回溯算法是一种通过探索所有可能的候选解来找出所有解的算法。如果候选解被确认不是一个解(或者至少不是最后一个解),回溯算法会通过在上一步进行一些变化来丢弃该解,即"回溯"并尝试其他可能的解。
回溯法通常用递归来实现,在递归调用的过程中"前进",在递归返回的过程中"回溯"。这种技术特别适用于解决以下几类问题:
提示:回溯算法本质上是暴力搜索,但通过剪枝可以大大提高效率。理解回溯的关键在于掌握"递归展开树"的概念。
LeetCode 491题要求我们找出给定整数数组中所有不同的非递减子序列,子序列长度至少为2。例如对于输入[4,6,7,7],输出应包含[4,6],[4,6,7],[4,6,7,7],[4,7],[4,7,7],[6,7],[6,7,7],[7,7]等。
初始解法使用了回溯+集合去重的思路:
cpp复制class Solution {
private:
vector<vector<int>> ans;
vector<int> res;
set<vector<int>> st;
bool isNonDecreasing(vector<int>& res) {
for(int i = 1; i < res.size(); i++) {
if(res[i] < res[i-1]) return false;
}
return true;
}
void backtracking(vector<int>& nums, int start, int n) {
if(res.size() >= 2 && isNonDecreasing(res)) {
if(st.find(res) == st.end()) {
st.insert(res);
ans.push_back(res);
}
if(start == n) return;
}
for(int i = start; i < n; i++) {
res.push_back(nums[i]);
backtracking(nums, i+1, n);
res.pop_back();
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0, nums.size());
return ans;
}
};
这个解法存在几个明显问题:
改进后的解法使用数组哈希和即时剪枝策略:
cpp复制class Solution {
private:
vector<vector<int>> ans;
vector<int> res;
void backtracking(vector<int>& nums, int start, int n) {
if(res.size() >= 2) {
ans.push_back(res);
}
if(start == n) return;
int used[201] = {0}; // -100~100映射到0~200
for(int i = start; i < n; i++) {
if(!res.empty() && nums[i] < res.back()) continue;
if(used[nums[i] + 100]) continue;
used[nums[i] + 100] = 1;
res.push_back(nums[i]);
backtracking(nums, i+1, n);
res.pop_back();
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0, nums.size());
return ans;
}
};
优化点分析:
注意:数组哈希法仅适用于数值范围已知且不大的情况。如果数值范围很大或未知,仍需使用unordered_set。
LeetCode 46题要求给定一个不含重复数字的数组,返回其所有可能的全排列。例如输入[1,2,3],输出应为[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]。
基础解法使用数值标记:
cpp复制class Solution {
private:
vector<vector<int>> ans;
vector<int> res;
int used[21] = {0}; // -10~10映射到0~20
void backtracking(vector<int>& nums, int n) {
if(res.size() == n) {
ans.push_back(res);
return;
}
for(int i = 0; i < n; i++) {
if(used[nums[i] + 10]) continue;
used[nums[i] + 10] = 1;
res.push_back(nums[i]);
backtracking(nums, n);
res.pop_back();
used[nums[i] + 10] = 0;
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
backtracking(nums, nums.size());
return ans;
}
};
使用下标标记法更通用,不受数值范围限制:
cpp复制class Solution {
private:
vector<vector<int>> ans;
vector<int> res;
vector<bool> used;
void backtracking(vector<int>& nums) {
if(res.size() == nums.size()) {
ans.push_back(res);
return;
}
for(int i = 0; i < nums.size(); i++) {
if(used[i]) continue;
used[i] = true;
res.push_back(nums[i]);
backtracking(nums);
res.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> permute(vector<int>& nums) {
used.resize(nums.size(), false);
backtracking(nums);
return ans;
}
};
关键区别:
实操技巧:在类内初始化成员变量时,vector
used(10,false)会报错,应该使用vector used = vector (10,false)或vector used{10,false}。
LeetCode 47题扩展了46题的条件,输入数组可能包含重复元素,要求返回所有不重复的全排列。例如输入[1,1,2],输出应为[[1,1,2],[1,2,1],[2,1,1]]。
解法需要结合排序和used数组进行树层去重:
cpp复制class Solution {
private:
vector<vector<int>> ans;
vector<int> res;
void backtracking(vector<int>& nums, vector<bool>& used) {
if(res.size() == nums.size()) {
ans.push_back(res);
return;
}
for(int i = 0; i < nums.size(); i++) {
if(i > 0 && nums[i] == nums[i-1] && !used[i-1]) continue;
if(used[i]) continue;
used[i] = true;
res.push_back(nums[i]);
backtracking(nums, used);
res.pop_back();
used[i] = false;
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(), nums.end());
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return ans;
}
};
关键点解析:
回溯算法通常遵循以下通用模板:
cpp复制void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
startIndex的使用:
去重策略:
结果收集时机:
哈希优化:
重复结果问题:
遗漏结果问题:
性能优化:
特殊语法问题:
调试建议:可以打印递归树和中间状态,帮助理解回溯过程。对于复杂问题,先在小规模数据上验证算法正确性。
在实际刷题和工程实践中,我总结了以下几点经验:
画图辅助理解:对于复杂的回溯问题,先画出递归树,明确每一层的选择和约束条件。这能帮助理清思路,避免逻辑错误。
剪枝是关键:回溯算法本质是暴力搜索,合理的剪枝能大幅提升效率。思考哪些路径可以提前终止,如何减少不必要的递归调用。
空间复杂度考量:虽然回溯通常时间复杂度较高,但也要注意空间使用。避免不必要的全局变量,合理重用中间数据结构。
测试用例设计:特别注意边界情况,如空输入、全部相同元素、极端大小输入等。这些情况往往容易暴露算法缺陷。
多种解法对比:对于同一问题,尝试用不同思路解决(如迭代vs递归,不同去重方式)。这能加深对问题本质的理解。
模板不是万能的:虽然回溯有通用模板,但具体问题需要灵活调整。理解算法本质比死记硬背模板更重要。
性能分析习惯:养成分析时间/空间复杂度的习惯,这有助于在面试中更好地解释自己的代码。
回溯算法虽然概念上简单,但要熟练掌握需要大量练习。建议从基础问题开始,逐步挑战更复杂的变种,同时注意总结各类问题的共性和特性。