Longest Substring Without Repeating Characters
The variable-size sliding window — grow the right edge greedily, and jump the left edge only when a rule breaks
Problem Statement
Given a string s, find the length of the longest substring with no repeated characters.
A substring is contiguous — unlike a subsequence, you cannot skip characters.
Input: s = "abcabcbb"
Output: 3
Why: "abc"
Input: s = "bbbbb"
Output: 1
Why: "b"
Input: s = "pwwkew"
Output: 3
Why: "wke" — note "pwke" is a subsequence, not a substringIntuition
The brute force is to try every starting position and extend until a repeat appears. That is O(n²) and it does a lot of repeated work.
The key insight
A window with no repeats stays valid as you extend it — right up until the moment you add a character already inside it.
When that happens, you do not need to restart. You only need to move the left edge just past the previous copy of that character. Everything between the new left edge and the right edge is still repeat-free.
This is the variable-size sliding window, and it differs from the fixed-size kind in one important way.
| Fixed size (Max Average Subarray) | Variable size (here) | |
|---|---|---|
| Window width | Always exactly k | Grows and shrinks |
| Left edge | Moves every step, in lockstep | Moves only when the rule breaks |
| Question asked | "best window of size k" | "largest window that stays valid" |
Why the left edge can jump
This is the part worth slowing down on.
s = "a b c a b c b b"
0 1 2 3 4 5 6 7
Window is [0..2] = "abc". Add s[3] = 'a'.
'a' was last seen at index 0.Any window still containing index 0 now has two as. So every start from 0 is dead — and the next legal start is index 1.
Crucially, you can jump straight there. Sliding the left edge one step at a time would give the same answer, but the jump is what keeps this to a single pass.
The left edge must never move backwards
s = "abba"
At index 3 ('a'), the map still says 'a' was last seen at index 0.
Naively: left = 0 + 1 = 1.
But left is already 2 (pushed there by the second 'b').
Moving back to 1 would put 'b' in the window twice.The fix is left = max(left, lastSeen[c] + 1). The window may only ever grow from the left.
Forgetting the max is the defining bug of this problem — "abba" returns 3 instead of 2.
The real-world version
You are reading a shelf of books left to right, collecting a run with no duplicate titles. When you hit a title already in your run, you do not empty your arms and start again. You drop everything up to and including the earlier copy, and carry on from there.
Approach
Keep a map of character → last index seen
This is what makes the jump possible. A set would tell you whether a character repeats, but not where, so the left edge could only creep.
Move the right edge across the string
One character per step, always forward.
If the character is inside the window, jump the left edge
Inside means it was seen before and its last index is at or after left. Then left = lastSeen[c] + 1, guarded by max.
Record the character's index and the best width
Update lastSeen[c] = right, then best = max(best, right - left + 1).
The +1 is because both edges are inclusive.
Watch it run
Solution
def length_of_longest_substring(s: str) -> int:
"""Longest repeat-free substring, using a variable-size window."""
last_seen: dict[str, int] = {} # character -> most recent index
left = 0
best = 0
for right, char in enumerate(s):
# Only jump if the previous copy is INSIDE the current window.
# max() stops the left edge ever moving backwards.
if char in last_seen and last_seen[char] >= left:
left = last_seen[char] + 1
last_seen[char] = right
# Both edges are inclusive, hence the + 1.
best = max(best, right - left + 1)
return best
def length_of_longest_substring_set(s: str) -> int:
"""Set version — shrinks one step at a time instead of jumping.
Same O(n) total, since each character is removed at most once."""
window: set[str] = set()
left = 0
best = 0
for right, char in enumerate(s):
while char in window:
window.remove(s[left])
left += 1
window.add(char)
best = max(best, right - left + 1)
return best`previous !== undefined`, not `if (previous)`
A character first seen at index 0 gives previous === 0, which is falsy in JavaScript. Writing if (previous) skips the jump entirely for any character that first appeared at the start of the string.
"abba" would then return 4. This is the same falsy-zero trap as in Two Sum, and it is worth building the habit of comparing against undefined whenever a Map can hold 0.
Complexity Analysis
Time Complexity
O(n)
Space Complexity
O(min(n, alphabet))
- Time — the right edge moves
ntimes and the left edge only ever moves forward, so it also moves at mostntimes in total. Two pointers, each crossing the string once. - Space — the map holds at most one entry per distinct character. For lowercase English that is 26 regardless of input length; in general it is bounded by both the alphabet size and
n.
Why the set version is still O(n)
The inner while loop looks like it makes the set version quadratic. It does not.
Each character is added to the window exactly once and removed at most once across the entire run. The total work of the inner loop over the whole algorithm is at most n — this is amortised analysis, the same argument that makes a monotonic stack linear.
Edge Cases
Related Problems
- Maximum Average Subarray I — the fixed-size window, where the left edge moves every step
- Is Subsequence — subsequences, where skipping is allowed
- Two Sum — the same falsy-zero trap with a
Map - Valid Palindrome — two pointers converging instead of sliding