Counting

Counting instead of timing

A stopwatch measures the laptop it runs on. A counter measures the algorithm. Every number on this site comes from an array that increments a tally each time it is read, written, compared or swapped — which makes the counts exact, reproducible to the last digit, and identical on every machine that has ever built this page.

There are two ways to find out what an algorithm costs: run it and look at a clock, or run it and count.

The first is what almost everybody does, and it produces a number that describes the machine it ran on. Change the machine and the number changes. Change the compiler, the memory pressure, whether a background process woke up during the third trial, whether the CPU decided to boost its clock — the number changes. Run the identical benchmark twice on the identical machine and the number changes, which is why anyone doing this carefully reports a median of many trials and a confidence interval, and why the results are hard to compare against anybody else’s.

The second produces a number that describes the algorithm. Selection sort performs exactly 130,816 comparisons on 512 elements. Not approximately, not on average, not on this machine: exactly, always, on every input of that size, forever. That number is n(n1)2\frac{n(n-1)}{2} with n=512n = 512, and it will be the same number today and when the hardware this page was built on is landfill.

Every measurement on this site is of the second kind.

What a stopwatch is measuring

It is worth being concrete about what goes wrong with timing, because “benchmarks are noisy” undersells it.

Suppose two sorting algorithms are timed on a million elements and one comes out 8% faster. What has been learned? Possibly that it does less work. Possibly that it happens to fit in this processor’s L2 cache and the other one does not, which will reverse on a machine with a different cache. Possibly that the compiler unrolled one inner loop and not the other. Possibly that the faster one allocated no scratch memory and so avoided a page fault that the slower one paid once. Possibly that the operating system scheduled a timer interrupt during the wrong trial.

Every one of those is a real effect and only the first is a property of the algorithm. Disentangling them is a serious undertaking — and it is a different undertaking from the one most people think they are doing when they write a benchmark loop.

A count has none of these problems, at the price of not being a time. That is a trade this site makes deliberately and re-examines whenever it matters, which is most of the machine field.

The instrumented array

Every algorithm on this site is written against a small object rather than against a plain array. It has four primitives that touch data:

  • get(i) — read the element at index i
  • set(i, v) — write v at index i
  • cmp(i, j) — compare the elements at i and j
  • swap(i, j) — exchange them

Each one increments a counter before doing its job. Running an algorithm therefore leaves behind a tally: comparisons, swaps, reads, writes, and the complete sequence of indices touched in the order they were touched.

That last item matters more than it looks and gets an essay of its own. The other four are what the textbooks count.

There is no way to stop an implementation reaching past the primitives and touching the underlying array directly. Nothing in the language prevents it, and an implementation that did would sort perfectly and be counted wrongly — a sabotage that survives every other check on this site. The gate breaks exactly that way on purpose and requires the discrepancy to be caught by a count known independently.

What counts as one comparison

cmp(i, j) is recorded as one comparison and two reads. That is the convention every textbook analysis uses and it is worth saying out loud that it is a convention rather than a fact.

An implementation that kept one operand in a register across an inner loop — which insertion sort’s inner loop does naturally, and which the version here reflects with a separate cmpValue primitive counted as one comparison and one read — performs the same number of comparisons and strictly fewer reads. Neither count is more correct. They answer different questions, and the answer to “how many operations” begins with naming the operation.

The convention also flattens something real. Comparing two machine integers and comparing two long strings that share a prefix cost wildly different amounts, and this site’s count does not distinguish them. Where that difference is the point, the essay says so; where it is not, the flattening is the same one every complexity analysis makes, and it is what makes the numbers portable.

Determinism, or the figures would lie

An algorithm’s cost depends on its input. Measuring the average case means averaging over random inputs, and the obvious way to generate those is a random number generator.

Math.random() is unusable here, and the reason is specific rather than fastidious. Figures on this site are generated at build time and their captions quote the numbers in them. A figure built on an unseeded generator produces different numbers on every build, so a caption saying 2,086 comparisons would be quoting a number that the picture beside it does not contain — the picture on the reader’s screen came from a different build. The caption and the figure would drift apart silently and permanently.

So every random input here comes from a stated seed through a small deterministic generator, and the seeds are in the source. The consequence is that every number in every caption is the number in the figure beside it, and rebuilding the site produces byte-identical output. The distributions are averages over hundreds of inputs, and those hundreds are the same hundreds every time.

There is a real cost to this. Determinism means these are not independent samples in the statistical sense — they are one fixed sample, drawn once. A figure claiming a 95% confidence interval would be claiming more than the method supports. The figures do not; they show the whole distribution and let the spread speak.

The sample size is then a question that can be answered by measurement rather than by rule of thumb.

One number needs the input named

