在O(n)时间和O(1)空间中查找重复项


121

输入:给定n个元素组成的数组,其中包含从0到n-1的元素,这些数字中的任何一个都出现多次。

目标:在O(n)中查找这些重复数字,并且仅使用恒定的存储空间。

例如,假设n为7,数组为{1、2、3、1、3、0、6},答案应该为1和3。我在这里检查了类似的问题,但答案使用了诸如HashSetetc之类的一些数据结构。

有没有相同的有效算法?

Answers:


164

这是我想出的,不需要额外的符号位:

for i := 0 to n - 1
    while A[A[i]] != A[i] 
        swap(A[i], A[A[i]])
    end while
end for

for i := 0 to n - 1
    if A[i] != i then 
        print A[i]
    end if
end for

第一个循环对数组进行置换,因此,如果element x至少存在一次,则这些条目之一将位于position A[x]

请注意,乍一看它看起来可能不是O(n),但它是-尽管它具有嵌套循环,但仍会O(N)及时运行。仅当有一个会发生交换i,使得A[i] != i,每个交换台的至少一种元素,使得A[i] == i,在那里,这不是真之前。这意味着交换的总数(以及while循环体的执行总数)最多为N-1

第二环路打印的值x对其A[x]不等于x-由于第一循环保证,如果x存在至少一次阵列中,其中的一个实例将在A[x],这意味着它打印的那些值x中不存在在数组。

(Ideone链接,因此您可以使用它)


10
@arasmussen:是的。不过,我首先想出了一个损坏的版本。问题的矛盾点为解决方案提供了一些线索-每个有效数组值也是一个有效数组索引提示这一事实a[a[i]],而O(1)空间约束则以该swap()操作为关键提示这一事实。
caf

2
@caf:请使用数组{3,4,5,3,4}运行您的代码,否则它将失败。
NirmalGeo 2011年

6
@NirmalGeo:这不是有效的输入,因为5它不在范围内0..N-1N本例中为5)。
caf

2
@caf {1,2,3,1,3,0,0,0,0,6}的输出为3 1 0 0 0或在任何情况下重复次数大于2的情况。o / p是否正确?
码头

3
这真太了不起了!我在这个问题上看到了许多变体,通常会受到更多的限制,这是我所见过的最通用的解决方法。我会简单地提到,改变print语句来print i轮流到这个解决stackoverflow.com/questions/5249985/...和(假设“袋”是一个可修改的阵列)的Qk的stackoverflow.com/questions/3492302/...
j_random_hacker 2011年

35

caf出色的答案是将出现在k-1次数组中k次的每个数字打印出来。这是有用的行为,但是这个问题可以说每个副本只能打印一次,而且他暗示了这样做的可能性而不会超出线性时间/恒定空间界限。这可以通过用以下伪代码替换他的第二个循环来完成:

for (i = 0; i < N; ++i) {
    if (A[i] != i && A[A[i]] == A[i]) {
        print A[i];
        A[A[i]] = i;
    }
}

这利用了第一个循环运行之后的属性,如果任何值m出现多次,则保证其中一个出现在正确的位置,即A[m]。如果我们小心的话,我们可以使用该“主页”位置来存储有关是否已打印任何副本的信息。

在caf的版本中,当我们遍历数组时,A[i] != i暗示这A[i]是重复的。在我的版本中,我依赖于稍有不同的不变式:这A[i] != i && A[A[i]] == A[i]意味着这A[i]我们之前从未见过的重复项。(如果删除“我们之前从未见过的”部分,则可以看出其余部分是由caf不变式的事实隐含的,并保证所有重复项在原位都具有某些副本。)首先(在caf的第一个循环完成之后),我在下面显示,它在每个步骤之后都得到维护。

当我们遍历数组时,A[i] != i测试的成功意味着A[i] 可能是以前从未见过的重复。如果我们以前从未看过它,那么我们希望A[i]的原位指向自己-这是在if条件的后半部分进行测试的结果。如果是这种情况,我们将其打印出来并更改家庭位置,使其指向该首次发现的重复项,从而创建一个两步的“循环”。

要查看此操作不会改变我们的不变,假设m = A[i]一个特定的位置i满足A[i] != i && A[A[i]] == A[i]。显然,我们所做的(A[A[i]] = i)更改将m通过使if条件的后一半出现故障来防止其他非住宅出现作为重复输出而起作用,但是i到达住宅位置是否起作用m?是的,因为现在,即使现在i我们发现if条件的第一个一半A[i] != i为真,第二个一半也会测试条件所指向的位置是否为家中位置,而发现不是。在这种情况下,我们已经不知道是否mA[m]为重复的值,但我们知道,无论哪种方式,由于已经保证了这2个循环不会出现在caf的第一个循环的结果中,因此已经有报道。(请注意,如果m != A[m]恰好是m和之一A[m]发生多次,而另一个根本不发生。)


