【LeetCode Hot100 刷题日记 (61/100)】131. 分割回文串 —— 回溯 + 动态规划预处理 🧠

6 阅读6分钟

📌 题目链接:131. 分割回文串 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:回溯算法、动态规划、字符串处理

⏱️ 目标时间复杂度:O(n ⋅ 2ⁿ) (最坏情况)

💾 空间复杂度:O(n²) (用于预处理回文信息)


在 LeetCode Hot100 中,第 131 题「分割回文串」 是一道经典的 组合型回溯问题,同时巧妙融合了 动态规划预处理 的思想。它不仅考察你对递归与回溯的理解,还要求你具备优化重复计算的意识——这正是面试官最爱问的“如何避免暴力中的冗余? ”的经典场景。

本文将带你从 题目本质 → 核心算法 → 解题步骤 → 复杂度分析 → 多语言实现 全链路打通,确保你不仅能 AC 这道题,还能在面试中清晰表达设计思路!


🔍 题目分析

给定一个字符串 s,要求将其 分割成若干子串,使得 每个子串都是回文串,并返回 所有可能的分割方案

  • 输入:字符串 s(长度 1~16,仅小写字母)

  • 输出:二维字符串数组,每行是一种合法分割

  • 关键点

    • 所有子串必须是回文;
    • 要求 所有可能方案 → 枚举问题 → 回溯(Backtracking)
    • 判断回文若每次都用双指针,会重复计算 → 需预处理

💡 为什么是回溯?
因为我们要“尝试所有可能的切分位置”,并在满足条件时记录路径,不满足则撤销选择——这正是回溯的典型特征:决策树遍历 + 状态恢复


⚙️ 核心算法及代码讲解

本题的核心在于 两个技术点的结合

  1. 回溯算法(Backtracking) :用于枚举所有分割方案;
  2. 动态规划预处理(DP Preprocessing) :用于 O(1) 判断任意子串是否为回文。

🧩 动态规划预处理回文表 f[i][j]

我们定义 f[i][j] 表示子串 s[i..j] 是否为回文串。

状态转移方程

f[i][j] = 
  true,                    if i >= j          (空串或单字符)
  (s[i] == s[j]) && f[i+1][j-1],  otherwise

📌 注意遍历顺序:由于 f[i][j] 依赖 f[i+1][j-1],所以 i 必须 从后往前 遍历,ji+1 开始往后。

这样预处理后,任意子串是否回文可在 O(1) 时间判断。

🔁 回溯搜索所有分割方案

  • 从位置 i = 0 开始;
  • 枚举结束位置 j ∈ [i, n-1]
  • f[i][j] == true,说明 s[i..j] 是回文,加入当前路径;
  • 递归处理 j+1 开始的剩余字符串;
  • 回溯时弹出刚加入的子串(状态恢复);
  • i == n 时,说明已处理完整个字符串,将当前路径加入结果集。

回溯三要素

  1. 选择:选 s[i..j] 作为下一个回文段;
  2. 约束:只有当 f[i][j] 为真才可选;
  3. 目标i == n 时收集答案。

🧭 解题思路(分步详解)

步骤 1️⃣:预处理所有子串是否为回文

  • 创建二维布尔数组 f[n][n],初始化为 true(因为 i >= j 时默认为回文);
  • i = n-10,内层 j = i+1n-1,按 DP 方程填充。

步骤 2️⃣:回溯枚举所有合法分割

  • 使用全局变量 ans 存储当前路径,ret 存储所有结果;

  • i=0 开始 DFS;

  • 对每个 j,若 f[i][j] 成立,则:

    • s.substr(i, j-i+1) 加入 ans
    • 递归 dfs(s, j+1)
    • 回溯:ans.pop_back()

步骤 3️⃣:返回结果

  • i == n,说明完成一次有效分割,将 ans 拷贝进 ret

📊 算法分析

