各种遍历算法

Published: by Creative Commons Licence

  • Tags:

通过叶子统计树上结点数

对于一颗有根树,如果所有非叶子结点都至少有两个孩子(等价的不存在度数为2的结点),那么我们就可以用叶子数来约束整个树的大小,这样的树我们称其为无杆树。记n表示叶子数,m表示非叶子数,实际上可以发现mn,因此总的树上结点数不超过2n

证明非常简单,我们可以不断选择最深的一个叶子,将其父亲下的所有叶子全部删除。可以发现这样一次操作,至少会减少两个叶子,并出现一个新的叶子,因此总的叶子数至少减少了1,且重要的是操作后得到的新树依旧是无杆树。可以发现不断执行上面操作,直到只剩下一个根结点,我们总共最多执行n次操作,但是将m个非叶结点都转换成了叶子结点,因此有mn成立。但是如果我们将根也视作一个叶子的话,可以发现有m<n成立。

上面这个技术在证明树形递归(无环)算法时间复杂度的时候非常有用。考虑一个经典的递归枚举子集算法:

void gen(int[] bits, int i, int sum){
    if(i == -1){
        //组合已经生成完毕,做一些操作
        return;
    }
    gen(bits, i - 1, sum);
    bits[i] = 1;
    gen(bits, i - 1, sum + 1);
    bits[i] = 0;
}

将每次调用视作树上的一个结点,我们发现时间复杂度恰好是O(n+m)。这里可以发现形成的树是无杆树,而叶子总数仅有O(2s)个,其中s是全集的大小。因此总的时间复杂度为O(2s)。由于递归遍历的时候可以维护选中的子集的摘要信息,因此实际上比一般暴力循环的O(s2s)的时间复杂度会好上很多。

再考虑一个经典的递归枚举置换算法:

void gen(int[] perm, int i, boolean[] used) {
    if(i == -1){
        //排列已经生成,做些操作
        return;
    }
    for(int j = 0; j < used.length; j++){
        if(used[j]){
            continue;
        }
        used[j] = true;
        perm[i] = j;
        gen(perm, i - 1, used);
        used[j] = false; 
    }
}

p为置换的长度。依旧我们将每次调用视作树上的一个结点,可以发现深度为pp1的调用次数都是p!,而深度小于等于p1的部分是形成了一颗无杆树。因此树上结点总数为O(p!)。而每个结点(每次函数调用)消耗的时间复杂度为O(p),因此总的时间复杂度为O((p+1)!)

置换遍历

要遍历所有大小为n的置换,我们一般有递归和迭代两种方式。

递归方式可以用于处理全排列,时间复杂度为O(nn!)。好处是可以计算的时候附带处理一些约束条件,以及计算一些汇总信息。递归方式适用于生成大量的排列的情况,而它计算单个排列(比如计算某个排列的后继)的最坏时间复杂度为O(n2)。这时候我们可以使用迭代的方式来优化。

迭代算法分成两类,计算某个置换的后继和前驱。我们先考虑后继,前驱版本可以逆向思维得到。

对于P,如何找到比P大且在这些置换中最小的置换呢T。我们应该保证PT的公共前缀尽量长。因此我们要选择一个最短的后缀,通过调整后缀得到一个更大的置换。

很显然后缀是倒序的时候,我们不可能通过调整它得到更大的一个置换,考虑i为最大的下标,且满足Pi<Pi+1。我们先对Pi+1,,Pn进行排序(考虑到它们是倒序的,因此只要翻转即可)。之后我们将Pi和排序完的Pi+1,,Pn中最小的但是比Pi大的数交换,就可以得到后继了。

注意迭代算法是支持排列中出现相同的元素的。它计算单个排列的最坏时间复杂度为O(n)。但是当保证置换中不存在相同的值的情况下,我们可以得出更加好的平均时间复杂度,下面我们进行计算。

很显然单次操作的时间复杂度取决于具体翻转了多少个元素。可以发现如果我们当前操作翻转了最后t个元素,那么下一次翻转t或更多元素,必须在t!轮之后。即如果我们要找的是第k大的排列,那么这一轮中会翻转的元素等价于找到最大的一个i,满足i!k,这一轮会翻转i个元素,记i=f(k)

