0/1背包问题-----回溯法求解
問題描述
有n個物品和一個容量為c的背包,從n個物品中選取裝包的物品。物品i的重量為w[i],價值為p[i]。一個可行的背包裝載是指,裝包的物品總重量不超過背包的重量。一個最佳背包裝載是指,物品總價值最高的可行的背包裝載。
我們要求出x[i]的值。x[i] == 1表示物品i裝入背包,x[i] == 0表示物品i沒有裝入背包。
問題的公式描述是:
//總價值最高的可能的背包裝載,x[i]只能等于0或者1 max{p[1] * x[1] + p[2] * x[2] + ... + p[i] * x[i] + ... + p[n] * x[n]}約束條件
//裝入的所有物品的重量之和不大于背包容量 size_t totalWeight = 0; for(size_t i = 1; i <= n; ++i) {totalWeight += w[i] * x[i]; //x[i] = 0 || x[i] = 1 } if(totalWeight <= c)//約束條件成立 else//約束條件不成立回溯法
顧名思義,回溯法一個很顯著的特征就是回溯,即在處理完某種情況或出現不能繼續進行的情況時,要退回到之前的某個“交叉口”,處理另一種可能。
通常,回溯法會定義一個解空間,這個解空間通常是以圖或樹的形式呈現出來的。背包問題的解空間是樹的形式,屬于深度優先搜索。
問題分析
考察這樣一個0/1背包問題: 總共4個物品,重量分別是w[1:4] = {8, 6, 2, 3},價值分別是p[1:4] = {8, 6, 2, 3},規定背包容量為12(即可以容納的最大重量為12),求出獲得最大價值的解情況。
回溯法首先要做的就是根據已知條件建立解空間樹。
如圖為根據4個物品的重量建立的解空間樹,根節點A沒有任何意義,每個節點的高度代表它是第幾個節點(A節點高度為0)。對于一個節點Z,它的高度是H,則Z節點表示的就是物品H。左孩子表示裝入物品H+1(權值是物品H+1的重量),右孩子表示不裝入物品H+1(權值是0)。
可以理解為Z就是一個“交叉口”,向Z左邊走是一種情況(即物品H+1裝入背包的情況),向Z右邊走是另一種情況(即物品H+1沒有裝入背包的情況)。
另外,如果Z的權值不是0,就證明Z的父節點的左孩子,表示物品H已經裝入背包。
對于本題而言,起始位置在A點,當前可用容量為12.
1.因為12>8,所以可以向左孩子節點移動,此時位于B點,可用容量變為4,高度為1,表示第一個物品裝入背包。
2.考慮B的左右孩子節點,因為當前可用容量不足以裝入物品2,所以不能夠移向D點,故移動到E點,可用容量仍為4,高度為2,表示第二個物品沒有裝入背包。
3.考慮E的左右節點J和K,因為當前可用容量可以裝入物品3,所以可以移動到J點,可用容量變為2,高度為3,表示第三個物品裝入背包。
4.考慮J的左右孩子節點,因為2<3,不足以裝入物品4,所以只能夠移動到J的右孩子結點??捎萌萘咳詾?,高度為4,表示第四個物品沒有裝入背包。
5.到達葉子節點,這種情況下背包裝入情況是[1,0,1,0],獲得的總價值為10,記錄此時的葉子節點。
6.開始向上回溯,即回到此時節點的父節點處,回到J點??捎萌萘繛?,高度為3.
7.因為J的右孩子已經考慮過了,所以繼續向上回溯,回到J的父節點E點。可用容量要加上J的重量,變為4,高度為2。這時回到了步驟3的交叉口(稱為回溯)。
8.因為剛才移動到E的左孩子節點,所以回溯回來后移動到E的右孩子節點K。此時,可用容量不變,仍為4,高度為3。表示第三個物品沒有裝入背包。
9.考慮K的左右孩子節點,因為4>3,所以可以移動到K的左孩子節點,當前可用容量變為1,高度為4,表示第四個物品被裝入背包。
10.到達葉子結點,這種情況下背包裝入情況是[1,0,0,1],獲得的總價值是11大于10,所以更新最優解的葉子節點。
11.繼續向上回溯,直到處理完所有情況。
以上就是回溯法的大體思路,接下來會分別用遞歸和迭代兩種方法求解。
遞歸求解
遞歸求解相對簡單,不過要對回溯有比較好的理解。
為了減少遞歸調用傳參的代價,可以把大部分的變量作為全局變量使用。
int *Weight; //Weight[i]表示物品i的重量 int *Profit; //Profit[i]表示物品i的價值量 int n; //物品個數首先考慮如何構建解空間樹,
一種方法是以樹節點指針的形式描述,但是回溯起來需要另外添加父節點指針。
另一種方法是使用一維數組表示解空間樹,對于某個節點Z,它在數組中的索引是index,則Z的左孩子索引是index * 2,Z的右孩子索引是index * 2 + 1,Z的父節點索引是index / 2。(這種描述方法和大根堆,小根堆一樣)
這里采用一維數組描述解空間樹,
1.需要事先算出樹中節點個數。因為已知物品數量n,并且樹中每一層代表一個物品,所以樹的高度是n+1(算上根節點A)。利用等比數列求和公式可以求出節點個數為2的n+1次方-1。
2.初始化解空間樹的一維數組。因為每一層的節點數量和該層的高度有關,所以可以事先算出這一層第一個節點的索引和最后一個節點的索引。然后一個賦值成Weight[i],一個賦值成0,每次i += 2。
初始化解空間樹的代碼如下:
//節點個數為2的n+1次方-1 size_t pNodeSize = 1; for(size_t i = 1; i <= n+1; ++i)pNodeSize *= 2; pNodeSize--;//根節點的索引為1,權值為0 int *pTree = new int[pNodeSize+1]; pTree[1] = 0;for(size_t pHeight = 1; pHeight <= n; ++pHeight) {//高度為pHeight的第一個節點的索引為2的pHeight次方size_t pStartNode = 1;for(size_t i = 1; i <= pHeight; ++i)pStartNode *= 2;//高度為pHeight的最后一個節點的索引為pStartNode * 2 - 1for(size_t i = pStartNode; i < pStartNode * 2; i += 2){//高度為pHeight表示的就是第pHeight個物品,重量即為Weight[pHeight]pTree[i] = Weight[pHeight]; //左孩子pTree[i+1] = 0; //右孩子} }由上面的圖片發現,每一層的賦值實際上就是一個w[i],一個0,一個w[i],一個0…
w[i]永遠是父節點左孩子的權值,如果到達這個節點就表示將物品i裝入背包。
0永遠是父節點右孩子的權值,如果到達這個節點就表示物品i沒有裝入背包。
到此為止,解空間樹的初始化工作就完成了?,F在添加全局變量
int n; //物品個數 int Capacity; //背包容量 int *Weight; //Weight[i]表示物品i的重量 int *Profit; //Profit[i]表示物品i的價值量 int *pTree; //解空間樹,pTree[i]表示節點i的重量,同時又可以用于判斷是否裝入物品i int pNodeSize; //節點數量 int pLastNode; //最優解的葉子結點索引 int pMaxProfit; //最優解的值,初始為0 int pHeight; //當前高度,初始為0,始終表示當前結點的高度 int pCurrentProfit; //當前價值量,初始為0 int pCurrentWeight; //當前加入背包的總重量,初始為0遞歸程序核心步驟就是向左右孩子節點移動的過程,創建遞歸函數
void Backpack(size_t pCurrentNode);遞歸函數表示的是在節點pCurrentNode這個位置,考慮向它左右孩子移動的問題(也就是選擇物品H+1和不選擇物品H+1的問題)。
pHeight始終是pCurrentNode的高度,也表示第幾個物品。
1.如果向左孩子移動,即pCurrentNode * 2,表示第pHeight + 1個物品裝入背包。則pCurrentProfit需要加上第pHeight + 1個物品的價值量,同時pCurrentWeight需要加上第pHeight + 1個物品的重量。
2.如果向右孩子移動,即pCurrentNode * 2 + 1,表示第pHeight + 1個物品不裝入背包。pCurrentProfit和pCurrentWeight都不需要改變。
需要注意
1.pHeight在剛進入函數時表示的是pCurrentNode節點的高度,也表示當前考慮的是第幾個物品。pHeight+1表示下一個物品,即pCurrentNode * 2和pCurrentNode * 2 + 1表示的物品。
2.只有背包剩余容量足夠裝入下一個物品時,才向左孩子節點移動。而向右孩子節點移動不需要考慮背包剩余容量是否足夠裝入,因為往右孩子移動意味著不裝入下一個物品
另外考慮回溯的情況,當向左孩子移動之后經過一系列操作返回到該節點處,也就是說Backpack(pCurrentNode * 2);返回后,說明左孩子節點表示的物品(下一個物品)裝入背包的情況已經考慮完了,需要把調用Backpack(pCurrentNode * 2);之前為pCurrentWeight和pCurrentProfit加上的值減去,因為它們加的值是下一個物品的重量和價值(它已經考慮完了,接下來需要考慮右節點,下一個物品沒有裝入背包的情況了)
所以兩種情況的考慮代碼如下:
void Backpack(int pCurrentNode) { //到達葉子節點,更新最優解,記錄最優解的葉子結點if(pCurrentNode * 2 > pNodeSize){if(pCurrentProfit > pMaxProfit){pMaxProfit = pCurrentProfit;pLastNode = pCurrentNode;}return;}//高度加一,此時表示的是下一個物品(pCurrentNode左孩子和右孩子表示的物品)pHeight++; //如果當前背包裝入的重量加上下一個物品的重量仍然小于等于背包容量//則可以將下一個物品加入背包if(pCurrentWeight + Weight[pHeight] <= Capacity){//假設pCurrentNode表示的是物品i,則此時加上的是物品i+1的重量和價值//因為物品i的重量和價值已經在上一層遞歸中加上了pCurrentWeight += Weight[pHeight];pCurrentProfit += Profit[pHeight];Backpack(pCurrentNode * 2); //跳轉到左孩子,表示將物品i+1(pHeight)裝入背包//回溯到這個節點后,物品i+1裝入背包的情況已經考慮完,需要將物品i+1的重量和價值減去//開始考慮物品i+1沒有裝入背包的情況pCurrentWeight -= Weight[pHeight];pCurrentProfit -= Profit[pHeight];}//跳轉到右孩子節點,表示物品i+1沒有裝入背包(i + 1 == pHeight)Backpack(pCurrentNode * 2 + 1);//返回后,回溯到這個節點,物品i+1沒有裝入的情況也已經考慮完,需要向上回溯,高度減一pHeight--; }大體的流程已經完成。現在考慮一個問題,在一系列操作之后,從節點Z的左孩子節點回溯到Z。假設節點Z表示的是物品i,此時
pCurrentWeight表示的是物品1,2,…,i裝入背包情況的總重量,即前i個物品裝入背包的重量。
pCurrentProfit表示的是前i個物品裝入背包的總價值。
假設存在一個變量pRemainingProfit,它表示從物品i+2到物品n的價值總和。那么我們就可以根據pRemainingProfit和pCurrentProfit的大小來決定是否還有必要跳轉到右孩子節點。
像這樣,為跳轉到右孩子增加一個限制條件。
注:因為上述是為了判斷是否向右孩子跳轉,又因為右孩子表示物品i+1不裝入背包,所以剩余價值不包括物品i+1的價值。
優化后的代碼如下(注意需要將pRemainingProfit加入到全局變量,初始化為所有物品的總價值和):
void Backpack(int pCurrentNode) { //到達葉子節點,更新最優解,記錄最優解的葉子結點if(pCurrentNode * 2 > pNodeSize){if(pCurrentProfit > pMaxProfit){pMaxProfit = pCurrentProfit;pLastNode = pCurrentNode;}return;}//高度加一,此時表示的是下一個物品(pCurrentNode左孩子和右孩子表示的物品)pHeight++; //如果pCurrentNode表示物品i, 則加一后的pHeight表示物品i+1,pRemainingProfit應該把物品i+1的價值減掉pRemainingProfit -= Profit[pHeight];//如果當前背包裝入的重量加上下一個物品的重量仍然小于等于背包容量//則可以將下一個物品加入背包if(pCurrentWeight + Weight[pHeight] <= Capacity){//假設pCurrentNode表示的是物品i,則此時加上的是物品i+1的重量和價值//因為物品i的重量和價值已經在上一層遞歸中加上了pCurrentWeight += Weight[pHeight];pCurrentProfit += Profit[pHeight];Backpack(pCurrentNode * 2); //跳轉到左孩子,表示將物品i+1(pHeight)裝入背包//回溯到這個節點后,物品i+1裝入背包的情況已經考慮完,需要將物品i+1的重量和價值減去//開始考慮物品i+1沒有裝入背包的情況pCurrentWeight -= Weight[pHeight];pCurrentProfit -= Profit[pHeight];}//跳轉到右孩子節點,表示物品i+1沒有裝入背包(i + 1 == pHeight)if(pRemainingProfit + pCurrentProfit > pMaxProfit)Backpack(pCurrentNode * 2 + 1);//返回后,回溯到這個節點,物品i+1沒有裝入的情況也已經考慮完,需要向上回溯,高度減一,pRemainingProfit加回pRemainingProfit += Profit[pHeight];pHeight--; }迭代求解
迭代求解就是將遞歸的每一步細化,但是考慮到回溯的問題,需要解決兩個問題。
1.為了不重復回到已經到達的節點,需要一個一維數組pReach[],pReach[i] == 1表示達到過節點i,pReach[i] == 0表示沒有到達過節點i。
2.回到父節點只需pCurrentNode /= 2即可,這也是用一維數組存儲樹節點的好處。
其他的就是細化遞歸代碼的工作了。
首先考慮對每一個節點的處理(只處理之前沒有到達過的節點)
1.如果該節點的權值不為0,表示將物品裝入背包,則需要將pCurrentProfit和pCurrentWeight更新,然后標識為到達。
2.如果該節點的權值為0,表示不將該物品裝入背包,則不需要做任何處理
pHeight始終表示pCurrentNode的高度,也表示當前考慮第幾個物品。
if(pReach[pCurrentNode] == 0) {//只在裝入該物品時才更新pCurrentWeight和pCurrentProfitif(pTree[pCurrentNode] != 0){pCurrentWeight += pTree[pCurrentNode];pCurrentProfit += Profit[pHeight];}pReach[pCurrentNode] = 1; }其次考慮到達葉子節點的情況
1.如果需要更新最優解的值,則更新并記錄最優解的葉節點索引
2.到達葉子節點之后,向上回溯。
然后考慮向左右節點移動的情況。
1.向左移動,首先之前沒有到達過左孩子節點,然后剩余容量要足夠裝入下一個物品。
2.向右移動,之前沒有到達過右孩子節點。
3.如果都不滿足(即左右節點都已經考慮完),向上回溯。
最后考慮回溯部分。
由對節點的處理部分可以得知,假設pCurrentNode表示的是物品i,那么pCurrentWeight和pCurrentProfit表示的是物品1,2,…,i的背包裝入問題的當前重量和當前價值。
所以在回溯到父節點之前需要將物品i的重量和價值減去(如果物品i裝入背包的話)。
同時改變當前節點令其表示其父節點,高度減一。
接下來,利用遞歸程序中判斷是否需要移動到右孩子節點的方法,在迭代程序中添加判斷部分。假設pCurrentNode表示物品i,則只有當
從物品i+2到n的價值量(剩余價值量) + 當前價值量 > 當前最優解(pMaxProfit)時,才需要跳轉到右孩子節點。因為只有這種情況,才有可能產生比當前最優解更優的解,否則不會產生更優的解,沒有意義。
體現在程序中就是在跳轉到右孩子節點的if判斷中增加一條約束:
假設pCurrentNode表示的是物品i,需要注意的是,pRemainingProfit始終表示的是i+2,i+3,…,n這幾個物品的價值總和。因為向右跳轉證明物品i+1沒有被裝入背包,它的價值和重量不會算在pCurrentWeight和pCurrentProfit中。
接下來可以明確的是,肯定是一個循環來執行上述求解過程,在第一次循環中pCurrentNode = 1,表示的是根節點(A),這個節點沒有實際的意義。
為什么不是2呢,因為在選擇第一個物品時有裝入和不裝入兩種情況,而如果一上來就另pCurrentNode = 2,那么就相當于默認選擇了物品1,不選擇物品1的情況被忽略了。
然后考慮,假設從節點A跳轉到左孩子節點B,然后在A的左半部分執行一系列操作回溯到A點后,開始向右半部份跳轉,又執行一系列操作回溯到A點,這時A的左右孩子都已經考慮過了,需要執行“向左右孩子節點移動的情況”中else部分,即向上回溯,這時pCurrentNode /= 2,導致pCurrentNode = 0,在這時我們需要終止算法。所以很顯然需要一個while循環,判斷的條件就是pCurrentNode != 0。即
將上面的代碼組裝起來
1.先計算解空間樹的節點數量
2.初始化解空間樹
3.初始化pReach數組,用于判斷某個節點是否到達過
4.初始化各種變量,如pCurrentWeight,pCurrentProfit,pCurrentNode…
5.while循環開始回溯程序
注意不要忘記添加pRemainingProfit的修改代碼:
void Backpack(int Weight[], int Profit, int n, int Capacity) {//計算解空間樹的節點個數size_t pNodeSize = 1;for (size_t i = 1; i <= n + 1; ++i){pNodeSize *= 2;}pNodeSize--;//初始化解空間樹,對于某一個節點Z,Z的下一層節點表示物品i//Z的左孩子表示選擇該物品,Z的右孩子表示不選擇該物品//相應的左孩子的權值就是物品i的重量,右孩子的權值為0int *pTree = new int[pNodeSize + 1];//pReach數組記錄節點的到達情況,遇到過的節點值為1,反之為0.用于回溯判斷int *pReach = new int[pNodeSize + 1];for (size_t pHeight = 1; pHeight <= n; ++pHeight){//為解空間樹賦值,每層都是weight[pHeight], 0, weight[pHeight], 0, .....size_t pStartNode = 1;for (size_t i = 1; i <= pHeight; ++i)pStartNode *= 2;for (size_t i = pStartNode; i < pStartNode * 2; i += 2){pTree[i] = Demension[pHeight];pTree[i + 1] = 0;pReach[i] = pReach[i + 1] = 0;}}//pRemainingProfit:記錄剩余的總價值量,用于判斷是否還需要在右子樹中查找//若當前高度為height,則pRemainingProfit記錄的永遠是從物品height+2到backCnt的總價值size_t pRemainingProfit = 0;for (size_t i = 1; i <= n; ++i)pRemainingProfit += theProfit[i];size_t pLastNode = 0; //當前最優解的最后一個節點size_t pMaxProfit = 0; //當前最優解size_t pCurrentWeight = 0; //當前重量size_t pCurrentProfit = 0; //當前總價值size_t pHeight = 0; //節點高度,同時也是物品的索引,表示第pHeight個物品size_t pCurrentNode = 1; //當前節點while (pCurrentNode != 0){//如果之前沒有到達過當前結點,則將當前結點的重量,價值加入//更新節點到達情況if(pReach[pCurrentNode] == 0){ if(pTree[pCurrentNode] != 0){pCurrentWeight += pTree[pCurrentNode];pCurrentProfit += theProfit[pHeight];}pReach[pCurrentNode] = 1;}//判斷是否到達葉子節點//如果到達葉子節點,同時當前的總價值大于上一次的最優解,則將最優解更新為當前的總價值//同時記錄最優解對應的最后一個節點位置if (pCurrentNode * 2 > pNodeSize){if (pCurrentProfit > pMaxProfit) {pMaxProfit = pCurrentProfit;pLastNode = pCurrentNode;}//到達葉子節點,更新完數據后就應該向上回溯,回溯方法pCurrentNode /= 2,返回父節點。//每次向上回溯,都應該將該層的重量和價值從當前價值和當前重量中減去//注意只有當是左孩子的時候才減去價值 if(pTree[pCurrentNode] != 0){pCurrentWeight -= pTree[pCurrentNode];pCurrentProfit -= theProfit[pHeight];}pCurrentNode /= 2; pHeight--;//注:這里不需要更新pRemainingProfit,因為pRemainingProfit表示的是從當前結點的孫子開始計算的總價值//而此時當前結點跳到葉節點的父節點處,沒有孫子節點。}//沒有到達葉子節點else{//剩余價值量減去當前結點孩子的價值pRemainingProfit -= theProfit[pHeight+1];//當當前結點的左孩子沒有達到過且容量足以滿足左孩子的重量時,跳轉到左孩子if (pReach[pCurrentNode * 2] == 0 &&pCurrentWeight + pTree[pCurrentNode * 2] <= theCapacity){pCurrentNode *= 2;pHeight++;}//當當前結點左孩子節點已經到達過,而右孩子沒有到達過,//同時當前價值量加上剩余價值量有大于當前最優解的可能時,跳轉到右孩子處//注:因為右孩子表示沒有選擇該物品,所以該物品的價值就無需考慮,//這也正是為什么pRemainingProfit表示的是從孫子節點開始的價值量,因為右孩子節點的價值就是0else if (pReach[pCurrentNode * 2 + 1] == 0 && pCurrentProfit + pRemainingProfit > pMaxProfit){pCurrentNode = pCurrentNode * 2 + 1;pHeight++;}else{//左右孩子都不滿足條件,則繼續向上回溯if(pTree[pCurrentNode] != 0){pCurrentWeight -= pTree[pCurrentNode];pCurrentProfit -= theProfit[pHeight];}pCurrentNode /= 2;//將孩子節點的價值加回pRemainingProfit += theProfit[pHeight+1];pHeight--;}}} }輸出背包裝入情況
上面遞歸和迭代兩個程序中,在更新最優解的同時記錄了一個變量pLastNode,它表示的是最優解的葉子節點在數組pTree中的索引。又因為從根節點到該葉子節點只有一條路徑,所以可以從葉子節點不斷跳轉到父節點(即不斷的pLastNode /= 2),直到pLastNode等于1。在這個過程中,如果到達的節點權值為0,那么表示相應高度對應的物品沒有裝入背包,如果達到的節點權值不為0,那么表示相應高度對應的物品裝入背包。所以在上述過程中,只需要根據pTree[pLastNode]的值就可以得到背包裝入問題。
pHeight = n; while(pLastNode != 1) {if(pTree[pLastNode] == 0)std::cout << "Backpack" << pHeight << ": " << 0 << std::endl;elsestd::cout << "Backpack" << pHeight << ": " << 1 << std::endl;pLastNode /= 2;pHeight--; }總結
以上是生活随笔為你收集整理的0/1背包问题-----回溯法求解的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 0/1背包问题-----动态规划求解
- 下一篇: 数据结构-----Trie树