C++回溯算法

2023-09-20 09:23 更新

「回溯算法 backtracking algorithm」是一種通過窮舉來解決問題的方法,它的核心思想是從一個初始狀態(tài)出發(fā),暴力搜索所有可能的解決方案,當(dāng)遇到正確的解則將其記錄,直到找到解或者嘗試了所有可能的選擇都無法找到解為止。

回溯算法通常采用“深度優(yōu)先搜索”來遍歷解空間。在二叉樹章節(jié)中,我們提到前序、中序和后序遍歷都屬于深度優(yōu)先搜索。接下來,我們利用前序遍歷構(gòu)造一個回溯問題,逐步了解回溯算法的工作原理。

例題一

給定一個二叉樹,搜索并記錄所有值為 7 的節(jié)點,請返回節(jié)點列表。

對于此題,我們前序遍歷這顆樹,并判斷當(dāng)前節(jié)點的值是否為 7 ,若是則將該節(jié)點的值加入到結(jié)果列表 res 之中。相關(guān)過程實現(xiàn)如圖 13-1 和以下代碼所示。


preorder_traversal_i_compact.cpp

/* 前序遍歷:例題一 */
void preOrder(TreeNode *root) {
    if (root == nullptr) {
        return;
    }
    if (root->val == 7) {
        // 記錄解
        res.push_back(root);
    }
    preOrder(root->left);
    preOrder(root->right);
}

在前序遍歷中搜索節(jié)點

圖 13-1   在前序遍歷中搜索節(jié)點

嘗試與回退

之所以稱之為回溯算法,是因為該算法在搜索解空間時會采用“嘗試”與“回退”的策略。當(dāng)算法在搜索過程中遇到某個狀態(tài)無法繼續(xù)前進或無法得到滿足條件的解時,它會撤銷上一步的選擇,退回到之前的狀態(tài),并嘗試其他可能的選擇。

對于例題一,訪問每個節(jié)點都代表一次“嘗試”,而越過葉結(jié)點或返回父節(jié)點的 return 則表示“回退”。

值得說明的是,回退并不僅僅包括函數(shù)返回。為解釋這一點,我們對例題一稍作拓展。

例題二

在二叉樹中搜索所有值為 7 的節(jié)點,請返回根節(jié)點到這些節(jié)點的路徑。

在例題一代碼的基礎(chǔ)上,我們需要借助一個列表 path 記錄訪問過的節(jié)點路徑。當(dāng)訪問到值為 7 的節(jié)點時,則復(fù)制 path 并添加進結(jié)果列表 res 。遍歷完成后,res 中保存的就是所有的解。

preorder_traversal_ii_compact.cpp

/* 前序遍歷:例題二 */
void preOrder(TreeNode *root) {
    if (root == nullptr) {
        return;
    }
    // 嘗試
    path.push_back(root);
    if (root->val == 7) {
        // 記錄解
        res.push_back(path);
    }
    preOrder(root->left);
    preOrder(root->right);
    // 回退
    path.pop_back();
}

在每次“嘗試”中,我們通過將當(dāng)前節(jié)點添加進 path 來記錄路徑;而在“回退”前,我們需要將該節(jié)點從 path 中彈出,以恢復(fù)本次嘗試之前的狀態(tài)。

觀察圖 13-2 所示的過程,我們可以將嘗試和回退理解為“前進”與“撤銷”,兩個操作是互為逆向的。

嘗試與回退preorder_find_paths_step2preorder_find_paths_step3preorder_find_paths_step4preorder_find_paths_step5preorder_find_paths_step6preorder_find_paths_step7preorder_find_paths_step8preorder_find_paths_step9preorder_find_paths_step10preorder_find_paths_step11

圖 13-2   嘗試與回退

13.1.2   剪枝

復(fù)雜的回溯問題通常包含一個或多個約束條件,約束條件通??捎糜凇凹糁Α?/strong>。

例題三

在二叉樹中搜索所有值為 7 的節(jié)點,請返回根節(jié)點到這些節(jié)點的路徑,并要求路徑中不包含值為 3 的節(jié)點。

為了滿足以上約束條件,我們需要添加剪枝操作:在搜索過程中,若遇到值為 3 的節(jié)點,則提前返回,停止繼續(xù)搜索。

preorder_traversal_iii_compact.cpp

/* 前序遍歷:例題三 */
void preOrder(TreeNode *root) {
    // 剪枝
    if (root == nullptr || root->val == 3) {
        return;
    }
    // 嘗試
    path.push_back(root);
    if (root->val == 7) {
        // 記錄解
        res.push_back(path);
    }
    preOrder(root->left);
    preOrder(root->right);
    // 回退
    path.pop_back();
}

