Learn › DSA Patterns › Sliding Window › Case Study
Longest Substring:
6 Bugs to a Clean Solution
Not a tutorial — a real debugging log. Six attempts, six distinct bugs, each one reproduced with a hand-traced example before it was fixed.
The Problem
LeetCode 3 — Longest Substring Without Repeating Characters
Given a string s, find the length of the
longest substring without repeating characters.
A substring must be contiguous — you can't skip characters.
Input
"abcabcbb" Output
3 ("abc") Input
"bbbbb" Output
1 ("b") Input
"pwwkew" Output
3 ("wke") The Journey
Attempt by Attempt
Every bug below was traced by hand against a real input — not just described.
function lengthOfLongestSubstring(s: string): number {
let i=0, j=1, max_out=0;
while(i<j && j< s.length-1) {
let current_s =s.substring(i,j)
if(current_s.includes(s[i])) {
i++; j=j+1
} else {
max_out = Math.max(current_s.length(), max_out)
j++
}
}
return max_out;
}; Compiles fine at a glance, crashes at runtime, and would return the wrong answer even if it didn't.
current_s.length() calls .length as a method — TypeError, since it's a property, not a function. Separately, current_s.includes(s[i]) checks whether s[i] is inside s.substring(i, j) — but s[i] is always the FIRST character of that exact substring, so this check is always true. That means the duplicate branch fires on every iteration, and max_out never gets a chance to update. On top of that, j < s.length - 1 stops one character short of the end.
Proof — traced by hand on real input:
lengthOfLongestSubstring("abcabcbb")
i=0, j=1: current_s="a", "a".includes(s[0]="a") → true (always, since s[i] IS current_s[0])
→ .length() throws before this is even reached: TypeError: current_s.length is not a function Use .length (not .length()), check against the character actually causing the duplicate — not the window's own boundary character — and change the loop bound to j < s.length.
function lengthOfLongestSubstring(s: string): number {
let i=0, j=1, max_out=0;
while(i<j && j< s.length) {
let current_s =s.substring(i,j+1)
if(current_s.includes(s[j])) {
i++; j=j+1
} else {
max_out = Math.max(current_s.length, max_out)
j++
}
}
return max_out;
};
// ⚠ s[j] is always the LAST char of substring(i, j+1) — the check is always true The .includes() check now looks at s[j] instead of s[i] — but it's checking against substring(i, j+1), where s[j] is always the last character.
Same shape of bug as Attempt 1, just shifted: s.substring(i, j+1).includes(s[j]) is tautological because s[j] is, by construction, the final character of substring(i, j+1). This check is always true, so the code always takes the duplicate branch and max_out stays 0 for the entire string, no matter the input.
Proof — traced by hand on real input:
lengthOfLongestSubstring("abcabcbb")
i=0,j=1: current_s="ab", "ab".includes(s[1]="b") → true → i=1,j=2
i=1,j=2: current_s="bc", "bc".includes(s[2]="c") → true → i=2,j=3
... continues this way for the whole string ...
Returns 0 (WRONG — expected 3) Check s[j] against the substring that does NOT already include position j — i.e. substring(i, j), not substring(i, j+1).
function lengthOfLongestSubstring(s: string): number {
let i=0, j=1, max_out=0;
while(i<j && j< s.length) {
let current_s =s.substring(i,j)
if(current_s.includes(s[j])) {
i++; j=j+1
} else {
max_out = Math.max(current_s.length, max_out)
j++
}
}
return max_out;
};
// ⚠ on a duplicate, i and j both move — that SHIFTS the window instead of shrinking it The tautology is gone — current_s.includes(s[j]) against substring(i, j) is a real duplicate check now. But when a duplicate is found, both i and j move forward together.
Shifting the whole window forward on a duplicate throws away information: it doesn't shrink from the left until the specific duplicate is gone, it just slides both edges by one and re-checks. That undercounts the true longest window whenever the duplicate's earlier occurrence isn't exactly one position behind the left edge.
Proof — traced by hand on real input:
lengthOfLongestSubstring("abcabcbb")
i=0,j=1:"a" no dup→max=2,j=2 i=0,j=2:"ab" no dup→max=3,j=3
i=0,j=3:"abc" dup("a" at j=3)→i=1,j=4
i=1,j=4:"bca" dup("b" at j=4)→i=2,j=5
i=2,j=5:"cab" dup("c" at j=5)→i=3,j=6
...
Returns 2 (WRONG — expected 3, "abc") On a duplicate, only shrink i (possibly repeatedly) — leave j exactly where it is and re-check, don't advance both.
function lengthOfLongestSubstring(s: string): number {
let i = 0, j = 1, max_out = 0;
let hashMap = { s[i]: 1,}
while (j < s.length - 1) {
if (hashMap[s[j]]) i++
else {
hashMap[s[j]] = hashMap[s[j]] + 1
max_out = Math.max(max_out, Objects.keys(hashMap).length)
j++
}
}
return max_out
};
// ⚠ invalid key syntax, Objects.keys typo, undefined+1=NaN, if-branch never advances j Good instinct (O(1) lookups beat re-scanning a substring every time), but the first attempt has four separate issues.
{ s[i]: 1 } is invalid object literal syntax — a computed key needs [s[i]]. Objects.keys should be Object.keys. hashMap[s[j]] + 1 evaluates to NaN when the key doesn't exist yet (undefined + 1 = NaN), which is falsy and silently breaks presence checks. And the if-branch (duplicate found) only does i++ — it never advances j and never removes anything from the map, which risks looping forever without j ever changing.
Proof — traced by hand on real input:
hashMap = { s[i]: 1 } → SyntaxError: cannot use "s[i]" as an object key without brackets
(even past that: hashMap[s[j]] + 1 on a fresh key → undefined + 1 → NaN, always falsy) Use { [s[i]]: 1 } for the computed key, Object.keys (capital O), and make sure every branch of the loop eventually advances i or j — otherwise it can spin forever.
function lengthOfLongestSubstring(s: string): number {
let i = 0, j = 1, max_out = 0;
let hashMap = { [s[i]]: 1,}
while (j < s.length - 1) {
if (hashMap[s[j]]) i++
else {
hashMap[s[j]] = hashMap[s[j]] ?? 0 + 1
max_out = Math.max(max_out, j-i+1)
j++
}
}
return max_out
};
// ⚠ if-branch still never removes anything from hashMap AND never advances j — infinite loop [s[i]]: 1 and j-i+1 (instead of Object.keys(...).length) are real improvements. But the duplicate branch still doesn't remove anything from the map, and still never advances j.
When hashMap[s[j]] is truthy, the code does i++ and nothing else. If the duplicate character is more than one position behind i, incrementing i alone doesn't make hashMap[s[j]] falsy — so the same branch fires again next iteration, forever, with j frozen in place.
Proof — traced by hand on real input:
lengthOfLongestSubstring("aab")
i=0,j=1: hashMap={a:1}. hashMap[s[1]="a"]=1 → truthy → i=1 (j stays 1)
i=1,j=1: same check, hashMap["a"] still 1 → truthy → i=2
i=2,j=1: same check again → truthy → i=3
... i climbs forever, j never moves, hashMap never changes → infinite loop The removal has to happen through i, specifically: while s[j] is already in the hashmap, remove s[i] from the hashmap and move i forward. Only once s[j] is no longer present should the else-branch add it and advance j.
function lengthOfLongestSubstring(s: string): number {
let i = 0, j = 1, max_out = 0;
let hashMap = { [s[i]]: 1,}
while (j < s.length - 1) {
if (hashMap[s[j]]) {
hashMap[s[j]] = 0
i++; j++
}
else {
hashMap[s[j]] = hashMap[s[j]] ?? 1
max_out = Math.max(max_out, j-i+1)
j++
}
}
return max_out
};
// ⚠ zeroes s[j] (incoming char) not s[i] (outgoing char) — and 0 ?? 1 stays 0 forever hashMap[s[j]] = 0 does clear an entry — but it clears the entry for s[j] (the incoming duplicate), not s[i] (the character actually leaving the window on the left). Combined with a second bug in the reinsertion logic, this produces a silently wrong answer rather than a crash.
hashMap[s[j]] ?? 1 is meant to re-mark a character as present, but ?? only replaces null or undefined — not 0. Once an entry has been zeroed, 0 ?? 1 evaluates to 0 forever, so that character can never be detected as a duplicate again for the rest of the string, even when it legitimately reappears inside the current window.
Proof — traced by hand on real input:
lengthOfLongestSubstring("aaxa")
i=0,j=1: hashMap={a:1}. hashMap["a"]=1→dup: hashMap["a"]=0, i=1,j=2
i=1,j=2: hashMap["x"] undefined→else: hashMap={a:0,x:1}, max=max(0,2)=2, j=3
i=1,j=3: hashMap["a"]=0→falsy→else(!): hashMap["a"]=0??1=0 (stays 0!), max=max(2,3)=3, j=4
Returns 3 (WRONG — expected 2, "ax" or "xa") Remove based on s[i] (the outgoing left-boundary character), not s[j] — and use a marker that a re-insertion check can actually distinguish from 'never seen', like delete or a Set.
Lessons Learned
Key Takeaways
A .includes() check can be tautological without looking like one
Checking whether a character is inside a substring that was built using that exact character's own index is circular — it's checking a fact that's true by construction, not detecting an actual duplicate.
On a duplicate, shrink — don't shift
Moving both pointers forward together throws away window history. The left pointer needs to shrink (possibly several times) until the specific duplicate is gone, while the right pointer holds still.
undefined + 1 is NaN, and NaN is falsy
Incrementing a hashmap count that doesn't exist yet silently produces NaN instead of an error — it just breaks truthiness checks downstream without any obvious signal.
?? only replaces null/undefined — never other falsy values
Using ?? to 'reset' something you previously set to 0 doesn't work: 0 ?? 1 is 0. If you need '0 means absent,' use a comparison against 0 directly, or better, delete the key entirely.
Remove the character leaving the window, not the one arriving
The character to remove from a duplicate-tracking structure is s[i] — the one physically exiting on the left — never s[j], the one that triggered the check.
Final Result
The Verified Solution
Two pointers, one Set — expand right, shrink left while a duplicate exists.
// O(N) time, O(min(N, σ)) space — two pointers + Set
function lengthOfLongestSubstring(s: string): number {
let left = 0;
let maxLen = 0;
const seen = new Set<string>();
for (let right = 0; right < s.length; right++) {
// Shrink from the left until the incoming char is no longer in the window
while (seen.has(s[right])) {
seen.delete(s[left]);
left++;
}
seen.add(s[right]);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
// "abcabcbb" → 3 ("abc")
console.log(lengthOfLongestSubstring("abcabcbb")); // → 3 | Solution | Time | Space | Verified |
|---|---|---|---|
| Sliding Window + Set (Attempt 7) Verified ✓ | O(N) | O(min(N,σ)) | Traced against every failing case above, incl. "aab" and "aaxa" |
Keep Learning
Bugs are part of the process 🎉
Six real bugs, six real fixes — that's what getting to a correct Sliding Window solution actually looks like.