JAVA:实现Palindromic Partitioning回文分区问题算法(附带源码)

一、项目背景详细介绍

1. 回文与回文分区问题概述

回文(Palindrome) 指从左到右与从右到左读取完全相同的字符串,如 "aba", "abba", "a", 乃至空串。
回文分区(Palindromic Partitioning) 问题是字符串分割与动态规划中的经典题目:

给定字符串 s,将其划分为若干子串,使得每个子串都是回文。常见目标有两类:
1)枚举型:输出所有可能的回文划分;
2)优化型:在所有回文划分中,最小化切分次数(或等价地最小化分段数)。

“最小切分次数”通常定义为:把 s 切成 k 个回文段需要 k-1 次切割。因此,最小切分次数 = 最少分段数 - 1。例如:

  • s = "aab" 的回文划分有:["a","a","b"]["aa","b"]

    • 最少分段为 2(["aa","b"]),对应 最小切分次数 = 1

  • s = "abba" 的最少分段为 1(整串回文),对应 最小切分次数 = 0

2. 问题的重要性与应用

回文分区兼具搜索动态规划特征:

  • 编译器语法分析中,子串有效性检查经常出现布尔表预处理(如回文表);

  • 文本处理/字符串分析中,需要快速分段并满足特定“合法性”约束;

  • 算法教学中,它串联了回文判定预处理回溯搜索DP 优化路径重构等核心技巧;

  • 面试中是高频题,考查代码工程化能力与复杂度权衡。

3. 常见变体

  • 输出全部回文划分(回溯+剪枝);

  • 计算最小切分次数(DP 或记忆化搜索);

  • 在“最优值”基础上,恢复一组最优划分方案

  • 统计回文子串总数(同一张回文表可一鱼多吃);

  • 在线/流式扩展(较难,常用 Manacher 或中心扩展改造)。


二、项目需求详细介绍

为便于教学与复用,本项目实现下列功能,并给出清晰 API:

  1. 输入/输出

    • 输入:字符串 s(仅小写/大小写混合/含空格均可;本文采用通用字符处理)。

    • 输出:

      • List<List<String>>:输出所有回文分区(枚举型)。

      • int最小切分次数(优化型)。

      • List<String>恢复一组最优分区方案(可选但实用)。

  2. 必备算法模块

    • 回文表预处理isPal[i][j] 表示 s[i..j] 是否回文,O(n²) 时间/空间;

    • 回溯搜索:在 isPal 的支撑下枚举所有方案,显著剪枝;

    • 最小切分 DP:计算最小切割次数,O(n²);

    • 方案重建:从 DP 或记录数组中构造一组最优分区

  3. 工程与教学要求

    • 面向对象封装,类职责清晰;

    • 充分中文注释,便于课堂讲解与二次开发;

    • 提供覆盖主流程的 Main 测试;

    • 处理边界:空串、单字符、重复字符串、全不回文、全回文、超长字符串性能等。


三、相关技术详细介绍

1. 回文判定与预处理

最朴素的回文判定是 O(L) 双指针;在回溯中反复调用会放大到 O(n³)。
优化:预处理 isPal 二维布尔表:

  • isPal[i][i] = true

  • isPal[i][i+1] = (s[i]==s[i+1])

  • 一般:isPal[i][j] = (s[i]==s[j]) && isPal[i+1][j-1]
    按子串长度从小到大填表,时间 O(n²),空间 O(n²)。
    有了 isPal,任意子串回文判定变 O(1)。

2. 回溯(DFS)与剪枝

从起点 start=0 开始,尝试每个 end>=start,若 isPal[start][end] 为真,则选择子串 s[start..end] 进入下一层 start=end+1

  • 剪枝:非回文直接跳过;

  • 路径:用 List<String> 维护当前切分轨迹,到达 start==n 时加入答案。

复杂度取决于输出规模(指数级最坏),但 isPal 可以显著减少无效探索。

3. 最小切分次数的 DP

