从零开始写算法——回溯篇4:分割回文串 + N皇后

回溯算法(Backtracking)一直是算法面试中的"拦路虎"。很多时候我们能写出递归的大致框架,但往往在**状态重置(恢复现场)**的细节上栽跟头。

今天通过两道经典的 LeetCode 题目------分割回文串N 皇后 ,来深入探讨回溯算法的通用模板,并重点解决一个核心痛点:到底什么时候在 for 循环里面恢复现场,什么时候在外面?

一、 分割回文串 (Palindrome Partitioning)

这道题是典型的"切割"问题。给定一个字符串,我们需要把它切成若干块,要求每一块都是回文串。

1. 解题思路

我们可以把字符串想象成一根长条面包,我们手里的刀就是变量 j

  • 横向遍历 (for 循环):决定当前这一刀切在哪里(切 1 个字?切 2 个字?...)。

  • 纵向递归 (dfs):切完这一刀后,剩下的字符串交给下一层递归去继续切。

  • 合法性判断:只有当切下来的这一块是回文串时,才允许进入下一层。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 思路: i作为分割的起点,j作为分割的落点, 剩下的j+1到end我们交给下一层dfs去处理
    vector<vector<string>> ans;
    
    // 判断是否为回文串的双指针法
    bool isTrue(string& s, int left, int right) {
        while (left < right) {
            if (s[left++] != s[right--]) {
                return false;
            }
        }
        return true;
    }

    void dfs(string& s, vector<string>& vals, int i, int n) {
        // Base Case: i 走到了 n,说明字符串已经全部切割完毕
        if (i == n) {
            ans.push_back(vals);
            return;
        }
        
        // j 代表当前这一刀切在哪个位置(闭区间 [i, j])
        for (int j = i; j < n; j++) {
            // 单个数字的分割一定是符合的比如a, b, c,也就是j = i的时候
            // 只有当前切出来的子串 s[i...j] 是回文,才继续往下递归
            if (isTrue(s, i, j)) {
                // string substr (size_t pos = 0, size_t len = npos) const; 参数二是长度
                // 计算长度: j - i + 1
                vals.push_back(s.substr(i, j - i + 1)); // 做选择
                
                dfs(s, vals, j + 1, n); // 递归:剩下的交给下一层
                
                vals.pop_back(); // 撤销选择:回溯,尝试下一种切法
            }
        }
    }

public:
    vector<vector<string>> partition(string s) {
        vector<string> vals;
        int n = s.size();
        dfs(s, vals, 0, n);
        return ans;
    }
};

3. 时空复杂度分析

  • 时间复杂度:O(N * 2^N)。

    • 在最坏情况下(比如字符串全是同一个字符 "aaaa"),每个字符之间的空隙都可以切或者不切,共有 2^(N-1) 种切割方案。

    • 每一种方案我们需要 O(N) 的时间去构造子串和判断回文。

  • 空间复杂度:O(N)。

    • 递归调用栈的最大深度为 N,且用于存储路径的 vals 数组最多也存储 N 个字符串。

二、 N 皇后 (N-Queens)

这是回溯算法的巅峰之作。我们需要在 N*N 的棋盘上放置 N 个皇后,使得它们不能互相攻击(同行、同列、同对角线)。

1. 解题思路

这道题本质上是一个全排列问题。因为每一行只能放一个皇后,我们只需要决定每一行的皇后放在哪一列。 为了将冲突判断从 O(N) 优化到 O(1),我们使用了三个辅助数组:

  • col[j]:标记第 j 列是否占用。

  • diag1[i + j] :标记副对角线(/ 方向)。规律是 row + col 为定值。

  • diag2[i - j + n - 1] :标记主对角线(\ 方向)。规律是 row - col 为定值(加偏移量 n-1 防止负数)。

2. 代码实现

C++代码实现:

cpp 复制代码
class Solution {
    // 思路: 按行去遍历,然后标记列重复,主副对角线重复
    vector<vector<string>> ans;
    
