在列表中找到 k 个不重复的元素,额外的空间“很少”

Posted

技术标签:

【中文标题】在列表中找到 k 个不重复的元素,额外的空间“很少”【英文标题】:Find the k non-repeating elements in a list with "little" additional space 【发布时间】:2012-08-08 14:32:04 【问题描述】:

原来的问题陈述是这样的:

给定一个 32 位无符号整数数组,其中每个数字都恰好出现两次,除了其中三个(恰好出现一次),使用 O(1) 额外空间在 O(n) 时间内找到这三个数字。输入数组是只读的。如果有 k 个异常而不是 3 个呢?

如果由于输入限制(数组最多可以有 233 个条目)接受一个非常高的常数因子,则很容易在 Ο(1) 时间和 Ο(1) 空间中解决此问题:

for i in lst:
    if sum(1 for j in lst if i == j) == 1:
        print i

因此,为了这个问题,让我们放弃对位长度的限制,专注于数字最多可以有m 位的更普遍的问题。

Generalizing an algorithm for k = 2,我想到的是:

    XOR 那些最低有效位为1 的数字和那些分别为0 的数字。如果对于两个分区,结果值都不为零,我们知道我们已经将不重复的数字分成了两组,每组至少有一个成员 对于这些组中的每一个,尝试通过检查次低有效位等来进一步划分它

不过,有一种特殊情况需要考虑。如果在划分一个组之后,其中一个组的 XOR 值都为零,我们不知道得到的子组之一是否为空。在这种情况下,我的算法只是忽略了这一位并继续下一位,这是不正确的,例如输入 [0,1,2,3,4,5,6] 失败。

现在我的想法是不仅要计算元素的异或,还要计算应用某个函数后的值的异或(我在这里选择了f(x) = 3x + 1)。有关此额外检查的反例,请参见下面 Evgeny 的回答。

现在虽然下面的算法对于 k >= 7 是不正确的,我仍然在这里包含实现给你一个想法:

def xor(seq):
  return reduce(lambda x, y: x ^ y, seq, 0)

def compute_xors(ary, mask, bits):
  a = xor(i for i in ary if i & mask == bits)
  b = xor(i * 3 + 1 for i in ary if i & mask == bits)
  return a if max(a, b) > 0 else None

def solve(ary, high = 0, mask = 0, bits = 0, old_xor = 0):
  for h in xrange(high, 32):
    hibit = 1 << h
    m = mask | hibit
    # partition the array into two groups
    x = compute_xors(ary, m, bits | hibit)
    y = compute_xors(ary, m, bits)
    if x is None or y is None:
      # at this point, we can't be sure if both groups are non-empty,
      # so we check the next bit
      continue
    mask |= hibit
    # we recurse if we are absolutely sure that we can find at least one
    # new value in both branches. This means that the number of recursions
    # is linear in k, rather then exponential.
    solve(ary, h + 1, mask, bits | hibit, x)
    solve(ary, h + 1, mask, bits, y)
    break
  else:
    # we couldn't find a partitioning bit, so we output (but 
    # this might be incorrect, see above!)
    print old_xor

# expects input of the form "10 1 1 2 3 4 2 5 6 7 10"
ary = map(int, raw_input().split())
solve(ary, old_xor=xor(ary))

根据我的分析,这段代码的最坏情况时间复杂度为O(k * m² * n) 其中n 是输入元素的数量(XORing 是O(m) 并且最多k 分区操作可以成功)和空间复杂度O(m²)(因为m是最大递归深度,临时数的长度可以是m)。

问题当然是是否存在正确、具有良好渐近运行时间的高效方法(为了完整起见,我们假设k &lt;&lt; nm &lt;&lt; n 在这里),这也几乎不需要额外空间(例如,将不接受对输入进行排序的方法,因为我们至少需要O(n) 额外空间,因为我们无法修改输入!)。

编辑:既然上面的算法被证明是不正确的,当然很高兴看到它是如何正确的,可能会降低它的效率。空间复杂度应该在o(n*m) 中(即在输入比特的总数中是次线性的)。如果可以使任务更轻松,可以将k 作为附加输入。

【问题讨论】:

您提出的“不雅”解决方案似乎是O(n^2),而不是声称的O(n) except three of them - 这是否意味着这三个出现的次数与 2 不同? 1,3,4,5,...? Albert:我的解释是其他数字只出现一次,但它确实很模棱两可。我没有写问题陈述 @NiklasB。我同意你的推理,但我会颠倒过来。尽管从技术上讲 O(1) 因为有限界,但我认为因为 2^32 >= N 声明您在 O(N^2) 中的解决方案是合理的。就像在这个域中一样 O(2**32N) >= O(N^2) [稍微滥用 O 表示法]。 哦,如果一个模组看到这个:我觉得回答者应该因其回答而获得声誉,所以如果有人可以取消社区维基这个问题,那就太好了! 【参考方案1】:

有空间复杂度要求,放宽到O(m * n),这个任务可以在O(n)时间内轻松解决.只需使用哈希表计算每个元素的实例数,然后过滤计数器等于 1 的条目。或者使用任何分布式排序算法。

但这是一种概率算法,对空间的要求更轻。

此算法使用大小为 s 的附加位集。对于输入数组中的每个值,计算一个哈希函数。该散列函数确定位集中的索引。这个想法是扫描输入数组,为每个数组条目切换位集中的相应位。重复的条目将同一位切换两次。由唯一条目(几乎所有条目)切换的位保留在位集中。这实际上与计数 Bloom 过滤器相同,每个计数器中唯一使用的位是最低有效位。

再次扫描数组,我们可能会提取唯一值(不包括一些假阴性)以及一些重复值(假阳性)。

bitset 应该足够稀疏,以尽可能少地给出误报,以减少不需要的重复值的数量,从而降低空间复杂度。位集高度稀疏的另一个好处是减少了假阴性的数量,从而稍微提高了运行时间。

要确定位集的最佳大小,请在位集和包含唯一值和误报的临时数组之间平均分配可用空间(假设 k n): s = n * m * k / s,得到 s = sqrt(n * m * k)。并且预期的空间要求是 O(sqrt(n * m * k))。

    扫描输入数组并切换位集中的位。 扫描输入数组并过滤bitset中对应非零位的元素,将它们写入临时数组。 使用任何简单的方法(分布排序或哈希)从临时数组中排除重复项。 如果临时数组的大小加上目前已知的唯一元素的数量小于k,则更改哈希函数,清除bitset和toggle bits,对应于已知的唯一值,继续步骤1 .

