AdvancedPython · 22 min · 20 challenges

Problem Solving: Advanced

In November, Spotify's Wrapped team must compute every listener's year: minutes played across any chosen stretch of days, the longest daily listening streak, and 2 tracks that together fill a 10-minute gap. A nested loop over every pair of daily rows would finish long after New Year. The 5 patterns here replace that check of every pair with 1 or 2 passes, plus at most 1 sort.

Two-Pointer Technique

Daily Life
Interviews

A two-pointer solution keeps 2 indexes into one sequence and moves them based on the values they point at. Neither pointer ever reverses direction. Starting from opposite ends, left only rises while right only falls. Each step shrinks the gap between them by at least 1, so on a list of n items the loop ends within n - 1 steps. The skill lies in proving that each move discards only values that can't be part of the answer.

Pointers that meet in the middle

A palindrome reads the same in both directions: character 0 must equal the last character, character 1 must equal the second to last, and so on inward. Put left at the start and right at the end, compare the 2 characters, then step both pointers toward the middle. The first mismatch ends the check, and if the pointers meet without one, every mirrored pair matched.

1def is_palindrome(s):
2 left = 0
3 right = len(s) - 1
4
5 while left < right:
6 if s[left] != s[right]:
7 return False
8 left += 1
9 right -= 1
10
11 return True
12
13print(is_palindrome("racecar"))
14print(is_palindrome("hello"))
output0 lines
defines is_palindrome
1/27

On "racecar" the loop starts by comparing indexes 0 and 6, then moves inward 1 step at a time to 1 and 5 and finally to 2 and 4. After that left and right both sit at 3, the while test fails, and the middle e needs no partner. On "hello" the very first comparison of h against o returns False. The 3 letters between them are never read.

Pair sums on a sorted list

A list of track lengths in minutes is sorted ascending, and a 10-minute ad-free gap should be filled by exactly 2 tracks. Start left at the shortest track and right at the longest, then add them. A total below the target can only grow if left moves up to a longer track, and a total above it can only shrink if right moves down to a shorter one.

def pair_with_sum(values, target):
left = 0
right = len(values) - 1
while left < right:
total = values[left] + values[right]
if total == target:
return values[left], values[right]
if total < target:
left += 1
else:
right -= 1
return None
durations = [2, 3, 4, 6, 7, 9]
print(pair_with_sum(durations, 10))
print(pair_with_sum(durations, 14))
print(pair_with_sum(durations, 16))
>>>Output
(3, 7)
None
(7, 9)
StepleftrightTotalNext move
1052 + 9 = 11too big: right -= 1
2042 + 7 = 9too small: left += 1
3143 + 7 = 10match: return (3, 7)

Why a move never skips the answer

Take the trace above for a target of 10. Step 1 computes 2 + 9 = 11 and overshoots. Since 2 is the shortest track still in play, every other partner for 9 would overshoot by more, so 9 belongs to no valid pair and dropping it loses nothing. Step 2 is the mirror case: 2 + 7 = 9 falls short, 7 is the longest track still in play, so 2 has no partner left that reaches 10.

Every step removes one value with proof that it cannot be in any valid pair, which is why the input must be sorted. On unsorted values values[left] is no longer guaranteed to be the shortest track still in play. Without that guarantee the loop can walk past a pair that exists.

Signs a problem fits 2 pointers
  • ▸The input is sorted, or can be read from both ends with a known order.
  • ▸Moving one pointer has a one-way effect: the total or the gap only grows, or only shrinks.
  • ▸The answer is a single pair or window of the input, or positions that mirror each other.

Pointers moving the same way

Pointers can also travel in the same direction. To remove duplicates from a sorted list in place, a read index visits every item while a write index marks where the next distinct value goes. Sorting already put equal values next to each other, so a value is new exactly when it differs from the last value written.

def dedupe_sorted(values):
write = 1
for read in range(1, len(values)):
if values[read] != values[write - 1]:
values[write] = values[read]
write += 1
del values[write:]
return values
print(dedupe_sorted([1, 1, 2, 3, 3, 3, 5]))
>>>Output
[1, 2, 3, 5]

