0087-扰乱字符串
使用下面描述的算法可以扰乱字符串 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
由小写英文字母组成
方法一:动态规划
思路与算法
显然「扰乱字符串」的关系是具有对称性的,即如果 $s_1$ 是 $s_2$ 的扰乱字符串,那么 $s_2$ 也是 $s_1$ 的扰乱字符串。为了叙述方便,我们称这种情况下,$s_1$ 和 $s_2$ 是「和谐」的。
那么如何判断 $s_1$ 和 $s_2$ 是否「和谐」呢?我们首先可以想到几个简单的判断方法:
如果 $s_1 = s_2$,那么它们是「和谐」的;
如果 $s_1$ 和 $s_2$ 的长度不同,那么它们一定不是「和谐」的;
如果 $s_1$ 中某个字符 $c$ 出现了 $x_1$ 次,而 $c$ 在 $s_2$ 中出现了 $x_2$ 次,且 $x_1 \neq x_2$,那么它们一定不是「和谐」的。这是因为任意操作都不会改变一个字符串中的字符种类以及数量。
那么对于剩下的情况,我们该如何判断呢?我们可以从 $s_1$ 的分割方法入手。假设 $s_1$ 作为根节点时被分割成了 $l(s_1)$ 以及 $r(s_1)$ 两个子串,那么:
如果 $l(s_1)$ 和 $r(s_1)$ 没有被交换,那么 $s_2$ 需要存在一种分割方法 $s_2 = l(s_2) + r(s_2)$,使得 $l(s_1)$ 和 $l(s_2)$ 是「和谐」的,并且 $r(s_1)$ 和 $r(s_2)$ 也是「和谐」的;
如果 $l(s_1)$ 和 $r(s_1)$ 被交换了,那么 $s_2$ 需要存在一种分割方法 $s_2 = l(s_2) + r(s_2)$,使得 $l(s_1)$ 和 $r(s_2)$ 是「和谐」的,并且 $r(s_1)$ 和 $l(s_2)$ 也是「和谐」的。
这样一来,我们就把原本需要解决的问题划分成了两个本质相同,但规模更小的子问题,因此可以考虑使用动态规划解决。
设 $f(s_1, s_2)$ 表示 $s_1$ 和 $s_2$ 是否「和谐」,那么我们可以写出状态转移方程:
$$
f(s_1, s_2) =
\begin{cases}
\text{True}, & \quad s_1=s_2 \
\text{False}, & \quad 存在某个字符c,它在s_1和s_2~中的出现次数不同 \
\end{cases}
$$
因为题目保证给定的原始字符串的长度相同,因此我们只需要判断上面的两种情况。如果 $s_1$ 和 $s_2$ 不符合这两种情况,那么我们需要枚举分割点。
设 $s_1$ 和 $s_2$ 的长度为 $n$,我们用 $s_1(x, y)$ 表示从 $s_1$ 从第 $x$ 个字符(从 $0$ 开始编号)开始,长度为 $y$ 的子串。由于分割出的两个字符串不能为空串,那么其中一个字符串就是 $s_1(0, i)$,另一个字符串是 $s_1(i, n-i)$。
对于 $l(s_1)$ 和 $r(s_1)$ 没有被交换的情况,$s_2$ 同样需要被分为 $s_2(0, i)$ 以及 $s_2(i, n-i)$,否则长度不同的字符串是不可能「和谐」的。因此我们可以写出状态转移方程:
$$
f(s_1, s_2) = \bigvee_{i=1}^{n-1} \big( f(s_1(0, i), s_2(0, i)) \wedge f(s_1(i, n-i), s_2(i, n-i)) \big)
$$其中 $\wedge$ 表示与运算,即 $s_1$ 和 $s_2$ 分割出的两对字符串都要是「和谐」的;$\vee$ 表示或运算,即只要有一种满足要求的分割方法,$s_1$ 和 $s_2$ 就是和谐的。
对于 $l(s_1)$ 和 $r(s_1)$ 被交换的情况,$s_2$ 需要被分为 $s_2(0, n-i)$ 以及 $s_2(n-i, i)$,这样对应的长度才会相同。因此我们可以写出状态转移方程:
$$
f(s_1, s_2) = \bigvee_{i=1}^{n-1} \big( f(s_1(0, i), s_2(n-i, i)) \wedge f(s_1(i, n-i), s_2(0, n-i)) \big)
$$
我们将上面两种状态转移方程用 $\vee$ 或运算拼在一起,即可得到最终的状态转移方程。
细节
细节部分比较长,希望读者仔细阅读,否则写出来的代码可能会较为复杂,或者使用较多不必要的空间。
在进行状态转移时,我们需要先计算出较短的字符串对应的 $f$ 值,再去转移计算出较长的字符串对应的 $f$ 值,这是因为我们需要保证在计算 $f(s_1, s_2)$ 时,所有它们的子串对应的状态都需要被计算过。因此,如果我们使用常规的动态规划方法编写代码,可能会受到计算顺序的困扰,使得代码冗长。
而我们可以考虑使用「记忆化搜索」自顶向下地进行动态规划,这样我们只需要用题目中给定的两个原始字符串开始,递归地计算所有的 $f$ 值,而无需考虑计算顺序。
由于我们使用记忆化搜索,因此我们需要把 $s_1$ 和 $s_2$ 作为参数传入记忆化搜索使用的递归函数。这样一来,在递归传递参数的过程中,会使用到大量字符串的切片、拷贝等操作,使得时空复杂度不那么优。本题中,由于给定原始字符串的长度不超过 $30$,因此不会产生太大的影响,但我们还是要尽可能对代码进行优化。
一种通用的优化方法是,我们将状态变更为 $f(i_1, i_2, \textit{length})$,表示第一个字符串是原始字符串从第 $i_1$ 个字符开始,长度为 $\textit{length}$ 的子串,第二个字符串是原始字符串从第 $i_2$ 个字符开始,长度为 $\textit{length}$ 的子串。可以发现,我们只是改变了表达 $s_1$ 和 $s_2$ 的方式,但此时我们只需要在递归时传递三个整数类型的变量,省去了字符串的操作;
代码
1 | class Solution { |
1 | class Solution { |
1 | class Solution: |
1 | func isScramble(s1, s2 string) bool { |
1 | struct HashTable { |
1 | var isScramble = function(s1, s2) { |
复杂度分析
时间复杂度:$O(n^4)$,其中 $n$ 是给定的原始字符串的长度。动态规划中的状态 $f(i_1, i_2, \textit{length})$ 有 $3$ 个维度,对于每一个状态,我们需要 $O(n)$ 枚举分割位置,因此总时间复杂度为 $O(n^4)$。
空间复杂度:$O(n^3)$,即为存储所有动态规划状态需要的空间。