Binary Search on the Answer
A shipping company loads packages onto a boat in the order they arrive, and it wants the queue gone in four days. The number you have to produce is the size of the hold, and that number appears nowhere in the input. There is no array to search.
Every capacity answers the same question, though. At this size, does everything ship in four days? Nine tonnes says no. Sixty-one says yes, and so does every size above it, because a bigger hold never forces an extra day. The answers flip once, from no to yes, and finding where they flip is a binary search over the capacities themselves.
const std = @import("std");
/// The problem: package weights in loading order, then the number of days.
const input =
\\5 3 8 2 9 4 7 6 1 8 3 5
\\4
;
/// Read one line of whitespace-separated values into `out`.
fn readRow(reader: *std.Io.Reader, out: []u32) ![]u32 {
const line = (try reader.takeDelimiter('\n')) orelse return error.MissingRow;
var count: usize = 0;
var fields = std.mem.tokenizeScalar(u8, line, ' ');
while (fields.next()) |field| {
if (count == out.len) return error.RowTooLong;
out[count] = try std.fmt.parseInt(u32, field, 10);
count += 1;
}
return out[0..count];
}
/// The ship, the deadline, and a count of how often the question was asked.
///
/// Packages are loaded in the order they arrive, so a capacity fixes the whole
/// plan. Fill today until the next package would overflow the hold, then start
/// tomorrow. `calls` counts every predicate call so the two searches below can
/// be priced against each other.
const Ship = struct {
weights: []const u32,
limit: u32,
calls: usize = 0,
/// Days needed at this capacity, or null when some package cannot be loaded.
fn daysNeeded(self: *Ship, cap: u32) ?u32 {
self.calls += 1;
var days: u32 = 1;
var load: u32 = 0;
for (self.weights) |w| {
if (w > cap) return null;
if (load + w > cap) {
days += 1;
load = 0;
}
load += w;
}
return days;
}
/// Does this capacity finish on time?
///
/// False below the boundary and true at or above it, with one flip in
/// between. Nothing else in this file is allowed to assume that; the run
/// below checks it.
fn feasible(self: *Ship, cap: u32) bool {
const days = self.daysNeeded(cap) orelse return false;
return days <= self.limit;
}
/// The same question with a second condition bolted on: no day may leave
/// more than half the hold empty.
///
/// It sounds like a tidier plan and it is not monotonic. A big ship
/// finishes early and sails half empty on the last day, so a capacity that
/// works can stop working when you add one tonne to it.
fn feasibleAndFull(self: *Ship, cap: u32) bool {
const days = self.daysNeeded(cap) orelse return false;
if (days > self.limit) return false;
var lightest: u32 = std.math.maxInt(u32);
var load: u32 = 0;
for (self.weights) |w| {
if (load + w > cap) {
lightest = @min(lightest, load);
load = 0;
}
load += w;
}
lightest = @min(lightest, load);
return lightest * 2 >= cap;
}
};
const Predicate = *const fn (*Ship, u32) bool;
/// First capacity in `[low, high]` that the predicate accepts.
///
/// The loop is the one from the binary search chapter with the array taken
/// out. `items[mid] < key` becomes `!pred(mid)`: mid is too small to be the
/// answer, so `lo = mid + 1` discards it. The other branch leaves mid a
/// candidate and keeps it. When the window empties, `lo` is the boundary.
fn firstFeasible(ship: *Ship, low: u32, high: u32, pred: Predicate) u32 {
var lo = low;
var hi = high;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
if (pred(ship, mid)) hi = mid else lo = mid + 1;
}
return lo;
}
/// The same walk with a row printed per step.
///
/// Kept separate so the function above stays the shape you would paste into a
/// solution. The run checks that the two return the same capacity.
fn traceFirstFeasible(out: *std.Io.Writer, ship: *Ship, low: u32, high: u32) !u32 {
var lo = low;
var hi = high;
var step: u32 = 0;
try out.writeAll(" step lo hi mid days ok window after\n");
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
step += 1;
const days = ship.daysNeeded(mid);
const ok = days != null and days.? <= ship.limit;
try out.print(" {d:>4} {d:>4} {d:>4} {d:>4} {d:>4} {s:<4} ", .{ step, lo, hi, mid, days orelse 0, if (ok) "yes" else "no" });
if (ok) hi = mid else lo = mid + 1;
try out.print("[{d:>3},{d:>3}] {d} left\n", .{ lo, hi, hi - lo + 1 });
}
try out.print(" window closed after {d} steps on capacity {d}\n", .{ step, lo });
return lo;
}
/// One day of the plan: the packages loaded, then the tonnage.
fn writeDay(out: *std.Io.Writer, day: u32, items: []const u32, load: u32) !void {
try out.print(" day {d}:", .{day});
var used: usize = 0;
for (items) |w| {
try out.print(" {d:>2}", .{w});
used += 3;
}
if (used < 38) try out.splatByteAll(' ', 38 - used);
try out.print("load {d:>2}\n", .{load});
}
/// Run the greedy pack at one capacity and print the plan it produces.
fn writePacking(out: *std.Io.Writer, ship: *Ship, cap: u32) !void {
try out.print("capacity {d}, packed in arrival order\n", .{cap});
var start: usize = 0;
var load: u32 = 0;
var day: u32 = 1;
for (ship.weights, 0..) |w, i| {
if (w > cap) {
try out.print(" package of {d} does not fit at all\n", .{w});
return;
}
if (load + w > cap) {
try writeDay(out, day, ship.weights[start..i], load);
day += 1;
start = i;
load = 0;
}
load += w;
}
try writeDay(out, day, ship.weights[start..], load);
try out.print(" {d} days against a limit of {d} -> {s}\n\n", .{
day,
ship.limit,
if (day <= ship.limit) "feasible" else "too slow",
});
}
/// What an exhaustive scan of the range found.
const Scan = struct {
first_true: ?u32,
flips: usize,
calls: usize,
};
/// Ask the predicate about every capacity in the range and draw the answers.
///
/// One character per capacity, so the shape of the predicate is visible rather
/// than assumed. A monotonic predicate flips once. Anything else is not a
/// question binary search can answer.
fn writeScan(out: *std.Io.Writer, ship: *Ship, low: u32, high: u32, pred: Predicate) !Scan {
const before = ship.calls;
var first_true: ?u32 = null;
var flips: usize = 0;
var previous = false;
try out.print(" {d:>3} ", .{low});
var cap = low;
while (cap <= high) : (cap += 1) {
const ok = pred(ship, cap);
try out.writeByte(if (ok) '#' else '.');
if (ok and first_true == null) first_true = cap;
if (cap > low and ok != previous) flips += 1;
previous = ok;
}
try out.print(" {d}\n", .{high});
return .{
.first_true = first_true,
.flips = flips,
.calls = ship.calls - before,
};
}
pub fn main(init: std.process.Init) !void {
var buf: [4096]u8 = undefined;
var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
const out = &file_writer.interface;
var reader: std.Io.Reader = .fixed(input);
var weight_storage: [64]u32 = undefined;
var day_storage: [1]u32 = undefined;
const weights = try readRow(&reader, &weight_storage);
const days = try readRow(&reader, &day_storage);
var heaviest: u32 = 0;
var total: u32 = 0;
for (weights) |w| {
heaviest = @max(heaviest, w);
total += w;
}
var ship: Ship = .{ .weights = weights, .limit = days[0] };
try out.print("{d} packages, {d} days\n ", .{ weights.len, ship.limit });
for (weights) |w| try out.print("{d:>3}", .{w});
try out.print("\n heaviest {d}, total {d}\n\n", .{ heaviest, total });
// The bounds. Nothing under the heaviest package can hold it, and one day
// at the total always works, so the boundary is somewhere in between.
try out.print("search range: [{d}, {d}], {d} candidates\n\n", .{
heaviest,
total,
total - heaviest + 1,
});
// What one predicate call actually does.
try writePacking(out, &ship, heaviest + (total - heaviest) / 2);
ship.calls = 0;
try out.writeAll("binary search over the capacities\n");
const answer = try traceFirstFeasible(out, &ship, heaviest, total);
const search_calls = ship.calls;
try out.print(" plain firstFeasible agrees -> {}\n\n", .{
answer == firstFeasible(&ship, heaviest, total, Ship.feasible),
});
// The two packings either side of the boundary are the whole claim.
try out.writeAll("either side of the boundary\n");
try writePacking(out, &ship, answer - 1);
try writePacking(out, &ship, answer);
ship.calls = 0;
try out.print("every capacity from {d} to {d}, '#' means feasible\n", .{ heaviest, total });
const scan = try writeScan(out, &ship, heaviest, total, Ship.feasible);
try out.print(" first '#' at {?d}, binary search said {d}, agree -> {}\n", .{
scan.first_true,
answer,
scan.first_true == answer,
});
try out.print(" the predicate flips {d} time, so it is monotonic\n", .{scan.flips});
try out.print(" predicate calls: {d} scanning, {d} searching\n\n", .{ scan.calls, search_calls });
// Move a bound and the search still returns a number.
const average = total / ship.limit;
const bad = firstFeasible(&ship, heaviest, average, Ship.feasible);
try out.print("with the high bound set to total / days = {d}\n", .{average});
try out.print(" the search returns {d} and feasible({d}) = {s}\n", .{
bad,
bad,
if (ship.feasible(bad)) "yes" else "no",
});
try out.print(" {d} days needed there, limit {d}\n\n", .{ ship.daysNeeded(bad).?, ship.limit });
// A predicate that flickers. The loop terminates, returns a capacity, and
// that capacity is not the smallest one the predicate accepts.
ship.calls = 0;
try out.writeAll("now require every day to fill at least half the hold\n");
const flicker = try writeScan(out, &ship, heaviest, total, Ship.feasibleAndFull);
const found = firstFeasible(&ship, heaviest, total, Ship.feasibleAndFull);
try out.print(" the predicate flips {d} times, so there is no boundary\n", .{flicker.flips});
try out.print(" binary search returns {d}, first '#' is at {?d}\n", .{ found, flicker.first_true });
var accepted_below: u32 = 0;
var cap = heaviest;
while (cap < found) : (cap += 1) {
if (ship.feasibleAndFull(cap)) accepted_below += 1;
}
try out.print(" feasibleAndFull({d}) = {}, and {d} smaller capacities pass too\n", .{
found,
ship.feasibleAndFull(found),
accepted_below,
});
try out.flush();
}The question you can afford to ask
/// Does this capacity finish on time?
///
/// False below the boundary and true at or above it, with one flip in
/// between. Nothing else in this file is allowed to assume that; the run
/// below checks it.
fn feasible(self: *Ship, cap: u32) bool {
const days = self.daysNeeded(cap) orelse return false;
return days <= self.limit;
}Packages are loaded in arrival order, so one capacity settles the whole plan. Keep adding to today’s load until the next package would overflow the hold, then start tomorrow. Nothing is left to choose, and two runs of the packer at the same capacity produce the same days.
daysNeeded returns null when a single package outweighs the hold. Such a
capacity cannot load that package at all, so there is no day count to compare
against the limit, and feasible reads the null as a no.
/// Days needed at this capacity, or null when some package cannot be loaded.
fn daysNeeded(self: *Ship, cap: u32) ?u32 {
self.calls += 1;
var days: u32 = 1;
var load: u32 = 0;
for (self.weights) |w| {
if (w > cap) return null;
if (load + w > cap) {
days += 1;
load = 0;
}
load += w;
}
return days;
}One call, printed in full:
capacity 35, packed in arrival order
day 1: 5 3 8 2 9 4 load 31
day 2: 7 6 1 8 3 5 load 30
2 days against a limit of 4 -> feasible
Thirty-five tonnes finishes with two days spare. The search takes one bit out
of all that: yes. Every call walks the weights once, and that walk is the n
in the cost at the end of this page.
Both ends come from the input
The low bound is the heaviest package. Nine tonnes has to go somewhere, and only a hold of nine or more can take it, so no capacity below nine is worth a call. The high bound is the total. Sixty-one tonnes is a single day of loading, and a deadline of one day or more accepts that, so the answer is at or below it.
Both ends are read off the input rather than picked, and both stay right when
the input changes. A bound chosen by feel is where this pattern goes wrong,
and nothing reports it. The average load per day, total / days, has the shape
of a ceiling:
with the high bound set to total / days = 15
the search returns 15 and feasible(15) = no
6 days needed there, limit 4
Fifteen comes back looking exactly like a real answer. The loop behaved perfectly. No capacity in its window was feasible, so it narrowed to the top of the window and stopped there, holding a number that needs six days for a four-day deadline. Two assertions at the end of a solve catch this every time: the answer must be feasible, and the value one below it must not be.
The same loop, with a function in place of the array
/// First capacity in `[low, high]` that the predicate accepts.
///
/// The loop is the one from the binary search chapter with the array taken
/// out. `items[mid] < key` becomes `!pred(mid)`: mid is too small to be the
/// answer, so `lo = mid + 1` discards it. The other branch leaves mid a
/// candidate and keeps it. When the window empties, `lo` is the boundary.
fn firstFeasible(ship: *Ship, low: u32, high: u32, pred: Predicate) u32 {
var lo = low;
var hi = high;
while (lo < hi) {
const mid = lo + (hi - lo) / 2;
if (pred(ship, mid)) hi = mid else lo = mid + 1;
}
return lo;
}Binary Search works through
the half-open window and the invariant that keeps these four lines correct. One
comparison changed. items[mid] < key meant that mid is too small to be the
answer, and !pred(ship, mid) says the same about a capacity, so lo = mid + 1
discards it along with everything below. The other branch leaves mid a
candidate and keeps it.
Written this way, firstFeasible is lowerBound with the array swapped for a
function. The indices were already the numbers under search. The array was only
supplying values to compare against, and the predicate supplies them now.
binary search over the capacities
step lo hi mid days ok window after
1 9 61 35 2 yes [ 9, 35] 27 left
2 9 35 22 4 yes [ 9, 22] 14 left
3 9 22 15 6 no [ 16, 22] 7 left
4 16 22 19 4 yes [ 16, 19] 4 left
5 16 19 17 4 yes [ 16, 17] 2 left
6 16 17 16 4 yes [ 16, 16] 1 left
window closed after 6 steps on capacity 16
Fifty-three candidates, six calls. Step three is the only no in the table, and it is the one that lifts the floor: 15 needs six days, so 15 and everything under it leave the window at once.
Either side of the boundary
capacity 15, packed in arrival order
day 1: 5 3 load 8
day 2: 8 2 load 10
day 3: 9 4 load 13
day 4: 7 6 1 load 14
day 5: 8 3 load 11
day 6: 5 load 5
6 days against a limit of 4 -> too slow
The deadline rejects that plan. One extra tonne of hold rewrites it:
capacity 16, packed in arrival order
day 1: 5 3 8 load 16
day 2: 2 9 4 load 15
day 3: 7 6 1 load 14
day 4: 8 3 5 load 16
4 days against a limit of 4 -> feasible
Six days become four, not five. The packer does not adjust the old plan. It repacks from the first package, so every later day boundary moves along with the first one that changed.
The scan that has to agree
/// Ask the predicate about every capacity in the range and draw the answers.
///
/// One character per capacity, so the shape of the predicate is visible rather
/// than assumed. A monotonic predicate flips once. Anything else is not a
/// question binary search can answer.
fn writeScan(out: *std.Io.Writer, ship: *Ship, low: u32, high: u32, pred: Predicate) !Scan {
const before = ship.calls;
var first_true: ?u32 = null;
var flips: usize = 0;
var previous = false;
try out.print(" {d:>3} ", .{low});
var cap = low;
while (cap <= high) : (cap += 1) {
const ok = pred(ship, cap);
try out.writeByte(if (ok) '#' else '.');
if (ok and first_true == null) first_true = cap;
if (cap > low and ok != previous) flips += 1;
previous = ok;
}
try out.print(" {d}\n", .{high});
return .{
.first_true = first_true,
.flips = flips,
.calls = ship.calls - before,
};
}Fifty-three candidates are few enough to ask about every one of them and draw the answers, one character per capacity:
every capacity from 9 to 61, '#' means feasible
9 .......############################################## 61
first '#' at 16, binary search said 16, agree -> true
the predicate flips 1 time, so it is monotonic
predicate calls: 53 scanning, 6 searching
Seven dots, then hashes to the end. The picture shows the precondition directly, and the flip count is how to check it on a small case: exactly one change of answer across the range means there is a boundary to find.
Six calls against fifty-three is not much of a win. Change the numbers and it
becomes the whole solution. A limit of a billion tonnes gives a range of a
billion, and thirty calls cover it, because the window halves every step and
2^30 is a little over a billion. Each call is one pass over the n packages, so
the search costs O(n log range) while the scan beside it pays O(n) for every
capacity in the range. A billion of those passes does not finish inside a time
limit.
When the predicate flickers
/// The same question with a second condition bolted on: no day may leave
/// more than half the hold empty.
///
/// It sounds like a tidier plan and it is not monotonic. A big ship
/// finishes early and sails half empty on the last day, so a capacity that
/// works can stop working when you add one tonne to it.
fn feasibleAndFull(self: *Ship, cap: u32) bool {
const days = self.daysNeeded(cap) orelse return false;
if (days > self.limit) return false;
var lightest: u32 = std.math.maxInt(u32);
var load: u32 = 0;
for (self.weights) |w| {
if (load + w > cap) {
lightest = @min(lightest, load);
load = 0;
}
load += w;
}
lightest = @min(lightest, load);
return lightest * 2 >= cap;
}Requiring every day to fill at least half the hold sounds like the same problem with a tidy plan attached. It removes the property the search is built on. A hold big enough to finish early leaves the last day nearly empty, so growing the capacity can turn a yes back into a no:
now require every day to fill at least half the hold
9 .......####...####....#############.................# 61
the predicate flips 7 times, so there is no boundary
binary search returns 31, first '#' is at 16
feasibleAndFull(31) = true, and 8 smaller capacities pass too
Seven flips, four separate runs of hashes, and the loop ran over them without complaint. It returned 31, which does satisfy the predicate. It is not the smallest capacity that does, and eight smaller ones are sitting to its left in the picture. Nothing in the loop can see them: each step looks at one candidate and throws away half the range on the strength of it.
The check before you write the loop is the argument, not the picture. Give the capacity one more tonne and every day ends at least as far along the list as it did before, by induction from the first day, so the day count cannot rise. The half-full rule has no such argument behind it. Where the argument is not obvious, print the picture on a small case and count the flips before trusting six calls over fifty-three.