
递归穷举划分为「遍历」和「分解问题」两种思路,其中「遍历」的思路扩展延伸一下就是回溯 算法,「分解问题」的思路可以扩展成动态规划算法。
解法一:回溯法
关键是如何把问题抽象为一棵树形结构,然后在树上进行遍历,把符合答案的遍历路径加入结果集,为了优化遍历效率,可以采用一些剪枝策略,避免冗余计算和无效计算。
这道题换个说法也可以变成一个排列问题:给你一个不包含重复单词的单词列表 wordDict 和一个字符串 s,请你判断是否可以从 wordDict 中选出若干单词的排列(可以重复挑选)构成字符串 s。
这是个元素无重可复选的排列问题
假设 wordDict = ["a", "aa", "ab"], s = "aaab",想用 wordDict 中的单词拼出 s,其实类似一棵 M 叉树,M 为 wordDict 中单词的个数(有多少个单词就有多少个选择路径,多个分叉),站在回溯树的每个节点上,看看哪个单词能够匹配 s[i...] 的前缀,从而判断应该往哪条树枝上走。
把 backtrack 函数理解成在回溯树上游走的一个指针,表示为了拼接s[i:]的子串,可以选择哪个单词(哪条树杈)去匹配该子串的前缀,维护每个节点上的变量 i,即可遍历整棵回溯树,寻找出匹配 s 的组合。 递归函数的时间复杂度的粗略估算方法就是:用递归函数调用次数(递归树的节点数)x 递归函数本身的复杂度。
对于这道题来说,递归树的每个节点其实就是对 s 进行的一次切割,那么最坏情况下 s 能有多少种切割呢?长度为 N 的字符串 s 中共有 N - 1 个「缝隙」可供切割,每个缝隙可以选择「切」或者「不切」,所以 s 最多有 2^N种切割方式,递归树上最多有 2^N个节点。
但是,注意回溯算法穷举时会存在重复的情况,虽然经历了不同的切分,但是切分得出的结果是相同的 "aab",所以这两个节点下面的子树也是重复的,即存在冗余计算。 不管是什么算法,消除冗余计算的方法就是加备忘录。回溯算法也可以加备忘录,我们可以称之为「剪枝」,即把冗余的子树给它剪掉。
就比如面对这个 "aab" 子串的局面,我希望让备忘录告诉我,这个 "aab" 到底能不能被成功切分?如果之前尝试过不能切分的话,我就直接跳过,不用遍历子树去穷举切分了,从而优化效率。
如果之前尝试过能成功切分的话,由于题意只要求判断是否能拆分,不需要穷举所有可能,我们只需要找到一个拼接答案即可,因此可以维护一个全局的found变量,found == true 就是个 base case,整个递归都会被提前终止。
go
func wordBreak(s string, wordDict []string) bool {
// 备忘录记录不能被切割的子串s[i:],避免在回溯递归树上重复判断
memo := make(map[string]struct{})
var found bool
backtrack(s, 0, wordDict, &found, memo)
return found
}
func backtrack(s string, offset int, wordList []string, found *bool, memo map[string]struct{}){
if *found { // 找到一个答案即可
return
}
if offset == len(s){ // 整个 s 都被匹配完成,找到一个合法答案
*found = true
return
}
// 剪枝,查询子串(子树)是否已经计算过
subStr := s[offset:]
if _, ok := memo[subStr]; ok {
// 当前子串(子树)不能被切分,就不用继续递归了
return
}
for _, word := range wordList{
// 剪枝,排除非法的单词选项
if offset + len(word) > len(s) || s[offset:offset+len(word)] != word{
continue
}
// 答案只关心是否能找到,不关心是由哪些单词拼接的,因此不需要记录递归路径上的选择
// 做选择
offset += len(word)
backtrack(s, offset, wordList, found, memo)
// 撤销选择
offset -= len(word)
// 后序位置,将不能切分的子串(子树)记录到备忘录
if *found == false{
memo[subStr] = struct{}{}
}
}
}
解法二:动态规划
现在我们换一种视角,思考一下是否能够把原问题分解成规模更小,结构相同的子问题,然后通过子问题的结果计算原问题的结果。
对于输入的字符串 s,如果我能够从单词列表 wordDict 中找到一个单词 word 匹配 s 的前缀 s[0..k],k为 word的长度,那么只要我能再拼出 s[k+1..],就一定能拼出整个 s。
go
func wordBreak(s string, wordDict []string) bool {
// 备忘录记录字符串s从某个下标 i起的子串是否能被拼接出来
// 0:初始值,-1:不能,1:能
memo := make([]int, len(s))
return dp(s, 0, wordDict, memo)
}
func dp(s string, start int, wordList []string, memo []int) bool{
if start == len(s){ // base case,拼接完整个s
return true
}
if memo[start] != 0{ // 避免重复计算
return memo[start] == 1
}
// 遍历 wordDict,看看哪些单词是 s[start...] 的前缀
for _, word := range wordList{
if start + len(word) > len(s) || s[start:start+len(word)] != word{
continue
}
// word是 s[start...]的前缀
// 那么只要 s[start+len(word)...] 可以被拼出,s[start...] 就能被拼出
if dp(s, start+len(word), wordList, memo){
memo[start] = 1
return true
}
}
// 所有单词都尝试过,无法拼出 s[start...]
memo[start] = -1
return false
}