当前位置: 首页 > news >正文

【Hot100】回溯

系列文章目录


提示:写完文章后,目录可以自动生成,如何生成可参考右边的帮助文档

文章目录

  • 系列文章目录
  • 方法论
  • Hot100 的回溯题
    • 46. 全排列
      • (1)解析
      • (2)代码
    • 78. 子集
      • (1)解析
      • (2)代码
    • 17. 电话号码的字母组合
    • 39. 组合总和
    • 22. 括号生成
    • 79. 单词搜索
    • 131. 分割回文串
      • (1)解析
      • (2)代码
    • 51. N 皇后
      • (1)解析
      • (2)代码


方法论

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

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

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

2、选择列表:也就是你当前可以做的选择。

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

result = []
def backtrack(路径, 选择列表):if 满足结束条件:result.add(路径)returnfor 选择 in 选择列表:做选择backtrack(路径, 选择列表)撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」, 特别简单。

Hot100 的回溯题

46. 全排列

「全排列」给定一个不含重复数字的数组 nums ,返回其所有可能的全排列 。你可以按任意顺序返回答案。

示例 1:
输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

示例 2:
输入:nums = [0,1]
输出:[[0,1],[1,0]]

示例 3:
输入:nums = [1]
输出:[[1]]

(1)解析

        我们都知道 n 个不重复的数,全排列共有 n! 个。那么我们是怎么穷举全排列的呢?比方说给三个数 [1,2,3],一般是这样:先固定第一位为 1,然后第二位可以是 2,那么第三位只能是 3;然后可以把第二位变成 3,第三位就只能是 2 了;然后就只能变化第一位,变成 2,然后再穷举后两位……
其实这就是回溯算法,我们无师自通就会用,并且我们可以画出如下这棵回溯树:
在这里插入图片描述
这棵树称为回溯算法的 「决策树」

为啥说这是决策树呢,因为你在每个节点上其实都在做决策。比如说你站在下图的红色节点上:
在这里插入图片描述
你现在就在做决策,可以选择 1 那条树枝,也可以选择 3 那条树枝。

对于回溯法,还有几个相关的概念:

  • 「路径」,[2] 就是走过的路径,记录你已经做过的选择;
  • 「选择列表」,[1,3] 就是选择列表,表示你当前可以做出的选择;
  • 「结束条件」 就是遍历到树的底层叶子节点,这里也就是选择列表为空的时候。

如果明白了这几个名词,可以把 「路径」「选择」 列表作为决策树上每个节点的属性,比如下图列出了几个蓝色节点的属性:
在这里插入图片描述

可以定义一个 backtrack 函数,让其像一个指针在这棵树上游走,同时正确维护每个节点的属性,每当走到树的底层叶子节点,其「路径」就是一个全排列。

再进一步,如何遍历一棵树?各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:

void traverse(TreeNode* root) {for (TreeNode* child : root->childern) {// 前序位置需要的操作traverse(child);// 后序位置需要的操作}
}

所谓的前序遍历和后序遍历,他们只是两个很有用的时间点。前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。

现在,就可以较好地理解回溯算法的这段核心框架了:

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

我们 只要在递归之前做出选择,在递归之后撤销刚才的选择,就能正确得到每个节点的选择列表和路径

(2)代码

class Solution {vector<vector<int>> res;
public:// 主函数,输入一组不重复的数字,返回它们的全排列vector<vector<int>> permute(vector<int>& nums) {list<int> track; // 记录「路径」vector<bool> used(nums.size(), false); // 「路径」中的元素会被标记为 true,避免重复使用backtrack(nums, track, used);return res;}private:// 路径:记录在 track 中// 选择列表:nums 中不存在于 track 的那些元素(used[i] 为 false)// 结束条件:nums 中的元素全都在 track 中出现void backtrack(vector<int>& nums, list<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]) // nums[i] 已经在 track 中,跳过continue;// 做选择track.push_back(nums[i]);used[i] = true;// 进入下一层决策树backtrack(nums, track, used);// 取消选择track.pop_back();used[i] = false;}}
};

这里稍微做了些变通,没有显式记录「选择列表」,而是通过 used 数组排除已经存在 track 中的元素,从而推导出当前的选择列表:
在这里插入图片描述

至此,我们就通过全排列问题详解了回溯算法的底层原理。当然,这个算法解决全排列不是最高效的,你可能看到有的解法连 used 数组都不使用,通过交换元素达到目的。但是那种解法稍微难理解一些,我会在
球盒模型:回溯算法两种穷举视角 中介绍。

但是必须说明的是,不管怎么优化,都符合回溯框架,而且 时间复杂度都不可能低于 O(N!),因为穷举整棵决策树是无法避免的,你最后肯定要穷举出 N! 种全排列结果。

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


78. 子集

78. 子集:给你一个整数数组 nums ,数组中的元素互不相同 。返回该数组所有可能的子集(幂集)。

解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

示例 1:
输入:nums = [1,2,3]
输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]

