Problem Solving: Advanced
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 = 03 right = len(s) - 145 while left < right:6 if s[left] != s[right]:7 return False8 left += 19 right -= 11011 return True1213print(is_palindrome("racecar"))14print(is_palindrome("hello"))
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.
| Step | left | right | Total | Next move |
|---|---|---|---|---|
| 1 | 0 | 5 | 2 + 9 = 11 | too big: right -= 1 |
| 2 | 0 | 4 | 2 + 7 = 9 | too small: left += 1 |
| 3 | 1 | 4 | 3 + 7 = 10 | match: return (3, 7) |
Why a move never skips the answer
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.
- ▸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.
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.
What does this print, and how many totals does the loop compute before it stops?
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.
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 list | Pairs the nested loop may check | Dict lookups in one pass |
|---|---|---|
| 4 | 6 | 4 |
| 1,000 | 499,500 | 1,000 |
| 100,000 | 4,999,950,000 | 100,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 = {}34 for i, num in enumerate(nums):5 complement = target - num67 if complement in seen:8 return [seen[complement], i]910 seen[num] = i1112 return None1314nums = [2, 7, 11, 15]15print(two_sum(nums, 9))
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 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
- ▸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.
What does this print?
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.
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
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.
What does this print?
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 = 04for 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))
Each day is compared only with the lowest day that came before it.
The starting value is part of the answer
Starting best at 0 looks harmless until every value is negative:
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.
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.
| Item | Extend: current + item | Restart: item | New current | best |
|---|---|---|---|---|
| 3 | no earlier run | 3 | 3 | 3 |
| -4 | 3 + -4 = -1 | -4 | -1 | 3 |
| 5 | -1 + 5 = 4 | 5 | 5 | 5 |
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.
| Day | Minutes | prefix[day]: total before it | prefix[day + 1]: total through it |
|---|---|---|---|
| 0 | 40 | 0 | 40 |
| 1 | 10 | 40 | 50 |
| 2 | 25 | 50 | 75 |
| 3 | 5 | 75 | 80 |
| 4 | 30 | 80 | 110 |
| 5 | 20 | 110 | 130 |
1minutes = [40, 10, 25, 5, 30, 20]2prefix = [0]3for m in minutes:4 prefix.append(prefix[-1] + m)56def range_total(first, last):7 return prefix[last + 1] - prefix[first]89print(prefix)10print(range_total(2, 4))11print(range_total(0, 5))12print(range_total(3, 3))
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.
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.
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.
- ▸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.
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.
Closest pair: compare only neighbours
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)
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
- Works on unsorted input as it arrives
- Stores one entry per value seen
- Answers exact matches only
- 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.
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.
This version of the merge leaves out the sort. What does it print?
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.
> 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.
target - num, and checking before storing stops a value pairing with itself.prefix[last + 1] - prefix[first].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
- 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
- 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
- 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
- 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
- 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