在使用“少量”额外空间的情况下,在列表中找到k个不重复的元素。

26
原始问题陈述如下:
给定一个32位无符号整数数组,其中每个数字都出现两次,除了其中三个数字只出现一次。在O(n)时间内使用O(1)额外空间找到这三个数字。输入数组是只读的。如果有k个例外情况而不是3个呢?
如果您接受由于输入限制(数组最多可以有2^33个条目)而导致非常高的常数因子,则可以很容易地在Ο(1)时间和Ο(1)空间内解决此问题。
for i in lst:
    if sum(1 for j in lst if i == j) == 1:
        print i

因此,为了回答这个问题,让我们放弃位数限制,集中在更一般的问题上,其中数字可以具有多达m位。 对k = 2进行算法泛化,我考虑以下内容:
  1. 将那些最低有效位为1和那些为0的数字分别进行异或。如果对于两个分区,结果值都不为零,则我们知道我们已将非重复数字分成了两组,每组至少有一个成员。
  2. 对于每个分组,尝试通过检查第二位最不显著的位等来进一步分区。
但是需要考虑一种特殊情况。如果在对组进行分区后,其中一个组的异或值都为零,我们不知道结果子组是否为空。在这种情况下,我的算法会跳过这一位并继续下一位,这是不正确的,例如它无法处理输入[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是输入元素的数量(XOR运算的复杂度为O(m),并且最多可以成功进行k次分区操作),空间复杂度为O(m²)(因为m是最大递归深度,临时数字可能具有长度m)。
当然问题在于是否有一种正确、高效的方法具有良好的渐进运行时间(假设这里的k << nm << n完整性的缘故),同时需要很少的额外空间(例如,排序输入的方法不会被接受,因为我们至少需要O(n)的额外空间,因为我们不能修改输入!)。
编辑:现在证明上述算法是不正确的,当然希望看到它如何能够变得正确,可能需要降低一些效率。空间复杂度应该小于o(n*m)(即,总输入位数的亚线性)。如果这使任务更容易,可以将k作为附加输入。

“除了其中三个” - 这是否意味着这三个数字出现的次数不是 2,而是可以是 1、3、4、5 等任意次数? - Xyand
@NiklasB。我同意你的推理,但我会颠倒它。虽然从技术上讲由于有限边界,时间复杂度为O(1),但我认为因为2^32 >= N,所以声称你的解决方案是O(N^2)是合理的。就像在这个领域中,O(2**32N) >= O(N^2) [稍微滥用一下O符号]。 - cmh
1
哦,如果管理员看到这个:我认为回答者应该因他们的回答而获得声望,所以如果有人能取消此问题的社区维基状态,那就太好了! - Niklas B.
我刚刚发布了一个适用于k = 3情况的合适解决方案,也许有人想将其扩展到一般情况。 - Antti Huima
@Tyrone:你为什么删除了你的答案?它看起来很棒啊! - Niklas B.
显示剩余5条评论
10个回答

10

我离线并证明了原算法受到异或技巧的猜想。事实上,异或技巧不起作用,但以下论点仍可能对某些人有兴趣。(我重新使用Haskell进行了计算,因为当我使用递归函数而不是循环和数据结构时,我发现证明要容易得多。但是对于观众中的Pythonistas,我尽可能使用列表理解。)

可编译的代码位于http://pastebin.com/BHCKGVaV

美丽的理论被丑陋的事实击败

问题:我们在一个非零32位单词序列中给定每个元素都是singletondoubleton

  • 如果一个单词恰好出现一次,则它是singleton

  • 如果一个单词恰好出现两次,则它是doubleton

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

问题是查找singleton。如果有三个singleton,我们应该使用线性时间和常数空间。更一般地,如果有ksingleton,我们应该使用O(k*n)时间和O(k)空间。该算法基于关于异或的未经证明的猜想。

我们从以下基本知识开始:

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

关键抽象:单词的部分规范

为了解决问题,我将介绍一个抽象概念:为了描述32位单词中最不重要的$w$位,我引入了一个Spec

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

如果最低有效位为bits,则Spec与单词匹配。 如果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 }

以下是有关Spec的一些声明:

  • 所有的单词都匹配于具有宽度为0的universalSpec

  • 如果匹配spec word并且width spec == 32,那么word == bits spec

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