Trace it on [1, 1, 2, 3, 3, 3, 5]. Both indexes start at 1. The 1 at position 1 equals the last value written, values[0], so it's skipped and only read advances. The 2 at position 2 differs, so it's copied to position 1 and write becomes 2. The first 3 lands at position 2, the next 2 copies of 3 match it and are skipped, and 5 lands at position 3. write ends at 4, and del values[write:] cuts the 3 stale items at positions 4 to 6. write never passes read, so a copy never overwrites a value that hasn't been read yet.

Multiple Choice

What does this print, and how many totals does the loop compute before it stops?

def pair_with_sum(values, target):
left = 0
right = len(values) - 1
while left < right:
total = values[left] + values[right]
if total == target:
return values[left], values[right]
if total < target:
left += 1
else:
right -= 1
return None
print(pair_with_sum([1, 4, 5, 8, 11], 13))

Each branch moves the pointer whose value caused the miss. A total below the target means the smaller value is too small, so left steps up to a larger one; a total above it means the larger value is too big, so right steps down. Swap the 2 moves and the walk heads away from the answer: on [2, 3, 4, 6, 7, 9] with a target of 10, the first total of 11 moves left up to the 3, and the totals climb to 12 and 13, then to 15 and 16, until the pointers meet and the function returns None.

Hash Map O(1) Lookups

Daily Life
Interviews
Drop the sorted guarantee. Track lengths now arrive in play order, and the task is to return the positions of 2 that add up to a target. The direct answer tries every pair with a nested loop:
def two_sum_pairs(nums, target):
for i in range(len(nums)):
for j in range(i + 1, len(nums)):
if nums[i] + nums[j] == target:
return [i, j]
return None

Starting the inner loop at i + 1 tries each pair once, yet the count of pairs grows with the square of the list length: double the list and the worst case roughly quadruples.

Items in the listPairs the nested loop may checkDict lookups in one pass
464
1,000499,5001,000
100,0004,999,950,000100,000

Turn the search into a lookup

For any number, its partner is fully decided: it must equal target - num. The question 'which number pairs with this one?' becomes 'have I already seen the value target - num?' A dict answers that with one hash lookup, and the cost of a lookup stays flat whether the dict holds 4 keys or 4 million. The keys are values seen so far, and each maps to the position where it appeared.

1def two_sum(nums, target):
2 seen = {}
3
4 for i, num in enumerate(nums):
5 complement = target - num
6
7 if complement in seen:
8 return [seen[complement], i]
9
10 seen[num] = i
11
12 return None
13
14nums = [2, 7, 11, 15]
15print(two_sum(nums, 9))
nums
[
]
0123
seen
:
output0 lines
defines two_sum
1/14

At position 0 the loop needs 7, which is not in the dict yet, so it stores {2: 0}. At position 1 it needs 2, which is a key, so it returns [0, 1] and never reads 11 or 15. On a list with no answer, each number is looked up once and stored once, so the work is one pass plus one dict entry per item.

Look up first, then store

The order of the 2 dict operations inside the loop decides correctness. Store the current number before checking, and a number can pair with itself:
def two_sum_store_first(nums, target):
seen = {}
for i, num in enumerate(nums):
seen[num] = i
if target - num in seen:
return [seen[target - num], i]
return None
print(two_sum_store_first([5, 3, 8], 10))
>>>Output
[0, 0]

The list [5, 3, 8] has no pair that sums to 10, yet the function reports positions 0 and 0: the 5 at position 0 found itself in the dict. Checking first means seen holds only earlier positions when the lookup runs. Repeated values still work in that order: on [5, 5] with a target of 10, position 1 needs 5, finds the 5 stored at position 0, and returns [0, 1].

Grouping is the same move

The nested-loop habit appears outside pair problems too. Splitting play events by user with an outer loop over users and an inner filter over all events reads every event once per user. A dict keyed by user does the split in one pass, sending each event straight to its user's list:
from collections import defaultdict
plays = [("ana", "t1"), ("ben", "t4"), ("ana", "t7"), ("cy", "t1"), ("ben", "t2")]
by_user = defaultdict(list)
for user, track in plays:
by_user[user].append(track)
print(dict(by_user))
print(len(by_user["ana"]))
>>>Output
{'ana': ['t1', 't7'], 'ben': ['t4', 't2'], 'cy': ['t1']}
2
Limits of the dict
  • ▸Memory: up to one entry per item, so 100,000 numbers can leave 100,000 keys in seen.
  • ▸Hashable keys: numbers work as keys, and so do strings and tuples, but a list as a key raises TypeError.
  • ▸Exact matches only: 'which seen value is closest to 7?' needs sorted data instead.
