Loading

11 回溯

前言

如果要构造长度为\(2\)的字符串,可以写一个二重循环:

for x in "abc"
  for y in "def"

外层枚举第\(1\)个字母,内层枚举第\(2\)个字母,这样可以。
但是如果要构造长度为\(3\)或者\(4\)或者不确定呢?

原问题:构造长度为\(n\)的字符串\(\rightarrow\)枚举\(1\)个字母
子问题:构造长度为\(n-1\)的字符串

像这样增量构造答案的过程通常用递归实现。

思考回溯问题:

  1. 当前操作?枚举\(path[i]\)要填入的字母
  2. 子问题?dfs(i)表示构造字符串\(\geq i\)的部分
  3. 下一个子问题?构造字符串\(\geq i+1\)的部分

1 电话号码的字母组合

image

1.1 代码实现

点击查看代码
class Solution {
public:
    vector<string> letterCombinations(string digits) {
        unordered_map<char, string> hash_table {
            {'2', "abc"},
            {'3', "def"},
            {'4', "ghi"},
            {'5', "jkl"},
            {'6', "mno"},
            {'7', "pqrs"},
            {'8', "tuv"},
            {'9', "wxyz"},
        };
        vector<string> ans;
        int n = digits.size();
        // dfs(i)表示确定第i个索引对应的数字
        string path(n, 0);
        auto dfs = [&](this auto&& dfs, int i) {
            if (i == n) {
                ans.emplace_back(path);
                return;
            }
            for (auto& c: hash_table[digits[i]]) {
                path[i] = c;
                dfs(i + 1);
            }
        };
        dfs(0);
        return ans;
    }
};
  • 时间复杂度:\(n*4^n\)(可以从答案的角度来理解,\(digits\)的长度为\(n\),那么第\(1\)个字符就有\(4\)种可能,不断组合,最多有\(4^n\)种;又因为每次记录答案都需要\(O(n)\)的拷贝。)
  • 空间复杂度:\(O(n)\)

子集型回溯(包括\(0-1\)背包问题)

2 子集

image

2.1 解题思路

2.1.1 法一:

站在输入的角度思考问题。
每个元素都可以选 or 不选
根节点是空的,对于第\(1\)个元素,选 or 不选,生成二叉树;然后左根节点 和 又根节点 再对第\(2\)个元素进行判断,选 or 不选......叶子节点是答案。

思考回溯问题:

  1. 当前操作?枚举第\(i\)个数,选 or 不选
  2. 子问题?dfs(i)表示从下标\(\geq i\)的数字中构造子集
  3. 下一个子问题?从下标\(\geq i+1\)的数字中构造子集

2.1.2 法二:

站在构造答案的角度思考问题。
根节点为空。
每次必须选一个数,枚举答案的第\(1\)个数选谁,可能是\(1,2,3\);然后下一层,看第\(2\)个数选谁。如果第\(1\)层选择了\(1\),那么第二层可能选择\(2,3\)......每个节点都是答案
\([1,2]\)\([2,1]\)本质是同一个子集

思考回溯问题:

  1. 当前操作?枚举一个下标为\(j \geq i\)的数字
  2. 子问题?从下标\(\geq i\)的数字中构造子集
  3. 下一个子问题?从下标\(\geq j+1\)的数字中构造子集

2.2 代码实现

2.2.1 法一:

点击查看代码
class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> ans;
        int n = nums.size();
        if (n == 0) {
            return {};
        }
        vector<int> subset;
        auto dfs = [&](this auto&& dfs, int i) {
            if (i == n) {
                ans.push_back(subset);
                return;
            }
            // 如果选择的话
            subset.emplace_back(nums[i]);
            dfs(i + 1);
            subset.pop_back();
            // 如果不选择当前元素
            dfs(i + 1);
        };
        dfs(0);
        return ans;
    }
};
  • 时间复杂度:\(O(n*2^n)\)
  • 空间复杂度:\(O(n)\)

2.2.2 法二:

