算法:从数组中高效地移除重复的整数

93

这个问题来自于微软的面试。

给定一个随机整数数组, 请用 C 语言编写一个算法,去除重复的数字并返回原始数组中的唯一数字。

例如,输入:{4, 8, 4, 1, 1, 2, 9} 输出:{4, 8, 1, 2, 9, ?, ?}

其中一个注意事项是,期望的算法不应该要求先对数组进行排序。当一个元素被移除后,后续的元素必须向前移位。无论如何,被移位的末尾元素的值都是可以忽略的。

更新:结果必须返回到原始数组中,不能使用帮助数据结构(例如哈希表)。然而,我猜想保持元素顺序并不是必需的。

更新2:对于那些想知道为什么有这些不切实际的限制的人,这是一个面试题,所有这些限制都是在思考过程中讨论的,以了解我如何提出不同的想法。


4
你需要保持独特数字的顺序吗? - Douglas Leeder
1
结果必须返回到原始数组中吗? - Douglas Leeder
1
我已经更新了问题。结果应该以原始数组的形式返回。然而,序列的顺序并不重要。 - ejel
3
当有人在问题和其他答案上进行推销时,这是相当让人烦恼的。只要耐心等待,人们最终会有结果的。 - GManNickG
3
为什么不允许使用哈希表?这个限制毫无意义。 - RBarryYoung
显示剩余4条评论
34个回答

137

我的女友提出的解决方案是归并排序的一种变体。唯一的修改是在合并步骤中,忽略重复的值。这个解决方案同样是O(n log n)。在这种方法中,排序/去重复操作被结合在一起。然而,我不确定这是否有任何区别。


8
好的建议,但你需要做一些簿记来跟踪每次合并输出的末尾。我实际上曾经这样做过,确实在合并时消除重复项可以使它更快。 - Mark Ransom
2
不清楚O(N/2)额外空间是否算作问题中禁止的“辅助数据结构” - 我不知道限制是旨在规定O(1)额外空间,还是仅规定答案不应依赖于大型数据结构实现。也许标准合并是可以的。但如果不行,最好的建议是:除非你真的知道自己在做什么,否则不要试图在面试中编写原地归并排序。 - Steve Jessop
很好的想法。但是它需要保持剩余数据的原始顺序。 - Hardy Feng
4
以下是你女友建议的论文描述链接:http://dc-pubs.dbs.uni-leipzig.de/files/Bitton1983Duplicaterecordeliminationin.pdf - Mike B

49

我之前曾在SO上发布过这个算法,但我会在这里再次重复一遍,因为它非常酷。它使用哈希技术,在原地构建类似哈希集合的东西。它保证在辅助空间(递归是尾调用)方面的复杂度为O(1),通常时间复杂度为O(N)。该算法如下:

  1. 取出数组的第一个元素,这将作为哨兵。
  2. 尽可能重新排列剩余的数组,使每个元素处于其哈希对应的位置。随着完成这一步,将发现重复项。将它们设置为哨兵。
  3. 将索引等于哈希的所有元素移动到数组的开头。
  4. 将所有等于哨兵的元素(除了数组的第一个元素)移动到数组的末尾。
  5. 正确哈希的元素与重复的元素之间的剩余部分将是由于冲突而无法放置在其哈希对应的位置的元素。递归处理这些元素。

只要哈希没有病态情况,这可以被证明是O(N):即使没有重复项,大约2/3的元素都会在每次递归中被消除。每个递归层是O(n),其中小n是剩余的元素数量。唯一的问题在于,实际上,当有很少的重复项时,即存在大量冲突时,它比快速排序慢。但是,当存在大量重复项时,它非常快。

编辑:在当前的D实现中,hash_t是32位的。关于此算法的所有内容都假定在完整的32位空间中几乎没有或完全没有哈希冲突。在模数空间中可能经常发生碰撞。但是,这个假设对于任何大小合理的数据集来说,很可能是正确的。如果键小于或等于32位,则它可以是自己的哈希,这意味着在完整的32位空间中不可能出现冲突。如果它更大,你只需无法将足够多的键放入32位内存地址空间中,以免造成问题。我假设在D的64位实现中,hash_t将增加到64位,在那里数据集可以更大。此外,如果这曾经被证明是一个问题,可以在每个递归级别更改哈希函数。