Multiple Choice

What does this print?

def two_sum(nums, target):
seen = {}
for i, num in enumerate(nums):
complement = target - num
if complement in seen:
return [seen[complement], i]
seen[num] = i
return None
print(two_sum([3, 2, 4], 6))

When the answer needs only whether a value appeared, the positions stored in the dict go unused, and a set does the same work with keys alone. x in seen is the same flat-cost hash lookup, and seen.add(x) records a value. Finding the first track id played a second time is the two-sum loop with a set: if the current id is already in seen, print it and stop; if not, add it. The order matters for the same reason as before: add first, and every id finds itself on its first visit.

Tracking the Best So Far

Daily Life
Interviews

Plenty of questions want one winner: the busiest day, the longest streak, the biggest jump. One approach builds a list of every candidate and calls max at the end. A tighter one keeps only the best candidate found so far and updates it as each item passes, so its memory stays the same size whether it reads 10 days or 10 years.

A running maximum that remembers where

minutes = [42, 18, 77, 77, 30]
best = minutes[0]
best_day = 0
for day, m in enumerate(minutes):
if m > best:
best = m
best_day = day
print(best, best_day)
>>>Output
77 2

Day 2 and day 3 both reach 77, and the strict > keeps the first, printing 77 2. Change it to >= and the later tie replaces the earlier one, printing 77 3. The comparison operator is a decision about ties, so choose it on purpose.

Multiple Choice

What does this print?

plays = [5, 9, 3, 9, 1]
best = plays[0]
best_hour = 0
for hour, p in enumerate(plays):
if p >= best:
best = p
best_hour = hour
print(best, best_hour)

Remembering 2 values at once

The biggest rise in daily listeners, from an earlier day to a later one, depends on a pair of days. Subtracting the overall minimum from the overall maximum gives the wrong answer when the peak comes before the trough. Keep 2 running values instead: lowest, the smallest count seen so far, and best_gain, the largest rise found so far. Each new day is measured against the lowest day before it.

1listeners = [120, 160, 95, 140, 88, 130]
2lowest = listeners[0]
3best_gain = 0
4for count in listeners[1:]:
5 best_gain = max(best_gain, count - lowest)
6 lowest = min(lowest, count)
7print(best_gain)
8print(max(listeners) - min(listeners))
lowest=
best_gain=
count=
listeners
[
]
012345
output0 lines
listeners is a new list that holds 120, 160, 95, 140, 88, 130
1/22

Each day is compared only with the lowest day that came before it.

The loop prints 45, the rise from 95 on day 2 to 140 on day 3. The naive 72 pairs 160 on day 1 with 88 on day 4, a drop in time order, so that rise never happened.

The starting value is part of the answer

Starting best at 0 looks harmless until every value is negative:

changes = [-5, -2, -9]
best = 0
for c in changes:
if c > best:
best = c
print(best)
best = changes[0]
for c in changes[1:]:
if c > best:
best = c
print(best)
>>>Output
0
-2

The first loop reports 0, a value that never appears in the list. Starting from changes[0] makes the first real item the first candidate. On an empty list that start raises IndexError, which is usually right: an empty list has no best value, and a guard clause before the loop can say so in its own words. The rise loop starts best_gain at 0 on purpose, meaning 'no rise found'.

The best run, built from the current run

Some winners are runs, such as the longest stretch of consecutive days with any listening. Keep current, the length of the run that ends today, and longest, the best run so far. A listening day extends current and offers it to longest; a silent day resets current to 0.

days = [30, 0, 12, 45, 9, 0, 5, 8]
current = 0
longest = 0
for m in days:
if m > 0:
current += 1
longest = max(longest, current)
else:
current = 0
print(longest)
>>>Output
3

