跳转至

递归算法

在思考递归问题的时候,可以思考这3个问题

  1. ​终止条件​​:明确最简单的情况,直接返回结果。例如计算阶乘时 if n == 0: return 1
    1. 或者说明确问题的终止情况。
  2. 问题拆解:将当前问题转化成更小的同类问题。例如斐波那契数列 fib(n) = fib(n-1) + fib(n-2)
  3. 递归假设假设更小的同类问题已经解决之后,如何从小问题的解推出大问题的解。例如归并排序中合并两个已排序子数组。

回溯算法

回溯法(Backtracking)是一种通过“试错”来寻找问题解决方案的算法。它常用于解决组合优化问题(如排列、子集、组合、棋盘类问题),核心思想是:递归地尝试所有可能的选择路径,当发现当前路径无法得到解时,撤销最后一步选择(回溯),尝试其他分支

在思考回溯问题的时候,可以在脑中构造一棵搜索树。

核心特点

  1. 系统性:穷举所有可能的分支。
  2. 剪枝优化:提前终止不符合条件的分支(如重复值、越界)。
  3. 状态重置:撤销最后一步选择,恢复到之前的状态。
def backtrack(路径, 选择列表):
    if 满足终止条件:
        保存结果如将路径添加到全局列表
        return

    for 选择 in 选择列表:
        if 不满足剪枝条件:
            做选择将选择加入路径
            backtrack(更新后的路径, 新的选择列表)  # 递归进入下一层
            撤销选择将选择从路径移除

一些例子

46. 全排列 - 力扣(LeetCode)问题为例。这里的res就是全局结果列表,path对应路径,选择列表就是nums - used。

class Solution:
    def permute(self, nums: List[int]) -> List[List[int]]:
        res = []
        used = [False] * len(nums)  # 记录元素是否被使用过

        def backtrack(path):
            if len(path) == len(nums):  # 终止条件:路径长度等于数组长度
                res.append(path.copy())
                return

            for i in range(len(nums)):
                if not used[i]:  # 剪枝:跳过已使用的元素
                    used[i] = True      # 做选择
                    path.append(nums[i])
                    backtrack(path)     # 递归进入下一层
                    path.pop()          # 撤销选择
                    used[i] = False

        backtrack([])
        return res
步骤 对应代码段 关键操作
​终止条件​ if len(path) == len(nums) 保存完整排列
​问题拆解​ for i in range(len(nums)): 将问题分解为「选当前+排剩余」
​递归假设​ path.append(num); backtrack(path); path.pop() 动态组合选择与子问题的解

47. 全排列 II - 力扣(LeetCode)为例,求的是不重复的所有全排列。以样例[1,1,2]为例。用树来表示所有的排列可能性,就是普通的全排列;如果要去除重复的全排列,要剪枝的就是下面的绿色部分。

graph TD
    Root[Root] --> A["1"]
    Root --> B["1"]
    Root --> C["2"]

    A --> A1["1"]
    A --> A2["2"]

    B --> B1["1"]
    B --> B2["2"]

    C --> C1["1"]
    C --> C2["1"]

    A1 --> A1a["2"]
    A2 --> A2a["1"]

    B1 --> B1a["2"]
    B2 --> B2a["1"]

    C1 --> C1a["1"]
    C2 --> C2a["1"]

    A1a --> R1["[1,1,2]"]
    A2a --> R2["[1,2,1]"]
    B1a --> R3["[1,1,2]"]
    B2a --> R4["[1,2,1]"]
    C1a --> R5["[2,1,1]"]
    C2a --> R6["[2,1,1]"]

    style B fill:#9f9,stroke:#333,stroke-width:2px
    style B1 fill:#9f9,stroke:#333,stroke-width:2px
    style B2 fill:#9f9,stroke:#333,stroke-width:2px
    style B1a fill:#9f9,stroke:#333,stroke-width:2px
    style R3 fill:#9f9,stroke:#333,stroke-width:2px
    style R4 fill:#9f9,stroke:#333,stroke-width:2px
    style B2a fill:#9f9,stroke:#333,stroke-width:2px
    style C2 fill:#9f9,stroke:#333,stroke-width:2px
    style C2a fill:#9f9,stroke:#333,stroke-width:2px
    style R6 fill:#9f9,stroke:#333,stroke-width:2px

根据这个图我们不难总结出规律,如果我们已经在同一层遍历过相同的数了,就不用再遍历一次了。所以我们可以将[1,1,2]排序,然后如果nums[i] == nums[i-1] && used[i-1]就剪枝。

class Solution {
    vector<vector<int>> r;
    vector<bool> used;
public:
    void bt(vector<int>& nums, vector<int>& p) {
        if (p.size() == nums.size()) {
            r.push_back(p);
            return;
        }

        for (int i=0; i<nums.size(); i++) {
            if (i>0 && nums[i] == nums[i-1] && used[i-1]) {
                continue;
            } // 去掉这个就是普通的全排列
            if (!used[i]) {
                used[i] = true;
                p.push_back(nums[i]);
                bt(nums, p);
                used[i] = false;
                p.pop_back();
            }
        }
    }
    vector<vector<int>> permuteUnique(vector<int>& nums) {
        std::sort(nums.begin(), nums.end());
        used.resize(nums.size(), false);
        vector<int> p;
        bt(nums, p);
        return r;
    }
};

