Skip to content

139. Word Break (Medium)

Problem

Given a string s and a dictionary of strings wordDict, return true if s can be segmented into a space-separated sequence of one or more dictionary words.

Example

  • s = "leetcode", wordDict = ["leet", "code"] → true
  • s = "applepenapple", wordDict = ["apple", "pen"] → true
  • s = "catsandog", wordDict = ["cats", "dog", "sand", "and", "cat"] → false

LeetCode 139 · Link · Medium

Try it yourself

idle

Starter code: this editor begins with intentional TODOs. Fill the function, run the embedded tests, then compare your solution with the worked approaches below.

Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Approach 1: Recursive, try every prefix

def word_break(s, word_dict):
words = set(word_dict) # L1: O(D) to build set
def f(start):
if start == len(s): # L2: base case, O(1)
return True
for end in range(start + 1, len(s) + 1): # L3: try every prefix from start
if s[start:end] in words and f(end): # L4: O(end - start) slice + hash lookup
return True
return False
return f(0) # L5: initial call

Where the time goes, line by line

Variables: n = len(s), W = word count, D = total dictionary characters, L = max word length.

LinePer-call costTimes executedContribution
L1 (build set)O(D)O(D)1O(D)O(D)
L2 (base case)O(1)O(1)once per callO(calls)O(calls)
L3 (range loop)O(n)O(n) iterationsonce per callO(n⋅calls)O(n · calls)
L4 (slice + lookup)O(n)O(n) upper boundO(n)O(n) per call, O(2n)O(2^n) callsO(n22n)O(n^2 2^n) coarse upper bound
L5 (initial call)O(1)O(1)1O(1)O(1)

Without memoization, different segmentations can reach the same suffix and recompute it. A run of "a" characters ending in "b", with dictionary words covering many lengths of "a", forces repeated failed branches. There are at most O(2n)O(2^n) calls, each trying up to n endpoints with up to n characters of substring work. This gives a deliberately coarse exponential upper bound.

Complexity

  • Time: O(D+n22n)O(D + n^2 2^n) as a coarse upper bound including substring work. Memoization removes the repeated subproblems.
  • Space: O(n+W)O(n + W) for the call stack, word-set entries, and a temporary substring. Recursion passes indices rather than suffix copies.

Approach 2: Top-down memoized

Cache by start index.

from functools import lru_cache
def word_break(s, word_dict):
words = set(word_dict) # L1: O(D) to build set
@lru_cache(maxsize=None)
def f(start):
if start == len(s): # L2: base case, O(1)
return True
for end in range(start + 1, len(s) + 1): # L3: try every split point
if s[start:end] in words and f(end): # L4: O(end - start) slice + hash lookup
return True
return False
return f(0) # L5: initial call

Where the time goes, line by line

Variables: n = len(s), W = word count, D = total dictionary characters, L = max word length.

LinePer-call costTimes executedContribution
L1 (build set)O(D)O(D)1O(D)O(D)
L2 (base case)O(1)O(1)at most n + 1O(n)O(n)
L3 (range loop)O(n)O(n) iterationsn unique start valuesO(n2)O(n²) total iters
L4 (slice + lookup)O(n)O(n) upper boundO(n2)O(n²) totalO(n3)O(n³) ← dominates
L5 (initial call)O(1)O(1)1O(1)O(1)

Memoization limits f to at most n + 1 unique start values. Each call tries up to O(n)O(n) endpoints. In Python, the unrestricted slices and their hashes can each cost O(n)O(n), so this implementation has an O(n3)O(n^3) upper bound, plus dictionary setup.

Complexity

  • Time: O(D+n3)O(D + n^3), including dictionary setup and unrestricted Python slicing and hashing.
  • Space: O(n+W)O(n + W) for cache entries, stack, set entries, and the temporary candidate substring. Recursion passes indices rather than copying suffix strings.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Approach 3: Bottom-up DP (canonical)

