Card 02/ 08
Find the First Faulty Build: From Testing Every Build to Halving
Testing builds in order costs one test per build; because every build after the first faulty one is also faulty, each test can rule out half of the rest.
Halving the range of builds finds the first faulty one in O(log n) tests, where testing them in order costs one test per build. A team has 16 builds of an app, numbered in the order they were made. Build 1 worked and build 16 is known to be faulty: something broke along the way and stayed broken. Each test means installing a build and running it, so tests are slow, and the task is to find the first faulty build with as few of them as possible.
The brute force tests build 1, then build 2, and so on, stopping at the first faulty one. That costs one test per build up to the answer, O(n) tests for n builds, and here, where the answer is build 11, it takes 11. The repeated work is that each fine result says more than the loop uses: once a build is fine, every build before it is fine too, and once a build is faulty, every build after it is faulty. The predicate "this build is faulty" is monotone, false up to the first faulty build and true from there on, so one test can rule out half of the builds that remain.
In orderO(n) tests
Test build 1, then 2, then 3, and stop at the first faulty one. Each test rules out one build.
HalvingO(log n) tests
Test the middle build of the range. Faulty means the first faulty build is this one or earlier; fine means it is later. Each test rules out half the range.
Inputbuilds 1 to 16; build 16 is known to be faulty
Start
- 1low
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16high
The first faulty build is somewhere from 1 to 16
Test 1: build 8 fine
- 1low
- 2
- 3
- 4
- 5
- 6
- 7
- 8mid
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16high
Build 8 is fine, so builds 1 to 8 are all fine: low moves to 9
Test 2: build 12 faulty
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9low
- 10
- 11
- 12mid
- 13
- 14
- 15
- 16high
Build 12 is faulty, so the first faulty build is 12 or earlier: high moves to 12
Test 3: build 10 fine
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9low
- 10mid
- 11
- 12high
- 13
- 14
- 15
- 16
Build 10 is fine, so builds 9 and 10 are too: low moves to 11
Test 4: build 11 faulty
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11lowmid
- 12high
- 13
- 14
- 15
- 16
Build 11 is faulty: high moves to 11
Done
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11lowhigh
- 12
- 13
- 14
- 15
- 16
low meets high at build 11 after four tests
ResultBuild 11 is the first faulty build, found in 4 tests.
The listing runs both versions on the same 16 builds and counts the tests each one made.
first_faulty = 11
newest = 16
def is_faulty(build: int) -> bool:
return build >= first_faulty
def linear() -> tuple[int, int]:
tests = 0
for build in range(1, newest + 1):
tests += 1
if is_faulty(build):
return build, tests
return newest, tests
def halving() -> tuple[int, int]:
low, high, tests = 1, newest, 0
while low < high:
mid = low + (high - low) // 2
tests += 1
if is_faulty(mid):
high = mid
else:
low = mid + 1
return low, tests
print("testing in order: build %d after %d tests" % linear())
print("halving: build %d after %d tests" % halving())testing in order: build 11 after 11 tests
halving: build 11 after 4 testsBoth find build 11, the first in 11 tests and the second in 4. For 1,000 builds the halving search needs at most 10 tests, against up to 1,000 in order, because 2¹⁰ = 1,024. The search starts with high at 16 rather than one past it, since build 16 is already known to be faulty and the answer is certain to lie in the range. The same reasoning drives tools that hunt for the change that broke a program, such as git bisect.
← → move through sections and cards