题目描述

✅ 87. 扰乱字符串

image-20260928225225492

image-20260928225225493

题意分析

每次把字符串切成两个非空连续部分,可以保留它们的先后顺序,也可以整体交换,再分别递归处理两部分。判断 s2 能否这样由 s1 得到。操作保留每个字符的数量,但不允许任意重排,因此频次相同只是必要条件。

解法:记忆化搜索(递归划分 + 剪枝)

核心思路

[!blue]

用 dfs(i1, i2, len) 表示:s1 从 i1 开始、长度为 len 的片段,能否扰乱得到 s2 从 i2 开始的同长度片段。结果只由这三个量决定,与如何到达这个子问题无关,因此可以记忆化,避免不同切法重复求解相同片段。

枚举第一串前半段的长度 k,范围为 1 到 len - 1。若当前层不交换,第一串的前 k 个字符对应第二串的前 k 个,剩余部分也一一对应,条件是 dfs(i1, i2, k) 与 dfs(i1 + k, i2 + k, len - k) 同时为真。

若当前层交换,第一串前 k 个字符被放到目标片段的末尾,所以它在第二串的起点为 i2 + len - k;第一串后 len - k 个字符则对应目标开头。条件变为 dfs(i1, i2 + len - k, k) 与 dfs(i1 + k, i2, len - k) 同时为真。任意一种切点和交换选择成功,就能拼出当前目标;反过来,任何合法扰乱都必然有这样的第一层划分,因此枚举不会遗漏。

搜索前先做两种剪枝:两段完全相同,可以递归保持各部分顺序,直接返回真;字符频次不同,无论如何交换都无法弥补,直接返回假。长度为 1 时必然被其中一种情况处理;其余递归长度严格减小,保证结束。

缓存需要区分“未计算、成功、失败”。Java 用可为 null 的 Boolean 数组,Go 用 seen 区分是否求过,再由 memo 保存布尔结果。失败也要缓存,否则无解片段仍会被反复展开。

解题步骤

  1. 长度不同则返回假,否则初始化缓存,从 dfs(0, 0, n) 开始。
  2. 若状态已有缓存,直接返回;否则先判断两段相同或字符频次不同,并缓存相应结果。
  3. 枚举所有非空切分,依次检查不交换和交换两种配对。某种配对的两个子问题都成功时,缓存真并返回。
  4. 所有切法都失败,缓存假并返回。只满足某一侧不足以构造完整目标,两侧必须同时成立。

代码实现

class Solution {
    private String s1;
    private String s2;
    private Boolean[][][] memo;

    public boolean isScramble(String s1, String s2) {
        if (s1.length() != s2.length()) {
            return false;
        }

        this.s1 = s1;
        this.s2 = s2;
        int n = s1.length();

        memo = new Boolean[n][n][n + 1];

        return dfs(0, 0, n);
    }

    // 状态由两串起点与共同长度确定,切点两侧必须同时成立
    private boolean dfs(int i1, int i2, int len) {
        if (memo[i1][i2][len] != null) {
            return memo[i1][i2][len];
        }

        if (s1.regionMatches(i1, s2, i2, len)) {
            memo[i1][i2][len] = true;

            return true;
        }

        // 频次不符直接缓存失败,之后不再重复展开同一状态
        if (!sameCount(i1, i2, len)) {
            memo[i1][i2][len] = false;

            return false;
        }

        for (int k = 1; k < len; k++) {
            if (dfs(i1, i2, k) && dfs(i1 + k, i2 + k, len - k)) {
                memo[i1][i2][len] = true;

                return true;
            }

            // 交换时前 k 个字符对应第二串末尾 k 个,而非从 k 处开始
            if (dfs(i1, i2 + len - k, k) && dfs(i1 + k, i2, len - k)) {
                memo[i1][i2][len] = true;

                return true;
            }
        }

        memo[i1][i2][len] = false;

        return false;
    }

    private boolean sameCount(int i1, int i2, int len) {
        int[] cnt = new int[26];

        for (int k = 0; k < len; k++) {
            cnt[s1.charAt(i1 + k) - 'a']++;
            cnt[s2.charAt(i2 + k) - 'a']--;
        }

        for (int x : cnt) {
            if (x != 0) {
                return false;
            }
        }

        return true;
    }
}
func isScramble(s1 string, s2 string) bool {
    if len(s1) != len(s2) {
        return false
    }
    n := len(s1)

    memo := make(map[[3]int]bool)
    seen := make(map[[3]int]bool)

    var sameCount func(i1, i2, length int) bool
    sameCount = func(i1, i2, length int) bool {
        var cnt [26]int
        for k := 0; k < length; k++ {
            cnt[s1[i1+k]-'a']++
            cnt[s2[i2+k]-'a']--
        }
        for i := 0; i < 26; i++ {
            if cnt[i] != 0 {
                return false
            }
        }
        return true
    }

    var dfs func(i1, i2, length int) bool
    // 状态由两串起点与共同长度确定,切点两侧必须同时成立
    dfs = func(i1, i2, length int) bool {
        key := [3]int{
            i1,
            i2,
            length,
        }
        if seen[key] {
            return memo[key]
        }
        seen[key] = true

        if s1[i1:i1+length] == s2[i2:i2+length] {
            memo[key] = true
            return true
        }
        // 频次不符直接缓存失败,之后不再重复展开同一状态
        if !sameCount(i1, i2, length) {
            memo[key] = false
            return false
        }

        for k := 1; k < length; k++ {
            if dfs(i1, i2, k) && dfs(i1+k, i2+k, length-k) {
                memo[key] = true
                return true
            }
            // 交换时前 k 个字符对应第二串末尾 k 个,而非从 k 处开始
            if dfs(i1, i2+length-k, k) && dfs(i1+k, i2, length-k) {
                memo[key] = true
                return true
            }
        }
        memo[key] = false
        return false
    }

    return dfs(0, 0, n)
}

复杂度分析

  • 时间复杂度:$O(n^4)$ 上界,其中 $n$ 为字符串长度。共有 $O(n^3)$ 个起点与长度状态,每个状态至多花 $O(n)$ 检查片段、统计频次和枚举切点;已计算的子问题直接查缓存。
  • 空间复杂度:$O(n^3)$,主要来自记忆化缓存,递归深度最多为 $O(n)$。

关键点总结

[!green]

  • 状态必须区分未计算、真、假。
  • 当前切点两侧用与连接,不同切法之间用或。

易错点总结

[!yellow]

  • 交换分支定位第二串尾部时,长度减切点写错会配错片段。
  • 切点含零或全长,子问题不再严格缩小。
  • 只比较频次就返回,忽略递归切分约束。

相似题目

题目 难度 关联与区别
241. 为运算表达式设计优先级 中等 同样枚举区间分割点并组合左右子问题,本题还要比较交换与不交换两种对应。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/46508600
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!