Patterns for coding problems
Two pointers, counting with dictionaries, prefix sums and sliding windows: O(n) answers to classic problems.
Create a free account Try part of it here
Why it matters
Coding interviews look like hundreds of different puzzles, but most of them are a few patterns in disguise. Once you can name the pattern, "two pointers", "a dictionary of counts", "a running total", the solution is usually ten lines. The same patterns are everyday tools at work, for log analysis, deduplication and time series. This lesson teaches them on the trail club's data, and each one replaces a slow, obvious answer with a fast one you can explain.
What you'll be able to do
- Recognize the handful of patterns behind most coding-interview problems
- Use two pointers and fast and slow pointers on sorted lists and linked lists
- Count with dictionaries and sets to replace nested loops
- Answer range questions with prefix sums and sliding windows
- Turn a brute-force O(n²) answer into an O(n) one, and show it's faster
What it covers
- Two pointers
- Counting with a dictionary
- The most common word
- Prefix sums: totals over any range
- Sliding windows and running bests
- Talking through a problem
- Worked example: brute force against the pattern
Two pointers
When data is sorted, two positions moving toward each other can replace a nested loop. Does any pair of gear prices add up to exactly a budget? Start with the cheapest and the dearest. If the pair is too expensive, the dearest item can't be in any answer, so move that pointer left; if too cheap, move the other one right. Each step throws away one item for good, so it's one pass, O(n), instead of trying every pair, O(n²):
def pair_with_sum(prices, budget): prices = sorted(prices) lo, hi = 0, len(prices) - 1 while lo < hi: total = prices[lo] + prices[hi] if total == budget: return prices[lo], prices[hi] if total > budget: hi -= 1 else: lo += 1 return None print(pair_with_sum([45, 12, 170, 39, 88, 61], 100)) print(pair_with_sum([45, 12, 170, 39, 88, 61], 1000))
Fast and slow pointers are the linked-list version. Move one pointer two steps at a time and another one step. When the fast one reaches the end, the slow one is in the middle. And if the list loops back on itself, the fast one laps the slow one and they meet, which detects a cycle using no extra memory (Floyd's method):
class Node: def __init__(self, name, next=None): self.name, self.next = name, next def middle(head): slow = fast = head while fast and fast.next: slow, fast = slow.next, fast.next.next return slow.name def has_cycle(head): slow = fast = head while fast and fast.next: slow, fast = slow.next, fast.next.next if slow is fast: return True return False stops = Node("trailhead", Node("bridge", Node("falls", Node("ridge", Node("summit"))))) print(middle(stops), has_cycle(stops)) stops.next.next.next.next.next = stops.next # summit leads back to the bridge: a loop print(has_cycle(stops))
pair_with_sum sort the prices first?Show the answer
So moving a pointer reliably makes the total bigger or smaller, which lets each step rule an item out
The method relies on order: moving the left pointer right can only raise the total, and moving the right one left can only lower it. Sorting costs O(n log n), still far less than trying every pair.
Counting with a dictionary
Many problems ask "how many of each?" or "have I seen this before?" A dictionary of counts, or a set, answers in one pass where a nested loop would compare everything with everything. collections.Counter is a dictionary made for counting, and a dictionary whose key is chosen cleverly can group things that belong together:
from collections import Counter, defaultdict def first_unique(text): counts = Counter(text) for ch in text: if counts[ch] == 1: return ch return None def group_anagrams(words): groups = defaultdict(list) for w in words: groups["".join(sorted(w))].append(w) # anagrams share their sorted letters return list(groups.values()) print(first_unique("swallow")) print(group_anagrams(["pots", "stop", "tops", "trail", "trial", "lake"]))
The trick in group_anagrams is the key: anagrams have the same letters, so sorting the letters gives each group one key. Choosing a key that makes "the same" things equal is a pattern of its own, and it works for many other kinds of "group these", such as emails by domain or log lines by error message with the numbers taken out.
The most common word
Write most_common(words): the word that appears most often, ignoring case. If two tie, return the one that comes first alphabetically. Use Counter, not a nested loop.
from collections import Counter def most_common(words): return words[0] print(most_common(["Tent", "stove", "tent", "Stove", "map", "tent"]))
Prefix sums: totals over any range
The trail club has a counter at the trailhead that records hikers each day. "How many came between day 30 and day 90?" Adding up the range every time costs O(n) per question. Instead, build the running totals once: prefix[i] is the total of the first i days. Then any range total is one subtraction, prefix[b] - prefix[a].
The same idea answers a harder question in one pass: how many stretches of consecutive days add up to exactly k? A stretch from a to b sums to k exactly when prefix[b] - prefix[a] == k, so as you walk, count how often each running total has appeared, and look up total - k:
from collections import Counter from itertools import accumulate daily = [3, 1, 4, 1, 5, 9, 2, 6] prefix = [0] + list(accumulate(daily)) print("days 2 to 5:", prefix[6] - prefix[2], "=", sum(daily[2:6])) def stretches_summing_to(values, k): seen = Counter({0: 1}) # the empty start total = found = 0 for v in values: total += v found += seen[total - k] seen[total] += 1 return found print("stretches adding to 10:", stretches_summing_to(daily, 10))
from collections import Counter def stretches_summing_to(values, k): seen, total, found = Counter({0: 1}), 0, 0 for v in values: total += v found += seen[total - k] seen[total] += 1 return found print(stretches_summing_to([1, 2, 3], 3), stretches_summing_to([2, 2, 2], 4))
Show the answer
2 2
In [1, 2, 3], the stretches [1, 2] and [3] both add up to 3. In [2, 2, 2], the first two and the last two each add up to 4. The {0: 1} start is what counts stretches beginning at the first value.
Most problems give themselves away: sorted input, a count, a range, or a fixed-size window.
Sliding windows and running bests
A sliding window is a range that moves along the data. For a fixed size, like "the busiest 7 days", don't re-add all 7 days at each step: add the day that enters and subtract the day that leaves. For a variable size, grow the window on the right and shrink it from the left whenever it breaks a rule. The longest stretch of trail markers without a repeated color, for example, keeps a set of what's inside the window.
A running best is the simplest one-pass pattern: keep the best answer so far, plus whatever you need to update it. The largest rise between an earlier and a later reading needs only the lowest reading so far:
def busiest_week(daily, k=7): window = sum(daily[:k]) best = window for i in range(k, len(daily)): window += daily[i] - daily[i - k] # one day in, one day out best = max(best, window) return best def longest_without_repeat(colors): inside, start, best = set(), 0, 0 for end, c in enumerate(colors): while c in inside: # shrink until the repeat is gone inside.remove(colors[start]) start += 1 inside.add(c) best = max(best, end - start + 1) return best def largest_rise(readings): low, best = readings[0], 0 for r in readings[1:]: best = max(best, r - low) low = min(low, r) return best daily = [12, 30, 8, 41, 22, 19, 35, 50, 7, 14] print(busiest_week(daily), longest_without_repeat("RGBRYGBB"), largest_rise([40, 31, 35, 28, 52, 47]))
The last one is a classic interview question, usually told about prices. Here it's the counter's readings: the largest rise between an earlier reading and a later one is 52 - 28 = 24. Keeping only the lowest value so far makes the obvious "compare every pair" answer unnecessary.
Talking through a problem
In an interview, how you get to the answer counts as much as the answer. A steady routine also stops you freezing:
- Restate the problem in your own words, and ask about the input: can it be empty, sorted, negative, huge?
- Work a small example by hand, out loud. It often shows the pattern before any code does.
- Say the brute force and its cost. "Check every pair: O(n²)." It proves you understand the problem, and it's a working answer if time runs out.
- Name what's slow and the pattern that removes it. "The inner loop searches for a partner; a set finds it in one step." Then write the code.
- Test it on your example and the edges: an empty list, one item, duplicates, all the same value.
None of this is only for interviews. Restating, trying a small case and saying the cost out loud are how good engineers explain a design to their team too.
Worked example: brute force against the pattern
Two answers to "how many stretches add up to k?": the obvious one checks every start and end, and the prefix-sum one walks once. Time them on the same data.
import random import time from collections import Counter def brute_force(values, k): count = 0 for a in range(len(values)): total = 0 for b in range(a, len(values)): total += values[b] if total == k: count += 1 return count def one_pass(values, k): seen, total, found = Counter({0: 1}), 0, 0 for v in values: total += v found += seen[total - k] seen[total] += 1 return found values = random.Random(4).choices(range(-5, 10), k=1500) for f in (brute_force, one_pass): start = time.perf_counter() answer = f(values, 12) print(f"{f.__name__:>11}: {answer} stretches in {time.perf_counter() - start:.3f}s")
Both give the same answer, but the brute force does about n²/2 additions (over a million here), and the one-pass version does n dictionary lookups. Double the data and the brute force takes four times as long; the pattern takes twice as long. In an interview, say the brute force out loud first (it shows you understand the problem), state its cost, then name the pattern that removes the inner loop. Note that the values include negatives, which is exactly when a sliding window can't be used for "sums to k" (a window can't know whether to grow or shrink), and prefix sums can.
Practice what you just read
- 6 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Fast and slow pointers start at the head of a linked list of 7 nodes. Where is the slow one when the fast one can't move on?