def word_break(s, word_dict):
words = set(word_dict) # L1: O(D) to build set
n = len(s) # L2: O(1)
dp = [False] * (n + 1) # L3: O(n) allocation
dp[0] = True # L4: base case, O(1)
for i in range(1, n + 1): # L5: outer loop, n iterations
for j in range(i): # L6: inner loop, i iterations
if dp[j] and s[j:i] in words: # L7: O(i - j) slice + hash lookup
dp[i] = True # L8: O(1)
break
return dp[n] # L9: O(1)

State: what does each slot mean?

dp[i] means the first i characters can be segmented into dictionary words. It does not mean that the entire prefix must itself be one dictionary word.

For s = "leetcode" and word_dict = ["leet", "code"]:

Prefix length: 0 1 2 3 4 5 6 7 8
Prefix: "" l le lee leet leetc leetco leetcod leetcode
dp: T F F F T F F F T

There are n + 1 slots because prefix lengths run from 0 through n. dp[0] = True is the valid empty prefix: it supplies a starting point for the first dictionary word. This is the prefix-length DP convention.

What each part of the code does

CodeMeaning
words = set(word_dict)Build a membership structure for whole dictionary words. dict(word_dict) would expect key-value pairs.
dp = [False] * (n + 1)Reserve one slot for every prefix length, including zero.
dp[0] = TrueMark the empty prefix as valid so the first word can begin at index 0.
for i in range(1, n + 1)Choose the endpoint of the prefix being solved.
for j in range(i)Try each starting position for its final word.
dp[j]Check whether everything before the split is already segmentable.
s[j:i] in wordsCheck whether the entire final piece is one dictionary word. s[i - 1] alone would inspect only one character.
dp[i] = TrueRecord that a valid split was found.
breakStop searching: this is a feasibility question, so one valid split is enough.
return dp[n]Return whether the entire string can be segmented.

Concrete transitions: a valid prefix plus one last word

At i = 4, the split j = 0 works:

Already valid prefix: "" dp[0] = True
Last word: "leet" s[0:4] in words
Result: dp[4] = True

At i = 8, the split j = 4 works:

Character index: 0 1 2 3 4 5 6 7
s: l e e t | c o d e |
Split position: j=4 i=8
Already valid prefix: "leet" dp[4] = True
Last word: "code" s[4:8] in words
Result: dp[8] = True

The reusable rule is a valid earlier prefix plus one valid final piece makes a valid larger prefix. Both checks must hold for the same split j. A dictionary word at the end cannot repair an invalid earlier prefix: "xcode" is false with ["leet", "code"], even though "code" is a word.

Python’s and skips the substring check when dp[j] is false. Words remain in the set after use, so "applepenapple" can use "apple" twice. The loop considers all possible splits rather than committing to the first word that looks plausible.

Where the time goes, line by line

Let n = len(s), W be the number of dictionary words, and D be their total character count. These Python bounds account for creating and hashing new substrings under average hash-table behavior.

LineWorkCost
L1Build the word set, including initial word hashing.O(D)O(D)
L2-L4Read the length, allocate the DP table, and set its base case.O(n)O(n)
L5-L6Try split positions for every endpoint.At most 1 + 2 + ... + n pairs, or O(n2)O(n^2)
L7If dp[j] is true, create and hash s[j:i].O(i−j)O(i-j) per checked substring, up to O(n)O(n)
L8Set a feasible state and stop its inner loop.At most once per endpoint, O(n)O(n) total
L9Read the final state.O(1)O(1)

Complexity: The unrestricted version takes O(D+n3)O(D + n^3) worst-case time, not merely O(n2)O(n^2): counting split pairs alone omits substring creation and hashing. An early break can help individual inputs but does not remove that worst case. It uses O(n+W)O(n + W) auxiliary space for the DP table, set entries referencing the existing words, and one temporary substring of up to length n. If dictionary character storage is counted too, the bound is O(n+D)O(n + D).

The maximum dictionary-word length does not bound the substrings created by this code: the unrestricted loop still constructs candidates that are too long to match. The final version below avoids that wasted work.

Try this approach:

idle
Click Run Python to execute. First run downloads Python (~10 MB, cached after that).

Final version: bound the last word’s length

Keep the same state, base case, and two checks. Only the candidate split range changes. If the longest dictionary word has length L, a substring longer than L cannot match a word, so there is no reason to construct it.

def word_break_bounded(s: str, word_dict: list[str]) -> bool:
words = set(word_dict)
max_len = max(len(word) for word in words)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(max(0, i - max_len), i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
return dp[n]

The problem guarantees a nonempty dictionary of nonempty words, so max has at least one value. A valid final word starts no earlier than i - max_len. Clamping that start to 0 avoids negative positions near the beginning of the string. Every valid segmentation considered by the original version is still reachable.

Complexity discussion

Let L be the longest dictionary-word length and K = min(n, L). The optimized version considers at most K splits per endpoint, for O(nK)O(nK) candidates. Each new substring has at most K characters, so slicing and hashing cost at most O(K)O(K). Total time is O(D+nK2)O(D + nK^2), commonly written O(D+nL2)O(D + nL^2) when L <= n. Computing max_len takes O(W)O(W), absorbed by O(D)O(D) because dictionary words are nonempty.

For this problem, L <= 20. That bounds the substring work at each endpoint, so the optimized scan is linear in n when treating the maximum word length as a fixed constant, plus dictionary setup. Without the length cap, the baseline has O(n2)O(n^2) candidate splits and up to O(n)O(n) substring work per candidate, hence O(D+n3)O(D + n^3).

The optimized implementation uses O(n+W)O(n + W) auxiliary space: the DP table, word-set references, and one temporary substring of up to K characters. Counting the input word characters as stored dictionary data gives O(n+D)O(n + D) instead. A set lookup has average constant-time table probing, but hashing a newly created string still takes time proportional to its length.

This remains prefix DP. Word Break can depend on any valid earlier split within the word-length window, so the two-variable optimization from Climbing Stairs and House Robber cannot be copied directly.

Summary

Python costs below include dictionary setup. D is total dictionary characters, W is word count, and L is maximum word length. The naive recursion row is a coarse upper bound that includes substring work.

ApproachTimeSpace
Naive recursionO(D+n22n)O(D + n^2 2^n) coarse upper boundO(n+W)O(n + W)
Top-down memoO(D+n3)O(D + n^3) upper boundO(n+W)O(n + W)
Bottom-up DPO(D+n3)O(D + n^3)O(n+W)O(n + W)
Length-bounded bottom-up DPO(D+nK2)O(D + nK^2), K = min(n, L)O(n+W)O(n + W)

The “partition a string into dictionary pieces” template applies to Word Break II (140, which enumerates all segmentations) and Concatenated Words (472).

Test cases

# Quick smoke tests, paste into a REPL or save as test_word_break.py and run.
# Uses the canonical implementation (Approach 3: bottom-up DP).
def word_break(s, word_dict):
words = set(word_dict)
n = len(s)
dp = [False] * (n + 1)
dp[0] = True
for i in range(1, n + 1):
for j in range(i):
if dp[j] and s[j:i] in words:
dp[i] = True
break
return dp[n]
def _run_tests():
# LeetCode examples
assert word_break("leetcode", ["leet", "code"]) == True
assert word_break("applepenapple", ["apple", "pen"]) == True
assert word_break("catsandog", ["cats", "dog", "sand", "and", "cat"]) == False
# Edge cases
assert word_break("a", ["a"]) == True
assert word_break("a", ["b"]) == False
# Word reuse
assert word_break("aaaa", ["a", "aa"]) == True
print("all tests pass")
if __name__ == "__main__":
_run_tests()
  • Strings, prefix DP
  • Hash Tables, average constant-time table probing after hashing the candidate word
  • Memoization, top-down caching tactics for preserving recursive clarity while avoiding repeated subproblem work.

  • Sequence DP, order-aware DP tactics for strings and arrays where prefixes or positions define reusable states.

  • Tabulation: prefix-length DP, one Boolean state per prefix and a valid empty prefix.