点击查看代码
class Solution {
public:
    vector<vector<int>> subsets(vector<int>& nums) {
        vector<vector<int>> ans;
        int n = nums.size();
        if (n == 0) {
            return {};
        }
        vector<int> subset;
        auto dfs = [&](this auto&& dfs, int i) {
            ans.emplace_back(subset);
            if (i == n) {
                return;
            }
            for (int j = i; j < n; ++j) {
                subset.emplace_back(nums[j]);
                dfs(j + 1);
                subset.pop_back();
            }
        };
        dfs(0);
        return ans;
    }
};

3 分割回文串

image
根据子集型回溯思考问题,看看自己掌握了没有?

3.1 解题思路

3.1.1 法一:

根据示例\(1\),枚举\([a,a,b]\)的两个逗号,选 or 不选。

思考回溯问题:

  1. 当前操作?枚举第\(i\)个逗号,选 or 不选
  2. 子问题?从下标\(\geq i\)的数字中构造子集
  3. 下一个子问题?从下标\(\geq i+1\)的数字中构造子集

3.1.2 法二:

站在构造答案的角度。

回溯三问:

  1. 当前操作?选择回文串\(s[i\cdots j]\)
  2. 子问题?从下标\(\geq i\)的后缀中构造回文串
  3. 下一个子问题?从下拨\(\geq j + 1\)的后缀中构造回文串

3.2 代码实现

3.2.1 法一

点击查看代码
class Solution {
    bool isPalindrome(string& s, int left, int right) { // [left, right]
        while (left < right) {
            if (s[left] != s[right]) {
                return false;
            }
            left++, right--;
        }
        return true;   
    }
public:
    vector<vector<string>> partition(string s) {
        // 应用法一:选 or 不选
        int n = s.size();
        if (n == 0) {
            return {};
        }
        vector<vector<string>> ans;
        vector<string> substr;
        // dfs(i, start) i表示i后面的逗号选 or 不选, start 当前回文串的起始位置
        auto dfs = [&](this auto&& dfs, int i, int start) {
            if (i == n - 1) {
                if (isPalindrome(s, start, i)) {
                    substr.emplace_back(s.substr(start, i - start + 1));
                    ans.emplace_back(substr);
                    substr.pop_back();
                }
                return;
            }
            // 加逗号
            if (isPalindrome(s, start, i)) {
                substr.emplace_back(s.substr(start, i - start + 1));
                dfs(i + 1, i + 1);
                substr.pop_back();
            }
            // 不加
            dfs(i + 1, start);
        };
        dfs(0, 0);
        return ans;
    }
};

3.2.2 法二

点击查看代码
class Solution {
    bool isPalindrome(string& s, int left, int right) { // [left, right]
        while (left < right) {
            if (s[left] != s[right]) {
                return false;
            }
            left++, right--;
        }
        return true;   
    }
public:
    vector<vector<string>> partition(string s) {
        // 应用法二:从构造答案的角度
        int n = s.size();
        if (n == 0) {
            return {};
        }
        vector<vector<string>> ans;
        vector<string> substr;
        // dfs(i)表示以第i个字符为起点,枚举字符串结束的位置
        auto dfs = [&](this auto&& dfs, int i) {
            if (i == n) {
                ans.emplace_back(substr);
                return;
            }
            // 加逗号
            for (int j = i; j < n; ++j) {
                if (isPalindrome(s, i, j)) {
                    substr.emplace_back(s.substr(i, j - i + 1));
                    dfs(j + 1);
                    substr.pop_back();
                }
            }
        };
        dfs(0);
        return ans;
    }
};
  • 时间复杂度:\(O(n2^n)\) 从答案的角度理解,选 or 不选一共有\(2^n-2\)情况,拷贝又至多是\(O(n)\)的,所以是\(O(n2^n)\)的。
  • 空间复杂度:\(O(n)\)

组合型回溯

4 组合

image

4.1 解题思路

4.1.1 法一

在子集[构造答案]的基础上增加逻辑判断减枝即可。

\(n\) 个数中选 \(k\) 个数的组合可以看成长度固定的子集

4.1.2 法二

选 or 不选

4.2 代码实现

4.2.1 法一

