Start learning free

LESSON 454 OF 540 · DYNAMIC PROGRAMMING

Word Break in TypeScript: Bottom-Up Dynamic Programming

Practice O(n · m · k) Read the full explanation

You are given a string s and an array of strings wordDict, the dictionary. Return true if s can be split into one or more dictionary words, written one after another with nothing left over, or false otherwise.

The same dictionary word may be used any number of times in a split.

 

Example 1:

Input: s = "leetcode", wordDict = ["leet", "code"]
Output: true
Explanation: "leetcode" is "leet" followed by "code".

Example 2:

Input: s = "applepenapple", wordDict = ["apple", "pen"]
Output: true
Explanation: "applepenapple" is "apple" + "pen" + "apple". The word "apple" is used twice.

Example 3:

Input: s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"]
Output: false
Explanation: A split can start with "cat" + "sand" or with "cats" + "and". Both leave "og",
which is not a word and doesn't start with one.

 

Constraints:

  • 1 <= s.length <= 300
  • 1 <= wordDict.length <= 1000
  • 1 <= wordDict[i].length <= 20
  • s and every wordDict[i] are made of lowercase English letters only, a to z.
  • All the words of wordDict are distinct.

Note:

Your algorithm should run in O(n · m · k) time and use O(n) extra space, where n is the length of s, m the number of words in wordDict and k the length of the longest word.



Trying every way to cut s is far too slow: with s = "aaa…ab" and the words "a", "aa", "aaa"…, the number of ways to cut the a's grows exponentially, and none of them works. Solve smaller versions of the problem instead: let dp[i] be true when the first i characters of s can be split into words. dp[0] is true: the empty prefix needs no words at all. The answer is dp[n].

Fill dp from left to right. The first i characters can be split exactly when they end with some word w and the part before that word can be split. If w has L characters, it must fit (L <= i), the L characters that end the prefix, from position i - L on, must spell w, and dp[i - L] must be true. Try every word for every i: one that works is enough.