这是该算法的关键思想:我们可以通过增加另一个位来扩展 Spec。扩展Spec会产生两个Spec列表。

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

关键的声明如下:如果specword匹配,且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 函数。

XOR 错误的想法

xorWith f ws 函数应用于 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。我可以通过添加第三个函数g使Evgeny的示例效果正常,但显然这种情况需要证明,而我没有证明。)

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

高效搜索单例

我们的主要函数返回一个匹配特定条件的所有单例对象列表。

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 < 32$。(这与归纳的通常方向相反,但无关紧要。)

    这里出现了问题:对于较窄的宽度,即使给出单独的数组,hasSingleton可能仍然返回False。这很悲惨。

    宽度为mspec调用extend spec会返回两个宽度为$m+1$的spec。根据假设,singletonsMatching在这些specs上是正确的。需要证明的是:结果仅包含与spec匹配的那些单例。根据引理一,与spec匹配的任何单词都恰好与扩展的specs中的一个匹配。根据假设,递归调用返回完全匹配扩展specs的单例。当我们将这些调用的结果与concat组合时,我们得到完全匹配的单例,没有重复项和省略项。

实际解决问题有点无聊:所有与空spec匹配的单例都是单例:

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

以下是另一个属性,用于测试使用排序算法的较慢算法与快速的单例算法。

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

事实上,我比喜欢Python更喜欢Haskell :) 这篇文章看起来非常有趣,我迫不及待地想读它了。 - Niklas B.
首先,非常感谢您提供的这些有用的见解,教我如何处理这类形式证明。由于我计划很快为一家使用Haskell进行生产的公司工作,因此这对我尤其有用,即使我的直觉和测试结果与这个特定算法不符。 - Niklas B.
顺便提一下,我的算法假设,Evgeny已经证明是错误的,比你在这里表述的要稍微强一些。它更像是“如果一个值组包含多个单例,则对于至少一个位位置,在该位置上按其各自位进行分组将导致这样一种情况:通过检查两个XOR操作的结果,我们可以确定两个分区都不为空。” - Niklas B.
因此,“Spec”数据类型会变得稍微复杂一些,因为它指定的位块不必是连续的。但事实证明,这并不能确保正确性,所以这已经不再重要了 :) - Niklas B.

8

k >= 7的OP算法不成立

这个算法利用了一种可能性,即使用单个比特位的值将一组k个唯一值递归地分成两组,当其中至少一组与非零值进行异或运算时。例如,以下数字:

01000
00001
10001

可能分成

01000

并且

00001
10001

使用最低有效位的值。
如果正确实施,这适用于 k <= 6。但是,对于 k = 8 和 k = 7,此方法失败。假设 m = 4 并使用从 0 到 14 的 8 个偶数:
0000
0010
0100
0110
1000
1010
1100
1110

每个比特位,除了最低有效位外,都有恰好4个非零值。如果我们试图对这个集合进行分区,由于这种对称性,我们总会得到一个具有2个或4个或0个非零值的子集。这些子集的异或总是为0。这不允许算法进行任何分割,因此“else”部分只打印所有这些唯一值的异或(一个单独的零)。
“3x + 1”技巧没有帮助:它只是重新排列这8个值并切换最低有效位。
如果我们从上述子集中删除第一个(全零)值,则完全相同的论据也适用于k = 7。
由于任何一组唯一值都可以分成一个包含7个或8个值的组和另一些组,因此该算法对于k>8也失败了。

概率算法

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

每次算法访问输入数组的一个元素时,它应该将某些变换函数应用于此元素:y=transform(x)。这个转换后的值y可以像原始算法中使用x一样使用——用于划分集合和异或值。

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

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

每次使用不同的哈希函数计算的后续步骤可以在原始的k值集上执行,也可以在先前步骤中找到的每个子集上分别执行(更好)。
要获得算法每个步骤的不同哈希函数,可以使用通用哈希。哈希函数的一个必要属性是可逆性-原始值理论上应该能够从哈希值中重构出来。这是为了避免几个“唯一”的值被散列到相同的哈希值中。由于使用任何可逆的m位哈希函数都很难解决“反例”问题,因此哈希值应该比m位长。这样的哈希函数的一个简单示例是连接原始值和该值的某个单向哈希函数。
如果k不是非常大,那么我们不太可能得到类似于那个反例的数据集。 (我没有证明没有其他具有不同结构的“坏”数据模式,但让我们希望它们也不太可能)。在这种情况下,平均时间复杂度不会比O(k * m2 * n)大多少。

