各种遍历算法
通过叶子统计树上结点数
对于一颗有根树,如果所有非叶子结点都至少有两个孩子(等价的不存在度数为2的结点),那么我们就可以用叶子数来约束整个树的大小,这样的树我们称其为无杆树。记n表示叶子数,m表示非叶子数,实际上可以发现m≤n,因此总的树上结点数不超过2n。
证明非常简单,我们可以不断选择最深的一个叶子,将其父亲下的所有叶子全部删除。可以发现这样一次操作,至少会减少两个叶子,并出现一个新的叶子,因此总的叶子数至少减少了1,且重要的是操作后得到的新树依旧是无杆树。可以发现不断执行上面操作,直到只剩下一个根结点,我们总共最多执行n次操作,但是将m个非叶结点都转换成了叶子结点,因此有m≤n成立。但是如果我们将根也视作一个叶子的话,可以发现有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为置换的长度。依旧我们将每次调用视作树上的一个结点,可以发现深度为p和p−1的调用次数都是p!,而深度小于等于p−1的部分是形成了一颗无杆树。因此树上结点总数为O(p!)。而每个结点(每次函数调用)消耗的时间复杂度为O(p),因此总的时间复杂度为O((p+1)!)。
置换遍历
要遍历所有大小为n的置换,我们一般有递归和迭代两种方式。
递归方式可以用于处理全排列,时间复杂度为O(n⋅n!)。好处是可以计算的时候附带处理一些约束条件,以及计算一些汇总信息。递归方式适用于生成大量的排列的情况,而它计算单个排列(比如计算某个排列的后继)的最坏时间复杂度为O(n2)。这时候我们可以使用迭代的方式来优化。
迭代算法分成两类,计算某个置换的后继和前驱。我们先考虑后继,前驱版本可以逆向思维得到。
对于P,如何找到比P大且在这些置换中最小的置换呢T。我们应该保证P和T的公共前缀尽量长。因此我们要选择一个最短的后缀,通过调整后缀得到一个更大的置换。
很显然后缀是倒序的时候,我们不可能通过调整它得到更大的一个置换,考虑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)=∑n−1i=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=2n−1这样的集合的子集。之前没有注意,一直以为可以处理任意形式的子集,结果某次Topcoder比赛上用了这个板子,最终成功FST了。
下面讲一下具体如果找任意集合的所有k大子集。原本是希望通过改造Gosper's Hack
算法来实现,但是里面用到下整除的黑魔法,搞不太定。下面讲一下我的做法。
我们先考虑S=2n−1的形式,我们要从小到大枚举S的所有的k大子集。我们维护最低位1所在连通块的信息(如果两个1相邻,我们认为它们在一个连通块中),假设它们的范围从第l位开始,到第r位结束。
现在我们考虑要找到下一个k大子集。怎么找,根据取下一个置换的算法,实际上我们需要将第r+1位设置为1,将l到r位设置为0,之后将0到r−1−l位都设置为1。
由于只涉及一些二进制操作,实际上我们可以O(1)执行上面所有的操作。难点在于要维护l和r。如果r−1≥l,那么上面的流程后我们能很容易维护新的l′,r′。否则,新的l′=r+1,r′的话我们可以暴力从l′开始查找,直到找到第一个0位置。
上面涉及到暴力查找r′的过程是摊还O(1)的,可以认为我们每次我们右移一个1的时候(对应将r位置0,将r+1位置1),我们实际支付了两笔费用,一笔是移动的费用,一笔是支付后面用于扫描的费用。很显然一个被扫描的1之前一定被移动过,因此扫描的时候可以用预付的费用来支付。摊还时间复杂度为O(1)。
因此总的每次调用的时间复杂度均为O(1)。
现在你可能会问,这个算法和Gosper's Hack
解决的问题有什么不同吗?
解决的问题是一样的,但是这个新的算法很容易扩展。现在考虑任意的集合S,我们先将其中所有1出现的位置进行排序,记第i个1出现的位置为pi,并假设其中出现了n个1。之后我们维护另外一个集合T=2n−1。之后我们用上面提到的算法遍历T的所有k大子集t,同时我们为S维护对应的k大子集s。这样对所有t的操作,我们可以同步修改s,比如设置t的第i位,等价于设置s的pi位,而反转t的l,r区间,等价于我们让s先异或上2p1+…+2pr,之后再异或上2p1+…+2pl−1,这部分可以通过前缀和计算。
因此我们得到了一个预处理O(log2S),且O(1)计算下一个k大集合的算法。
实际上这个算法非常容易扩展,考虑我们需要同时计算得到的k大集合的一个摘要(比如给集合每个元素赋予一个权重,要算子集的权重和)。一般的做法会每次计算下一个k大集合的时候时间复杂度提高到O(k),但是实际上我们可以同步维护当前的摘要信息,只要它支持批量增加元素和批量删除元素。这样时间复杂度依旧不变,是O(1)。