点击查看代码
class Solution {
public:
    vector<vector<int>> combine(int n, int k) {
        vector<vector<int>> ans;
        vector<int> subset;
        /*
        回溯三问:
        当前操作:选择第 $j >= i$ 元素
        原问题:dfs(i)表示选择第 $j >= i$ 元素,构造子集
        子问题:dfs(i+1)表示选择 $> j$的元素
        */
        // 优化1:我们是从小到大枚举的,枚举到哪1个数一定无法满足k个数了呢
        /*
            如果当前选择的元素数量为 size(),那么就还需要 k - size()个数,如果n - i + 1< k - size(第i个数还没选),直接返回
        */
        auto dfs = [&](this auto&& dfs, int i) {
            if (n - i + 1 < k - subset.size()) {
                return;
            }
            if (subset.size() == k) {
                ans.emplace_back(subset);
                return;
            }
            for (int j = i; j <= n; ++j) {
                subset.push_back(j);
                dfs(j + 1);
                subset.pop_back();
            }
        };
        dfs(1);
        return ans;
    }
};
  • 时间复杂度: \(O(kC^n_k)\)
  • 空间复杂度:\(O(k)\)
    注:如果说倒序枚举,设 \(path\) 长为 \(m\),那么还需要选 \(d=k-m\) 个数。设当前需要从 \([1,i]\)\(i\) 个数中选,那么 \(i < d\) 时,必然无法选出 \(k\) 个数,不需要再递归。

4.2.2 法二

点击查看代码
class Solution {
public:
    vector<vector<int>> combine(int n, int k) {
        // 选 or 不选
        /*
        回溯三问:当前的操作?第 $i$ 个数 选 or 不选
        原问题 从 $n$ 个数中选择 $k$ 个数
        子问题 从 $i + 1 ~n$个数中选择 $k-1$ 个数
        */
        vector<vector<int>> ans;
        vector<int> subset;
        auto dfs = [&](this auto&& dfs, int i) {
            if (subset.size() == k) {
                ans.emplace_back(subset);
                return;
            }
            if (i == n + 1) {
                return;
            }
            // 选
            subset.push_back(i);
            dfs(i + 1);
            subset.pop_back();
            // 不选
            dfs(i + 1);
        };
        dfs(1);
        return ans;
    }
};
  • 时间复杂度:\(O(kC^k_n)\)
  • 空间复杂度:\(O(k)\)

5 组合总和 III

image
image

5.1 解题思路

5.1.1 选 or 不选

5.1.2 构造答案

设还需要选择 \(d = k - m\) 个数字
设还需要选和为 \(t\) 的数字
(初始为 \(n\),每选一个数字 \(j\),就把 \(t\) 减小 \(j\))

剪枝:

  1. 剩余数字数目不够 \(i \leq d\)
  2. \(t \leq 0\)
  3. 剩余数字即使全部选最大的,和也不够 \(t\),例如 \(i=5\),还需要选 \(d=3\) 个数,那么如果 \(t > 5 + 4 + 3\),可以直接返回。

5.2 代码实现

5.2.1 法一

点击查看代码
class Solution {
public:
    vector<vector<int>> combinationSum3(int k, int n) {
        // 选 or 不选
        /* 剪枝 优化
            设还需要 d = k - subset.size() 个数字
            设还需要选择和为 target 的数字
            1. 剩余数字不够 d 个, i < d
            2. target <= 0
            3. 选择最大的 d 个数字, target依然 > 0 可以直接返回
            i + (i - 1) + ... + (i - d + 1) = d(i + i -d + 1)/2
            如果说d = 2
            
        */
        vector<vector<int>> ans;
        vector<int> subset;
        int target = n;
        auto dfs = [&](this auto&& dfs, int i) {
            int d = k - subset.size();
            if (i < d || target < 0 || target > d*(2*i-d+1)/2) {
                return;
            }
            if (subset.size() == k) {
                ans.emplace_back(subset);
                return;
            }
            // 选
            subset.push_back(i);
            target -= i;
            dfs(i - 1);
            subset.pop_back();
            target += i;
            // 不选
            dfs(i - 1);
        };
        dfs(9);
        return ans;
    }
};

