139. Word Break(单词拆分)五种解法(C++ & 注释)

1. 题目描述

给定一个非空字符串 s 和一个包含非空单词列表的字典 wordDict,判定 s 是否可以被空格拆分为一个或多个在字典中出现的单词。

说明:

  • 拆分时可以重复使用字典中的单词。
  • 你可以假设字典中没有重复的单词。

示例 1:

输入: s = “leetcode”, wordDict = [“leet”, “code”]
输出: true
解释: 返回 true 因为 “leetcode” 可以被拆分成 “leet code”。

示例 2:

输入: s = “applepenapple”, wordDict = [“apple”, “pen”]
输出: true
解释: 返回 true 因为 “applepenapple” 可以被拆分成 “apple pen apple”。注意你可以重复使用字典中的单词。

示例 3:

输入: s = “catsandog”, wordDict = [“cats”, “dog”, “sand”, “and”, “cat”]
输出: false

题目链接:中文题目英文题目

2. 暴力解法(Brute Force, Time Limit Exceeded)

2.1 解题思路

暴力解法的思路简单粗暴:

  1. 从序号0开始,分别生成长度为1 ~ s长度的子串(j),并在wordDict里面查找;
  2. 如果找到,说明s[0, j - 1]在wordDict里面存在,继续从序号j开始重复1. 的步骤;
  3. 如果idx = s.size(),表示s可以用wordDict里面的字符串进行拼接;反之,则不可以;

但是这种方法会出现很多重复查找的情况,所以运行超时。举个例子说明重复查找的情况:

s = “catsanddog”, wordDict = [“cat”, “cats”, “sand”, “and”, “dogs”]

上面例子有两个路径:1)cat -> sand;2)cats -> and;无论走哪一个,最终都会来到dog这个地方,但是wordDict里面没有"dog",所以只要走过其中一条,就能到这么一条信息:不论前面怎么分解,只要最后来到dog,那么s一定不可拆解。但是暴力破解的方法会重复拆解上面重复的路径,所以浪费了很多时间。那么下面“3. 深度优先搜索 + 记忆”中,我们将对其进行优化。

2.2 实例代码

class Solution {
    unordered_set<string> m;

    bool recursionMethod(int idx, int maxLen, string& s) {
        if (idx >= maxLen) return true;
        for (int i = maxLen - idx; i >= 1; i--) {
            if (m.count(s.substr(idx, i)) && recursionMethod(idx + i, maxLen, s)) return true;
        }

        return false;
    }

public:
    bool wordBreak(string s, vector<string>& wordDict) {
        if (!s.size() || !wordDict.size()) return false;
        for (string& str : wordDict) m.insert(str);
        return recursionMethod(0, s.size(), s);
    }
};

3. 深度优先搜索 + 记忆(Depth-First Search + Memoization)

3.1 解题思路

通过上一节的分析,我们发现在递归求解过程中只需要把不能拆解的序号记录下来,之后如果访问到相同的序号,则直接跳过,避免重复计算。所以初始化Hash Table(unbreakable)记录不能拆解的序号,每次循环时,遇到相同的序号则跳过。

3.2 实例代码

class Solution {
    unordered_set<string> wordDictSet;
    unordered_set<int> unbreakable;

    bool dsf(string& s, int idx) {
        if (idx == s.size()) return true;
        for (int i = idx + 1; i <= s.size(); i++) {
            if (unbreakable.count(i)) continue;
            if (wordDictSet.count(s.substr(idx, i - idx))) {
                if (dsf(s, i)) return true;
                unbreakable.insert(i); // 标记s[i]这个位置不能拆解
            }
        }

        return false;
    }

public:
    bool wordBreak(string s, vector<string>& wordDict) {
        if (s.empty() || wordDict.empty()) return false;
        for (string& str : wordDict) wordDictSet.insert(str);
        return dsf(s, 0);
    }
};

4. 广度优先搜索(Breadth-First Search)

4.1 解题思路

我们先来看看题目给的一个例子:

s = “applepenapple”, wordDict = [“apple”, “pen”]

当我们查找完成,返回true的时候,经过的序号顺序为:0 -> 4 -> 7 -> 12,那么这道题可以转换成:从0能不能走到最后的序号,所以有以下步骤:

  1. 把0放在记录待访问的queue中(toDo),再初始化记录访问过序号的Hash Table(visited),取出queue第一个元素(position);
  2. 如果position不在visited之中,访问wordDict中每个字符串(str),如果s[position, position + str.size() - 1] == str,则表示s可以拆解成str,并且position + str.size() <= lenS,表示str不会超过s的长度,否则不能拆解,
  3. 如果position + str.size() == lenS,表示s刚好被拆解完全,返回true即可;反之把position放在visited,把position + str.size()放在toDo中,表示下一次需要拆解的起始序号;
  4. 重复上面步骤直到toDo没有任何元素,最后返回false;

这个思路的关键之处是把单词拆解转换成了路径访问问题,所以以后遇到问题可以转换成路径访问的,都可以使用本方法解题。

4.2 实例代码

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        int lenS = s.size();
        queue<int> toDo;
        toDo.push(0);
        unordered_set<int> visited;

        while (toDo.size()) {
            int position = toDo.front();
            toDo.pop();

            if (!visited.count(position)) {
                visited.insert(position);

                // 查找可能的路径
                for (string& str : wordDict) {
                    int lenStr = str.size();
                    if (position + lenStr <= lenS && str == s.substr(position, lenStr)) {
                        if (lenStr + position == lenS) return true;
                        toDo.push(lenStr + position);
                    }
                }
            }
        }

        return false;
    }
};

5. 动态规划(Dynamic Programming)

5.1 解题思路

既然可以保存不可拆解的序号,进行逐层拆解,那么能不能引入动态规划的思想呢?当然是OK哒,我们用一个数(dps)组表示某个位置是否可以拆解,dps[0] = true,表示初始状态。

动态规划有两种查找方法:1)遍历wordDict查找;2)在wordDict中查找某一个字符串;对于前者,如果某个字符串(str)长度小于等于当前序号(i),且dps[i - str.size()] = true,表示i - str.size()位置之前都可以拆解,最后满足str == s[i - str.size(), i - 1],那么dps[i] = true;注意这里dps长度是s.size() + 1,如下图所示:

在这里插入图片描述
对于后者,我们从某个序号(i)开始,从s左边界开始连续生成长度为1 ~ str.size()的子串,在wordDict中查找,找到表示dps[i]位置可以拆解。上面是用已知wordDict中的字符串生成子串,这里是生成某个位置之前所有包含的子串,如下图所示:
在这里插入图片描述

5.2 实例代码

class Solution {
public:
    bool wordBreak(string s, vector<string>& wordDict) {
        int lenS = s.size();
        vector<bool> dps(lenS + 1, false);
        dps[0] = true;

        // 第一种DP写法
        /*for (int i = 1; i <= lenS; i++) {
            for (string& str : wordDict) {
                if (str.size() <= i && dps[i - str.size()] && str == s.substr(i - str.size(), str.size())) {
                    dps[i] = true; break;
                }
            }
        }*/

        // 第二种DP写法
        unordered_set<string> m;
        for (string& str : wordDict) m[str] = true;

        for (int i = 1; i <= lenS; i++) {
            for (int j = 0; j < i; j++) {
                if (dps[j] && m.count(s.substr(j, i - j))) { dps[i] = true; break; }
            }
        }

        return dps[lenS];
    }
};

6. 前缀树(Prefix-tree)

6.1 解题思路

前缀树方法的主要思想和“3. 深度优先搜索 + 记忆”是一样的,不同的是这里不用Hash Table来进行查找,而使用前缀树来进行查找。所以本方法的重点是理解前缀树的结构、生成和查找。

