🚀 Alguni Start learning

Unit 5: Cut It In Half

Binary search, and searching the answer itself.

Unit 5 of 25 in Competitive programming for kids. Its 4 lessons are Guess the Number, The First One That Fits, Search the Answer Itself and Meet in the Middle — below is everything each one explains, and a question or two from it to try.

Every sample on this page was run through real Python before it shipped, and prints exactly what it says it prints.

This unit opens with a fortnight’s trial of everything — no card needed — or with a family plan, bought in the iPhone app. The first two units of every track are free for ever. Try it in the app.

🎯 Guess the Number

Halving beats walking, and it is not close

Guess a number between 1 and 100. Guess the middle, hear "higher" or "lower", and throw away half the numbers each time.

Six guesses here. A billion numbers would take thirty.

Python

secret = 73
low = 1
high = 100
steps = 0

while low <= high:
    mid = (low + high) // 2
    steps += 1
    if mid == secret:
        break
    if mid < secret:
        low = mid + 1
    else:
        high = mid - 1

print(mid, steps)

It prints

73 6

The same loop, on a sorted list

The list is sorted, so the middle item tells you which half the target could be in. Keep two ends and pull them together.

Return the position when you land on it, and -1 when the ends cross without a hit.

Python

def find(numbers, target):
    low = 0
    high = len(numbers) - 1
    while low <= high:
        mid = (low + high) // 2
        if numbers[mid] == target:
            return mid
        if numbers[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1

numbers = [2, 4, 8, 15, 16, 23, 42]
print(find(numbers, 15), find(numbers, 5))

It prints

3 -1

How many halvings a list can take

Each guess halves what is left, so the number of guesses is log2 n — rounded up. It grows so slowly that the size of the list barely matters.

One warning that costs people whole contests: mid + 1 and mid - 1 must be there. Without them the ends stop moving and the loop never finishes.

Python

import math

for n in [100, 1000000, 1000000000]:
    print(n, math.floor(math.log2(n)) + 1)

It prints

100 7
1000000 20
1000000000 30

Try it yourself

A sorted list of a billion numbers. Roughly how many steps does binary search take?

  • A billion
  • A thousand
  • Thirty
  • Three

What does this print?

Python

def find(numbers, target):
    low = 0
    high = len(numbers) - 1
    steps = 0
    while low <= high:
        mid = (low + high) // 2
        steps += 1
        if numbers[mid] == target:
            return steps
        if numbers[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return steps

print(find([1, 2, 3, 4, 5, 6, 7], 7))

Answer them in the app

📍 The First One That Fits

Usually you do not want an exact match

Real questions sound like "how many are under 50?" or "where would 7 go?". Those want the first position holding something not smaller than the target — its lower bound.

The loop is subtly different: low < high, and high starts one past the end because the answer may be "off the end".

Python

def lower_bound(numbers, target):
    low = 0
    high = len(numbers)
    while low < high:
        mid = (low + high) // 2
        if numbers[mid] < target:
            low = mid + 1
        else:
            high = mid
    return low

numbers = [1, 3, 3, 3, 7, 9]
print(lower_bound(numbers, 3), lower_bound(numbers, 4), lower_bound(numbers, 10))

It prints

1 4 6

Why high = mid and not mid - 1

The item at mid might be the answer, so it must stay inside the range. That is exactly why this version never uses <=: with high = mid, a low <= high loop would spin for ever.

Hold the meaning steady instead of memorising the punctuation: everything before low is too small, everything from high on is not.

Two lower bounds count anything you like

How many 3s are in the list? The first position of a 3, subtracted from the first position of a 4. Two binary searches, no counting.

The same subtraction gives "how many are between 3 and 8" and every question shaped like it.

Python

def lower_bound(numbers, target):
    low = 0
    high = len(numbers)
    while low < high:
        mid = (low + high) // 2
        if numbers[mid] < target:
            low = mid + 1
        else:
            high = mid
    return low

numbers = [1, 3, 3, 3, 7, 9]
print(lower_bound(numbers, 4) - lower_bound(numbers, 3))
print(lower_bound(numbers, 8) - lower_bound(numbers, 3))

It prints

3
4

And now you may use the library

Python ships this as bisect. bisect_left is the lower bound you just wrote; bisect_right is the first position past the last equal item.

Use it in a contest — but only now that you know what it does, because when a problem needs a lower bound over something that is not a list, you will have to write the loop again.

Python

import bisect

numbers = [1, 3, 3, 3, 7, 9]
print(bisect.bisect_left(numbers, 3), bisect.bisect_right(numbers, 3))
print(bisect.bisect_right(numbers, 3) - bisect.bisect_left(numbers, 3))

It prints

1 4
3

Try it yourself

What does a lower bound return when every number in the list is smaller than the target?

  • -1
  • The last position
  • The length of the list
  • 0

What does this print?

Python

def lower_bound(numbers, target):
    low = 0
    high = len(numbers)
    while low < high:
        mid = (low + high) // 2
        if numbers[mid] < target:
            low = mid + 1
        else:
            high = mid
    return low

numbers = [2, 4, 6, 8]
print(lower_bound(numbers, 5), lower_bound(numbers, 6), lower_bound(numbers, 1))

Answer them in the app

🪓 Search the Answer Itself

Nothing is sorted, and there is no list

Three planks of wood, 7cm, 9cm and 3cm. Cut them into pieces that are all the same whole number of centimetres, and give one piece to each of four friends. Waste is allowed.

How long can each piece be? There is no list to search — but the answer is a number, and the answers behave themselves.

The question that flips exactly once

Ask: "can I get 4 pieces of length L?" Length 1 — easily. Length 3 — yes, six pieces. Length 4 — only three. Length 5 and up — no.

Yes, yes, yes, no, no, no. It flips once and never flips back, and that is all binary search has ever needed.

Python

planks = [7, 9, 3]

def pieces(length):
    return sum(p // length for p in planks)

for length in range(1, 10):
    print(length, pieces(length), pieces(length) >= 4)

It prints

1 19 True
2 8 True
3 6 True
4 3 False
5 2 False
6 2 False
7 2 False
8 1 False
9 1 False

So binary search the length

Low is 1, high is the longest plank. Test the middle: if it works, remember it and try longer; if not, go shorter.

The answer is 3. Thirty tests would handle planks a billion centimetres long.

Python

planks = [7, 9, 3]
friends = 4

def pieces(length):
    return sum(p // length for p in planks)

low = 1
high = max(planks)
best = 0
while low <= high:
    mid = (low + high) // 2
    if pieces(mid) >= friends:
        best = mid
        low = mid + 1
    else:
        high = mid - 1

print(best)

It prints

3

The one thing that has to be true

The yes/no question must be monotone: once it turns from yes to no, it can never go back to yes.

Here it holds because shorter pieces can only be easier to cut. If your question wobbles — yes, no, yes — binary search will confidently return nonsense, and it will look right on your own small example.

Try it yourself

Which of these questions is safe to binary search on?

  • Is this number prime?
  • Can the job be finished in at most T minutes?
  • Is this number a perfect square?
  • Does this number have exactly 3 bits set?

What does this print?

Python

planks = [10, 10]

def pieces(length):
    return sum(p // length for p in planks)

low = 1
high = 10
best = 0
while low <= high:
    mid = (low + high) // 2
    if pieces(mid) >= 3:
        best = mid
        low = mid + 1
    else:
        high = mid - 1

print(best)

Answer them in the app

🏆 Meet in the Middle

Forty numbers is too many to try

Back to subset sums, but with 40 numbers instead of 20. Trying every subset is 2 ** 40 — about a million million. Out of reach.

But 2 ** 20 is only a million, and it is reachable twice.

Cut the list in half and list both sides

Every subset of the whole list is some subset of the left half plus some subset of the right half. So build all the sums of each half separately.

Ten numbers: 1024 subsets in total, but only 32 and 32 to list.

Python

numbers = [3, 34, 4, 12, 5, 2, 9, 14, 7, 6]

def sums(part):
    result = []
    for mask in range(1 << len(part)):
        total = 0
        for i in range(len(part)):
            if (mask >> i) & 1:
                total += part[i]
        result.append(total)
    return result

first = sums(numbers[:5])
second = sums(numbers[5:])
print(len(first), len(second), 1 << 10)

It prints

32 32 1024

Then join the halves with a binary search

Sort the second half's sums. Now for each sum s from the first half, ask whether target - s is in the sorted pile — one binary search each.

So the cost is 2 20 to build, plus 2 20 binary searches. Millions instead of millions of millions.

Python

numbers = [3, 34, 4, 12, 5, 2, 9, 14, 7, 6]

def sums(part):
    result = []
    for mask in range(1 << len(part)):
        total = 0
        for i in range(len(part)):
            if (mask >> i) & 1:
                total += part[i]
        result.append(total)
    return result

def contains(values, wanted):
    low = 0
    high = len(values) - 1
    while low <= high:
        mid = (low + high) // 2
        if values[mid] == wanted:
            return True
        if values[mid] < wanted:
            low = mid + 1
        else:
            high = mid - 1
    return False

first = sums(numbers[:5])
second = sorted(sums(numbers[5:]))

def reachable(target):
    for s in first:
        if contains(second, target - s):
            return True
    return False

print(reachable(41), reachable(95), reachable(96))

It prints

True False True

Why 95 is out of reach

The ten numbers add up to 96. Reaching 95 would mean leaving out exactly 1 — and there is no 1 to leave out.

Worth noticing because it is a check you can do by hand, on a program whose answer you otherwise have to trust.

Try it yourself

Splitting 40 numbers into two halves turns a million million into roughly what?

  • Half a million million
  • A few million
  • A thousand
  • Forty

What does this print?

Python

def sums(part):
    result = []
    for mask in range(1 << len(part)):
        total = 0
        for i in range(len(part)):
            if (mask >> i) & 1:
                total += part[i]
        result.append(total)
    return result

print(sorted(sums([2, 3])))

Answer them in the app