LeetCode139 单词拆分

leetcode.cn/problems/wo...

递归穷举划分为「遍历」和「分解问题」两种思路,其中「遍历」的思路扩展延伸一下就是回溯 算法,「分解问题」的思路可以扩展成动态规划算法。

解法一:回溯法

关键是如何把问题抽象为一棵树形结构,然后在树上进行遍历,把符合答案的遍历路径加入结果集,为了优化遍历效率,可以采用一些剪枝策略,避免冗余计算和无效计算。

这道题换个说法也可以变成一个排列问题:给你一个不包含重复单词的单词列表 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
}
相关推荐
图南随笔16 分钟前
Spring Boot(二十一):RedisTemplate的String和Hash类型操作
java·spring boot·redis·后端·缓存
吃饭了呀呀呀16 分钟前
🐳 《Android》 安卓开发教程 - 三级地区联动
android·java·后端
_一条咸鱼_17 分钟前
深入剖析 Vue 状态管理模块原理(七)
前端·javascript·面试
shengjk129 分钟前
SparkSQL Join的源码分析
后端
Linux编程用C29 分钟前
Rust编程学习(一): 变量与数据类型
开发语言·后端·rust
uhakadotcom37 分钟前
一文读懂DSP(需求方平台):程序化广告投放的核心基础与实战案例
后端·面试·github
uhakadotcom1 小时前
拟牛顿算法入门:用简单方法快速找到函数最优解
算法·面试·github
吴生43961 小时前
数据库ALGORITHM = INSTANT 特性研究过程
后端
小黑屋的黑小子1 小时前
【数据结构】反射、枚举以及lambda表达式
数据结构·面试·枚举·lambda表达式·反射机制