预期时间复杂度介于 O(n * m) 和 O(n * m * log(n * m * k) / log(n * m / k))。

【讨论】:

还有一个很棒的建议 :) 你似乎很喜欢这个问题 :P 这似乎是计数过滤器解决方案的次优版本,即它是计数过滤器解决方案,但 k=1(哈希数)。 @cmh:如果我弄错了,请纠正我,但是对于使用 sqrt(n * m * k) 计数器计算过滤器解决方案(在您的答案中描述),每个计数器的预期值为 sqrt( n / (m * k))。对于较大的 n,我们没有太多机会看到任何值为 1 的计数器。这意味着对输入数组的重新扫描次数过多。所以它应该慢得多。 不正确,在计数过滤器中,我们只需要一个 k 哈希 = 1。但是使用您的切换解决方案,每次超过 1 (% 2) 时都会出现假阴性/阳性。 让我们使用一些实数:n=1000000000,m=k=32,计数过滤器大小 1000000,预期计数器值 1000*number_of_hashes。这 1000000 个计数器中的任何一个具有值 1 的机会是多少?使用相同的参数切换解决方案只有 32000 个误报,几乎没有机会出现误报(这意味着数组只会被扫描 2 次)。【参考方案2】:

我想你事先知道 k 我选择 Squeak Smalltalk 作为实现语言。

inject:into: 是 reduce,在空间上是 O(1),在时间上是 O(N) select: 是过滤器,(我们不使用它,因为需要 O(1) 空间) collect:是地图,(我们不使用它,因为需要 O(1) 空间) do: 是forall,在空间上是O(1),在时间上是O(N) 方括号中的块是闭包,如果不关闭任何变量且不使用 return,则为纯 lambda,以冒号为前缀的符号是参数。 ^ 表示返回

对于 k=1,单例是通过使用位 xor 减少序列来获得的

所以我们在类Collection中定义了一个方法xorSum(所以self就是序列)

Collection>>xorSum
    ^self inject: 0 into: [:sum :element | sum bitXor:element]

还有第二种方法

Collection>>find1Singleton
    ^self xorSum

我们测试它

 self assert: 0. 3. 5. 2. 5. 4. 3. 0. 2. find1Singleton = 4

成本是O(N),空间O(1)

对于 k=2,我们搜索两个单例,(s1,s2)

Collection>>find2Singleton
    | sum lowestBit s1 s2 |
    sum := self xorSum.

sum不为0,等于(s1 bitXOr: s2),两个单例的异或

在总和的最低设置位处拆分,并像您建议的那样对两个序列进行异或运算,您将得到 2 个单例

    lowestBit := sum bitAnd: sum negated.
    s1 := s2 := 0.
    self do: [:element |
        (element bitAnd: lowestBit) = 0
            ifTrue: [s1 := s1 bitXor: element]
            ifFalse: [s2 := s2 bitXor: element]].
    ^s1. s2

 self assert: 0. 1. 1. 3. 5. 6. 2. 6. 4. 3. 0. 2. find2Singleton sorted = 4. 5

成本是2*O(N),空间O(1)

对于 k=3,

我们定义了一个特定的类来实现异或拆分的细微变化,实际上我们使用了三元拆分,掩码可以有 value1 或 value2,任何其他值都被忽略。

Object
    subclass: #BinarySplit
    instanceVariableNames: 'sum1 sum2 size1 size2'
    classVariableNames: '' poolDictionaries: '' category: 'SO'.

使用这些实例方法:

sum1
    ^sum1

sum2
    ^sum2

size1
    ^size1

size2
    ^size2

split: aSequence withMask: aMask value1: value1 value2: value2
    sum1 := sum2 := size1 := size2 := 0.
    aSequence do: [:element |
    (element bitAnd: aMask) = value1
            ifTrue:
                [sum1 := sum1 bitXor: element.
                size1 := size1 + 1].
    (element bitAnd: aMask) = value2
            ifTrue:
                [sum2 := sum2 bitXor: element.
                size2 := size2 + 1]].

doesSplitInto: s1 and: s2
    ^(sum1 = s1 and: [sum2 = s2])
        or: [sum1 = s2 and: [sum2 = s1]]

还有这个类端方法,一种创建实例的构造函数

split: aSequence withMask: aMask value1: value1 value2: value2
    ^self new split: aSequence withMask: aMask value1: value1 value2: value2

然后我们计算:

Collection>>find3SingletonUpToBit: m
    | sum split split2 mask value1 value2 |
    sum := self xorSum.