原算法的其他改进

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

针对k = 3的OP算法证明

这不是针对OP实际程序的证明,而是针对其思想的证明。目前的实际程序在结果子集之一为零时会拒绝任何分割。请参见建议改进,以了解我们可以接受某些这样的分割的情况。因此,以下证明仅适用于该程序,当如果x为None或y为None被更改为考虑子集大小的奇偶性的某个条件或添加预处理步骤以从数组中排除唯一的零元素后。

我们有三个不同的数字。它们应该至少在2位位置上不同(如果它们只在一个位上不同,则第三个数字必须等于其中之一)。循环在solve函数中找到这些位位置中最左边的一个,并将这3个数字分成两个子集(一个单独数字和两个不同数字的子集)。这个2个数字的子集在这个位位置上具有相等的位,但数字仍然应该不同,所以应该有一个更多的分割位位置(显然,在第一个位的右边)。第二个递归步骤轻松地将这个2个数字的子集分成两个单个数字。i * 3 + 1的技巧在这里是多余的:它只会使算法的复杂度翻倍。

这里是一个关于一组三个数字中第一个分割的示意图:
 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,这意味着我们不能在这些位上拆分数字。但是我们有三个非相等的数字,这意味着在某个位置(1)我们应该有不同的位(x和y)。其中一个(x)可以在我们的两个数字中找到(偶数大小的子集!),另一个(y)-在一个数字中。让我们看看这个偶数大小的子集中的值的异或。从A和B选择包含位置1处比特0的值(C),但C只是两个非相等值的异或。它们在位位置1处相等,因此它们必须在至少一个更多的位位置(位置2,比特a和b)上不同。因此C!= 0并且它对应于偶数大小的子集。这个拆分是有效的,因为我们可以通过非常简单的算法或该算法的下一个递归进一步拆分这个偶数大小的子集。
如果数组中没有唯一的零元素,则可以简化此证明。我们总是将唯一的数字分成两个子集-一个具有2个元素(它不能异或为零,因为元素不同),另一个具有一个元素(根据定义非零)。因此,原始程序经过少量预处理后应正常工作。
复杂度为O(m2*n)。如果您应用我之前建议的改进,该算法扫描数组的预期次数为m/3+2。因为第一个分割位位置预计为m/3,需要单独扫描2个元素子集,每个1个元素子集不需要任何数组扫描,并且最初需要进行一次扫描(在solve方法之外)。

证明k = 4 .. 6的算法

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

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

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


确定性算法

k >= 7 的反证法表明了原始算法无法处理的模式:我们有一个大小大于2的子集,在每个位位置上都有偶数个非零位。但我们总是可以找到一对位置,其中非零位在单个数字中重叠。换句话说,在大小为3或4的子集中,始终可以找到一对位置,其中两个位置中所有位的异或值均为非零。这提示我们使用一个额外的分割位置:通过两个单独的指针遍历位位置,在这些位置上将数组中的所有数字分成两个子集,其中一个子集具有这些位置上的两个非零位,而另一个子集则包含所有其他数字。这增加了最坏情况下的复杂度 m,但允许更多的 k 值。一旦不能再获得小于5的子集,添加第三个“分裂指针”,依此类推。每次 k 加倍时,我们可能需要一个额外的“分裂指针”,这会再次增加最坏情况下的复杂度 m

这可能被认为是以下算法的一个证明草图:

  1. 使用原始(改进后的)算法找到零个或多个独特值和零个或多个不可分割子集。当没有更多的不可分割子集时停止。
  2. 对于任何这些不可分割子集,尝试增加“分裂指针”的数量进行拆分。当找到拆分时,请继续执行步骤1。

