๐ŸŽ“ A1Learner
๐Ÿ”

DSA Koko Eating Bananas (Answer-Space Search)

Koko Eating Bananas asks for the slowest speed that finishes every pile within the hour limit. The check for one speed is monotone, so binary search over the speeds finds the slowest working one.

The problem

Koko Eating Bananas asks for the slowest eating speed that finishes every pile within a limit of hours. Koko eats from one pile per hour, at a fixed number of bananas per hour, and a partly eaten pile still costs its full hour.

The speed is a whole number of bananas per hour. The answer is the smallest speed whose total hours fit the limit. A speed equal to the largest pile always works when the limit is at least the number of piles.

The cases used in this article

cases = [
    ([3, 6, 7, 11], 8, 4),
    ([30, 11, 23, 4, 20], 6, 23),
    ([30, 11, 23, 4, 20], 5, 30),
    ([1, 1, 1], 3, 1),
    ([5], 1, 5),
]
for piles, limit, expected in cases:
    print("piles", piles, "limit", limit, "slowest speed:", expected)

Read one row at a time. The third row allows one hour per pile, so the speed must clear the largest pile in a single hour. The last row makes the same point with one pile.

Intuition: dividing the total by the limit ignores the rounding

The first idea adds all the bananas and divides by the hour limit. That gives a fair average speed. It feels like the natural answer.

The average ignores one rule. Each pile rounds its hours up on its own, and a new pile never shares the leftover room in an hour. The average speed below fails its own limit.

The average speed failing its limit

piles = [3, 6, 7, 11]
limit = 8
speed = sum(piles) // limit
hours = 0
for pile in piles:
    share = (pile + speed - 1) // speed
    hours += share
    print("pile:", pile, "hours at speed", speed, ":", share)
print("average speed:", speed, "total hours:", hours, "limit:", limit, "works:", hours <= limit)

Speed 3 needs 10 hours against a limit of 8. Four separate roundings added the extra hours. Any correct method must price each pile on its own.

Brute force: test speeds from 1 upward

The brute force tests speed 1, then 2, then 3, and stops at the first speed that fits the limit. Each test prices every pile with the rounded-up share. A counter records the speeds tested.

The rounded-up share for one pile is pile plus speed minus 1, divided by speed with whole-number division. That formula needs no fractions. It gives 1 hour for any pile at or under the speed.

The brute force, with speeds counted

def hours_for(piles, speed):
    total = 0
    for pile in piles:
        total += (pile + speed - 1) // speed
    return total

def min_speed_brute(piles, limit):
    speed = 1
    tested = 0
    while True:
        tested += 1
        if hours_for(piles, speed) <= limit:
            return speed, tested
        speed += 1

speed, tested = min_speed_brute([3, 6, 7, 11], 8)
print("slowest speed:", speed, "speeds tested:", tested)
speed, tested = min_speed_brute([30, 11, 23, 4, 20], 5)
print("slowest speed:", speed, "speeds tested:", tested)

The second row tested 30 speeds, one per value up to the answer. Each test walked all five piles. A large largest pile makes the upward walk long.

The optimal solution: binary search the answer itself

The check for one speed is a predicate, a test that answers yes or no, and it is monotone, meaning a yes stays yes for every larger speed, so binary search over the speeds finds the first yes. That one sentence is the whole method.

This is answer-space search, the search of the answer itself rather than of the data. The range runs from 1 up to the largest pile. A working middle speed moves the right edge to the middle, and a failing one moves the left edge past it.

Answer-space search on every case

def hours_for(piles, speed):
    total = 0
    for pile in piles:
        total += (pile + speed - 1) // speed
    return total


def min_speed(piles, limit):
    lo = 1
    hi = max(piles)
    while lo < hi:
        mid = (lo + hi) // 2
        if hours_for(piles, mid) <= limit:
            hi = mid
        else:
            lo = mid + 1
    return lo

