哪种算法需要一套?


10

在我的第一门编程课程中,我被告知我每当需要执行某些操作(例如删除某些重复项)时都应该使用一组。例如:要从向量中删除所有重复项,请遍历所述向量并将每个元素添加到集合中,然后剩下唯一的情况。但是,我也可以通过将每个elemento添加到另一个向量并检查该元素是否已经存在来做到这一点。我假设根据所使用的语言,性能可能有所不同。但是,除了其他原因,还有其他理由使用吗?

基本上:哪种算法需要一个集合,而其他任何容器类型都不应该完成?


2
您能否更确切地说明使用“集合”一词的意思?您是指C ++集吗?
罗伯特·哈维

是的,实际上,“设置”定义在大多数语言中似乎非常相似:一个仅接受唯一元素的容器。
Floella

6
“将每个元素添加到另一个向量中,并检查该元素是否已经存在” –这只是自己实现一个集合。因此,您问为什么可以手动编写自己的内置功能?
JacquesB '17

Answers:


8

您是在专门询问集合,但我认为您的问题是关于一个更大的概念:抽象。您可以使用Vector来做到这一点是完全正确的(如果使用Java,请改用ArrayList。)但是为什么要停在那里?您需要Vector做什么?您可以使用数组完成所有操作。

每当需要向数组中添加项目时,都可以简单地遍历每个元素,如果不存在,则在末尾添加它。但是,实际上,您首先需要检查阵列中是否有空间。如果没有,则需要创建一个更大的新数组并将所有现有元素从旧数组复制到新数组,然后可以添加新元素。当然,您还需要更新对旧数组的每个引用以指向新数组。完成所有这些了吗?大!现在,我们又要努力完成什么?

或者,您可以使用Set实例,然后调用add()。存在集合的原因是,它们是对许多常见问题有用的抽象。例如,假设您要跟踪项目并在添加新项目时做出反应。您调用add()一个集,它返回truefalse基于该集是否被修改。您可以使用原语手工编写所有内容,但是为什么呢?

实际上,在某些情况下您可能具有列表,并且想要删除重复项。基本上,您提出的算法是最慢的算法。有两种常见的快速方法:对它们进行分类或排序。或者,您可以将它们添加到实现这些算法之一的集合中。

在您的职业/教育初期,重点是构建和理解这些算法,做到这一点很重要。但这不是专业开发人员通常所做的。他们使用这些方法来构建更多有趣的东西,并使用预先构建的可靠实现节省大量时间。


23

我假设根据所使用的语言,性能可能有所不同。但是,除了其他原因,还有其他理由使用吗?

哦,是的,(但这不是性能。)

可以使用集合时使用集合,因为不使用它意味着您必须编写额外的代码。使用一组可以使您的工作变得容易阅读。所有关于唯一性逻辑的测试都隐藏在其他无需考虑的地方。它已经在经过测试的地方,您可以相信它可以正常工作。

编写自己的代码可以做到这一点,您不必担心。eh 谁想这样做?

基本上:哪种算法需要一个集合,而其他任何容器类型都不应该完成?

没有算法“不应使用任何其他容器类型来完成”。有一些简单的算法可以利用集合。当您不必编写额外的代码时,这很好。

现在在这方面没有什么特别的设置。您应该始终使用最适合您需求的收藏。在Java中,我发现这张照片有助于做出这一决定。您会注意到它具有三种不同的集合。

在此处输入图片说明

正如@germi正确指出的那样,如果您为工作使用正确的集合,则代码将变得易于他人阅读。


6
您已经提到了它,但是使用集合也使其他人更容易推理代码。他们不必查看它的填充方式就知道它仅包含唯一项。
杰米

14

但是,我也可以通过将每个elemento添加到另一个向量并检查该元素是否已经存在来做到这一点。

如果这样做,那么您将在向量数据结构之上实现集合的语义。您正在编写额外的代码(其中可能包含错误),如果您有很多条目,结果将非常慢。

您为什么要通过使用现有的,经过测试的有效集合实现来做到这一点?


6

代表真实世界实体的软件实体通常是逻辑集合。例如,考虑一辆汽车。汽车具有唯一的标识符,并且汽车组形成一个集合。集合概念是对程序可能知道的汽车集合的约束,并且约束数据值非常有价值。