The table has n + 1 entries. Each one tries the m words, and comparing a word with the end of the prefix takes up to k character comparisons: O(n · m · k) time. The table takes O(n) space.
When the dictionary is large, you can put the words in a Set instead and, for each i, look up only the pieces of length 1 to k that end at i.
STEP 1 OF 9
    Ctrl + Enter to check

    TYPESCRIPT · DYNAMIC PROGRAMMING

    Word Break in TypeScript, explained

    Problem overview

    The method wordBreak receives a string s and an array of strings wordDict. It returns true when s can be cut into one or more dictionary words, laid one after another with no characters left over, and false otherwise. A word may be used as many times as you like.

    With s = "leetcode" and wordDict = ["leet", "code"] the answer is true: "leet" followed by "code". With "applepenapple" and ["apple", "pen"] it is also true, using "apple" twice. With "catsandog" and ["cats", "dog", "sand", "and", "cat"] it is false: both "cat" + "sand" and "cats" + "and" leave "og", which is not a word.

    The statement asks for O(n · m · k) time and O(n) extra space, where n is the length of s, m the number of words and k the length of the longest word.

    Approach: which prefixes can be split?

    The direct idea is to try every way of cutting the string. That is hopeless in the worst case: with s made of many as followed by a b, and the words "a", "aa", "aaa" and so on, the number of ways to cut the as grows exponentially, and none of them works.

    The fix is to solve smaller versions of the same question and reuse the answers. Let dp[i] say whether the first i characters of s can be split into words. Two facts define the table:

    • dp[0] is true. The empty prefix needs no words, and every split has to start from it.
    • dp[i] is true when some dictionary word w ends exactly at position i and the part before that word, dp[i - w.length], is already true.

    Each entry depends only on shorter prefixes, so filling the table from left to right always has the needed answers ready. The answer to the whole problem is dp[n].

    For "leetcode" with ["leet", "code"] the table ends up as follows (position: splittable):

    0:T 1:F 2:F 3:F 4:T 5:F 6:F 7:F 8:T
    

    Position 4 is true because "leet" ends there and dp[0] is true. Position 8 is true because "code" ends there and dp[4] is true. For "catsandog" with the five words, the table is:

    0:T 1:F 2:F 3:T 4:T 5:F 6:F 7:T 8:F 9:F
    

    Positions 3 ("cat"), 4 ("cats") and 7 ("sand" after "cat", or "and" after "cats") are reachable. Nothing ends at 9, because "og" is not a word, so dp[9] stays false.

    Step by step

    1. Store the length of s in n. The table has an entry for every prefix length from 0 to n, so this number is used again and again.
    2. Create dp with n + 1 entries, all false. A prefix counts as splittable only once a split has been found for it.
    3. Set dp[0] to true. The first word of any split follows the empty prefix.
    4. Loop i from 1 to n inclusive. Entry n is the whole string, so the loop must reach it.
    5. Try every word with a for...of loop: each one is a candidate for the last word of a split of the first i characters.
    6. Compute start, the index where the word would begin if it ended the prefix: i - word.length. It is negative when the word is longer than the prefix.
    7. Check three conditions with &&: the word fits (start >= 0), the part before it can be split (dp[start]), and the characters from start up to i really spell the word. The checks run left to right and stop at the first one that fails, so the text is compared only when the earlier checks pass.
    8. Set dp[i] to true. One word that works is enough.
    9. Return dp[n] after the outer loop.

    The solution in TypeScript

    class Solution {
        wordBreak(s: string, wordDict: string[]): boolean {
            const n = s.length;
            const dp: boolean[] = new Array(n + 1).fill(false);
            dp[0] = true;
            for (let i = 1; i <= n; i++) {
                for (const word of wordDict) {
                    const start = i - word.length;
                    if (start >= 0 && dp[start] && s.substring(start, i) === word) {
                        dp[i] = true;
                    }
                }
            }
            return dp[n];
        }
    }
    

    Some TypeScript details worth knowing:

    • new Array(n + 1).fill(false) creates n + 1 empty slots and fills every one with false. Without fill, the slots would be empty and read as undefined. The annotation boolean[] is only for the compiler; it is erased when the code becomes JavaScript.
    • substring(start, i) returns the characters from index start up to, but not including, index i. That is exactly the last word.length characters of the prefix. Unlike slice, substring treats a negative argument as 0: "abc".substring(-1, 2) is "ab". That is another reason to test start >= 0 first rather than rely on the other checks.
    • Reading outside an array does not throw in TypeScript or JavaScript. dp[-2] is undefined, which is falsy, so a missing start >= 0 check would not crash. It is still the clearer way to say "the word fits".
    • === on strings compares the characters, so s.substring(start, i) === word is a real text comparison.
    • for (const word of wordDict) gives each element directly. for...in would give indices as strings, which is not what you want here.

    Complexity

    Time: O(n · m · k). The table has n + 1 entries. For each one the inner loop tries m words, and comparing a word with the end of the prefix takes up to k character comparisons.

    Space: O(n). The table holds one boolean per prefix length. The substring built for each comparison is at most k characters long.

    When the dictionary is large, a variant puts the words in a Set and, for each i, looks up only the pieces of length 1 up to k that end at i. It returns the same answers on the examples above, and it does not depend on m.

    Edge cases and common mistakes

    • No word fits. "a" with ["b"] returns false, because dp[1] is never set.
    • A word longer than the string. "aaaa" with ["aaaaa"] returns false: for every i, start is negative.
    • Almost splittable. "aaaaaaab" with ["a", "aa", "aaa"] returns false. The first seven characters are all splittable, but nothing ends with b.
    • Several words share a start. "abcd" with ["abcd", "abc"] returns true. Only the word that ends exactly at the end of the string counts for dp[4]; "abc" sets dp[3] and leaves a "d" that is not a word.
    • Forgetting dp[0] = true. Then no entry ever becomes true, since every split starts from the empty prefix.
    • Looping to i < n. The table is then never filled at index n, so dp[n] stays false.
    • Checking only the text. Testing that a word ends at i without checking dp[start] accepts splits that leave a bad piece in front. In "catsandog", "dog" does end at 9, but dp[6] is false.

    Testing

    • "leetcode", ["leet", "code"] gives true.
    • "applepenapple", ["apple", "pen"] gives true.
    • "catsandog", ["cats", "dog", "sand", "and", "cat"] gives false.
    • "a", ["b"] gives false.
    • "aaaaaaab", ["a", "aa", "aaa"] gives false.
    • "abcd", ["abcd", "abc"] gives true.
    • "aaaa", ["aaaaa"] gives false.
    Keep going with TypeScript500+ step-by-step lessons, from your first line of code to coding interviews. Free to start.
    Start learning free