扰乱字符串

标签: 字符串 动态规划

难度: Hard

使用下面描述的算法可以扰乱字符串 s 得到字符串 t
  1. 如果字符串的长度为 1 ,算法停止
  2. 如果字符串的长度 > 1 ,执行下述步骤:
    • 在一个随机下标处将字符串分割成两个非空的子字符串。即,如果已知字符串 s ,则可以将其分成两个子字符串 xy ,且满足 s = x + y
    • 随机 决定是要「交换两个子字符串」还是要「保持这两个子字符串的顺序不变」。即,在执行这一步骤之后,s 可能是 s = x + y 或者 s = y + x
    • xy 这两个子字符串上继续从步骤 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
  • s1s2 由小写英文字母组成

Submission

运行时间: 32 ms

内存: 16.2 MB

class Solution:
    def save(self,s1,s2,flag):
        self.compare[s1+"_"+s2]=flag

    def check(self,s1,s2):
        answer=list()
        in_s1_notin_s2=list()
        in_s2_notin_s1=list()
        for idx in range(len(s1)):
            if s1[idx]!=s2[idx]:
                if s1[idx]in in_s2_notin_s1:
                    in_s2_notin_s1.remove(s1[idx])
                else:
                    in_s1_notin_s2.append(s1[idx])
                if s2[idx]in in_s1_notin_s2:
                    in_s1_notin_s2.remove(s2[idx])
                else:
                    in_s2_notin_s1.append(s2[idx])
            if len(in_s1_notin_s2)==0 and len(in_s2_notin_s1)==0:
                answer.append(idx+1)
        if len(answer)!=0 and answer[-1]!=len(s1):
            return []
        return answer[:-1]

    def go(self,s1,s2):
        key=s1+"_"+s2
        if key in self.compare:
            return self.compare[key]
        if len(s1)==1:
            self.save(s1,s2,True)
            return s1==s2
        mids=self.check(s1,s2)
        if mids!=[]:
            if len(s1)==2 and mids==[1]:
                self.save(s1,s2,True)
                return True
            for mid in mids:
                if self.go(s1[:mid],s2[:mid])and self.go(s1[mid:],s2[mid:]):
                    self.save(s1,s2,True)
                    return True
        s2=s2[::-1]
        mids=self.check(s1,s2)
        if mids!=[]: 
            for mid in mids:
                if self.go(s1[:mid],s2[:mid])and self.go(s1[mid:],s2[mid:]):
                    self.save(s1,s2,True)
                    return True
        self.save(s1,s2,False)
        self.save(s1,s2[::-1],False)
        return False

    def isScramble(self, s1: str, s2: str) -> bool:
        self.compare=dict()
        return self.go(s1,s2)

Explain

这个题解使用了记忆化搜索的方法。首先判断两个字符串是否相等,如果相等直接返回True。然后通过check函数找出所有可能的分割点,即两个字符串在该位置之前的部分所包含的字符相同。接着对于每个分割点,递归判断两个字符串分割后的左右两部分是否分别满足扰乱字符串的定义。在递归过程中,使用一个字典self.compare存储已经计算过的子问题的结果,避免重复计算。如果按顺序分割的情况不满足,就再尝试将s2反转后再进行分割判断。最后如果所有分割点都无法满足条件,则返回False。

时间复杂度: O(n^3),但实际运行时间低于O(n^3)

空间复杂度: O(n^2)

```python
class Solution:
    def save(self, s1, s2, flag):
        # 将子问题的结果存入字典
        self.compare[s1 + "_" + s2] = flag

    def check(self, s1, s2):
        # 检查两个字符串在每个位置上是否包含相同的字符
        answer = list()
        in_s1_notin_s2 = list()
        in_s2_notin_s1 = list()
        for idx in range(len(s1)):
            if s1[idx] != s2[idx]:
                if s1[idx] in in_s2_notin_s1:
                    in_s2_notin_s1.remove(s1[idx])
                else:
                    in_s1_notin_s2.append(s1[idx])
                if s2[idx] in in_s1_notin_s2:
                    in_s1_notin_s2.remove(s2[idx])
                else:
                    in_s2_notin_s1.append(s2[idx])
            if len(in_s1_notin_s2) == 0 and len(in_s2_notin_s1) == 0:
                answer.append(idx + 1)
        if len(answer) != 0 and answer[-1] != len(s1):
            return []
        return answer[:-1]

    def go(self, s1, s2):
        # 记忆化搜索
        key = s1 + "_" + s2
        if key in self.compare:
            return self.compare[key]
        if len(s1) == 1:
            self.save(s1, s2, True)
            return s1 == s2
        mids = self.check(s1, s2)
        if mids != []:
            if len(s1) == 2 and mids == [1]:
                self.save(s1, s2, True)
                return True
            for mid in mids:
                if self.go(s1[:mid], s2[:mid]) and self.go(s1[mid:], s2[mid:]):
                    self.save(s1, s2, True)
                    return True
        s2 = s2[::-1]
        mids = self.check(s1, s2)
        if mids != []:
            for mid in mids:
                if self.go(s1[:mid], s2[:mid]) and self.go(s1[mid:], s2[mid:]):
                    self.save(s1, s2, True)
                    return True
        self.save(s1, s2, False)
        self.save(s1, s2[::-1], False)
        return False

    def isScramble(self, s1: str, s2: str) -> bool:
        self.compare = dict()
        return self.go(s1, s2)
```

Explore

在`check`函数中,通过逐个比较`s1`和`s2`中对应位置的字符,并维护两个列表`in_s1_notin_s2`和`in_s2_notin_s1`来记录当前位置前`s1`中有而`s2`中没有的字符,以及`s2`中有而`s1`中没有的字符。每次当这两个列表都为空时,说明到当前位置为止,`s1`和`s2`包含相同的字符集合,因此这个位置就可以作为一个潜在的分割点。分割点的选择是基于两个字符串在该位置之前的部分包含相同的字符。

因为扰乱字符串的定义允许在任何位置将一个字符串分成两部分并交换这两部分的顺序。所以,即使按原始顺序的分割未能证明两个字符串是扰乱字符串,交换`s2`的任意两部分后的新顺序可能会满足条件。因此,当按顺序分割的所有可能都被尝试且失败后,我们将`s2`反转,这相当于在递归过程中尝试了另一种分割方式,即交换了`s2`的左右两部分,然后再次使用`check`函数寻找新的分割点进行判断。

记忆化搜索中使用的字典`self.compare`的键是由`s1`和`s2`两个字符串通过下划线连接构成的字符串(例如`s1_s2`),代表一个特定的子问题。值是一个布尔值,表示这个子问题的结果,即字符串`s1`是否可以通过扰乱字符串的规则转换成字符串`s2`。这种方式存储每个子问题的结果可以避免重复计算相同的问题,提高算法效率。

当`s1`和`s2`的长度为1时,两个字符串都只包含一个字符。由于扰乱字符串的定义涉及字符的重新排列,如果两个字符串只有一个字符,那么唯一的排列方式就是字符本身。因此,这种情况下,直接比较这两个字符是否相等即可确定它们是否是扰乱字符串的关系。这是最基本的边界条件,为递归提供了停止点。