cut[i] 表示前 i 个字符s[0..i-1])的最小切分次数。常见两种等价写法:

  • 写法 A(对切割次数):

    • 初始化:cut[0] = -1(空串无需分段,令切割次数为 -1 方便转移)

    • 转移:对所有 j < i,若 isPal[j][i-1],则 cut[i] = min(cut[i], cut[j] + 1)

    • 结果:cut[n] 即最小切分次数

  • 写法 B(对分段数):

    • part[i] 为最少分段数,初始 part[0]=0

    • isPal[j][i-1],则 part[i] = min(part[i], part[j]+1)

    • 最小切分 = part[n]-1

本文采用写法 A,简洁、常见、利于方案重建。

4. 方案重建

在 DP 时额外记录最佳 prev[i]:到达 i 的最优切割来自哪个 j(使 isPal[j][i-1]cut[i]=cut[j]+1)。
最终从 i=n 倒推到 0,即可恢复一条最优回文分区;再反转即可得到从左到右的方案。

5. 复杂度分析

  • 预处理回文表:O(n²) 时间,O(n²) 空间;

  • 回溯枚举:与答案数量相关,理论最坏指数;

  • 最小切分 DP:O(n²) 时间,O(n) 或 O(n²) 空间(是否保留 isPal 决定)。
    在实际字符串(自然语言、随机字符)中,回文子串稀疏,回溯常能较快结束;而 DP 部分稳定在 O(n²)。


四、实现思路详细介绍

  1. 类设计

    • PalPartitioningSolver:核心算法类

      • precomputePalindrome(s):构建 isPal

      • allPartitions(s):回溯枚举全部回文分区

      • minCut(s):计算最小切分次数

      • minCutPartition(s):恢复一条最优分区方案

      • minCutMemo(s)(可选):记忆化搜索计算最小切分

    • Main:演示与简单测试

  2. 数据结构

    • boolean[][] isPal:回文表

    • 回溯用 Deque<String>List<String> 存放路径

    • DP 用 int[] cutint[] prev

  3. 边界与鲁棒性

    • 空串:所有函数应安全返回(最小切分=0,分区结果为[][[]]视定义,本文输出[[]]表示“零分段”)

    • 单字符:天然回文

    • 大小写与非字母:按字符逐一比较,天然支持

    • 超长串:建议只做最小切分,不枚举全部方案

  4. 可扩展点

    • 支持仅字母数字参与回文(预处理前清洗字符集)

    • 支持忽略大小写(统一小写)

    • 输出所有最小切分方案(需要 DAG 回溯)


五、完整实现代码

/*********************** 文件:PalPartitioningSolver.java ************************/
import java.util.*;

/**
 * 回文分区问题(Palindromic Partitioning)综合求解器
 * 功能:
 * 1)预处理回文表 O(n^2)
 * 2)回溯枚举所有回文分区
 * 3)DP 求最小切分次数
 * 4)从 DP 记录中恢复一组最优分区方案
 * 5)可选:记忆化搜索求最小切分
 */
public class PalPartitioningSolver {

    /**
     * 预处理:构建回文判定表 isPal
     * isPal[i][j] == true 表示 s[i..j](含端点)是回文子串
     * 时间复杂度 O(n^2),空间 O(n^2)
     */
    public boolean[][] precomputePalindrome(String s) {
        int n = s.length();
        boolean[][] isPal = new boolean[n][n];

        // 按子串长度 len 从小到大填表
        for (int len = 1; len <= n; len++) {
            for (int i = 0; i + len - 1 < n; i++) {
                int j = i + len - 1;
                if (len == 1) {
                    isPal[i][j] = true;           // 单字符必回文
                } else if (len == 2) {
                    isPal[i][j] = (s.charAt(i) == s.charAt(j)); // 两字符相等才回文
                } else {
                    isPal[i][j] = (s.charAt(i) == s.charAt(j)) && isPal[i + 1][j - 1];
                }
            }
        }
        return isPal;
    }

    /**
     * 回溯:输出所有回文分区
     * 返回:每个元素是一条完整分区(从左到右的子串列表)
     * 若 s 为空串,返回列表中包含一个空列表,表示“零分段”的合法方案
     */
    public List<List<String>> allPartitions(String s) {
        List<List<String>> ans = new ArrayList<>();
        int n = s.length();
        boolean[][] isPal = precomputePalindrome(s);

        // 路径使用栈/列表保存当前切分
        Deque<String> path = new ArrayDeque<>();
        dfsAllPartitions(s, 0, isPal, path, ans);
        return ans;
    }

