The Same Seven Numbers, Two Trees: Why Insert Order Decides Lookup Cost
A binary search tree is usually introduced with a tidy picture: a root, two children, everything smaller on the left, everything larger on the right. The picture is correct, and it hides the most important thing about the structure.
A binary search tree has no say in its own shape. It stores what it is given, in the order it is given, and that order decides what the tree costs to use for the rest of its life. The same seven numbers can be a three-level tree or a seven-level one. The data did not change. The insert code did not change. Only the sequence.
Build one, then break it
Insert the values in any order you like. The tree redraws after each one.
A binary search tree holds these 7 values: 1, 2, 3, 4, 5, 6, 7. Each new value
starts at the root and moves left or right until it finds an empty spot, so the ORDER they
arrive in decides the shape of the tree β and the contents never change.
Inserting them in sorted order gives a tree 7 levels deep: every node has one
child and no left children at all, which is a linked list. Inserting
4, 2, 6, 1, 3, 5, 7 gives one 3 levels deep. Same
values, same insert code, but finding something costs up to 7 comparisons in the
first tree and at most 3 in the second β 2.3Γ worse at its worst,
on every lookup for the rest of the tree's life, and the gap widens sharply as the tree grows.
Building them costs 21 comparisons versus 10.
Insert all 7 values in whatever order you like. The shallowest tree they can possibly form has 3 levels. Sorted order gives 7.
- Levels
- 0
- Shallowest possible
- 0
- Worst-case lookup
- β
- Comparisons to build
- 0
The same tree, as values
Insert order
In-order traversal
Try 1 through 7 in order first. Then press Reset and start from 4 instead. The values are identical in both trees β only the sequence you clicked them in is different.
Two things are worth noticing before reading on.
The in-order row never changes. Whatever shape you produce, reading the tree left to right gives the values in sorted order every single time. That is what the structure is for β and it is also why a badly shaped tree gets away with it. Shape does not buy correctness. It buys speed.
Sorted input is the worst possible input. Insert 1, then 2, then 3, and each value is larger than everything already there, so each one goes right, forever. You get a staircase: a linked list with tree-shaped code wrapped around it. The most natural way to load data β in whatever order it already happens to be in, which is very often sorted β is the one that destroys the tree.
Why the shape moves at all
Insertion never reorganises anything. It walks down from the root, going left when the new value is smaller and right when it is not, and hangs the new node on the first empty spot it finds. Where a node lands is decided entirely by which nodes are already there.
So the first value inserted becomes the root permanently, and everything after it is filed relative
to that choice. Insert 4 first and roughly half the remaining values go left, half go right. Insert
1 first and every remaining value goes right.
Here is that walk with the cost counted. One comparison means one existing node visited β the same unit a lookup is measured in later, which is what makes the two numbers comparable.
def insert(root, value):
"""Insert value; return (root, comparisons). One comparison = one existing node visited."""
node = {"value": value, "left": None, "right": None}
if root is None:
return node, 0
cur, comparisons = root, 0
while True:
comparisons += 1
if value < cur["value"]:
if cur["left"] is None:
cur["left"] = node
return root, comparisons
cur = cur["left"]
else:
if cur["right"] is None:
cur["right"] = node
return root, comparisons
cur = cur["right"]
def build(order):
root, total = None, 0
for value in order:
root, spent = insert(root, value)
total += spent
return root, total
def height(root):
levels, stack = 0, [(root, 1)]
while stack:
node, depth = stack.pop()
if node is None:
continue
levels = max(levels, depth)
stack.append((node["left"], depth + 1))
stack.append((node["right"], depth + 1))
return levels
def balanced_order(values):
"""Medians first, breadth-first: the order that builds the shallowest possible tree."""
order, queue = [], [(0, len(values) - 1)]
while queue:
lo, hi = queue.pop(0)
if lo > hi:
continue
mid = (lo + hi) // 2
order.append(values[mid])
queue.append((lo, mid - 1))
queue.append((mid + 1, hi))
return order
values = list(range(1, 8))
for name, order in [("sorted", values), ("medians", balanced_order(values))]:
root, comparisons = build(order)
print(f"{name}: insert {order}")
print(f" height {height(root)} levels, {comparisons} comparisons to build")sorted: insert [1, 2, 3, 4, 5, 6, 7] height 7 levels, 21 comparisons to build medians: insert [4, 2, 6, 1, 3, 5, 7] height 3 levels, 10 comparisons to build
Edit values to list(range(1, 16)) and run it again to watch the gap widen.
Seven levels against three is the figure the widget reports, and it is a worst-case number: the deepest node takes seven comparisons to reach instead of three. That is the right thing to say about one unlucky lookup and the wrong thing to say about the tree as a whole, because most nodes are not the deepest one.
What the shape actually costs
The honest measure is the total cost of finding every key once. At seven values it is a smaller ratio than the worst case suggests. As the tree grows it becomes a far larger one.
def insert(root, value):
node = {"value": value, "left": None, "right": None}
if root is None:
return node
cur = root
while True:
if value < cur["value"]:
if cur["left"] is None:
cur["left"] = node
return root
cur = cur["left"]
else:
if cur["right"] is None:
cur["right"] = node
return root
cur = cur["right"]
def lookup_cost(root, value):
"""Comparisons needed to FIND value - the cost this tree charges forever after."""
cur, steps = root, 0
while cur is not None:
steps += 1
if value == cur["value"]:
return steps
cur = cur["left"] if value < cur["value"] else cur["right"]
return steps
def balanced_order(values):
order, queue = [], [(0, len(values) - 1)]
while queue:
lo, hi = queue.pop(0)
if lo > hi:
continue
mid = (lo + hi) // 2
order.append(values[mid])
queue.append((lo, mid - 1))
queue.append((mid + 1, hi))
return order
print(f"{'values':>7} {'sorted order':>13} {'medians first':>14} {'ratio':>6}")
for n in (7, 63, 1023):
values = list(range(1, n + 1))
results = []
for order in (values, balanced_order(values)):
root = None
for value in order:
root = insert(root, value)
results.append(sum(lookup_cost(root, v) for v in values))
slow, fast = results
print(f"{n:>7} {slow:>13,} {fast:>14,} {slow / fast:>5.1f}x") values sorted order medians first ratio
7 28 17 1.6x
63 2,016 321 6.3x
1023 523,776 9,217 56.8x At seven values the badly built tree costs 1.6 times as much in total. At 1023 it costs nearly
fifty-seven times as much, and the gap keeps widening β because these are not two similar structures
with different constants. They are different complexity classes wearing the same type name. A
balanced tree costs about log2(n) per lookup; a chain costs about n. At 1023 values that is 10
against 1023, and the tableβs ratio is below 100 only because the average node in a chain sits
halfway down it rather than at the end.
That is the practical shape of the danger. A merely lopsided tree is a small tax. A tree built from sorted input has quietly stopped being a tree, while the code around it still compiles, still returns correct answers, and still passes its tests β on small inputs.
What real systems do about it
Nothing on this page fixes the problem; it only makes it visible. The fixes have names worth knowing, and each is a different answer to the question who is responsible for the shape:
- Self-balancing trees. AVL and red-black trees do extra work during insertion to keep the height bounded no matter what order the data arrives in. The structure takes responsibility.
- B-trees. The family databases and filesystems actually use, built wide and shallow because the expensive operation there is fetching a disk block, not comparing two numbers.
- Shuffling the input. Random insertion order avoids the pathological case without any rebalancing code β and is unavailable whenever data has to be taken in as it arrives.
How those work is a different article. This one exists to establish why they are needed at all, which is the part that usually gets skipped.
What this page simplifies
- Deletion is not covered. Removing nodes is fiddlier than inserting them and can degrade shape on its own. Everything above assumes an insert-only tree.
- Comparisons, not seconds. Every figure here counts node visits, which is deterministic and identical on any machine. Wall-clock timings would also depend on cache behaviour and allocation, which differ per run and per machine, and would make the numbers unreproducible.
- Distinct values only. The widget and both programs assume no duplicates. A real implementation has to decide whether equal keys go right, get rejected, or carry a count, and that choice affects shape.
- The named fixes are named, not measured. AVL, red-black and B-trees appear above as terminology. No claim about how they perform is made here, because nothing on this page measures them.