Topic 05

Sorting in the Real World

Algorithms

No mainstream language ships a textbook sort. CPython sorts with Timsort, Java ships two different sorts, and C++, Go and Rust ship tuned hybrids. Each is a merge sort or a quicksort that hands small pieces to a simple sort and guards against its own worst case.

Past the library, two sorting problems reach every system sooner or later: data bigger than memory, and lists that are already sorted and need combining. The second is what Lantern does for every query with two terms, and it turns out to need no sorting at all.

Timsort: Sorting the Data People Have

Real data is rarely random. It arrives as ascending or descending runs: records appended in time order, a log, a sorted list with a few new items at the end. Timsort, written by Tim Peters for CPython in 2002, is built around that fact. It scans the input for natural runs and reverses descending ones in place. It extends any run shorter than a minimum length, between 32 and 64 items, with binary insertion sort, the small-range sort of The Simple Sorts. Then it merges runs of similar size, and when one run keeps winning a long stretch of comparisons it gallops: it jumps ahead 1, 2, 4, 8 items and halves back, instead of comparing one item at a time.

Natural runs and the merges built over them
the input, as it arrived: four natural runs4915223041ascending: 63527181262descending, reversed: 6814203347ascending: 51510ascending: 3run A (6)run B (6)run C (5)run D (3)merge A + B (12)merge C + D (8)final merge (20)toy sizes: CPython first extends any run shorter than its minimum, 32 to 64 items

The payoff is that order in the input becomes speed. Measured on Python 3.15, sorting a million sorted items took 999,999 comparisons, and so did a million reversed items: one pass to find a single run. A million random items took 18.6 million. A sorted million with 100 random items appended took 1,003,243. Since CPython 3.11, the order in which runs are merged follows the Powersort rule of Ian Munro and Sebastian Wild, which is provably close to the cheapest possible order of merges.

The Hybrids Other Languages Ship

Java sorts objects with Timsort, adopted from Python in Java 7, because two equal objects can be told apart and the order of ties shows. It sorts primitive arrays, such as an array of ints, with a dual-pivot quicksort by Vladimir Yaroslavskiy, Jon Bentley and Joshua Bloch, because two equal ints are indistinguishable and stability is invisible. Choosing different sorts for the two cases is not an inconsistency; it follows from what the caller can observe.

GCC's C++ library implements std::sort as introsort: a quicksort that watches its own recursion depth and switches to heapsort, from Chapter 6, when the depth passes twice the base-2 logarithm of n, which guarantees n log n on any input, and that finishes by running insertion sort over ranges of up to 16 items. Go, since version 1.19, uses pattern-defeating quicksort. Rust, since version 1.81, uses driftsort for its stable sort and ipnsort for its unstable one. Every one of them hands small ranges to a simple sort, because at a dozen items the simple sort wins.

Sorting Data Bigger Than Memory

To sort 100 GB with 8 GB of memory, read a memory-sized chunk, sort it in memory, and write it back to disk as a sorted run. Thirteen chunks make 13 sorted runs. Then merge all 13 in a single pass, with a heap holding the current head of each run: take the smallest head, write it out, and refill that slot from its run, as the heap topic of Chapter 6 describes.

An external merge sort in two passes
sorting 100 GB with 8 GB of memoryinput file, unsorted: 13 chunks of up to 8 GBpass 1: read a chunk, sort it in memory, write it back as a sorted runheap of 13 headssmallest out, next inoutput file, sortedpass 2: one 13-way mergethe data crosses the disk twice, both times in order

The data crosses the disk twice, once in each pass, and both times sequentially, which is the cheap way to use any disk. At disk speed, the reading and writing are nearly all of the cost; the comparisons hide underneath it. Databases do the same when a sort outgrows the working memory it is allowed. PostgreSQL, for one, switches to an external merge sort on disk when a sort exceeds its memory budget.

Lantern's AND: Merging Postings Lists

Each term in Lantern's index has a postings list, the sorted list of record ids that contain the term. A query with two terms asks for the records in both lists: the intersection of two sorted lists. Keep one pointer in each list. If the two ids are equal, keep the id and advance both pointers. Otherwise advance whichever pointer holds the smaller id, since that id cannot be in the other list.

Intersecting two sorted postings lists
guin10485761503935181329245233615316391585191173112377663881utopia104857611276181503935236428032493415071419585191187432399763724two sorted postings lists: 8 and 9 record idsone pointer per list; advance the smaller id; keep an id when both agree12 steps for 17 ids, 3 matches, nothing sorted, no extra memory

That is merge sort's merge, keeping only the matches. It costs at most m plus n steps for lists of m and n ids, it sorts nothing, and it needs no memory beyond the two pointers and the output. An OR is the same walk keeping every id once, and a NOT walks one list while skipping the ids of the other. The sorting was paid for once, when the index was built.

When One List Is Tiny

A rare term with 50 postings joined to a common term with 400,000 costs up to 400,050 steps by walking both lists. Almost all of that is walking the long list past ids that cannot match. Looking each of the 50 ids up in the long list by binary search costs about 50 times 19, about 950 comparisons, because the base-2 logarithm of 400,000 is about 18.6. Measured on Python 3.15 with lists of those sizes, the walk took 394,387 steps and the 50 binary searches 944 comparisons.

Galloping comes close to the better of the two whatever the sizes: from the current position in the long list, probe 1, 2, 4, 8 places ahead until passing the target, then halve back. When the lists are similar in size it behaves like the walk, and when one is tiny it behaves like binary search. This is why a search engine intersects starting from its shortest list, so that the cost follows the rare term, not the common one.

What Sorting Costs in Lantern

Lantern never sorts at query time. Postings are sorted once, in the 02:00 rebuild, so at the Saturday peak of 300 searches a second each two-term query pays only a merge walk or a gallop. The 10 results on a page are the top 10 by score, kept in a heap of ten as Chapter 6 showed, not a sorted list of every match.

The costs that remain are the ones a sort hides. A key recomputed on every comparison instead of once per item multiplies the cost by the logarithm of n. And titles sorted by code point instead of by collation come out wrong. Measured on Python 3.15, sorting apple, Zebra, Émile and zebra gives Zebra, apple, zebra, Émile: every capital before every lowercase letter, and the accented É after z. Human alphabetical order is locale collation, a different and costlier key built on top of the code points of Chapter 2.

Misconceptions
  • "Python's sort is quicksort, like C++'s." CPython's list sort has been Timsort for over two decades: stable, adaptive, n minus one comparisons on sorted input, with a merge buffer rather than an in-place partition.
  • "Sorting data bigger than RAM needs a cluster." An external merge sort on one machine sorts many times its memory in two sequential passes over the disk. A distributed sort is for data that does not fit one machine's disk or time budget.
  • "To show the top ten results, sort the results." A full sort of 400,000 matches is about 7 million comparisons to throw away all but ten. A heap of ten keeps the best ten in one pass.
  • "Intersecting two lists needs a nested loop or a set." The nested loop is m times n comparisons, and a set means building a hash table per query. Two sorted lists intersect in m plus n steps with no extra memory.
  • "The sorted function puts strings in alphabetical order." It orders by code point: every capital before every lowercase letter, accented letters after z, and scripts in the order of their Unicode blocks. Human alphabetical order is locale collation, a different key.
Why It Matters
  • Use the standard library's sort and hand it a key. It is adaptive, stable where promised, and guarded against its own worst case.
  • Keep lists that are queried together sorted by the same key at write time. Intersection, union and difference then cost one walk.
  • Intersect starting from the shortest list and gallop into the longest. The cost then follows the rare term, not the common one.
  • Plan any sort over a data set's full size as an external sort: memory-sized runs, then one merge. The price is two sequential passes over the disk, not a bigger machine.
RelatedSort-merge join a database join is the same two-pointer walk; the engine stays with PostgreSQL Deep DiveSkip pointers stored inside long postings lists, they make the gallop cheaper stillk-way merge the merge pass of an external sort is a heap problem (Chapter 6)

Knowledge Check

How many comparisons does CPython's sort make on a million items that are already in reverse order?

  • About 20 million, the same as on random input
  • 999,999: one descending run, found and reversed
  • About 500 billion, because reversed input is the worst case
  • About 10 million, half of the random-input count

Why does Java sort arrays of objects and arrays of ints with two different algorithms?

  • Objects are larger, so they need a sort that moves them fewer times
  • Quicksort cannot compare objects, because they have no natural order
  • Equal objects can be told apart, so stability shows; equal ints cannot
  • Timsort is newer, and ints still use the older sort for compatibility

A 50-id postings list is intersected with a 400,000-id list. Which approach costs least?

  • Walking both lists with two pointers, about 400,050 steps
  • Sorting both lists together and scanning for adjacent duplicates
  • A nested loop comparing each short-list id with every long-list id
  • Binary search or galloping into the long list for each of the 50

How many sorted runs and passes does an external sort of 100 GB need with 8 GB of memory?

  • 13 runs, then one 13-way merge: two passes over the data
  • 8 runs, then one merge, because memory holds 8 GB at a time
  • 100 runs of 1 GB each, merged in pairs over seven passes
  • One run, because the operating system pages the rest of the data

Why does Lantern's first page of 10 results need no full sort of the matches?

  • The index stores every match in score order, so the page is a slice
  • A heap of ten keeps the best ten in one pass over the matches
  • Only the first 10 matches are scored; the rest are never read
  • The query intersection returns matches already sorted by score

Why does sorting titles with the default string order put "Zebra" before "apple"?

  • The sort is unstable, so words with different case come out in any order
  • Python sorts strings by length first, and "Zebra" is the shorter word
  • Titles starting with capital letters are sorted as proper names first
  • Strings compare by code point, and capitals precede lowercase

You got correct