The winning run is 3 days long. It starts with 12 minutes and 45 minutes and ends on a 9-minute day. The same 2-variable shape finds the largest total over any consecutive run when values can be negative. Now current is the best total of a run ending exactly at this item, and that run has 2 choices: extend the run that ended at the previous item, or start fresh here. A run whose total is negative only drags down whatever follows it, so when current is negative the next run starts over at the new value. That is current = max(value, current + value). The winning run can end at any item, so best = max(best, current) keeps the largest current seen.

ItemExtend: current + itemRestart: itemNew currentbest
3no earlier run333
-43 + -4 = -1-4-13
5-1 + 5 = 4555

Prefix Sum Concept

Daily Life
Interviews
A dashboard lets a listener pick any range of days and shows the minutes played in it. Summing the slice for each query reads the whole range every time: 1,000 queries over a year can read up to 365,000 values, adding the same days again and again. A prefix sum does the adding once.

Build a list prefix where prefix[i] is the total of the first i days, starting with prefix[0] = 0. The total from day first through day last, both included, is prefix[last + 1] - prefix[first]: everything up to and including last, minus everything before first.

DayMinutesprefix[day]: total before itprefix[day + 1]: total through it
040040
1104050
2255075
357580
43080110
520110130
1minutes = [40, 10, 25, 5, 30, 20]
2prefix = [0]
3for m in minutes:
4 prefix.append(prefix[-1] + m)
5
6def range_total(first, last):
7 return prefix[last + 1] - prefix[first]
8
9print(prefix)
10print(range_total(2, 4))
11print(range_total(0, 5))
12print(range_total(3, 3))
m=
minutes
[
]
012345
prefix
[
]
0123456
output0 lines
minutes is a new list that holds 40, 10, 25, 5, 30, 20
1/27

The prefix list gains one running total per day, then 3 queries read from it.

The query for days 2 through 4 reads only the 2 entries 110 and 50, however many days lie between them. Day 3 alone works the same way: prefix[4] - prefix[3] is 80 minus 75. The build costs one pass, and each query after it costs one subtraction.

The leading 0 in the prefix list

Without the leading 0, prefix[i] would hold the total through day i, and a range starting at day 0 would subtract prefix[first - 1]. In Python that is prefix[-1], the last element, so the query returns a wrong number and raises no error. The 0 at the front gives every range, including one that starts at day 0, the same formula.

TIP
itertools.accumulate(minutes, initial=0) yields the same running totals. Wrap it in list() to get [0, 40, 50, 75, 80, 110, 130] in one line.

Prefix sums meet the dict

How many runs of consecutive hours have a net follower change of exactly 7? A run from hour first to hour last totals 7 when prefix[last + 1] - prefix[first] == 7, so at each hour the question is how many earlier running totals equal the current total minus 7. That's the complement lookup from two-sum, with counts in the dict instead of positions.

from collections import defaultdict
def count_runs(values, k):
seen = defaultdict(int)
seen[0] = 1
running = 0
count = 0
for v in values:
running += v
count += seen[running - k]
seen[running] += 1
return count
print(count_runs([3, 4, 7, 2, -3, 1, 4, 2], 7))
>>>Output
4

The 4 runs are 3, 4, then 7, then 7, 2, -3, 1, then 1, 4, 2. The entry seen[0] = 1 stands for the empty prefix before hour 0, so a run starting at hour 0 gets counted. Negative values rule out a sliding window here, since growing a window no longer guarantees a larger total.

When prefix sums stop paying
  • ▸The data changes between queries: one edited day shifts every prefix value after it.
  • ▸There's only one query: the build is a full pass, the same work as one plain sum.
  • ▸The question is a range maximum or minimum: subtraction undoes a sum, but no subtraction undoes a max.
Multiple Choice

With prefix = [0, 40, 50, 75, 80, 110, 130] built from 6 days of minutes, which expression gives the minutes for days 1 through 3, both included?

Prefix code goes wrong in 2 places, and both are off by one. The list must start with 0, which gives it 1 more entry than there are days, and a query's end index must be last + 1, because entry i holds the total of the days before day i. A one-day range tests both at once: range_total(d, d) has to equal day d's own minutes. Without the + 1 it returns 0, and without the leading 0 it returns the next day's minutes instead.

Sort as Preprocessing