39. 组合总和 - 力扣(LeetCode)为例。这道题看起来可以使用动态规划来解决,但是题目求的不是方案数量而是所有组合,动态规划可能导致太高的空间复杂度。下面是回溯的程序,sum用来简化计算,也可以不用这个参数:

class Solution:
    def combinationSum(self, candidates: List[int], target: int) -> List[List[int]]:
        result = []

        def backtrack(path: List[int], sum: int):
            if sum == target:
                result.append(path.copy())
                return
            for num in candidates:
                if num + sum <= target and (len(path) == 0 or num >= path[-1]):
                    path.append(num)
                    backtrack(path, sum+num)
                    path.pop()

        backtrack([], 0)
        return result

40. 组合总和 II - 力扣(LeetCode) 这题和上面很像,很容易想到用搜索+回溯的方法完成。但是题目要求“答案不能包含重复的组合”,所以我在最后用了hash set去重。但是这并不是最好的办法。

class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        ret = []
        candidates.sort()
        n = len(candidates)

        def dfs(path, i, s):
            if i == n:
                if s == target:
                    ret.append(tuple(path))
                return

            path.append(candidates[i])
            dfs(path, i + 1, s + candidates[i])
            path.pop()
            dfs(path, i + 1, s)

        dfs([], 0, 0)
        return list(set(ret))

为了完成去重,可以将相同的数放在一起进行处理,也就是说,如果数 x 出现了 y 次,那么在递归时一次性地处理它们,即分别调用选择 0,1,⋯,y 次 x 的递归函数。

class Solution:
    def combinationSum2(self, candidates: List[int], target: int) -> List[List[int]]:
        ret = []
        nums = defaultdict(lambda: 0)
        for i in candidates:
            nums[i] += 1
        nums = list(nums.items())
        n = len(nums)

        def dfs(path, index, s):
            if index == n:
                if s == target:
                    ret.append(path.copy())
                return

            dfs(path, index + 1, s)
            most = min((target-s) // nums[index][0], nums[index][1])
            for i in range(1, most+1):
                path.append(nums[index][0])
                dfs(path, index + 1, s + nums[index][0]*i)
            for i in range(most):
                path.pop()

        dfs([], 0, 0)
        return ret

这是华为机考的走迷宫问题。经典的使用回溯的例子。我们使用上面的三步拆解这个问题:

def find_path(maze):
    h, w = len(maze), len(maze[0])
    visited = [[False for _ in range(w)] for _ in range(h)]
    path = []

    def dfs(i, j):
        # 1. 终止条件
        if i == h - 1 and j == w - 1:
            path.append((i, j))
            return True

        # 2. 问题拆解:尝试四个方向
        directions = [(-1, 0), (1, 0), (0, -1), (0, 1)]
        for di, dj in directions:
            ni, nj = i + di, j + dj
            # 检查新位置是否合法
            if 0 <= ni < h and 0 <= nj < w and maze[ni][nj] == 0 and not visited[ni][nj]:
                visited[ni][nj] = True  # 标记已访问
                # 3. 递归假设:假设 dfs(ni, nj) 能解决子问题
                if dfs(ni, nj):
                    # 4. 结果组合:将当前点加入路径
                    path.append((i, j))
                    return True
                visited[ni][nj] = False  # 回溯(本题唯一路径可省略)
        return False

    dfs(0, 0)
    path.reverse()  # 路径是从终点回溯的,需要反转
    return path
步骤 对应代码段 关键操作
​终止条件​ if i == h - 1 and j == w - 1: 走到迷宫终点,返回True
​问题拆解​ for di, dj in directions: 将问题分解为4个子问题
​递归假设​ if dfs(ni, nj): 如果子问题能解决,那么将当前点加入路径

一道原汁原味的数独题:Sudoku_牛客题霸_牛客网

import sys

def main():
    sudoku = []
    spaces = []
    for line in sys.stdin:
        a = [int(n) for n in line.split()]
        sudoku.append(a)
    for i in range(0,9):
        for j in range(0,9):
            if sudoku[i][j] == 0:
                spaces.append((i,j))

    def legal(x,y,num):
        for i in range(0,9):
            if sudoku[x][i] == num or sudoku[i][y] == num:
                return False
        for i in range(0,3):
            for j in range(0,3):
                if sudoku[x // 3 * 3 + i][y // 3 * 3 + j] == num:
                    return False
        return True

    def solve(n) -> bool:
        if n == len(spaces):
            return True
        x, y = spaces[n]
        for num in range(1,10):
            if legal(x,y,num):
                sudoku[x][y] = num
                if solve(n+1):
                    return True
                sudoku[x][y] = 0

    def output():
        for i in range(0,9):
            for j in range(0,9):
                print(sudoku[i][j], end = ' ')
            print()

    solve(0)
    output()

main()

参考链接

13.1   回溯算法 - Hello 算法