【算法】【学习笔记】回溯
❔题目
1. 组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
你可以按 任何顺序 返回答案。
解题思路
组合数的计算公式如下
回溯三要素拆解
我们可以把寻找组合的过程看作在一棵树上做 DFS 遍历:
- 路径(path):当前已经选好的数字列表(例如 [1, 2])。
- 选择列表(startIndex):为了避免选到重复的组合,下一层递归只能从 startIndex 开始往后选。
还需要凑的元素个数:\(k - \text{path.length}\)
包含 \(i\) 在内,在 \([i, n]\) 范围内可用的元素个数是:\(n - i + 1\)
必须满足:\(n - i + 1 \ge k - \text{path.length}\)
移项解出 \(i\) 的上限:\(i \le n - (k - \text{path.length}) + 1\)
选择后,需要将选择撤销,为了选择新的选项。 - 终止条件:当 path 的长度达到 \(k\) 时,说明找到了一组解,存入结果数组并 return。
实现
function combine(n: number, k: number): number[][] {
let res:number[][] = [];
let path:number[] = [];
backtrack(1, n, k, res, path);
return res;
};
function backtrack(startIndex: number,n:number, k:number, res: number[][], path: number[]){
// 终止条件:到达k长度
if(path.length === k){
res.push([...path]); // 需要浅拷贝,不然影响后续数据
return;
}
// 带剪枝优化的循环条件
const maxStartIndex = n - (k - path.length) + 1;
for(let i = startIndex; i <= maxStartIndex; i++){
path.push(i); // 继续选择
backtrack(i+1, n, k, res, path); // 对此选择继续查询
path.pop(); // 撤销选择
}
}
public class Solution {
public IList<IList<int>> Combine(int n, int k) {
var res = new List<IList<int>>();
var path = new List<int>();
Backtrack(n, k, 1, res, path);
return res;
}
public void Backtrack(int n, int k, int startIdx, IList<IList<int>> res, IList<int> path){
if(path.Count == k){
res.Add(new List<int>(path));
return;
}
int maxStartIdx = n - (k - path.Count) + 1;
for(int i = startIdx; i <= maxStartIdx; i++){
path.Add(i);
Backtrack(n, k, i + 1, res, path);
path.RemoveAt(path.Count-1);
}
}
}
class Solution {
public:
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> res;
vector<int> path;
backtrack(n, k, 1, res, path);
return res;
}
void backtrack(int n, int k, int startIdx, vector<vector<int>>& res, vector<int>& path){
if(path.size() == k){
res.push_back(path);
return;
}
int maxStartIdx = n - (k - path.size()) + 1;
for(int i = startIdx; i <= maxStartIdx; i++){
path.push_back(i);
backtrack(n, k, i+1, res, path);
path.pop_back();
}
}
};
复杂度分析
- 时间复杂度:\(O\left(k\binom{n}{k}\right)\)
一共有 \(\binom{n}{k}\) 个组合,每个组合需要复制长度为 \(k\) 的path保存到答案中。 - 空间复杂度:\(O(k)\)(不计算返回结果)
path最多保存 \(k\) 个元素,同时递归调用栈最多为 \(k\) 层。
如果计算最终返回的结果,则需要 \(O\left(k\binom{n}{k}\right)\) 的空间。
2. 复原IP地址
有效 IP 地址 正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。
- 例如:
"0.1.2.201"和"192.168.1.1"是 有效 IP 地址,但是"0.011.255.245"、"192.168.1.312"和"192.168@1.1"是 无效 IP 地址。
给定一个只包含数字的字符串 s ,用以表示一个 IP 地址,返回所有可能的有效 IP 地址,这些地址可以通过在 s 中插入 '.' 来形成。你 不能 重新排序或删除 s 中的任何数字。你可以按 任何 顺序返回答案。
解题思路
速思:IP 地址固定有 4 段,每段长度只能是 1~3,所以每一层回溯只需要尝试截取 1、2、3 个字符,并判断当前段是否合法。切满 4 段且刚好用完整个字符串时,就得到一个答案。
但是可以看出条件还是不足的,需要加上3个限制条件:
- 总长度剪枝:合法 IP 的字符数必须在 \([4, 12]\) 之间,超出直接返回空。
- 段数限制:必须恰好切成 4 段(即用一个path数组来记录每一段的字符串,当数组长度为4时),且刚好耗尽整个字符串(即 startIndex === s.length)。
- 单段有效性规则:数值必须在 \([0, 255]\) 之间。不能有前导 0(比如 "0" 合法,但 "01"、015 不合法)。
可以把切割过程想象成在一棵树上走:每一层决定当前这一段要截取 1 位、2 位还是 3 位字符。
s = "25525511135"
/ | \
截 1 位 截 2 位 截 3 位
"2" "25" "255" (判断均合法,进入下一层递归)
实现
function restoreIpAddresses(s: string): string[] {
if(s.length < 4 || s.length > 12) return [];
let res: string[] = [];
let path: string[] = []; // 存四段
backtrack(s, 0, res, path);
return res;
};
function backtrack(s: string, startIdx: number, res: string[], path:string[]){
if(path.length == 4){
if(startIdx === s.length){
res.push(path.join("."));
}
return;
}
// 子串长度最大为3
for(let len = 1; len <= 3; len++){
if(startIdx >= s.length) break;
const sub = s.substring(startIdx, startIdx + len);
if(isValid(sub)){
path.push(sub);
backtrack(s, startIdx + len, res, path);
path.pop();
}
}
}
// 检查截断子串是否合法
function isValid(sub: string):boolean{
if(sub.length > 1 && sub[0] === '0') return false;
const num = Number(sub);
return num <= 255;
}
public class Solution {
public IList<string> RestoreIpAddresses(string s) {
if(s.Length < 4 || s.Length > 12) return [];
var res = new List<string>();
var path = new List<string>();
BackTrack(s, 0, res, path);
return res;
}
private void BackTrack(string s, int startIdx, List<string> res, List<string> path){
if(path.Count == 4){
if(startIdx == s.Length){
res.Add(string.Join(".", path));
}
return;
}
for(int len = 1; len <= 3; len++){
if(startIdx + len > s.Length) break;
string sub = s.Substring(startIdx, len);
if(IsValid(sub)){
path.Add(sub);
BackTrack(s, startIdx + len, res, path);
path.RemoveAt(path.Count - 1);
}
}
}
private bool IsValid(string sub){
if(sub.Length > 1 && sub[0] == '0') return false;
return int.TryParse(sub, out int num) && num >= 0 && num <= 255;
}
}
class Solution {
public:
vector<string> restoreIpAddresses(string s) {
vector<string> res;
vector<string> path;
backtrack(s, 0, res, path);
return res;
}
private:
bool isValid(string& sub){
if(sub.size() > 1 && sub[0] == '0') return false;
int num = stoi(sub);
return num >= 0 && num <= 255;
}
void backtrack(string& s, int startIdx, vector<string>& res, vector<string>& path){
if(path.size() == 4){
if(s.size() == startIdx){
res.push_back(path[0]+'.'+path[1]+'.'+path[2]+'.'+path[3]);
}
return;
}
for(int len = 1; len <= 3; len++){
if(startIdx + len > s.size()) break;
string sub = s.substr(startIdx, len);
if(isValid(sub)){
path.push_back(sub);
backtrack(s, startIdx + len, res, path);
path.pop_back();
}
}
}
};
复杂度分析
- 时间复杂度:\(O(3^4 \times N)=O(N)\)
IP 地址固定只有 4 段,每一段最多尝试截取 1、2、3 个字符,
因此回溯树的规模最多为 \(3^4\),每个节点处理字符串需要 \(O(N)\)。 - 空间复杂度:\(O(N)\)
递归深度最多为 4,path最多保存 4 个字符串。
如果计算返回结果,则还需要保存所有合法 IP 地址。
3. 长度为 n 的开心字符串中字典序第 k 小的字符串
一个 「开心字符串」定义为:
- 仅包含小写字母 ['a', 'b', 'c'].
- 对所有在 1 到 s.length - 1 之间的 i ,满足 s[i] != s[i + 1] (字符串的下标从 1 开始)。
比方说,字符串 "abc","ac","b" 和 "abcbabcbcb" 都是开心字符串,但是 "aa","baa" 和 "ababbc" 都不是开心字符串。
给你两个整数 n 和 k ,你需要将长度为 n 的所有开心字符串按字典序排序。
请你返回排序后的第 k 个开心字符串,如果长度为 n 的开心字符串少于 k 个,那么请你返回 空字符串 。
解题思路
练了上面两道题之后,这道就很明朗了。
只要在回溯中按字母序进行构造,构造完取第k-1个即可。
这个方法还可以优化,不需要逐个 if else ,在递归时当相同跳过不递归即可。然后在递归到第k个时,结束递归,不用构造不需要的值。
实现
TS为老方法,C#和C++为优化方法。
function getHappyString(n: number, k: number): string {
// n 是长度,k 是次序
let res:string[] = [];
let path = "";
backtrack(n, res, path);
return res.length >= k ? res[k-1] : "";
};
function backtrack(n: number, res: string[], path: string){
if(path.length === n){
res.push(path);
return;
}
if(path.length === 0){
backtrack(n, res, 'a');
backtrack(n, res, 'b');
backtrack(n, res, 'c');
}else if(path[path.length-1] === 'a'){
backtrack(n, res, path+'b');
backtrack(n, res, path+'c');
}else if(path[path.length-1] === 'b'){
backtrack(n, res, path+'a');
backtrack(n, res, path+'c');
}else if(path[path.length-1] === 'c'){
backtrack(n, res, path+'a');
backtrack(n, res, path+'b');
}
}
public class Solution {
private const string abc = "abc";
public string GetHappyString(int n, int k) {
string res = "";
int count = 0;
Backtrack(n, k, "", ref res, ref count);
return res;
}
private bool Backtrack(int n, int k, string path, ref string res, ref int count){
if(path.Length == n){
count++;
if(count == k){
res = path;
return true; // 找到了,返回true触发终止
}
return false;
}
foreach(char ch in abc){
// 剪枝:相邻字符不相同
if(path.Length > 0 && path[path.Length-1] == ch) continue;
// 递归内层,如果已经找到答案,向上传递true并立即中断循环
if(Backtrack(n, k, path+ch, ref res, ref count)) return true;
}
return false;
}
}
class Solution {
public:
string getHappyString(int n, int k) {
string res = "", path = "";
int count = 0;
backtrack(n, k, res, path, count);
return res;
}
private:
const string abc = "abc";
bool backtrack(int n, int k, string& res, string path, int& count){
if(path.size() == n){
count++;
if(count == k){
res = path;
return true;
}
return false;
}
for(auto ch : abc){
if(path.size() > 0 && path[path.size()-1] == ch) continue;
if(backtrack(n, k, res, path+ch, count)) return true;
}
return false;
}
};
复杂度分析
- 时间复杂度:\(O(K \times N)\)
最多遍历前 K 个开心字符串,每个字符串长度为 N。 - 空间复杂度:\(O(N)\)
递归深度和当前路径长度最多为 N,只保存当前路径和最终答案。
引用
力扣探险模式
注:本文为个人学习与刷题笔记,部分文本结构与排版格式由 AI 辅助整理。

浙公网安备 33010602011771号