77. 组合
核心思路
组合问题无法通过多层for循环暴力实现(k层嵌套不现实),需用回溯法模拟多层循环:将问题抽象为「n为树宽、k为树深」的N叉树,横向遍历(for循环)选当前层元素,纵向遍历(递归)选下一层元素,遍历到叶子节点(path长度=k)时收集结果。
一、基础回溯解法
核心逻辑
- 全局变量:
result存最终组合集,path存单次组合; - 递归参数:
startIndex控制每层遍历起始位置(避免重复组合); - 终止条件:
path.size() == k时,将path存入result; - 单层逻辑:for循环横向遍历(从startIndex开始),递归纵向遍历(i+1避免重复),回溯撤销当前选择。
class Solution {
private:
vector<vector<int>> result; // 最终结果集
vector<int> path; // 单次组合路径
void backtracking(int n, int k, int startIndex) {
// 终止条件:路径长度达到k,收集结果
if (path.size() == k) {
result.push_back(path);
return;
}
// 横向遍历:从startIndex开始,避免重复组合
for (int i = startIndex; i <= n; i++) {
path.push_back(i); // 选择当前元素
backtracking(n, k, i + 1); // 递归:纵向遍历下一层
path.pop_back(); // 回溯:撤销选择
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1); // 初始起始位置为1
return result;
}
};
二、剪枝优化
核心逻辑
若当前起始位置后剩余元素数 < 所需元素数(k - path.size()),无需继续遍历,直接剪枝:
- 已选元素数:
path.size(); - 还需元素数:
k - path.size(); - 遍历终止位置:
n - (k - path.size()) + 1(+1因左闭区间)。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
// 剪枝:i <= 终止位置,避免无效遍历
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
三、核心总结
- 回溯本质:用「递归+for循环」模拟多层嵌套循环,解决k层for循环无法实现的问题;
- 去重关键:
startIndex控制每层遍历起始位置,避免出现[1,2]和[2,1]这类重复组合; - 剪枝逻辑:通过计算剩余元素数,减少无效遍历,提升效率(如n=4、k=3时,起始位置无需从3/4开始)。
基础解法直观易懂,剪枝优化是面试加分项,核心是理解「树形结构+循环不变量」的回溯逻辑。
216. 组合总和III
核心思路
在固定集合 [1,2,...,9] 中找 k个不重复数的组合,且组合和为n。核心逻辑与77.组合一致(回溯法+树形遍历),仅新增「和的限制」与「固定集合范围(1-9)」两个约束。
一、基础回溯解法
核心逻辑
- 全局变量:
result存最终结果集,path存单次组合; - 递归参数:
targetSum(目标和n)、k(组合长度)、sum(当前组合和)、startIndex(遍历起始位置); - 终止条件:
path.size() == k时,若sum == targetSum则收集结果,否则直接返回; - 单层逻辑:for循环遍历1-9(从startIndex开始),累加元素和、记录路径,递归后回溯撤销选择。
class Solution {
private:
vector<vector<int>> result; // 最终结果集
vector<int> path; // 单次组合路径
void backtracking(int targetSum, int k, int sum, int startIndex) {
// 终止条件:组合长度达k,判断和是否符合
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return;
}
// 横向遍历:固定范围1-9,startIndex避免重复
for (int i = startIndex; i <= 9; i++) {
sum += i; // 累加当前元素和
path.push_back(i); // 记录当前元素
backtracking(targetSum, k, sum, i + 1); // 递归下一层
sum -= i; // 回溯:撤销和
path.pop_back(); // 回溯:撤销元素
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(n, k, 0, 1); // 初始和为0,起始位置1
return result;
}
};
二、剪枝优化(关键优化)
核心逻辑
- 提前终止:若当前组合和
sum > targetSum,直接返回(后续元素更大,无需遍历); - 遍历范围剪枝:
i <= 9 - (k - path.size()) + 1(剩余元素数不足时,停止遍历)。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (sum > targetSum) return; // 剪枝:和超目标,直接返回
if (path.size() == k) {
if (sum == targetSum) result.push_back(path);
return;
}
// 剪枝:缩小遍历范围,避免无效循环
for (int i = startIndex; i <= 9 - (k - path.size()) + 1; i++) {
sum += i;
path.push_back(i);
backtracking(targetSum, k, sum, i + 1);
sum -= i;
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
backtracking(n, k, 0, 1);
return result;
}
};
三、核心总结
- 与77.组合的区别:新增「和为n」的约束、集合固定为1-9(无需传n,遍历范围直接限制为9);
- 回溯核心:
startIndex避免重复组合,「处理+递归+回溯」三步缺一不可; - 剪枝技巧:和超目标时提前终止、遍历范围按剩余所需元素数收缩,大幅减少无效遍历。
17. 电话号码的字母组合(精简版)
核心思路
将数字字符串映射为字母集合,通过回溯法解决多层for循环嵌套问题:把数字串长度作为树的深度,每个数字对应的字母集作为树的宽度,遍历到叶子节点(遍历完所有数字)时收集字母组合结果。
版本一:直观回溯
关键准备
定义数字到字母的映射数组(匹配电话按键):
const string letterMap[10] = {
"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};
完整代码
class Solution {
private:
const string letterMap[10] = {
"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};
vector<string> result; // 最终结果集
string s; // 单次组合路径
void backtracking(const string& digits, int index) {
// 终止条件:遍历完所有数字,收集结果
if (index == digits.size()) {
result.push_back(s);
return;
}
// 取当前数字对应的字母集
int digit = digits[index] - '0'; // 字符转数字
string letters = letterMap[digit];
// 遍历当前数字的所有字母
for (int i = 0; i < letters.size(); i++) {
s.push_back(letters[i]); // 选择当前字母
backtracking(digits, index + 1); // 递归处理下一个数字
s.pop_back(); // 回溯:撤销选择
}
}
public:
vector<string> letterCombinations(string digits) {
if (digits.empty()) return {}; // 空输入直接返回空
backtracking(digits, 0);
return result;
}
};
版本二:回溯藏于参数
class Solution {
private:
const string letterMap[10] = {
"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"
};
vector<string> result;
void getCombinations(const string& digits, int index, const string& s) {
if (index == digits.size()) {
result.push_back(s);
return;
}
int digit = digits[index] - '0';
string letters = letterMap[digit];
// 回溯隐藏在参数拼接中(s + letters[i] 生成新字符串,无需手动pop)
for (int i = 0; i < letters.size(); i++) {
getCombinations(digits, index + 1, s + letters[i]);
}
}
public:
vector<string> letterCombinations(string digits) {
if (digits.empty()) return {};
getCombinations(digits, 0, "");
return result;
}
};
三、核心总结
- 与组合问题的区别:本题是多集合求组合(每个数字对应独立字母集),77/216是单集合求组合;
- 关键参数:
index记录当前处理的数字下标(树的深度),而非startIndex; - 边界处理:空输入直接返回空结果,避免无效递归;
- 效率说明:时间复杂度
O(3^m * 4^n)(m为对应3个字母的数字数,n为对应4个字母的数字数)。
版本一逻辑直观(手动处理回溯),是面试首选;版本二通过参数拼接隐藏回溯,代码更短但可读性稍差。
39. 组合总和
核心思路
在无重复元素的数组中找和为target的组合,元素可无限重复选取。核心逻辑:用回溯法遍历所有可能的组合,通过startIndex控制遍历起始位置(避免重复组合),递归时允许重复选取当前元素(i而非i+1),并通过排序+剪枝优化减少无效遍历。
一、基础回溯解法
核心逻辑
- 全局变量:
result存最终组合集,path存单次组合; - 递归参数:
candidates(候选数组)、target(目标和)、sum(当前组合和)、startIndex(遍历起始位置); - 终止条件:
sum > target直接返回,sum == target收集结果; - 单层逻辑:for循环从
startIndex开始,累加元素和、记录路径,递归时传入i(允许重复选当前元素),最后回溯撤销选择。
class Solution {
private:
vector<vector<int>> result; // 最终结果集
vector<int> path; // 单次组合路径
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum > target) return; // 和超目标,直接返回
if (sum == target) { // 和符合目标,收集结果
result.push_back(path);
return;
}
// 遍历候选数组,startIndex避免重复组合
for (int i = startIndex; i < candidates.size(); i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i); // 传i而非i+1,允许重复选当前元素
sum -= candidates[i]; // 回溯:撤销和
path.pop_back(); // 回溯:撤销元素
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
backtracking(candidates, target, 0, 0);
return result;
}
};
二、剪枝优化
核心逻辑
- 排序:先对候选数组排序,使元素从小到大排列;
- 遍历剪枝:
sum + candidates[i] <= target时才遍历(提前终止无效循环)。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
return;
}
// 剪枝:sum + 当前元素 > target 时,终止遍历(后续元素更大)
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i);
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end()); // 排序为剪枝做准备
backtracking(candidates, target, 0, 0);
return result;
}
};
三、核心总结
- 与经典组合问题的区别:
- 无组合数量限制,仅限制和为target;
- 元素可无限重复选取(递归传
i而非i+1);
startIndex的作用:单集合求组合需用(避免[2,3]和[3,2]这类重复),多集合求组合无需用(如17.电话号码字母组合);- 剪枝技巧:求和问题中,排序后通过
sum + candidates[i] <= target剪枝,大幅减少无效递归。
基础解法逻辑直观,剪枝优化是面试加分项,核心是理解「允许重复选元素」和「排序剪枝」的底层逻辑。
40. 组合总和II(精简版)
核心思路
在有重复元素的数组中找和为target的组合,要求:① 每个元素在组合中仅用一次;② 解集无重复组合。核心难点是树层去重(避免同一层重复选相同元素),需先排序数组,再通过used数组或startIndex实现去重,同时保留树枝上的合法重复(如示例中的[1,1,6])。
版本一:used数组去重,直观易理解
核心逻辑
- 排序:先对数组排序,让相同元素相邻,为去重做准备;
- used数组:标记元素是否被使用,
used[i-1]==false表示同一树层的相同元素已使用(需跳过),used[i-1]==true表示同一树枝的相同元素已使用(合法); - 递归参数:新增
used数组,递归时i+1(元素仅用一次); - 剪枝:
sum + candidates[i] <= target提前终止无效遍历。
class Solution {
private:
vector<vector<int>> result; // 最终结果集
vector<int> path; // 单次组合路径
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
if (sum == target) { // 收集符合条件的组合
result.push_back(path);
return;
}
// 遍历+剪枝:sum + 当前元素 > target 时终止
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// 树层去重:相同元素且前一个未使用(同一层已选过)
if (i > 0 && candidates[i] == candidates[i-1] && used[i-1] == false) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[i] = true; // 标记当前元素在树枝上已使用
backtracking(candidates, target, sum, i+1, used); // i+1:元素仅用一次
// 回溯
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(), false);
sort(candidates.begin(), candidates.end()); // 排序为去重做准备
backtracking(candidates, target, 0, 0, used);
return result;
}
};
版本二:无需used数组,startIndex去重
核心逻辑
直接通过i > startIndex判断是否为同一树层的重复元素(无需额外数组,更简洁):
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// 树层去重:i > startIndex 说明是同一层的重复元素
if (i > startIndex && candidates[i] == candidates[i-1]) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
backtracking(candidates, target, sum, i+1);
sum -= candidates[i];
path.pop_back();
}
}
public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0);
return result;
}
};
三、核心总结
- 与39.组合总和的关键区别:
- 元素仅用一次(递归传
i+1而非i); - 数组有重复元素,需树层去重(避免重复组合);
- 元素仅用一次(递归传
- 去重核心:
- 先排序,让相同元素相邻;
- 树层去重(跳过同一层重复元素),树枝去重(允许同一组合内的合法重复);
- 两种去重方式:
- used数组:直观区分树层/树枝去重,适合理解;
- startIndex:更简洁,无需额外数组,面试首选。
本题的核心是理解「树层去重」和「树枝去重」的区别,排序+去重是解决重复数组组合问题的通用套路。
131. 分割回文串
核心思路
将字符串分割为若干回文子串,本质是组合问题的变形:把「切割」抽象为树形遍历(递归纵向遍历、for循环横向遍历),切割线遍历到字符串末尾时收集结果,核心需解决「切割模拟」和「回文判断」两个问题。
一、基础回溯解法
核心逻辑
- 全局变量:
result存最终分割方案,path存单次分割的回文子串; - 递归参数:
startIndex表示当前切割起始位置(即切割线); - 终止条件:
startIndex >= s.size()时,说明切割到字符串末尾,收集当前分割方案; - 单层逻辑:for循环从
startIndex开始截取子串[startIndex, i],判断是否为回文,是则加入path,递归处理下一段(i+1),最后回溯撤销选择。
class Solution {
private:
vector<vector<string>> result; // 最终分割方案
vector<string> path; // 单次分割的回文子串
// 判断子串s[start..end]是否为回文(双指针法)
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) return false;
}
return true;
}
void backtracking(const string& s, int startIndex) {
// 终止条件:切割线到字符串末尾,收集结果
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
// 横向遍历:截取[startIndex, i]的子串
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文才切割
string sub = s.substr(startIndex, i - startIndex + 1);
path.push_back(sub);
} else {
continue; // 非回文跳过
}
backtracking(s, i + 1); // 递归:切割下一段(i+1为新切割线)
path.pop_back(); // 回溯:撤销当前切割
}
}
public:
vector<vector<string>> partition(string s) {
backtracking(s, 0);
return result;
}
};
二、优化解法(动态规划预存回文判断)
核心逻辑
提前用动态规划计算所有子串是否为回文,避免回溯中重复判断,提升效率:
isPalindrome[i][j]表示s[i..j]是否为回文;- 状态转移:
s[i]==s[j]且s[i+1..j-1]是回文 →s[i..j]是回文。
class Solution {
private:
vector<vector<string>> result;
vector<string> path;
vector<vector<bool>> isPalindrome; // 预存子串是否为回文
// 预处理所有子串的回文状态
void computePalindrome(const string& s) {
isPalindrome.resize(s.size(), vector<bool>(s.size(), false));
// 倒序遍历,保证计算i行时i+1行已完成
for (int i = s.size()-1; i >= 0; i--) {
for (int j = i; j < s.size(); j++) {
if (i == j) isPalindrome[i][j] = true; // 单个字符是回文
else if (j - i == 1) isPalindrome[i][j] = (s[i] == s[j]); // 两个字符
else isPalindrome[i][j] = (s[i] == s[j] && isPalindrome[i+1][j-1]);
}
}
}
void backtracking(const string& s, int startIndex) {
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome[startIndex][i]) { // 直接查预处理结果
string sub = s.substr(startIndex, i - startIndex + 1);
path.push_back(sub);
} else {
continue;
}
backtracking(s, i + 1);
path.pop_back();
}
}
public:
vector<vector<string>> partition(string s) {
computePalindrome(s); // 预处理回文状态
backtracking(s, 0);
return result;
}
};
三、核心总结
- 切割问题本质:等价于组合问题,
startIndex是切割线,i是当前尝试的新切割线,递归传i+1避免重复切割; - 回文判断:基础版用双指针(简单直观),优化版用动态规划(预存结果,减少重复计算);
- 关键逻辑:
- 终止条件:切割线遍历到字符串末尾;
- 单层遍历:仅截取回文子串,非回文直接跳过。
本题难点在于将「切割」抽象为树形遍历,核心是复用组合问题的回溯框架,结合回文判断即可解决。
93. 复原IP地址
核心思路
将纯数字字符串分割为4段合法IP段(每段0-255、无前置0、仅数字),本质是带约束的切割问题:用回溯法遍历所有切割可能,通过「逗点数量」控制分割段数,通过合法性校验过滤无效分割。
一、完整解法
核心逻辑
- 递归参数:
startIndex(切割起始位置)、pointNum(已添加逗点数量,最多3个); - 终止条件:
pointNum == 3时,校验最后一段是否合法,合法则收集结果; - 单层逻辑:遍历截取
[startIndex, i]子串,校验合法后插入.,递归处理下一段,最后回溯撤销.; - 合法性校验:① 无前置0(单0合法);② 纯数字;③ 数值≤255。
class Solution {
private:
vector<string> result; // 最终合法IP集合
// 校验[start, end]子串是否为合法IP段
bool isValid(const string& s, int start, int end) {
if (start > end) return false;
// 前置0不合法(单0除外)
if (s[start] == '0' && start != end) return false;
int num = 0;
for (int i = start; i <= end; i++) {
if (s[i] < '0' || s[i] > '9') return false; // 非数字
num = num * 10 + (s[i] - '0');
if (num > 255) return false; // 超过255
}
return true;
}
// startIndex:当前切割起始位,pointNum:已添加逗点数
void backtracking(string& s, int startIndex, int pointNum) {
// 终止条件:逗点数为3,校验最后一段
if (pointNum == 3) {
if (isValid(s, startIndex, s.size()-1)) {
result.push_back(s);
}
return;
}
// 横向遍历:截取[startIndex, i]子串
for (int i = startIndex; i < s.size(); i++) {
if (isValid(s, startIndex, i)) {
s.insert(s.begin() + i + 1, '.'); // 插入逗点
backtracking(s, i + 2, pointNum + 1); // 下一段从i+2开始(跳过逗点)
s.erase(s.begin() + i + 1); // 回溯:删除逗点
} else break; // 子串非法,直接终止本层遍历
}
}
public:
vector<string> restoreIpAddresses(string s) {
// 剪枝:IP长度需4-12位(4段,每段1-3位)
if (s.size() < 4 || s.size() > 12) return {};
backtracking(s, 0, 0);
return result;
}
};
二、核心总结
- 切割逻辑:与131.分割回文串一致,
startIndex为切割线,区别在于用pointNum限制分割段数(最多3个逗点=4段); - 合法性校验(关键):
- 段不能以0开头(单0除外);
- 段必须是纯数字且数值≤255;
- 回溯操作:插入
.后递归,回溯时删除.,保证字符串还原; - 剪枝优化:提前过滤长度<4或>12的字符串(IP段数/位数约束)。
78. 子集
核心思路
子集问题是回溯法的经典应用,与组合/分割问题的核心区别:组合/分割只收集叶子节点,子集需收集所有节点。将数组抽象为树形结构,递归纵向遍历、for循环横向遍历,遍历过程中收集每一步的路径(即所有子集),通过startIndex控制元素不重复选取。
一、完整解法
核心逻辑
- 全局变量:
result存所有子集,path存当前子集路径; - 递归参数:
startIndex表示当前遍历起始位置(避免重复选取元素); - 关键操作:进入递归后先收集当前路径(所有节点都是子集),再通过for循环遍历剩余元素;
- 终止条件:
startIndex >= nums.size()时自然终止(可省略,因for循环会结束)。
class Solution {
private:
vector<vector<int>> result; // 所有子集
vector<int> path; // 当前子集路径
void backtracking(vector<int>& nums, int startIndex) {
result.push_back(path); // 收集所有节点(核心:先存路径,再递归)
if (startIndex >= nums.size()) return; // 终止条件(可省略)
// 横向遍历:从startIndex开始,避免重复选取
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 选择当前元素
backtracking(nums, i + 1); // 递归:下一层从i+1开始
path.pop_back(); // 回溯:撤销选择
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
backtracking(nums, 0);
return result;
}
};
二、核心总结
- 核心区别:
- 组合/分割问题:仅收集树形结构的叶子节点;
- 子集问题:收集树形结构的所有节点(每一步路径都是子集);
- 关键逻辑:
- 进入递归后先收集当前路径(必做,否则漏掉空集/中间子集);
startIndex控制遍历起始位置,保证元素不重复选取(子集无序);
- 简化点:终止条件可省略,因
startIndex >= nums.size()时for循环直接结束,无递归操作。
本题是回溯模板题,核心是理解「收集所有节点」的逻辑,其余与组合问题的回溯框架完全一致。
90. 子集II
核心思路
在含重复元素的数组中求所有不重复子集,核心是子集问题+树层去重:先排序让重复元素相邻,再通过used数组或startIndex跳过同一树层的重复元素(保留树枝上的合法重复),其余逻辑与78.子集一致(收集所有节点)。
一、解法1:used数组去重(直观易理解)
核心逻辑
- 排序:让重复元素相邻,为去重做准备;
- used数组:
used[i-1]==false表示同一树层的重复元素已使用(需跳过),used[i-1]==true表示同一树枝的重复元素(合法); - 收集子集:进入递归后先收集当前路径(所有节点都是子集)。
class Solution {
private:
vector<vector<int>> result; // 所有子集
vector<int> path; // 当前子集路径
void backtracking(vector<int>& nums, int startIndex, vector<bool>& used) {
result.push_back(path); // 收集所有节点(核心)
for (int i = startIndex; i < nums.size(); i++) {
// 树层去重:相同元素且前一个未使用(同一层已选过)
if (i > 0 && nums[i] == nums[i-1] && used[i-1] == false) {
continue;
}
path.push_back(nums[i]);
used[i] = true;
backtracking(nums, i+1, used); // i+1:元素不重复选取
// 回溯
used[i] = false;
path.pop_back();
}
}
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end()); // 排序为去重做准备
vector<bool> used(nums.size(), false);
backtracking(nums, 0, used);
return result;
}
};
二、解法2:无需used数组(简洁版)
核心逻辑
直接通过i > startIndex判断同一树层的重复元素(无需额外数组):
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
result.push_back(path);
for (int i = startIndex; i < nums.size(); i++) {
// 树层去重:i > startIndex 说明是同一层的重复元素
if (i > startIndex && nums[i] == nums[i-1]) {
continue;
}
path.push_back(nums[i]);
backtracking(nums, i+1);
path.pop_back();
}
}
public:
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
sort(nums.begin(), nums.end());
backtracking(nums, 0);
return result;
}
};
三、核心总结
- 与78.子集的核心区别:
- 数组含重复元素,需树层去重(避免重复子集);
- 先排序,让重复元素相邻,是去重的前提;
- 去重逻辑(二选一):
- used数组:
i>0 && nums[i]==nums[i-1] && used[i-1]==false跳过树层重复; - startIndex:
i>startIndex && nums[i]==nums[i-1]跳过树层重复(更简洁);
- used数组:
- 子集核心:进入递归后先收集当前路径(所有节点都是子集),这是与组合/分割问题的关键区别。
本题是78.子集+40.组合总和II的结合体,掌握「排序+树层去重」即可解决,是回溯去重的经典应用。
491. 递增子序列(精简版)
核心思路
在不排序的前提下找所有长度≥2的递增子序列,核心是「递增约束+树层去重」:
- 递增约束:当前选的数必须≥路径最后一个数(保证子序列递增);
- 树层去重:同一层不能选重复数(避免重复子序列),因不能排序,需用哈希(数组/set)记录本层已选数;
- 子集逻辑:收集所有长度≥2的路径(无需终止条件,startIndex自然限制递归)。
一、基础解法(unordered_set去重)
class Solution {
private:
vector<vector<int>> result; // 所有递增子序列
vector<int> path; // 当前子序列路径
void backtracking(vector<int>& nums, int startIndex) {
if (path.size() > 1) { // 收集长度≥2的子序列
result.push_back(path);
}
unordered_set<int> uset; // 记录本层已选数(去重)
for (int i = startIndex; i < nums.size(); i++) {
// 跳过:1. 破坏递增 2. 本层已选过该数
if ((!path.empty() && nums[i] < path.back()) || uset.count(nums[i])) {
continue;
}
uset.insert(nums[i]); // 标记本层已选
path.push_back(nums[i]);
backtracking(nums, i + 1); // 下一层从i+1开始
path.pop_back(); // 回溯
}
}
public:
vector<vector<int>> findSubsequences(vector<int>& nums) {
backtracking(nums, 0);
return result;
}
};
二、核心总结
- 与90.子集II的关键区别:
- 本题不能排序(排序会破坏原数组的递增子序列逻辑);
- 去重方式:用哈希(set/数组)记录本层已选数,而非排序+startIndex;
- 核心约束:
- 递增:
nums[i] ≥ path.back()(path非空时); - 去重:同一层不选重复数(哈希表标记);
- 递增:
- 效率优化:
- 数值范围小([-100,100])时,用数组哈希比unordered_set更快,避免哈希映射开销。
本题是子集去重的进阶版,核心是「不排序场景下的树层去重」,掌握哈希表记录本层已选数是关键。
46. 全排列(精简版)
核心思路
全排列是有序组合([1,2]和[2,1]是不同结果),核心区别于组合/子集:
- 无需
startIndex,每层遍历从0开始(允许重复选元素,但同一排列内元素仅用一次); - 用
used数组标记已选元素,避免同一排列内重复使用; - 终止条件:路径长度等于数组长度(收集完整排列)。
一、完整解法
class Solution {
public:
vector<vector<int>> result; // 所有全排列
vector<int> path; // 当前排列路径
void backtracking(vector<int>& nums, vector<bool>& used) {
// 终止条件:路径长度等于数组长度,收集结果
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
// 每层从0开始遍历(排列有序)
for (int i = 0; i < nums.size(); i++) {
if (used[i]) continue; // 跳过已选元素(同一排列内)
used[i] = true; // 标记已选
path.push_back(nums[i]);
backtracking(nums, used);
// 回溯
path.pop_back();
used[i] = false;
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
二、核心总结
- 与组合/子集的关键区别:
- 排列有序:每层遍历从
0开始,而非startIndex; - 去重约束:用
used数组标记已选元素,保证同一排列内元素不重复;
- 排列有序:每层遍历从
- 终止条件:路径长度等于数组长度(收集完整排列);
- 核心逻辑:
- 遍历:0到nums.size()-1,跳过已标记元素;
- 回溯:标记/取消标记
used,添加/删除路径元素。
本题是排列问题的基础模板,核心是理解「有序遍历+used数组标记」的逻辑,区别于组合/子集的无序遍历。
47. 全排列 II(精简版)
核心思路
在含重复元素的数组中求不重复全排列,核心是「全排列基础逻辑+树层去重」:
- 先排序:让重复元素相邻,为去重做准备;
- used数组双重作用:
- 标记同一排列内已选元素(避免重复使用);
- 区分「树层重复」和「树枝重复」,优先树层去重(效率更高);
- 终止条件:路径长度等于数组长度(收集完整排列)。
一、最优解法(树层去重,效率更高)
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, vector<bool>& used) {
// 终止条件:收集完整排列
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
// 树层去重:重复元素且前一个未使用(同一层已选过)
if (i > 0 && nums[i] == nums[i-1] && used[i-1] == false) {
continue;
}
if (!used[i]) { // 跳过同一排列内已选元素
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
// 回溯
path.pop_back();
used[i] = false;
}
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
sort(nums.begin(), nums.end()); // 排序为去重做准备
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
二、关键拓展(两种去重方式对比)
| 去重写法 | 逻辑含义 | 效率 |
|---|---|---|
used[i-1] == false | 树层去重(优先选) | 高(少无效搜索) |
used[i-1] == true | 树枝去重 | 低(多无效搜索) |
核心原则:两种写法都能去重,但树层去重更高效;必须加used[i-1]判断,不能仅用nums[i]==nums[i-1](否则无法区分层/枝)。
三、核心总结
- 与46.全排列的区别:
- 数组含重复元素,需排序+树层去重;
- used数组新增「去重」功能(原仅标记已选元素);
- 核心逻辑:
- 排序:让重复元素相邻,是去重前提;
- 树层去重:跳过同一层重复元素,避免生成重复排列;
- 排列遍历:每层从0开始,用used数组避免同一排列内重复选元素。
本题是全排列+去重的综合应用,核心是理解「used数组区分树层/树枝重复」的逻辑,优先选择树层去重提升效率。
后面两道二维的等我考上研再来。

194

被折叠的 条评论
为什么被折叠?