    // i: 当前正在处理第几行
    void dfs(int i, int n, vector<string>& path, vector<int>& col, vector<int>& diag1, vector<int>& diag2) {
        // Base Case: i == n 说明 N 行都放好了
        if (i == n) {
            ans.push_back(path);
            return;
        }
        
        // 尝试当前行的每一列 j
        for (int j = 0; j < n; j++) {
            int d1 = i + j;           // 副对角线索引
            int d2 = i - j + n - 1;   // 主对角线索引
            
            // 剪枝:如果列、主对角线、副对角线任意一个有冲突,跳过
            if (col[j] == 1 || diag1[d1] == 1 || diag2[d2] == 1) {
                continue;
            }
            
            // 做选择
            path[i][j] = 'Q';
            col[j] = diag1[d1] = diag2[d2] = 1; // 标记占用
            
            // 进入下一行
            dfs(i + 1, n, path, col, diag1, diag2);
            
            // 撤销选择 (恢复现场)
            // 必须在 for 循环内部恢复,否则会影响下一次循环对 j+1 列的尝试
            col[j] = diag1[d1] = diag2[d2] = 0; // 解除占用
            path[i][j] = '.';
        }
    }

public:
    vector<vector<string>> solveNQueens(int n) {
        vector<string> path(n, string(n, '.'));
        vector<int> col(n, 0);
        // 对角线数组大小需要 2*n,防止越界
        vector<int> diag1(2*n);
        vector<int> diag2(2*n);
        
        dfs(0, n, path, col, diag1, diag2);
        return ans; 
    }
};

3. 时空复杂度分析

  • 时间复杂度:O(N!)。

    • 第一行有 N 种选法,第二行有 N-1 种... 近似于 N 的阶乘。实际因为剪枝的存在,运行速度会远快于 N!。
  • 空间复杂度:O(N)。

    • 递归深度为 N,辅助数组 path, col, diag 的空间也是 O(N)。

三、 核心探讨:恢复现场,到底在 For 循环里还是外?

这是很多同学最容易混淆的地方。观察上面的两段代码,你会发现:它们的 pop_back 或者状态重置,都写在了 for 循环里面(递归函数的后面)。

但是,如果你做过"单词搜索 (Word Search)"或者"迷宫路径",你会发现那些题的恢复现场是写在 for 循环外面的。

区别在哪里?

我总结为两种模式:"试衣间模式" vs "进屋锁门模式"

1. 试衣间模式(恢复在 For 循环里)

适用场景 :N 皇后、全排列、分割回文串、组合总和。 逻辑本质:兄弟节点之间是**互斥(竞争)**关系。

想象你在试衣间试衣服(遍历 for 循环的 j):

  • 你穿上红衣服(j=0),照镜子(dfs)。

  • 在你试下一件蓝衣服(j=1)之前,你必须先把红衣服脱下来(恢复现场)。

  • 如果你不脱,你身上就穿了两件衣服,这就乱套了。

所以,选一个,撤销一个,再选下一个。恢复现场必须紧跟在递归调用之后,包含在循环体内。

2. 进屋锁门模式(恢复在 For 循环外)

适用场景 :网格 DFS 搜索(Word Search)、迷宫找路。 逻辑本质:兄弟节点之间是**依存(合作)**关系。

想象你在走迷宫,你站在十字路口 A

  • 进屋 :你一旦踏上 A 点,就要立刻标记 A 已访问(锁门)。这是为了防止你派出去的侦察兵(递归)晕头转向又走回 A 点。

  • 派出侦察兵 :你在这个函数里写 for 循环,分别向上下左右探索。

  • 关键点 :当大儿子(上)探索失败回来时,你不能 恢复 A 点的状态!因为二儿子(下)还没出发呢,你(父亲 A)必须一直站在那里占位。

  • 撤退 :只有当四个方向都试完了,证明 A 点是个死胡同,你准备回退到上一个路口时,你才把 A 点解锁(恢复现场)。

总结口诀

  1. 构造参数/做选择题 (如 N 皇后、切字符串):在循环里恢复。因为每个选项是独立的,选完必须撤销才能选下一个。

  2. 地图游走/防止回头 (如网格 DFS):在循环外恢复。因为当前节点是所有分支的共同基石,所有分支走完前,当前节点不能松开。

相关推荐
ScilogyHunter2 小时前
qBI有什么用
算法·qbi
DashVector3 小时前
通义深度搜索-上传文件
人工智能·深度学习·阿里云·ai·深度优先
龙山云仓3 小时前
No131:AI中国故事-对话荀子——性恶论与AI约束:礼法并用、化性起伪与算法治理
大数据·人工智能·深度学习·算法·机器学习
夏鹏今天学习了吗3 小时前
【LeetCode热题100(90/100)】编辑距离
算法·leetcode·职场和发展
芒克芒克4 小时前
数组去重进阶:一次遍历实现最多保留指定个数重复元素(O(n)时间+O(1)空间)
数据结构·算法
星火开发设计4 小时前
二维数组:矩阵存储与多维数组的内存布局
开发语言·c++·人工智能·算法·矩阵·函数·知识
丨康有为丨4 小时前
算法时间复杂度和空间复杂度
算法
HarmonLTS4 小时前
Python人工智能深度开发:技术体系、核心实践与工程化落地
开发语言·人工智能·python·算法
a程序小傲5 小时前
京东Java面试被问:RPC调用的熔断降级和自适应限流
java·开发语言·算法·面试·职场和发展·rpc·边缘计算