Longest Playlist With No Repeated Songs : Brute Force to Optimal
You are given a user's listening history, where songs are stored in the order they were played:
Song1, Song2, Song3, Song1, Song4, Song2
Your task is to find the longest possible playlist that can be created from this listening history. The playlist must follow these rules:
- The songs must remain in the same order as the user's listening history.
- The playlist must contain a continuous sequence of songs. You cannot skip any song.
- No song can be repeated within the playlist.
- Return the length of the longest valid playlist.
For the example above, the answer is 4, one such playlist is Song2, Song3, Song1, Song4.
Understanding the Problem First
Before jumping into code, let's pin down exactly what we're being asked for, because two words in the problem statement do all the heavy lifting:
- "Continuous sequence" → we are looking at a window of the array. Once we pick a starting song, every song after it up to some ending song must be included we can't cherry-pick.
- "No song repeated" → within that window, every song must be unique.
So really, we're searching over all possible windows [i, j] of the history array and among all windows where every element is unique, we want the one with the maximum length j - i + 1.
That framing naturally gives us a spectrum of solutions from "try every window and check it" (slow but obviously correct) to "smartly grow and shrink one window as we scan" (fast). Let's walk through that spectrum step by step.
Approach 1: Brute Force (Check Every Window)
The most direct translation of the problem into code: generate every possible contiguous window [i, j] and for each one, check whether all songs in it are distinct. Keep track of the largest window length where the check passes.
To check "are all songs in this window distinct?", we can insert each song into a HashSet as we go if an insertion fails (song already present), the window is invalid.
Dry Run
For [Song1, Song2, Song3, Song1, Song4, Song2]:
- Start
i = 0: extendjfrom 0 → 1 → 2 (all unique, length 3), thenj = 3hitsSong1again → stop this window at length 3. - Start
i = 1: extendjfrom 1 → 2 → 3 → 4 (all unique, length 4), thenj = 5hitsSong2again → stop at length 4. - Start
i = 2: extend similarly... - ...continue for every
i.
We track the maximum length seen across all starting points. This is a straightforward "brute force" because we're not reusing any work between different values of i every window starts its uniqueness check from scratch.
// Java Code
import java.util.HashSet;
import java.util.Set;
public class PlaylistBruteForce {
public static int longestPlaylist(String[] history) {
int n = history.length;
int maxLength = 0;
// Try every possible starting index
for (int i = 0; i < n; i++) {
Set<String> seen = new HashSet<>();
// Extend the window as far right as possible
for (int j = i; j < n; j++) {
if (seen.contains(history[j])) {
// Repeated song found, this window can't extend further
break;
}
seen.add(history[j]);
maxLength = Math.max(maxLength, j - i + 1);
}
}
return maxLength;
}
public static void main(String[] args) {
String[] history = {"Song1", "Song2", "Song3", "Song1", "Song4", "Song2"};
System.out.println("Longest Playlist Length: " + longestPlaylist(history));
// Output: 4
}
}
Why It Works
For every starting point i, we greedily extend j until we hit a duplicate and since the window is checked incrementally (not re-verified from scratch with nested loops), this is already a slight improvement over the "textbook" brute force that would use three nested loops (pick i, pick j, then loop again to verify uniqueness).
Complexity
| Value | |
|---|---|
| Time | O(n²) for each of the n starting indices, we may scan up to n elements again |
| Space | O(min(n, k)) where k = number of distinct songs, for the HashSet used per starting index |
This works fine for small histories, but if a user has 100,000 plays in their history, n² becomes 10 billion operations far too slow.
Approach 2: Sliding Window with HashSet
The brute force approach wastes work: for every starting index i, it throws away everything it learned at the previous starting index, rebuilds the HashSet from scratch and re-scans forward even though most of that scanning repeats work already done.
Instead of restarting for every i, we can maintain a single window and let it evolve incrementally, driven by the right pointer moving forward one step at a time. This is the classic sliding window technique:
- Maintain two pointers, left and right, marking the current window.
- Maintain a HashSet of songs currently inside the window.
- Move right forward one step at a time, trying to add history[right] to the set.
- If history[right] is not already in the set, adding it can't create a duplicate the window simply grows and it stays valid.
- If history[right] is already in the set, adding it would create a duplicate. So before adding it, keep removing the song at left (and moving left forward) until the duplicate is cleared. Removing an element can never create a new duplicate, it can only eliminate one, so once the offending song is gone, the window is guaranteed valid again.
- After every step, update the maximum window length.
The key insight: both pointers only move forward and left only moves in reaction to a duplicate appearing at right we never rebuild the set from scratch for a new starting point. That's what makes this fast.
Dry Run
[Song1, Song2, Song3, Song1, Song4, Song2], indices 0..5
| right | song | action | window | length |
|---|---|---|---|---|
| 0 | Song1 | add | [0,0] | 1 |
| 1 | Song2 | add | [0,1] | 2 |
| 2 | Song3 | add | [0,2] | 3 |
| 3 | Song1 | duplicate! remove Song1 (left=0→1) | [1,3] | 3 |
| 4 | Song4 | add | [1,4] | 4 |
| 5 | Song2 | duplicate! remove Song2 (left=1→2) | [2,5] | 4 |
Maximum length observed: 4
// Java Code
import java.util.HashSet;
import java.util.Set;
public class PlaylistSlidingWindowSet {
public static int longestPlaylist(String[] history) {
int n = history.length;
Set<String> window = new HashSet<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < n; right++) {
// Shrink from the left until the duplicate is gone
while (window.contains(history[right])) {
window.remove(history[left]);
left++;
}
// Now it's safe to add the current song
window.add(history[right]);
// Update the max window length seen so far
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
public static void main(String[] args) {
String[] history = {"Song1", "Song2", "Song3", "Song1", "Song4", "Song2"};
System.out.println("Longest Playlist Length: " + longestPlaylist(history));
// Output: 4
}
}
Extension: Printing the Actual Playlist
The length tells us how long the answer is, but not which songs make it up. To recover the songs themselves, we track the left pointer's value at the exact moment a new maximum length is found (call it bestStart) and once the scan finishes, slice the array from bestStart to bestStart + maxLength.
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
public class PlaylistSlidingWindowSetWithPrint {
public static int longestPlaylist(String[] history, int[] result) {
int n = history.length;
Set<String> window = new HashSet<>();
int left = 0;
int maxLength = 0;
int bestStart = 0; // start index of the best window found so far
for (int right = 0; right < n; right++) {
// Shrink from the left until the duplicate is gone
while (window.contains(history[right])) {
window.remove(history[left]);
left++;
}
// Now it's safe to add the current song
window.add(history[right]);
// Update the max window length and remember where it started
int currentLength = right - left + 1;
if (currentLength > maxLength) {
maxLength = currentLength;
bestStart = left;
}
}
result[0] = bestStart; // pass the start index back to the caller
return maxLength;
}
public static void main(String[] args) {
String[] history = {"Song1", "Song2", "Song3", "Song1", "Song4", "Song2"};
int[] bestStartHolder = new int[1];
int length = longestPlaylist(history, bestStartHolder);
String[] playlist = Arrays.copyOfRange(history, bestStartHolder[0], bestStartHolder[0] + length);
System.out.println("Longest Playlist Length: " + length);
System.out.println(String.join(", ", playlist) + " → length " + length);
// Output:
// Longest Playlist Length: 4
// Song2, Song3, Song1, Song4 → length 4
}
}
Why It Works
Each song is added to the HashSet at most once and removed at most once as right and left sweep across the array. Since both pointers move strictly forward and never revisit a position, the total number of add/remove operations is bounded by 2n, not n².
Complexity
| Value | |
|---|---|
| Time | O(n) technically O(2n) since each element can be added and removed once, which simplifies to O(n) |
| Space | O(min(n, k)) for the HashSet, where k = number of distinct songs |
Approach 3: Sliding Window with HashMap
Approach 2 is already O(n), but look closely at the inner while loop when we hit a duplicate, we remove songs from the left one at a time until the duplicate clears. In the worst case (e.g., the same song repeating at the very start and very end of a long, otherwise-unique stretch), this can still mean a lot of individual removal steps.
We can do better by directly jumping left to the position right after the previous occurrence of the duplicate song, instead of inching forward one element at a time. To do this, we store not just whether we've seen a song, but the last index where we saw it which calls for a HashMap<String, Integer> instead of a HashSet.
Steps:
- Maintain a
HashMapmapping each song to the most recent index it was played at. - For each song at index
right:- If the song was seen before and that previous occurrence is inside the current window (i.e.,
lastSeenIndex >= left), jumplefttolastSeenIndex + 1. - Update the song's last-seen index to
right. - Update the max window length.
- If the song was seen before and that previous occurrence is inside the current window (i.e.,
This guarantees left only ever moves forward and each index is processed in strictly O(1) amortized work no inner while loop needed at all.
Dry Run
[Song1, Song2, Song3, Song1, Song4, Song2]
| right | song | lastSeen before update | left before | left after | window length |
|---|---|---|---|---|---|
| 0 | Song1 | none | 0 | 0 | 1 |
| 1 | Song2 | none | 0 | 0 | 2 |
| 2 | Song3 | none | 0 | 0 | 3 |
| 3 | Song1 | index 0 (≥ left=0) | 0 | 1 | 3 |
| 4 | Song4 | none | 1 | 1 | 4 |
| 5 | Song2 | index 1 (< left=1? no, 1 ≥ 1) | 1 | 2 | 4 |
Maximum length observed: 4
Notice how at right = 3, instead of removing songs one at a time from the HashSet, we directly compute left = lastSeenIndex + 1 = 1 in a single step.
// Java Code
import java.util.HashMap;
import java.util.Map;
public class PlaylistSlidingWindowMap {
public static int longestPlaylist(String[] history) {
int n = history.length;
Map<String, Integer> lastSeenIndex = new HashMap<>();
int left = 0;
int maxLength = 0;
for (int right = 0; right < n; right++) {
String currentSong = history[right];
// If this song was seen before AND that occurrence is
// still inside our current window, jump left past it
if (lastSeenIndex.containsKey(currentSong)
&& lastSeenIndex.get(currentSong) >= left) {
left = lastSeenIndex.get(currentSong) + 1;
}
// Record/update the most recent index for this song
lastSeenIndex.put(currentSong, right);
// Update the max window length
maxLength = Math.max(maxLength, right - left + 1);
}
return maxLength;
}
public static void main(String[] args) {
String[] history = {"Song1", "Song2", "Song3", "Song1", "Song4", "Song2"};
System.out.println("Longest Playlist Length: " + longestPlaylist(history));
// Output: 4
}
}
Extension: Printing the Actual Playlist
Same idea as before: track bestStart (the left pointer's value at the moment a new max length is found) alongside maxLength, then slice the array once the single pass finishes. No extra loop or second pass is needed this stays true to the O(n) optimal approach.
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
public class PlaylistSlidingWindowMapWithPrint {
public static int longestPlaylist(String[] history, int[] result) {
int n = history.length;
Map<String, Integer> lastSeenIndex = new HashMap<>();
int left = 0;
int maxLength = 0;
int bestStart = 0; // start index of the best window found so far
for (int right = 0; right < n; right++) {
String currentSong = history[right];
// If this song was seen before AND that occurrence is
// still inside our current window, jump left past it
if (lastSeenIndex.containsKey(currentSong)
&& lastSeenIndex.get(currentSong) >= left) {
left = lastSeenIndex.get(currentSong) + 1;
}
// Record/update the most recent index for this song
lastSeenIndex.put(currentSong, right);
// Update the max window length and remember where it started
int currentLength = right - left + 1;
if (currentLength > maxLength) {
maxLength = currentLength;
bestStart = left;
}
}
result[0] = bestStart; // pass the start index back to the caller
return maxLength;
}
public static void main(String[] args) {
String[] history = {"Song1", "Song2", "Song3", "Song1", "Song4", "Song2"};
int[] bestStartHolder = new int[1];
int length = longestPlaylist(history, bestStartHolder);
String[] playlist = Arrays.copyOfRange(history, bestStartHolder[0], bestStartHolder[0] + length);
System.out.println("Longest Playlist Length: " + length);
System.out.println(String.join(", ", playlist) + " → length " + length);
// Output:
// Longest Playlist Length: 4
// Song2, Song3, Song1, Song4 → length 4
}
}
Note: since multiple windows can tie for the maximum length (as we saw earlier both Song2, Song3, Song1, Song4 and Song3, Song1, Song4, Song2 have length 4), this code prints the first such window encountered while scanning left to right, since bestStart only updates on a strict > comparison. Changing that condition to >= would instead print the last one.
Why This Is the Optimal Approach
- Every index
rightis visited exactly once. leftonly ever moves forward and it moves in a single O(1) jump rather than a sequence of removals.- There's no nested loop of any kind this is a true single-pass O(n) algorithm.
This is the same core pattern used to solve the well-known "Longest Substring Without Repeating Characters" problem a very common interview question so recognizing this pattern is valuable well beyond just this playlist scenario.
Complexity
| Value | |
|---|---|
| Time | O(n) single pass, O(1) work per element |
| Space | O(min(n, k)) for the HashMap, where k = number of distinct songs |
Comparing All Approaches
| Approach | Time Complexity | Space Complexity | Core Idea |
|---|---|---|---|
| Brute Force | O(n²) | O(min(n, k)) | Try every window starting point, extend until a duplicate appears |
| Sliding Window (HashSet) | O(n) | O(min(n, k)) | Grow window right, shrink left one step at a time on duplicates |
| Sliding Window (HashMap) | O(n) | O(min(n, k)) | Grow window right, jump left directly using last-seen index |
While both sliding window approaches are O(n) asymptotically, the HashMap version does strictly less work in practice since it eliminates the inner while loop entirely every operation is O(1) with no amortization argument needed.
Key Takeaways
- Recognize the window pattern early. Any time a problem asks for the longest/shortest contiguous segment satisfying some condition, sliding window should be one of the first techniques you consider.
- Brute force is a stepping stone, not a dead end. Writing the O(n²) version first helps you see why the sliding window works the optimal solution literally comes from asking "what work am I repeating unnecessarily?"
- HashSet vs. HashMap is about what you need to remember. A
HashSetonly tells you if something exists in the window. AHashMaptells you where it last existed and that extra bit of information is what lets you jump instead of crawl. - This pattern generalizes. The exact same technique solves "longest substring without repeating characters", "longest subarray with at most K distinct elements," and several other frequently-asked interview problems.