The other thing a single count hides is that most algorithms do not have one cost. They have a cost per kind of input, and for the adaptive ones the spread is enormous.

At n = 512, insertion sort performs 511 comparisons on an already sorted array and 130,816 on a reversed one — a factor of 256, and the gap widens with n. Selection sort performs 130,816 on both, and on every other input of that size, because its two loops run to completion regardless of what they encounter. That difference between the two algorithms is not visible in their shared classification as quadratic, and it is the difference that decides which one is worth having when the data is nearly in order.

Every measurement on this site therefore carries its input kind, and the five kinds are fixed and stated: uniformly random, already sorted, reversed, nearly sorted, and few distinct values. The last two exist because they are where the interesting failures live — nearly sorted is where adaptivity is supposed to pay off, and few distinct values is where several perfectly respectable quicksort variants fall apart.

The check that catches everything

The single most valuable line in the measurement machinery is this one: after a sorting algorithm runs, the array is checked to be sorted, and if it is not, the run is an error rather than a measurement.

This has caught more mistakes during construction than every other check combined, and the reason is worth dwelling on. A broken sorting algorithm produces a perfectly convincing growth curve. If the inner loop stops one element early, the array comes out almost sorted, the comparison count is very slightly lower than it should be, the curve on logarithmic axes is a straight line with the same slope, and the fitted exponent is 2.00 to three digits. Every check on this site except one would pass. The figure would be beautiful and wrong.

Insertion sort on 24 random elements, 6 moments from one runEach panel is the array as it actually stood after a particular write, sampled evenly across the 159 writes the run performed. The comparison counter beside each panel is the counter at that instant, so the picture and the numbers come from the same execution. The run finished at 157 comparisons and 0 swaps.after 0 writes0 cmpafter 32 writes32 cmpafter 64 writes63 cmpafter 95 writes94 cmpafter 127 writes125 cmpafter 159 writes157 cmprandom input, seed stated in lib/count.js157 comparisons in this run
Fig. 1 Insertion sort on 24 random values, six moments from a single run. The panels are states the algorithm actually passed through — the snapshots are taken by wrapping the array’s own write primitive — and the comparison counter printed on each panel is the counter at that instant. The picture and the numbers come from one execution, so they cannot disagree.

Counting the counter

There is a subtler failure available. Suppose a primitive forgot to increment — cmp counted comparisons but cmpValue did not, say.

Every count on the site would fall. Every curve would still be a curve. Every fitted exponent would still be right, because a constant factor does not change a slope on logarithmic axes, and the fitted constant would be wrong by exactly the factor that got dropped. Nothing would look broken.

The only defence against a systematically wrong instrument is a case whose answer is known by an independent route. Selection sort is that case. Its comparison count is n(n1)2\frac{n(n-1)}{2} for every input of size n, derivable in one line from its two nested loops, and the counters are checked against that closed form on every build. At n = 512 the machinery must report 130,816 and nothing else.

The check is unglamorous and it is the reason to trust any other number here.

Four counts, and they disagree

Once the counting is in place, the first thing it shows is that “number of operations” is not one number.

At n = 512 on random input:

algorithm comparisons swaps reads writes
insertion sort 63,071 0 126,145 63,074
selection sort 130,816 504 262,640 1,008
bubble sort 129,688 62,563 384,502 125,126
merge sort 3,964 0 12,536 4,608
heapsort 7,653 4,170 23,646 8,340

Selection sort makes 2.1 times as many comparisons as insertion sort and 1/124th as many writes. Bubble sort makes about the same number of comparisons as selection sort and 62 times as many swaps. If the elements being sorted are machine integers, selection sort’s write economy is worth very little. If they are large records that must be physically moved, it is worth a great deal, and the ranking by comparisons is the wrong ranking entirely.

Insertion sort records zero swaps, which is not a claim that it does not move anything — it moves a great deal, but it does so by shifting with get and set rather than by exchanging, so the movement appears in the read and write columns instead. That is an artefact of how the algorithm is written, and it is visible only because four counts are kept instead of one.

This is the first appearance of a pattern that runs through the whole site: the ranking depends on the quantity, and the quantity has to be named. It appears again, much more sharply, when the second and genuinely independent count is introduced.

What exactness buys that approximation cannot

There is a consequence of counting rather than timing that the argument above has not claimed, and the table two sections up demonstrates it three times over: an exact count can be done arithmetic on. A median of trials cannot. Differences of exact counts are themselves exact, and a difference can identify a structural fact that neither of the two numbers states.

