zl程序教程

您现在的位置是:首页 >  其他

当前栏目

Leetcode No.87 扰乱字符串(动态规划)

2023-04-18 16:58:39 时间

一、题目描述

使用下面描述的算法可以扰乱字符串 s 得到字符串 t : 如果字符串的长度为 1 ,算法停止 如果字符串的长度 > 1 ,执行下述步骤: 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串 s ,则可以将其分成两个子字符串 x 和 y ,且满足 s = x + y 。 随机 决定是要「交换两个子字符串」还是要「保持这两个子字符串的顺序不变」。即,在执行这一步骤之后,s 可能是 s = x + y 或者 s = y + x 。 在 x 和 y 这两个子字符串上继续从步骤 1 开始递归执行此算法。 给你两个 长度相等 的字符串 s1 和 s2,判断 s2 是否是 s1 的扰乱字符串。如果是,返回 true ;否则,返回 false 。

示例 1:

输入:s1 = "great", s2 = "rgeat"
输出:true
解释:s1 上可能发生的一种情形是:
"great" --> "gr/eat" // 在一个随机下标处分割得到两个子字符串
"gr/eat" --> "gr/eat" // 随机决定:「保持这两个子字符串的顺序不变」
"gr/eat" --> "g/r / e/at" // 在子字符串上递归执行此算法。两个子字符串分别在随机下标处进行一轮分割
"g/r / e/at" --> "r/g / e/at" // 随机决定:第一组「交换两个子字符串」,第二组「保持这两个子字符串的顺序不变」
"r/g / e/at" --> "r/g / e/ a/t" // 继续递归执行此算法,将 "at" 分割得到 "a/t"
"r/g / e/ a/t" --> "r/g / e/ a/t" // 随机决定:「保持这两个子字符串的顺序不变」
算法终止,结果字符串和 s2 相同,都是 "rgeat"
这是一种能够扰乱 s1 得到 s2 的情形,可以认为 s2 是 s1 的扰乱字符串,返回 true
示例 2:
 输入:s1 = "abcde", s2 = "caebd"
 输出:false
示例 3:
 输入:s1 = "a", s2 = "a"
 输出:true
提示:
s1.length == s2.length
 1 <= s1.length <= 30
 s1 和 s2 由小写英文字母组成

二、解题思路:动态规划

显然「扰乱字符串」的关系是具有对称性的,即如果s1是s2的扰乱字符串,那么s2也是s1的扰乱字符串。为了叙述方便,我们称这种情况下,s1和s2是「和谐」的。

那么如何判断s1和s2是否「和谐」呢?我们首先可以想到几个简单的判断方法:

如果s1=s2,那么它们是「和谐」的;

如果s1和s2的长度不同,那么它们一定不是「和谐」的;

如果s1中某个字符 c 出现了x1次,而 c 在s2中出现了x2次,且x1!=x2,那么它们一定不是「和谐」的。这是因为任意操作都不会改变一个字符串中的字符种类以及数量。

那么对于剩下的情况,我们该如何判断呢?我们可以从s1的分割方法入手。假设s1作为根节点时被分割成了 l(s1) 以及 r(s1) 两个子串,那么:

如果 l(s1) 和 r(s1)没有被交换,那么s2需要存在一种分割方法 s2 = l(s2) + r(s2),使得 l(s1)和l(s2) 是「和谐」的,并且r(s1) 和 r(s2) 也是「和谐」的;

如果 l(s1) 和 r(s1)被交换了,那么 s2需要存在一种分割方法 s2 = l(s2) + r(s2),使得 l(s1)和 r(s2)是「和谐」的,并且 r(s1) 和 l(s2) 也是「和谐」的。

这样一来,我们就把原本需要解决的问题划分成了两个本质相同,但规模更小的子问题,因此可以考虑使用动态规划解决。

设 f(s1, s2)表示 s1和 s2是否「和谐」,那么我们可以写出状态转移方程:

因为题目保证给定的原始字符串的长度相同,因此我们只需要判断上面的两种情况。如果 s1和 s2不符合这两种情况,那么我们需要枚举分割点。

设 s1和 s2 的长度为n,我们用 s1(x, y)表示从 s1从第 x 个字符(从 0 开始编号)开始,长度为 y 的子串。由于分割出的两个字符串不能为空串,那么其中一个字符串就是 s1(0, i),另一个字符串是 s1(i,n−i)。

对于 l(s1) 和 r(s1)没有被交换的情况,s2同样需要被分为 s2(0, i)以及 s2(i, n-i),否则长度不同的字符串是不可能「和谐」的。因此我们可以写出状态转移方程:

对于 l(s1) 和 r(s1)被交换的情况,s2需要被分为 s2(0, n-i)以及 s2(n-i, i),这样对应的长度才会相同。因此我们可以写出状态转移方程:

我们将上面两种状态转移方程用 ∨ 或运算拼在一起,即可得到最终的状态转移方程。

细节

细节部分比较长,希望读者仔细阅读,否则写出来的代码可能会较为复杂,或者使用较多不必要的空间。

1、在进行状态转移时,我们需要先计算出较短的字符串对应的 f 值,再去转移计算出较长的字符串对应的 f 值,这是因为我们需要保证在计算 f(s1, s2)时,所有它们的子串对应的状态都需要被计算过。因此,如果我们使用常规的动态规划方法编写代码,可能会受到计算顺序的困扰,使得代码冗长。

而我们可以考虑使用「记忆化搜索」自顶向下地进行动态规划,这样我们只需要用题目中给定的两个原始字符串开始,递归地计算所有的 f 值,而无需考虑计算顺序。

2、由于我们使用记忆化搜索,因此我们需要把s1和s2作为参数传入记忆化搜索使用的递归函数。这样一来,在递归传递参数的过程中,会使用到大量字符串的切片、拷贝等操作,使得时空复杂度不那么优。本题中,由于给定原始字符串的长度不超过 30,因此不会产生太大的影响,但我们还是要尽可能对代码进行优化。

一种通用的优化方法是,我们将状态变更为 f(i1, i2, length),表示第一个字符串是原始字符串从第 i1个字符开始,长度为length 的子串,第二个字符串是原始字符串从第i2个字符开始,长度为length的子串。可以发现,我们只是改变了表达s1和s2的方式,但此时我们只需要在递归时传递三个整数类型的变量,省去了字符串的操作;

三、代码

public class Solution {
    // 记忆化搜索存储状态的数组
    // -1 表示 false,1 表示 true,0 表示未计算
    int[][][] memo;
    String s1, s2;

    public boolean isScramble(String s1, String s2) {
        int length = s1.length();
        this.memo = new int[length][length][length + 1];
        this.s1 = s1;
        this.s2 = s2;
        return dfs(0, 0, length);
    }

    // 第一个字符串从 i1 开始,第二个字符串从 i2 开始,子串的长度为 length,是否和谐
    public boolean dfs(int i1, int i2, int length) {
        if (memo[i1][i2][length] != 0) {
            return memo[i1][i2][length] == 1;
        }

        // 判断两个子串是否相等
        if (s1.substring(i1, i1 + length).equals(s2.substring(i2, i2 + length))) {
            memo[i1][i2][length] = 1;
            return true;
        }

        // 判断是否存在字符 c 在两个子串中出现的次数不同
        if (!checkIfSimilar(i1, i2, length)) {
            memo[i1][i2][length] = -1;
            return false;
        }

        // 枚举分割位置
        for (int i = 1; i < length; ++i) {
            // 不交换的情况
            if (dfs(i1, i2, i) && dfs(i1 + i, i2 + i, length - i)) {
                memo[i1][i2][length] = 1;
                return true;
            }
            // 交换的情况
            if (dfs(i1, i2 + length - i, i) && dfs(i1 + i, i2, length - i)) {
                memo[i1][i2][length] = 1;
                return true;
            }
        }

        memo[i1][i2][length] = -1;
        return false;
    }

    public boolean checkIfSimilar(int i1, int i2, int length) {
        Map<Character, Integer> freq = new HashMap<Character, Integer>();
        for (int i = i1; i < i1 + length; ++i) {
            char c = s1.charAt(i);
            freq.put(c, freq.getOrDefault(c, 0) + 1);
        }
        for (int i = i2; i < i2 + length; ++i) {
            char c = s2.charAt(i);
            freq.put(c, freq.getOrDefault(c, 0) - 1);
        }
        for (Map.Entry<Character, Integer> entry : freq.entrySet()) {
            int value = entry.getValue();
            if (value != 0) {
                return false;
            }
        }
        return true;
    }

    public static void main(String[] args) {
        Solution solution=new Solution();
        System.out.println(solution.isScramble("great","rgeat"));
    }
}

四、复杂度分析

时间复杂度:O(n^4),其中 n 是给定的原始字符串的长度。动态规划中的状态 f(i1,i2,length) 有 3 个维度,对于每一个状态,我们需要 O(n) 枚举分割位置,因此总时间复杂度为 O(n^4)。

空间复杂度:O(n^3),即为存储所有动态规划状态需要的空间。