Topic 06

Choosing a Container

Data Structures

Most performance bugs in ordinary code come from the wrong container, not a slow algorithm: a list searched inside a loop, a dictionary asked for a range, a scan repeated to find the smallest item. The fix is a habit rather than a trick. Write down what the code asks of the data and how often, and let that choose the structure.

This topic is that habit as a decision guide, closing the chapter. It is also the question most coding interviews are really asking underneath the puzzle: given these operations, which structure makes the hot one cheap?

Start From the Operations

List what the code asks of its data. Is x present? What goes with key k? What is the smallest item? Everything between a and b? The items in the order they arrived? How many of each? Then mark which of those questions run inside a loop, and at what n.

The operation that runs most often, at the largest n, decides the structure. Every other operation only has to be tolerable. A structure chosen for the operation that runs once at start-up, while the hot one scans, is the most common way a correct program ends up slow.

Membership and Lookup

"Is it there" and "what goes with it" belong to a set or a dictionary (see Hash Tables): one probe instead of a scan. The two versions of the code look almost identical, which is why the wrong one survives review.

Deduplicating ids, two ways
unique = []
for x in ids:
    if x not in unique:     # a scan of the list so far
        unique.append(x)
seen, unique = set(), []
for x in ids:
    if x not in seen:       # one probe
        seen.add(x)
        unique.append(x)

Both loops keep the first occurrence of each id in its original order. The first checks each id against the list built so far, which is a scan; for 100,000 distinct ids that is about five billion comparisons. The second checks a set, one probe per id. On CPython 3.15, the list version took 1.8 seconds for only 20,000 ids and grows with the square of n, while the set version took under 8 milliseconds for 100,000.

Three Kinds of Order

Insertion order, sorted order and priority order are three different promises. A list, a deque and a Python dictionary keep insertion order. A sorted list maintained with binary search (Chapter 7), a balanced tree (Chapter 6) and a B-tree (Chapter 6) keep sorted order. A heap (Chapter 6) keeps priority order: it always knows its smallest item and nothing more.

Keeping the order you need is cheaper than restoring it. Finding the smallest of a million items after every insert costs a million comparisons each time with a scan, and one look with a heap, whose insert costs about 20 comparisons. On CPython 3.15, an append followed by a minimum over a million-item list took about 10 milliseconds; a heap push followed by a look at its top took under half a microsecond.

Ranges, Prefixes and Neighbours

"Between a and b", "starts with" and "the next one after x" all need keys kept in order. A hash table cannot answer any of them, because it scattered the keys on purpose. A sorted structure answers each with one search and a short walk from the place the search lands.

Python ships no sorted map. A sorted list maintained with the standard binary-search module serves while inserts are modest: each insert is one search and one block move of pointers (see Arrays and Dynamic Arrays). On CPython 3.15 an insert took about 1 microsecond into 10,000 items, about 5 into 50,000, and about 110 into a million. Past that point a tree-shaped structure is the answer: the third-party sortedcontainers package, or a database index (Chapter 6).

Duplicates and Counts

A set silently drops duplicates. A list keeps them, in order. A dictionary of counts, such as the Counter in the standard collections module, keeps how many of each. These are three different answers to "what did we see", and the right one depends on whether repetition carries meaning.

Replacing a list with a set where duplicates matter is a correctness bug, not a performance fix. Two orders for the same book, two identical log lines, two votes from the same branch: no test that uses unique data will catch the change, and production data is never unique.

The Cost of the Wrong Choice

The wrong container is invisible at a hundred items and becomes the page that times out at a million (Chapter 1). It is a memory decision too (Chapter 1). Measured on CPython 3.15, a million integers cost 8 megabytes as a packed array, about 40 as a list, about 66 as a set, and about 106 as a dictionary mapping them to a million other integers.

The table below is the chapter on one page. For membership, a list or deque scans in O(n), a set answers in O(1) on average, a heap also has to scan, a sorted list or a balanced tree takes O(log n), and a trie, the prefix tree of Chapter 6, takes time proportional to the key's length. For the smallest item, a heap and a sorted list answer at once, a balanced tree walks down its left edge in O(log n), a trie walks down its first branch, and the rest scan.

Adding an item is O(1) at the end of a list and at either end of a deque, O(1) on average for a set, O(log n) for a heap or a balanced tree, proportional to the key's length for a trie, and a search plus an O(n) shift for a sorted list. Ranges and "the next key" need a sorted list or a balanced tree, which find the start in O(log n) and then walk the k items in the answer; a trie answers prefixes only, and a set or a heap cannot answer them at all.