以下是D编程语言中的一个实现:

void uniqueInPlace(T)(ref T[] dataIn) {
    uniqueInPlaceImpl(dataIn, 0);
}

void uniqueInPlaceImpl(T)(ref T[] dataIn, size_t start) {
    if(dataIn.length - start < 2)
        return;

    invariant T sentinel = dataIn[start];
    T[] data = dataIn[start + 1..$];

    static hash_t getHash(T elem) {
        static if(is(T == uint) || is(T == int)) {
            return cast(hash_t) elem;
        } else static if(__traits(compiles, elem.toHash)) {
            return elem.toHash;
        } else {
            static auto ti = typeid(typeof(elem));
            return ti.getHash(&elem);
        }
    }

    for(size_t index = 0; index < data.length;) {
        if(data[index] == sentinel) {
            index++;
            continue;
        }

        auto hash = getHash(data[index]) % data.length;
        if(index == hash) {
            index++;
            continue;
        }

        if(data[index] == data[hash]) {
            data[index] = sentinel;
            index++;
            continue;
        }

        if(data[hash] == sentinel) {
            swap(data[hash], data[index]);
            index++;
            continue;
        }

        auto hashHash = getHash(data[hash]) % data.length;
        if(hashHash != hash) {
            swap(data[index], data[hash]);
            if(hash < index)
                index++;
        } else {
            index++;
        }
    }


    size_t swapPos = 0;
    foreach(i; 0..data.length) {
        if(data[i] != sentinel && i == getHash(data[i]) % data.length) {
            swap(data[i], data[swapPos++]);
        }
    }

    size_t sentinelPos = data.length;
    for(size_t i = swapPos; i < sentinelPos;) {
        if(data[i] == sentinel) {
            swap(data[i], data[--sentinelPos]);
        } else {
            i++;
        }
    }

    dataIn = dataIn[0..sentinelPos + start + 1];
    uniqueInPlaceImpl(dataIn, start + swapPos + 1);
}

1
非常酷,被低估的答案!我喜欢使用位置1中的元素作为哨兵值的想法。如果我可以提出一些小建议,那就是将步骤2更改为包括“每个元素都位于其哈希模数组大小对应的位置”,并可能澄清要设置为哨兵的重复项是具有相同值的元素(而不是相同的哈希或相同的哈希模数组大小)。 - j_random_hacker

19
如下所示:

怎么样:

void rmdup(int *array, int length)
{
    int *current , *end = array + length - 1;

    for ( current = array + 1; array < end; array++, current = array + 1 )
    {
        while ( current <= end )
        {
            if ( *current == *array )
            {
                *current = *end--;
            }
            else
            {
                current++;
            }
        }
    }
}

应该是 O(n^2) 或更低的时间复杂度。


3
这是简单的解决方案,很可能是面试问题要寻找的答案。 - Kirk Broadhurst
8
他们甚至可能会检查你是否过度追求早期优化,除非他们也给了你运行时间限制! :-) - Trevor Tippins
16
尽管这样做需要更快地对数组进行排序并在排序后处理,但应该提供API来进行排序,这并不是过早优化。 - ziggystar
2
应该是 while ( current <= end ) 而不是 while ( current < end ) 吧? - Shail
2
为什么这被接受为正确答案?如果不需要保持顺序,那么使用归并排序O(nlogn)然后在O(n)中去除重复元素不是更好吗?总复杂度 - O(nlogn),比这个解决方案要好得多。 - Pawan
显示剩余8条评论

19

另一种更高效的实现方法

int i, j;

/* new length of modified array */
int NewLength = 1;