    // 辅助:基于 isPal 的回溯
    private void dfsAllPartitions(String s, int start, boolean[][] isPal,
                                  Deque<String> path, List<List<String>> ans) {
        int n = s.length();
        if (start == n) {
            // 到达末尾,一条完整路径
            ans.add(new ArrayList<>(path));
            return;
        }
        for (int end = start; end < n; end++) {
            if (isPal[start][end]) {
                // 可切;选择 s[start..end]
                path.addLast(s.substring(start, end + 1));
                dfsAllPartitions(s, end + 1, isPal, path, ans);
                // 回溯
                path.removeLast();
            }
        }
    }

    /**
     * 动态规划:最小切分次数(最少切割几刀,使每段为回文)
     * 采用 cut[i] 表示 s[0..i-1] 的最小切分次数,cut[0] = -1
     * 转移:若 isPal[j][i-1] 为真,则 cut[i] = min(cut[i], cut[j] + 1)
     * 结果:cut[n]
     */
    public int minCut(String s) {
        int n = s.length();
        if (n <= 1) return 0;
        boolean[][] isPal = precomputePalindrome(s);

        int[] cut = new int[n + 1];
        Arrays.fill(cut, Integer.MAX_VALUE / 2);
        cut[0] = -1; // 便于转移

        for (int i = 1; i <= n; i++) {
            // 尝试所有 j < i,使 s[j..i-1] 为回文
            for (int j = 0; j < i; j++) {
                if (isPal[j][i - 1]) {
                    cut[i] = Math.min(cut[i], cut[j] + 1);
                }
            }
        }
        return cut[n];
    }

    /**
     * 在求最小切分次数的同时,记录前驱位置以恢复一组最优回文分区方案
     * 返回值:一条最优分区(从左到右)
     */
    public List<String> minCutPartition(String s) {
        int n = s.length();
        List<String> empty = new ArrayList<>();
        if (n == 0) return empty;
        boolean[][] isPal = precomputePalindrome(s);

        int[] cut = new int[n + 1];
        int[] prev = new int[n + 1]; // 记录到达 i 的最佳 j
        Arrays.fill(cut, Integer.MAX_VALUE / 2);
        Arrays.fill(prev, -1);
        cut[0] = -1;

        for (int i = 1; i <= n; i++) {
            for (int j = 0; j < i; j++) {
                if (isPal[j][i - 1] && cut[j] + 1 < cut[i]) {
                    cut[i] = cut[j] + 1;
                    prev[i] = j;
                }
            }
        }

        // 通过 prev 回溯,恢复一条最优分区
        List<String> res = new ArrayList<>();
        int idx = n;
        while (idx > 0) {
            int j = prev[idx];
            if (j < 0) { // 理论上不会发生(除非 s 为空或异常)
                // 兜底:整串作为一段
                res.add(s.substring(0, idx));
                break;
            }
            res.add(s.substring(j, idx));
            idx = j;
        }
        Collections.reverse(res);
        return res;
    }

    /**
     * 记忆化搜索:自顶向下求最小切分次数
     * f(i) 表示 s[i..n-1] 的最小切分次数
     * 转移:f(i) = min( 1 + f(j+1) ) 对所有 isPal[i][j] 为真
     * 边界:f(n) = -1(空串无需切割)
     */
    public int minCutMemo(String s) {
        int n = s.length();
        boolean[][] isPal = precomputePalindrome(s);
        int[] memo = new int[n + 1];
        Arrays.fill(memo, Integer.MIN_VALUE);
        return dfsMinCut(0, s, isPal, memo);
    }

    // 记忆化搜索的递归体
    private int dfsMinCut(int i, String s, boolean[][] isPal, int[] memo) {
        int n = s.length();
        if (i == n) return -1; // 空串无需切割
        if (memo[i] != Integer.MIN_VALUE) return memo[i];

        int best = Integer.MAX_VALUE / 2;
        for (int j = i; j < n; j++) {
            if (isPal[i][j]) {
                // s[i..j] 作为一段,后缀的切分次数 + 1
                best = Math.min(best, 1 + dfsMinCut(j + 1, s, isPal, memo));
            }
        }
        memo[i] = best;
        return best;
    }
}