Operationlistdequeset / dictheapsorted listbalanced treetrie
is x presentO(n)O(n)O(1) avgO(n)O(log n)O(log n)O(key length)
smallest itemO(n)O(n)O(n)O(1)O(1)O(log n)O(key length)
add an itemO(1) at endO(1) either endO(1) avgO(log n)O(n) shiftO(log n)O(key length)
range, prefix or nextO(n)O(n)nonoO(log n) + kO(log n) + kprefix only
From the hot operation to the container
Is it there, or what goes with this key→set or dict
Take from the front, add at the back→deque
Always the smallest, or the next deadline→heap
Between a and b, or the next key after x→sorted list or tree
Everything that starts with a prefix→trie
A sequence read by position→list
Insertion Order vs Sorted Order

A Python dictionary remembers the order keys were inserted, a language guarantee since Python 3.7, and nothing more: insert 3, 1, 2 and it iterates 3, 1, 2. Choose it when "the order they arrived" is the order you need.

A sorted structure, such as a sorted list, a balanced tree or a B-tree, keeps keys in key order and can answer ranges and "the next key". Choose it when the question compares keys to each other.

A set keeps no order at all, and for strings its iteration order changes from run to run (see Hash Functions and Their Attacks). Choose by the order the question needs, never by the order a test happened to print.

Misconceptions
  • "A dictionary is ordered, so it works as a sorted map." It keeps insertion order. A range or "next key" query still needs a sorted structure.
  • "The list is the default, and other containers are optimizations for later." The list is right for sequences and positions. Membership on a list is a scan, and "later" is usually the outage.
  • "A set is for mathematical set operations." Its main job in production code is O(1) membership and deduplication.
  • "A sorted list is too slow to maintain." Each insert is one binary search and one block move of pointers, a few microseconds for tens of thousands of items. It becomes the wrong choice at millions of items with frequent inserts, where each insert took about 110 microseconds on CPython 3.15.
  • "A more specialized container is always faster." For ten items a list scan beats a heap or a tree on constants (Chapter 4). The specialized structure pays off only when n is large or the operation is hot.
Why It Matters
  • Write down the operations and their frequencies before choosing a structure. The hottest operation at the largest n decides.
  • Replace every membership test on a list inside a loop with a set built once. One O(n) build replaces n scans of O(n).
  • Keep a second structure beside the first when two operations both matter, such as a dictionary beside a list or a set beside a queue, and pay its memory knowingly (Chapter 1). Each structure makes a different question cheap.
  • Choose by the order the question needs: insertion, sorted or priority. Never choose by the order a test happened to print.
RelatedDatabase indexes the same decision made on disk (Chapter 6)Probabilistic structures when an approximate answer is acceptable and memory is tight (Chapter 9)The time-space trade every second structure beside the first is an instance (Chapter 1)

Knowledge Check

A service keeps a queue of jobs, must take the oldest job next, and must also answer "is job X already queued?" thousands of times a second. Which containers fit?

  • A single list, appending jobs and scanning it for membership
  • A single set, which answers membership and remembers arrival order
  • A deque for the order, with a set beside it for membership
  • A heap keyed by arrival time, scanned whenever membership is asked

A script removes duplicate ids from a list of 100,000 by checking a result list before each append. The same job is rewritten with a set. What changes?

  • Nothing much, since both versions visit each id exactly once
  • About five billion comparisons become about 100,000 probes
  • The set version is faster but loses the original order of the ids
  • The set version uses less memory, which makes it faster

Why can a Python dictionary not answer "all keys between 1970 and 1980" efficiently?

  • Dictionaries forbid integer keys, so years must be strings
  • The query needs sorted keys, and a dictionary sorts only on demand
  • Ranges need locks, and dictionaries are not thread-safe
  • Hashing scatters keys, so neighbours in value sit far apart

Roughly how much memory do a million integers take in a packed array, a list and a dictionary on CPython 3.15?

  • About 8 MB in each, since an integer is 8 bytes
  • About 8, 16 and 24 MB: one, two and three words each
  • About 8, 40 and 106 MB, the last mapping them to other integers
  • About 40, 40 and 40 MB, since all three store Python objects

A team replaces a list of order lines with a set "for speed". Which bug can follow?

  • Iteration becomes slower, because sets are unordered
  • Two identical lines for the same book collapse into one
  • Lookups become O(n), because sets are scanned like lists
  • The program crashes, because sets cannot hold strings

You got correct