for(i=1; i< Length; i++){

   for(j=0; j< NewLength ; j++)
   {

      if(array[i] == array[j])
      break;
   }

   /* if none of the values in index[0..j] of array is not same as array[i],
      then copy the current value to corresponding new position in array */

  if (j==NewLength )
      array[NewLength++] = array[i];
}
在这个实现中,无需对数组进行排序。而且如果找到重复的元素,则无需将该元素后面的所有元素向右移一位。 该代码的输出是大小为NewLength的数组[]。
在这里,我们从数组的第二个元素开始,并将其与此数组中的所有元素进行比较。 我们保留一个额外的索引变量'NewLength'来修改输入数组。 NewLength变量初始化为0。
数组[1]中的元素将与数组[0]进行比较。 如果它们不同,则array[NewLength]中的值将被修改为array[1]并增加NewLength。 如果它们相同,则不会修改NewLength。
因此,如果我们有一个数组[1 2 1 3 1], 那么
在'j'循环的第一次通过中,array[1](2)将与array0进行比较,然后将2写入array [NewLength] = array[1],因此array将为[1 2],因为NewLength = 2
在'j'循环的第二次通过中,将比较数组[2](1)和数组0和数组1。由于数组[2](1)和数组0相同,因此循环将在此处中断。 因此,数组将为[1 2],因为NewLength = 2
等等

3
好的。我有一个改进建议。第二个嵌套循环可以改为 for(j=0; j < NewLength; j++),最后的 if 检查可以改为 if (j == NewLength)。 - Vadakkumpadath
那是一个很好的建议。我已经根据您的评论更新了代码。 - Byju
如果数组中有相同的值{1,1,1,1,1,1},那么代码将会失败。这段代码是无用的。 - Yuriy Chernyshov
这个的复杂度是多少呢,难道也是O(n^2)吗? - JavaSa
1
有这么多的upvotes,但这并不高效:当存在少量重复时,它是O(n^2)。 - Paul Hankin

19

如果您正在寻找更高级的O-符号,则使用O(nlogn)排序对数组进行排序,然后进行O(n)遍历可能是最佳选择。如果不进行排序,则需要O(n ^ 2)的时间复杂度。

编辑:如果只处理整数,则还可以使用基数排序以获得O(n)。


Jeff B的答案仅仅是O(n)。哈希集和哈希字典是最好的选择。 - ChrisW
4
ChrisW说,哈希集合/字典只有在没有冲突的情况下才是O(1)。他并不是说他不会在这个问题中使用它们 - 他可能会使用 - 只是声称它们真正的时间复杂度为O(1)是一种谬论。 - Laurence Gonsalves
2
实际上,由于您事先知道数组的大小,因此可以保证O(1)。然后,您可以权衡冲突与使用多少额外内存。 - Vitali
你可能需要重新考虑那个踩票了 - 新发布的问题条件使Jeff B的解决方案无效。 - Mark Ransom
3
您可能需要对“遍历”进行详细解释,因为naive erasure方法可能会导致大量重复的情况下O(n^2)的时间复杂度。 - Mark Ransom
Jeff的还好,你可以进行比较和原地运动。请看下面我(可悲地被忽略了)完全正确的解决方案。 - Andy Ross

11

1. 使用 O(n log n) 的时间复杂度,在 O(1) 的额外空间内进行

这是可能的,例如:

  • 首先进行原地 O(n log n) 排序
  • 然后遍历整个列表一次,将每个元素的第一个实例写回到列表开头

我认为 ejel 的伙伴正确指出了最好的方法是使用原地归并排序和简化的归并步骤,并且如果您例如正在编写一个新的库函数以尽可能高效地处理此类输入而没有能力改进输入,则可能是问题的意图,并且在某些情况下,不使用哈希表进行此操作将非常有用。但是我没有实际检查过。

2. 使用 O(lots) 的额外空间,在 O(n) 的时间复杂度内进行

  • 声明一个足够大以容纳所有整数的零数组
  • 遍历数组一次
  • 为每个整数设置相应的数组元素为1。
  • 如果它已经是1,则跳过该整数。

只有在满足几个可疑的假设的情况下才能起作用:

  • 可以廉价地清零内存,或者整数的大小与它们的数量相比较小
  • 您愿意为大小为256 ^ sizeof(int)的内存请求操作系统
  • 如果它很大,操作系统将以非常高效的方式进行缓存

这是一个糟糕的答案,但是如果您有许多输入元素,但它们都是8位整数(甚至可能是16位整数),那么它可能是最佳方法。

3. O(little)-ish 额外空间,O(n)-ish 时间

与#2相同,但使用哈希表。

4. 清晰的方法

如果元素数量很少,则编写适当的算法没有用,如果其他代码编写和阅读速度更快,则不需要。

例如,遍历每个唯一元素的数组(即第一个元素,第二个元素(删除第一个的副本)等),移除所有相同的元素。O(1)额外空间,O(n^2)时间。