/******************************* 文件:Main.java ********************************/
public class Main {
    public static void main(String[] args) {
        PalPartitioningSolver solver = new PalPartitioningSolver();

        // 示例一:枚举所有回文分区
        String s1 = "aab";
        System.out.println("=== 所有回文分区(" + s1 + ")===");
        var all1 = solver.allPartitions(s1);
        for (var list : all1) {
            System.out.println(list);
        }

        // 示例二:最小切分次数
        String s2 = "aab";
        int minCut = solver.minCut(s2);
        System.out.println("\n=== 最小切分次数(" + s2 + ")===\n" + minCut);

        // 示例三:恢复一组最优分区方案
        System.out.println("\n=== 一组最优分区方案(" + s2 + ")===");
        var best = solver.minCutPartition(s2);
        System.out.println(best + "(切分次数=" + (best.size() - 1) + ")");

        // 示例四:更长字符串的最小切分 & 记忆化搜索验证
        String s3 = "abacdcaba";
        System.out.println("\n=== 最小切分次数(" + s3 + ")===");
        System.out.println("自底向上 DP :" + solver.minCut(s3));
        System.out.println("自顶向下 Memo:" + solver.minCutMemo(s3));

        // 边界用例:空串、单字符、全回文、全不回文
        String sEmpty = "";
        System.out.println("\n=== 边界用例 ===");
        System.out.println("空串所有分区:" + solver.allPartitions(sEmpty));
        System.out.println("空串最小切分次数:" + solver.minCut(sEmpty));

        String sOne = "x";
        System.out.println("单字符所有分区:" + solver.allPartitions(sOne));
        System.out.println("单字符最小切分次数:" + solver.minCut(sOne));
        System.out.println("单字符一组最优方案:" + solver.minCutPartition(sOne));

        String sPal = "abba";
        System.out.println("全回文最小切分次数(abba):" + solver.minCut(sPal)
                + ";方案:" + solver.minCutPartition(sPal));

        String sNoPal = "abcd"; // 每个字符独立回文,需 3 次切割
        System.out.println("全不回文最小切分次数(abcd):" + solver.minCut(sNoPal)
                + ";方案:" + solver.minCutPartition(sNoPal));
    }
}

六、代码详细解读

  1. precomputePalindrome(String s)

    • 作用:以 O(n²) 时间构建布尔表 isPal[i][j],用于 O(1) 查询任意子串是否回文。

    • 要点:按子串长度从小到大填表,减少依赖;单字符回文,双字符相等即回文,其余 isPal[i][j] = (s[i]==s[j]) && isPal[i+1][j-1]

    • 适用:作为所有后续算法(回溯/DP/记忆化)的公共加速模块。

  2. allPartitions(String s)

    • 作用:基于 isPal 回溯枚举所有回文分区方案。

    • 流程:从 start 出发,尝试所有 endisPal[start][end] 为真则选之并进入下一层;到达末尾将路径加入答案。

    • 适用:教学演示、需要列出全部方案的业务(注意最坏指数复杂度)。

  3. dfsAllPartitions(...)(私有)

    • 作用allPartitions 的递归主体,维护当前路径并在终止点收集结果。

    • 要点:使用 Deque<String> 做入栈/出栈避免多余复制。

  4. minCut(String s)

    • 作用:以 DP 计算最小切分次数,时间 O(n²)。

    • 定义cut[i] = s[0..i-1] 的最小切分次数,cut[0]=-1

    • 转移:枚举所有 j<i,若 isPal[j][i-1],取 cut[i] = min(cut[i], cut[j]+1)

    • 适用:只需最小切割次数的场景。

  5. minCutPartition(String s)

    • 作用:在求最小切分的同时记录 prev[i],最终恢复一条最优分区(从左到右)。

    • 要点:DP 时若发现更优,更新 prev[i]=j;结束后自 n 逆推到 0 得到分段,再反转输出。

    • 适用:既要最小切割次数,又要给出一条对应方案的业务。

  6. minCutMemo(String s) / dfsMinCut(...)

    • 作用:提供一个自顶向下记忆化版本的最小切分解法,用来对照自底向上。

    • 定义f(i) 表示 s[i..] 的最小切分次数,f(n)=-1

    • 转移:枚举 j>=i,若 isPal[i][j]f(i) = min(f(i), 1 + f(j+1));使用 memo 缓存避免重复。

    • 适用:递归思维或需要局部求解时;也可作为正确性对拍。

  7. Main.main

    • 作用:样例演示与基本验证,覆盖“枚举全部”“最小切分”“方案恢复”“记忆化”“边界用例”等。


