Heaps: keep the next best candidate
“A stream is too large to sort after every update. Keep its three largest observations. What does the smallest retained value tell you about a new arrival?”
With retained [4,7,7], discard 2 but admit 9 and remove 4. Then ask why a cheapest tentative route can still have an obsolete heap entry.
The coding-practice chapter will apply this tool to complete problems. Here, focus on the mechanism and trace how its state changes.
Working example: Return the K largest observations (duplicates count), then compute shortest distances with nonnegative edge weights. “Largest” is different from “most frequent”: a value seen once can be largest.
The idea: A heap orders the next candidate, not every element. Dijkstra skips stale entries whose distance is no longer current.
First, what does a heap actually guarantee?
A Python heapq is a min-heap: its smallest value is always at position 0. The rest of its internal list is not sorted. heappush and heappop each take O(log k) work for k retained entries. To retain the three largest arrivals, keep the smallest winner at the root, ready to evict when a larger number arrives.
from heapq import heappush, heapreplace
winners = []
for value in [4, 7, 7, 2, 9]:
if len(winners) < 3:
heappush(winners, value)
elif value > winners[0]:
heapreplace(winners, value)
print(sorted(winners, reverse=True)) # [9, 7, 7]
Before the 9 arrives, the retained values are [4, 7, 7]; arrival 2 loses to the smallest winner, 4. When 9 arrives it replaces 4. The diagram below shows heap repair, not a sorted array. Another use of a heap is Dijkstra's shortest paths: it selects the cheapest tentative route, and an older, more expensive entry can stay in the heap after an improved route appears.
Check the mechanism
Predict each expected result, then trace the state that produces it. Explain the boundary case before opening the reference.
Cost: Top k largest stream: O(n log(k+1)) updates, O(k) retained space. Lazy-heap Dijkstra: O(V + E log(E+1)) time, O(V+E) space.
01 · Try this input
- Input / starting state
- Top 2, arrivals
[4, 1, 7, 3] - Expected result
[7, 4]
Boundary: Discard smaller arrivals.
02 · Try this input
- Input / starting state
- Top 2, arrivals
[5, 5, 4] - Expected result
[5, 5]
Boundary: Repeated observations count separately.
03 · Try this input
- Input / starting state
- Top 0, arrivals
[4, 7] - Expected result
[]
Boundary: Store nothing.
04 · Try this input
- Input / starting state
- Top 5, arrivals
[4, 7] - Expected result
[7, 4]
Boundary: Return only what arrived.
05 · Try this input
- Input / starting state
- Routes
A→B:10, A→C:1, C→B:1 - Expected result
- Best A→B is 2
Boundary: The old cost-10 heap entry is stale.
06 · Try this input
- Input / starting state
- Source A, isolated D
- Expected result
- Distance to D is infinity
Boundary: Do not invent a route.
07 · Try this input
- Input / starting state
- Edge cost −1
- Expected result
- Reject for Dijkstra
Boundary: Its nonnegative-weight assumption fails.
For the largest-observations stream, each of n arrivals costs at most O(log(k+1)); retained state is O(k), and presenting a descending snapshot costs O(k log(k+1)). Counting n samples and retaining the k most frequent of u distinct values is a different task with a bound such as O(n + u log(k+1)). Keep frequency and magnitude problems separate. For Dijkstra, V is vertices and E edges; a heap may contain stale tentative routes, so storage can reach O(V+E), and heap operations cost up to O(log(E+1)).
Pass before moving on: Explain what the root guarantees and why FIFO BFS cannot replace Dijkstra for weighted edges.
Your K is almost the number of observations retained for a finite batch. Compare sorting with a heap, including the cost of returning a sorted snapshot.
After attempting: reference and explanation
Compare the largest-observations implementation (download file, source below) with shortest_paths in algorithms.py (download file, source below). top_k_frequent solves a different ranking question. Use pattern notes for the invariant and contracts for complexity edge cases. Reimplement tomorrow without copying.
"""Keep the k largest observed integers, including duplicate observations."""
import heapq
class TopK:
def __init__(self, k):
if type(k) is not int or k < 0:
raise ValueError("k must be a nonnegative integer")
self.k = k
self._top_k = []
def add(self, value):
if type(value) is not int:
raise ValueError("integer observations required")
if self.k == 0:
return
if len(self._top_k) < self.k:
heapq.heappush(self._top_k, value)
elif value > self._top_k[0]:
heapq.heapreplace(self._top_k, value)
def largest(self):
return sorted(self._top_k, reverse=True)
"""Reference solutions. Try the exercises in README.md before opening this file."""
from collections import Counter, OrderedDict, deque
from heapq import heappop, heappush, nlargest
def two_sum(nums, target):
visited = {}
for j, value in enumerate(nums):
complement = target - value
if complement in visited:
return visited[complement], j
visited.setdefault(value, j)
return None
def longest_unique(text):
left = best = 0
last_seen = {}
for right, char in enumerate(text):
left = max(left, last_seen.get(char, -1) + 1)
best = max(best, right - left + 1)
last_seen[char] = right
return best
def subarray_sum(nums, target):
prefix_counts = {0: 1}
prefix = result = 0
for value in nums:
prefix += value
result += prefix_counts.get(prefix - target, 0)
prefix_counts[prefix] = prefix_counts.get(prefix, 0) + 1
return result
def lower_bound(nums, target):
lo, hi = 0, len(nums)
while lo < hi:
mid = lo + (hi - lo) // 2
if nums[mid] < target:
lo = mid + 1
else:
hi = mid
return lo
def merge_intervals(intervals):
"""Closed intervals; touching endpoints merge. Does not mutate input."""
out = []
for start, end in sorted(intervals):
if start > end:
raise ValueError('reversed interval')
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
def top_k_frequent(nums, k):
if k < 0:
raise ValueError('k must be nonnegative')
counts = Counter(nums)
# Higher count wins; smaller value breaks ties deterministically.
return nlargest(k, counts, key=lambda value: (counts[value], -value))
def islands(grid):
"""Rectangular list of lists of '0'/'1'; input remains unchanged."""
if not grid:
return 0
cols = len(grid[0])
if any(len(row) != cols or any(x not in ('0', '1') for x in row) for row in grid):
raise ValueError('expected rectangular binary grid')
seen = set()
count = 0
for r, row in enumerate(grid):
for c, value in enumerate(row):
if value != '1' or (r, c) in seen:
continue
count += 1
seen.add((r, c))
queue = deque([(r, c)])
while queue:
x, y = queue.popleft()
for a, b in ((x-1, y), (x+1, y), (x, y-1), (x, y+1)):
if 0 <= a < len(grid) and 0 <= b < cols and grid[a][b] == '1' and (a, b) not in seen:
seen.add((a, b)) # Mark on enqueue, not dequeue.
queue.append((a, b))
return count
def course_order(n, prerequisites):
"""Each pair is (course, prerequisite); [] means a cycle or no courses."""
if n < 0:
raise ValueError('negative course count')
adjacency = [set() for _ in range(n)]
degree = [0] * n
for course, prerequisite in prerequisites:
if not 0 <= course < n or not 0 <= prerequisite < n:
raise ValueError('course outside graph')
if course not in adjacency[prerequisite]:
adjacency[prerequisite].add(course)
degree[course] += 1
queue = deque(i for i, d in enumerate(degree) if d == 0)
order = []
while queue:
node = queue.popleft()
order.append(node)
for neighbor in adjacency[node]:
degree[neighbor] -= 1
if degree[neighbor] == 0:
queue.append(neighbor)
return order if len(order) == n else []
def shortest_paths(n, edges, source):
"""Directed nonnegative weighted graph; unreachable distances are infinity."""
if not 0 <= source < n:
raise ValueError('invalid source')
graph = [[] for _ in range(n)]
for u, v, weight in edges:
if not 0 <= u < n or not 0 <= v < n or weight < 0:
raise ValueError('invalid edge')
graph[u].append((v, weight))
distance = [float('inf')] * n
distance[source] = 0
heap = [(0, source)]
while heap:
cost, node = heappop(heap)
if cost != distance[node]:
continue
for neighbor, weight in graph[node]:
candidate = cost + weight
if candidate < distance[neighbor]:
distance[neighbor] = candidate
heappush(heap, (candidate, neighbor))
return distance
class LRU:
"""Capacity in entries; None denotes a miss. Not thread-safe."""
def __init__(self, capacity):
if capacity < 0:
raise ValueError('negative capacity')
self.capacity = capacity
self.data = OrderedDict()
def get(self, key):
if key not in self.data:
return None
self.data.move_to_end(key)
return self.data[key]
def put(self, key, value):
if self.capacity == 0:
return
self.data[key] = value
self.data.move_to_end(key)
if len(self.data) > self.capacity:
self.data.popitem(last=False)
def min_coins(coins, amount):
if amount < 0 or any(c <= 0 for c in coins):
raise ValueError('nonnegative amount and positive coins required')
dp = [0] + [amount + 1] * amount
for total in range(1, amount + 1):
for coin in coins:
if coin <= total:
dp[total] = min(dp[total], dp[total-coin] + 1)
return -1 if dp[amount] > amount else dp[amount]
def daily_temperatures(temperatures):
answer = [0] * len(temperatures)
stack = []
for i, temp in enumerate(temperatures):
while stack and temperatures[stack[-1]] < temp:
previous = stack.pop()
answer[previous] = i - previous
stack.append(i)
return answer
def word_exists(board, word):
"""4-neighbor word search; do not reuse a cell or mutate the board."""
if not word:
return True
if not board:
return False
cols = len(board[0])
if any(len(row) != cols for row in board):
raise ValueError('ragged board')
def visit(r, c, index, used):
if not (0 <= r < len(board) and 0 <= c < cols) or (r, c) in used or board[r][c] != word[index]:
return False
if index == len(word) - 1:
return True
used.add((r, c))
found = any(visit(a, b, index+1, used) for a, b in ((r+1,c),(r-1,c),(r,c+1),(r,c-1)))
used.remove((r, c))
return found
return any(visit(r, c, 0, set()) for r in range(len(board)) for c in range(cols))
class Trie:
END = object()
def __init__(self):
self.root = {}
def insert(self, word):
node = self.root
for char in word:
node = node.setdefault(char, {})
node[self.END] = True
def contains(self, word):
node = self.root
for char in word:
if char not in node:
return False
node = node[char]
return self.END in node