而且,集合具有定义明确的代数。如果您有一套由George拥有的汽车和一套由Alice拥有的汽车,那么即使George和Alice都拥有同一辆汽车,工会显然也是George和Alice都拥有的汽车。因此,应该使用集合的算法是所涉及实体的逻辑表现出集合特征的算法。事实证明这很普遍。

如何实现集合以及如何确保唯一性约束是另一回事。一个人希望能够为集合逻辑找到一个合适的实现,以消除重复,因为集合对于逻辑是如此重要,但是即使您自己执行实现,唯一性保证对于在集合中插入项目也是固有的。您不必必须“检查元素是否已经存在”。


对于重复数据删除,“检查是否已经存在”通常是必不可少的。通常,对象是根据数据创建的。而且,您只希望一个对象具有相同的数据,任何人都可以使用相同的数据创建一个对象,以重复使用该对象。因此,您创建了一个新对象,检查它是否在集合中,如果在集合中,则从集合中取出该对象,否则插入您的对象。如果只是插入对象,那么仍然会有很多相同的对象。
gnasher729

1
@ gnasher729 Set 的实现者的职责包括检查是否存在,但是Set 的用户可以for 1..100: set.insert(10)并且仍然知道集合中只有一个10
Caleth

用户可以在十组相等的对象中创建一百个不同的对象。插入后,集合中有10个对象,但仍有100个对象在周围漂浮。重复数据删除意味着该组中有十个对象,每个人都使用这十个对象。显然,您不仅需要测试-还需要一个函数,该函数给定一个对象,并返回集合中的匹配对象。
gnasher729

4

除了性能特征(这是非常重要的,并且不应该轻易消除)之外,集合作为抽象集合非常重要。

您可以使用数组模拟Set行为(忽略性能)吗?是的,一点没错!每次插入时,您可以检查元素是否已在数组中,然后仅在未找到元素时添加它。但这是您有意识地必须意识到的事情,并记住每次插入Array-Psuedo-Set时的情况。哦,那是什么,您直接插入一次,而没有先检查重复项?抱歉,您的数组已打破其不变性(所有元素都是唯一的,并且等效地,不存在重复项)。

那么您将如何解决呢?您将创建一个新的数据类型,将其称为(例如PsuedoSet),该数据类型将包装内部Array,并公开公开insert操作,这将强制元素的唯一性。由于只有通过此公共insertAPI 才能访问包装好的数组,因此您可以保证不会出现重复的数组。现在添加一些哈希以提高contains检查的性能,您迟早会意识到自己实现了全面解决方案Set

我也将发表声明并跟进问题:

在我的第一门编程课程中,我被告知我每当需要执行诸如存储某项的多个有序元素之类的事情时都应使用数组。例如:存储同事姓名的集合。但是,我也可以通过分配原始内存并设置起始指针给定的内存地址的值+一些偏移量来实现。

您可以使用原始指针和固定偏移量来模拟数组吗?是的,一点没错!每次插入时,都可以检查偏移量是否没有偏离正在使用的分配内存的末尾。但这是您有意识地必须意识到的事情,并且每次插入伪数组时都要记住。哦,那是什么,您直接插入一次而没有先检查偏移量?抱歉,您的姓名出现了细分错误!

那么您将如何解决呢?您将创建一个新的数据类型,将其称为(例如PsuedoArray),它包装一个指针和一个大小,并公开地公开一个insert操作,这将强制偏移量不超过该大小。由于只能通过此公共insertAPI 访问包装的数据,因此您保证不会发生缓冲区溢出。现在添加其他一些便捷功能(数组大小调整,元素删除等),您迟早会意识到自己实现了全面功能Array


3

有各种基于集合的算法,尤其是在您需要执行集合的交集和并集并使结果成为集合的情况下。

基于集合的算法在各种路径查找算法等中大量使用。

有关集合论的入门知识,请查看以下链接:http : //people.umass.edu/partee/NZ_2006/Set%20Theory%20Basics.pdf

如果需要集合语义,请使用集合。这样可以避免由于虚假重复而导致的错误,因为您在某个阶段忘记修剪矢量/列表,并且比不断修剪矢量/列表要快得多。


1

我实际上发现标准集容器对我自己几乎没有用,并且喜欢只使用数组,但是我以不同的方式来做。