七、项目详细总结

  • 知识要点串联

    • 回文表将回文判定从 O(L) 降至 O(1),为搜索与 DP 提供高效基础。

    • 回溯 解决“枚举型”问题:结构清晰,易剪枝,输出规模主导复杂度。

    • DP 解决“优化型”问题:O(n²) 时间即可拿下最小切分次数;辅以前驱记录即可重建方案

    • 记忆化搜索自底向上互为镜像,便于理解与对拍。

  • 工程性

    • 单一类封装常用功能,主函数集中演示;

    • 注释完整、接口清晰、边界可控;

    • 可在生产中直接复用 minCutminCutPartition 两大接口。

  • 性能权衡

    • 预处理 O(n²) 与空间 O(n²) 是多数字符串 DP 的常态;

    • n 很大且只需最小切割次数,可考虑中心扩展在线计算回文以降空间(见扩展)。

    • 枚举所有方案最坏指数,不建议对超长 s 使用。


八、项目常见问题及解答(FAQ)

  1. Q:为什么 cut[0] = -1
    A:这样当 s[0..i-1] 以某个回文 s[0..i-1] 直接构成一段时,cut[i] = cut[0] + 1 = 0,自然表示“零切割”。该初始化让转移更简洁。

  2. Q:allPartitions 的时间复杂度是多少?
    A:与答案数量相关,最坏指数级。因为每个切分点都有“切/不切”的选择,且需要保证子串回文。isPal 使得判定降到 O(1),但整体仍由解的规模主导。

  3. Q:空间能否优化到 O(n)?
    A:若只求最小切分次数,可以不显式存 isPal,改成中心扩展边算边转移:对每个中心向两侧扩展,实时更新 cut,整体仍 O(n²) 时间、O(n) 空间(见扩展方向)。

  4. Q:如何输出所有最小切分方案?
    A:在最小切分 DP 后得到一张 “可行前驱” 的 DAG(所有使得 cut[i]=cut[j]+1isPal[j][i-1] 的边),再从 n0 回溯 DFS,收集所有路径即可。注意数量可能非常大。

  5. Q:支持忽略大小写或只考虑字母数字吗?
    A:可以。在 precomputePalindrome 前统一预处理字符串(如 toLowerCase()、去除非字母数字),同时保留原索引映射用于还原分区。

  6. Q:记忆化与自底向上谁更好?
    A:二者复杂度相近。记忆化套路直观,便于局部求解;自底向上在工程中更稳定,迭代不受递归深度限制。


九、扩展方向与性能优化

  1. 空间优化的最小切分(中心扩展版,O(n) 空间)

    • 不建 isPal。对每个中心(奇回文中心 i、偶回文中心 i,i+1)向外扩展,每扩一步 (L,R) 都代表 s[L..R] 是回文,于是可以用它更新 cut[R+1] = min(cut[R+1], cut[L] + 1)

    • 这样仅需 int[] cut,空间降至 O(n)。

  2. Manacher 算法结合 DP(理论探索)

    • Manacher 可在 O(n) 求出每个位置的最大回文半径,再配合线段覆盖/跳表结构加速更新 cut。实现复杂度高,综合实用性需权衡。

  3. 输出所有最小切分方案(多路径恢复)

    • 记录所有最优前驱 prevs[i](列表),最终从 n 回溯 DFS,得到全部最优分区。需谨慎内存与输出规模。

  4. 长文本的工程实践

    • 对超长 s,通常只求最小切分,禁用全枚举。

    • 可分段处理(滑窗),或只对热点区间做枚举。

    • 多线程:对不同中心或不同区间并行扩展(需小心共享数组写入同步,通常改为分桶合并)。

  5. 更多业务规则融合

    • 限制分段最大/最小长度;

    • 自定义“合法子串”判定(不仅是回文),只需替换 isPal 的构造逻辑即可复用回溯/DP 框架。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值