1
是的,这与我想出的非常相似。有趣的是,相同的第一个循环对于几个不同的问题(仅具有不同的打印循环)如何有用。
caf

22

这是伪代码

for i <- 0 to n-1:
   if (A[abs(A[i])]) >= 0 :
       (A[abs(A[i])]) = -(A[abs(A[i])])
   else
      print i
end for

C ++中的示例代码


3
非常聪明-在索引条目的符号位中编码答案!
holtavolt

3
@sashang:不可能。查看问题说明。“给出n个元素的数组,其中包含从0到n-1的元素
Prasoon Saurav 2011年

5
这不会检测到重复的0,并且会发现与重复多次相同的数字。
Null

1
@null设置:只需更换-~的零问题。
2011年

26
这可能是问题的根源所在,但从技术上讲,它使用了O(n)隐藏空间- n符号位。如果将数组定义为每个元素只能包含0和之间的值n-1,则显然不起作用。
caf

2

对于相对较小的N,我们可以使用div / mod操作

n.times do |i|
  e = a[i]%n
  a[e] += n
end

n.times do |i| 
  count = a[i]/n
  puts i if count > 1
end

不是C / C ++,但无论如何

http://ideone.com/GRZPI


+1不错的解决方案。两次停止向条目添加n会容纳更大的n
2011年

1

并不是很漂亮,但至少很容易看到O(N)和O(1)属性。基本上,我们扫描数组,对于每个数字,我们查看对应的位置是否已标记为已见过一次(N)或已见过多次(N + 1)。如果将其标记为已看到一次,我们将其打印并标记为已多次看到。如果未标记,则将其标记为已经可见一次,然后将相应索引的原始值移动到当前位置(标记是一种破坏性操作)。

for (i=0; i<a.length; i++) {
  value = a[i];
  if (value >= N)
    continue;
  if (a[value] == N)  {
    a[value] = N+1; 
    print value;
  } else if (a[value] < N) {
    if (value > i)
      a[i--] = a[value];
    a[value] = N;
  }
}

或者更好(更快,尽管有双循环):

for (i=0; i<a.length; i++) {
  value = a[i];
  while (value < N) {
    if (a[value] == N)  {
      a[value] = N+1; 
      print value;
      value = N;
    } else if (a[value] < N) {
      newvalue = value > i ? a[value] : N;
      a[value] = N;
      value = newvalue;
    }
  }
}

+1可以很好地工作,但是花了一些时间才想出确切的工作原因if (value > i) a[i--] = a[value];:如果value <= i那么我们已经处理过的值,a[value]就可以安全地覆盖它。我也不会说O(N)性质很明显!清楚说明:主循环运行N时间,加上该a[i--] = a[value];行运行的次数。该行仅在时才运行a[value] < N,并且每次运行时,紧随其后的是一个尚未N设置为的数组值N,因此它最多可以运行,最多N执行一次2N循环迭代。
j_random_hacker 2012年

1

C语言的一种解决方案是:

#include <stdio.h>

int finddup(int *arr,int len)
{
    int i;
    printf("Duplicate Elements ::");
    for(i = 0; i < len; i++)
    {
        if(arr[abs(arr[i])] > 0)
          arr[abs(arr[i])] = -arr[abs(arr[i])];
        else if(arr[abs(arr[i])] == 0)
        {
             arr[abs(arr[i])] = - len ;
        }
        else
          printf("%d ", abs(arr[i]));
    }

}
int main()
{   
    int arr1[]={0,1,1,2,2,0,2,0,0,5};
    finddup(arr1,sizeof(arr1)/sizeof(arr1[0]));
    return 0;
}

它是O(n)时间和O(1)空间复杂度。


1
它的空间复杂度为O(N),因为它使用了N个其他符号位。该算法应在数组元素类型只能容纳0到N-1的数字的假设下工作。
caf 2012年

是的,这是真的,但对于被问到的算法来说,它是完美的,因为他们只希望数字0到n-1的算法,而且我检查了您的解决方案其O(n)以上,所以我想到了
Anshul garg

1

假设我们将此数组表示为单向图形数据结构-每个数字都是一个顶点,并且数组中的索引指向形成图形边缘的另一个顶点。

为了更简单起见,我们将索引0设置为n-1,并将数字范围设置为0..n-1。例如

   0  1  2  3  4 
 a[3, 2, 4, 3, 1]

0(3)-> 3(3)是一个循环。

答:仅依靠索引遍历数组。如果a [x] = a [y],则为一个循环,因此重复。跳到下一个索引,然后再次继续下去,直到数组结尾。复杂度:O(n)时间和O(1)空间。


0

一个微小的python代码来演示上述caf的方法:

a = [3, 1, 1, 0, 4, 4, 6] 
n = len(a)
for i in range(0,n):
    if a[ a[i] ] != a[i]: a[a[i]], a[i] = a[i], a[a[i]]
for i in range(0,n):
    if a[i] != i: print( a[i] )

请注意,对于单个i值,交换可能必须进行多次-请注意while我的回答中的。
caf

0

在下面的C函数中可以很容易地看到算法。取原始数组,尽管不是必需的,但可以将每个条目取n为模。

void print_repeats(unsigned a[], unsigned n)
{
    unsigned i, _2n = 2*n;
    for(i = 0; i < n; ++i) if(a[a[i] % n] < _2n) a[a[i] % n] += n;
    for(i = 0; i < n; ++i) if(a[i] >= _2n) printf("%u ", i);
    putchar('\n');
}

Ideone链接进行测试。


恐怕这在技术上是“作弊”,因为处理高达2 * n的数字需要比存储原始数字所需的每个数组项额外的1位存储空间。实际上,每个条目需要接近log2(3)= 1.58个额外位,因为您要存储的数字最大为3 * n-1。
j_random_hacker 2012年

0
static void findrepeat()
{
    int[] arr = new int[7] {0,2,1,0,0,4,4};

    for (int i = 0; i < arr.Length; i++)
    {
        if (i != arr[i])
        {
            if (arr[i] == arr[arr[i]])
            {
                Console.WriteLine(arr[i] + "!!!");
            }

            int t = arr[i];
            arr[i] = arr[arr[i]];
            arr[t] = t;
        }
    }

    for (int j = 0; j < arr.Length; j++)
    {
        Console.Write(arr[j] + " ");
    }
    Console.WriteLine();

    for (int j = 0; j < arr.Length; j++)
    {
        if (j == arr[j])
        {
            arr[j] = 1;
        }
        else
        {
            arr[arr[j]]++;
            arr[j] = 0;
        }
    }

    for (int j = 0; j < arr.Length; j++)
    {
        Console.Write(arr[j] + " ");
    }
    Console.WriteLine();
}

0

我迅速创建了一个示例游乐场应用程序,用于查找时间复杂度为0(n)和恒定额外空间的重复项。请检查网址查找重复项

当数组包含从0到n-1的元素时,这些数字中的任何一个出现多次,IMP上述解决方案就可以工作。


0
private static void printRepeating(int arr[], int size) {
        int i = 0;
        int j = 1;
        while (i < (size - 1)) {
            if (arr[i] == arr[j]) {
                System.out.println(arr[i] + " repeated at index " + j);
                j = size;
            }
            j++;
            if (j >= (size - 1)) {
                i++;
                j = i + 1;
            }
        }

    }

上述解决方案将在O(n)和恒定空间的时间复杂度上实现相同。
user12704811

3
感谢您提供此代码段,它可能会提供一些有限的短期帮助。通过说明为什么这是解决问题的好方法,适当的解释将大大提高其长期价值,对于其他存在类似问题的读者来说,这样做将更为有用。请编辑您的答案以添加一些解释,包括您所做的假设。
Toby Speight

3
顺便说一句,这里的时间复杂度似乎是O(n²)-隐藏内部循环不会改变它。
Toby Speight

-2

如果数组不是太大,则此解决方案比较简单,它将创建另一个相同大小的数组以进行滴答。

1创建与输入数组大小相同的位图/数组

 int check_list[SIZE_OF_INPUT];
 for(n elements in checklist)
     check_list[i]=0;    //initialize to zero

2扫描您的输入数组并在上面的数组中增加其计数

for(i=0;i<n;i++) // every element in input array
{
  check_list[a[i]]++; //increment its count  
}  

3现在扫描check_list数组,并重复打印一次或重复多次

for(i=0;i<n;i++)
{

    if(check_list[i]>1) // appeared as duplicate
    {
        printf(" ",i);  
    }
}

当然,它占用上述解决方案消耗的空间的两倍,但是时间效率为O(2n),基本上是O(n)。


这不是O(1)空间。
Daniel Kamil Kozar 2012年

哎呀...!没注意到...我不好。
Deepthought

@nikhil O(1)怎么样?我的数组check_list随输入大小的增长而线性增长,如果是,它的O(1)怎么样?您使用什么启发式方法将其称为O(1)。
Deepthought 2012年

对于给定的输入,您需要恒定的空间,这不是O(1)吗?我完全有可能错了:)
nikhil

随着输入的增长,我的解决方案需要更多空间。没有针对特定输入测量算法的效率(空间/时间)(在这种情况下,每种搜索算法的时间效率将是恒定的,即在我们搜索的第一个索引中找到的元素)。我们有最好的情况,最坏的情况和一般情况的原因。
Deepthought 2012年
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.