现在考虑k是均匀随机给定的,那么Pr(f(k)=i)=1i!1(i+1)!=i(i+1)!。那么单次翻转的期望费用可以写作E[ni=1(i+1)xi],其中xi表示当前轮是否翻转i个元素。答案为ni=1(i+1)Pr(f(k)=i)=n1i=01i!e。因此我们可以认为一次操作的平均时间复杂度为O(e)

迭代算法中前驱和后继的计算逻辑相似,都是翻转尾部,以及一次交换操作。所以可以得出相同的时间复杂度,这里不赘述。

k大子集遍历

所谓的k大子集是正好包含k个元素的子集。

k大子集遍历的算法有不少。最简单的方式就是使用一个包含k个1的置换,并使用置换遍历算法来枚举所有的,但是时间复杂度不太清楚。

还有一个叫做Gosper's Hack的算法。它的代码如下:

// find next k-combination
bool next_combination(unsigned long& x) // assume x has form x'01^a10^b in binary
{
  unsigned long u = x & -x; // extract rightmost bit 1; u =  0'00^a10^b
  unsigned long v = u + x; // set last non-trailing bit 0, and clear to the right; v=x'10^a00^b
  if (v==0) // then overflow in v, or x==0
    return false; // signal that next k-combination cannot be represented
  x = v +(((v^x)/u)>>2); // v^x = 0'11^a10^b, (v^x)/u = 0'0^b1^{a+2}, and x ← x'100^b1^a
  return true; // successful completion
}

上面的代码来自wiki

很显然它是O(1)的,原理我也没去了解。

但是这个算法只能处理形如S=2n1这样的集合的子集。之前没有注意,一直以为可以处理任意形式的子集,结果某次Topcoder比赛上用了这个板子,最终成功FST了。

下面讲一下具体如果找任意集合的所有k大子集。原本是希望通过改造Gosper's Hack算法来实现,但是里面用到下整除的黑魔法,搞不太定。下面讲一下我的做法。

我们先考虑S=2n1的形式,我们要从小到大枚举S的所有的k大子集。我们维护最低位1所在连通块的信息(如果两个1相邻,我们认为它们在一个连通块中),假设它们的范围从第l位开始,到第r位结束。

现在我们考虑要找到下一个k大子集。怎么找,根据取下一个置换的算法,实际上我们需要将第r+1位设置为1,将lr位设置为0,之后将0r1l位都设置为1

由于只涉及一些二进制操作,实际上我们可以O(1)执行上面所有的操作。难点在于要维护lr。如果r1l,那么上面的流程后我们能很容易维护新的l,r。否则,新的l=r+1r的话我们可以暴力从l开始查找,直到找到第一个0位置。

上面涉及到暴力查找r的过程是摊还O(1)的,可以认为我们每次我们右移一个1的时候(对应将r位置0,将r+1位置1),我们实际支付了两笔费用,一笔是移动的费用,一笔是支付后面用于扫描的费用。很显然一个被扫描的1之前一定被移动过,因此扫描的时候可以用预付的费用来支付。摊还时间复杂度为O(1)

因此总的每次调用的时间复杂度均为O(1)

现在你可能会问,这个算法和Gosper's Hack解决的问题有什么不同吗?

解决的问题是一样的,但是这个新的算法很容易扩展。现在考虑任意的集合S,我们先将其中所有1出现的位置进行排序,记第i1出现的位置为pi,并假设其中出现了n1。之后我们维护另外一个集合T=2n1。之后我们用上面提到的算法遍历T的所有k大子集t,同时我们为S维护对应的k大子集s。这样对所有t的操作,我们可以同步修改s,比如设置t的第i位,等价于设置spi位,而反转tl,r区间,等价于我们让s先异或上2p1++2pr,之后再异或上2p1++2pl1,这部分可以通过前缀和计算。

因此我们得到了一个预处理O(log2S),且O(1)计算下一个k大集合的算法。

实际上这个算法非常容易扩展,考虑我们需要同时计算得到的k大集合的一个摘要(比如给集合每个元素赋予一个权重,要算子集的权重和)。一般的做法会每次计算下一个k大集合的时候时间复杂度提高到O(k),但是实际上我们可以同步维护当前的摘要信息,只要它支持批量增加元素和批量删除元素。这样时间复杂度依旧不变,是O(1)