Daily Life
Interviews
Many problems that look hard on raw input become a single pass once the input is sorted. Sorting puts equal values next to each other, places the closest values side by side, lines up overlapping intervals, and gives 2 pointers the order their proof needs. A sort costs more than one pass, but far less than checking every pair.
~20M
comparisons to sort 1M values
~500B
pairs among 1M values

Closest pair: compare only neighbours

Which 2 play timestamps, in seconds, sit closest together? In sorted order the closest pair must be neighbours: a value between them would be closer to each of them than they're to each other. One pass over neighbouring pairs, keeping the smallest gap so far, replaces the check of every pair.
timestamps = [905, 140, 612, 150, 880, 600]
ordered = sorted(timestamps)
best_gap = None
for a, b in zip(ordered, ordered[1:]):
gap = b - a
if best_gap is None or gap < best_gap:
best_gap = gap
pair = (a, b)
print(ordered)
print(pair, best_gap)
>>>Output
[140, 150, 600, 612, 880, 905]
(140, 150) 10

Merging overlapping sessions

Listening sessions arrive as (start, end) hours in any order, and overlapping ones should become one block. After sorting by start, a session can only overlap the block built just before it, so each session either extends the last block or opens a new one.

1sessions = [(9, 12), (1, 4), (3, 6), (14, 15), (11, 13)]
2sessions.sort()
3merged = [list(sessions[0])]
4for start, end in sessions[1:]:
5 last = merged[-1]
6 if start <= last[1]:
7 last[1] = max(last[1], end)
8 else:
9 merged.append([start, end])
10print(merged)
end=
start=
sessions
[
]
01234
merged
[
]
012
last
[
]
01
last
[
]
01
output0 lines
sessions is a new list that holds (2), (2), (2), (2), (2)
1/22

After the sort, each session is compared with the last merged block only.

(3, 6) starts at 3, inside [1, 4], so the block grows to [1, 6]. (9, 12) starts after 6 and opens a new block, which (11, 13) then stretches to 13. The max(last[1], end) matters for a session lying fully inside a block, such as (2, 3) inside [1, 6], which must not shrink it.

Sorting without losing positions

Sorting rearranges values, so the positions from the original list are lost. When the answer must be original positions, sort the positions themselves, using each position's value as the sort key. The pair walk from the first section then returns indexes into the unsorted list:
def two_sum_sorted(nums, target):
order = sorted(range(len(nums)), key=lambda i: nums[i])
left, right = 0, len(order) - 1
while left < right:
total = nums[order[left]] + nums[order[right]]
if total == target:
return sorted([order[left], order[right]])
if total < target:
left += 1
else:
right -= 1
return None
print(two_sum_sorted([11, 2, 15, 7], 9))
>>>Output
[1, 3]
✓Dict lookup in one pass
  • Works on unsorted input as it arrives
  • Stores one entry per value seen
  • Answers exact matches only
✗Sort, then walk or search
  • Pays for one sort up front
  • Can run in place with 2 indexes
  • Answers ordering questions, such as which values are closest or fall in a range

Sort once, answer many questions

A sorted list also supports binary search. bisect_right(lengths, x) returns how many values are at most x, so the program below finds 3 sessions of 10 minutes or less and 2 longer than 20. One sort pays for any number of these questions.

from bisect import bisect_right
lengths = sorted([12, 3, 45, 7, 30, 3, 18])
print(lengths)
print(bisect_right(lengths, 10))
print(len(lengths) - bisect_right(lengths, 20))
>>>Output
[3, 3, 7, 12, 18, 30, 45]
3
2

Every pass in this section trusts the order the sort produced. The closest-pair loop assumes the nearest values are neighbours, the merge assumes a session can only overlap the last block, and bisect_right assumes no larger value sits before a smaller one. Remove the sort and each loop still runs to the end with no exception. The assumption is false, though, so the output is a wrong answer that looks like a real one.

Multiple Choice

This version of the merge leaves out the sort. What does it print?

sessions = [(9, 12), (1, 4), (3, 6)]
merged = [list(sessions[0])]
for start, end in sessions[1:]:
last = merged[-1]
if start <= last[1]:
last[1] = max(last[1], end)
else:
merged.append([start, end])
print(merged)