cases = [
    ([3, 6, 7, 11], 8, 4),
    ([30, 11, 23, 4, 20], 6, 23),
    ([30, 11, 23, 4, 20], 5, 30),
    ([1, 1, 1], 3, 1),
    ([5], 1, 5),
]
for piles, limit, expected in cases:
    result = min_speed(piles, limit)
    print("piles", piles, "limit", limit, "->", result, "expected:", expected, "match:", result == expected)

All five cases from the start of the article pass. The first-yes position is the answer in each row. Speeds below it fail and speeds at or above it pass.

Walkthrough: piles of 3, 6, 7 and 11 with a limit of 8

The walk halves the speed range from 1 to 11. The state is the range and the verdict on its middle speed. Each line prices the guess pile by pile through the total.

The guesses and their verdicts

def hours_for(piles, speed):
    total = 0
    for pile in piles:
        total += (pile + speed - 1) // speed
    return total

piles = [3, 6, 7, 11]
limit = 8
lo = 1
hi = max(piles)
while lo < hi:
    mid = (lo + hi) // 2
    hours = hours_for(piles, mid)
    works = hours <= limit
    print("range", lo, hi, "- guess", mid, "hours:", hours, "works:", works)
    if works:
        hi = mid
    else:
        lo = mid + 1
print("slowest working speed:", lo)

The guesses run 6, 3, 5 and 4. The failing guess at 3 pushes the range up, and the passing guesses pull the right edge down. The range closes on 4, the first speed that passes.

Complexity

The brute force tests up to the largest pile in speeds, and each test prices every pile, so its time is O(largest pile times pile count). Answer-space search tests about log of the largest pile in speeds, so its time is O(log of the largest pile, times pile count).

Both methods store only totals and range edges beyond the input list. Their extra space is O(1).

Counting the guesses instead of timing it

def hours_for(piles, speed):
    total = 0
    for pile in piles:
        total += (pile + speed - 1) // speed
    return total

def binary_guesses(piles, limit):
    lo = 1
    hi = max(piles)
    guesses = 0
    while lo < hi:
        mid = (lo + hi) // 2
        guesses += 1
        if hours_for(piles, mid) <= limit:
            hi = mid
        else:
            lo = mid + 1
    return lo, guesses

def brute_guesses(piles, limit):
    speed = 1
    while hours_for(piles, speed) > limit:
        speed += 1
    return speed, speed

for piles, limit in [([3, 6, 7, 11], 8), ([30, 11, 23, 4, 20], 5), ([100, 90, 80], 3)]:
    answer_b, count_b = brute_guesses(piles, limit)
    answer_s, count_s = binary_guesses(piles, limit)
    print("largest pile", max(piles), "- brute speeds tested:", count_b, " search guesses:", count_s, " answer:", answer_s)

Counts beat clocks here. At a largest pile of 100 the upward walk tests 100 speeds. The search answers in 6 guesses, with the same result.

Edge cases and pitfalls

Speed zero is not a slow option, it is a broken one. It divides by zero in the share formula. The search range starts at 1, so zero is never tested.

A limit equal to the pile count forces one hour per pile. The answer is then the largest pile. A generous limit drops the answer to 1, because even the slowest real speed finishes in time.

Edge cases, run

def hours_for(piles, speed):
    total = 0
    for pile in piles:
        total += (pile + speed - 1) // speed
    return total


def min_speed(piles, limit):
    lo = 1
    hi = max(piles)
    while lo < hi:
        mid = (lo + hi) // 2
        if hours_for(piles, mid) <= limit:
            hi = mid
        else:
            lo = mid + 1
    return lo

print("one pile, one hour:", min_speed([5], 1))
print("one pile, five hours:", min_speed([5], 5))
print("limit equals pile count:", min_speed([30, 11, 23, 4, 20], 5))
print("generous limit:", min_speed([3, 6, 7, 11], 100))
print("hours at speed 1:", hours_for([3, 6], 1))
print("tiny pile at a fast speed:", hours_for([2], 9))

The last row still costs a full hour. A pile never shares an hour with another pile, at any speed. That single rule is what broke the average-speed idea at the start.

Exercise

A speed of 6 finishes in time. What does the monotone check promise about a speed of 9?