leetcode算法題基礎(四十五) 回溯算法總結 (四) 回溯法的解空間表示方法


0 解題步驟

回溯法解題時通常包含3個步驟:

1. 針對所給問題,定義問題的解空間;

2. 確定易於搜索的解空間結構;

3. 以深度優先方式搜索解空間,並在搜索過程中用剪枝函數避免無效搜索。

對於問題的解空間結構通常以樹或圖的形式表示,常用的兩類典型的解空間樹是子集樹和排列樹。當所給的問題是從n個元素的集合S中找到S滿足某種性質的子集時,相應的解空間樹稱為子集樹。例如,n個物品的0-1背包問題所對應的解空間樹是一棵子集樹,這類子集樹通常有2**n個葉結點,遍歷子集樹的算法需要O(2**n)計算時間。當所給問題是確定n個元素滿足某種性質的排列時,相應的解空間樹稱為排列樹。排列樹通常有n!個葉結點。因此,排列樹需要O(n!)計算時間。當問題的解空間確定后,便可用不同的剪枝函數和最優解表示方法來獲得最終結果。下面,介紹用子集樹或排列樹構造解空間的常見問題。

1. 子集樹

圖1 子集樹

上圖是一棵n=3的子集樹。它是一棵完全二叉樹(有時可能是n叉樹,如圖着色問題,每一個結點可能有n種選擇),從根到每一個葉結點的路徑表示一個可行解。從根結點出發,以深度優先的方式搜索整棵樹。用回溯法搜索子集樹的一般算法可描述為:

復制代碼
void backtrack(int t)
{
    if(t > n) output(x);
    else
        for(int i = 0; i <= 1; i++) 
        {
            x[t] = i;
            if(constraint(t) && bound(t)) backtrack(t+1);
        }
}
復制代碼

其中,t表示遞歸深度,表示樹的第t層。當t > n時,算法已搜索到葉結點,由output( x ) 輸出可行解。否則,記錄當前選擇的x的值(即0或1),並遞歸遍歷所有子樹(這里只有左右子樹)。(constraint(t) && bound(t)) 表示剪枝函數,只有滿足剪枝函數的子樹才繼續遞歸。一棵子集樹一共有2**n個可搜索的解,對於所有可用子集樹表示解空間的問題都是在這

2**n個解中找到滿足條件的解,不同的是剪枝函數不同,最終解的表示形式不同。

1.1 求解組合

給定正整數n和k,求所有k個數的組合,例如,n=4,k=2,解為:

 

復制代碼
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]
復制代碼

此時,集合S={1,2,3,4},從第一個元素開始搜索,對於每一個元素可以選擇或者不選擇,當當前選擇到的元素個數等於k時記錄這個結果。因此,我們定義result來保存記錄的結果,

answer記錄了當前選擇元素,即當前的解。

復制代碼
class Solution {
public:
    void help(int n , int k , int start, vector<int> &answer, vector<vector<int>> & result) {
        if(k == answer.size()) {
            result.push_back(answer);  //記錄結果
            return ;
        }
        if(start > n) return; //搜索至葉結點
        answer.push_back(start);              // 選擇當前元素的情況
        help(n, k, start + 1, answer, result);// 
        answer.pop_back();
        help(n, k, start + 1, answer, result);  // 不選擇的情況
    }
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> result;
        vector<int> answer;
        help(n, k, 1, answer, result);
        return result;
    }
};
復制代碼

1.2 求解組合和

上面一個問題其實就是在所有S的子集中尋找大小為k的子集,所以最終的解是大小等於k的子集的集合。

現在,我們給定一個整數的集合C,和一個整數T,找到集合C中元素和為T的所有集合的集合。其中,對應每有個C中的元素可以重復多次。

那么,這個問題與上面問題的區別是:1.找到一個解的條件不同(和為T);2.元素可重復。

復制代碼
class Solution {
private:
    void help(vector<int>& candidates, int start, int target, int sum, vector<int>& answer, vector<vector<int>>& result) {
        if(sum == target) {
            result.push_back(answer);
            return;
        } 
        if(start == candidates.size() || sum + candidates[start] > target) return; // 添加一個剪枝函數,當加入當前元素使和大於T時那么這是一棵不符合條件的子樹  
        answer.push_back(candidates[start]);
        help(candidates, start, target, sum + candidates[start], answer, result);//依然從start開始,因為元素可重復
        answer.pop_back();
        help(candidates, start + 1, target, sum, answer, result);
    }
public:
    vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
        vector<int> answer;
        vector<vector<int>> result;
        sort(candidates.begin(), candidates.end());
        help(candidates, 0, target, 0, answer, result);
        return result;
    }
};
復制代碼

1.3 求解組合和II

上面一題再變一下,一個元素只能用一次。上面一題為了解決元素可重復的問題,我們遍歷的時候,當加入當前元素時,遞歸遍歷繼續從當前元素開始。而現在要讓元素只被選擇一次,那么我們改變搜索策略,當選擇當前元素時,我們從下一個元素遞歸遍歷,當沒有選擇當前元素時,我們需要從下一個與當前不重復的元素開始。

復制代碼
void help(vector<int> & candidates, int start, int sum, int target, vector<int>& answer, vector<vector<int>>& result) {
        if(sum == target) {
            result.push_back(answer);
            return ;
        }
        if(start == candidates.size() || sum + candidates[start] > target) return;
        answer.push_back(candidates[start]);
        help(candidates, start + 1, sum + candidates[start], target, answer, result);
        answer.pop_back();
        while(start + 1 < candidates.size() && candidates[start + 1] == candidates[start]) start++;  //去掉重復的元素
        help(candidates, start + 1, sum, target, answer, result);
    }
