Introduction to the Longest Substring Without Repeating Characters Problem
The "Longest Substring Without Repeating Characters" is one of the most classic algorithmic problems you will encounter in coding interviews and competitive programming. Given a string, the goal is to find the length of the longest contiguous substring that contains no duplicate characters. While the problem statement is deceptively simple, solving it efficiently requires a solid understanding of the sliding window technique and hash-based lookups.
In this tutorial, we will walk through the problem from brute force to optimal solution, explain the underlying mechanics, and discuss best practices for writing clean, performant JavaScript code.
What Is the Longest Substring Without Repeating Characters?
A substring is a contiguous sequence of characters within a string. The problem asks you to identify the longest such sequence where every character appears at most once. For example:
"abcabcbb"→ the answer is3(substring"abc")"bbbbb"→ the answer is1(substring"b")"pwwkew"→ the answer is3(substring"wke")""→ the answer is0
Notice that the answer is the length, not the substring itself, although returning the substring is a common variation.
Why This Problem Matters
This problem is a staple in technical interviews at companies like Google, Amazon, and Microsoft because it tests several fundamental skills at once:
- Sliding window technique: A pattern used across many substring and subarray problems.
- Hash maps and sets: Efficient lookups to track seen characters.
- Time and space complexity analysis: Understanding the trade-offs between approaches.
- Edge case handling: Empty strings, single characters, all-duplicate strings, and Unicode.
Mastering this problem gives you a reusable mental model for solving dozens of similar problems, such as "Longest Substring with At Most K Distinct Characters" or "Minimum Window Substring."
Approach 1: Brute Force
The most intuitive approach is to generate every possible substring and check whether it contains duplicate characters. While easy to understand, this approach is inefficient.
Algorithm
- Iterate over every starting index
i. - For each
i, iterate over every ending indexjstarting fromi. - Use a set to track characters in the substring
s[i..j]. - If a duplicate is found, break; otherwise, update the maximum length.
Code Example
function lengthOfLongestSubstringBruteForce(s) {
let maxLen = 0;
for (let i = 0; i < s.length; i++) {
const seen = new Set();
for (let j = i; j < s.length; j++) {
if (seen.has(s[j])) {
break;
}
seen.add(s[j]);
maxLen = Math.max(maxLen, j - i + 1);
}
}
return maxLen;
}
console.log(lengthOfLongestSubstringBruteForce("abcabcbb")); // 3
console.log(lengthOfLongestSubstringBruteForce("bbbbb")); // 1
console.log(lengthOfLongestSubstringBruteForce("pwwkew")); // 3
Complexity Analysis
The brute force approach has a time complexity of O(n²) in the average case, because we examine every pair of indices. In the worst case, where all characters are unique, the inner set operations push the complexity toward O(n³). The space complexity is O(min(n, m)), where m is the size of the character set.
This is acceptable for very short strings but will not scale to inputs of thousands of characters.
Approach 2: Sliding Window with a Set
The sliding window technique improves on brute force by avoiding redundant checks. Instead of restarting from every index, we maintain a window [left, right] that always contains a valid substring with no repeating characters. As we expand the window to the right, we shrink it from the left whenever a duplicate is detected.
Algorithm
- Initialize two pointers,
leftandright, both at index0. - Maintain a
Setof characters currently in the window. - Expand
rightone step at a time. - If
s[right]is already in the set, remove characters from the left until the duplicate is gone. - Add
s[right]to the set and update the maximum length.
Code Example
function lengthOfLongestSubstringSet(s) {
const seen = new Set();
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
while (seen.has(s[right])) {
seen.delete(s[left]);
left++;
}
seen.add(s[right]);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
console.log(lengthOfLongestSubstringSet("abcabcbb")); // 3
console.log(lengthOfLongestSubstringSet("dvdf")); // 3
Complexity Analysis
Each character is added to and removed from the set at most once, so the time complexity is O(n). The space complexity is O(min(n, m)) for the set. This is a significant improvement over brute force and is the approach most interviewers expect.
Approach 3: Sliding Window with a Map (Optimal)
The set-based sliding window still has a subtle inefficiency: when a duplicate is found, we shrink the window one character at a time. We can skip directly past the previous occurrence of the duplicate by storing each character's most recent index in a Map. This lets us jump left straight to the position after the earlier occurrence.
Algorithm
- Use a
Mapto store the latest index of each character. - Iterate with a
rightpointer. - If
s[right]exists in the map and its index is at leastleft, movelefttomap.get(s[right]) + 1. - Update the map with the current index of
s[right]. - Track the maximum window length.
Code Example
function lengthOfLongestSubstring(s) {
const charIndex = new Map();
let left = 0;
let maxLen = 0;
for (let right = 0; right < s.length; right++) {
const ch = s[right];
if (charIndex.has(ch) && charIndex.get(ch) >= left) {
left = charIndex.get(ch) + 1;
}
charIndex.set(ch, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
console.log(lengthOfLongestSubstring("abcabcbb")); // 3
console.log(lengthOfLongestSubstring("bbbbb")); // 1
console.log(lengthOfLongestSubstring("pwwkew")); // 3
console.log(lengthOfLongestSubstring("")); // 0
console.log(lengthOfLongestSubstring(" ")); // 1
Why This Is Optimal
This version performs exactly one pass over the string, with constant-time map operations. The time complexity is O(n) and the space complexity is O(min(n, m)). The charIndex.get(ch) >= left check is critical: it ensures we only jump left forward when the previous occurrence is actually inside the current window. Without this check, we might incorrectly shrink the window based on a stale index.
Returning the Substring Itself
Sometimes the problem asks for the substring rather than its length. The same sliding window logic applies — we just need to record the start and end indices of the best window.
function longestSubstringWithoutRepeating(s) {
const charIndex = new Map();
let left = 0;
let maxLen = 0;
let bestStart = 0;
for (let right = 0; right < s.length; right++) {
const ch = s[right];
if (charIndex.has(ch) && charIndex.get(ch) >= left) {
left = charIndex.get(ch) + 1;
}
charIndex.set(ch, right);
if (right - left + 1 > maxLen) {
maxLen = right - left + 1;
bestStart = left;
}
}
return s.slice(bestStart, bestStart + maxLen);
}
console.log(longestSubstringWithoutRepeating("abcabcbb")); // "abc"
console.log(longestSubstringWithoutRepeating("pwwkew")); // "wke"
Handling Edge Cases
A robust solution must handle several edge cases gracefully:
- Empty string: Return
0immediately or let the loop handle it naturally. - Single character: The loop runs once and returns
1. - All identical characters: The window never grows beyond
1. - All unique characters: The window spans the entire string.
- Unicode and surrogate pairs: JavaScript strings are UTF-16, so characters outside the Basic Multilingual Plane (like some emojis) are represented as two code units. For full Unicode support, iterate using
for...ofor spread the string into an array of code points.
Unicode-Safe Version
function lengthOfLongestSubstringUnicode(s) {
const chars = [...s]; // Splits into code points, handling surrogate pairs
const charIndex = new Map();
let left = 0;
let maxLen = 0;
for (let right = 0; right < chars.length; right++) {
const ch = chars[right];
if (charIndex.has(ch) && charIndex.get(ch) >= left) {
left = charIndex.get(ch) + 1;
}
charIndex.set(ch, right);
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
console.log(lengthOfLongestSubstringUnicode("😀abc😀")); // 4
Best Practices
1. Choose the Right Data Structure
Use a Map when you need to store indices, and a Set when you only need membership checks. For ASCII-only inputs, a fixed-size array of length 128 can be even faster than a hash map because it avoids hashing overhead.
2. Avoid Off-by-One Errors
The condition charIndex.get(ch) >= left is easy to get wrong. Always test with inputs like "abba", where the window must correctly move forward and never backward.
3. Prefer Early Returns for Trivial Cases
if (!s || s.length === 0) return 0;
if (s.length === 1) return 1;
These guards improve readability and can short-circuit unnecessary work.
4. Write Test Cases
Always validate your solution against a range of inputs:
const testCases = [
{ input: "abcabcbb", expected: 3 },
{ input: "bbbbb", expected: 1 },
{ input: "pwwkew", expected: 3 },
{ input: "", expected: 0 },
{ input: " ", expected: 1 },
{ input: "au", expected: 2 },
{ input: "abba", expected: 2 },
{ input: "dvdf", expected: 3 },
];
for (const { input, expected } of testCases) {
const result = lengthOfLongestSubstring(input);
console.assert(result === expected, `Failed for "${input}": got ${result}, expected ${expected}`);
}
5. Document Your Complexity
Always state the time and space complexity in comments or documentation. Interviewers and teammates appreciate clarity about performance characteristics.
Common Mistakes to Avoid
- Forgetting the
>= leftcheck: Without it, stale indices can causeleftto jump backward, producing incorrect results. - Using
includeson substrings: Callings.substring(left, right).includes(s[right])inside the loop turns anO(n)solution intoO(n²)or worse. - Confusing substrings with subsequences: Substrings must be contiguous; subsequences do not. This problem is about substrings.
- Ignoring Unicode: If your input may contain emojis or astral-plane characters, naive indexing will break.
Conclusion
The Longest Substring Without Repeating Characters problem is a perfect showcase for the sliding window pattern. Starting from a brute force O(n²) solution, we refined our approach using a set and then a map to achieve an optimal O(n) algorithm. Along the way, we covered edge cases, Unicode handling, and best practices for writing maintainable JavaScript. The sliding window technique you learned here is a transferable skill — once you internalize the mechanics of expanding and contracting a window with hash-based lookups, you will be equipped to tackle a wide family of substring and subarray problems with confidence.