Longest Substring Without Repeating Characters in Java DSA: Sliding Window Made Simple
-
Last Updated: August 18, 2026
-
By: javahandson
-
Series
Solve Longest Substring Without Repeating Characters in Java DSA with a clear sliding window. Full step-by-step dry runs for brute force, HashSet, and index-array approaches.
Longest Substring Without Repeating Characters in Java is a favourite interview question, and it is the problem that finally makes the sliding window idea click for most people. You get one string. You have to find the length of the longest stretch inside it where no letter repeats.
Let’s clarify one key point first: a substring is a sequence of characters that are right next to each other. For example, in the word “abcabcbb,” the parts “abc” and “bca” are substrings because they are made up of characters sitting right next to each other. However, “acb” isn’t a substring since its letters are not all consecutive.
The task asks for a length, not the substring itself. Take “abcabcbb”. The longest clean run is “abc”, which has 3 characters, so the answer is 3. The moment a fourth character repeats one already inside our run, the streak breaks.
We typically approach the problem in three friendly and practical steps. First, we consider checking every possible substring, which is the slowest method. Next, we speed things up using a sliding window with a HashSet. Finally, a more refined approach with an index lookup makes the solution leaner and is often appreciated by interviewers.
Every approach gets a full, step-by-step dry run on the same 8-character string. Nothing is skipped, so you can watch the two pointers move, see each character go in and out, and understand exactly why the window grows and shrinks.
Let us pin down the rules before we touch any code.
For our string the answer is 3, coming from the run “abc” at the very start. After that, every new character bumps into a letter already in our current run, so the window can never grow past 3.
Keep one thing in mind throughout. We only ever care about the longest length we have seen, so we save that best value and keep updating it as we scan.
A sliding window is a stretch of the string marked by two pointers. We call them lower and higher. The lower pointer marks where our current run starts, while the higher pointer marks where it ends.
So the window is the slice from lower to higher, and its length is higher minus lower plus one.
As the window expands, we need to quickly verify if a new character is already within it. Scanning the entire window each time would be inefficient, so we maintain a separate record of its current contents.
The main idea is to examine each substring for repeats. Start with a position, then extend the end outward. For every extension, verify if all characters are unique. Keep track of the longest one that’s all clean. It works well, but it involves quite a bit of repeated checking.
best = 0
for start in 0 .. length(s) - 1:
seen = empty set
for end in start .. length(s) - 1:
ch = s[end]
if ch is already in seen:
break // repeat found, stop this start
add ch to seen
windowLen = end - start + 1
best = max(best, windowLen)
return bestThe whole idea rests on one plain thought. Any character in the string could be the first letter of the best clean run, so we simply try every character as a starting point and see how far each one reaches. Let us walk the pseudocode part by part.
Set up the best tracker. The line best = 0 serves as a starting point for tracking the longest clean run throughout the process. We intentionally keep it outside both loops, so it can remember the largest run found across all starting points we test. Beginning it at 0 is a safe choice because any actual run we discover will be at least 1, which will then update this value.
Fix a starting point. The outer loop, for start in 0 .. length(s) – 1, walks the start pointer across every index in the string. Each pass picks one fixed starting position and asks a simple question: beginning here, how long can a run go before a letter repeats? We must try all starts because the best run could begin anywhere, not just at index 0.
Reset the memory for this start. Inside the outer loop, ‘seen = empty set’ clears the record at the beginning of each new start. This reset is very important because each start is a fresh attempt, and we want to make sure characters from previous starts don’t carry over. If they did, the repeat check could give us false results.
Stretch the end outward. The inner loop, for end in start .. length(s) – 1, grows a second pointer forward from the current start. So the pair start and end mark the slice we are testing right now. As end moves right, the slice gets longer one character at a time.
Read and test the new character. The line ch = s[end] grabs the character sitting at the end pointer. Then the check whether ch is already in seen asks whether this exact character has appeared earlier in the current slice. If the answer is yes, the run cannot continue because repeats aren’t allowed, so break stops this inner loop and returns control to the outer loop to start again.
Record and measure. When the character is fresh, add ch to seen writes it into the record so future steps in this same slice will spot it if it repeats. Next, windowLen = end – start + 1 measures how many characters the clean slice now holds. The plus one is there because both ends are included: a slice from index 2 to index 4 covers 2, 3, and 4, which is three characters.
Keep the largest length. Finally, best = max(best, windowLen) compares the current slice length with the best we’ve seen and keeps the larger one. So even if a later start finds a shorter run, best never shrinks. After all starts finish, return best hands back the single largest clean length found in the whole string.
import java.util.*;
public class LongestSubstringBrute {
public static int lengthOfLongestSubstring(String s) {
int best = 0;
for (int start = 0; start < s.length(); start++) {
Set<Character> seen = new HashSet<>();
for (int end = start; end < s.length(); end++) {
char ch = s.charAt(end);
if (seen.contains(ch)) {
break;
}
seen.add(ch);
best = Math.max(best, end - start + 1);
}
}
return best;
}
public static void main(String[] args) {
System.out.println(lengthOfLongestSubstring("abcabcbb")); // 3
}
}This is the same plan in Java. A fresh HashSet is used for each start, so old characters never leak into a new run.
Let us trace s = “abcabcbb”, which has 8 characters at indices 0 to 7. To keep the table short, we show each start and how far its inner loop reaches before a repeat stops it.
| start | characters read (end →) | stopped because | run length | best so far |
|---|---|---|---|---|
| 0 | a, b, c, then a | a repeats at index 3 | 3 (abc) | 3 |
| 1 | b, c, a, then b | b repeats at index 4 | 3 (bca) | 3 |
| 2 | c, a, b, then c | c repeats at index 5 | 3 (cab) | 3 |
| 3 | a, b, c, then b | b repeats at index 6 | 3 (abc) | 3 |
| 4 | b, c, then b | b repeats at index 6 | 2 (bc) | 3 |
| 5 | c, b, then b | b repeats at index 7 | 2 (cb) | 3 |
| 6 | b, then b | b repeats at index 7 | 1 (b) | 3 |
| 7 | b | string ends | 1 (b) | 3 |
Every start peaks at 3 or less, so best stays at 3 the whole way. The method returns 3.
Let us walk every start one by one and see exactly why each run stops where it does. Remember the string is s = “abcabcbb” with indices 0 to 7.
Start = 0, the run abc. We begin at index 0 with an empty set.
So this start reaches a clean run of 3, the substring “abc”. Best is now 3.
Start = 1, the run bca. The outer loop moves the start to index 1, and the set is wiped clean again.
This start also tops out at 3, the substring “bca”. Best is already 3, so it does not change.
Start = 2, the run cab. Now the start moves to index 2 with a fresh empty set.
Another run of 3, the substring “cab”. Best holds at 3.
Start = 3, the run abc again. The start is now index 3, and the set resets once more.
Yet another run of 3, the substring “abc”. Best stays at 3.
Start = 4 onward, the short tails. From index 4 the leftover string is just b, c, b, b, which is crowded with repeats, so these starts run out of room quickly.
None of these tail starts can reach 3, so best finishes at 3 and the method returns 3. Notice the real cost of this approach. We rebuilt the set from scratch for every single start and re-read characters we had already checked many times over. The sliding window fixes exactly this waste.
| 💡 Interview Insight If asked why brute force is slow, say it re-scans overlapping substrings. The same characters get checked again and again, which is the O(n²) cost we want to remove. |
The repeated scanning is the problem. Next we let one window slide across the string just once, so no character gets re-read from scratch.
Keep one window open and slide it smoothly. The higher pointer brings in a new character with each step. If that character’s already inside the window, we gently move the lower pointer forward, dropping characters until the repeat is gone. After each safe addition, we measure the window size and update the best. Each character enters and leaves the set at most once, so the entire scan remains efficient and straightforward.
window = empty set
lower = 0
best = 0
for higher in 0 .. length(s) - 1:
ch = s[higher]
while ch is in window:
remove s[lower] from window // shrink from the left
lower = lower + 1
add ch to window // now safe to add
windowLen = higher - lower + 1
best = max(best, windowLen)
return bestThe major improvement from brute force is that we no longer start from scratch each time. Instead of rebuilding a run every time we begin anew, we maintain a single window and slide it across the string just once. This window can expand to the right and contract on the left, but it always moves forward. Let’s go through it line by line.
Set up the window and its trackers. The line window = empty set holds the characters that sit inside the current window right now, and nothing else. Then lower = 0 marks the left edge of the window, and best = 0 remembers the longest clean run so far. These three lines live outside the loop because they must survive across every step of the scan.
Drive the right edge forward. The loop that runs from 0 to length(s) – 1 gently moves the higher pointer through the string, one step at a time. Imagine higher as the edge that brings new characters into the window, steadily progressing forward. Since it only moves forward, each character is read just once during this loop.
Look at the incoming character. The line ch = s[higher] grabs the character the right edge is about to bring in. Before we can safely add it, we must make sure it does not already sit inside the window, because the window is supposed to hold only unique characters.
Shrink from the left while there is a clash. This is the heart of the approach. The line while ch is in window keeps looping as long as the incoming character already exists in the window. Inside, remove s[lower] from window drops the character at the left edge out of the set, and lower = lower + 1 slides the left edge one step to the right. So we keep peeling characters off the left until the duplicate copy of ch is gone. We use a while, not an if, because in some cases more than one removal is needed before the clash clears.
Add the character safely. Once the while loop ends, the window no longer contains ch, so adding ch to window incorporates the new character into the set. Now, the window is clean again, stretching from lower to higher without any repeats.
Measure and save the best. The line windowLen = higher – lower + 1 counts how many characters the clean window now holds, with the plus one again because both edges are included. Then best = max(best, windowLen) keeps the larger of the current window and the best seen before, so best only ever grows. After the whole string is scanned, return best gives the final answer. Because each character is added once and removed at most once, the entire scan stays fast.
import java.util.*;
public class LongestSubstringSet {
public static int lengthOfLongestSubstring(String s) {
Set<Character> window = new HashSet<>();
int lower = 0;
int best = 0;
for (int higher = 0; higher < s.length(); higher++) {
char ch = s.charAt(higher);
while (window.contains(ch)) {
window.remove(s.charAt(lower));
lower++;
}
window.add(ch);
best = Math.max(best, higher - lower + 1);
}
return best;
}
public static void main(String[] args) {
System.out.println(lengthOfLongestSubstring("abcabcbb")); // 3
}
}The Java code follows the window idea directly. One set holds only the characters currently between lower and higher.
Let’s carefully go through s = “abcabcbb” again, from indices 0 to 7. Each step shows one action. When we see a shrink row, it means we’re removing a character from the left. An add row indicates we’re adding a new character into the window.
Legend: higher is the read pointer, lower is the left edge, window shows the set after the action, best is the largest length so far.
| higher | char | action | lower | window after | len | best |
|---|---|---|---|---|---|---|
| 0 | a | add | 0 | {a} | 1 | 1 |
| 1 | b | add | 0 | {a, b} | 2 | 2 |
| 2 | c | add | 0 | {a, b, c} | 3 | 3 |
| 3 | a | shrink: remove a | 1 | {b, c} | — | 3 |
| 3 | a | add | 1 | {b, c, a} | 3 | 3 |
| 4 | b | shrink: remove b | 2 | {c, a} | — | 3 |
| 4 | b | add | 2 | {c, a, b} | 3 | 3 |
| 5 | c | shrink: remove c | 3 | {a, b} | — | 3 |
| 5 | c | add | 3 | {a, b, c} | 3 | 3 |
| 6 | b | shrink: remove a | 4 | {b, c} | — | 3 |
| 6 | b | shrink: remove b | 5 | {c} | — | 3 |
| 6 | b | add | 5 | {c, b} | 2 | 3 |
| 7 | b | shrink: remove c | 6 | {b} | — | 3 |
| 7 | b | shrink: remove b | 7 | { } | — | 3 |
| 7 | b | add | 7 | {b} | 1 | 3 |
The window never grows past 3, so best finishes at 3. The method returns 3.
Let us go step by step and watch the window breathe in and out.
Steps higher=0 to 2, the window fills up. These first three characters are all different, so nothing needs shrinking.
So far, lower hasn’t changed at all. The window covers the entire slice from index 0 to index 2, and index 3 is where we find our first real answer.
Step higher=3, the first repeat. Now we read a, but a is already in the window {a, b, c}.
Best stays at 3 because the window is the same size, just shifted one step to the right.
Steps higher=4 and higher=5, the same dance. Each new character collides with an old copy, so each time we drop one from the left and slide across.
As these steps unfold, the window stays at a size of 3 and gently progresses to the right. Best remains steady and confident throughout.
Step higher=6, a double shrink. Here we read b, and this time the window {a, b, c} needs two removals before b is clear.
This is the first time the window has actually shrunk below 3. Luckily, the ‘best’ doesn’t change though, since it only keeps track of the largest length we’ve seen, which remains at 3.
Step higher=7, the window empties then refills. The last character is another b, and it clashes with the b already in {c, b}.
The scan is done. The best was set to 3 early on, and nothing ever beat it, so the method returns 3. Notice that every character was added once and removed at most once, which is why one pass is enough.
| 💡 Interview Insight A common follow-up is why the lower pointer never moves backward. Explain that once a character is dropped from the left, no earlier window can help, so lower only ever moves forward, keeping the scan linear. |
This approach is a big step up from brute force. However, the shrink loop might feel a bit slow when many repeats build up, as it removes characters one at a time. Luckily, using an index lookup allows the lower pointer to skip right past the repeat in a single, efficient move.
Instead of maintaining a set, track the last position where each character appeared. When the upper pointer encounters a character it has seen before, and that previous position is within the current window, move the lower pointer directly to just after that position. This eliminates the slow shrinking loop, replacing it with a single, efficient jump. For ASCII input, a simple int array of size 128 suffices to store these positions.
last = array of 128 values, all set to -1
lower = 0
best = 0
for higher in 0 .. length(s) - 1:
ch = s[higher]
if last[ch] >= lower:
lower = last[ch] + 1 // jump past the old copy
last[ch] = higher // record newest position
windowLen = higher - lower + 1
best = max(best, windowLen)
return bestThe HashSet version was fast, but its shrink loop could remove characters one at a time, which feels wasteful. This version fixes that by remembering more. Instead of just knowing which characters are in the window, we remember the last index where each character appeared. That extra memory lets the left edge leap straight to the right spot in a single move. Let us read it line by line.
Set up the memory array. The line last = array of 128 values, all set to -1 makes one slot for every ASCII character. Each slot will hold the most recent index where that character was seen. We fill every slot with -1 at the start, and -1 is a deliberate signal meaning we have not seen this character yet. That default matters, as you will see in the jump check.
Set up the window trackers. Just like before, when lower = 0, it marks the left edge of the window, and best = 0 keeps track of the longest clean run. There’s no need to set anything this time because the last array takes care of all the remembering for us.
Drive the right edge forward. The loop for higher in 0 .. length(s) – 1 moves the higher pointer across the string, reading one character per step. The line ch = s[higher] grabs the current character so we can look up its history.
Decide whether to jump the left edge. This is the clever part. The check if last[ch] >= lower asks whether the last time we saw this character falls at or after the current left edge. If it does, that earlier copy is still inside the window, so the window is no longer clean. To fix it in one move, lower = last[ch] + 1 slides the left edge to just past that old copy. We compare against lower for a reason: if last[ch] is smaller than lower, the old copy already fell out of the window earlier, so we must ignore it and leave lower where it is. Skipping this check would let lower jump backward, which would break everything.
Record the newest position. The line last[ch] = higher overwrites the character’s slot with its current index. This way, the array always knows the most recent position where the character resides. Updating this at every step helps keep the memory current and accurate.
Measure and save the best. Finally windowLen = higher – lower + 1 counts the clean window, and best = max(best, windowLen) keeps the larger value. After the scan, return best gives the answer. Notice there is no inner loop at all, so each character is touched exactly once, which is why this version is the leanest of the three.
import java.util.*;
public class LongestSubstringIndex {
public static int lengthOfLongestSubstring(String s) {
int[] last = new int[128];
Arrays.fill(last, -1);
int lower = 0;
int best = 0;
for (int higher = 0; higher < s.length(); higher++) {
char ch = s.charAt(higher);
if (last[ch] >= lower) {
lower = last[ch] + 1;
}
last[ch] = higher;
best = Math.max(best, higher - lower + 1);
}
return best;
}
public static void main(String[] args) {
System.out.println(lengthOfLongestSubstring("abcabcbb")); // 3
}
}The code is short because the array does the remembering. One pass, no inner loop.
Let’s go through s = “abcabcbb” once more together. Each row represents a step with a higher pointer. The jump column indicates if the lower pointer moved during that step, and last[ch] shows the position we just recorded.
Legend: higher is the read pointer, char is s[higher], last[char] is the stored index for that letter, lower is the left edge after any jump, len is higher minus lower plus one.
| higher | char | last[char] set to | jumped? | lower | len | best |
|---|---|---|---|---|---|---|
| 0 | a | 0 | no | 0 | 1 | 1 |
| 1 | b | 1 | no | 0 | 2 | 2 |
| 2 | c | 2 | no | 0 | 3 | 3 |
| 3 | a | 3 | yes, to 1 | 1 | 3 | 3 |
| 4 | b | 4 | yes, to 2 | 2 | 3 | 3 |
| 5 | c | 5 | yes, to 3 | 3 | 3 | 3 |
| 6 | b | 6 | yes, to 5 | 5 | 2 | 3 |
| 7 | b | 7 | yes, to 7 | 7 | 1 | 3 |
Best hits 3 at higher=2 and stays there. The method returns 3.
Let us step through and watch the lower pointer jump instead of crawl.
Steps higher=0 to 2, no jumps needed. The first three characters are brand new, so their old positions are all -1.
Lower is still 0 here, and best has climbed to 3 with the run “abc”.
Step higher=3, the first jump. Now we read a, and last[a] is 0.
Notice the win here. The HashSet version needed a removal step to shrink, but the array jumped the left edge in one move.
Steps higher=4 and higher=5, more single jumps. Each character repeats exactly once inside the window, so lower jumps one slot each time.
Through both steps the window length stays at 3, so best holds at 3 while the window glides right.
Step higher=6, a bigger jump. We read b, and last[b] is now 4.
This single jump did the work that took the HashSet two separate removals. The window is now smaller, but best still remembers 3.
Step higher=7, the final jump. The last character is b again, and last[b] is 6.
The scan ends. Best was locked at 3 early and never beaten, so the method returns 3. The array version reached the same answer with no inner loop at all.
| 💡 Interview Insight Interviewers often ask why we compare last[ch] with lower. Explain that an old position before lower has already left the window, so we must ignore it, or lower could wrongly jump backward. |
| Approach | How it handles a repeat | Extra memory | Speed feel |
|---|---|---|---|
| Check every substring | Rebuilds a set per start | One set per start | Slowest, re-reads a lot |
| Window with HashSet | Shrinks left one by one | One set of letters | Fast, single pass |
| Window with index array | Jumps left in one move | Fixed 128 ints | Fastest and leanest |
All three return the same answer of 3. They just pay different prices to get there.
| Approach | Time | Space | Note |
|---|---|---|---|
| Check every substring | O(n²) | O(k) | Simple, but re-scans overlaps |
| Window with HashSet | O(n) | O(k) | Fast, works for any characters |
| Window with index array | O(n) | O(1) | Fast and lean, expected answer |
Both window versions operate over the same linear time, but they have a key difference in the shrink step. The HashSet gently moves the left edge forward, whereas the index array takes a big leap in a single jump, all while using just a small, fixed amount of memory.
During the interview, start by mentioning brute force to acknowledge the straightforward approach. Then, introduce the HashSet as a simple fix and highlight the index array as your top choice. Keep the HashSet as a reliable backup for inputs that extend beyond basic ASCII characters.
| 💡 Interview Insight If pushed on the O(1) space claim, point out that the array is always 128 ints no matter how long the string is. Its size does not grow with the input, so it counts as constant extra space. |
A few small traps catch beginners on this problem. Keep them in mind.
Run an empty string and a single-character string through your code before you call it done. Small inputs like these catch more bugs than any long input will.
A: You are given one string and must return the length of the longest run of side-by-side characters where no character repeats. For “abcabcbb” the answer is 3, from the run “abc”.
A: It is O(n). Each character enters and leaves the window at most once, so a single pass across the string is enough, unlike the O(n²) brute force that re-scans overlapping substrings.
A: The index array stores the last position of each character, so the left pointer can jump straight past a repeat in one move. A HashSet has to remove characters one at a time. The array also uses fixed O(1) space for ASCII input.
A: The answer is 0. The loop never runs, so the best length stays at its starting value of 0.
A: The size-128 array assumes ASCII. For wider input like Unicode or mixed characters, fall back to the HashSet window, which handles any character.
Understanding the Longest Substring Without Repeating Characters in Java might seem challenging at first, but it actually highlights a key pattern you’ll find yourself using often: the sliding window. Once you start seeing the string as a window that expands on the right and contracts on the left, everything begins to click and feels much more approachable.
Our 8-character trace showed the payoff clearly. Brute force re-checked the same characters over and over. The HashSet window slid across in one pass, dropping the left edge when a repeat appeared. The index array did the same thing but jumped the left edge instantly.
Focus on understanding the pattern, not just the answer. When faced with a problem asking for the longest or shortest run that follows a certain rule, try using two pointers and a sliding window. Keep a set or an index map handy to catch issues quickly. This way, many challenging string problems become much easier and more manageable.