回溯算法例题梳理

341 阅读6分钟

回溯算法

回溯算法(Backtracking)是一种广泛用于解决组合问题、排列问题、路径搜索问题等的算法。

回溯法(Backtracking)是一种尝试-回退的算法思想,适用于解决需要在决策过程中试探多种可能性的问题。它在探索所有解的过程中,可以在确定某条路径不满足条件时,立即回退到上一步,从而避免不必要的计算。这种方法特别适用于以下几类场景:

它的核心思想是:尝试每一种可能的选择,并在发现当前选择不符合条件时,回退到上一步,继续尝试其他的可能性。回溯算法的关键在于“试探”和“回退”,它在求解问题的过程中能够有效地避免不必要的计算。

回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高

回溯算法框架

抽象地说,解决一个回溯问题,实际上就是遍历一棵「决策树」的过程,树的每个叶子节点存放着一个合法答案。你把整棵树遍历一遍,把叶子节点上的答案都收集起来,就能得到所有的合法答案

站在回溯树的一个节点上,你只需要思考 3 个问题:

  1. 路径:也就是已经做出的选择。

    • 目的:记录之前的选择,按照题目的要求来判断当前选择是否合理(比如用过的不能再用、不能和之前的处于同一列同一行)

    • 图:决策树里从头节点到最底下竖着的那根都是路径(记录)。

      • 全排列就是已经得到的排列。
      • N皇后就是已经摆好前面Q的棋盘。
  2. 选择列表:也就是你当前可以做的选择。

    • 目的:确定当前这一步可以做多少个节点。

    • 图:决策树里当前节点下所有的分支都是选择。但是需要注意按照题目的限制进行「剪枝」。

      • 全排列的「选择列表」就是之前还没用过的元素。
      • N皇后就是这一行的每一列。
    • 工作顺序:选择完这一个之后就顺着「路径」往下选择,对应决策树里的一根树枝,等价于树的递归遍历。

    • 如何选择:遍历选择列表,判断是否满足要求。例如,是棋盘就遍历当前行的每一列,全排列就是遍历所有元素,但是都要判断当前元素(当前列)是否满足要求。

  3. 结束条件:也就是到达决策树底层,无法再做选择的条件。、

回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return
    
    for 选择 in 选择列表:
        # 做选择
				将该选择从选择列表移除
				路径.add(选择)
				
        backtrack(路径, 选择列表)
        
        # 撤销选择
		    路径.remove(选择)
		    将该选择再加入选择列表

适用场景

  • 适用场景

    1. 组合问题

    • 需要从一个集合中选择出满足特定条件的所有组合。
    • 例子:子集生成、组合求和、排列生成等。例如,从集合 {1, 2, 3} 中生成所有子集,或求解某个和为指定数的组合。

    2. 排列问题

    • 需要找出某个集合或数组的所有排列方式。
    • 例子:全排列问题。在给定数组 [1, 2, 3] 中,找出所有可能的排列方式。

    3. 划分问题

    • 将一个集合或数组划分成满足特定条件的子集。
    • 例子:将一个数组划分成两个和相等的子集,或划分为每部分和相同的多个子集。

    4. 路径搜索问题

    • 在图、网格或树结构中寻找所有可能的路径或特定条件的路径。
    • 例子:迷宫问题(找到所有可行路径),N皇后问题(在N×N棋盘上放置N个皇后,使它们互不攻击),八数码问题等。

    5. 数独与填充问题

    • 需要按照规则填充数独棋盘、填字游戏等,需要逐步试探填充的方案。
    • 例子:经典的数独问题,根据已知数字在9×9棋盘上填满数字,使每行、每列和每个3×3的小宫格内都含有1到9不重复的数字。

    6. 分配问题

    • 将若干元素分配到不同位置或组合,使得满足特定条件。
    • 例子:背包问题的分配,或将任务分配给工人以最小化时间、成本等。

    7. 约束满足问题(CSP)

    • 需要在满足多个约束条件的前提下找到解答。
    • 例子:八皇后问题、图的着色问题、逻辑谜题等。这类问题通常需要满足不同的限制条件,回溯法可以逐步试探解空间,直到找到符合条件的解。

    8. 分支限界问题

    • 这类问题通常使用回溯结合分支限界(Branch and Bound)方法来减少搜索空间。
    • 例子:旅行商问题(TSP)、最小生成树等。这些问题的解法通常会将解空间分割成多个部分,在回溯时通过限界条件剪枝,减少不必要的计算。

回溯算法的经典例题

1. 全排列问题

全排列问题要求从一个数组中找出所有可能的排列方式。假设数组是 [1, 2, 3],我们需要输出所有的排列。

image.png

思路:
  • 路径:记录当前已选的元素。

  • 选择列表:当前未选的元素。

  • 结束条件:路径的长度等于数组长度时,即完成一次排列。

代码实现:

class Solution {
private:
    vector<vector<int>> res;
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<int> track;
        vector<bool> used(nums.size(), false);
        backtrack(nums, track, used);
        return res;
    }

    void backtrack(vector<int>& nums, vector<int> &track, vector<bool>& used) {
        if (track.size() == nums.size()) {
            res.push_back(track);
            return;
        }

        for (int i = 0; i < nums.size(); i++) {
            if (used[i]) continue;
            used[i] = true;
            track.push_back(nums[i]);
            backtrack(nums, track, used);
            used[i] = false;
            track.pop_back();
        }
    }
};


2. N 皇后问题

N 皇后问题要求在一个 N×N 的棋盘上放置 N 个皇后,使得它们互不攻击。

思路:
  • 路径:记录当前棋盘上每一行已放置的皇后。

  • 选择列表:当前行的所有列。

  • 结束条件:所有行都已放置完皇后。

代码实现:

class Solution {
public:
    vector<vector<string>> results;
    
    vector<vector<string>> solveNQueens(int n) {
        string line(n, '.');
        vector<string> board(n, line);
        backtrack(board, 0);
        return results;
    }

    void backtrack(vector<string>& board, int row) {
        if (row == board.size()) {
            results.push_back(board);
            return;
        }

        for (int col = 0; col < board.size(); col++) {
            if (!is_valid(board, row, col)) continue;
            board[row][col] = 'Q';
            backtrack(board, row + 1);
            board[row][col] = '.';  // 撤销选择
        }
    }

    bool is_valid(vector<string>& board, int row, int col) {
        // 检查同一列是否已有皇后
        for (int i = 0; i < row; i++) {
            if (board[i][col] == 'Q') return false;
        }

        // 检查左上对角线
        for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
            if (board[i][j] == 'Q') return false;
        }

        // 检查右上对角线
        for (int i = row - 1, j = col + 1; i >= 0 && j < board.size(); i--, j++) {
            if (board[i][j] == 'Q') return false;
        }

        return true;
    }
};


总结

回溯算法通过递归的方式探索所有可能的解,并通过剪枝避免无效的计算。它广泛应用于组合、排列、路径搜索等问题,能够有效解决许多复杂的约束满足问题。在实际应用中,回溯算法虽然是暴力穷举的方式,但通过合理的剪枝策略,可以在一定程度上优化搜索效率。