【編程珠璣】【第二章】編程求解組合問題
組合問題
以下兩個題目是等價的:
題目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; }
【編程珠璣】【第二章】編程求解組合問題