项目分析
时间复杂度O(n ⋅ 2ⁿ) 最坏情况:所有字符相同(如 "aaaa"),任意切分都合法,共有 2ⁿ⁻¹ 种方案,每种方案需 O(n) 时间构造字符串列表。DP 预处理 O(n²) 可忽略。
空间复杂度O(n²) 主要来自 f 数组(n×n)。回溯栈深 O(n),路径存储 O(n),均低于 O(n²)。
面试高频点✅ 回溯模板掌握 ✅ DP 预处理优化思想 ✅ 字符串子串提取(substr)性能意识(C++ 中为 O(k),k 为长度)

💬 面试加分回答
“为了避免在回溯过程中重复判断回文,我采用动态规划预处理所有子串的回文性,将每次判断从 O(n) 降到 O(1),虽然空间多用了 O(n²),但整体效率显著提升,尤其在字符串较长或回文判断频繁时。”


💻 代码

C++ 实现

#include <bits/stdc++.h>
using namespace std;
using ll = long long;

class Solution {
private:
    vector<vector<int>> f;         // f[i][j] 表示 s[i..j] 是否为回文
    vector<vector<string>> ret;    // 存储所有分割方案
    vector<string> ans;            // 当前路径
    int n;                         // 字符串长度

public:
    void dfs(const string& s, int i) {
        if (i == n) {              // 已处理完所有字符
            ret.push_back(ans);    // 将当前方案加入结果
            return;
        }
        for (int j = i; j < n; ++j) {
            if (f[i][j]) {         // 如果 s[i..j] 是回文
                ans.push_back(s.substr(i, j - i + 1)); // 选择该子串
                dfs(s, j + 1);     // 递归处理剩余部分
                ans.pop_back();    // 回溯:撤销选择
            }
        }
    }

    vector<vector<string>> partition(string s) {
        n = s.size();
        f.assign(n, vector<int>(n, true)); // 初始化为 true(i>=j 时成立)

        // 动态规划预处理:从下往上填表
        for (int i = n - 1; i >= 0; --i) {
            for (int j = i + 1; j < n; ++j) {
                f[i][j] = (s[i] == s[j]) && f[i + 1][j - 1];
            }
        }

        dfs(s, 0);                 // 从索引 0 开始回溯
        return ret;
    }
};

// 测试
signed main(){
    ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    Solution sol;
    string s1 = "aab";
    auto res1 = sol.partition(s1);
    // 输出: [["a","a","b"],["aa","b"]]
    for (auto& v : res1) {
        cout << "[";
        for (int i = 0; i < v.size(); ++i) {
            cout << """ << v[i] << """;
            if (i != v.size() - 1) cout << ",";
        }
        cout << "]\n";
    }

    string s2 = "a";
    auto res2 = sol.partition(s2);
    // 输出: [["a"]]
    for (auto& v : res2) {
        cout << "[";
        for (int i = 0; i < v.size(); ++i) {
            cout << """ << v[i] << """;
            if (i != v.size() - 1) cout << ",";
        }
        cout << "]\n";
    }

    return 0;
}

JavaScript 实现(等效逻辑)

/**
 * @param {string} s
 * @return {string[][]}
 */
var partition = function(s) {
    const n = s.length;
    // 预处理 f[i][j]:是否回文
    const f = Array.from({ length: n }, () => Array(n).fill(true));
    
    for (let i = n - 1; i >= 0; i--) {
        for (let j = i + 1; j < n; j++) {
            f[i][j] = (s[i] === s[j]) && f[i + 1][j - 1];
        }
    }

    const ret = [];
    const ans = [];

    function dfs(i) {
        if (i === n) {
            ret.push([...ans]); // 深拷贝当前路径
            return;
        }
        for (let j = i; j < n; j++) {
            if (f[i][j]) {
                ans.push(s.slice(i, j + 1)); // 选择
                dfs(j + 1);                  // 递归
                ans.pop();                   // 回溯
            }
        }
    }

    dfs(0);
    return ret;
};

JS 注意点

  • 使用 slice(i, j+1) 提取子串(左闭右开);
  • ret.push([...ans]) 必须深拷贝,否则后续 pop 会影响已存结果。

🌟 本期完结,下期见!🔥

👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!

💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!