Week 12 · Phase 4 · Choosing well

Searching: Linear vs Binary

Guess-the-number, the phone book trick, and why log n barely grows.

How do you find something in a million items with only 20 looks?

Lesson

Sorted order makes discarding safe

Find 33 in a sorted list, using inclusive bounds
index
01234567
value
3791421283340
check 1
mid 3: 14 < 33keep indices 4…7
check 2
mid 5: 28 < 33keep indices 6…7
check 3
mid 6: 33found

Each failed comparison removes the middle item and one side. Without sorted order, the discarded side might contain the target.

Make the interval shrink every time

Run in Colab · predict the result first
def binary_search(a, target):
    low, high = 0, len(a) - 1
    while low <= high:
        mid = (low + high) // 2
        if a[mid] == target:
            return mid
        if a[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1
Worst-case middle checks, n > 0⌊log₂ n⌋ + 1

Empty input makes high = −1, so the loop never runs. With duplicates, this version returns a matching index, not necessarily the first. The +1 and −1 prevent checking the same middle forever.

Include the price of preparing the data

q queries on initially unsorted n valuesTotal work model
Scan each timeO(qn)
Sort once, then binary searchO(n log n + q log n)
Build a set once, membership onlyO(n + q) average

For one query, sorting may cost more than scanning. A set is suitable for membership; sorted order also supports ranges. Constants and required outputs decide which model fits.

Test boundaries; use insertion positions for ranges

Run in Colab · predict the result first
from bisect import bisect_left, bisect_right
a = [2, 4, 4, 7, 9]
count = bisect_right(a, 7) - bisect_left(a, 4)
print(count)  # 3 values in the inclusive range [4, 7]
Boundary test set
test
emptyone hitone miss
test
first hitlast hitinterior gap

bisect_left finds the first allowed position; bisect_right goes after equal values. Finding a position is logarithmic; inserting into a list can still shift n items.

Practice

Practice questions

10 test questions · 10 written questions · 20 total

Each question is followed by its answer and explanation. Hard questions also include a starting hint and smaller reasoning steps.

Test questions

Choose one option. Cost questions state their model and whether the bound is tight, expected or worst-case. Here lg means log₂, and heap positions start at 1.

Question 1 · easy

In a binary search tree the minimum key is found by:

  1. repeatedly going right
  2. repeatedly going left
  3. looking at the root
  4. a hash lookup

Answer: option B. Smaller keys always lie to the left.

Question 2 · easy

An in-order traversal of a BST prints the keys:

  1. level by level
  2. in the order they were inserted
  3. in sorted order
  4. in reverse sorted order

Answer: option C. Left subtree, node, right subtree.

Question 3 · easy

Insert distinct keys 1, 2, …, n into a plain, unbalanced BST, for n ≥ 3. Counting edges from root to deepest leaf, its height is:

  1. about log n
  2. n − 1
  3. 1
  4. about √n

Answer: option B. Each key becomes the right child of the previous one: a chain.

Question 4 · medium

Using the successor-based deletion method, a BST node with two children is replaced by:

  1. its parent
  2. its left child
  3. its in-order successor (the minimum of its right subtree)
  4. the root

Answer: option C. The successor has at most one child, so it is easy to remove from its old place.

Question 5 · medium

The successor of a BST node that has a right child is:

  1. the minimum of its right subtree
  2. the maximum of its right subtree
  3. its parent
  4. the maximum of its left subtree

Answer: option A. The next larger key is the smallest key that is larger.

Question 6 · medium

Which structure gives the best guaranteed worst-case bound on all seven dictionary operations?

  1. a hash table
  2. an unsorted array
  3. a balanced binary search tree
  4. a sorted linked list

Answer: option C. A balanced BST supports each ordered-dictionary operation in O(log n) worst-case time. An ordinary hash table does not maintain key order, and its worst-case lookup can be linear.

Question 7 · medium · course question

A sorted list has 15 items. Binary search checks one middle item per iteration and stops on equality. What is the maximum number of checks needed for a successful search?

  1. 15
  2. 7
  3. 4
  4. 3

Answer: option C. A search with four checks can cover up to 2⁴−1=15 items. Three checks cover at most seven.

Question 8 · medium · course question

Binary search uses inclusive bounds lo and hi. After comparing a middle value smaller than the target in an ascending list, what update is correct?

  1. lo = mid + 1
  2. hi = mid − 1
  3. lo = mid
  4. hi = mid

Answer: option A. The middle value and everything before it are too small. Excluding the checked middle position also guarantees progress.

Question 9 · medium · course question

With inclusive bounds, which condition means a binary search has exhausted all candidates?

  1. lo == hi
  2. mid == 0
  3. the target is positive
  4. lo > hi

Answer: option D. When lo exceeds hi, the candidate interval is empty. With lo == hi there is still one candidate to check.

Question 10 · medium · course question

You will perform one search in an unsorted list. Which statement compares a scan with sorting first correctly?

  1. sorting first is always faster
  2. include the sorting cost; one linear scan can require less total work
  3. binary search needs no sorted order
  4. ignore preprocessing because it happens before the search

Answer: option B. For a single query, linear scanning takes O(n), while comparison sorting plus binary search generally costs O(n log n). Repeated queries may justify preprocessing.

Written questions

Read each question together with its explanation, trace or proof. Numbering continues from the test questions.

Question 11 · easy

In a binary search tree, where is the largest key? The smallest?

Answer & reasoning

largest: start at the root and keep going right until you cannot. Smallest: keep going left.

Question 12 · easy

Insert 50, 30, 70, 20, 40, 60, 80 in that order into an empty BST. Describe the tree and give its height.

Answer & reasoning

50 is the root; 30 is its left child and 70 its right child; 20 and 40 hang under 30; 60 and 80 hang under 70. Three levels, height 2. It is perfectly balanced.

Question 13 · easy

What does an in-order traversal of the tree in exercise 38 print?

Exercise 38 uses the BST obtained by inserting 50, 30, 70, 20, 40, 60, 80 in that order.

Answer & reasoning

20 30 40 50 60 70 80, the keys in sorted order.

Question 14 · easy

Insert 1, 2, 3, 4, 5 in that order into an empty BST. What is its height, and what does a search cost?

Answer & reasoning

each key goes to the right of the previous one, giving a chain of height 4; search costs O(n), no better than a linked list.

Question 15 · medium

In a BST, node x has no right child. Where is its successor?

Answer & reasoning

walk up from x until you arrive at an ancestor from its left child; that ancestor is the successor (the next key in in-order). If you reach the root without ever coming up from a left child, x is the maximum.

Question 16 · medium

When deleting a BST node with two children, which node takes its place, and why is that node guaranteed to have at most one child?

Answer & reasoning

its successor, the minimum of the right subtree (the predecessor works symmetrically). Being a minimum, it cannot have a left child, so it has at most a right child and can be removed with the one-child case.

Question 17 · medium

How can you check in O(n) time whether a given binary tree with keys satisfies the BST property?

Answer & reasoning

For distinct keys and a strict BST ordering rule, traverse in order and check that successive keys are strictly increasing. Visit every node once: O(n) time. If duplicate keys are allowed, specify which subtree may contain them and enforce that policy with lower and upper bounds during traversal; a nondecreasing traversal alone cannot enforce a one-sided duplicate policy.

Question 18 · medium

Binary search on a sorted array costs O(log n). Why does the same idea not work on a sorted linked list?

Answer & reasoning

binary search needs to jump to the middle element in O(1); a linked list can only reach the middle by walking n/2 pointers, which already costs O(n).

Question 19 · medium

What is the height of a perfectly balanced BST holding 1,023 keys?

Answer & reasoning

9. A full tree of height h has 2ʰ⁺¹ − 1 nodes, and 2¹⁰ − 1 = 1,023, so there are 10 levels and 9 edges from root to leaf.

Question 20 · hard

Prove that an in-order traversal of any BST outputs its keys in sorted order.

In simpler words: Explain why left–root–right prints a search tree in order.

Starting hint: Assume each smaller subtree already prints correctly.

Answer & reasoning
Step by step
  1. For an empty tree, the output is empty and sorted.
  2. For distinct keys, every left-subtree key is below the root and every right-subtree key is above it.
  3. By induction, each subtree output is sorted. Joining sorted-left, root, sorted-right creates a sorted whole.

induction on the number of nodes. An empty tree prints nothing, which is sorted. For a tree with root r, by the induction hypothesis the left subtree prints its keys sorted and so does the right subtree. Every left key is < r < every right key by the BST property, so “left output, then r, then right output” is sorted.

Three core tasks

Predict on paper, run the code, then explain any difference. Use the animations below to inspect individual steps.

1. Trace

Search for 30 in [3, 7, 9, 14, 21, 28, 33, 40]. Record middle values.

Check your reasoning

14 → 28 → 33; then low = 6, high = 5, so return −1.

2. Calculate

What is the worst-case number of middle checks for 1,023 sorted items?

Check your reasoning

⌊log₂ 1023⌋ + 1 = 10.

3. Change one thing

In the range example, change the interval to [4, 4]. Predict the answer.

Check your reasoning

bisect_right(a, 4) − bisect_left(a, 4) = 3 − 1 = 2. Both copies count.

Explore the animations & more worked tasks

12.9Try it yourself

Task 1 — count the halvings

Use the steps value returned by binary_search to fill a table for n = 1 000, 10 000, 100 000, 1 000 000, 10 000 000 (worst case: search for a missing value). What is the pattern?

Animate it — watch the window halve, then fill the table
Expected

For a missing target larger than every item: 10, 14, 17, 20, 24. Other missing targets can take fewer steps. Each ten-fold increase in data adds roughly three or four steps. That is what "logarithmic" feels like from the inside.

Task 2 — break it deliberately

Run binary_search on an unsorted list and find a value it fails to locate even though it is present. Then explain in two sentences why no error was raised.

Animate it — pick a value and watch binary search miss it
Task 3 — the crossover with sorting included

For a list of 1 000 000 unsorted items, compare total time for q searches, q = 1, 10, 100, 1 000, 10 000, under three strategies: (a) linear each time, (b) sort once then binary, (c) build a set once then look up. At what q does each strategy win?

Animate it — predict the winner, then race three strategies
What you should find

For a single search, linear scanning avoids preparation and is often preferable; the actual winner depends on the input and implementation. The set overtakes very quickly (usually within a handful of queries) because building it is only one pass. Sorting pays off later than the set for pure membership, but it is the only one of the three that also answers range questions.

Task 4 — a question a set cannot answer

Given a sorted list of a million timestamps, use bisect to count how many fall between two given values, in O(log n). Then try to do the same with a set and describe why it degrades to O(n).

Animate it — count a range with bisect, then with a set
Solution
range count
import bisect
count = bisect.bisect_right(data, high) - bisect.bisect_left(data, low)

Two O(log n) lookups and a subtraction. A set has no notion of order, so the only option is to examine every element — O(n). This is the reason sorted structures still exist in a world with hash tables.

Task 5 — trace it on paper

By hand, trace binary_search([3, 7, 9, 14, 21, 28, 33, 40], 33), writing low, high and middle at each step. How many steps? Then trace a search for 10 (absent) the same way.

Animate it — check your paper trace, or fill the table yourself
Answer

Finding 33 (indices 0–7): mid 3 → data[3]=14, 33>14, low=4; mid 5 → data[5]=28, 33>28, low=6; mid 6 → data[6]=33, match. 3 steps. Searching for 10: mid 3 → 14, 10<14, high=2; mid 1 → 7, 10>7, low=2; mid 2 → 9, 10>9, low=3; now low 3 > high 2, return −1. 3 steps, then the crossing.

Task 6 — pass the six tests

Write your own binary search and run it against all six boundary cases from §12.5: an empty list, a one-item list, a two-item list, the first element, the last element, and a missing value. Fix anything that fails. Which case caught the most bugs for you?

Animate it — run the six tests on five versions
What to watch for

The two-item list and the "missing value just past the end" case are the usual culprits — they are exactly where a stray < instead of <=, or a missing −1, shows itself. If all six pass, the loop condition and the two narrowing steps are consistent.

Check your understanding

12.10Self-check

Binary search on 1 000 000 sorted items needs at most about:

Each step halves the range; a million halves to one in about twenty steps.

You have unsorted data and need exactly one search. The right choice is:

Preparation only pays off when its preparation cost is spread over many searches. And the last option is silently wrong, which is worse than slow.

Binary search on unsorted data:

It happily discards the half containing your item. Silent wrongness is the most dangerous failure mode in software.

When is a sorted list with bisect better than a set?

Hash tables destroy order by design. Order questions need an ordered structure.

A binary search that writes high = middle instead of high = middle - 1 risks:

If the window never narrows, low and high never cross and the while loop never ends. Always move past the middle.

Why is the loop condition while low <= high rather than <?

With <, the last remaining candidate is never checked, so a value sitting there is missed. The trace in §12.4 ends exactly when low passes high.
Extra material & reference
Optional depth · full technical reference

12.1The game you already played

In week 1 you guessed a number between 1 and 100 by always choosing the middle of the remaining range, and it took at most seven guesses. That is binary search, and it is the second most useful algorithm a beginner can learn (the first is "put it in a dictionary").

Range sizeGuessing one by oneHalving
100up to 1007
1 000up to 1 00010
1 000 000up to 1 000 00020
1 000 000 000up to a billion30
the world's population8 billion33

Thirty-three questions to find one person on Earth. That column is why O(log n) is treated as "practically free": between a thousand items and a billion, it grows from 10 to 30.

12.2Linear search, for reference

linear_search.py
def linear_search(data, target):
    """Return the position of target, or -1. Works on any list."""
    for i in range(len(data)):
        if data[i] == target:
            return i
    return -1

Worst case n comparisons: O(n). Its virtues are that it needs no preparation and works on unsorted data, which is exactly what you usually have.

12.3Binary search

The rule: look in the middle; if the target is smaller, throw away the right half; if larger, throw away the left half; repeat.

binary_search.py — data must be sorted
def binary_search(data, target):
    low = 0
    high = len(data) - 1
    steps = 0

    while low <= high:
        steps += 1
        middle = (low + high) // 2

        if data[middle] == target:
            return middle, steps
        elif target < data[middle]:
            high = middle - 1          # drop the right half
        else:
            low = middle + 1           # drop the left half

    return -1, steps
see the steps for yourself
data = list(range(1000000))          # already sorted

print(binary_search(data, 999999))   # (999999, 20)
print(binary_search(data, 0))        # (0, 19)
print(binary_search(data, -5))       # (-1, 19)

At most twenty middle inspections in this million-item list. These exact calls use twenty for the far-end hit and nineteen for the near-end hit and the miss. Compare with linear search, which needs up to a million.

The two ways to get this wrong
  1. Unsorted data. Binary search on unsorted data does not error — it silently returns wrong answers. That is worse than crashing.
  2. Off-by-one. while low <= high, and middle ± 1 when narrowing. Get either wrong and you loop forever or miss the last item. Test with a 1-item list, a 2-item list, the first element, the last, and a missing value.

12.4Watch it run: a step-by-step trace

The code is short, but short code hides its behaviour. The cure is to trace it once by hand, writing down low, high and middle at every turn. Take this ten-item sorted list (indices along the top) and search for 23:

index0123456789
value25812162338567291
steplowhighmiddledata[middle]decision
10941623 > 16 → search right, low = 5
25975623 < 56 → search left, high = 6
356523match → return (5, 3)

Three steps, and each one threw away roughly half of what was left: ten candidates, then five, then two. Now trace a miss — search for 40, which is not in the list:

steplowhighmiddledata[middle]decision
10941640 > 16 → low = 5
25975640 < 56 → high = 6
35652340 > 23 → low = 6
46663840 > 38 → low = 7
576——low > high → return (−1, 4)

The loop ends the instant low overtakes high: the window has closed to nothing, so the value cannot be present. That crossing is the whole reason the condition is while low <= high and not < — with <, a one-element window (low == high) is never examined, and a value sitting there is missed.

Counting the halvings

Each mismatch at least halves the remaining candidate count. For this inclusive-boundary implementation, the maximum number of middle inspections is floor(log₂ n) + 1 for n > 0, and zero for n = 0. This is 4 at n = 8 or 10 and 20 at n = 1 000 000. Actual hits and misses can use fewer steps; reaching one candidate is not the same as checking it.

12.5The off-by-one minefield

Binary search is famous for being easy to describe and hard to get exactly right. Almost every bug is one of three kinds. Here is a version with the classic mistake planted in it — predict what it does before reading on:

buggy_binary_search.py — do not ship this
def buggy_search(data, target):
    low, high = 0, len(data) - 1
    while low <= high:
        middle = (low + high) // 2
        if data[middle] == target:
            return middle
        elif target < data[middle]:
            high = middle              # BUG: should be middle - 1
        else:
            low = middle + 1
    return -1

Run it on [10, 20] searching for 5. Step 1: low 0, high 1, middle 0, data[0] is 10, and 5 < 10, so high = middle = 0. Step 2: low 0, high 0, middle 0, still 5 < 10, so high = 0 again. Nothing has changed. It loops forever. The fix — high = middle - 1 — guarantees the window shrinks on every branch, so low and high must eventually cross.

MistakeSymptomThe rule
while low < highMisses a value sitting in a one-element windowUse <= so low == high is still checked
high = middle (no −1)Infinite loop when the window will not shrinkAlways move past the middle: middle − 1 / middle + 1
Searching unsorted dataWrong answer, no errorSort first, or use a set — precondition, not optional

The defence is not cleverness, it is a fixed set of tests. Any binary search you write should be tried on: an empty list, a one-item list, a two-item list, the very first element, the very last element, and a value that is absent. Passing these tests is useful evidence, not proof. Justify correctness by showing that all possible answers stay inside the window and that every mismatch strictly shrinks it.

A useful cousin returns not "is it here?" but "where would it go?" — the insertion point. This is what powers bisect in the next section, and it never needs the equality branch at all:

insertion_point.py
def insertion_point(data, target):
    """First index i such that everything left of i is < target."""
    low, high = 0, len(data)          # note: high starts at len, not len-1
    while low < high:
        middle = (low + high) // 2
        if data[middle] < target:
            low = middle + 1
        else:
            high = middle
    return low

data = [10, 20, 30, 40, 50]
print(insertion_point(data, 30))      # 2  — 30 belongs at index 2
print(insertion_point(data, 35))      # 3  — between 30 and 40
print(insertion_point(data, 99))      # 5  — past the end
2 3 5

Notice the deliberately different shape: high starts at len(data) and the loop is while low < high with high = middle (no −1). The two idioms are not interchangeable — each is internally consistent, and mixing pieces of one into the other is how the bugs above are born. Learn one of each and keep them separate.

12.6But sorting is not free

Binary search costs O(log n) — if the data is already sorted. Sorting costs O(n log n), which is more than a single linear scan. So the arithmetic depends entirely on how many searches you will do.

SituationBest choiceTotal cost
One search, unsorted dataLinear searchO(n) — sorting first would cost more
Many searches, data stays putSort once, then binary searchO(n log n) once + O(log n) each
Many searches, you only ask "is it there?"Build a setO(n) once + O(1) each
Data already sorted for other reasonsBinary searchO(log n) each
You need neighbours, ranges, "next larger"Sorted list + bisectO(log n) to locate boundaries or a neighbour; returning k range items adds O(k)

Notice row three: for repeated plain membership, a set offers average O(1) query time, while binary search offers O(log n); measured constants and preparation still matter. Binary search earns its place when you need order — the nearest value, everything between two dates, the next larger measurement. That is a question a hash table cannot answer at all.

The crossover is worth doing on the back of an envelope. Say sorting a list costs about n log₂ n and each linear scan costs n. Sorting-then-searching for q queries costs roughly n log₂ n + q log₂ n; scanning q times costs q n. For n = 1 000 000, log₂ n ≈ 20, so sorting pays for itself once q climbs past about 20 — after that the scan is throwing away the same million items over and over, while binary search never looks at more than twenty.

12.7Do not write it yourself

Python ships with a tested implementation. Use it in real work; write your own only to understand it, as you did above.

bisect
import bisect

data = [10, 20, 30, 40, 50]

i = bisect.bisect_left(data, 30)
print(i, i < len(data) and data[i] == 30)   # 2 True; boundary check also handles empty/missing inputs

print(bisect.bisect_left(data, 35))   # 3 — where 35 would go
bisect.insort(data, 35)               # insert and keep sorted
print(data)                           # [10, 20, 30, 35, 40, 50]

bisect_left is O(log n). insort finds the spot in O(log n) but still has to shift items, so the insertion itself is O(n) — the week 10 lesson has not gone away.

bisect_left vs bisect_right

When the value is already present, bisect_left returns the index of the first copy and bisect_right the index just after the last. Their difference, bisect_right(data, x) - bisect_left(data, x), is exactly how many copies of x the list holds — an O(log n) count, no scanning. You will use this in Task 4.

12.8The benchmark

three strategies, one job
import time, random, bisect

def bench(n, queries=1000):
    data = list(range(n))
    targets = [random.randrange(n) for _ in range(queries)]

    start = time.perf_counter()
    for t in targets:
        linear_search(data, t)
    t_lin = time.perf_counter() - start

    start = time.perf_counter()
    for t in targets:
        bisect.bisect_left(data, t)
    t_bin = time.perf_counter() - start

    lookup = set(data)
    start = time.perf_counter()
    for t in targets:
        t in lookup
    t_set = time.perf_counter() - start

    print(f"n={n:>8}  linear {t_lin:8.4f}s   binary {t_bin:7.5f}s   set {t_set:7.5f}s")

for n in [10000, 100000, 1000000]:
    bench(n)
n= 10000 linear 0.4120s binary 0.00081s set 0.00007s n= 100000 linear 4.1932s binary 0.00098s set 0.00007s n= 1000000 linear 42.0517s binary 0.00119s set 0.00007s

Three completely different behaviours in one table: linear multiplies by ten each row, binary creeps up by a fifth (that is log n at work), and the set does not move. Everything in this course is visible in those three columns.

From the beginner notes · Lectures 3, 4, 5

Binary search needs more than sorted values

Binary search repeatedly discards half the positions that might still contain the answer. Its O(log n) search cost requires reaching the middle position in constant time. A sorted array supports that access; a linked list has to walk through links to reach the middle.

With one candidate, a search may still need a comparison. For a conventional successful binary search on n ≥ 1 array items, the worst comparison count is floor(log₂ n) + 1. “About log n” is useful for growth, but an exact count needs an implementation and a rule for what counts as one comparison.

A balanced search tree offers a different route: follow one link per level, keeping the number of levels proportional to log n. An ordinary unbalanced tree can become a chain. Sorted data, random insertion order and guaranteed balance are not interchangeable conditions.

Engineering use. Choose between an array of calibration points and a linked record chain by examining the actual access operations.

Learning goals & class plan
By the end of this week you can
  • write linear search and binary search yourself;
  • trace a binary search by hand, following low, high and middle;
  • name the off-by-one bugs that make binary search loop forever or miss an item, and test against them;
  • explain why binary search needs sorted data, and what that costs;
  • show that log n grows so slowly it is almost constant in practice;
  • decide between scanning, sorting-then-searching, and building a set;
  • use Python's bisect module instead of writing your own.
Three-hour interactive studio

00:00–01:00: launch, predict–run–explain cycle, questions  ·  01:00–01:10: break  ·  01:10–02:00: worked variation, peer instruction, questions  ·  02:00–02:10: break  ·  02:10–03:00: core mechatronics practice, exam bridge, and exit ticket.

Ask at any point. Weekly self-checks stay private; optional extensions are not collected.

Need a slower explanation? Open the English + Türkçe reference guide.

Optional extra practice

12.11Optional Studio Extension

Optional practice · no submission or deadline
  1. Create AA_Week12.ipynb and complete Tasks 1–6.
  2. Reproduce §12.8 at four sizes and plot all three strategies on one log–log figure.
  3. Write the five-sentence interpretation for the plot, naming the class of each line from its slope.
  4. Take your working binary search from Task 6 and add an assertion or comment documenting each of the six boundary cases it survives. Keep it — you may reuse it in the final project.
  5. Choose your final-project problem (see the course home page) and write one paragraph: the task, the data, the two approaches you intend to compare.
Optional reference · Words from this week

12.12Words from this week

TermMeaning in plain words
linear searchCheck items one at a time. O(n), no preparation needed.
binary searchHalve the sorted range each step. O(log n).
preconditionSomething that must be true for an algorithm to be correct — here, "the data is sorted".
off-by-oneA boundary error — a stray ±1 or the wrong comparison — that misses an item or loops forever.
insertion pointThe index where a value would go to keep the list sorted; what bisect returns.
preprocessingWork done once up front (sorting, building a set) to make later queries cheap.
bisectPython's built-in binary search, including "where would this go?".
range query"Everything between x and y" — answerable with order, not with hashing.
Chapter problem set — Skiena 2.10

12.13Chapter problem set — Skiena 2.10

Binary search runs on logarithms, so this is the natural week to meet the log rules and a cluster of Skiena's puzzles that all turn on the same idea: halving, and the number of times you can do it. Throughout, lg means log base 2.

Problem 2-39 · proving the logarithm rules

Give an accessible argument for each identity: (a) loga(xy) = loga x + loga y; (b) loga(xy) = y · loga x; (c) the change-of-base rule loga x = logb x / logb a; and (d) xlogb y = ylogb x.

Worked solution

The one fact behind all four: a logarithm is just "the exponent you need". If loga x = p, that is only shorthand for x = ap. Keep swapping between those two ways of writing the same thing and the rules fall out.

(a) loga(xy) = loga x + loga y. Write x = ap and y = aq. When you multiply powers of the same base you add the exponents: xy = ap+q. So the exponent for xy is p + q, i.e. loga(xy) = p + q = loga x + loga y. (This is exactly why old slide rules turned multiplication into addition.)

(b) loga(xy) = y · loga x. With x = ap, raising to the y-th power multiplies the exponent: xy = apy. So the exponent for xy is py = y · loga x.

(c) change of base. Start from x = ap where p = loga x. Take log base b of both sides and use rule (b): logb x = p · logb a. Divide across: p = logb x / logb a. That is the rule.

(d) xlogb y = ylogb x. Two things are equal when their logs are equal. Take log base b of each side (rule b): the left gives logb y · logb x, the right gives logb x · logb y. Same product, so the two sides are equal.

check_log_rules.py
import math

x, a, b = 50, 2, 10
print(math.log(x, a))                    # log base 2 of 50
print(math.log(x, b) / math.log(a, b))   # change of base — same value

# identity (d): x**(log_b y) should equal y**(log_b x)
print(3 ** math.log(8, 10))              # 2.6968...
print(8 ** math.log(3, 10))              # 2.6968... — equal
5.643856189774724 5.643856189774724 2.696844... 2.696844...

Answer: all four hold, and each is just the exponent rules read through the "log = exponent" lens. Rule (c) is the one you will use most: it says any two bases differ only by the constant factor 1 / logb a. That constant vanishes inside Big-O — which is why the base of the log never matters for complexity, and why we can write O(log n) without ever naming a base.

Problem 2-40 · ⌈lg(n+1)⌉ = ⌊lg n⌋ + 1

Show that the ceiling of lg(n+1) equals the floor of lg n, plus one, for every integer n ≥ 1.

Worked solution

Both sides are secretly counting the same thing: how many bits n needs (equivalently, how many times you halve n before reaching 1). The interesting action is all at the powers of two, so tabulate a few values and watch the two columns agree:

nlg n⌊lg n⌋ + 1lg(n+1)⌈lg(n+1)⌉
10.0011.001
21.0021.582
31.5822.002
42.0032.323
52.3232.583
62.5832.813
72.8133.003
83.0043.174

Why it works: for any n with 2k−1 ≤ n < 2k, the floor ⌊lg n⌋ is k−1, so the left-hand side is k. On the same range n+1 sits in (2k−1, 2k], so lg(n+1) is just above k−1 and at most exactly k, and its ceiling is also k. Both sides are k. The only place to be careful is the boundary n = 2k: there lg n is exactly k so the left side is k+1, and n+1 = 2k+1 pushes lg(n+1) just above k so its ceiling is k+1 too — they still agree (look at the n = 2, 4, 8 rows).

Answer: both sides count the number of bits of n, so ⌈lg(n+1)⌉ = ⌊lg n⌋ + 1 for all n ≥ 1, the two ways of writing "how many halvings to reach 1".

Problem 2-41 · how many bits does n need?

Prove that the binary representation of an integer n ≥ 1 has exactly ⌊lg n⌋ + 1 bits.

Worked solution

The largest number you can write with b bits is 2b − 1 (all ones): 1 bit reaches 1, 2 bits reach 3, 3 bits reach 7, 8 bits reach 255. So a number n needs exactly b bits when it fits in b bits but not in b−1:

2b−1 ≤ n < 2b

Take lg of that double inequality (lg is increasing, so it preserves order): b − 1 ≤ lg n < b. The only integer in the half-open interval [lg n, lg n + 1) whose floor sits at the bottom is b − 1 = ⌊lg n⌋, so b = ⌊lg n⌋ + 1. A small table confirms it:

nbinarybits⌊lg n⌋ + 1
1110 + 1 = 1
21021 + 1 = 2
410032 + 1 = 3
2551111111187 + 1 = 8
25610000000098 + 1 = 9

Answer: ⌊lg n⌋ + 1 bits. This is the same quantity as problem 2-40, and it is exactly why binary search takes about lg n steps — each comparison learns one bit about where the target sits, and a number in a list of n needs ⌊lg n⌋ + 1 bits to pin down.

Problem 2-42 · a sort in O(n log √n)?

Someone shows you a sorting algorithm that runs in O(n log √n). Sorting is known to need Ω(n log n) comparisons in the worst case. How can both be true?

Worked solution

It looks like a contradiction until you simplify the log. Using rule (b) from 2-39, √n = n1/2, so:

log √n = log(n1/2) = ½ · log n

Therefore O(n log √n) = O(n · ½ log n) = O(½ · n log n) = O(n log n) — the one-half is a constant factor, and Big-O throws constant factors away.

Answer: nothing is broken. O(n log √n) is the very same class as O(n log n), just written in a disguise. It does not beat the Ω(n log n) lower bound — it sits right on it. A tidy reminder that "log √n" and "½ log n" are the same thing, and that constant factors never change the class.

Problem 2-46 · marbles and a 100-storey building

You want the lowest floor of a 100-storey building from which a dropped marble breaks. How few drops do you need (a) with a plentiful supply of identical marbles, and (b) with only two marbles?

Worked solution

(a) Plenty of marbles → binary search. A broken marble is no loss, so you can gamble on the middle floor every time: drop from 50. If it breaks, the answer is below 50; if not, above. Each drop halves the range, so you need ⌈lg 100⌉ = 7 drops — the same halving as every other week-12 idea.

(b) Only two marbles → you cannot binary search. If your first marble breaks at floor 50, you have one marble left and no choice but to walk it up one floor at a time from where you last knew it was safe — a linear scan. So a reckless first jump risks up to 49 further drops. The cure is to make the first marble take smaller and smaller steps, so that whichever gap it breaks in, the linear finish with the second marble is short. Drop the first marble at floors 14, then 27, 39, 50, 60, 69, 77, 84, 90, 95, 99, 100 — gaps of 14, 13, 12, … shrinking by one each time.

Why 14? If the first marble breaks on drop number d, you have already spent d drops and the remaining gap to scan is 14 − d floors, for a total of about 14 every time. You want the first gap k big enough that k + (k−1) + … + 1 = k(k+1)/2 covers all 100 floors: the smallest such k is 14, because 14 × 15 / 2 = 105 ≥ 100 while 13 × 14 / 2 = 91 falls short.

Answer: 7 drops with unlimited marbles (binary search); about 14 drops worst case with just two, using the shrinking-gap (triangular- number) strategy. Two marbles cost you the logarithm — you drop from lg n to roughly √n.

Problem 2-47 · ten bags, one weighing

You have 10 bags of coins. Nine hold genuine 10-gram coins; one bag holds only 9-gram forgeries. With a digital scale that reads exact grams, identify the bad bag in a single weighing.

Worked solution

The trick is to make each bag contribute a different, recognisable amount to one pile. Number the bags 1 to 10, and take 1 coin from bag 1, 2 coins from bag 2, 3 from bag 3, …, 10 from bag 10 — 55 coins in all. If every coin were genuine the pile would weigh 55 × 10 = 550 grams. Each forged coin is 1 gram light, and bag k contributed exactly k coins, so a light bag k makes the pile read 550 − k grams. The number of grams missing is the guilty bag's number.

coin_bags.py
weights = [10] * 10          # ten bags, all 10-gram coins to start
weights[6] = 9               # bag number 7 secretly holds 9-gram coins

# take (i+1) coins from bag i (0-indexed) and weigh them all together
total = sum((i + 1) * weights[i] for i in range(10))
print(550 - total)           # 7  -> the light bag is number 7
7

Answer: take k coins from bag k, weigh all 55 together, and read off 550 − (reading) — that difference is the light bag's number. One weighing, no searching: a positional-counting trick that packs ten answers into a single number.

Problem 2-48 · eight balls, two weighings

You have 8 balls that look identical, but one is slightly heavier. Using a balance scale only twice, find the heavy one.

Worked solution

A balance has three outcomes each time — left heavier, right heavier, or level — so two weighings can distinguish up to 3 × 3 = 9 possibilities, and 9 ≥ 8. That count tells you it must be doable; here is how. Split the 8 balls into groups of 3, 3 and 2.

  1. Weighing 1: put the two groups of 3 on the pans.
    • If they balance, the heavy ball is one of the 2 set aside. Weigh those two against each other — the heavier pan shows it. Done in two.
    • If one side is heavier, the heavy ball is among those 3.
  2. Weighing 2 (when the heavy ball is in a group of 3): take that group and weigh any 1 against another 1. If one is heavier, that is it; if they balance, it is the third ball you did not place.

Answer: split 3 / 3 / 2 and you always finish in two weighings. It works because each weighing yields three outcomes, so two weighings carry 3² = 9 distinguishable results — comfortably more than the 8 balls you must tell apart. (The same reasoning shows you could handle up to 9 balls in two weighings, but not 10.)

Where this leads

Binary search only works on sorted data. Week 13 asks what sorting itself costs, and why the sort you invent is so much slower than the built-in one.