5.2.2 法二

点击查看代码
class Solution {
public:
    vector<vector<int>> combinationSum3(int k, int n) {
        // 构造答案的角度
        vector<vector<int>> ans;
        vector<int> subset;
        /*
            回溯三问:当前操作?
            原问题: dfs(i) 选第 $j \geq i$ 个数
            子问题:dfs(i + 1) 选第 $ \geq j + 1$ 个数
        */
        /* 剪枝优化
            1. 剩余数字数目不够
            2. target < 0
            3. 剩余数字即使全部选择最大的,和也不够 target
                最大的数字是 i,还需要 d 个
                例如 i = 5,还需要 3 个
                target > 5 + 4 + 3
                条件应该是 target > i + (i - 1) + ... (i - d + 1) = d(i + i - d + 1) / 2

        */
        int target = n;
        auto dfs = [&](this auto&& dfs, int i) {
            int d = k - subset.size();
            if (i < d || target < 0 || target > d*(2*i - d + 1) / 2) {
                return;
            }
            if (subset.size() == k) {
                ans.emplace_back(subset);
                return;
            }
            for (int j = i; j >= 1; --j) {
                target -= j;
                subset.push_back(j);
                dfs(j - 1);
                subset.pop_back();
                target += j;
            }
        };
        dfs(9);
        return ans;
    }
};

6 括号生成

image

6.1 解题思路

6.1.1 选 or 不选

  1. 对于字符串的前缀,左括号的个数一定要大于等于右括号的个数。
  2. 左括号的个数是 \(n\)

这道题可以看成是从 \(2*n\) 个位置中选 \(n\) 个位置放置左括号。
对于一个位置,你选择,可以认为是放置左括号;你不选择,就等价于放置右括号。
上述是 选 or 不选的思路。

6.1.2 枚举下一个左括号的位置

6.2 代码实现

6.2.1 法一

点击查看代码
class Solution {
public:
    vector<string> generateParenthesis(int n) {
        // 选 or 不选
        /*
            回溯三问: 当前操作?枚举 $path[i]$是左括号还是右括号
                子问题? 构造 $\geq i$ 的部分
                下一个子问题? 构造 $\geq i + 1$ 的部分
        */
        vector<string> ans;
        string str(2 * n, 0);
        int left_cnt = 0;
        auto dfs = [&](this auto&& dfs, int i) {
            
            if (i == 2 * n) {
                ans.emplace_back(str);
                return;
            }
            // 选
            // 需要选 $n$ 个左括号,只要左括号的个数小于 $n$ 就可以选择左括号
            if (left_cnt < n) {
                str[i] = '(';
                left_cnt += 1;
                dfs(i + 1);
                left_cnt -= 1;
            }
            // 不选
            // 右括号的个数为 $i - left_cnt$,如果右括号的个数 < 左括号的个数,那么可以选择右括号
            if (i - left_cnt < left_cnt) {
                str[i] = ')';
                dfs(i + 1);
            }
        };
        dfs(0);
        return ans;
    }
};
  • 时间复杂度:\(O(2nC^n_{2n})\)
  • 空间复杂度:\(O(n)\)

6.2.2 枚举下一个左括号的位置

还是太抽象了,搞不明白,真遇上了再说吧。

排列型回溯

7 全排列

image

7.1 解题思路

回溯三问:
数组 \(path\) 记录路径上的数(已选数字),集合 \(s\) 记录未选数字。
当前操作?从集合 \(s\) 中枚举 \(path[i]\) 要填入的数字 \(x\)
子问题? 构造排列 \(\geq i\) 的部分,剩余未选数字集合为 \(s\)
下一个子问题? 构造排列 \(\geq i + 1\) 的部分,剩余未选数字集合为 \(s-\{x\}\)

7.2 代码实现

7.2.1 \(bool\) 数组