例如,使用库函数来完成此操作。效率取决于您能够轻松获取哪些函数。


7

基本实现非常简单。遍历所有元素,检查剩余元素中是否有重复项,并将其余元素移动到它们上面。

这种方法效率非常低下,你可以通过输出辅助数组或排序/二叉树来加快速度,但似乎不允许使用这些方法。


1
另一方面,实现排序树所需的额外代码可能比简单解决方案更少(内存),对于小型数组(例如少于100个元素),运行时可能效率更低。 - TMN

6

这个函数的返回值应该是唯一元素的数量,并且它们都存储在数组的前面。如果没有这个额外的信息,你甚至不知道是否有任何重复项。

外部循环的每次迭代处理数组中的一个元素。如果它是唯一的,它保留在数组的前面;如果是重复的,它将被数组中未处理的最后一个元素覆盖。这种解决方案的运行时间为O(n^2)。

#include <stdio.h>
#include <stdlib.h>

size_t rmdup(int *arr, size_t len)
{
  size_t prev = 0;
  size_t curr = 1;
  size_t last = len - 1;
  while (curr <= last) {
    for (prev = 0; prev < curr && arr[curr] != arr[prev]; ++prev);
    if (prev == curr) {
      ++curr;
    } else {
      arr[curr] = arr[last];
      --last;
    }
  }
  return curr;
}

void print_array(int *arr, size_t len)
{
  printf("{");
  size_t curr = 0;
  for (curr = 0; curr < len; ++curr) {
    if (curr > 0) printf(", ");
    printf("%d", arr[curr]);
  }
  printf("}");
}

int main()
{
  int arr[] = {4, 8, 4, 1, 1, 2, 9};
  printf("Before: ");
  size_t len = sizeof (arr) / sizeof (arr[0]);
  print_array(arr, len);
  len = rmdup(arr, len);
  printf("\nAfter: ");
  print_array(arr, len);
  printf("\n");
  return 0;
}

6

如果您愿意牺牲内存,可以在单次遍历中完成此操作。您可以在哈希/关联数组中简单地计算您是否已经看到了一个整数。如果您已经看到了一个数字,请在进行操作时将其删除,或者更好的方法是将您没有看到的数字移动到一个新数组中,避免对原始数组进行任何移动。

在 Perl 中:

foreach $i (@myary) {
    if(!defined $seen{$i}) {
        $seen{$i} = 1;
        push @newary, $i;
    }
}

不清楚答案是否必须在原始数组中。 - Douglas Leeder
为了不需要新的数组,你可以用一个从数组末尾弹出的元素来替换重复的元素,并重新执行当前循环,因为问题没有指定顺序的要求。这需要一些额外的边界检查,但是非常可行。 - Jeff B
6
这个主意本来不错,但是问题被编辑后就不行了。你的哈希表想法显然违反了规定。 - Isabelle Wedin
14
我不明白为什么这个回答被投票评为最佳。它是用 Perl 写的,并使用了 C 中没有的重要功能,而问题正是要求使用 C。 - LiraNuna
5
问题要求用C语言编写,而不是Perl。使用Perl可以免费获得哈希表和"push"。如果我能用Scala做到,你只需要调用input.removeDuplicates,但我怀疑面试官不会接受这种答案 :) - Peter Recore
“seen”功能本质上是“n”的函数,因此这个算法是二次的,尽管看起来可能是线性的。 - Eastern Monk

6
如果您可以使用C ++,则调用std :: sort 后跟调用std :: unique 将为您提供答案。排序的时间复杂度为O(N log N),唯一遍历的时间复杂度为O(N)。如果无法使用C ++,则这些算法也可以在C中编写。

一个需要注意的地方是,期望的算法不应该要求先对数组进行排序。 - sbi
2
它并没有说你不能在获取数组后对其进行排序... 如果不使用O(N)的外部内存,排序是实现O(N log N)或更好时间复杂度的唯一方法。 - Greg Rogers
为了解决这个问题,不应使用标准库工具。关于排序,然而,我越想越不确定是否可以使用。 - ejel
1
我认为涉及C++和C++标准函数的答案很有用,即使它们没有回答原始问题,因为它们为那些以后发现这个问题的人提供了一个更全面的答案。 - Douglas Leeder

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接