要计算集合相交,我遍历第一个数组并用单个位标记元素。然后,我遍历第二个数组并查找标记的元素。瞧,在线性时间中设置交集比散列表要少得多的工作和内存,例如,使用这种方法,联合和差同样容易应用。它确实有助于我的代码库围绕索引元素而不是复制它们(我将索引复制到元素,而不是元素本身的数据),并且几乎不需要排序任何东西,但是多年来我没有使用任何设置的数据结构结果。

即使元素没有提供用于此类目的的数据字段,我也使用一些邪恶的摆弄C代码。它涉及通过设置最高有效位(我从未使用过)来使用元素本身的内存,以标记经过的元素。那是很粗略的,除非您确实在接近装配水平上工作,否则不要这样做,而只是想提一下,即使在元素不能提供特定于遍历的字段的情况下,如果可以保证的话,该方法如何适用。某些位将永远不会被使用。在我的dinky i7上,它可以在不到一秒钟的时间内计算出2亿个元素(约2.4个千兆字节的数据)之间的设定交集。尝试在两个同时std::set包含一亿个元素的实例之间进行设置交集;甚至没有接近。

那边...

但是,我也可以通过将每个elemento添加到另一个向量并检查该元素是否已经存在来做到这一点。

检查新矢量中是否已经存在元素的检查通常将是线性时间操作,这将使交集本身成为二次操作(爆炸性工作量越大,输入大小越大)。如果您只想使用简单的旧向量或数组并以可扩展的方式进行操作,那么我建议您使用上述技术。

基本上:哪种算法需要一个集合,而其他任何容器类型都不应该完成?

如果您问我的偏见是否在容器级别谈论(例如,在专门为有效提供设置操作而实现的数据结构中),则没有任何问题,但是有很多要求在概念级别使用设置逻辑。例如,假设您想在游戏世界中找到既能飞行又能游泳的生物,并且您有一组飞行的生物(无论您是否实际使用了一组容器),又有一组可以游泳的生物。 。在这种情况下,您需要设置交点。如果您想要可以飞行或具有魔法的生物,​​则可以使用固定联合。当然,您实际上不需要设置容器来实现此目的,并且最佳优化实现通常不需要或不希望将容器专门设计为集合。

切线

好的,我从JimmyJames那里得到了一些关于此交集方法的很好的问题。这有点偏离主题,但是,我希望看到更多的人使用这种基本的侵入式方法来设置交集,以便他们不仅仅为了设置操作的目的而构建诸如平衡二叉树和哈希表之类的整个辅助结构。如前所述,基本要求是列出浅表复制元素,以便它们索引或指向共享元素,该共享元素可以通过第一个未排序的列表或数组或随后在第二个未排序的列表中传递的内容进行“标记”通过第二个列表。

但是,即使在多线程上下文中,只要不满足以下条件,就可以实际完成此操作:

  1. 这两个集合包含元素的索引。
  2. 索引的范围不会太大(例如[0,2 ^ 26],不是十亿或更多),并且被合理地密集占用。

这使我们可以使用并行数组(每个元素仅一位)来进行设置操作。图解:

在此处输入图片说明

仅当从池中获取并行位数组并将其释放回池时才需要线程同步(超出范围时隐式完成)。执行设置操作的实际两个循环不需要任何线程同步。如果线程只可以在本地分配和释放位,我们甚至不需要使用并行位池,但是位池可以方便地在适合此类数据表示的代码库中概括模式,在这种情况下,经常引用中央元素按索引,这样每个线程都不必担心高效的内存管理。我所在领域的主要示例是实体组件系统和索引网格表示。两者都经常需要设置交集,并且倾向于引用集中存储的所有内容(ECS中的组件和实体以及顶点,边,

如果索引没有被密集地占用和稀疏地散布,那么这仍然适用于并行位/布尔数组的合理稀疏实现,例如仅以512位块(每个展开的节点64个字节代表512个连续索引)存储内存的情况。 ),并跳过分配完全空闲的连续块。如果您的中央数据结构很少被元素本身占用,那么您很有可能已经在使用这样的东西。

在此处输入图片说明

...稀疏位集用作并行位数组的类似想法。这些结构也很容易实现不变性,因为浅层复制块状块很容易,不需要进行深层复制即可创建新的不可变拷贝。

使用这种方法,在一台非常普通的机器上,可以在一秒钟之内完成数亿个元素之间的相交设置,而这仅在一个线程内。

