Index Ai Academy
Phase 7 · Software engineering · Data structures & algorithms

Patterns for coding problems

Two pointers, counting with dictionaries, prefix sums and sliding windows: O(n) answers to classic problems.

About 45 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Two pointers
  2. Counting with a dictionary
  3. The most common word
  4. Prefix sums: totals over any range
  5. Sliding windows and running bests
  6. Talking through a problem
  7. 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))
It runs right here in your browser, with nothing to install.

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))
Quick check
Why does pair_with_sum sort the prices first?
  1. So moving a pointer reliably makes the total bigger or smaller, which lets each step rule an item out
  2. To remove duplicates
  3. Because Python can't add unsorted numbers
  4. Sorting makes it O(1)
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.

Drill

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"]))
In the lesson, your code is checked as you write it.

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))
Quick check
What does this print?
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))
  1. 2 1
  2. 2 2
  3. 3 2
  4. 1 1
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.

Try it now

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:

  1. Restate the problem in your own words, and ask about the input: can it be empty, sorted, negative, huge?
  2. Work a small example by hand, out loud. It often shows the pattern before any code does.
  3. 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.
  4. 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.
  5. 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

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")
In the lesson you can change this and run it.

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

Create a free account

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?

  1. The 4th node, the middle
  2. The 7th node
  3. The 2nd node
  4. It never stops

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson