Longest Substring Without Repeating Characters
Sliding window: grow the right edge one character at a time, and when a repeat appears, jump the left edge just past its previous position.
- Pattern
- Sliding window — grow the right edge, jump the left edge past the last repeat
- Difficulty
- Medium
- Time
- O(n)
- Space
- O(k)
Given a string, return the length of the longest substring (contiguous) with no repeated character. (LC 3)
This is the sliding window in its cleanest form, and nothing else in the circuit teaches it. Checking every substring is O(n³) done naively, and O(n²) with a set per start. The window gets it to one pass.
The recipe
Say before you type: "I keep a window with no repeats, defined by a left and a right edge. I move the right edge one character at a time. If that character is already inside the window, I jump the left edge to just past where I last saw it. Either way I record the window's length."
- Keep a map from character to the last index you saw it at.
- Move
rightacross the string. - If the character was last seen at an index that is inside the window (
>= left), setleft = lastSeen + 1. - Record
lastSeen[char] = right. - Update the best with
right - left + 1.
The code
function lengthOfLongestSubstring(s: string): number {
const lastSeen = new Map<string, number>();
let left = 0, best = 0;
for (let right = 0; right < s.length; right++) {
const ch = s[right];
const prev = lastSeen.get(ch);
if (prev !== undefined && prev >= left) left = prev + 1; // only if it is inside the window
lastSeen.set(ch, right);
best = Math.max(best, right - left + 1);
}
return best;
}
Why this shape
The window [left, right] always holds distinct characters. When right meets a repeat, you do not need to slide the left edge one step at a time, because the map already says where the repeat is: everything up to and including its old position has to go. Jumping there directly is why the whole thing is one pass.
Both edges only move forward, so each index is visited at most twice, which is O(n).
Complexity
| Cost | Because | |
|---|---|---|
| Time | O(n) | right visits each character once, and left only moves forward |
| Space | O(k) | The map holds at most one entry per distinct character, k of them |
Traps
- The left edge must never move backwards.
"abba"is the classic. When you reach the finala, the map says it was last seen at index 0, which is behind the window (leftis already 2). Without theprev >= leftcheck you would dragleftback to 1 and report a window of 3 instead of 2."tmmzuxt"breaks it the same way. - Restarting the window at a repeat. Clearing everything when you see a duplicate is tempting.
"dvdf"shows why it fails: the best window is"vdf", which starts inside the old one. - Subsequence versus substring.
"pwwkew"is3("wke"), not4. Counting distinct characters,new Set(s).size, answers a different question. - The off-by-one. The window length is
right - left + 1, notright - left. - Unicode.
s[right]indexes UTF-16 code units, so an emoji counts as two. If the prompt allows them, iterate withArray.from(s)instead. Mentioning it is the kind of thing that gets noticed.
The pattern this trains
A contiguous range with a property that breaks when you extend it is a sliding window. Grow the right edge to include more, shrink the left edge to restore the property. It is the same two-pointer move in Minimum Window Substring (76), Longest Repeating Character Replacement (424), Permutation in String (567) and Max Consecutive Ones III (1004). The trigger words are longest, shortest or at most k attached to a substring or subarray.