如果客户端不需要用于结果交集的元素列表,也可以在不到一半的时间内完成,例如如果客户只想对两个列表中的元素应用某种逻辑,此时他们可以通过函数指针,函子或委托或将被调用以返回相交元素范围的任何内容。达到此效果的方法:

// 'func' receives a range of indices to
// process.
set_intersection(func):
{
    parallel_bits = bit_pool.acquire()

    // Mark the indices found in the first list.
    for each index in list1:
        parallel_bits[index] = 1

    // Look for the first element in the second list 
    // that intersects.
    first = -1
    for each index in list2:
    {
         if parallel_bits[index] == 1:
         {
              first = index
              break
         }
    }

    // Look for elements that don't intersect in the second
    // list to call func for each range of elements that do
    // intersect.
    for each index in list2 starting from first:
    {
        if parallel_bits[index] != 1:
        {
             func(first, index)
             first = index
        }
    }
    If first != list2.num-1:
        func(first, list2.num)
}

...或达到这种效果的东西。第一个图中伪代码最昂贵的部分是intersection.append(index)在第二个循环中,即使std::vector事先保留了较小列表的大小,这也适用。

如果我复制所有内容该怎么办?

好吧,别说了!如果需要设置相交,则意味着您正在复制数据以进行相交。甚至您最细小的对象都有可能不小于32位索引。除非您实际上需要实例化超过43亿个元素,否则很有可能将元素的寻址范围减小到2 ^ 32(2 ^ 32个元素,而不是2 ^ 32字节),此时需要一个完全不同的解决方案(而这绝对不是在内存中使用set容器)。

关键比赛

在需要元素不相同但可能具有匹配键的设置操作的情况下呢?在这种情况下,与上述相同。我们只需要将每个唯一键映射到索引。例如,如果键是字符串,则实习字符串可以做到这一点。在这些情况下,需要一个很好的数据结构(例如trie或哈希表)将字符串键映射到32位索引,但是我们不需要这种结构即可对所得的32位索引进行设置操作。

当我们可以在非常合理的范围内(而不是在机器的全部寻址范围内)对元素的索引进行操作时,就会出现许多非常便宜且直接的算法解决方案和数据结构,就像这样,这通常是值得的能够为每个唯一键获取唯一索引。

我喜欢指数!

我喜欢指数,就像比萨饼和啤酒一样。在我20多岁的时候,我真正地开始使用C ++,并开始设计各种完全符合标准的数据结构(包括在编译时消除范围ctor歧义的技巧)。回想起来,这是浪费大量时间。

如果围绕数据库将元素集中存储在数组中并对其进行索引,而不是将它们存储在整个机器可寻址范围内的零散的方式中,那么您最终可能会探索算法和数据结构的可能性,设计围绕旧的int或旧的容器和算法int32_t。而且我发现最终结果更加高效并且易于维护,因为我不必不断地将元素从一种数据结构转移到另一种数据结构再转移到另一种数据结构。

一些示例用例,当您可以假设的任何唯一值都T具有唯一索引,并且实例驻留在中央数组中时:

多线程基数排序可以很好地与索引的无符号整数配合使用。我实际上有一个多线程基数排序,大约需要1/10的时间来排序1亿个元素,这是Intel自己的并行排序,而Intel的速度已经比std::sort这么大的输入快4倍。当然,英特尔的灵活性更高,因为它是一种基于比较的排序,并且可以按字典顺序对事物进行排序,因此它可以将苹果与桔子进行比较。但是在这里,我通常只需要橘子,就像我可能要进行基数排序一样,以实现对缓存友好的内存访问模式或快速过滤出重复项。

无需链接节点的堆分配即可构建链接结构(如链接列表,树,图形,单独的链式哈希表等)的能力。我们可以批量分配与元素平行的节点,然后将它们与索引链接在一起。节点本身只是成为下一个节点的32位索引,并存储在一个大数组中,如下所示:

在此处输入图片说明

便于并行处理。通常,链接结构对并行处理不太友好,因为至少要在树或链接列表遍历中实现并行性相对于仅通过数组进行并行for循环是很尴尬的。使用索引/中心数组表示,我们始终可以转到该中心数组,并在大块并行循环中处理所有内容。即使我们只想处理某些元素,我们也始终拥有一个可以通过这种方式处理的所有元素的中央数组(此时,您可以处理由基数排序的列表索引的元素,以便通过中央数组进行缓存友好的访问)。

