Problem overview
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 nothing left over. A word may be used as many times as you like.
For "leetcode" with {"leet", "code"} the answer is true. For "applepenapple" with {"apple", "pen"} it is also true, and "apple" is used twice. For "catsandog" with {"cats", "dog", "sand", "and", "cat"} it is false: both "cat" + "sand" and "cats" + "and" leave "og", which is not a word.
Approach: solve every prefix
The direct idea is to try every way to cut the string. That is exponential. With s made of many as followed by a b, and the words "a", "aa", "aaa", there are exponentially many ways to cut the as, and every one of them fails at the b.
Instead, answer a smaller question for each prefix: can the first i characters be split into words? Call the answer dp[i]. The empty prefix needs no words, so dp[0] is true, and the final answer is dp[n] for a string of length n.
A prefix of length i can be split exactly when it ends with some dictionary word w and the part before that word can be split. If w has L characters, three things must hold: L <= i (the word fits), dp[i - L] is true, and the L characters from index i - L on spell w. Each dp[i] uses only shorter prefixes, so filling the table from left to right works.
For "leetcode" and {"leet", "code"}, the table for lengths 0 to 8 is:
True False False False True False False False True
Length 4 is true because "leet" follows the empty prefix. Length 8 is true because "code" follows the prefix of length 4, which is already true.
Step by step
- Store
s.Lengthinn. The table has an entry for every prefix length from 0 ton, so this number comes up often. - Create
dpasnew bool[n + 1]. A newboolarray is allfalse: a prefix counts as splittable only once a split is found. - Set
dp[0]totrue. Every split starts from the empty prefix. - Loop
ifrom 1 toninclusive. The last length,n, is the whole string. - Loop over the words with
foreach. Each word is tried as the last word of the prefix of lengthi. - Compute
start = i - word.Length, the index where the word would begin. It is negative when the word is longer than the prefix. - Test three conditions joined by
&&:start >= 0, thendp[start], then the substring comparison.&&stops at the firstfalse, sodp[start]is never read with a negative index, and the text is compared only when the earlier part already splits. - Set
dp[i]totrueinside theif. One word that works is enough. - Return
dp[n]after the outer loop.
The solution in C#
class Solution {
public bool WordBreak(string s, string[] wordDict) {
int n = s.Length;
bool[] dp = new bool[n + 1];
dp[0] = true;
for (int i = 1; i <= n; i++) {
foreach (string word in wordDict) {
int start = i - word.Length;
if (start >= 0 && dp[start] && s.Substring(start, word.Length) == word) {
dp[i] = true;
}
}
}
return dp[n];
}
}
The C# details worth knowing:
s.Substring(start, word.Length)takes a length, not an end index:word.Lengthcharacters beginning atstart. It throwsArgumentOutOfRangeExceptionif the range does not fit inside the string, which is whystart >= 0is checked first. Becausestart + word.Lengthequalsi, the range always ends insides.- In C#,
==on two strings compares their characters, so the comparison withwordworks as it reads. foreachover astring[]reads each word in turn. The loop never changes the array.- Each
Substringcall creates a new string. That is fine for the constraints here (strings up to 300 characters).
Complexity
Time: O(n * m * k), where n is the length of s, m the number of words and k the length of the longest word. The table has n + 1 entries, each tries m words, and one comparison costs up to k characters.
Space: O(n) for the table.
When the dictionary is large, you can put the words in a HashSet<string> and, for each i, look up only the pieces of length 1 to k that end at i.
A variant is useful when the dictionary is large and words are short. Put the words in a HashSet<string>, and for each i test only the pieces of length 1 to 20 that end at i:
public bool WordBreak(string s, string[] wordDict) {
HashSet<string> words = new HashSet<string>(wordDict);
int n = s.Length;
bool[] dp = new bool[n + 1];
dp[0] = true;
for (int i = 1; i <= n; i++) {
for (int len = 1; len <= 20 && len <= i; len++) {
if (dp[i - len] && words.Contains(s.Substring(i - len, len))) { dp[i] = true; break; }
}
}
return dp[n];
}
The limit 20 is the longest word the constraints allow. It gives the same answers on the three examples and on "abcd" with {"ab", "abc", "d"}: True, True, False and True for that last one. In a file of your own it needs using System.Collections.Generic;.
Edge cases and common mistakes
- Leftover characters.
"aba"with{"ab"}returnsfalse: theaat the end has no word. - Reusing a word.
"aaaaaaab"with{"a", "aa", "aaa"}returnsfalsequickly, because the table never marks length 8 as splittable. Words may repeat, so the loop over the words restarts for everyi. - Forgetting
dp[0] = true. Then no prefix can ever become true, and every answer isfalse. - Looping only to
n - 1. The table needsdp[n], so the loop condition isi <= n. - Greedy matching. Taking the first word that matches at the front can fail: for
"abcd"with{"ab", "abc", "d"}, starting with"ab"leaves"cd", but"abc" + "d"works and the answer isTrue. The table tries every word at every position.
Testing
"leetcode"with{"leet", "code"}givesTrue."applepenapple"with{"apple", "pen"}givesTrue."catsandog"with{"cats", "dog", "sand", "and", "cat"}givesFalse."aaaaaaab"with{"a", "aa", "aaa"}givesFalse."aba"with{"ab"}givesFalse."abcd"with{"ab", "abc", "d"}givesTrue."a"with{"a"}givesTrue.
