在整数数组中查找重复项

13

这是一道面试题。

我得到了一个由范围在[1,n]内的n+1个整数组成的数组,该数组具有k (k≥1)个重复元素,每个重复元素可以出现多次。任务是以最佳时间和空间复杂度找到出现多次的数组元素。

经过长时间的思考,我自豪地提出了一个时间复杂度为O(nlogn)、空间复杂度为O(1)的解决方案。我的想法是将范围[1,n-1]分成两个部分,并确定哪个部分包含更多来自输入数组的元素(我使用了鸽笼原理)。算法继续递归执行,直到达到区间[X,X],其中X出现两次,这是一个重复元素。

面试官很满意,但他告诉我存在一种具有恒定空间复杂度的O(n)解决方案。他慷慨地提供了几个提示(与置换有关?),但我不知道如何想出这样的解决方案。假设他没有说谎,有没有人能提供指导方针?我搜索了SO,找到了一些(更简单的)变体问题,但没有找到这个具体的问题。谢谢。

编辑:为了让事情变得更加复杂,面试官提到输入数组不应被修改。


@maraca 至少需要 O(n) 的空间。 - Aurel Bílý
啊,我明白了,如果没有重复项,你可以通过将元素插入到其正确位置来进行排序,如果已经存在一个具有应该具有的值的元素,则找到了一个重复项。 - maraca
没有任何可逆的修改吗?(比如将一个元素变为负数)? - rici
@rici 让一个元素变成负数听起来技术上似乎不再是 O(1) 空间了。 - גלעד ברקן
@Rose M:终于搞定了,你说的置换循环是对的。 - maraca
显示剩余4条评论
4个回答

14
  1. 取最后一个元素(x)。

  2. 保存位置x上的元素(y)。

  3. 如果x==y,则找到了重复项。

  4. 用x覆盖位置x。

  5. 将x=y赋值并继续执行第2步。

你基本上是在对数组进行排序,这是可能的,因为你知道要插入元素的位置。O(1)的额外空间和O(n)的时间复杂度。你只需要小心索引,为了简单起见,我在这里假设第一个索引是1(而不是0),所以我们不必进行+1或-1操作。

编辑:在不修改输入数组的情况下

该算法基于找到置换循环的入口点的想法,然后我们也找到了一个重复项(为了简单起见,再次使用基于1的数组):

示例:

2 3 4 1 5 4 6 7 8

输入: 8 7 6

置换循环: 4 1 2 3

我们可以看到重复的数字(4)是循环的第一个数字。

  1. 找到置换循环

    1. x = 最后一个元素
    2. x = 在位置x的元素
    3. 重复执行步骤2 n 次(总共),这保证了我们已经进入循环中
  2. 测量循环长度

    1. a = 上述 x 的最后一个值, b = 上述 x 的最后一个值, 计数器 c = 0
    2. a = 位置为a的元素, b = 位置为b的元素, b = 位置为b的元素,c++ (所以我们在 b 中向前走了2步,在 a 中向前走了1步)
    3. 如果 a == b,则循环长度为 c,否则继续执行步骤2。
  3. 找到置换循环的起始点

    1. x = 最后一个元素
    2. x = 在位置x的元素
    3. 重复执行步骤2 c 次(总共)
    4. y = 最后一个元素
    5. 如果 x == y 那么 x 就是答案(因为 x 完成了一次完整的循环,而 y 刚好要进入循环)
    6. x = 位置为x的元素, y = 位置为y的元素
    7. 重复执行步骤5和6直到找到一个答案。

这3个主要步骤都是 O(n),顺序执行,因此总体复杂度也是 O(n),空间复杂度为 O(1)。

上面的例子:

  1. x 取以下值: 8 7 6 4 1 2 3 4 1 2

  2. a 取以下值: 2 3 4 1 2
    b 取以下值: 2 4 2 4 2
    因此 c = 4 (是的,有5个数字,但只有在进行步骤时才会增加c,而不是一开始就增加)

  3. x 取以下值: 8 7 6 4 | 1 2 3 4
    y 取以下值: | 8 7 6 4
    最终 x == y == 4,这就是一个解!

