组合问题在日常生活中随处可见,先来看一个摘于《离散数学》(第六版,Richard Johnsonbaugh著)的例子:
摇滚乐队“Unhinged Universe”录制了n段视频节目,时间长度分别为t1 ,t2 ,t3 ,…,tn 秒。一盘磁带可以容纳C秒的视频。这是Unhinged Universe发行的第一盘磁带,乐队希望这盘磁带能尽可能多的收入他们的视频节目。问题转化为从{1,2,3,...,n}中选出一个子集{i1 ,i2 ,i3 ,…,ik } 使在∑k j=1 tij 不超过C的情况下尽可能大。最直接的算法是穷举{1,2,3,...,n}的所有子集,选出使上式在不超过C的情况下最大的子集。该算法需要生成n元素集合的所有组合。
生活中有很多像Unhinged Universe这样的例子,我们可能并不总是在整数集合上生成组合,为了解决这些问题,我们需要生成n元素集合X的所有组合,至于X中的元素并不总是整数,它们可以是任何东西。
问题看来是那么熟悉,也许你立刻就能给出一个生成X上所有组合的思路:遍历X中的所有元素,每遇到一个元素视为一步,每步都作出两种选择,将该元素加入到正在生成的组合中或不将其加入这个组合。这样,生成所有组合的过程就是作出n步选择的过程。由乘法原理得,n步选择会让我们得到2n 种可能的情况,这正好是X子集的总数。这个想法着实不错,那就开始实现吧,每作出一次选择问题的规模就会减一,这也许会让你想的递归,下边给出递归算法的伪代码:
/************************************************************************* 算法1 组合生成算法:生成集合{Sk,Sk+1,...,Sn}上的所有组合,每个加以前缀a。 若生成{S1,S2,...,Sn}上的所有组合,则调用combination_rc(S,1,n,"")。 输入:S,k,n,字符串a 输出:生成集合{Sk,Sk+1,...,Sn}上的所有组合,每个加以前缀a **************************************************************************/ combination_rc(S, k, n , a) { if(n == 0) { println(a); return; } //打印包含Sk的组合 b = a + "" + Sk; //字符串连接 combination_rc(S, k+1, n-1, b); //打印不包含Sk的组合 combination_rc(S, k+1, n-1, a); }
递归函数总是带来一些让我们无法接受的额外开销,一般需要提供一个非递归的版本,这可以使用辅助栈和辅助队列来实现,这里只提供一种使用辅助栈实现的思路。定义两个栈用以存放结果,同一时间只有一个栈是有效的。开始时向一个栈中压入一个空集合,并置该栈有效。然后遍历X中的元素,每遇到一个元素Xi就依次弹出当前有效栈中的所有元素,每弹出一个元素,就把它压入当前无效栈,再把Xi加入到刚弹出的栈元素所代表的X的一个子集中,并把新子集压入当前无效栈,然后反置栈的有效和无效标志。这样重复n次,当前有效栈中的元素就是X上所有的组合。分析算法1及它的非递归版本,他们的时间复杂度都是 θ(2n ) ,符合组合问题的固有复杂度。虽然递归算法在构建栈和反解栈时开销很大,但我们的非递归版本可以很好的解决问题。Unhinged Universe乐队一定会很满意。
故事还没有结束,后来Unhinged Universe乐队决定,要在这盘磁带中收入r个视频节目。现在问题变为了求解n元素集合X的所有r组合,我们可以简单的剔除算法1结果中非r元素子集来得到新问题的解,删除操作需要进行 2n -C(n,r) 次。问题似乎有得到了解决。
但这次Unhinged Universe乐队可能会抱怨了。他们也许明白我们现在得到的候选解较之以前少了 2n -C(n,r) 个,但却耗掉的和原来一样甚至更长的时间。也许他们会问,生成 2n 个候选解的时间复杂度为 θ(2n ) ,那为什么生成C(n,r)个候选解的时间复杂度不可以是θ(C(n,r))?
迫于压力,你开始修改算法1,以使该算法能尽早发现并结束对不可能产生最终解情况的计算,算法可能像是这样:
/*************************************************************************** 算法2 组合生成算法:生成集合{Sk,Sk+1,...,Sn}上的所有r组合,每个加以前缀a。 若生成{S1,S2,...,Sn}上的所有r组合,则调用r_combination_rc(r,S,1,n,"")。 输入:r,S,k,n,字符串a 输出:生成集合{Sk,Sk+1,...,Sn}上的所有r组合,每个加以前缀a ****************************************************************************/ r_combination_rc(r, S, k, n , a) { if(r == 0) { println(a); return; } if(r ==n ) { println(a, Sk, Sk+1, ..., Sn); return; } else if(n < r) { return; } //打印包含Sk的r组合 b = a + "" + Sk; //字符串连接 r_combination_rc(r-1, S, k+1, n-1, b); //打印不包含Sk的r组合 r_combination_rc(r-1, S, k+1, n-1, a); }
算法执行过程中,当出现n 让我们放弃以前的思路,从长计议,排序可以使问题得到化简。 首先让我们给出一些假设。假设R是集合X上的一个偏序关系,则R可以定义集合X上元素的序。换言之,我们可以使用R对X中的元素排序。此处规定:若x∈X,y∈X,x≠y且xRy,则x排在y的前边,记作x 有了上边这些假设,我们的新思路就很清晰了。因为集合Y是有限的且存在关系Q,可得Y是良序的,即可以在Y中找到一个最小的串a。又由关系Q可以确定的求出Y中任何一个非最大串b的后续c。这样,从a开始重复进行C(n,r)次对c的求解,就可以得到Y中的所有元素,即X上的所有r组合。 也许上边的描述让你迷惑,让X={1,2,...,n}是个不错的想法,这个整数集合上的特化版本会让你更好的理解思路。这里之所以把X定义在整数集合上,主要原因是将问题域定义在整数集合更具有代表性。因为X可以是任何东西的集合,所以其上关系R的确定多样且通常并不容易。此时我们可以简单的使用集合X中各元素的下标来代替真正的元素(虽然数学意义上的集合是无序的,也就不存在下标;但当在计算机中存储集合时,却总是按某一顺序进行的,计算机中存储的集合是一个序列,此处我们假设这个序列以下标1开始),求出这些下标的组合后,把下标替换为相应的元素即可。现在我们只要使用整数集合上的二元关系“小于等于”来定义R,就可以解决所有的问题。在给出算法前我们还差点什么呢?对,我们需要定义关系Q。这里使用字典序来定义Q,即把字典中的有序字母集替换为有序整数集{1,2,...,n}(该整数集的序由其上的二元关系“小于等于”关系来定义),这样我们就把单词在字典中的顺序推广为定义在整数集{1,2,...,n}上的字典序,其定义如下: 定义1 设a= S1 S2 …Sp 和b= T1 T2 …Tq 为定义在{1,2,...,n}上的字符串。称在字典序中a在b之前,当且仅当(1)或(2)成立,记为a 1)p 所有的准备工作都已完成,下面让我们来看怎样确定串b的后续c。假设b= S1 S2 …Sr ,c= T1 T2 …Tr ,在串b中从右向左找到第一个非最大值的元素 Sm ( Sr 的最大值为n, Sr-1 的最大值为n-1,依此类推),则: Ti =Si , i=1,2,…,m-1 Tn =Sm +1 Tm+1 Tm+2 …Tr =(Sm +2)(Sm +3)… 一切都已明了,终于迎来了我们的算法: /*************************************************************************** 算法3 按照字典序生成n元素集合{1,2,...,n}上的所有r组合。 输入:r,n 输出:按照字典序生成n元素集合{1,2,...,n}上的所有r组合 ****************************************************************************/ r_combination_n(r, n) { for i = 1 to r Si = i; println(S1, S2, ..., Sr); //打印第一个r组合 for i = 2 to C(n,r) { m = r; max_val = n; while(Sm == max_val) { //从右向左找到第一个非最大值的元素 m = m - 1; max_val = max_val - 1; } //将从右向左第一个非最大值的元素加一 Sm = Sm + 1; //Sm之后的元素一次递增 for j = m+1 to r Sj = Sj-1 + 1; println(S1, S2, ..., Sr); //打印第i个组合 } } 我们的新算法好高效的解决了问题,同时也证明了生成n元素集合上的r组合的时间复杂度为O(C(n,r))。对比前边的两种方法,算法2像一个莽夫,它盲目的尝试将一个元素加入一个中间集合或者不加入其中,直到发现这个中间集合不可能得到任何最终解时,才停止对该集合的进一步计算;而算法三不存在这样的尝试,像一位智者,它永远都那么明确的生成下一个解,而且它知道下一个解一定是一个要求的r组合。2 <…,且 {x1 ,x2 ,…,xr }={S1 ,S2 ,…,Sr } 。并且假设集合Y是由所有的字符串S组成。接着,我们再定义一个对X上字符串 S1 S2 …Sr 排序的偏序关系Q,在关系Q中,当需要对X中的元素排序时,使用关系R。r Si =Ti ,i=1,2,...,p。
2)i为使 Si ≠ Ti 成立的最小的i,且 Si
整个过程中我们所做的就是把组合问题化简为排序问题,而我们得到的是无与伦比的效率。