有人說算法導論中沒有回溯和分支定界這兩種算法。我覺得這個算是導論中算法的應用吧,廢話不多說,走起。
回溯算法之子集和數問題。
這個算法要解決的問題:假定有N個不同的正數(通常稱為權),要求找出這些數中所有使得某和數為M的組合。
這種問題的解的形式:(1)問題的解是大小固定的N元組,解向量中的元素的個數就是正數的個數,每個元素為X(i),它的取值為0或者1,表示這個解是否包 含了相對應的正數W(i)。
(2)問題的解是大小不固定的K元組,這里不做討論。
這樣的整個的求解過程就構成了一棵樹,對於i級上的一個結點,其左兒子是對應於X(i)=1產生的狀態,右兒子是對應於X(i)=0產生的狀態(父節點到兒子節點的邊可以看成一種決策,這種決策就是選不選這個正數)。
但是為了防止這棵樹長得很大,我們可以引入限界函數,可以提前預知這個結點不可能產生最后的解,這樣我們就能提前的殺死這個結點,同時也能夠提前的殺死這個結點的所有的子樹,這樣就大大的減少了樹的節點數,加快了產生最終解的速度。
限界函數的產生:
(1)我們假設一個前提條件:這些正數是按照非降次序排列的。
(2)引入一個記號:B(X(1),...,X(k))表示是否可以把第K個正數加入進來,所以它的取指為true或者false。
那么當我們考慮是否要把第K個正數加入到解向量中的時候,我們就能找到兩個條件組成這個限界函數了:
(1)這個公式的含義是:當你考慮是否要把第K個正數加入到解向量的時候,不管你要加進來或者是不打算把它加進來,前K個解向量的和(包括第K個,當然X(k)可能是0或者1),加上后面所有的數的和一定要大於等於M,否則你把剩下的數都加了進來還比M小,這次的決策X(k)=0或者1肯定得不到滿足條件的解向量。所以也就沒有必要擴展這個結點的左兒子或者右兒子了。(說的明白點,如果X(k)=1不滿足上面的式子,那就沒有必要擴展第K-1個正數的左兒子了;右兒子同理;如果還不理解不要緊,看后面的例子)
(2)這個公式的含義是,當你考慮是否要把第K個正數加入到解向量的時候,不管你要加進來或者是不打算把它加進來,提前往后看一步,判斷如果把第K+1個正數算進來后的值大於M,就不把第K個正數加進來。也就是說不生成第K-1個節點的兒子。(說明白點,不管你的第K個結點是否加入到解向量中,如果X(k)=1不滿足上面的式子,那就沒有必要擴展第K-1個正數的左兒子了;右兒子同理;如果還不理解不要緊,看后面的例子)
注意:這個條件可能很難理解,首先這些數是非降序排列的,如果要考慮加入這個第K個數的話,分三種情況:
①加入進來剛好等於M,那么正好就得到了一個解向量。此時不要產生這個結點,因為提前向后看一步肯定會不滿足(2)的,所以這種情況下樹已經被界限函數殺死了,但是確實找到了正確的解向量,所以程序中的動作是輸出這個解向量。樹中的表現形式是,產生一個不同於普通結點的終結結點表示找到了一個正確的解向量。(后面的圖中有區分,方塊表示擴展了的結點,圓圈表示正確的解向量)
②加入進來的正數求和后小於M,往后看一步,看第K+1個節點,如果在加上第K+1個正數后的和大於M,則后面就不會再有滿足條件的解向量了,因為這些正數是非降序排列的。后面的每個數和前面的這K個數的和一定都大於M;同時前面的K-1個數的和小於M。也就是說不會產生解向量,這這個第K個結點就不會加入到樹中來,樹在這里被界限函數殺死。
③加入進來的正數求和后大於M,因為這些正數是非降序排列的,顯而易見不能產生解向量。
同時,如果決策是不加如這個第K個正數(產生右孩子),如果前面這K個數的和(包括第K個的X(k)=0)加上第K+1個數的和大於M,也不會產生解向量,同樣可以不用產生這個右孩子。
只有同時滿足(1)(2)兩個條件的時候,B(X(1),...,X(k))=true,也就是說可以產生第K個正數的結點,否則就要在他的上級結點殺死。
注意:其實回溯算法很簡單,但是重點和難點在於找到最后的界限函數,界限函數找的好就能提前殺死好多的節點,大大的提高算法的效率,如果界限函數找的不好就會是一個很爛的算法。
好了,界限函數也明確了,下面先看看偽代碼:
1 procedure SUMOFSUB(s,k,r) 2 //找W(1:n)中和數為M的所有子集。進入此過程時X(1),…,X(k-1)的值已確定。W(j)按非降次序排列。// 3 //下面的變量解釋:s表示已經加進來的這個序列的和;r表示還沒有加入進來的所有的數的和;k表示級數// 4 global integer M,n; 5 global real W(1:n); 6 global boolean X(1:n); 7 real r, s; 8 integer k,j; 9 //生成左兒子// 10 X(k)←111 if s+W(k)=M then 12 print(X(j),j←1 to k) 13 else 14 //這里指判斷了界限函數的一個條件:我們假設所有的數的和大於等於M,否則沒意義了,將一定無解;還假設第一個數小於等於M// 15 if s+W(k)+W(k+1) ≤ M then//B(k)=true// 16 call SUMOFSUB(S+W(k),k+1,r-W(k)) 17 endif 18 endif 19 //生成右兒子和計算Bk的值// 20 if s+r-W(k) ≥ M and s+W(k+1) ≤ M//B(k)=true// 21 then X(k)←0 22 call SUMOFSUB(s,k+1,r-W(k)) 23 endif 24 end SUMOFSUB
注解:為什么第二個if中只判斷了一個條件?因為我們一開始就假設所有的數的和大於等於M,所以在生成左孩子的時候,這個條件一定滿足,因為我們的做法是把這個數加進來。只要他的父結點滿足條件,它就滿足條件(父結點如果是根,我們有假設;父結點如果是爺爺結點的右孩子,那么父結點判斷界限函數了;父結點是爺爺結點的左孩子,那么往上遞推)。
可能到這里還有好多的不明白,那么我們來實際的跑一次:
設n=4個正數的集合,W={11,13,24,7},和M=31。求W的所有元素之和為M的子集。
解:
注意:(1)最后的解不是一個結點,而是在上一級就截斷了,用小圓圈表示這個解。還有A結點的打印輸出不是N元組。
(2)一定要先對所有的正數排序。
(3)構建上面的樹的時候,產生一個結點,如果要接着構建其左孩子,那么讓他入棧,等到輪到他的時候再出棧構建其右孩子。
--------------------------------------------------------------------------------------------------
好了,鋪墊了那么久,終於該輪到代碼上場了,看看具體的實現(其實最終要的還是界限函數的選取,不要本末倒置):
1 #include <stdio.h> 2 #define M 31 3 #define N 5 4 5 int w[N] = {0,11,13,24,7}; 6 int x[N] = {0}; 7 int flag = 0; 8 9 //回溯算法實現 10 void sumOfSub(int s, int k, int r); 11 //首先對這些正數排序 12 void InsertionSort(int a[], int low, int high); 13 //每產生一個解向量就打印出來,同時清零。准備下一個解向量 14 void print(); 15 16 int main() 17 { 18 int sum = 0; 19 //先判斷所有數的和是否小於M,如果小於M則不會有解向量 20 for(int i=1; i<N; i++) 21 { 22 sum += w[i]; 23 }//for 24 25 if(sum < M) 26 { 27 printf("沒有解向量滿足條件\n"); 28 return 0; 29 }//if 30 31 //如果要用回溯算法,首先對數據排序。因為數據的規模不大,用InsertionSort搞定 32 InsertionSort(w, 1, N-1); 33 34 if(w[0] > M) 35 { 36 printf("沒有解向量滿足條件\n"); 37 return 0; 38 }//if 39 40 //回溯算法的准備工作完畢,下面開始調用 41 sumOfSub(0,1,sum); 42 43 //通過flag的值判斷print()函數有沒有被調用過,從而確定是否存在解向量 44 if(!flag) 45 { 46 printf("不存在滿足條件的序列\n"); 47 } 48 49 return 0; 50 } 51 void sumOfSub(int s, int k, int r) 52 { 53 //生成左子樹 54 x[k] = 1; 55 if(s + w[k] == M) 56 { 57 print(); 58 }//if 59 else 60 { 61 if(k < N - 1 && s + w[k] + w[k+1] <= M) 62 { 63 sumOfSub(s+w[k], k + 1, r - w[k]); 64 }//if 65 66 }//else 67 68 //生成右子樹 69 x[k] = 0; 70 if(k < N - 1 && s + r - w[k] >= M && s + w[k+1] <= M) 71 { 72 sumOfSub(s, k + 1, r - w[k]); 73 }//if 74 75 } 76 77 void print() 78 { 79 for(int i=1; i<N; i++) 80 { 81 printf("%d ", x[i]); 82 } 83 printf("\n"); 84 flag = 1; 85 } 86 87 void InsertionSort(int a[], int low, int high) 88 { 89 int unsort = low + 1; 90 int j; 91 for(;unsort <= high; ++unsort) 92 { 93 int temp = a[unsort]; 94 j = unsort - 1; 95 while(j >= 0 && temp < a[j]) 96 { 97 a[j+1] = a[j]; 98 j--; 99 }//while 100 101 a[j+1] = temp; 102 }//for 103 }
測試:
(1)程序數據:{5,10,12,13,15,18}
程序結果:
(2)程序數據:{11,13,24,7}
程序結果:
未完待續:
回溯算法的子集和數問題到此告一段落,有時間再追加時間復雜度。