🎓 A1Learner
🔍
0 of 112 lessons completed

DSA Word Break

Word break asks whether a string can split fully into words from a given word set. A reachable position is a string index that a valid split can reach.

The problem

Word Break asks whether a string can split fully into words from a given word set. A split covers the whole string. No letters are left over, and no letters are skipped.

The answer is True or False. The article also recovers one full split when one exists. A word from the set may be used more than once.

The cases used in this article

cases = [
    ("leetcode", ["leet", "code"], True),
    ("applepenapple", ["apple", "pen"], True),
    ("catsandog", ["cats", "dog", "sand", "and", "cat"], False),
    ("aaaaaaa", ["aaaa", "aaa"], True),
]
for s, words, expected in cases:
    print("string", s, "words", words, "can break:", expected)

Read the third row with care. The string catsandog starts well with its set words. Its last letters still block a full split.

Intuition: take the longest word that fits

The first idea scans from the start. It takes a set word that matches at the current position. Taking the longest match feels safest, because it covers the most letters.

That greedy choice can strand the tail. The block below shows the trap on a tiny string.

Greedy longest match fails on aaaa

s = "aaaa"
words = {"aa", "aaa"}
pos = 0
taken = []
while pos < len(s):
    matches = [w for w in words if s.startswith(w, pos)]
    if not matches:
        break
    word = max(matches, key=len)
    taken.append(word)
    pos += len(word)
print("greedy took:", taken)
print("letters covered:", pos, "of", len(s))
print("the split aa + aa works:", "aa" in words and "aa" in words)

The greedy run took aaa and stalled at one letter. The split aa plus aa covers the whole string. A full search must be ready to back away from a long match.

Brute force: try every cut from every position

The brute force tries each set word at the current position. It then solves the rest of the string the same way. No position result is stored, so the same tail is solved again and again.

The brute force, with calls counted

calls = 0

def can_break(s, words, start):
    global calls
    calls += 1
    if start == len(s):
        return True
    for end in range(start + 1, len(s) + 1):
        if s[start:end] in words and can_break(s, words, end):
            return True
    return False

words = {"a", "aa"}
for n in [6, 8, 10]:
    calls = 0
    result = can_break("a" * n + "b", words, 0)
    print("length", n + 1, "can break:", result, "calls:", calls)

The trailing b makes every split fail. The brute force only learns that after trying the cuts. Its call count climbs fast as the string grows.

The optimal solution: spread reachable positions

The state is reachable(i), which is true when the first i characters split fully into set words. A position spreads forward through every set word that starts there.

The start position 0 is reachable, because the empty prefix needs no words. The answer is the value at the final position. A parent records one word start per reached position, so one split can be rebuilt.

Reachable positions on every case

def word_break(s, words):
    parent = [-1] * (len(s) + 1)
    parent[0] = 0
    for end in range(1, len(s) + 1):
        for start in range(end):
            if parent[start] != -1 and s[start:end] in words:
                parent[end] = start
                break
    if len(s) > 0 and parent[len(s)] == -1:
        return False, []
    pieces = []
    end = len(s)
    while end > 0:
        start = parent[end]
        pieces.append(s[start:end])
        end = start
    pieces.reverse()
    return True, pieces

cases = [
    ("leetcode", {"leet", "code"}, True),
    ("applepenapple", {"apple", "pen"}, True),
    ("catsandog", {"cats", "dog", "sand", "and", "cat"}, False),
    ("aaaaaaa", {"aaaa", "aaa"}, True),
]
for s, words, expected in cases:
    answer, pieces = word_break(s, words)
    print("string:", s, "->", answer, "split:", pieces, "match:", answer == expected)

All four cases from the start of the article pass. The greedy trap string also splits here, as a later edge case shows. Reachable positions never commit to one greedy choice.

Walkthrough: leetcode, position by position

The walk fills the reachable table from left to right. Each row names the end position and the word that reached it, when one did.

Reachable positions after each end

s = "leetcode"
words = {"leet", "code"}
reachable = [False] * (len(s) + 1)
reachable[0] = True
print("position 0 reachable: True (start)")
for end in range(1, len(s) + 1):
    found = None
    for start in range(end):
        if reachable[start] and s[start:end] in words:
            found = s[start:end]
            reachable[end] = True
            break
    print("position", end, "reachable:", reachable[end], "word:", found)
print("positions:", [i for i in range(len(s) + 1) if reachable[i]])
print("verdict:", reachable[-1])

Only positions 0, 4, and 8 are reachable. Position 4 comes from leet. Position 8 comes from code, and it is the final position, so the verdict is True.

Complexity

The table checks pairs of positions, so its time is O(n^2) substring checks for a string of length n. The table and the parent list each hold one entry per position, so the space is O(n).

Counting the work instead of timing it

calls = 0

def can_break(s, words, start):
    global calls
    calls += 1
    if start == len(s):
        return True
    for end in range(start + 1, len(s) + 1):
        if s[start:end] in words and can_break(s, words, end):
            return True
    return False

words = {"a", "aa"}
for n in [6, 8, 10]:
    calls = 0
    can_break("a" * n + "b", words, 0)
    checks = (n + 1) * (n + 2) // 2
    print("length", n + 1, "- brute calls:", calls, " table pair checks:", checks)

The brute calls grow faster than the pair checks. The table pays each pair of positions once. The brute force pays for the same tail many times.

Edge cases and pitfalls

The empty string splits with no words at all. Its verdict is True. A middle position can stay unreachable while the final verdict is still True, as the walkthrough showed.

Edge cases, run

def word_break(s, words):
    reachable = [False] * (len(s) + 1)
    reachable[0] = True
    for end in range(1, len(s) + 1):
        for start in range(end):
            if reachable[start] and s[start:end] in words:
                reachable[end] = True
    return reachable[-1]

print("empty string:", word_break("", {"a"}))
print("greedy trap aaaa:", word_break("aaaa", {"aa", "aaa"}))
print("word reused:", word_break("aa", {"a"}))
print("tail blocked:", word_break("catsandog", {"cats", "dog", "sand", "and", "cat"}))
print("no word fits at all:", word_break("abc", {"ab"}))
print("unused words ignored:", word_break("ab", {"ab", "zz"}))

Reuse is allowed in this problem. The word a covers aa by appearing twice. Reading only the final position gives the verdict, never the middle rows.

Exercise

reachable reads [True, False, True, False, True] for a string of length 4. What is the verdict?