Longest Substring Without Repeating Characters
Problem statement
Statement
Given a string s, return the length of the longest substring that contains no repeated character. A substring is contiguous, which is what separates this from the subsequence version.
Examples
s = "abcabcbb"
-> 3 ("abc"; every longer window repeats a letter)
s = "bbbbb"
-> 1 ("b")
s = "pwwkew"
-> 3 ("wke"; note "pwke" is a subsequence, not a substring)
s = ""
-> 0
s = "abba"
-> 2 ("ab", then "ba"; the trap case for stale indices)
Constraints
0 <= len(s) <= 5 * 10^4smay contain English letters, digits, symbols and spaces, so do not assume lowercase and do not assume 26 characters.
What this problem is really testing
Whether you can maintain an invariant under two competing pressures. The window wants to grow, because a longer window is a better answer. It is forced to shrink whenever growth would break the uniqueness property. Everything in a sliding-window problem comes down to naming the invariant precisely, deciding which pointer restores it, and proving the total work stays linear even though the window moves in both directions.
Think of it as a train with a fixed rule rather than a fixed length: every carriage must carry a different passenger. The front of the train keeps coupling new carriages. The moment a passenger boards who is already somewhere down the train, you uncouple from the back, past the offending carriage, and the rule holds again. Neither end ever reverses. That is why an algorithm whose window is constantly expanding and contracting is still one pass: the two pointers are each monotone, and monotone pointers over n positions do O(n) total work no matter how the window between them oscillates.
The refinement worth reaching in the interview is the jump. A naive shrink walks the left edge forward one character at a time until the duplicate is gone, which can be O(n) per step and O(n^2) overall on inputs such as "abcdefg...zabcdefg...z". If instead you remember where each character was last seen, the left edge can leap directly past the previous occurrence in one move. The upgrade from a set to a map, from "is it here?" to "where was it?", is the actual content of the problem.
Hints
Hints
- State the invariant first. "The window
s[left..right]contains no repeated character." Every line you write either preserves that or restores it. Without naming it you will write the right code and be unable to defend it. - Grow on the right, repair on the left. Advance
rightone character at a time. If the new character breaks uniqueness, moveleftforward until it holds again. Record the best length after every repair, because the window is valid at that moment. - Notice why this is still linear.
leftandrighteach only ever move forward, so together they traverse at most2npositions regardless of how much the window expands and contracts. The nested loop is a mirage; it does not multiply the work. - Upgrade from a set to a map of last-seen indices. A set answers "is this character in the window?" and forces you to walk
leftforward one removal at a time. A map from character to its most recent index answers "where was it?", lettingleftjump tolast_seen[ch] + 1in a single step. - Guard against stale entries. The map holds the globally most recent index, which may be behind
leftand therefore outside the window. Only jump whenlast_seen[ch] >= left. On"abba", without this guard the finaladragsleftbackwards to index 1 and the window becomes incoherent. - Size the memory by the alphabet, not the string. The map holds at most one entry per distinct character, so space is O(min(m, n)) where
mis the alphabet size. For ASCII that is a 128-slot array and a constant.
Solution and approaches
Solution: three approaches
Approach 1 - Check every substring: O(n^3) time, O(min(m, n)) space
The definition, transcribed directly.
def longest_unique_brute(s):
n, best = len(s), 0
for i in range(n):
for j in range(i, n):
window = s[i:j + 1]
if len(set(window)) == len(window):
best = max(best, j - i + 1)
return best
There are n(n+1)/2 substrings and testing each for uniqueness costs O(n), so the total is Theta(n^3). At n = 5 * 10^4 this is astronomically out of reach. It is still worth writing on the whiteboard for thirty seconds, because it defines correctness precisely and gives you a reference implementation to test the fast version against. It also makes the wasted work visible, which is what motivates the fix: the substring starting at i and the one starting at i + 1 overlap almost entirely, yet this version rebuilds the uniqueness test from scratch for each. Any time two adjacent subproblems share nearly all of their input, there is a sliding-window solution waiting, and naming that observation is a better route to the optimal algorithm than recognising the problem by its title. Generate random strings over a three-letter alphabet and diff this against your fast version; three letters make collisions frequent enough that bugs surface within a few dozen cases.
Approach 2 - Sliding window with a set: O(n) time, O(min(m, n)) space
The first real algorithm: grow right, shrink left one character at a time.
def longest_unique_set(s):
window = set()
left = best = 0
for right, ch in enumerate(s):
while ch in window:
window.remove(s[left])
left += 1
window.add(ch)
best = max(best, right - left + 1)
return best
Why the amortised cost is linear despite the nested loop: each iteration of the inner while removes one character and advances left by one. Since left never decreases and is bounded by n, the inner loop body executes at most n times summed across the entire outer loop. Total work is therefore O(n), not O(n^2). This is the standard amortised argument for sliding windows and it is worth saying explicitly, because an interviewer who sees a loop inside a loop will want to hear it. The pattern is generalised in the sliding window. The remaining inefficiency is in the constant, not the exponent: on a string such as "abcdefghij" * 5000, every repeat drags the left edge through nine removals one at a time, so the algorithm does roughly twice the pointer work of the version below while touching the set on every step. Both are O(n), and only one of them avoids the inner loop entirely. That gap is worth closing, because the fix also removes a whole category of bug: with no inner loop there is no possibility of the shrink condition and the window contents disagreeing.
Approach 3 - Sliding window with last-seen indices: O(n) time, one pass, no inner loop
The optimal formulation. The left edge teleports instead of walking.
def length_of_longest_substring(s):
last_seen = {} # char -> most recent index
left = best = 0
for right, ch in enumerate(s):
if ch in last_seen and last_seen[ch] >= left:
left = last_seen[ch] + 1
last_seen[ch] = right
best = max(best, right - left + 1)
return best
Why the jump is safe: if ch last appeared at index p inside the current window, then every window ending at right and starting at or before p contains two copies of ch and is invalid. The earliest legal start is therefore p + 1, and moving left there in one step skips exactly the positions that were provably doomed. Nothing is lost because none of the skipped windows could have been answers.
Why the >= left guard is not optional: the map records the last occurrence anywhere in the string, including occurrences that have already fallen out of the window. On "abba", by the time right reaches the final a, left has already advanced to 2, while last_seen['a'] is still 0. Without the guard, left would be set to 1, moving backwards, and the window would silently readmit the duplicate b. The reported answer becomes 3 instead of 2. This single comparison is the most common bug in the problem.
Why best updates every iteration: after the possible jump, the window is valid by construction, so its length is a legitimate candidate. Deferring the update to the end of some conditional branch means windows that never triggered a jump are never measured, and the answer comes back too small on strings with no repeats at all.
Why the space bound has a min in it: the map stores one entry per distinct character currently tracked, which cannot exceed the alphabet size m nor the string length n. For ASCII input the map never exceeds 128 entries, so it is constant in practice, and the honest bound for arbitrary Unicode is O(min(m, n)).
Worked trace on "abba"
| right | ch | last_seen[ch] | jump? | left | window | best |
|---|---|---|---|---|---|---|
| 0 | a | absent | no | 0 | "a" | 1 |
| 1 | b | absent | no | 0 | "ab" | 2 |
| 2 | b | 1 >= 0 | yes, to 2 | 2 | "b" | 2 |
| 3 | a | 0 < 2, stale | no | 2 | "ba" | 2 |
Complexity comparison
| Approach | Time | Space | Left-edge movement | Notes |
|---|---|---|---|---|
| All substrings | O(n^3) | O(min(m, n)) | n/a | Reference only |
| Window with a set | O(n) amortised | O(min(m, n)) | one step at a time | Correct, needs the amortised argument |
| Window with index map | O(n) | O(min(m, n)) | jumps | Expected answer, no inner loop |
Edge cases to verify
- Empty string: returns 0. The loop never runs and
bestkeeps its initial value. - All identical:
"bbbbb"returns 1.leftjumps on every character and the window is never longer than one. - All distinct:
"abcdef"returns 6. No jump ever fires, which is whybestmust be updated unconditionally. - Stale index:
"abba"returns 2. Drop the>= leftguard and this returns 3. - Non-letter characters:
"a b!a"treats space and punctuation as ordinary characters. Any solution using a 26-slot array indexed byord(c) - ord('a')reads out of bounds here.
Common mistakes
Where beginners go wrong
- Omitting the
last_seen[ch] >= leftguard. The map remembers occurrences that have already left the window, so an unguarded jump can moveleftbackwards and readmit a duplicate."abba"is the four-character input that exposes it, and nothing shorter does. - Walking the left edge instead of jumping it. Removing one character at a time is correct and amortised linear with a set, but people who mix the two, keeping an index map yet still stepping
leftforward in a loop, get the worst of both: extra memory and no speed-up. - Using a set when you need positions. A set answers membership only. The whole optimisation rests on knowing where the previous occurrence was, so the container has to be a map. The choice of data structure is the algorithmic decision here.
- Updating
bestonly inside the shrink branch. On a string with no repeats the branch never fires and the function returns 0. Measure the window on every iteration, after the possible jump. - Confusing substring with subsequence. On
"pwwkew"the answer is 3 from"wke", not 4 from"pwke". Substrings are contiguous; a solution that drops characters is answering a different and much harder question. - Assuming a 26-letter alphabet. The constraints allow digits, symbols and spaces. Indexing a fixed array by
ord(c) - ord('a')produces negative indices for space and punctuation, which in Python reads silently from the wrong end of the array rather than crashing.
Interview follow-ups to expect
- "Return the substring, not its length." Record the start index alongside
bestwhenever the best improves, then slice at the end. One extra variable, no change to the loop. - "Allow at most k distinct characters." The invariant changes from "no repeats" to "at most k distinct", so the window needs counts rather than last-seen indices. Shrink from the left while the map holds more than k keys. Same skeleton, different invariant, which is the point of the exercise.
- "Allow each character to repeat at most twice." Again counts, shrinking while any count exceeds two. Once you see that the window is defined by a predicate over its contents, all of these are one template.
- "Longest substring with all characters the same after at most k replacements." The predicate becomes
window_length - max_frequency <= k. Maintaining the running maximum frequency is the only subtlety, and it need never decrease for the algorithm to stay correct. - "What if the input is a stream you cannot index?" The index-jump version needs absolute positions, which a stream can still supply as a running counter. Memory stays O(alphabet), so this works unchanged on unbounded input.
- "Make it work on Unicode grapheme clusters." Iterate over clusters rather than code points, since an emoji with a modifier is several code points but one user-perceived character. The algorithm is untouched; the tokenisation is the work.
Related reading
- The Sliding Window: the general template, and how to decide which pointer restores the invariant.
- Strings: the module this belongs to, including why alphabet size so often replaces input size in the space bound.
- Minimum Window Substring: the same machinery aimed at the shortest valid window instead of the longest, which flips when you shrink and when you record.