【算法】【学习笔记】回溯

❔题目

1. 组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

解题思路

组合数的计算公式如下

\[C_{n}^{k} = \binom{n}{k} = \frac{n!}{k!(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 地址 正好由四个整数(每个整数位于 0255 之间组成,且不能含有前导 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" 都不是开心字符串。

给你两个整数 nk ,你需要将长度为 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 辅助整理。

posted @ 2026-08-14 22:10  SEHOD  阅读(3)  评论(0)    收藏  举报