Selection sort is the worked case, and the check the site actually performs is the weaker half of what is available. Its comparison count is n(n1)/2=130,816n(n-1)/2 = 130{,}816, which is the closed form the build asserts. But two reads a comparison and two reads and two writes a swap gives the rest of the row as well: 130,816×2=261,632130{,}816 \times 2 = 261{,}632 reads from the comparisons, plus 504×2=1,008504 \times 2 = 1{,}008 from the swaps, is 262,640 — the measured figure, to the digit. The writes are 504×2=1,008504 \times 2 = 1{,}008, also exact.

So all four of selection sort’s columns are determined in advance and all four agree. A dropped increment in swap would leave the comparison check green and move the write column by exactly 1,008, which is a defect the single closed form cannot see and this one catches for free. The independent route was available for four counts and was being used for one.

Bubble sort’s row then yields something the table does not say anywhere. Its comparison count on random input is 129,688 against selection sort’s 130,816, and the difference is 1,128 — which is 47×48/247 \times 48 / 2, the forty-seventh triangular number, exactly.

That identifies what the early-exit test actually did. Bubble sort’s passes cost n1,n2,,1n-1, n-2, \ldots, 1 comparisons, so skipping the last kk passes saves exactly k(k+1)/2k(k+1)/2. A saving of 1,128 means the last 47 passes of 511 never ran, because the array’s final 47 elements had reached their places and the sweep found nothing to swap.

Which is the honest measurement of an optimisation that is usually quoted as though it were free money. On random input the early exit saves 0.86% of the comparisons — 47 passes of 511, under a tenth of them — and the whole of its value is on inputs that arrive nearly ordered, where the same test can skip almost every pass. That is the input-sensitivity argument two sections above, arriving as an integer identity rather than as a curve.

None of that arithmetic is possible with a stopwatch. A duration carries noise, so a difference of two durations carries the noise of both, and 0.86% would be indistinguishable from the third trial having been unlucky. The exactness is not merely tidier — it moves a class of question from unanswerable to answerable in one line, and the questions it moves are about mechanism, which is what an operation count was supposed to be for.

The general form is worth stating, because it applies to every table on this site. Where a count has a closed form, check the whole row against it, not the column the textbook quotes. And where two counts differ by an amount with structure in it — a triangular number, a power of two, a multiple of the swap count — the structure is a fact about what the algorithm did, and it is sitting in the difference waiting to be read. That is one run, several counts taken one step further: the counts disagree, and how they disagree is itself a measurement.

What a count is not

An operation count is exact, machine-independent, and not a running time.

The gap between the two is mostly memory. A comparison whose operands are already in the processor’s cache costs a fraction of a nanosecond; one that has to reach main memory costs a hundred times more. An algorithm performing half as many comparisons in a worse order is routinely slower on real hardware, and no amount of counting comparisons will reveal that.

This is not a small caveat to be mentioned once. It is why the site carries a second count alongside the first, why the insertion-sort fallback that every standard library implements turns out not to be about comparisons at all, and why an essay here will say fewer comparisons rather than faster unless it has measured something that entitles it to the stronger word.

There is a second thing a count is not, and it is more fundamental. A finite set of measurements cannot establish an asymptotic claim. Measuring an algorithm at every size up to a million establishes nothing certain about a billion, because infinitely many functions agree with any finite sample and diverge beyond it. That limit is real, it applies to everything on this site, and it is taken seriously enough to have its own essay — including a case where the fitted class genuinely changes when the range is extended.

What counting can do is refute a claim, and measure the constant. Both are worth having.

The same trace, four ways

A trace is the cheapest instrument on this site and the one that makes a count checkable, so it is worth running on more than one algorithm and more than one input.

Insertion sort on 24 nearly sorted elements, 6 moments from one runEach panel is the array as it actually stood after a particular write, sampled evenly across the 28 writes the run performed. The comparison counter beside each panel is the counter at that instant, so the picture and the numbers come from the same execution. The run finished at 28 comparisons and 0 swaps.after 0 writes0 cmpafter 6 writes6 cmpafter 11 writes11 cmpafter 17 writes17 cmpafter 22 writes22 cmpafter 28 writes28 cmpnearly sorted input, seed stated in lib/count.js28 comparisons in this run
Fig. 2 Insertion sort on nearly sorted input. Almost every element is already where it belongs, so almost every panel differs from the last by one comparison and no move.

Reversed input is the other end of the same axis, and it is the case a bound is written about.

Insertion sort on 24 reversed elements, 6 moments from one runEach panel is the array as it actually stood after a particular write, sampled evenly across the 299 writes the run performed. The comparison counter beside each panel is the counter at that instant, so the picture and the numbers come from the same execution. The run finished at 276 comparisons and 0 swaps.after 0 writes0 cmpafter 60 writes51 cmpafter 120 writes106 cmpafter 179 writes162 cmpafter 239 writes219 cmpafter 299 writes276 cmpreversed input, seed stated in lib/count.js276 comparisons in this run
Fig. 3 And on reversed input, its worst case: every element travels the whole way to the front, and the panels show the same algorithm doing quadratically more work.

Both of those hold the algorithm fixed. The other two dials are which algorithm runs and how finely the run is sampled.

Merge sort on 24 random elements, 6 moments from one runEach panel is the array as it actually stood after a particular write, sampled evenly across the 112 writes the run performed. The comparison counter beside each panel is the counter at that instant, so the picture and the numbers come from the same execution. The run finished at 80 comparisons and 0 swaps.after 0 writes0 cmpafter 22 writes14 cmpafter 45 writes33 cmpafter 67 writes45 cmpafter 90 writes80 cmpafter 112 writes80 cmprandom input, seed stated in lib/count.js80 comparisons in this run
Fig. 4 Merge sort on random input, where the array is rebuilt bottom-up rather than grown from the left, and the panels are moments in a recursion rather than steps along a scan.
Insertion sort on 16 random elements, 8 moments from one runEach panel is the array as it actually stood after a particular write, sampled evenly across the 86 writes the run performed. The comparison counter beside each panel is the counter at that instant, so the picture and the numbers come from the same execution. The run finished at 85 comparisons and 0 swaps.after 0 writes0 cmpafter 12 writes12 cmpafter 25 writes25 cmpafter 37 writes36 cmpafter 49 writes48 cmpafter 61 writes60 cmpafter 74 writes73 cmpafter 86 writes85 cmprandom input, seed stated in lib/count.js85 comparisons in this run
Fig. 5 And a shorter array sampled at eight moments rather than six. The trace is the run and the panels are a choice about how much of it to show — which is the sense in which a count is checkable and a timing is not.

Why not both?

The obvious objection to all of this is that timings are what people actually care about, so why not measure them too?

Because a timing on the machine that built this page is a fact about that machine, and publishing it would invite exactly the inference it cannot support. Readers would compare the numbers across essays, and the comparison would be dominated by which build ran on which container with what else running. The honest options are to measure timings properly — many trials, a quiet machine, reported distributions, all of it stated — or not to publish them at all.

This site does not publish them. What it does instead is model the part of the machine that explains most of the difference, which is memory, and state the model’s parameters wherever a number from it appears. That model does not produce a time and no essay treats its output as one. It produces a second count, and the interesting thing about a second count is that it can disagree with the first.

Where each algorithm looks, and whenEvery array access from one run of each algorithm on 256 random elements: time along the horizontal axis, array index up the vertical. Merge sort makes 49% of its accesses to the next element or the same one; Heapsort makes 15%. That difference is invisible in the comparison count and is most of what the machine feels.Merge sort49% sequential · 7,540 accesses2560Heapsort15% sequential · 14,044 accesses2560time (accesses, left to right) · index (bottom to top)one run each, n = 256every access plotted
Fig. 6 Every array access from one run of each algorithm: time along the horizontal axis, index up the vertical. Merge sort’s accesses are sweeps; heapsort’s are a spray. Both were counted by the same instrument in the same way, and the difference between these two pictures is invisible in every number on this page.

The whole method, in order

  1. Write the algorithm against instrumented primitives and nothing else.
  2. Run it on inputs generated from stated seeds.
  3. Check the output. A sort that did not sort is an error, not a data point.
  4. Record comparisons, swaps, reads, writes, and the access trace.
  5. Repeat across sizes spanning three orders of magnitude.
  6. Fit the measured counts against the candidate complexity classes, and grant a class only if the fit holds.
  7. Replay the access trace through a stated cache model to get the second count.
  8. Print the model’s parameters on any figure that reports a number from it.

Six of those eight steps are about not fooling yourself. That ratio is about right.

The pay-off is that every claim on this site is checkable by running the code, and every number in every caption came from the run that drew the figure.

It also has a consequence that was not planned. Two of the ten sorting algorithms here originally declared a complexity class that the fit refused to grant, and the refusals were not mistakes in the machinery. Bubble sort’s advertised behaviour on nearly sorted input fits neither of the classes it is usually assigned. The hybrid merge sort’s behaviour on reversed input fits one class up to n = 4,096 and a different one out to n = 65,536, with nothing changing but the range.

Both claims were withdrawn. Both became essays, because a measurement that disagrees with the expected answer is the only kind worth taking.

What this makes readable

Essays that name this one as a prerequisite.

Named alongside this one

Essays reaching for the same objects. Nobody chose these; they are what the concept index makes visible.

What links here

The 8 essays that link to this one and share the most of its objects, of 35 that link here.

The objects this essay names

Each one links to every other essay that touches it.

BenchmarkingCacheComparison countDeterminismDistributionInstrumentationOperation countRankingReproducibilitySwaps