但这并没有提供关于要拆分的位的任何信息...所以我们尝试每个位 i=0..m-1。

    0 to: m-1 do: [:i |
        split := BinarySplit split: self withMask: 1 << i value1: 1<<i value2: 0.

如果您获得 (sum1,sum2) == (0,sum),那么您很容易将 3 个单身人士放在同一个包中... 所以重复,直到你得到不同的东西 否则,如果不同,您将得到一个带有 s1(奇数大小)和另一个带有 s2、s3(偶数大小)的包,因此只需应用 k=1 (s1=sum1) 和 k=2 的算法修改位模式

        (split doesSplitInto: 0 and: sum)
            ifFalse:
                [split size1 odd
                    ifTrue:
                        [mask := (split sum2 bitAnd: split sum2 negated) + (1 << i).
                        value1 := (split sum2 bitAnd: split sum2 negated).
                        value2 := 0.
                        split2 := BinarySplit split: self withMask: mask value1: value1 value2: value2.
                        ^ split sum1. split2 sum1. split2 sum2]
                    ifFalse:
                        [mask := (split sum1 bitAnd: split sum1 negated) + (1 << i).
                        value1 := (split sum1 bitAnd: split sum1 negated) + (1 << i).
                        value2 := (1 << i).
                        split2 := BinarySplit split: self withMask: mask value1: value1 value2: value2.
                        ^ split sum2. split2 sum1. split2 sum2]].

我们用它来测试它

self assert: (0. 1. 3. 5. 6. 2. 6. 4. 3. 0. 2. find3SingletonUpToBit: 32) sorted = 1. 4. 5

最差的代价是(M+1)*O(N)

对于 k=4,

当我们拆分时,我们可以有 (0,4) 或 (1,3) 或 (2,2) 单例。 (2,2) 很容易识别,两个大小都是偶数,并且两个异或和都不为0,案例解决了。 (0,4) 很容易识别,两个大小都是偶数,并且至少有一个和为零,所以在总和 != 0 (1,3) 更难,因为两个大小都是奇数,我们回退到未知数量的单身人士的情况......虽然,如果袋子的一个元素等于异或和,我们可以很容易地识别出单身人士,这对于 3 个不同的数字是不可能的...

我们可以对 k=5 进行泛化...但是上面会很难,因为我们必须为 (4,2) 和 (1,5) 的情况找到一个技巧,记住我们的假设,我们必须提前知道 k ...我们必须做假设并随后验证它们...

如果你有反例,直接提交,我会检查上面的 Smalltalk 实现

编辑:我在http://ss3.gemstone.com/ss/SONiklasBContest.html提交了代码(MIT 许可证)

【讨论】:

嗯,我的算法已经适用于k &lt;= 6,正如 Evgeny 所证明的那样(证明实际上非常简单)......我实际上对一般情况更感兴趣。不过,我喜欢这种语言,以前从未真正见过工作 Smalltalk 代码:P 你对编程语言的品味很有趣! 我将代码重构为递归并将递归扩展为 k=5(但它不是通用的)并提交到ss3.gemstone.com/ss/SONiklasBContest.html。网页界面不是浏览代码的多余的,但是如果你下载.mcz,它实际上是一个.zip文件【参考方案3】:

这只是一种直觉,但我认为解决方案是增加您评估的分区数量,直到找到一个异或和不为零的分区。

例如,对于 [0,m) 范围内的每两个位 (x,y),请考虑由 a &amp; ((1&lt;&lt;x) || (1 &lt;&lt; y)) 的值定义的分区。在 32 位的情况下,这会产生 32*32*4 = 4096 个分区,它允许正确解决 k = 4 的情况。

现在有趣的事情是找到 k 与解决问题所需的分区数之间的关系,这也可以让我们计算算法的复杂性。另一个悬而未决的问题是是否有更好的分区模式。

一些 Perl 代码来说明这个想法:

my $m = 10;
my @a = (0, 2, 4, 6, 8, 10, 12, 14, 15, 15, 7, 7, 5, 5);

my %xor;
my %part;
for my $a (@a) 
    for my $i (0..$m-1) 
        my $shift_i = 1 << $i;
        my $bit_i = ($a & $shift_i ? 1 : 0);
        for my $j (0..$m-1) 
            my $shift_j = 1 << $j;
            my $bit_j = ($a & $shift_j ? 1 : 0);
            my $k = "$i:$bit_i,$j:$bit_j";
            $xor$k ^= $a;
            push @$part$k //= [], $a;
        
    


print "list: @a\n";
for my $k (sort keys %xor) 
    if ($xor$k) 
        print "partition with unique elements $k: @$part$k\n";
    
    else 
        # print "partition without unique elements detected $k: @$part$k\n";
    

【讨论】:

a relation between k and the number of partitions: O(k/m * k^log(m)) 在最坏的情况下。详情见我的回答。 是的,这实际上与 Evgeny 在他的回答中分析的想法相同(和我的想法相同,但我认为它可能会做得更好)【参考方案4】:

前一个问题的解决方案(在 O(N) 中找到唯一的 uint32 数字,内存使用量为 O(1))非常简单,虽然不是特别快:

void unique(int n, uint32 *a) 
  uint32 i = 0;
  do 
    int j, count;
    for (count = j = 0; j < n; j++) 
      if (a[j] == i) count++;
    
    if (count == 1) printf("%u appears only once\n", (unsigned int)i);
   while (++i);

对于 M 位数不受限制的情况,复杂度变为 O(N*M*2M),内存使用仍为 O(1)。

更新:使用位图的补充解决方案导致复杂度 O(N*M) 和内存使用 O(2M):

void unique(int n, uint32 *a) 
  unsigned char seen[1<<(32 - 8)];
  unsigned char dup[1<<(32 - 8)];
  int i;
  memset(seen, sizeof(seen), 0);
  memset(dup,  sizeof(dup),  0);
  for (i = 0; i < n; i++) 
    if (bitmap_get(seen, a[i])) 
      bitmap_set(dup, a[i], 1);
    
    else 
      bitmap_set(seen, a[i], 1);
    
  
  for (i = 0; i < n; i++) 
    if (bitmap_get(seen, a[i]) && !bitmap_get(dup, a[i])) 
      printf("%u appears only once\n", (unsigned int)a[i]);
      bitmap_set(seen, a[i], 0);
    
  

有趣的是,这两种方法可以结合使用,将 2M 空间划分为带状。然后,您必须遍历所有波段,并在每个波段内使用位向量技术找到唯一值。

【讨论】:

是的,我想我在问题中提到了这一点(参见第一个代码示例) @NiklasB,不,空间使用不是 N 的函数,而是 M 的函数 这很好,但它占用Ω(n) 的空间,远非最佳 来自n &lt;= 2*2^m,因此2^m = Ω(n)【参考方案5】:

k >= 7

的 OP 中算法的反证

当这些组中的至​​少一个被异或到非零值时,该算法使用单个位的值递归地将一组 k 个唯一值分成两组。比如下面的数字

01000
00001
10001

可以拆分成

01000

00001
10001

使用最低有效位的值。

如果实施得当,这适用于 k k = 8 和 k = 7 无效。让我们假设 m = 4 并使用从 0 到 14 的 8 个偶数:

0000
0010
0100
0110
1000
1010
1100
1110

每个位,除了最低有效位,正好有 4 个非零值。如果我们尝试分割这个集合,由于这种对称性,我们总是会得到一个包含 2 个或 4 个或 0 个非零值的子集。这些子集的 XOR 始终为 0。这不允许算法进行任何拆分,因此else 部分仅打印所有这些唯一值的 XOR(单个零)。

3x + 1 技巧没有帮助:它只会打乱这 8 个值并切换最低有效位。

如果我们从上述子集中删除第一个(全零)值,则完全相同的参数适用于 k = 7。

由于任何一组唯一值都可能被分成一组 7 或 8 个值和其他组,因此该算法对于 k> 8 也会失败。


概率算法

可以不发明全新的算法,而是在 OP 中修改算法,使其适用于任何输入值。

算法每次访问输入数组的元素时,都应该对这个元素应用一些转换函数:y=transform(x)。这个转换后的值y 可以完全按照x 在原始算法中使用的方式使用——用于对集合进行分区并对值进行异或运算。

最初是transform(x)=x(未修改的原始算法)。如果在这一步之后我们得到少于 k 个结果(一些结果是几个唯一值异或),我们将transform 更改为某个哈希函数并重复计算。这应该重复(每次使用不同的哈希函数),直到我们得到准确的 k 值。

如果这些 k 值是在算法的第一步(没有散列)获得的,这些值就是我们的结果。否则我们应该再次扫描数组,计算每个值的哈希值并报告那些匹配 k 个哈希值的值。

可以对原始 k 值集或(更好)分别对上一步中找到的每个子集执行具有不同哈希函数的计算的每个后续步骤。

要为算法的每一步获取不同的哈希函数,可以使用Universal hashing。散列函数的一个必要属性是可逆性——原始值应该(理论上)可以从散列值重构。这需要避免将几个“唯一”值散列到相同的散列值。由于使用任何可逆的m位散列函数都没有太多机会解决“反例”问题,因此散列值应该比m位长。这种散列函数的一个简单示例是原始值和该值的一些单向散列函数的串联。

如果 k 不是很大,我们不太可能得到与该反例相似的一组数据。 (我没有证据表明没有其他结构不同的“坏”数据模式,但我们希望它们也不太可能)。在这种情况下,平均时间复杂度不会比 O(k * m2 * n) 大多少。


原始算法的其他改进

在计算所有(尚未分区的)值的异或时,检查数组中唯一的零值是合理的。如果有,只需递减 k。 在每个递归步骤中,我们不能总是知道每个分区的确切大小。但我们知道它是奇数还是偶数:在非零位上的每个拆分都会给出奇数大小的子集,另一个子集的奇偶校验是原始子集的“切换”奇偶校验。 在最新的递归步骤中,当唯一的非拆分子集的大小为 1 时,我们可能会跳过对拆分位的搜索并立即报告结果(这是对非常小的 k 的优化)。 如果我们在某些拆分后得到一个奇数大小的子集(并且如果我们不确定它的大小是否为 1),则扫描数组并尝试找到一个唯一值,等于该子集的 XOR。李> 无需遍历每一位来拆分偶数大小的集合。只需使用其 XORed 值的任何非零位。对结果子集之一进行异或可能会产生零,但这种拆分仍然有效,因为我们有 odd 个用于此拆分位的“1”,但 even 设置了大小。这也意味着,即使剩余的子集异或为零,任何在异或时产生非零大小的子集的分割都是有效的。 您不应在每次递归时继续拆分位搜索(如solve(ary, h + 1...)。相反,您应该从头开始重新搜索。可以在第 31 位上拆分集合,并且在第 0 位上对结果子集之一进行拆分。 您不应该扫描整个阵列两次(因此不需要第二次y = compute_xors(ary, m, bits))。您已经拥有整个集合的 XOR 和拆分位非零的子集的 XOR。这意味着您可以立即计算yy = x ^ old_xor

OP 中 k = 3 的算法证明

这不是对 OP 中实际程序的证明,而是对它的想法的证明。当结果子集之一为零时,实际程序当前拒绝任何拆分。当我们可以接受某些此类拆分时,请参阅建议的改进。因此,只有在将 if x is None or y is None 更改为考虑子集大小的奇偶性的条件或添加预处理步骤以从数组中排除唯一的零元素之后,才能将以下证明应用于该程序。

我们有 3 个不同的数字。它们至少应在 2 个位位置上不同(如果它们仅在一个位上不同,则第三个数字必须等于其他数字之一)。 solve 函数中的循环找到这些位位置的最左边,并将这 3 个数字分成两个子集(单个数字和 2 个不同数字)。 2 数子集在该位位置具有相等的位,但数字仍然应该不同,因此应该还有一个拆分位位置(显然,在第一个位的右侧)。第二个递归步骤很容易将这个 2 数字子集拆分为两个单个数字。 i * 3 + 1 的技巧在这里是多余的:它只会使算法的复杂性加倍。

这是一组 3 个数字中第一次拆分的图示:

 2  1
*b**yzvw
*b**xzvw
*a**xzvw

我们有一个循环遍历每个位位置并计算整个字的 XOR,但单独地,一个 XOR 值 (A) 用于给定位置的真位,另一个 XOR 值 (B) 用于假位。 如果数字 A 在这个位置有零位,则 A 包含一些偶数大小的值子集的 XOR,如果非零 - 奇数子集。 B 也是如此。我们只对偶数大小的子集感兴趣。 它可能包含 0 或 2 个值。

虽然位值(位 z、v、w)没有差异,但我们有 A=B=0,这意味着我们不能在这些位上拆分我们的数字。 但是我们有 3 个不相等的数字,这意味着在某个位置 (1) 我们应该有不同的位(x 和 y)。其中一个 (x) 可以在我们的两个数字中找到(偶数子集!),另一个 (y) - 在一个数字中。 让我们看看这个偶数子集中的值的异或。从 A 和 B 选择值 (C),在位置 1 处包含位 0。但 C 只是两个不相等值的 XOR。 它们在位位置 1 处相等,因此它们必须在至少一个位位置(位置 2,位 a 和 b)上有所不同。所以 C != 0 并且它对应于偶数大小的子集。 这种拆分是有效的,因为我们可以通过非常简单的算法或该算法的下一次递归进一步拆分这个偶数大小的子集。

如果数组中没有唯一的零元素,则可以简化此证明。我们总是将唯一的数字分成 2 个子集 - 一个具有 2 个元素(并且它不能 XOR 为零,因为元素不同),另一个具有一个元素(根据定义非零)。所以预处理很少的原始程序应该可以正常工作。

复杂度为 O(m2 * n)。如果你应用我之前建议的改进,这个算法扫描数组的预期次数是 m / 3 + 2。因为第一个分割位的位置预计是 m / 3,处理2元素子集需要一次扫描,每个1元素子集不需要任何数组扫描,最初需要多扫描一次(solve方法之外)。


OP 中 k = 4 .. 6 的算法证明

这里我们假设所有建议的对原始算法的改进都已应用。

k=4 和 k=5:由于至少有一个位置具有不同的位,因此可以将这组数字拆分为其中一个子集的大小为 1 或 2 . 如果子集的大小为 1,则它是非零的(我们没有零唯一值)。如果子集的大小为 2,我们有两个不同数字的异或,这是非零的。所以在这两种情况下,拆分都是有效的。

k=6:如果整个集合的异或非零,我们可以通过这个异或有非零位的任何位置分割这个集合。否则,我们在每个位置都有偶数个非零位。由于至少有一个位置具有不同的位,因此该位置将集合拆分为大小为 2 和 4 的子集。大小为 2 的子集始终具有非零 XOR,因为它包含 2 个不同的数字。同样,在这两种情况下,我们都有有效的拆分。


确定性算法

k >= 7 的反证显示了原始算法不起作用的模式:我们有一个大小大于 2 的子集,并且在每个位位置我们有偶数个非零位。但是我们总能找到一对非零位在单个数字中重叠的位置。换句话说,总是可以在大小为 3 或 4 的子集中找到一对位置,其中 两个 位置的子集中所有位的非零 XOR。这建议我们使用额外的拆分位置:使用两个单独的指针遍历位位置,将数组中的所有数字分组为两个子集,其中一个子集在这些位置具有非零位,而其他 - 所有剩余的数字。这增加了我的 m 的最坏情况复杂性,但允许 k 有更多值。一旦不再可能获得大小小于 5 的子集,则添加第三个“拆分指针”,依此类推。每次 k 加倍,我们可能需要一个额外的“拆分指针”,这会再次增加 my m 的最坏情况复杂度。

这可能被视为以下算法的证明草图:

    使用原始(改进的)算法来查找零个或多个唯一值以及零个或多个不可拆分子集。当没有更多不可分割的子集时停止。 对于这些不可拆分的子集,尝试拆分它,同时增加“拆分指针”的数量。找到拆分后,继续执行步骤 1。

最坏情况复杂度为 O(k * m2 * n * mmax(0, floor(log(floor(k/4))))),可以近似为 O(k * n * mlog(k)) = O(k * n * klog(m))。

对于小k,该算法的预期运行时间比概率算法稍差,但仍不大于 O(k * m 2 * n)。

【讨论】:

感谢反例,我怀疑是这样的。您的直觉是什么:是否有可能使该方法真正起作用,或者 XORing 通常注定要失败?我已经在 math.SE 上询问了question regarding the issue,但实际上我们还有一个额外的事实,即对于每一位,其中一个分区需要异或为零才能使算法失败。我的胆子说我们找不到这样的功能f,但也许我错了。 @NiklasB.:我认为,使用 XORing 的方法可能有效,但复杂度可能大于 O(k * m * n)。 抱歉,刚刚在上面的评论中添加了更多信息,以防您觉得有趣。 @NiklasB.:3x+1 部分的更多详细信息:将 0,2,4,6,8,10,12,14 乘以 3(并丢弃溢出位)后,我们有 0,6,12,2,8,14,4,10 - 完全相同的值转置。添加任何常量(并丢弃溢出位)再次打乱这些数字(并可能切换最低有效位)。所以问题没有改变。 @NiklasB.:我想以一种直接的方式使用这些数字。起初我说服自己 k=3 可以正常工作,然后我试图获得 k=4 的证明,但发现它很困难。然后我认为对于较大的 k,它可能会从“困难”变为“不可能”。在搜索“不可能”的东西时,我立即得到了那些数字,不知道为什么,可能是因为这个子集的对称性。【参考方案6】:

对于 k = 3 的情况,这里有一个适当的解决方案,它只占用最少的空间,并且空间要求是 O(1)。

让'transform' 是一个函数,它接受一个 m 位无符号整数 x 和一个索引 i 作为参数。 i 介于 0 .. m - 1 之间,transform 将整数 x 转化为

x 本身,如果 x 的第 i 位未设置 to x ^ (x

在下面的 T(x, i) 中使用作为 transform(x, i) 的简写。

我现在声称如果 a、b、c 是三个不同的 m 位无符号整数和 a'、b'、c' 和其他三个不同的 m 位无符号整数,使得 a XOR b XOR c == a' XOR b' XOR c',但是集合 a, b, c 和 a', b', c' 是两个不同的集合,那么有一个索引 i 使得 T(a, i) XOR T( b, i) XOR T(c, i) 不同于 T(a', i) XOR T(b', i) XOR T(c', i)。

要看到这个,让a' == a XOR a'', b' == b XOR b'' and c' == c XOR c'',即让a''表示a和a的XOR ' 等等。因为 a XOR b XOR c 在每个位上都等于 a' XOR b' XOR c',所以 a'' XOR b'' XOR c'' == 0。这意味着在每个位位置,要么 a '、b'、c' 与 a、b、c 相同,或者它们中的两个恰好将所选位置的位翻转(0->1 或 1->0)。因为 a'、b'、c' 与 a、b、c 不同,所以设 P 是已经发生两次位翻转的任何位位置。我们继续证明 T(a', P) XOR T(b', P) XOR T(c', P) 不同于 T(a, P) XOR T(b, P) XOR T(c, P) .不失一般性假设 a' 与 a 相比具有位翻转,b' 与 b 相比具有位翻转,并且 c' 在该位置 P 与 c 具有相同的位值。

除了位位置 P 之外,还必须有另一个位位置 Q,其中 a' 和 b' 不同(否则集合不包含三个不同的整数,或者翻转位置 P 处的位不会创建新集合整数,不需要考虑的情况)。位位置 Q 的桶旋转版本的 XOR 在位位置 (Q + 1) mod m 处产生奇偶校验错误,这导致声称 T(a', P) XOR T(b', P) XOR T(c', P) 不同于 T(a, P) XOR T(b, P) XOR T(c, P)。显然,c' 的实际值不会影响奇偶校验错误。

因此,算法是

遍历输入数组,并计算 (1) 所有元素的 XOR,以及 (2) 0 .. m - 1 之间所有元素 x 和 i 的 T(x, i) 的 XOR 在常量空间中搜索三个 32 位整数 a、b、c,使得所有有效值的 a XOR b XOR c 和 T(a, i) XOR b(a, i) XOR c(a, i)我的匹配那些从数组中计算出来的

这很明显,因为重复元素在 XOR 操作中被取消,而对于其余三个元素,上述推理成立。

实现了这个并且它有效。这是我的测试程序的源代码,它使用 16 位整数来提高速度。

#include <iostream>
#include <stdlib.h>
using namespace std;

/* CONSTANTS */
#define BITS  16
#define MASK ((1L<<(BITS)) - 1)
#define N   MASK
#define D   500
#define K      3
#define ARRAY_SIZE (D*2+K)

/* INPUT ARRAY */
unsigned int A[ARRAY_SIZE];

/* 'transform' function */
unsigned int bmap(unsigned int x, int idx) 
    if (idx == 0) return x;
    if ((x & ((1L << (idx - 1)))) != 0)
        x ^= (x << (BITS - 1) | (x >> 1));
    return (x & MASK);


/* Number of valid index values to 'transform'. Note that here
   index 0 is used to get plain XOR. */
#define NOPS 17

/* Fill in the array --- for testing. */
void fill() 
    int used[N], i, j;
    unsigned int r;
    for (i = 0; i < N; i++) used[i] = 0;
    for (i = 0; i < D * 2; i += 2)
    
        do  r = random() & MASK;  while (used[r]);
        A[i] = A[i + 1] = r;
        used[r] = 1;
    
    for (j = 0; j < K; j++)
    
        do  r = random() & MASK;  while (used[r]);
        A[i++] = r;
        used[r] = 1;
    


/* ACTUAL PROCEDURE */
void solve() 
    int i, j;
    unsigned int acc[NOPS];
    for (j = 0; j < NOPS; j++)  acc[j] = 0; 
    for (i = 0; i < ARRAY_SIZE; i++)
    
        for (j = 0; j < NOPS; j++)
            acc[j] ^= bmap(A[i], j);
    
    /* Search for the three unique integers */
    unsigned int e1, e2, e3;
    for (e1 = 0; e1 < N; e1++)
    
        for (e2 = e1 + 1; e2 < N; e2++)
        
            e3 = acc[0] ^ e1 ^ e2; // acc[0] is the xor of the 3 elements
            /* Enforce increasing order for speed */
            if (e3 <= e2 || e3 <= e1) continue;
            for (j = 0; j < NOPS; j++)
            
                if (acc[j] != (bmap(e1, j) ^ bmap(e2, j) ^ bmap(e3, j)))
                    goto reject;
            
            cout << "Solved elements: " << e1
                 << ", " << e2 << ", " << e3 << endl;
            exit(0);
          reject:
            continue;
        
    


int main()

    srandom(time(NULL));
    fill();
    solve();

【讨论】:

我的算法对于 k = 3 已经可以正常工作,并且对于有界输入数字大小具有运行时间 O(n) 和空间 O(1)。更有趣的问题是如何解决 k > 3 的问题 @attini:我的意思是问题中的那个。很容易证明它对 k = 3 是正确的(但我同意我应该更清楚地说明这一点......我很抱歉)。你得到了我的支持:) 哦,抱歉,我删除了适用于 k = 3 的实现,因为它被证明对于 k >= 8 是不正确的:/ 在当前版本的问题中,我只是提到我的想法是不仅要计算值的异或,还要计算应用函数f(x) = 3x + 1 后的值的异或。这消除了 k = 3 可能发生的一个棘手情况(在 k > 3 的其他情况中,但不幸的是,并非所有情况,正如其他回答者所显示的那样)编辑现在我重新包含它,抱歉混乱 如果我理解正确的话,这个程序的运行时间是O(n*m^2 + m*2^(2m))。这里 ^ 表示指数,而不是 XOR。对于应该超过几千年的 32 位数字 :( @antti: [0,1,2,3,4,5,6] 是一个有效的输入,没有重复和 7 个“单例”。输出应该是输入。【参考方案7】:

我离线并根据 XOR 技巧有效的猜想证明了原始算法。碰巧,XOR 技巧不起作用,但下面的论点可能仍然有些人感兴趣。 (我在 Haskell 中重新做了,因为当我使用递归函数而不是循环时,我发现证明更容易,而且我可以使用数据结构。但对于观众中的 Pythonista,我尝试尽可能使用列表推导。)

http://pastebin.com/BHCKGVaV 处的可编译代码。

美丽的理论被丑陋的事实扼杀

问题:我们得到了 n 个非零 32 位字的序列 其中每个元素要么是 singleton 要么是 doubleton

如果一个词只出现一次,它就是单例

如果一个词恰好出现两次,则为doubleton

没有单词出现三次或更多次。

问题是找到单例。如果有三个 单例,我们应该使用线性时间和恒定空间。更多的 一般来说,如果有 k 个单例,我们应该使用 O(k*n) 时间 和 O(k) 空间。该算法基于一个未经证实的猜想 关于异或。

我们从这些基础开始:

module Singleton where
import Data.Bits
import Data.List
import Data.Word
import Test.QuickCheck hiding ((.&.))

关键抽象:词的部分说明

为了解决这个问题,我将引入一个抽象:to 描述一个 32 位字的最低有效 $w$ 位,我 介绍一个Spec

data Spec = Spec  w :: Int, bits :: Word32 
   deriving Show
width = w -- width of a Spec

如果w 的最低有效位相等,则Spec 匹配一个字 到bits。如果w 为零,则根据定义,所有单词都匹配:

matches :: Spec -> Word32 -> Bool
matches spec word = width spec == 0 ||
                    ((word `shiftL` n) `shiftR` n) == bits spec
  where n = 32 - width spec

universalSpec = Spec  w = 0, bits = 0 

以下是关于Specs 的一些声明:

所有单词都匹配universalSpec,其宽度为0

如果matches spec wordwidth spec == 32,那么 word == bits spec

关键思想:“扩展”部分规范

这是算法的关键思想:我们可以扩展Spec 在规范中添加另一个位。扩展Spec 生成两个 Specs 的列表

extend :: Spec -> [Spec]
extend spec = [ Spec  w = w', bits = bits spec .|. (bit `shiftL` width spec) 
              | bit <- [0, 1] ]
  where w' = width spec + 1

这是关键的声明:如果 spec 匹配 word 并且如果 width spec 小于 32,那么正好是两个规格之一 来自extend spec 匹配word。案例分析证明 word 的相关位。这个声明是如此重要,以至于我 将称之为引理一这是一个测试:

lemmaOne :: Spec -> Word32 -> Property
lemmaOne spec word =
  width spec < 32 && (spec `matches` word) ==> 
      isSingletonList [s | s <- extend spec, s `matches` word]

isSingletonList :: [a] -> Bool
isSingletonList [a] = True
isSingletonList _   = False

我们将定义一个函数,它给出一个Spec 和一个 32 位字的序列,返回单例字的列表 符合规格。该功能将花费时间与 输入的长度乘以答案的大小乘以 32,以及 额外空间与答案乘以 32 的大小成正比。之前 我们处理主要功能,我们定义了一些常数空间异或 功能。

XOR 被破坏的想法

函数xorWith f ws 将函数f 应用于ws 中的每个单词 并返回结果的异或。

xorWith :: (Word32 -> Word32) -> [Word32] -> Word32
xorWith f ws = reduce xor 0 [f w | w <- ws]
  where reduce = foldl'

感谢流融合(参见 ICFP 2007),函数 xorWith 采用 常数空间。

一个非零词列表有一个单例当且仅当 异或非零,或者如果3 * w + 1 的异或是 非零。 (“如果”方向是微不足道的。“仅当”方向是 一个被 Evgeny Kluev 推翻的猜想;举个反例, 请参阅下面的数组testb。我可以通过添加使 Evgeny 的示例工作 第三个函数g,但显然这种情况需要一个 证明,我没有。)

hasSingleton :: [Word32] -> Bool
hasSingleton ws = xorWith id ws /= 0 || xorWith f ws /= 0 || xorWith g ws /= 0
  where f w = 3 * w + 1
        g w = 31 * w + 17

高效搜索单身人士

我们的 main 函数返回一个包含所有匹配 a 的单例的列表 规格。

singletonsMatching :: Spec -> [Word32] -> [Word32]
singletonsMatching spec words =
 if hasSingleton [w | w <- words, spec `matches` w] then
   if width spec == 32 then
     [bits spec]       
   else
     concat [singletonsMatching spec' words | spec' <- extend spec]
 else
   []

我们将通过对宽度的归纳来证明它的正确性 spec.

基本情况是 spec 的宽度为 32。在这种情况下, 列表理解将给出完全符合的单词列表 等于bits spec。如果函数hasSingleton 将返回True 并且仅当此列表仅包含一个元素时,才为真 确切地说,当bits specwords 中是单例时。

现在让我们证明如果singletonsMatching 是正确的 对于 m+1,宽度 m 也是正确的,其中 *m

这是损坏的部分:对于较窄的宽度,hasSingleton 可能会返回 False,即使给定一个单例数组也是如此。这是悲剧。

在宽度为 mspec 上调用 extend spec 会返回两个规范 宽度为 $m+1$。根据假设,singletonsMatching 是 在这些规格上是正确的。证明:结果正好包含 那些匹配spec 的单例。通过引理一,任何词 匹配 spec 完全匹配扩展规范之一。经过 假设,递归调用完全返回单例 匹配扩展规格。当我们结合这些结果 用concat 调用,我们得到完全匹配的单例,用 没有重复,没有遗漏。

其实解决问题是虎头蛇尾:单例是 所有符合空规范的单例:

singletons :: [Word32] -> [Word32]
singletons words = singletonsMatching universalSpec words

测试代码

testa, testb :: [Word32]
testa = [10, 1, 1, 2, 3, 4, 2, 5, 6, 7, 10]
testb = [ 0x0000
        , 0x0010
        , 0x0100
        , 0x0110
        , 0x1000
        , 0x1010
        , 0x1100
        , 0x1110
        ]

除此之外,如果您想了解正在发生的事情,您需要 要知道QuickCheck。

这是规格的随机生成器:

instance Arbitrary Spec where
  arbitrary = do width <- choose (0, 32)
                 b <- arbitrary
                 return (randomSpec width b)
  shrink spec = [randomSpec w' (bits spec) | w' <- shrink (width spec)] ++
                [randomSpec (width spec) b | b  <- shrink (bits spec)]
randomSpec width bits = Spec  w = width, bits = mask bits      
  where mask b = if width == 32 then b
                 else (b `shiftL` n) `shiftR` n
        n = 32 - width

使用这个生成器,我们可以使用 quickCheck lemmaOne.

我们可以测试一下,任何声称是单例的词都在 事实单身:

singletonsAreSingleton nzwords = 
  not (hasTriple words) ==> all (`isSingleton` words) (singletons words)
  where isSingleton w words = isSingletonList [w' | w' <- words, w' == w]
        words = [w | NonZero w <- nzwords]

hasTriple :: [Word32] -> Bool
hasTriple words = hasTrip (sort words)
hasTrip (w1:w2:w3:ws) = (w1 == w2 && w2 == w3) || hasTrip (w2:w3:ws)
hasTrip _ = False

这是另一个测试快速 singletons 与 使用排序的较慢算法。

singletonsOK :: [NonZero Word32] -> Property
singletonsOK nzwords = not (hasTriple words) ==>
  sort (singletons words) == sort (slowSingletons words)
 where words = [w | NonZero w <- nzwords ]
       slowSingletons words = stripDoubletons (sort words)
       stripDoubletons (w1:w2:ws) | w1 == w2 = stripDoubletons ws
                                  | otherwise = w1 : stripDoubletons (w2:ws)
       stripDoubletons as = as

【讨论】:

事实上,我对 Haskell 的喜爱远胜于对 Python 的喜爱 :) 这篇文章看起来非常很有趣,我迫不及待地想阅读它 首先,非常感谢您对如何处理这些形式的证明提供了非常有用的见解。由于我计划很快为一家在生产中使用 Haskell 的公司工作,这对我来说特别有用,即使我对这个特定算法的直觉和测试结果证明是错误的。 顺便说一下,我的算法的假设,即 Evgeny 证明是错误的,比你在这里制定的要强一些。这更像是“如果一组值包含多个单例,那么对于至少一个位位置,将一组值按其在该位置的相应位进行分区将导致我们可以确定两者的情况通过检查两个 XOR 操作的结果,分区是非空的” 因此,Spec 数据类型会变得稍微复杂一些,因为它指定值的位块不必是连续的。尽管如此,事实证明这并不能确保正确性,所以它不再重要了:)【参考方案8】:

一种概率方法是使用counting filter。

算法如下:

    线性扫描数组并“更新”计数过滤器。 线性扫描数组并创建一个包含过滤器中计数为 2 的所有元素的集合,这将是真正解决方案的&lt;= k。 (在这种情况下,误报是独特的元素,看起来不像)。 选择一个新的哈希函数基础并重复,直到我们拥有所有 k 解决方案。

这使用2m 位空间(独立于n)。时间复杂度更多,但知道在第 2 步中找不到任何给定唯一元素的概率约为(1 - e^(-kn/m))^k,我们将很快解决一个解决方案,但不幸的是,我们在n 中并不是很线性。

我很欣赏这不能满足您的约束,因为它在时间上是超线性的,并且是概率性的,但鉴于原始条件可能无法满足 方法可能值得考虑。

【讨论】:

希望有空的时候给个更具体的时间限制。 不错的想法,即使它不是确定性算法,我仍然很欣赏这里的新想法。我不得不承认,这不是我面临的一个真正的 问题,这是我在某处看到的一个看起来相当简单的问题,但结果根本不是那么简单。我喜欢这类问题,所以我想看看其他人是怎么想的,所以它不满足我在问题中给出的非常严格的限制是完全可以的。 @NiklasB。我明白这不是你面临的真正问题,是在面试中给出的吗?我很好奇是否暗示存在满足原始约束的解决方案?我也喜欢这些问题,所以谢谢你给了我一些有趣的东西来思考:) 实际上是我的 ICPC 团队的成员在 G+ 上发布了它。再次见到他时,不得不问他是从哪里来的。问题文本或多或少正是我在问题中引用的文本。我怀疑O(n)/O(1) 的限制只适用于k = 3 的情况,如您所见,一般情况下没有给出具体的界限。 “如果bla bla怎么办?”是一个普遍的问题 当然,在我写问题的时候,我认为我的算法确实有效,所以我把它的复杂度作为一个上限。由于事实证明这是错误的,我愿意接受效率较低的解决方案:)【参考方案9】:

您的算法不是 O(n),因为不能保证在每一步中将数字分成两个相同大小的组,还因为您的数字大小没有限制(它们与 n 无关),您可能的步骤没有限制,如果您对输入数字大小没有任何限制(如果它们独立于n),您的算法运行时间可能是 ω(n),假设以下数字大小@ 987654323@ 位和它们的第一个 n 位可能不同: (假设m &gt; 2n

---- n bits --- ---- m-n bits --
111111....11111 00000....00000
111111....11111 00000....00000
111111....11110 00000....00000
111111....11110 00000....00000
....
100000....00000 00000....00000

您的算法将运行第一个m-n 位,并且在每一步中它将是O(n),直到现在您到达O((mn)*n),它大于O(n^2)。

PS:如果你总是有 32 位数字,你的算法是O(n),不难证明这一点。

【讨论】:

您的算法不是 O(nk),您可以在我的示例中看到这一点。我看到你写你的算法是 O(nk) 但你不能证明它,我提供一个样本来证明你的算法不是 O(nk)。但是,如果我能提供更好的算法,我会编辑我的答案,无论如何我认为我回答了你问题的隐含部分。事实上,找到 O(nk) 算法具有挑战性。 通常(我在写问题时的意思是这个),n 是输入的总大小(以位为单位),而不是元素的数量。那么您的分析没有多大意义,因为m 不能大于n。另外,我不是说我不能证明复杂性,我是说我不能证明正确性 @NiklasB.Normally 当我们说n 表示输入的数量而不是输入的大小,由于这种差异,我们可以将问题分为两类数字问题和其他问题(例如哈密顿路径与子集总和问题),并且在第一眼(和第二眼)中,您的问题并不清楚,无论如何,正如我所说,我会在闲暇时间考虑您的问题,如果可以的话,我会证明这是最好的算法,否则我会提供一个新算法,总而言之,放轻松。 很公平,我现在为这个问题添加了一个赏金,也许它会得到你自己或其他人的更多关注 :) 顺便说一下,实际上称为子集总和或背包的 DP 方法伪多项式,因为它们只是输入大小的多项式,您将输入编码为一元。严格来说,哈密顿路径和子集和都是 NP 完全的,并且最著名的算法在输入的大小上是指数的 另外,请注意我编辑了原始算法,因为它是错误的(我不知道当前版本是否也是)。【参考方案10】:

两种方法都可以。

(1) 创建一个临时哈希表,其中键是整数,值是数字 的重复。当然,这将使用比指定更多的空间。

(2) 对数组(或副本)进行排序,然后计算array[n+2]==array[n] 的情况。 当然,这会花费比指定时间更多的时间。

看到满足原始约束的解决方案,我会感到非常惊讶。

【讨论】:

1) 违反O(1) 空间要求。 2) 违反只读要求。 也违反了 O(n) 时间复杂度,哈希平均使用 O(1) 而不是最坏的情况。 对于 k = 3,这很有可能,正如我的代码所示。我认为O(log k * n) 在一般情况下也是可能的。 另外,这两种算法的效率都低于我提出的解决方案。其实我想要更好的东西。 “违反”确实,但跳过第 1 步会起作用并且会产生预期的结果。可能既不是 O(n) 时间也不是 O(1) 空间,但它是实用的并且适用于现实世界。

以上是关于在列表中找到 k 个不重复的元素,额外的空间“很少”的主要内容,如果未能解决你的问题,请参考以下文章

LeetCode 442.数组中重复的数据 - JavaScript

在递减然后递增并且可能包含重复项的列表中查找最小值

内联块 DIV 元素之间的额外空间 [重复]

占用太多空间的 Swiftui 小部件列表

计数排序是个啥?

LeetCode 448.找到所有数组中消失的数字 - JavaScript