点击查看代码
class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        int n = nums.size();
        vector<int> temp(n, 0);
        vector<int> visited(n, false);
        auto dfs = [&](this auto&& dfs, int i) {
            if (i == n) {
                ans.emplace_back(temp);
                return;
            }
            for (int j = 0; j < n; ++j) {
                if (!visited[j]) {
                    temp[i] = nums[j];
                    visited[j] = true;
                    dfs(i + 1);
                    visited[j] = false;
                }
            }
        };
        dfs(0);
        return ans;
    }
};

7.2.2 哈希表

点击查看代码
class Solution {
public:
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ans;
        int n = nums.size();
        vector<int> temp(n, 0);
        unordered_set<int> hash_table;
        auto dfs = [&](this auto&& dfs, int i) {
            if (i == n) {
                ans.emplace_back(temp);
                return;
            }
            for (int j = 0; j < n; ++j) {
                int x = nums[j];
                if (!hash_table.contains(x)) {
                    hash_table.insert(x);
                    temp[i] = x;
                    dfs(i + 1);
                    hash_table.erase(x);
                }
            }
        };
        dfs(0);
        return ans;
    }
};
  • 时间复杂度: \(O(n*n!)\)
    解释:对于长度为 \(n\) 的数组,全排列的总数是 \(n!\)
    每生成一个排列,需要执行 \(n\) 次操作
    无论是哈希表还是 \(bool\)数组,查询时间都是 \(O(1)\)
  • 空间复杂度: \(O(n)\)
    解释:递归栈深度 + temp 数组 + hash_table 的空间,都是 O (n) 级别

8 \(N\) 皇后

image
image

8.1 解题思路

不同行,不同列 \(\rightarrow\) 每行每列恰好有一个皇后

证明:反证法
假设有一行,一个皇后都没有。那么剩下 \(n - 1\) 行,需要放 \(n\) 个皇后,那么必然有一行至少要放 \(2\) 个皇后,矛盾,所以每行恰好有一个皇后。

用一个长度为 \(n\) 的数组 \(col\) 记录皇后的位置,即第 \(i\) 行的皇后在第 \(col\) 列,那么 \(col\) 将是 \(0 ~ n - 1\) 的排列。
如图1 $\begin{bmatrix}1 & 3 & 0 & 2\end{bmatrix};
如图2 $\begin{bmatrix}2 & 1 & 3 & 1\end{bmatrix}。

于是,变成枚举 \(col\) 的全排列,每行只选一个,每列只选一个,同时还要判断右上(x+y=c)或者左下(x-y=c)是否有其他皇后。

8.2 代码实现

点击查看代码
class Solution {
public:
    vector<vector<string>> solveNQueens(int n) {
        // 全排列
        vector<vector<string>> ans;
        vector board(n, string(n, '.')); // 一开始棋盘是空的
        unordered_set<int> hash_table1; // 记录 r - c
        unordered_set<int> hash_table2; // 记录 r + c
        vector<bool> col(n, false);
        // r 表示当前要枚举的行号
        auto dfs = [&](this auto&& dfs, int r) {
            if (r == n) {
                ans.emplace_back(board);
                return;
            }
            // 在 (r, c) 放皇后
            for (int c = 0; c < n; ++c) {
                if (!hash_table1.contains(r + c) && !hash_table2.contains(r - c) && !col[c]) {
                    hash_table1.insert(r + c);
                    hash_table2.insert(r - c);
                    board[r][c] = 'Q';
                    col[c] = true;
                    dfs(r + 1);
                    hash_table1.erase(r + c);
                    hash_table2.erase(r - c);
                    board[r][c] = '.';
                    col[c] = false;
                }
            }
        };
        dfs(0);
        return ans;
    }
};

这里的话判断对角条件的哈希表也可以替换成 \(bool\) 数组,然后为了解决索引是负数,需要添加一个偏移量,负数最大为 \(0 - (n - 1)\)(row-col),所以 \(+(n - 1)\)即可。

  • 时间复杂度:\(O(n^2n!)\)
  • 空间复杂度:\(O(n)\)

完结撒花!

posted @ 2026-01-27 10:28  王仲康  阅读(2)  评论(0)    收藏  举报