剪枝是一個非常形象的名詞。如圖 13-3 所示,在搜索過程中,我們“剪掉”了不滿足約束條件的搜索分支,避免許多無意義的嘗試,從而提高了搜索效率。

根據(jù)約束條件剪枝

圖 13-3   根據(jù)約束條件剪枝

13.1.3   框架代碼?

接下來,我們嘗試將回溯的“嘗試、回退、剪枝”的主體框架提煉出來,提升代碼的通用性。

在以下框架代碼中,state 表示問題的當(dāng)前狀態(tài),choices 表示當(dāng)前狀態(tài)下可以做出的選擇。

/* 回溯算法框架 */
void backtrack(State *state, vector<Choice *> &choices, vector<State *> &res) {
    // 判斷是否為解
    if (isSolution(state)) {
        // 記錄解
        recordSolution(state, res);
        // 停止繼續(xù)搜索
        return;
    }
    // 遍歷所有選擇
    for (Choice choice : choices) {
        // 剪枝:判斷選擇是否合法
        if (isValid(state, choice)) {
            // 嘗試:做出選擇,更新狀態(tài)
            makeChoice(state, choice);
            backtrack(state, choices, res);
            // 回退:撤銷選擇,恢復(fù)到之前的狀態(tài)
            undoChoice(state, choice);
        }
    }
}

接下來,我們基于框架代碼來解決例題三。狀態(tài) state 為節(jié)點遍歷路徑,選擇 choices 為當(dāng)前節(jié)點的左子節(jié)點和右子節(jié)點,結(jié)果 res 是路徑列表。

preorder_traversal_iii_template.cpp

/* 判斷當(dāng)前狀態(tài)是否為解 */
bool isSolution(vector<TreeNode *> &state) {
    return !state.empty() && state.back()->val == 7;
}

/* 記錄解 */
void recordSolution(vector<TreeNode *> &state, vector<vector<TreeNode *>> &res) {
    res.push_back(state);
}

/* 判斷在當(dāng)前狀態(tài)下,該選擇是否合法 */
bool isValid(vector<TreeNode *> &state, TreeNode *choice) {
    return choice != nullptr && choice->val != 3;
}

/* 更新狀態(tài) */
void makeChoice(vector<TreeNode *> &state, TreeNode *choice) {
    state.push_back(choice);
}

/* 恢復(fù)狀態(tài) */
void undoChoice(vector<TreeNode *> &state, TreeNode *choice) {
    state.pop_back();
}

/* 回溯算法:例題三 */
void backtrack(vector<TreeNode *> &state, vector<TreeNode *> &choices, vector<vector<TreeNode *>> &res) {
    // 檢查是否為解
    if (isSolution(state)) {
        // 記錄解
        recordSolution(state, res);
    }
    // 遍歷所有選擇
    for (TreeNode *choice : choices) {
        // 剪枝:檢查選擇是否合法
        if (isValid(state, choice)) {
            // 嘗試:做出選擇,更新狀態(tài)
            makeChoice(state, choice);
            // 進行下一輪選擇
            vector<TreeNode *> nextChoices{choice->left, choice->right};
            backtrack(state, nextChoices, res);
            // 回退:撤銷選擇,恢復(fù)到之前的狀態(tài)
            undoChoice(state, choice);
        }
    }
}

根據(jù)題意,我們在找到值為 7 的節(jié)點后應(yīng)該繼續(xù)搜索,因此需要將記錄解之后的 return 語句刪除。圖 13-4 對比了保留或刪除 return 語句的搜索過程。

保留與刪除 return 的搜索過程對比

圖 13-4   保留與刪除 return 的搜索過程對比

相比基于前序遍歷的代碼實現(xiàn),基于回溯算法框架的代碼實現(xiàn)雖然顯得啰嗦,但通用性更好。實際上,許多回溯問題都可以在該框架下解決。我們只需根據(jù)具體問題來定義 statechoices ,并實現(xiàn)框架中的各個方法即可。

13.1.4   常用術(shù)語

為了更清晰地分析算法問題,我們總結(jié)一下回溯算法中常用術(shù)語的含義,并對照例題三給出對應(yīng)示例。

表 13-1   常見的回溯算法術(shù)語