可以将数据实时关联到每个元素。与上面的并行位数组的情况一样,我们可以轻松便宜地将并行数据与元素关联起来,例如进行临时处理。除了临时数据外,还有一些用例。例如,网格系统可能希望允许用户根据需要将尽可能多的UV贴图附加到网格上。在这种情况下,我们不能仅仅使用AoS方法硬编码每个顶点和面中将有多少UV贴图。我们需要能够即时关联此类数据,并且并行数组在那里很方便,并且比任何种类的复杂关联容器(甚至是哈希表)便宜得多。

当然,并行阵列由于易于出错的性质而使并行阵列彼此保持同步,因此人们对此并不满意。例如,每当我们从“根”数组中删除索引7中的元素时,我们同样都必须对“子项”执行相同的操作。但是,在大多数语言中,将这个概念推广到通用容器是很容易的,因此,使并行数组彼此保持同步的棘手逻辑只需要存在于整个代码库的一个位置,这样的并行数组容器就可以请使用上面的稀疏数组实现来避免浪费大量内存,以便在后续插入时回收数组中的连续空闲空间。

详细说明:稀疏位集树

好吧,我收到了详细说明一些我认为很讽刺的请求,但无论如何我还是会这样做的,因为它是如此的有趣!如果人们想将这个想法带到一个全新的高度,那么就可以执行设置的相交而无需线性地循环遍历N + M个元素。这是我使用了很长时间的基本数据结构,并且基本上是模型set<int>

在此处输入图片说明

它甚至不检查两个列表中的每个元素就可以执行集合交集的原因是因为层次结构根部的单个集合位可以指示,例如,一百万个连续元素被占用了集合。仅检查一位,就可以知道该范围内的N个索引在[first,first+N)集合中,其中N可以是一个非常大的数字。

在遍历占用的索引时,我实际上将其用作循环优化器,因为假设集合中有800万个索引。好吧,在这种情况下,通常我们必须访问800万个整数。有了它,它可以潜在地只检查几位并提出占用索引的索引范围进行循环。此外,它带来的索引范围是按排序顺序进行的,这使得缓存非常友好,可以进行顺序访问,而不是例如遍历用于访问原始元素数据的未排序索引数组。当然,这种技术在极端稀疏的情况下会变得更糟,最坏的情况是每个索引都是偶数(或者每个索引都是奇数),在这种情况下,没有连续的区域。但是至少在我的用例中,


2
“要计算集合相交,我遍历第一个数组并用一个位标记元素。然后遍历第二个数组并寻找标记的元素。” 您将它们标记在第二个阵列上的什么位置?
JimmyJames

1
哦,我明白了,您正在将数据“实习”为一个代表每个值的对象。对于一组用例的子集来说,这是一种有趣的技术。我认为没有理由不将这种方法实现为对您自己的集合类的操作。
JimmyJames

2
“这是一种侵入性解决方案,在某些情况下会违反封装……”一旦我弄清了您的意思,这种想法就发生了,但我认为并不需要。如果您有一个管理此行为的类,则索引对象可以独立于所有元素数据,并且可以在您的集合类型的所有实例之间共享。也就是说,将有一个主数据集,然后每个实例都将指向该主数据集。多线程将需要更多的复杂性,但我认为这将是可管理的。
JimmyJames

1
看来这在数据库解决方案中可能很有用,但我不知道是否有任何以这种方式实现的方法。感谢您在这里提出来。你让我下定决心。
JimmyJames

1
您能再详细一点吗?;)当我有一些(很多)时间时,我会检查一下。
JimmyJames

-1

要检查包含n个元素的集合是否包含另一个元素X,通常需要花费恒定的时间。要检查包含n个元素的数组是否包含另一个元素X,通常需要O(n)时间。不好,但是如果您想从n个项目中删除重复项,突然之间,它会花费O(n)的时间而不是O(n ^ 2);100,000件物品将使您的计算机瘫痪。

而您要问更多原因吗?“除了枪击事件,林肯太太,你还喜欢晚上吗?”


2
我想您可能想再读一遍。通常认为用O(n)时间代替 O(n²)时间是一件好事。
JimmyJames

也许您在阅读本文时站在头上?OP问“为什么不只取一个数组”。
gnasher729

2
为什么从O(n²)变为O(n)会使“计算机瘫痪”?我一定在课堂上错过了。
JimmyJames
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.