首先来介绍一下前缀树的概念。通俗易懂的就是:将字符按照从左往右的顺序连接起来(前缀),如果某个节点为真,那么表示该节点(含)之前的字符表示一个完整的单词。比如下面这个例子:

“a”, “abc”, “abcde”, “aec”, ”“bcd”

转换成前缀树,结构图如下:
在这里插入图片描述
树节点有两个属性成员:1)bool型表示是否是一个完整的单词(ifWord );2)存储当前字符的数组(nextNodes);那么添加字符串的操作为:从第一个节点开始,找到str[i],那么继续下一个str[i + 1];反之,找不到则将nextNoes[str[i]]位置添加一个节点,表示当前节点有str[i]字符;最后当到达str的末尾序号,ifWord = true,表示这里有一个完整的单词;

查找字符串的方法和添加方法基本一致:nextNoes[str[i]]不为空,表示有str[i]字符,继续查找str[i + 1]。如果为空,表示没有该字符,返回false;当最后当到达str的末尾序号,检查两个东西:1)nextNoes[str[i]] != nullptr 2)ifWord == true,只有两者都满足才表示前缀树中有该字符串。

6.2 实例代码

struct PrefixTreeNode {
    bool ifWord = false;
    vector<PrefixTreeNode*> nextNodes;
    PrefixTreeNode() :nextNodes(vector<PrefixTreeNode*>(256, nullptr)) {} // 本题这里可以初始化26个位置,因为测试案例只有小写字母,但是如果需要cover所有的字符,要初始化256个位置
};

class Solution {
    vector<PrefixTreeNode*> prefixTree; // 也可以初始化成一个PrefixTreeNode,但后面的函数略有变化,思路不变
    unordered_set<int> unbreakable;

    // 回收所有动态分配的内存
    void deleteNodes(vector<PrefixTreeNode*>& tree) {
        for (PrefixTreeNode* node : tree) {
            if (node) { deleteNodes(node->nextNodes); delete(node); }
        }
    }

    // 将字符串添加到前缀树中
    void addToPrefixTree(vector<PrefixTreeNode*>& tree, int idx, string& str) {
        if (idx > str.size()) return;
        if (!tree[str[idx]]) tree[str[idx]] = new PrefixTreeNode();;
        if (idx == static_cast<int>(str.size()) - 1) tree[str[idx]]->ifWord = true;
        addToPrefixTree(tree[str[idx]]->nextNodes, idx + 1, str);
    }

    // 查找字符串是否在前缀树中
    bool findStringInPrefixTree(vector<PrefixTreeNode*>& tree, int idx, string str) {
        if (idx == static_cast<int>(str.size()) - 1) return tree[str[idx]] && tree[str[idx]]->ifWord;
        if (!tree[str[idx]]) return false;
        return findStringInPrefixTree(tree[str[idx]]->nextNodes, idx + 1, str);
    }

    bool dsf(string& s, int idx) {
        if (idx == s.size()) return true;
        for (int i = idx + 1; i <= s.size(); i++) {
            if (unbreakable.count(i)) continue;
            if (findStringInPrefixTree(prefixTree, 0, s.substr(idx, i - idx))) {
                if (dsf(s, i)) return true;
                unbreakable.insert(i);
            }
        }

        return false;
    }

public:
    Solution() :prefixTree(vector<PrefixTreeNode*>(256, nullptr)) {}

    ~Solution() { deleteNodes(prefixTree); }

    bool wordBreak(string s, vector<string>& wordDict) {
        if (s.empty() || wordDict.empty()) return false;
        for (string& str : wordDict) addToPrefixTree(prefixTree, 0, str);  // 生成前缀树
        return dsf(s, 0);
    }
};

7. 参考资料

  1. C++中substr()详解
  2. std::string::substr
  3. Java implementation using DP in two ways
  4. A solution using BFS
  5. 4 different ways to solve this with detailed explanation

在这里插入图片描述

  • 5
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值