第二个请求的示例: 3 1 4 6 1 2 5

  1. 进入循环: 5 1 3 4 6 2 1 3

  2. 测量循环长度:
    a: 3 4 6 2 1 3
    b: 3 6 1 4 2 3
    c = 5

  3. 找到起始点:
    x: 5 1 3 4 6 | 2 1
    y: | 5 1
    x == y == 1 是一个解


哇,那真是太快了!谢谢,这是我在这里的第一个赞 :) 面试官提到(我忘了添加)输入数组不应被修改。你能想出这种情况下的解决方案吗? - Rose M
@RoseM 只有当存在一个重复项时才执行。 - maraca
@גלעדברקן 这不是整个数组,只是前4个处理过的元素。那是马拉卡提供的算法的反例。 - Rose M
@老程序员,是总数乘以n次而不是n+1次,我也在考虑这个问题。现在已经更正了,并且检查相等性的方法也需要反转,现在应该正确了。 - maraca
@גלעדברקן 是的,我编辑了我的答案并添加了你的例子。 - maraca
显示剩余9条评论

5
这里是一种可能的实现方法:

function checkDuplicate(arr) {
  console.log(arr.join(", "));
  let  len = arr.length
      ,pos = 0
      ,done = 0
      ,cur = arr[0]
      ;
  while (done < len) {
    if (pos === cur) {
      cur = arr[++pos];
    } else {
      pos = cur;
      if (arr[pos] === cur) {
        console.log(`> duplicate is ${cur}`);
        return cur;
      }
      cur = arr[pos];
    }
    done++;
  }
  console.log("> no duplicate");
  return -1;
}

for (t of [
     [0, 1, 2, 3]
    ,[0, 1, 2, 1]
    ,[1, 0, 2, 3]
    ,[1, 1, 0, 2, 4]
  ]) checkDuplicate(t);

这基本上是@maraca提出的解决方案(打字太慢了!)。它具有恒定的空间需求(对于局部变量),但除此之外,仅使用原始数组进行存储。在最坏情况下,它应该是O(n),因为一旦找到重复项,该过程就会终止。


谢谢!面试官提到(我忘了加上),输入的数组不应被修改。你能想出这种情况下的解决方案吗?我已经编辑了我的问题以添加此要求。 - Rose M
对于[1, 2, 1, 0]失败。 - giusti
@giusti,不允许使用0。 - גלעד ברקן
1
不在原始语句中,但答案将域映射到[0,n-1]。无论如何,它也无法通过“[1, 2, 1, 2]”测试用例。 - giusti

2
如果您允许非破坏性地修改输入向量,那么这很容易。假设我们可以通过取反来“标记”输入中的一个元素(这显然是可逆的)。在这种情况下,我们可以按照以下步骤进行:
注意:以下假设向量从1开始索引。由于它可能以0开始索引(在大多数语言中),您可以使用“将索引为i的项目标记”实现“将索引为i-1的项目取反”。
1. 将i设置为0并执行以下循环:
a. 增加i,直到第i个项目未被标记。
b. 将j设置为i并执行以下循环:
a. 将j设置为vector[j]。
b. 如果j处的项目被标记,则j是重复项。终止两个循环。
c. 标记j处的项目。
d. 如果j!= i,请继续内部循环。
2. 遍历向量,将每个元素设置为其绝对值(即取消所有标记以恢复向量)。

谢谢你的回答,我给你点赞。面试官说“只读输入数组”,所以我认为即使这样也不被允许。算法很棒。 - Rose M

-1
  • 这要看你(你的应用)能使用什么工具。目前有很多框架/库存在。例如,在C++标准中,您可以使用std::map<>,就像maraca提到的那样。

  • 或者如果您有时间,您可以自己实现二叉树,但需要记住插入元素与普通数组不同。在这种情况下,您可以优化搜索重复项,因为这可能在您的特定情况下是可能的。

二叉树解释参考: https://www.wikiwand.com/en/Binary_tree


1
我本可以使用一个映射表,但那样就不是O(1)的空间复杂度了。面试官明确要求使用常数空间 :/ - Rose M

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