最坏情况下复杂度为O(k * m^2 * n * m^(max(0, floor(log(floor(k/4))))), 可以近似表示为O(k * n * m^log(k)) = O(k * k^log(m)).

对于小k而言,该算法的预期运行时间略微劣于概率算法,但仍然不比O(k * m^2 * n)大太多。


@NiklasB:我认为,使用XOR的方法可能有效,但复杂度可能大于O(k * m * n)。 - Evgeny Kluev
1
@NiklasB .:关于3x + 1部分的更多细节:将{0、2、4、6、8、10、12、14}乘以3(并丢弃溢出位)后,我们得到了{0、6、12、2、8、14、4、10} - 正好是相同的值转置。再次添加任何常数(并且丢弃溢出位),这些数字再次被混洗(可能切换最低有效位)。因此问题保持不变。 - Evgeny Kluev
谢谢Evgeny,我现在明白了 :) 仍然不确定你最初是如何想到使用那些数字的,但无论如何都很有趣。 - Niklas B.
1
@NiklasB:我有一个使用这些数字的直接方法的想法。起初,我让自己相信k=3可以正常工作,然后我试图证明k=4并发现它很困难。然后我假设对于更大的k,它可能从“困难”变为“不可能”。当寻找“不可能”的东西时,我立即得到了这些数字,不太清楚为什么,可能是因为这个子集的对称性。 - Evgeny Kluev
@NiklasB:新增了一项改进,以及更多的证明和确定性算法(但其最坏情况复杂度肯定大于您想要的)。 - Evgeny Kluev
显示剩余11条评论

6
一种概率性的方法是使用计数过滤器
算法如下:
1. 线性扫描数组并“更新”计数过滤器。 2. 线性扫描数组并创建一个包含所有在过滤器中不确定为计数为2的元素的集合,这将是实际解决方案中的<= k个(在这种情况下,误报警率是看起来不是这样的唯一元素)。 3. 选择一个新的哈希函数基础并重复,直到我们获得所有k个解决方案。
这使用2m位空间(与n无关)。时间复杂度更加复杂,但是知道任何给定的唯一元素在步骤2中未被找到的概率约为(1 - e^(-kn/m))^k,我们将很快得出解决方案,但遗憾的是我们不完全是线性的n

我很感谢您的理解,但是考虑到原始条件可能无法满足,这种方法可能值得考虑,尽管它在时间上是超线性的,且具有概率性。


@NiklasB。我很感激你所面临的不是一个真正的问题,这是在面试中提出的吗?我很好奇是否有暗示原始约束条件下存在满足条件的解决方案?我也喜欢这些问题,所以谢谢你给我提供了一些有趣的思考题 :) - cmh
实际上,我的ICPC团队的一名成员在G+上发布了它。我必须尽快再次见到他时问他它来自哪里。问题文本与我在问题中引用的几乎完全相同。我怀疑O(n)/O(1)限制仅适用于k = 3的情况,对于一般情况没有给出具体限制,正如你所看到的那样。“如果bla bla怎么办?”是一个普遍性的问题。 - Niklas B.
你的算法是否实际上只使用了每个布隆过滤器计数器的最低有效位(换句话说,它只是为每个元素在位数组中切换一些位)?如果是这样,那么你不仅会得到误报(当“重复”元素的位与“唯一”元素的位重合时),还会得到误拒(当“唯一”值的位相互熄灭时)。那么误拒并不是一个问题,但是如何处理大量的误报而不使用O(n)空间呢? - Evgeny Kluev
不,它严格地计数;我最初考虑了切换最低有效位,但得出了相同的结论。在这种情况下,误报是独特的元素,它们似乎是非唯一的(它们所有哈希的最小值为2)。没有假阴性。 - cmh
我的m和问题中的m不同,只是另一个与n无关的变量。对于造成的困惑我表示抱歉。 - cmh
显示剩余7条评论

1

这里有一个适用于 k = 3 的合适解决方案,它只需要最小的空间,空间要求为 O(1)。

让“transform”成为一个函数,它以 m 位无符号整数 x 和索引 i 作为参数。i 在 0 .. m - 1 之间,并且 transform 将整数 x 转换为

  • 如果 x 的第 i 位未设置,则为 x 本身
  • 对于 x ^ (x <<< 1),其中 <<< 表示桶移位(旋转)