名詞 定義 例題三
解 Solution 解是滿足問題特定條件的答案,可能有一個或多個 根節(jié)點到節(jié)點 7 的滿足約束條件的所有路徑
約束條件 Constraint 約束條件是問題中限制解的可行性的條件,通常用于剪枝 路徑中不包含節(jié)點 3
狀態(tài) State 狀態(tài)表示問題在某一時刻的情況,包括已經(jīng)做出的選擇 當(dāng)前已訪問的節(jié)點路徑,即 path 節(jié)點列表
嘗試 Attempt 嘗試是根據(jù)可用選擇來探索解空間的過程,包括做出選擇,更新狀態(tài),檢查是否為解 遞歸訪問左(右)子節(jié)點,將節(jié)點添加進 path ,判斷節(jié)點的值是否為 7
回退 Backtracking 回退指遇到不滿足約束條件的狀態(tài)時,撤銷前面做出的選擇,回到上一個狀態(tài) 當(dāng)越過葉結(jié)點、結(jié)束結(jié)點訪問、遇到值為 3 的節(jié)點時終止搜索,函數(shù)返回
剪枝 Pruning 剪枝是根據(jù)問題特性和約束條件避免無意義的搜索路徑的方法,可提高搜索效率 當(dāng)遇到值為 3 的節(jié)點時,則終止繼續(xù)搜索

Tip

問題、解、狀態(tài)等概念是通用的,在分治、回溯、動態(tài)規(guī)劃、貪心等算法中都有涉及。

13.1.5   優(yōu)勢與局限性

回溯算法本質(zhì)上是一種深度優(yōu)先搜索算法,它嘗試所有可能的解決方案直到找到滿足條件的解。這種方法的優(yōu)勢在于它能夠找到所有可能的解決方案,而且在合理的剪枝操作下,具有很高的效率。

然而,在處理大規(guī)模或者復(fù)雜問題時,回溯算法的運行效率可能難以接受

  • 時間:回溯算法通常需要遍歷狀態(tài)空間的所有可能,時間復(fù)雜度可以達到指數(shù)階或階乘階。
  • 空間:在遞歸調(diào)用中需要保存當(dāng)前的狀態(tài)(例如路徑、用于剪枝的輔助變量等),當(dāng)深度很大時,空間需求可能會變得很大。

即便如此,回溯算法仍然是某些搜索問題和約束滿足問題的最佳解決方案。對于這些問題,由于無法預(yù)測哪些選擇可生成有效的解,因此我們必須對所有可能的選擇進行遍歷。在這種情況下,關(guān)鍵是如何進行效率優(yōu)化,常見的效率優(yōu)化方法有兩種。

  • 剪枝:避免搜索那些肯定不會產(chǎn)生解的路徑,從而節(jié)省時間和空間。
  • 啟發(fā)式搜索:在搜索過程中引入一些策略或者估計值,從而優(yōu)先搜索最有可能產(chǎn)生有效解的路徑。

13.1.6   回溯典型例題

回溯算法可用于解決許多搜索問題、約束滿足問題和組合優(yōu)化問題。

搜索問題:這類問題的目標是找到滿足特定條件的解決方案。

  • 全排列問題:給定一個集合,求出其所有可能的排列組合。
  • 子集和問題:給定一個集合和一個目標和,找到集合中所有和為目標和的子集。
  • 漢諾塔問題:給定三個柱子和一系列大小不同的圓盤,要求將所有圓盤從一個柱子移動到另一個柱子,每次只能移動一個圓盤,且不能將大圓盤放在小圓盤上。

約束滿足問題:這類問題的目標是找到滿足所有約束條件的解。

  • n 皇后:在 n×n 的棋盤上放置 n 個皇后,使得它們互不攻擊。
  • 數(shù)獨:在 9×9 的網(wǎng)格中填入數(shù)字 1 ~ 9 ,使得每行、每列和每個 3×3 子網(wǎng)格中的數(shù)字不重復(fù)。
  • 圖著色問題:給定一個無向圖,用最少的顏色給圖的每個頂點著色,使得相鄰頂點顏色不同。

組合優(yōu)化問題:這類問題的目標是在一個組合空間中找到滿足某些條件的最優(yōu)解。

  • 0-1 背包問題:給定一組物品和一個背包,每個物品有一定的價值和重量,要求在背包容量限制內(nèi),選擇物品使得總價值最大。
  • 旅行商問題:在一個圖中,從一個點出發(fā),訪問所有其他點恰好一次后返回起點,求最短路徑。
  • 最大團問題:給定一個無向圖,找到最大的完全子圖,即子圖中的任意兩個頂點之間都有邊相連。

請注意,對于許多組合優(yōu)化問題,回溯都不是最優(yōu)解決方案。

  • 0-1 背包問題通常使用動態(tài)規(guī)劃解決,以達到更高的時間效率。
  • 旅行商是一個著名的 NP-Hard 問題,常用解法有遺傳算法和蟻群算法等。
  • 最大團問題是圖論中的一個經(jīng)典問題,可用貪心等啟發(fā)式算法來解決。


以上內(nèi)容是否對您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號
微信公眾號

編程獅公眾號