示例 2:
输入:nums = [0]
输出:[[],[0]]

(1)解析

在这里插入图片描述

首先,生成元素个数为 0 的子集,即空集 [],为了方便表示,我称之为 S_0

然后,在 S_0 的基础上生成元素个数为 1 的所有子集,我称为 S_1[1][2][3]

接下来,我们可以在 S_1 的基础上推导出 S_2,即元素个数为 2 的所有子集:[1, 2][1, 3][2, 3]

为什么集合 [2] 只需要添加 3,而不添加前面的 1 呢?因为集合中的元素不用考虑顺序,[1,2,3]2 后面只有 3,如果你添加了前面的 1,那么 [2,1] 会和之前已经生成的子集 [1,2] 重复。

换句话说,我们可以 通过保证元素之间的相对顺序不变来防止出现重复的子集

接着,我们可以通过 S_2 推出 S_3,实际上 S_3 中只有一个集合 [1,2,3],它是通过 [1,2] 推出的。整个推导过程就是上面一棵树。

如果把根节点作为第 0 层,将每个节点和根节点之间树枝上的元素作为该节点的值,那么第 n 层的所有节点就是大小为 n 的所有子集。 比如大小为 2 的子集就是这一层节点的值:

在这里插入图片描述

(2)代码

class Solution {
private:vector<vector<int>> res;vector<int> track;	
public:// 主函数vector<vector<int>> subsets(vector<int>& nums) {backtrack(nums, 0);return res;}// 回溯算法核心函数,遍历子集问题的回溯树void backtrack(vector<int>& nums, int start) {// 前序位置,每个节点的值都是一个子集res.push_back(track);// 回溯算法标准框架for (int i = start; i < nums.size(); i++) {// 做选择track.push_back(nums[i]);// 通过 start 参数控制树枝的遍历,避免产生重复的子集backtrack(nums, i + 1);// 撤销选择track.pop_back();}}
};

【总结】
下面我们比较一下全排列子集 插入一个结果的时机。