在下面使用 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'',c' == c XOR c'',即让a''表示a和a'的异或等等。因为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)对于所有有效值的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 的问题。 - Niklas B.
@attini:我的意思是问题中的那个。很容易证明它对于k = 3是正确的(但我同意我应该更清楚地表达...抱歉)。你得到了我的赞成票 :) - Niklas B.
嗯,奇怪的是,因为你提到你的算法在输入[0,1,2,3,4,5,6]上失败了,但是根据问题陈述,这个输入是非法的(所有数字都不同)。 - Antti Huima
1
如果我理解正确的话,这个程序的运行时间是O(nm^2 + m2^(2m))。这里的^表示指数,而不是异或。对于32位数字来说,这将超过几千年的时间:( - Evgeny Kluev
1
@antti: [0,1,2,3,4,5,6] 是一个有效的输入,没有重复项且有7个“单例”。输出应为输入。 - Niklas B.
显示剩余2条评论

1

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

但是这里有一个具有较轻空间要求的概率算法。

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

再次扫描数组时,我们可以提取唯一值(排除一些错误负面效应)以及一些重复值(错误正面效应)。

位集应足够稀疏,以尽可能减少误报,从而减少不必要的重复值的数量,因此减少空间复杂度。位集高稀疏度的附加好处是减少误拒的数量,这会稍微提高运行时间。

为确定bitset的最佳大小,在bitset和包含唯一值和误报(假阳性)的临时数组之间均匀分配可用空间(假设k << n): s = n * m * k / s,其中给出s = sqrt(n * m * k)。预期的空间要求为O(sqrt(n * m * k))。
1. 扫描输入数组并在bitset中切换位。 2. 扫描输入数组并过滤具有bitset中相应非零位的元素,将它们写入临时数组。 3. 使用任何简单方法(分布式排序或哈希)从临时数组中排除重复项。 4. 如果临时数组的大小加上已知的唯一元素数量少于k,则更改哈希函数,清除bitset并切换位,与步骤1继续。

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


又一个很棒的建议 :) 你似乎很喜欢这个问题 :P - Niklas B.
@cmh:如果我错了,请纠正我,但是对于计数过滤器解决方案(在您的答案中描述),使用sqrt(n * m * k)个计数器,每个计数器的期望值为sqrt(n / (m * k))。对于大的n,我们很少有机会看到任何值为1的计数器。这意味着输入数组需要进行太多次重新扫描,因此速度应该会慢得多。 - Evgeny Kluev
在计数过滤器中,我们只需要其中一个k个哈希值等于1。但是使用您的切换解决方案,每当它超过1(%2)时都会出现错误的负/正结果。 - cmh
让我们使用一些实际数字:n=1000000000,m=k=32,计数过滤器大小为1000000,预期计数器值为1000*哈希数量。这1000000个计数器中有任何一个计数器的值为1的概率是多少?使用相同的参数,切换解决方案仅具有32000个误报,并且几乎没有假阴性的机会(这意味着数组只需扫描2次)。 - Evgeny Kluev
在计数过滤器示例中,过滤器的大小需要相应增大。请注意,虽然您减少了错误的否定结果,但是允许了比计数过滤器解决方案更多的错误的肯定结果(计数过滤器不允许任何错误的肯定结果),因此您的成本出现在第三步。 - cmh
显示剩余2条评论

1

我假设你事先知道k的含义。

我选择Squeak Smalltalk作为实现语言。

  • inject:into:是reduce,占用空间O(1),时间复杂度O(N)。
  • select:是filter,但由于空间要求是O(1),我们不使用它。
  • collect:是map,但由于空间要求是O(1),我们不使用它。
  • do:是forall,占用空间O(1),时间复杂度O(N)。
  • 方括号中的块是闭包,如果它不捕获任何变量并且没有使用return,则是一个纯lambda函数,冒号前缀的符号是参数。
  • ^表示返回。

对于k=1,我们通过对序列进行位异或操作来得到单例。

因此,我们在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),即两个单例的异或

在sum的最低位拆分,并像您提出的那样对两个序列进行异或,您将得到这两个单例

    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实现进行检查

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


嗯,我的算法已经适用于 k <= 6,正如 Evgeny 已经证明的那样(证明实际上非常简单)... 我更感兴趣的是一般情况。我喜欢那种语言,虽然以前从未见过可行的 Smalltalk 代码 :P - Niklas B.
你对编程语言的品味非常有趣! - Niklas B.
我重构了代码,使其成为递归形式,并将递归扩展到k=5(但不是通用的),并提交到http://ss3.gemstone.com/ss/SONiklasBContest.html。网络界面不是浏览代码的额外方式,但如果您下载.mcz文件,它实际上是一个.zip文件。 - aka.nice

