Part 7 · Lesson 31 · read
Substrings and Windows
Use a sliding window on text: no repeated letters, replacing characters, anagrams and the smallest window.
On this page
- A window is two markers and a little memory
- The variable-window template
- Longest substring without repeating characters
- Traced: windows over “abcabcbb”
- Longest repeating character replacement
- Fixed-size windows: permutations and anagrams
- Minimum window substring
- Practice questions (7)
- Make it stick
- Part 7 complete: what's next
A window is two markers and a little memory
Lessons 22 and 23 taught the sliding window on arrays. On text it is the same idea. Think of a small frame that you slide along a line of letters. Two pointers (markers), left and right, mark the two ends of a substring (a piece of the text with no gaps). A little memory tells you what is inside the frame right now. It can be a Set (a collection that keeps each item only once), a Map (a collection of key and value pairs, such as letter and count) or an array of counts. Moving the window one step costs O(1), which means it takes the same short time however long the text is. You only update the one letter that enters or leaves. You do not count the whole substring again.
The brute-force way is to check every substring. There are O(n²) substrings and each check costs O(n), so the total is O(n³). The window turns this into one O(n) pass (the time grows in step with the text length, so double the text means double the work). Each pointer only moves forward, so together they move at most 2n times.
Two kinds of windows cover almost every question.
- Variable-size windows grow and shrink to stay valid. The question sounds like “find the longest substring where…”.
- Fixed-size windows always have the same length and just slide. The question sounds like “is any window of length k an anagram of…”. (An anagram uses the same letters in a different order.)
The variable-window template
// Variable-size window: grow on the right, shrink from the left while the window is not valid.let left = 0;for (let right = 0; right < s.length; right++) { // 1. add s[right] to what the window remembers (a Set, counts, ...) while (/* the window is not valid */) { // 2. remove s[left] from what the window remembers left++; } // 3. the window s[left..right] is valid now: save the answer (length, start, ...)}Every problem needs three decisions: what the window remembers, when the window is not valid and what to save when the window is valid. Once you can answer these three, the code is easy to write.
Longest substring without repeating characters
- Memory: a
Set(a collection that keeps each item only once) of the letters in the window. - Not valid: the new letter is already in the Set.
- Shrink: remove letters from the left until the repeat is gone.
- Save: the length
right - left + 1after adding the new letter.
function lengthOfLongestSubstring(s) { const seen = new Set(); // letters that are inside the window now let left = 0, best = 0; for (let right = 0; right < s.length; right++) { while (seen.has(s[right])) { // s[right] is a repeat: shrink until it is gone seen.delete(s[left]); left++; } seen.add(s[right]); best = Math.max(best, right - left + 1); } return best;}console.log(lengthOfLongestSubstring("abcabcbb")); // 3console.log(lengthOfLongestSubstring("bbbbb")); // 1console.log(lengthOfLongestSubstring("pwwkew")); // 3console.log(lengthOfLongestSubstring("")); // 0Each letter is added once and removed at most once, so the time is O(n). The Set can never hold more than the number of different letters in the text or in the alphabet, so the space is O(min(n, alphabet)). That means it is the smaller of the text length and the alphabet size.
Traced: windows over “abcabcbb”
The window s[left..right] starts empty. The Set will hold exactly the letters that are inside the window.
1const s = "abcabcbb";2const seen = new Set();▶let left = 0, best = 0;4for (let right = 0; right < s.length; right++) {5 while (seen.has(s[right])) {6 seen.delete(s[left]);7 left++;8 }9 seen.add(s[right]);10 best = Math.max(best, right - left + 1);11}12console.log(best);| s | "abcabcbb" |
| left | 0 |
| best | 0 |
| seen | Set(0) {} |
| right | char | repeat? | window after | best |
|---|---|---|---|---|
| 0 | p | no | p | 1 |
| 1 | w | no | pw | 2 |
| 2 | w | yes → drop p, w | w | 2 |
| 3 | k | no | wk | 2 |
| 4 | e | no | wke | 3 |
| 5 | w | yes → drop w | kew | 3 |
The answer is 3 (“wke” or “kew”). “pwke” does not count. It skips a letter, so it is a subsequence (letters in the same order, but gaps are allowed) and not a substring. A window must have no gaps.
Longest repeating character replacement
You may change up to k letters. You want the longest window that can become one repeated letter. That is the longest window where (window size − count of the most common letter) ≤ k. The letters that are not the most common one are the ones you would change.
function characterReplacement(s, k) { const count = {}; let left = 0, maxFreq = 0, best = 0; for (let right = 0; right < s.length; right++) { count[s[right]] = (count[s[right]] || 0) + 1; maxFreq = Math.max(maxFreq, count[s[right]]); // highest count of one letter seen in any window so far // letters to change = window size - count of the most common letter while (right - left + 1 - maxFreq > k) { count[s[left]]--; left++; } best = Math.max(best, right - left + 1); } return best;}console.log(characterReplacement("ABAB", 2)); // 4console.log(characterReplacement("AABABBA", 1)); // 4maxFreq again. So it can be bigger than the real highest count inside the window. This is safe. The answer can only get bigger when a window with a larger maxFreq appears. An out-of-date value never lets the window grow past a size we already reached. It also saves us from checking all 26 counters at every step.Fixed-size windows: permutations and anagrams
An anagram of p has exactly the same letter counts as p and the same length. So slide a window of length p.length across s. Keep its letter counts up to date: add the letter that enters and subtract the letter that leaves. Then compare with the counts of p.
function findAnagrams(s, p) { const idx = (c) => c.charCodeAt(0) - 97; const need = new Array(26).fill(0); // letter counts of p const win = new Array(26).fill(0); // letter counts of the window now for (const c of p) need[idx(c)]++; const result = []; for (let i = 0; i < s.length; i++) { win[idx(s[i])]++; // letter enters on the right if (i >= p.length) win[idx(s[i - p.length])]--; // letter leaves on the left: the window stays p.length long if (i >= p.length - 1 && need.every((n, j) => n === win[j])) { result.push(i - p.length + 1); // start index of this anagram } } return result;}console.log(findAnagrams("cbaebabacd", "abc")); // [0, 6]console.log(findAnagrams("abab", "ab")); // [0, 1, 2]Comparing two arrays of 26 numbers costs O(26). That is a fixed amount, so it does not grow with the text, and the total time is O(n). The “permutation in string” question below uses the same loop. It just returns true at the first match.
Minimum window substring
Find the smallest substring of s that contains every letter of t, with the right counts. The window is valid when no letter is missing. This is the “shortest” kind. Grow the window until it has all of t. Then shrink it as far as you can while it still has all of t. Save the best window each time.
function minWindow(s, t) { const need = new Map(); // how many of each letter we still need for (const c of t) need.set(c, (need.get(c) || 0) + 1); let missing = t.length; // how many letters the window is still missing let left = 0, bestStart = 0, bestLen = Infinity; for (let right = 0; right < s.length; right++) { const c = s[right]; if (need.has(c)) { if (need.get(c) > 0) missing--; // we still needed this letter need.set(c, need.get(c) - 1); // can go below 0: that means an extra copy } while (missing === 0) { // the window has all of t: try to make it smaller if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestStart = left; } const l = s[left]; if (need.has(l)) { need.set(l, need.get(l) + 1); if (need.get(l) > 0) missing++; // we just lost a letter we needed } left++; } } return bestLen === Infinity ? "" : s.slice(bestStart, bestStart + bestLen);}console.log(minWindow("ADOBECODEBANC", "ABC")); // BANCconsole.log(minWindow("a", "a")); // aconsole.log(minWindow("a", "aa") === ""); // trueneed counts how many copies of each letter we still want. It can go below 0 when the window holds extra copies. missing is one number that tells you at once (O(1)) whether the window is valid. So you never compare whole maps. The time is O(n + m), where m is the length of t. The space is O(m) for the letters of t.
| Event | Window | Valid? | Best so far |
|---|---|---|---|
| right reaches C (index 5) | ADOBEC | yes | ADOBEC (6) |
| shrink: drop A | DOBEC | no | ADOBEC (6) |
| right reaches A (index 10) | DOBECODEBA | yes (after drops: CODEBA) | ADOBEC (6), because CODEBA is not shorter |
| right reaches C (index 12) | ODEBANC | yes (after drops: BANC) | BANC (4) |
The shortest window is “BANC”.
Practice questions
For each question, first name the three decisions: what the window remembers, when it is not valid, and what to save.
Return the length of the longest substring of s that has no repeated letter.
Hint 1
Hint 2
Hint 3
left straight past the earlier copy?Show all 2 solutions with explanations
- Move
rightforward one letter at a time. - While
s[right]is already in the Set, removes[left]and moveleftforward. - Add
s[right]and save the window length.
function lengthOfLongestSubstring(s) { const seen = new Set(); let left = 0, best = 0; for (let right = 0; right < s.length; right++) { while (seen.has(s[right])) { seen.delete(s[left]); left++; } seen.add(s[right]); best = Math.max(best, right - left + 1); } return best;}console.log(lengthOfLongestSubstring("abcabcbb")); // 3console.log(lengthOfLongestSubstring("bbbbb")); // 1console.log(lengthOfLongestSubstring("pwwkew")); // 3console.log(lengthOfLongestSubstring("")); // 0O(n) time, because each index enters the window once and leaves once. O(alphabet) space, which means the Set never holds more than the number of different letters in the alphabet.
Store the last index (position) of each letter. When a repeat appears inside the window, move left to the spot just after that old copy in one step.
function lengthOfLongestSubstring(s) { const last = new Map(); // letter -> last index where we saw it let left = 0, best = 0; for (let right = 0; right < s.length; right++) { const c = s[right]; if (last.has(c) && last.get(c) >= left) left = last.get(c) + 1; // jump past the earlier copy last.set(c, right); best = Math.max(best, right - left + 1); } return best;}console.log(lengthOfLongestSubstring("abba")); // 2console.log(lengthOfLongestSubstring("tmmzuxt")); // 5The check last.get(c) >= left is important. An old copy that is already outside the window must be ignored (try “abba”). It is still O(n), with fewer steps.
Both are fine. The Set version is easier to get right when you are nervous in an interview. (LeetCode 3.)
You get an uppercase string and a number k. You may replace at most k letters. Return the length of the longest substring made of one repeated letter that you can get.
Hint 1
Hint 2
k.Show the solution with explanations
- Keep the count of each letter in the window, and the highest count you have seen (
maxFreq). - If
size − maxFreq > k, the window needs too many changes, so shrink it from the left. - Save the window size.
function characterReplacement(s, k) { const count = {}; let left = 0, maxFreq = 0, best = 0; for (let right = 0; right < s.length; right++) { count[s[right]] = (count[s[right]] || 0) + 1; maxFreq = Math.max(maxFreq, count[s[right]]); while (right - left + 1 - maxFreq > k) { count[s[left]]--; left++; } best = Math.max(best, right - left + 1); } return best;}console.log(characterReplacement("ABAB", 2)); // 4console.log(characterReplacement("AABABBA", 1)); // 4console.log(characterReplacement("AAAA", 0)); // 4O(n) time, O(26) space. maxFreq is allowed to be out of date after shrinking, as the lesson explained.
(LeetCode 424.)
Return true if s2 contains a permutation of s1 as a substring. Both strings use only lowercase letters.
Hint 1
s1 (the same letters in any order) has the same letter counts and the same length.Hint 2
s1.length over s2. Update the counts as letters enter and leave.Show all 2 solutions with explanations
Sort s1. Then for each window of s2, sort the window and compare.
function checkInclusion(s1, s2) { const target = [...s1].sort().join(""); for (let i = 0; i + s1.length <= s2.length; i++) { if ([...s2.slice(i, i + s1.length)].sort().join("") === target) return true; } return false;}console.log(checkInclusion("ab", "eidbaooo")); // trueconsole.log(checkInclusion("ab", "eidboaoo")); // falseAbout O(n · m log m) time, where n is the length of s2 and m is the length of s1 (there are about n windows and sorting one costs m log m). This is too slow when both strings are long.
Keep two arrays of 26 counts. Add the letter that enters, remove the letter that leaves, and compare the arrays.
function checkInclusion(s1, s2) { if (s1.length > s2.length) return false; const idx = (c) => c.charCodeAt(0) - 97; const need = new Array(26).fill(0), win = new Array(26).fill(0); for (const c of s1) need[idx(c)]++; for (let i = 0; i < s2.length; i++) { win[idx(s2[i])]++; if (i >= s1.length) win[idx(s2[i - s1.length])]--; if (i >= s1.length - 1 && need.every((n, j) => n === win[j])) return true; } return false;}console.log(checkInclusion("ab", "eidbaooo")); // trueconsole.log(checkInclusion("ab", "eidboaoo")); // falseconsole.log(checkInclusion("abc", "ab")); // falseO(26 · n) = O(n) time, O(26) space.
Use the fixed-size window version. (LeetCode 567.)
Return the start index of every substring of s that is an anagram of p.
Hint 1
Show all 2 solutions with explanations
Slide a window of length p.length and compare its counts with the counts of p. Every time they match, add the start index to the result.
function findAnagrams(s, p) { const idx = (c) => c.charCodeAt(0) - 97; const need = new Array(26).fill(0), win = new Array(26).fill(0); for (const c of p) need[idx(c)]++; const out = []; for (let i = 0; i < s.length; i++) { win[idx(s[i])]++; if (i >= p.length) win[idx(s[i - p.length])]--; if (i >= p.length - 1 && need.every((n, j) => n === win[j])) out.push(i - p.length + 1); } return out;}console.log(findAnagrams("cbaebabacd", "abc")); // [0, 6]console.log(findAnagrams("abab", "ab")); // [0, 1, 2]console.log(findAnagrams("a", "ab")); // []O(n) time, O(1) space.
Do not compare 26 counts at every step. Just track how many letters are still missing, like in minimum window.
function findAnagrams(s, p) { const need = new Map(); for (const c of p) need.set(c, (need.get(c) || 0) + 1); let missing = p.length; const out = []; for (let i = 0; i < s.length; i++) { const add = s[i]; if (need.has(add)) { if (need.get(add) > 0) missing--; need.set(add, need.get(add) - 1); } if (i >= p.length) { const drop = s[i - p.length]; if (need.has(drop)) { need.set(drop, need.get(drop) + 1); if (need.get(drop) > 0) missing++; } } if (missing === 0) out.push(i - p.length + 1); } return out;}console.log(findAnagrams("cbaebabacd", "abc")); // [0, 6]console.log(findAnagrams("abab", "ab")); // [0, 1, 2]It is still O(n), but each step costs O(1) because there is no 26-number comparison. It also works for any characters, not only lowercase letters.
Use the first for clarity. The second is the more general technique. (LeetCode 438.)
Return the smallest substring of s that contains every letter of t (as many times as it appears in t). If there is none, return "".
Hint 1
t (with the right counts).Hint 2
Hint 3
missing number, so that the question “is the window valid?” takes O(1).Show all 2 solutions with explanations
For every start and end, count the letters in that piece and test it.
function minWindow(s, t) { const covers = (sub) => { const c = {}; for (const ch of sub) c[ch] = (c[ch] || 0) + 1; for (const ch of t) { if (!c[ch]) return false; c[ch]--; } return true; }; let best = ""; for (let i = 0; i < s.length; i++) { for (let j = i + t.length; j <= s.length; j++) { const sub = s.slice(i, j); if ((best === "" || sub.length < best.length) && covers(sub)) best = sub; } } return best;}console.log(minWindow("ADOBECODEBANC", "ABC")); // BANCO(n³) time or worse. Use it only as a starting point.
- Count the letters you need from
t. Setmissing = t.length. - Move
rightforward. Each needed letter lowersmissing. - While
missing === 0, save the window and moveleftforward, which gives letters back.
function minWindow(s, t) { const need = new Map(); for (const c of t) need.set(c, (need.get(c) || 0) + 1); let missing = t.length, left = 0, bestStart = 0, bestLen = Infinity; for (let right = 0; right < s.length; right++) { const c = s[right]; if (need.has(c)) { if (need.get(c) > 0) missing--; need.set(c, need.get(c) - 1); } while (missing === 0) { if (right - left + 1 < bestLen) { bestLen = right - left + 1; bestStart = left; } const l = s[left]; if (need.has(l)) { need.set(l, need.get(l) + 1); if (need.get(l) > 0) missing++; } left++; } } return bestLen === Infinity ? "" : s.slice(bestStart, bestStart + bestLen);}console.log(minWindow("ADOBECODEBANC", "ABC")); // BANCconsole.log(minWindow("a", "a")); // aconsole.log(minWindow("a", "aa") === ""); // trueconsole.log(minWindow("aa", "aa")); // aaO(n + m) time, because each pointer moves forward at most n times. O(m) space for the needed letters.
Use the template. This question is marked “Hard”, but it is just the window template done carefully. (LeetCode 76.)
Return the length of the longest substring that has at most k different letters.
Hint 1
Hint 2
k keys.Show the solution with explanations
Add the letter that enters. While the Map has more than k letters, remove letters from the left. Delete a key when its count reaches 0.
function lengthOfLongestSubstringKDistinct(s, k) { const count = new Map(); let left = 0, best = 0; for (let right = 0; right < s.length; right++) { count.set(s[right], (count.get(s[right]) || 0) + 1); while (count.size > k) { const l = s[left]; count.set(l, count.get(l) - 1); if (count.get(l) === 0) count.delete(l); left++; } best = Math.max(best, right - left + 1); } return best;}console.log(lengthOfLongestSubstringKDistinct("eceba", 2)); // 3console.log(lengthOfLongestSubstringKDistinct("aa", 1)); // 2console.log(lengthOfLongestSubstringKDistinct("abc", 0)); // 0O(n) time, O(k) space. Deleting a key when its count is 0 is what makes count.size equal the number of different letters.
(LeetCode 340, a paid-only problem. This pattern is the one you will reuse most often.)
Each tree gives one type of fruit. You have two baskets, and each basket holds only one type. You must pick from trees that are next to each other. Return the most fruit you can collect.
Hint 1
Hint 2
k = 2, but on an array.Show the solution with explanations
It is the same window, but on an array instead of a string.
function totalFruit(fruits) { const count = new Map(); let left = 0, best = 0; for (let right = 0; right < fruits.length; right++) { count.set(fruits[right], (count.get(fruits[right]) || 0) + 1); while (count.size > 2) { const l = fruits[left]; count.set(l, count.get(l) - 1); if (count.get(l) === 0) count.delete(l); left++; } best = Math.max(best, right - left + 1); } return best;}console.log(totalFruit([1, 2, 1])); // 3console.log(totalFruit([0, 1, 2, 2])); // 3console.log(totalFruit([1, 2, 3, 2, 2])); // 4O(n) time, O(1) space, because the Map has at most 3 keys. The hard part is to see that this is the same problem in a different story.
(LeetCode 904.)
| The question says… | Window | Memory | Not valid when… |
|---|---|---|---|
| longest substring with no repeats | variable | Set of letters | new letter already inside |
| longest … with at most k changes | variable | letter counts + maxFreq | size − maxFreq > k |
| longest … with at most k distinct | variable | Map of counts | map.size > k |
| is there an anagram / permutation | fixed (length p) | 26 counts | never; compare at each step |
| shortest substring containing t | variable | need counts + missing | valid when missing = 0, so shrink |
Make it stick
- Write the variable-window template and the three decisions every window problem needs.
- Explain why you save the longest-window answer after shrinking, and the shortest-window answer inside the loop.
- Work out the condition
windowSize − maxFreq > kfor character replacement. - Explain how the “missing” counter lets you check the minimum window in O(1).
Revisit schedule: solve two of the questions again tomorrow, again in 3 days, and once more in a week — without looking at the answers. Each time it will feel easier. That is the memory forming.
Part 7 complete: what's next
You can now solve questions on palindromes, prefixes, compression, and every common window question on text. Part 8 moves from scanning to exploring. You will learn recursion (a function that calls itself) and recursion trees (a drawing of all the calls). Then you will list every subset, permutation and combination with backtracking (try a choice, and undo it if it does not work).
Practise on LeetCode
Real interview problems that use exactly what this lesson teaches. Solve them in JavaScript after the practice questions above.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related series