Tie-Breaking in Tuple Heaps

Real heap entries carry data: a priority plus the job or row it belongs to. The usual encoding is a tuple, (priority, payload), which works because tuples compare position by position. Python compares the first elements, and only when they are equal does it move on to the second. That second comparison is where tuple heaps break.

When the payload gets compared

Push 2 jobs that share a priority and carry dict payloads. The first push compares nothing. The second push sifts the new entry up toward its parent, the priorities tie at 2, and Python moves on to compare the 2 dicts with <.

import heapq

jobs = []
heapq.heappush(jobs, (2, {"name": "load_orders"}))
heapq.heappush(jobs, (2, {"name": "load_events"}))

The crash depends on the data. A test suite with distinct priorities never reaches the payload comparison, and the job fails in production on the first day 2 entries share a priority. Equal payloads hide it further: if both dicts held the same keys and values, the tuples would compare equal at that position and no error would appear. Dicts fail this way, and so do objects without __lt__ and a None sitting next to a number.

The fix: a sequence number in the middle

Put a unique, comparable value between the priority and the payload. A counter from itertools.count() is the standard choice. Priorities decide first, the counter decides every tie, and since no 2 entries share a counter value, the comparison never reaches the payload.

import heapq
import itertools

seq = itertools.count()
jobs = []
for priority, name in [(2, "load_orders"), (1, "load_users"), (2, "load_events")]:
    heapq.heappush(jobs, (priority, next(seq), {"name": name}))

while jobs:
    priority, _, job = heapq.heappop(jobs)
    print(priority, job["name"])

The counter also settles the order of ties. Entries with equal priority leave in the order they arrived, first in, first out, which is what a job queue needs. The heapq documentation puts the count between the priority and the task in its own priority queue example.

A record type that compares chosen fields

When entries grow past 3 fields, the documentation's other suggestion is a dataclass. With order=True, instances compare as tuples of their fields, and field(compare=False) drops the payload from that tuple.

import heapq
import itertools
from dataclasses import dataclass, field

@dataclass(order=True)
class Job:
    priority: int
    seq: int
    payload: dict = field(compare=False)

counter = itertools.count()
jobs = []
heapq.heappush(jobs, Job(2, next(counter), {"name": "load_orders"}))
heapq.heappush(jobs, Job(2, next(counter), {"name": "load_events"}))
print(heapq.heappop(jobs).payload["name"])

Ties broken in a required order

Sometimes the tie-break is part of the spec: return the most frequent event types with the highest count first, breaking ties alphabetically. Negate the count and keep the name as the second element. In a min-heap, (-5, 'view') comes out before (-3, 'click'), and (-3, 'click') before (-3, 'share'), because 'click' sorts before 'share'.

import heapq

counts = {"view": 5, "click": 3, "share": 3, "like": 1}
heap = []
for word, c in counts.items():
    heapq.heappush(heap, (-c, word))
print(heap)

top = []
for _ in range(3):
    neg_count, word = heapq.heappop(heap)
    top.append((word, -neg_count))
print(top)
[(-5, 'view'), (-3, 'click'), (-3, 'share'), (-1, 'like')]
[('view', 5), ('click', 3), ('share', 3)]

The pushes produce [(-5, 'view'), (-3, 'click'), (-3, 'share'), (-1, 'like')], and the 3 pops return view, then click, then share. Negation flips the count order while the string keeps its natural order, which is the exact combination the spec asks for.

Tie direction flips in a capped heap

The heap above held every key. To keep only k, the root must be the weakest kept entry, and under this spec the weakest is the lowest count with the alphabetically last name. Plain (count, word) tuples in a min-heap order counts correctly and names backwards. With k = 2:

import heapq

counts = {"view": 5, "click": 3, "share": 3, "like": 1}
heap = []
for word, c in counts.items():
    entry = (c, word)
    if len(heap) < 2:
        heapq.heappush(heap, entry)
    elif entry > heap[0]:
        heapq.heapreplace(heap, entry)
print(sorted(heap, reverse=True))

When share arrives, (3, 'share') > (3, 'click') is true, so click is evicted even though it wins the tie. A string has no negative, so there is no one-character fix. Hand the full ordering to nsmallest as a key instead; the documentation defines it as equivalent to sorted(iterable, key=key)[:n], ties included:

import heapq

counts = {"view": 5, "click": 3, "share": 3, "like": 1}
top = heapq.nsmallest(2, counts.items(), key=lambda kv: (-kv[1], kv[0]))
print(top)
Do
  • Use (priority, next(counter), payload) for any heap of records
  • Write the tie-break rule down before writing the heap code
  • Use nsmallest with a key tuple when ties need a required order
  • Test with 2 entries that share a priority
Avoid
  • Put dicts or plain objects right after the priority in a tuple
  • Count on payload comparison to break ties
  • Try to negate a string to reverse its order
  • Trust a capped heap of (count, word) to break ties the right way

About this lesson

This section is part of the Heap & Top-K: Beginner lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.