0

你的算法不是O(n),因为没有保证在每一步中将数字分成两个相同大小的组,而且由于你的数字大小没有限制(它们与n无关),所以你的可能步骤没有限制,如果你的输入数字大小没有任何限制(如果它们与n无关),那么你的算法运行时间可能是ω(n)。假设下面的数字大小为m位,只有它们的前n位可能不同: (假设m > 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((m-n)*n) ,这比 O(n^2) 更大。

PS:如果你始终使用32位数字,那么你的算法是 O(n) 的,而且很容易证明这一点。


你的算法不是O(nk),你可以从我的样例中看出来。我看到你写了你的算法是O(nk),但你无法证明它,我提供一个样例来展示你的算法不是O(nk)。但如果我能提供一个更好的算法,我会编辑我的答案,无论如何,我认为我回答了你问题的隐含部分。事实上,找到O(nk)算法是具有挑战性的。 - Saeed Amiri
通常(我在写问题时是这个意思),n 是输入的总位数,而不是元素数量。因此,您的分析并没有太多意义,因为 m 不能大于 n。另外,我并没有说我无法证明复杂性,我说我无法证明正确性。 - Niklas B.
通常我们说 n 指的是输入数量而不是输入大小,因为这种差异,我们可以将问题分为两类:数字问题和其他问题(例如哈密顿路径与子集求和问题),在第一(和第二)次看到您的问题时并不清楚。无论如何,我会在闲暇时间思考您的问题,如果可能的话,我会证明这是最佳算法,或者我会提供一个新的算法。总之,放松心态。 - Saeed Amiri
好的,我现在已经为这个问题添加了赏金,也许它会得到更多的关注,无论是来自你还是其他人 :) 顺便说一下,子集和或背包问题的DP方法实际上被称为伪多项式,因为只有在你将输入编码为一元时才是多项式。严格来说,哈密顿路径和子集和都是NP完全问题,目前已知的最佳算法在输入规模上呈指数级增长。 - Niklas B.
另外,请注意我编辑了原始算法,因为它有缺陷(而且我不知道当前版本是否也有)。 - Niklas B.

0

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

例如,对于范围[0,m)中的每两个位(x,y),考虑由a&((1<<x)||(1<<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";
    }
}

最坏情况下,k和分区数量之间的关系是O(k/m * k^log(m))。详情请参考我的答案。 - Evgeny Kluev
是的,这实际上是Evgeny在他的答案中分析的相同想法(也是我所拥有的想法,但我认为可能还可以做得更好)。 - Niklas B.

-1

解决前一个问题(在O(N)时间内使用O(1)内存查找唯一的uint32数字)的方案非常简单,尽管速度不是特别快:

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空间分成带状区域。然后,您需要遍历所有带状区域,并在每个带状区域内使用位向量技术查找唯一值。

是的,我想我在问题中提到了这一点(请参见第一个代码示例) - Niklas B.
@NiklasB,不,空间使用量不是N的函数,而是M的函数。 - salva
这很好,但它的空间复杂度是Ω(n),远非最优。 - Niklas B.
n <= 2*2^m 可以得出 2^m = Ω(n) - Niklas B.

-4

有两种方法可以解决问题。

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

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

我会非常惊讶地看到一个满足原始约束条件的解决方案。


4
  1. 违反了 O(1) 的空间要求。
  2. 违反了只读(read only)的要求。
- cmh
2
同样违反O(n)时间复杂度,哈希平均情况下使用O(1),最坏情况除外。 - Saeed Amiri
当k = 3时,非常有可能,正如我的代码所示。我认为在一般情况下 O(log k * n) 也是可能的。 - Niklas B.
同时,这两个算法的渐进复杂度比我提出的解决方案还要低。实际上,我想要更好的东西。 - Niklas B.
确实会“违反规定”,但跳过第一步也可以工作并产生所需的结果。可能不是O(n)时间或O(1)空间,但它是务实的,在现实世界中起作用。 - smirkingman

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