A missing sort sometimes leaves a visible trace. In the closest-pair loop run on arrival order, b - a goes negative whenever a later timestamp is smaller, as 140 - 905 = -765 is. A negative gap is smaller than every real gap, so it wins. Any gap below 0 means the order the loop relies on is missing.

Capstone: the fullest gap

This one combines 3 patterns: sort the input, walk 2 pointers inward, and keep the best total so far. A total that fits the gap is a candidate, and moving left up tries a larger one; a total that overflows moves right down to a shorter track.

❯❯❯PUTTING IT ALL TOGETHER

> The Wrapped team at Spotify receives a year of rows per listener: minutes played each day, app sessions as start and end hours, and the lengths of every track played. From those rows they must produce each listener's summary cards before launch day.

A dict keyed by listener groups the rows in one pass, so every later pattern runs per person instead of filtering the whole year for each one.
Daily minutes become a prefix list once per listener, and 'minutes in your summer' or any other date range is a single subtraction.
The longest listening streak and the biggest rise in daily minutes each come from one pass with a running best, with no list of candidates stored.
Sessions sorted by start and merged turn overlapping app sessions into real listening blocks.
Track pairs that fill a gap come from sorting lengths and walking 2 pointers, or from a dict of lengths when the data must stay in arrival order.
KEY TAKEAWAYS
A two-pointer walk is correct when each move provably discards a value, and on pair sums that proof needs sorted input.
A dict turns a search for the partner into a lookup of target - num, and checking before storing stops a value pairing with itself.
A running best keeps memory flat, and its starting value and its tie comparison are both part of the answer.
A prefix list with a leading 0 answers any range sum as prefix[last + 1] - prefix[first].
Sorting first moves equal and closest values next to each other and lines up overlapping intervals, at the price of the original positions.

Turn nested loops into single passes with two-pointer walks, dict lookups, running bests, prefix sums and sorting.

Category
Python
Difficulty
advanced
Duration
22 minutes
Challenges
20 hands-on challenges

Topics covered: Two-Pointer Technique, Hash Map O(1) Lookups, Tracking the Best So Far, Prefix Sum Concept, Sort as Preprocessing

Lesson Sections

  1. Two-Pointer Technique (concepts: Two-Pointer)

    Pointers that meet in the middle Pair sums on a sorted list Why a move never skips the answer Take the trace above for a target of 10. Step 1 computes 2 + 9 = 11 and overshoots. Since 2 is the shortest track still in play, every other partner for 9 would overshoot by more, so 9 belongs to no valid pair and dropping it loses nothing. Step 2 is the mirror case: 2 + 7 = 9 falls short, 7 is the longest track still in play, so 2 has no partner left that reaches 10. Pointers moving the same way

  2. Hash Map O(1) Lookups

    Drop the sorted guarantee. Track lengths now arrive in play order, and the task is to return the positions of 2 that add up to a target. The direct answer tries every pair with a nested loop: Turn the search into a lookup Look up first, then store The order of the 2 dict operations inside the loop decides correctness. Store the current number before checking, and a number can pair with itself: Grouping is the same move The nested-loop habit appears outside pair problems too. Splitting play event

  3. Tracking the Best So Far (concepts: Frequency Counting)

    A running maximum that remembers where Remembering 2 values at once The loop prints 45, the rise from 95 on day 2 to 140 on day 3. The naive 72 pairs 160 on day 1 with 88 on day 4, a drop in time order, so that rise never happened. The starting value is part of the answer The best run, built from the current run

  4. Prefix Sum Concept (concepts: Prefix Sum)

    A dashboard lets a listener pick any range of days and shows the minutes played in it. Summing the slice for each query reads the whole range every time: 1,000 queries over a year can read up to 365,000 values, adding the same days again and again. A prefix sum does the adding once. The leading 0 in the prefix list Prefix sums meet the dict

  5. Sort as Preprocessing (concepts: Sorting)

    Many problems that look hard on raw input become a single pass once the input is sorted. Sorting puts equal values next to each other, places the closest values side by side, lines up overlapping intervals, and gives 2 pointers the order their proof needs. A sort costs more than one pass, but far less than checking every pair. Closest pair: compare only neighbours Which 2 play timestamps, in seconds, sit closest together? In sorted order the closest pair must be neighbours: a value between them