void backtrack(vector<int>& nums, list<int>& track, vector<bool>& used) {// 触发结束条件if (track.size() == nums.size()) {res.push_back(track);	return;}for(...)...}
void backtrack(vector<int>& nums, int start) {// 前序位置,每个节点的值都是一个子集res.push_back(track);for (...) }

造成差异的原因是,全排列的回溯树中,叶子节点才是可以添加的结果;而子集中每一个节点都是可以添加的结果。重点在于回溯树。


17. 电话号码的字母组合

17. 电话号码的字母组合:给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。
在这里插入图片描述

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例 1:
输入:digits = “23”
输出:[“ad”,“ae”,“af”,“bd”,“be”,“bf”,“cd”,“ce”,“cf”]

示例 2:
输入:digits = “”
输出:[]

示例 3:
输入:digits = “2”
输出:[“a”,“b”,“c”]

class Solution {
public:vector<string> res;string track;vector<string> strMap = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};vector<string> letterCombinations(string digits) {if(digits.empty()) return res;backtrack(digits, 0);return res;}void backtrack(string digits, int index){if(track.size() == digits.size()){res.push_back(track);return;}string str = strMap[digits[index] - '0'];   // 获取数字对应的字符串for(int i = 0; i < str.size(); i++){track.push_back(str[i]);backtrack(digits, index+1); // 利用index,递归遍历 digitstrack.pop_back();} }
};

39. 组合总和

给你一个 无重复元素的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。

对于给定的输入,保证和为 target 的不同组合数少于 150 个。

示例 1:
输入:candidates = [2,3,6,7], target = 7
输出:[[2,2,3],[7]] 解释: 2 和 3
可以形成一组候选,2 + 2 + 3 = 7 。
注意 2 可以使用多次。7 也是一个候选, 7 = 7 。仅有这两种组合。

示例 2:
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

示例 3:
输入: candidates = [2], target = 1
输出: []

class Solution {
public:vector<vector<int>> res;vector<int> track;vector<vector<int>> combinationSum(vector<int>& candidates, int target) {backtrack(candidates, target, 0);return res;}void backtrack(vector<int>& candidates, int target, int start){if(target == 0){res.push_back(track);return;}if(target < 0) return;for(int i = start; i < candidates.size(); i++){track.push_back(candidates[i]);target -= candidates[i];backtrack(candidates, target, i);	// 让下一次递归,下标从i开始,防止重复track.pop_back();target += candidates[i];}}
};

22. 括号生成

22. 括号生成:数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。

示例 1:
输入:n = 3
输出:[“((()))”,“(()())”,“(())()”,“()(())”,“()()()”]

示例 2:
输入:n = 1
输出:[“()”]

class Solution {
public:vector<string> res;string track;char chs[2] = {'(', ')'};vector<string> generateParenthesis(int n) {backtrack(n, 0, 0);return res;}void backtrack(int n, int l, int r){if(r > l || r > n || l > n)return;if(track.size() == n*2){res.push_back(track);return;}for(int i = 0; i < 2; ++i){track.push_back(chs[i]);chs[i] == '('? l++ : r++;backtrack(n, l, r);track.pop_back();chs[i] == '('? l-- : r--;}}
};

79. 单词搜索

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

示例1:
在这里插入图片描述
输入:board = [[‘A’,‘B’,‘C’,‘E’],[‘S’,‘F’,‘C’,‘S’],[‘A’,‘D’,‘E’,‘E’]], word = “ABCCED”
输出:true

示例2:
在这里插入图片描述
输入:board = [[‘A’,‘B’,‘C’,‘E’],[‘S’,‘F’,‘C’,‘S’],[‘A’,‘D’,‘E’,‘E’]], word = “SEE”
输出:true

示例3:
在这里插入图片描述
输入:board = [[‘A’,‘B’,‘C’,‘E’],[‘S’,‘F’,‘C’,‘S’],[‘A’,‘D’,‘E’,‘E’]], word = “ABCB”
输出:false

class Solution {
public:int dirs[4][2] = {{1, 0}, {-1, 0}, {0, 1}, {0, -1}};bool exist(vector<vector<char>>& board, string word) {// 剪枝1:统计 board 中各字符数量unordered_map<char, int> count;for (auto& row : board)for (char c : row)count[c]++;// 检查 word 中每个字符是否足够for (char c : word) {if (--count[c] < 0) {return false;}}// 剪枝2:如果 word 太长,直接 false(可选)if (word.empty()) return false;// 每一个字母都可以作为起点for(int i = 0; i < board.size(); i++){for(int j = 0; j < board[0].size(); ++j){if(backtrack(board, word, i, j, 0))   // 找到一个可行解,就可以返回,避免不必要的递归return true;}     }return false;}bool backtrack(vector<vector<char>>& board, string& word, int i, int j, int k){// 边界检查 or 已访问 or 字符不匹配(中包含已访问的情况)if(i<0 || j<0 || i>=board.size() || j>=board[0].size() || k>word.size() || board[i][j] != word[k]) return false;if (k == word.size() - 1) {return true;}board[i][j] = '#';	// 访问过设为特殊字符,后续递归回出现字符不匹配,避免重复访问// 可以往四个方向递归for(auto dir : dirs){if(backtrack(board, word, i+dir[0], j+dir[1], k+1))return true;}board[i][j] = word[k];return false;}
};

131. 分割回文串

131. 分割回文串:给你一个字符串 s,请你将 s 分割成一些 子串,使每个子串都是 回文串 。返回 s 所有可能的分割方案。

示例 1:
输入:s = “aab”
输出:[[“a”,“a”,“b”],[“aa”,“b”]]

示例 2:
输入:s = “a”
输出:[[“a”]]

(1)解析

该题主要有两个难点:1、怎么判断回文;2、怎么回溯。整体思路分为两大步骤:

  • 预处理:用动态规划(DP)快速判断任意子串 s[i…j] 是否为回文
  • 搜索:用 DFS(回溯)枚举所有合法的回文分割方案

1、动态规划
使用二维数组 vector<vector<int>> f; 来记录字符串的回文情况,f[i][j] 表示 s[i..j] 是否为回文串(true 表示是)。

一开始,先初始化 f 数组全为true。初始化为 true 是为了后续 DP 递推方便,并且长度为 1 的子串(i == j)一定是回文。

状态转移:一个子串是回文,当且仅当:首尾字符相同:s[i] == s[j]并且中间部分也是回文:f[i+1][j-1]true。核心代码如下:

for (int i = n - 1; i >= 0; --i) {for (int j = i + 1; j < n; ++j) {f[i][j] = (s[i] == s[j]) && f[i + 1][j - 1];}
}

为什么 i 要从 n-1 往 0 遍历?
因为 f[i][j] 依赖 f[i+1][j-1](左下角),所以必须 从下往上、从左往右 填表。
举例:s = “aab”
在这里插入图片描述

  • f[0][1] = (s[0] == s[1]) && f[1][0] → 但 i > j 时我们默认 f[i][j] 为 true(逻辑上空串是回文)
  • 实际上当 j == i+1 时,f[i+1][j-1] 就是 f[i+1][i],即 i > j,我们视作 true(边界条件)。

所以 “aa” 是回文 ✅
“aab”:s[0] == s[2]? 否 → f[0][2] = false

2、DFS

核心代码:

void dfs(const string& s, int i) {if (i == n) {ret.push_back(ans);return;}for (int j = i; j < n; ++j) {if (f[i][j]) {  // s[i..j] 是回文,可以作为一段ans.push_back(s.substr(i, j - i + 1));  // 加入当前回文段dfs(s, j + 1);  // 从 j+1 继续分割ans.pop_back(); // 回溯:撤销选择}}
}

📌 DFS 逻辑:

  • 当前从位置 i 开始尝试所有可能的结束位置 j
  • 如果 s[i…j] 是回文(查 f[i][j]),就把它作为一个片段加入 ans
  • 然后递归处理剩下的部分 s[j+1…]
  • 回溯时 pop_back(),尝试其他分割方式

(2)代码

class Solution {
private:vector<vector<int>> f;      // f[i][j] 表示 s[i..j] 是否为回文串(true 表示是)vector<vector<string>> ret; // 最终结果:存储所有分割方案vector<string> ans;         // 当前正在构造的一个分割方案int n;  // 字符串长度public:void dfs(const string& s, int i) {if (i == n) {ret.push_back(ans);return;}for (int j = i; j < n; ++j) {if (f[i][j]) {  // s[i..j] 是回文,可以作为一段ans.push_back(s.substr(i, j - i + 1));  // 加入当前回文段dfs(s, j + 1);  // 从 j+1 继续分割ans.pop_back();}}}vector<vector<string>> partition(string s) {n = s.size();f.assign(n, vector<int>(n, true));  // 初始化 f 数组,因为长度为 1 的子串(i == j)一定是回文。// 动态规划,判断所有子串是否为回文for (int i = n - 1; i >= 0; --i) {for (int j = i + 1; j < n; ++j) {f[i][j] = (s[i] == s[j]) && f[i + 1][j - 1];    // f[i][j]:表示子串 s[i] 到 s[j] 是否为回文。}}dfs(s, 0);return ret;}
};

51. N 皇后

51. N 皇后:按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

(1)解析

        对于 N 皇后问题,我们知道每行必然有且只有一个皇后,所以如果我们决定在 board[i] 这一行的某一列放置皇后,那么接下来就不用管 board[i] 这一行了,应该考虑 board[i+1] 这一行的皇后要放在哪里。所以 N 皇后问题的穷举对象是棋盘中的行,每一行都持有一个皇后,可以选择把皇后放在该行的任意一列。

        首先再某一行放置皇后的时候,需要判断位置是否合法,需要检查列和斜对角线上是否有皇后造成冲突。该判断函数命名为isValid,函数内容如下:

// 是否可以在 board[row][col] 放置皇后?
bool isValid(const std::vector<std::string>& board, int row, int col) {int n = board.size();// 检查列是否有皇后互相冲突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 < n; i--, j++) {if (board[i][j] == 'Q')return false;}// 检查左上方是否有皇后互相冲突for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {if (board[i][j] == 'Q')return false;}return true;
}

(2)代码

class Solution {
public:vector<vector<string>> res;vector<vector<string>> solveNQueens(int n) {vector<string> board(n, string(n, '.'));backtrack(board, n, 0);return res;}void backtrack(vector<string>& board, int n, int row){if(row == n){res.push_back(board);return;}// 遍历所有列for(int j = 0; j < n; ++j){// 排除掉不合法选择if(isValid(board, row, j)){board[row][j] = 'Q';backtrack(board, n, row+1); // 递归下一行board[row][j] = '.';}}}bool isValid(vector<string>& board, int row, int col){int n = board.size();   // 行数、列数都为n// 检查列是否有皇后互相冲突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 < n; --i,++j){if(board[i][j] == 'Q')return false;}return true;}
};
http://www.xdnf.cn/news/1471267.html

相关文章:

  • 遇到“指责型人格”别硬碰硬!3个反拿捏技巧,让他从挑刺变闭嘴
  • 【前端教程】JavaScript DOM 操作实战案例详解
  • javafx笔记
  • 有序数组,距离目标最近的k个数 二分查找
  • 2025 年高教社杯全国大学生数学建模竞赛C 题 NIPT 的时点选择与胎儿的异常判定详解(一)
  • 数据库基础知识——聚合函数、分组查询
  • ResNet 迁移学习---加速深度学习模型训练
  • 瑞芯微RV1126目标识别算法Yolov8的部署应用
  • 关于kubernetes和docker版本的一些总结
  • 工业设备管理软件与AI_HawkEye智能运维平台_璞华大数据
  • 自定义格式化数据(BYOFD)(81)
  • Python快速入门专业版(五):从 print 到交互:Python 解释器与 IDLE 的基础使用
  • 如何在序列水平上简单分析一个新蛋白质序列(novel protein sequence)
  • AM J BOT | 黄芪稳健骨架树构建
  • 360° 拖动旋转的角度计算原理
  • LangChain: Memory
  • 嵌入式学习日记(41)串口
  • 数据库(基础操作)
  • 载流子寿命
  • 基于FPGA实现CRC校验码算法(以MODBUS中校验码要求为例)verilog代码+仿真验证
  • Python命令行选项(flags)解析
  • 漫画布局面板设计系统
  • 事务管理的选择:为何 @Transactional 并非万能,TransactionTemplate 更值得信赖
  • 从Java全栈到前端框架:一位程序员的实战之路
  • NestJS 整合 Redis 特性详解
  • 2025年统计与数据分析领域专业认证发展指南
  • [TryHackMe]Wordpress: CVE-2021-29447(wp漏洞利用-SSRF+WpGetShell)
  • harmony 中集成 tuanjie/unity
  • Leetcode每日一练--20
  • ESP-IDF串口中断接收