Given a string, determine the length of its longest substring that consists only of unique characters.
Input: s = 'abcba'
Output: 3
Explanation: Substring "abc" is the longest substring of length 3 that contains unique characters ("cba" also fits this description).
New to this one? Start here. Below is the core idea in everyday language, plus a real-world analogy—then the sections that follow build on it with the formal reasoning, code, and step-by-step walkthroughs.
You're letting people into a room one by one, but no two guests may share the same name. As long as the next person's name is new, they walk in and the room grows. The moment someone whose name is already inside tries to enter, you politely ask people to leave from the front of the line until that duplicate name is no longer in the room. You track the largest the room ever got.
The brute force approach involves examining all possible substrings and checking if any consist of exclusively unique characters. Let’s break down this approach:
This means the brute force approach would take time overall. This is quite slow, largely because we look through every substring. Is there a way to reduce the number of substrings we examine?
Sliding window
Sliding window approaches can be quite useful for problems that involve substrings. In particular, because we’re looking for the longest substring that satisfies a specific condition (i.e., contains unique characters), a dynamic sliding window algorithm might be the way to go, as discussed in the introduction.
We can categorize any window in two ways. A window either:
Contains at least one character of a frequency greater than 1.
If A is true, we should expand the window by advancing the right pointer to find a longer window that also contains no duplicates.
If B is true because we encounter a duplicate character in the window, we should shrink the window by advancing the left pointer until it no longer contains a duplicate.
Let’s try this strategy over the following example. We initialize a hash set to keep track of the characters in a window.
To implement the sliding window technique, we should establish the following:
Left and right pointers: Initialize both at the start of the string to define the window's boundaries.
hash_set: Maintain a hash set to record the unique characters within the window, updating it as the window expands. Note, the hash set shown in the diagram displays its state before the character at the right pointer is added to it.
Now, let’s start looking for the longest window. Expand the window from the beginning of the string by advancing the right pointer. Keep expanding until a duplicate character is found:
We see above that the ‘b’ at index 3 is a duplicate character in the window because ‘b’ is already in the hash set.
Now that we found a duplicate, we should shrink the window by advancing the left pointer until the window no longer contains a duplicate ‘b’. Once the window is valid again, continue expanding:
Expanding the window any further will cause the right pointer to exceed the string’s boundary, at which point we end our search. The longest substring we’ve found with no duplicates is of length 3. We can use the variable max_len to keep track of this length during our search.
def longest_substring_with_unique_chars(s: str) -> int:
max_len = 0
hash_set = set()
left = right = 0
while right < len(s):
# If we encounter a duplicate character in the window, shrink the window until
# it’s no longer a duplicate.
while s[right] in hash_set:
hash_set.remove(s[left])
left += 1
# Once there are no more duplicates in the window, update 'max_len' if the
# current window is larger.
max_len = max(max_len, right - left + 1)
hash_set.add(s[right])
# Expand the window.
right += 1
return max_len#include <string>
#include <unordered_set>
#include <algorithm>
using namespace std;
int longestSubstringWithUniqueChars(string s) {
int n = s.size(), best = 0;
for (int start = 0; start < n; start++) {
unordered_set<char> seen;
for (int end = start; end < n; end++) {
if (seen.count(s[end])) break; // duplicate -> can't extend
seen.insert(s[end]);
best = max(best, end - start + 1);
}
}
return best;
}import java.util.*;
public int longestSubstringWithUniqueChars(String s) {
int n = s.length(), best = 0;
for (int start = 0; start < n; start++) {
Set<Character> seen = new HashSet<>();
for (int end = start; end < n; end++) {
if (seen.contains(s.charAt(end))) break; // duplicate
seen.add(s.charAt(end));
best = Math.max(best, end - start + 1);
}
}
return best;
}Time complexity: The time complexity of longest_substring_with_unique_chars is because we traverse the string linearly with two pointers.
Space complexity: The space complexity is because we use a hash set to store unique characters, where m represents the total number of unique characters within the string.
The above approach solves the problem, but we can still optimize it. The optimization has to do with how we shrink the window when encountering a duplicate character. Consider the following example, where the right pointer encounters a duplicate ‘c’:
In the previous approach, we respond to encountering a duplicate by continuously advancing the left pointer to shrink the window until the window no longer contains a duplicate:
The crucial insight here is that we advanced the left pointer until it passed the previous occurrence of ‘c’ in the window. This indicates that if we know the index of the previous occurrence of ‘c’, we can move our left pointer immediately past that index to remove it from the window:
This gives us a new strategy for advancing the left pointer: if the right pointer encounters a character whose previous index (i.e., previous occurrence) is in the window, move the left pointer one index past that previous index.
We can use a hash map (prev_indexes) to store the previous index of each character in the string.
Now we just need to ensure the previous index of a character is in the window. To do this, we compare its index to the left pointer:
Below is a visual of how to check whether a character is inside the window:
def longest_substring_with_unique_chars_optimized(s: str) -> int:
max_len = 0
prev_indexes = {}
left = right = 0
while right < len(s):
# If a previous index of the current character is present in the current
# window, it's a duplicate character in the window.
if s[right] in prev_indexes and prev_indexes[s[right]] >= left:
# Shrink the window to exclude the previous occurrence of this character.
left = prev_indexes[s[right]] + 1
# Update 'max_len' if the current window is larger.
max_len = max(max_len, right - left + 1)
prev_indexes[s[right]] = right
# Expand the window.
right += 1
return max_len#include <string>
#include <unordered_map>
#include <algorithm>
using namespace std;
int longestSubstringWithUniqueCharsOptimized(string s) {
unordered_map<char, int> last; // char -> most recent index
int left = 0, best = 0;
for (int right = 0; right < (int)s.size(); right++) {
char c = s[right];
if (last.count(c) && last[c] >= left) {
left = last[c] + 1; // jump past the earlier copy
}
last[c] = right; // record/refresh last seen
best = max(best, right - left + 1);
}
return best;
}import java.util.*;
public int longestSubstringWithUniqueCharsOptimized(String s) {
Map<Character, Integer> last = new HashMap<>(); // char -> most recent index
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
if (last.containsKey(c) && last.get(c) >= left) {
left = last.get(c) + 1; // jump past the earlier copy
}
last.put(c, right); // record/refresh last seen
best = Math.max(best, right - left + 1);
}
return best;
}Time complexity: The time complexity of the optimized implementation is because we traverse the string linearly with two pointers.
Space complexity: The space complexity is because we use a hash map to store unique characters, where represents the total number of unique characters within the string.
Two step-by-step walkthroughs of the algorithm below: a valid case (the expected happy path) and an invalid / edge case that exercises the tricky parts. Each step shows the program state as the code runs—the same steps apply to Python, C++ and Java.
current window duplicate that forces left to jump
| right | char | last[c] | left | window | len | best |
|---|---|---|---|---|---|---|
| 0 | a | — | 0 | a | 1 | 1 |
| 1 | b | — | 0 | ab | 2 | 2 |
| 2 | c | — | 0 | abc | 3 | 3 |
| 3 | a | 0≥0 | 1 | bca | 3 | 3 |
| 4 | b | 1≥1 | 2 | cab | 3 | 3 |
| 5 | c | 2≥2 | 3 | abc | 3 | 3 |
| 6 | b | 4≥3 | 5 | cb | 2 | 3 |
| 7 | b | 6≥5 | 7 | b | 1 | 3 |
Final answer: best = 3.
current window duplicate that forces left to jump
| right | char | last[c] | left | window | len | best |
|---|---|---|---|---|---|---|
| 0 | t | — | 0 | t | 1 | 1 |
| 1 | m | — | 0 | tm | 2 | 2 |
| 2 | m | 1≥0 → jump | 2 | m | 1 | 2 |
| 3 | z | — | 2 | mz | 2 | 2 |
| 4 | u | — | 2 | mzu | 3 | 3 |
| 5 | x | — | 2 | mzux | 4 | 4 |
| 6 | t | 0<2 → ignore | 2 | mzuxt | 5 | 5 |
Final answer best = 5 (“mzuxt”). Contrast with Dry run 1: the trap is the last t. It was seen before (index 0), but that copy is already behind left (0 < 2), so the last[c] ≥ left guard says “ignore it” and left stays put. Without that guard left would wrongly jump backwards to 1 and corrupt the answer.
Both runs step exactly as the tables above. Python's jump test is c in last and last[c] >= left; left only ever moves forward.
Same steps. C++: last.count(c) && last[c] >= left, then left = last[c] + 1.
Same steps. Java: last.containsKey(c) && last.get(c) >= left, then left = last.get(c) + 1.