復制代碼

2. 排列樹

pl

上圖是一棵表示旅行售貨員問題的排列樹,從根到葉結點表示一條可選路徑。與子集樹不同的是,每一個當前結點的搜索策略是選擇剩下的元素中的一個,而子集樹是選擇或不選擇當前元素。用回溯法搜索排列樹的一般算法為:

復制代碼
void backtrack(int t)
{
    if(t > n) output(x);
    else
        for(int i = t; i <= n; i++)  //與子集樹不同,搜索剩下的結點
        {
            swap(x[t], x[i]);
            if(constraint(t)&&bound(t)) backtrack(t+1);
            swap(x[t], x[i]);
        }
}
復制代碼

2.1 求排列

For example,
[1,2,3] have the following permutations:
[1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], and [3,2,1].

這是一個最簡單的排列樹搜索問題,因為問題的解就是葉結點個數,即n!個。因此,它沒有剪枝函數,只要搜索到葉結點就保存一個解。

復制代碼
class Solution {
private:
    void help(vector<int>& nums, vector<int>& pos, int now, vector<vector<int>>& result) {
        int n = nums.size();
        if(now == n) { //
            vector<int> answer(n, 0);
            for(int i = 0; i < n; i++) {
                answer[i] = nums[pos[i]];  //pos保存元素的下標,這里構造需要的解
            }
            result.push_back(answer);
            return;
        }
        for(int i = now; i < n; i++) {
            swap(pos[now], pos[i]);
            help(nums, pos, now + 1, result);
            swap(pos[now], pos[i]);
        }
    }
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> result;
        int n = nums.size();
        if(n == 0) return result;
        vector<int> pos(n, 0);
        for(int i = 0; i < n; i++) {
            pos[i] = i;
        }
        help(nums, pos, 0, result);
        return result;
    }
};
復制代碼

2.2 n-Queens 問題

n-Queens問題是把n個皇后放在nxn的棋盤上,讓她們不能互相攻擊。按照國際象棋的規則,皇后可以攻擊與之處在同一行或同一列或者同一斜線上的棋子。一個4皇后問題的解為:

復制代碼
[
 [".Q..",  // Solution 1
  "...Q",
  "Q...",
  "..Q."],

 ["..Q.",  // Solution 2
  "Q...",
  "...Q",
  ".Q.."]
]
復制代碼

我們可以把問題轉換成n個元素的集合的排列問題,而這個排列需要滿足上面這個規則。我們按行優先的順序,從上往下給每一行填一個皇后。這就相當於,解是一個n元向量,向量的元素下標對應於一行行坐標,而元素對應於列坐標。這里為了方便得到最終形式的解,我們把每一個解表示成nxn的char型矩陣。並設置了3個mask用於剪枝函數,即colFlag、

diagFlag1、diagFlag2分別表示列和兩對角線的mask,在剪枝函數中只需判斷當前位置處這3個mask對應的位是否被置位。

復制代碼
class Solution {
private:
    int colFlag;
    int diagFlag1;
    int diagFlag2;
    bool isValid(int rowIdx, int colIdx, int n) {
        if((1 << colIdx) & colFlag) return false;
        if((1 << (colIdx + rowIdx)) & diagFlag1) return false;
        if((1 << (n + rowIdx - colIdx - 1)) & diagFlag2) return false;
        return true;
    }
    void setFlag(int rowIdx, int colIdx, int n) { //設置mask flag
        colFlag |= (1 << colIdx);
        diagFlag1 |= (1 << (colIdx + rowIdx));
        diagFlag2 |= (1 << (n + rowIdx - colIdx -1));
    }
    void unsetFlag(int rowIdx, int colIdx, int n) { //取消mask flag
        colFlag &= ~(1 << colIdx);
        diagFlag1 &= ~(1 << (colIdx + rowIdx));
        diagFlag2 &= ~(1 << (n + rowIdx - colIdx -1));
    }
    void help(int n, vector<string>& answer, vector<vector<string>>& result) {
        int rowIdx = answer.size();
        if(rowIdx == n) { //搜索完成,記錄結果
            result.push_back(answer);
            return;
        }
        answer.push_back(string(n,'.'));
        for(int i = 0; i < n; ++i) {
            if(isValid(rowIdx, i, n)) { //如果當前位置可用
                setFlag(rowIdx, i, n); //設置flag表示選擇了
                answer.back()[i] = 'Q';
                help(n, answer, result);
                answer.back()[i] = '.';
                unsetFlag(rowIdx, i, n);//取消flag,回溯
            }
        }
        answer.pop_back();
    }
public:
    vector<vector<string> > solveNQueens(int n) {
        colFlag = diagFlag1 = diagFlag2 = 0;
        vector<string> answer;
        vector<vector<string>> result;
        help(n, answer, result);
        return result;
    }
};
復制代碼

總結:

子集樹和排列樹的不同是每一步的選擇策略不同。子集樹每一步對應的是對應的元素的選擇或不選擇,排列樹每一步對應的是剩下的元素選擇其中一個。一個的可搜索的解為2**n,一個為n!,因此,一個高效的回溯法算法必須依賴於剪枝函數來避免無效搜索。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM