Finding One Thing Among Many
Scanning a pile costs whatever the pile holds. Computing where the thing lives costs the same whatever the pile holds, and the whole subject is about earning that second option.
There are two ways to find something. Look through the pile until it turns
up, or know where it is and go there.
The first costs what the pile holds. The second costs nothing much at all,
and the entire difficulty is earning the right to use it.
| items examined | needs the data in order | cost grows with the coll | memory used | |
|---|---|---|---|---|
| scan an unordered list | 500000 | 0 | 1 | 1 |
| halve a sorted list | 20 | 1 | 1 | 1 |
| index a block by positio | 1 | 0 | 0 | 2 |
| compute a position from | 1 | 0 | 0 | 1 |
The three ways and what they cost
The second row is already good. Twenty comparisons for a million items is the
halving search, and it is why sorted order is worth keeping.
But twenty is not one, the twenty becomes thirty at a thousand million, and
the order has to be maintained on every insertion. The third and fourth rows
do not examine anything.
| step | step | scanning a list of a million | indexing a block by position |
|---|---|---|---|
| 1 | 1 | read the first item, compare, not it | multiply the position by the item size |
| 2 | 2 | read the second item, compare, not it | add that to the start address |
| 3 | 3 | read the third item, compare, not it | read that address |
| 4 | ... | and so on, about half a million times | finished |
| 5 | cost | grows with the collection | the same at any size |
When the key is already a position
If the keys are small whole numbers, there is nothing left to do. Make a
block with one slot per possible key and put each item at its own number.
- the key, used directly as the position
- the size of one slot in bytes
- the number of keys that could possibly occur, not the number stored
- the memory the table occupies
import bisect, random, time
n = 100_000
keys = random.sample(range(10_000_000), n)
as_list, as_sorted, as_dict = keys, sorted(keys), {k: 1 for k in keys}
wanted = random.sample(keys, 1000)
def timed(name, f):
t = time.perf_counter()
for k in wanted:
f(k)
print(name, round(time.perf_counter() - t, 4))
timed('scan', lambda k: k in as_list)
timed('halving', lambda k: as_sorted[bisect.bisect_left(as_sorted, k)] == k)
timed('dictionary', lambda k: as_dict[k])
days = [None] * 7 # a direct table, one slot per possible key
days[3] = 'Thursday'
print(days[3])The gap
Real keys are email addresses, order identifiers, paths and words. There are
astronomically many of them possible and a handful actually stored, so a slot
per possible key is not an option.
What is needed is a rule that turns any key into a position in a small block,
computed from the key alone. Since there are more possible keys than
positions, such a rule must sometimes send two keys to the same place.
That is not a flaw to be engineered away. It is a certainty, and the next two
lessons build the rule and then deal with its consequence.
Recap
- Searching by looking at items costs in proportion to how many there are, and no amount of care inside the loop changes that.
- Computing a position from the key and going straight to it costs the same whether the collection holds ten items or ten million, because nothing is examined on the way.
- That second option is only available when the key can be turned into a position, which is immediate for small whole numbers and is the entire problem for everything else.
This is the reading half
Starting the course gives you your own copy of it. Every idea on every page has problems standing under it, marked with a reason rather than a tick, and any sentence you do not believe can be opened and argued with. None of that can happen on a page nobody owns.
The contents