【编程珠玑】【第二章】编程求解组合问题
组合问题
以下两个题目是等价的:
题目1:输入一个字符串,输出该字符串中字符的所有组合。举个例子,如果输入abc,它的组合有空、a、b、c、ab、ac、bc、abc。
题目2:打印一个集合所有的子集和,比如{a,b,c}的子集和有{a},{b},{c},{a,b},{a,c},{b,c},{a,b,c}以及空集{}。
方法一、递归求解给定集合的全组合n!。
之前我们讨论了如何用递归的思路求字符串的全排列,同样,本题也可以用递归的思路来求字符串的全组合。
1、算法思路:
具有三个元素的集合{a,b,c}
1:{a,b,c}的子集 = {a,b}的所有子集中加入c元素 + {a,b}本身的所有子集。
2:{a,b}的子集 = 则{a}的所有子集中加入b元素 + {a}本身的所有子集。
3:{a}的子集 = {}和{a}
如:{a, b}的所有子集为{}, {a}, {b}, {a,b},那么{a, b, c}的子集是将{a, b}的子集加入c与{a, b}的子集的总和,即{}, {a}, {b}, {a,b}, {c}, {a, c}, {b, c}, {a, b, c}。
被形式化的描述为:
...
SubSet(a,b,c) = a.SubSet(b,c)+SubSet(b,c)
SubSet(b,c) = b.SubSet(c)+SubSet(c)
SubSet(c) = c.SubSet({}) + SubSet({}) = c + {}
由此可见,能够递归的生成集合{a,b,c}的所有子集。
2、递归分析:
给定任何子集,每个元素相对于该子集只有两种状态:0表示不属于该子集,1表示属于该子集。对于子集{ab}来说,a,b属于该子集而c不属于该子集,这样按照abc的顺序的二进制串110便标识了子集合{a,b}。更形象地将搜索过程表示为树的形式就是这样的:
t[0] a
0/ \1
t[1] b b
0/ \1 0/ \1
t[2] c c c c
0/ \1 0/ \1 ... ...
... ...
程序从上到下进行递归,对每种集合,用数组bitset记录集合中元素的选取情况,选取元素t[i]则置bitset[i]为1,否则置bitset[i]为0。最终bitset记录了所有元素的选取与否,标识了一个子集合的元素。递归函数SubSet(char*t,int i,int n)输出集合t[i...n]的所有子集,它的工作流程是:
(1)不包含t[i],即bitset[i]置0(默认),调用SubSet(t,i+1,n),相当于递归搜索当前扩展节点t[i]的左子树。
(2)包含t[i],即bitset[i]置1(通过代码bitset[i]=1-bitset[i];实现),调用SubSet(t,i+1,n),相当于递归搜索当前扩展节点t[i]的右子树。
代码如下:
#include <stdio.h> #include <string.h> int bitset[6] = {0}; //初始bitset[i]全为0,意味着不包含任何元素,为空集。 void trail(char*t,int i,int n){ int j; if(i <= n){ bitset[i]=0; trail(t,i+1,n); bitset[i]=1; trail(t,i+1,n); bitset[i]=0; }else{ printf("{"); for(j=0; j<=n;j++){ if(bitset[j]) printf("%c",t[j]); } printf("}\n"); } } int main(){ char b[]={'a','b','c'}; trail(b,0,sizeof(b)-1); return 0; }
3、代码解释:
代码中值得注意的地方是if语句的条件, “if(i <=n)”中i==n意味着当前集合的元素数目只有一个,即为t[0...n]中的最后一个元素,也就是搜索树的叶节点,它再向下调用递归函数trail(t,i+1,n)会抵达递归出口,也就是else中的语句,把到达当前叶节点的整条路径上的被包含元素输出出来。
4、总结:
其实本递归算法本质上是通过置1置0逐位生成二进制数串的过程,当生成至叶节点时输出与当前二进制串对应的整个集合。
方法二、用位图方法来求全组合n!。
元素个数为n的集合{a、b、c}所有的子集个数有2^n个,可以将这些子集合映射到0~2^n-1个二进制数中,然后按其中为1的位取元素即可。这样能够在枚举二进制的同时,枚举每种组合。【前提是给定的n元集合中不能够有重复元素,因为是求组合问题,如果有重复元素需要进行预处理以去除重复元素。】基本思路:
(1)假设原有元素n个,则最终组合结果是2^n个,也即1<<n个子集合。
(2)比如{a,b,c}这个三元素的集合按位映射成2^3==1<<3==8个子集合:
{0,0,0}=>( ) =>空
{0,0,1}=>( c) =>{c}
{0,1,0}=>( b ) =>{b}
{0,1,1}=>( b c) =>{b,c}
.
{1,1,1}=>(a b c )=>{a,b,c}
(3)对应二进制值000,001,010,011,100,101,110,111 依次对应十进制为0,1,2....2^n-1,所以我们可逐一从0到2^n-1根据其二进制值输出相应的元素,从而输出所有子集合。
代码如下:
#include <stdio.h> #include <string.h> int main(void){ char str[]="abc"; int n = strlen(str); int maxNum = 1<<n; //maxNum代表n个元素具有的子集数目 int i,j; //0到maxNum-1对应了maxNum个二进制序列的十进制值 for(i=0 ;i<maxNum ; i++) { printf("{"); //每个十进制值i对应了唯一子集合,按照顺序输出集合元素 for( j = 0 ; j < n ; ++j){ //根据二进制中为1的位,从0到n-1输出子集元素。 if( i&(1<<j) ) printf("%c",str[j]); } printf("}\n"); } return 0; }
注意:本算法是有缺点的,因为“int maxNum = 1<<n; ”代码,当运行在32位机器上时,n最大值只能取到32,否则就不能够保证十进制数字与32位二进制数字之间的一一对应关系,因为32位二进制能表示的最大值是2^32-1,也就是说最多可以表示32个元素的2^32-1种组合方式,再多则无法应对了。
但是,如果使用《编程珠玑》第一章中介绍的位图方法,通过Int数组组合成一个位数不受限制的位图,那么就可以表示任意数目元素的全组合了。不过这样编程的复杂度会大大增加,因为本解法实际上是一种穷举法,通过穷举十进制数字输出对应二进制数所表示的组合,因此必须解决新方法中十进制数字和位图表示的二进制数之间的映射关系。
方法三、用递归方法来求组合数C(n,k)。
严格来件,组合是指从n个不同元素中取出m个元素来合成的一个组,这个组内元素没有顺序。使用C(n, k)表示从n个元素中取出k个元素的取法数:C(n, k) = n! / (k! * (n-k)!)。
例如:从{1,2,3,4}中取出2个元素的组合为:12;13;14;23;24;34。方法是:先从集合中取出一个元素,若取出1,然后需要从剩下的3元素集合{2,3,4}中再取出1个元素,假如我们从{2,3,4}中取出2,这时12就构成了一个组;若不取出1,则需要从剩下的3元素集合{2,3,4}中再取出2个元素。
上面这个过程可以总结出,在长度为n的字符串中求m个字符的组合时,我们先从头扫描字符串的第一个字符。针对第一个字符,我们有两种选择:一是把这个字符取出,接下来需要在剩下的n-1个字符中选取m-1个字符;二是不把这个字符取出,接下来需要在剩下的n-1个字符中选择m个字符。如此这般,可以递归进行。
#include <stdio.h> #include <stdlib.h> #include <string.h> int bitset[10] = {0}; //Combination函数计算arr[start,end]集合中m个元素的子集合 void Combination(char *arr ,int m,int start,int end) { if(arr==NULL) return ; if(m == 0){ printf("{"); int j; for(j=0; j<=end;j++){ if(bitset[j]) printf("%c",arr[j]); } printf("}\n"); }else if((end-start+1)<m){ return; }else { // bitset[start]=0; //若不选中当前位,置0,进行递归 Combination(arr,m,start+1,end); bitset[start]=1; //若选中当前位,置1,进行递归 Combination(arr,m-1,start+1,end); bitset[start]=0; //恢复为0,避免干扰其他的探查,最关键步骤 } } int main(){ char b[]={'a','b','c','d','e'}; Combination(b,5,0,sizeof(b)-1); return 0; }
方法四、用位图方法(01交换法)来求组合数C(n,m)。
使用0或1表示集合中的元素是否出现在选出的集合中,因此一个0/1列表即可表示选出哪些元素。例如:[1 2 3 4 5],选出的元素是[1 2 3]那么列表就是[1 1 1 0 0]。
使用01交换法的思路是,bitset[]数组用于记录集合中某元素是否被选中。因为需要在n个元素中选择m个,所以bitset[]中总共有m个为1的元素,其他均为0。因此在初始化过程中,将bitset前m个元素赋值为1,其余n-m个元素赋值为0。然后从左到右扫描bitset数组元素值的“10”组合,找到第一个“10”组合后将其变为“01”组合,同时将其左边的所有“1”全部移动到数组的最左端。当第一个“1”移动到数组的n-m的位置,即m个“1”全部移动到最右端时,就得到了最后一个组合。
combine(bitset, n, m){ (1) 从左到右扫描0/1列表,如果遇到“10”组合,就将它转换为”01”. (2) 将上一步找出的“10”组合前面的所有1全部移到bitset的最左侧。 (3) 重复(1) (2)直到没有“10”组合出现。 }
例如5中选3的组合:
1 1 1 0 0 ==>1,2,3
1 1 0 1 0 ==>1,2,4
1 0 1 1 0==>1,3,4
0 1 1 1 0==>2,3,4
1 1 0 0 1==>1,2,5
1 0 1 0 1==>1,3,5
0 1 1 0 1==>2,3,5
1 0 0 1 1==>1,4,5
0 1 0 1 1==>2,4,5
0 0 1 1 1==>3,4,5
至于其中的道理,需要经过严格的组合数学上的证明才能明白,在这里只需要知道n个位置上有m个1,按照此方法进行移动,可以保证产生的C(n,m)个不重复的组合。代码如下(这个代码是目前为止,模块化最好的最清晰的代码,值得参考):
#include <stdio.h> #include <stdlib.h> #include <string.h> inline void move(int * bitset, int num, int end){ int i; for(i = 0; i < num; i++){ bitset[i]=1; } for(i = num;i< end;i++){ bitset[i]=0; } } inline void print(char* arr,int * bitset,int n){ int i; printf("{"); for(i=0; i<=n;i++){ printf("%d",bitset[i]); } printf("} : {"); for(i=0; i<=n;i++){ if(bitset[i]) printf("%c",arr[i]); } printf("}\n"); } void Combination(char *arr,int * bitset,int n,int m){ int i; for(i = 0; i < n; i++){ //把bitset初始化为如”11100“这种形式 if(i < m){ bitset[i]=1; //连续m个1 }else{ bitset[i]=0; } } print(arr,bitset,n-1); //输出"11100"对应的组合形式,不断循环查找bitset[]中的"10"对进行处理,直到到达状态"00111",break结束。 while(true){ int j = 0; //每次循环都重新从0开始,查找bitset[]中的“10”对。 int num1 = 0; //用于统计找到第一个“10”对之前总共出现的“1”的数目。 for(j=0;j<n-1;j++){ if(bitset[j]==1){//一边查找"10"对,一边统计到目前为止出现的“1”的数目。 num1++; if(bitset[j+1]==0){//bitset[j]==1,bitset[j+1]==0,找到10对。 bitset[j]=0; //交换“10”为“01”对。 bitset[j+1]=1; //当前"10"对的左边总共有num1个"1",这其中包括了当前"10"对中的"1",也就是交换之前的bitset[j],我们可以通过赋值操作取代移动操作,将bitset左边num-1个元素都赋值为1,紧随其后一直到j都赋值为0,实现了将所有"1"移动到最左边。 move(bitset, num1-1, j); break; } } } //j<n-1,意味这for循环是找到"01"对并处理结束后通过break退出的。 if(j+1 < n){//此时bitset[]中的01值对应了arr的一个子集(组合),将其输出 print(arr,bitset,n-1); }else //意味着遍历到最后没有找到"10"对,即到达状态"00111",算法结束。 break; } } int main(int argc, char* argv[]){ char arr[] = {'1', '2', '3', '4', '5'}; int bitset[10] = {0}; int n = sizeof(arr)/sizeof(char); Combination(arr,bitset,n,3); return 0; }
此代码的另一个版本通过去除了combine函数中的Num1变量,提高了代码可读性,在这里一并给出:
#include <stdio.h> #include <stdlib.h> #include <string.h> #define true 1 #define false 0 inline void movebyj(int * bitset, int end){ int i,num1=0; for(i = 0; i < end; i++){ if(bitset[i] == 1) num1++; } for(i = 0; i < num1; i++){ bitset[i]=1; } for(i = num1;i< end;i++){ bitset[i]=0; } } inline void print(char* arr,int * bitset,int n){ int i; printf("{"); for(i=0; i<=n;i++){ printf("%d",bitset[i]); } printf("} : {"); for(i=0; i<=n;i++){ if(bitset[i]) printf("%c",arr[i]); } printf("}\n"); } void Combination(char *arr,int * bitset,int n,int m){ int i,isEnd=true; for(i = 0; i < n; i++){ //把bitset初始化为如”11100“这种形式 if(i < m){ bitset[i]=1; //连续m个1 }else{ bitset[i]=0; } } print(arr,bitset,n-1); //输出"11100"对应的组合形式 //通过isEnd控制算法结束,当状态为“00111”时isEnd为false,算法结束。 while(isEnd){ int j = 0; //每次循环都重新从0开始,查找bitset[]中的“10”对。 for(j=0;j<n-1;j++){ if(bitset[j]==1&&bitset[j+1]==0){//bitset[j]==1,bitset[j+1]==0,找到10对。 bitset[j]=0; //交换“10”为“01”对。 bitset[j+1]=1; //统计1的数目的任务移交给功能函数实现,以简化代码。 movebyj(bitset,j); print(arr,bitset,n-1); //每发现一个"10"对就输出对应的组合。 break; } } //当最后n-m个位全都置为1时,isEnd置为false使算法结束,若不全为1仍继续。 isEnd=false; for (i = n - m; i < n; i++) { if (bitset[i] == 0) isEnd = true; } } } int main(int argc, char* argv[]){ char arr[] = {'1', '2', '3', '4', '5'}; int bitset[10] = {0}; int n = sizeof(arr)/sizeof(char); Combination(arr,bitset,n,3); return 0; }
该方法虽简便,但是其输出结果不是基于字典排序的。研究其过程发现,其步骤(1)是从左到有扫描第一个"10"组合,如果修改为从右到左扫描"10"组合,并将第一个组合右边的所有1全部移动到紧靠该组合的右边,则可实现其输出为字典排序。修改后的过程如下:
combine(bitset, n, m){ (1) 从右到左扫描0/1列表,如果遇到“10”组合,就将它转换为”01”. (2) 将上一步找出的“10”组合右面的所有1全部移到紧靠该组合的右面。 (3) 重复(1) (2)直到没有“10”组合出现。 }
例如5中选3的组合:
1 1 1 0 0 ==>1,2,3
1 1 0 1 0 ==>1,2,4
1 1 0 0 1 ==>1,2,5
1 0 1 1 0==>1,3,4
1 0 1 0 1==>1,3,5
1 0 0 1 1==>1,4,5
0 1 1 1 0 ==>2,3,4
0 1 1 0 1==>2,3,5
0 1 0 1 1==>2,4,5
0 0 1 1 1==>3,4,5
可是,原理的证明更是一头雾水,代码可以直接根据上边的代码进行改写,等以后弄懂了原理在重新写吧。
扩展——有限制的组合问题:输入两个整数n和m,从数列1,2,3...n中随意取几个数,使其和等于m,要求列出所有的组合。【这个问题其实是01背包问题的变形,动态规划思想可解】
解法一:用递归,效率可能低了点。假设问题的解为F(n, m),可分解为两个子问题 F(n-1, m-n)和F(n-1, m)。对这两个问题递归求解,求解过程中,如果找到了符合条件的数字组合,则打印出来。
求解思路:
从1~n的数字中找出和为m的组合,有三种可能情况:
(1)n>m:如n=7(1,2,3,4,5,6,7),m=5,此时6,7比m还大,是不可能被选择的,直接被忽略,置新的n = m,这样便成为第(2)种情况。
(2)n=m:如n=5(1,2,3,4,5),m=5,此时直接选出5便能够满足要求,F(n-1,m-n)即为F(4,0),m=0意味找到一个合法组合,F(n-1,0)内部会输出该组合(作为一个递归出口)。但是值得注意的是,此时还要继续探查,因为1+4和2+3也能够等于5而满足题意,因此仍需要F(n-1,m)即F(4,5)继续递归。
(3)n<m:如n=5(1,2,3,4,5),m=7,此时有两种选择:
(a)选择5,要F(n-1,m-n)=F(4,2)继续选择,直到遇到n==m为止。
(b)不选择5,要F(n-1,m)=F(4,7)继续选择,直到遇到n==m为止。
注意,(2)(3)两种情况实际上是一种,只要n<=m都要进行递归探查。需要注意算法的出口,若m为0(不管n是否为0)直接成功结束并输出当前的组合。m不为0而n为0则直接错误结束。(注:用户输入的m,n不能是负数)
代码如下:
#include <stdio.h> #include <stdlib.h> #include <string.h> inline void print(int * bitset,int len){ int i; printf("{"); for(i=1; i<=len;i++){ printf("%d",bitset[i]); } printf("} : {"); for( i = 1; i <= len; i++){ if(bitset[i] == 1){ printf("%d ",i); } } printf("}\n"); } //函数功能: 从数列1,2...n中随意取几个数,使其和等于m //函数参数: n为当前最大值,m为剩余值,len为n的最初最大值,用于输出组合。 //返回值: 无 void bagProblem(int *bitset,int n, int m,int len){ //递归出口1:m为0,则无论n是否为0都成功输出组合,并返回。 if(m==0){ print(bitset,len); return; } //递归出口2:若m不为0而n为0,则注定失败,直接返回。事实上n和m不可能为负数,因为n,m每次最多减1,每当减为0时就结束了。所以可以写作if(n==0),但是为了易读保留冗余的写法。 if(n<=0|| m<0 ) return; if(n<=m){ //如果n<=m,有两种选择。 bitset[n]=1; bagProblem(bitset,n-1,m-n,len); bitset[n]=0; bagProblem(bitset,n-1,m,len); }else{//如果n>m,缩小n之后,有两种选择。 int i; for( i = m; i <= n; i++){ bitset[i] = 0; } bitset[m] = 1; bagProblem(bitset,m-1,m-m,len); bitset[m] = 0; bagProblem(bitset,m-1,m,len); } } int main(int argc, char* argv[]){ int n=10,m=12; int *bitset= (int *)malloc(sizeof(int)*(n+1)); //注意第三个参数不能是sizeof(bitset),因为bitset只是个指针。 memset(bitset, 0, sizeof(int)*(n+1)); time_t start = clock(); bagProblem(bitset,n,m,n); time_t end = clock(); printf("time is %f \n",(double)(end - start)/CLOCKS_PER_SEC); return 0; }
改进:算法简单易懂,但是复杂度较高,可以通过增加一个限制条件来去除一些不必要的计算: 当m大于(n * (n + 1)) / 2,在{1~n}中的所有组合均无法构成m和数。该限制条件可减少一半的计算量,例如当m = 8, n = 10 时,相应的计算次数分别为43次(增加限制条件前)和25次(增加限制条件后),时间从0.018ms降为0.009ms。只需要修改递归出口2的判断条件:
if(n<=0|| m<0 || ((n * (n + 1)) / 2 < m )) return;
解法二:这个问题可以类比为前述的全组合问题,我们找出{1~n}的所有2^n个组合,然后统计每个组合的加和,如果值恰好为m则输出该组合。求全组合既可以使用递归策略也可以使用位图向量的策略,这里以位图为例。设n为5,m=7,即{1,2,3,4,5},位图01001(9)代表了子集{2,5},它的元素加和为7满足题意输出。如此这般,找出所有的不重复的子集,每个子集求一遍加和进行判断,最终就能得到所有满足要求的组合。
代码比较简单,在位图求全组合代码的基础上进行了改写:
#include <stdio.h> #include <string.h> int main(void){ int str[]={0,1,2,3,4,5}; int n = (sizeof(str)/sizeof(int))-1; int m = 7; int maxNum = 1<<n; int i,j,sum; for(i=0 ;i<maxNum ; i++) { sum=0; for( j =0 ; j < n ; ++j){ if( i&(1<<j) ) sum+= str[j+1]; } if(sum==m){ printf("{"); for( j =0 ; j < n ; ++j){ if( i&(1<<j) ) printf("%d",str[j+1]); } printf("}\n"); } } return 0; }