Sliding Window
Two questions about the same ten numbers. The largest sum of four consecutive values, and the shortest run of values adding up to 19 or more. Both answers are a contiguous slice, and both fall out of two indices that only ever move right.
The trace below brackets the window on every row. Beside it sit the value that came in, the value that dropped out, and what the total became.
const std = @import("std");
/// The problem, as text.
///
/// A snippet here runs under WASI in a browser tab, where there is no stdin to
/// read. The first line is the values, the second is the window width and the
/// target the second half of the program has to reach.
const input =
\\2 7 1 9 4 3 8 5 6 2
\\4 19
;
/// A second array, for the case the shrinking window gets wrong.
///
/// One negative value is enough. `2 + 3` reaches 5 in two values, and the
/// window never sees that pair, because it only ever shrinks a window it has
/// already grown past.
const with_a_negative = [_]i32{ 2, -1, 2, 3 };
/// Read one line of whitespace-separated integers into `out`.
///
/// Taking a `*std.Io.Reader` rather than the string is the same discipline the
/// networking chapters use for protocols. Point it at stdin on a judge and not
/// a line of it changes.
fn readRow(reader: *std.Io.Reader, out: []i32) ![]i32 {
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(i32, field, 10);
count += 1;
}
return out[0..count];
}
/// Half-open, so `items[span.lo..span.hi]` is the window and `span.hi` is one
/// past its last value. The same convention the binary search chapter uses.
const Span = struct {
lo: usize,
hi: usize,
fn len(self: Span) usize {
return self.hi - self.lo;
}
};
/// Largest sum of `k` consecutive values, by summing every window.
///
/// `adds` counts the additions, because the cost is the point. Each of the
/// `n - k + 1` windows costs `k` additions, so the work grows with the product
/// and a wide window on a long array is slow for no reason.
fn resumMaxWindow(items: []const i32, k: usize, adds: *usize) ?i64 {
if (k == 0 or k > items.len) return null;
var best: i64 = std.math.minInt(i64);
for (0..items.len - k + 1) |lo| {
var sum: i64 = 0;
for (items[lo..][0..k]) |value| {
sum += value;
adds.* += 1;
}
best = @max(best, sum);
}
return best;
}
/// The same answer, from one addition and one subtraction per step.
///
/// Two windows a step apart differ by two values: the one that entered on the
/// right and the one that left on the left. Everything between them is in both
/// sums, so re-adding it is work already done. Sum the first window, then keep
/// the total and repair it.
///
/// `i64` and not `i32`. Ten small values fit either way, and a contest array of
/// a hundred thousand values near the limit of `i32` does not.
fn slidingMaxWindow(items: []const i32, k: usize, adds: *usize) ?i64 {
if (k == 0 or k > items.len) return null;
var sum: i64 = 0;
for (items[0..k]) |value| {
sum += value;
adds.* += 1;
}
var best = sum;
for (k..items.len) |hi| {
sum += items[hi]; // entering on the right
sum -= items[hi - k]; // leaving on the left
adds.* += 2;
best = @max(best, sum);
}
return best;
}
/// Shortest run of values summing to `target` or more.
///
/// The right edge grows unconditionally, one value per pass. The left edge only
/// moves while the window still qualifies, and every window it passes through is
/// a candidate. `lo` never goes backwards, so each index enters once and leaves
/// at most once and the inner loop cannot run more than `items.len` times over
/// the whole call.
///
/// The shrink rule is what needs the values to be non-negative: dropping a value
/// from the left has to lower the sum, or a window that stopped qualifying might
/// have qualified again later.
fn shortestAtLeast(items: []const i32, target: i64, moves: *usize) ?Span {
var lo: usize = 0;
var sum: i64 = 0;
var best: ?Span = null;
for (items, 0..) |value, hi| {
sum += value;
moves.* += 1;
while (sum >= target) {
const span: Span = .{ .lo = lo, .hi = hi + 1 };
if (best == null or span.len() < best.?.len()) best = span;
sum -= items[lo];
lo += 1;
moves.* += 1;
}
}
return best;
}
/// The same answer, from every starting index in turn.
///
/// This one is genuinely quadratic. The outer loop fixes a start, the inner one
/// extends until the sum reaches the target, and the next start throws away
/// everything the previous one learned. `sums` counts the additions so the two
/// numbers can be compared rather than asserted.
fn everyStartAtLeast(items: []const i32, target: i64, sums: *usize) ?Span {
var best: ?Span = null;
for (0..items.len) |lo| {
var sum: i64 = 0;
for (lo..items.len) |hi| {
sum += items[hi];
sums.* += 1;
if (sum >= target) {
const span: Span = .{ .lo = lo, .hi = hi + 1 };
if (best == null or span.len() < best.?.len()) best = span;
break;
}
}
}
return best;
}
/// Right-align a value in a cell of `width` characters.
///
/// `{d:>4}` would be shorter, and it prints a `+` in front of a non-negative
/// signed integer as soon as a width is given. Formatting the digits first and
/// padding them keeps the columns readable.
fn writeCell(out: *std.Io.Writer, value: i64, width: usize) !void {
var digits: [24]u8 = undefined;
const text = try std.mem.print(&digits, "{d}", .{value});
try out.splatByteAll(' ', width - text.len);
try out.writeAll(text);
}
/// The array with the live window drawn into it.
///
/// Four characters per value: a bracket or a space, the value in two, a bracket
/// or a space. Every row lines up, so the window is something to watch move
/// rather than something to reconstruct from two indices.
fn writeWindow(out: *std.Io.Writer, items: []const i32, span: Span) !void {
const inside = span.lo < span.hi;
for (items, 0..) |value, i| {
try out.writeByte(if (inside and i == span.lo) '[' else ' ');
try writeCell(out, value, 2);
const closing: u8 = if (inside and i + 1 == span.hi) ']' else ' ';
// No trailing space at the end of a row: the expected output is diffed
// byte for byte, and an invisible column is a bad thing to depend on.
if (closing == ' ' and i + 1 == items.len) break;
try out.writeByte(closing);
}
}
/// One trace row: a label, three counters, then the window.
///
/// A counter of `null` prints a dot, which is what the first row of the fixed
/// trace needs and what the shortest-run trace needs before it has an answer.
fn writeRow(
out: *std.Io.Writer,
label: []const u8,
a: ?i64,
b: ?i64,
c: ?i64,
items: []const i32,
span: Span,
) !void {
try out.print(" {s: <8}", .{label});
for ([_]?i64{ a, b, c }) |maybe| {
if (maybe) |value| try writeCell(out, value, 6) else try out.writeAll(" .");
}
try out.writeAll(" ");
try writeWindow(out, items, span);
try out.writeByte('\n');
}
/// `slidingMaxWindow` with the window printed at every step.
///
/// Kept separate so the function above stays the shape you would paste into a
/// solution. The run below checks that the two agree rather than trusting it.
fn traceSlidingMaxWindow(out: *std.Io.Writer, items: []const i32, k: usize) !i64 {
try out.writeAll(" move enter leave sum window\n");
var sum: i64 = 0;
for (items[0..k]) |value| sum += value;
var best = sum;
try writeRow(out, "first", null, null, sum, items, .{ .lo = 0, .hi = k });
for (k..items.len) |hi| {
const entering = items[hi];
const leaving = items[hi - k];
sum += entering;
sum -= leaving;
best = @max(best, sum);
try writeRow(out, "slide", entering, leaving, sum, items, .{ .lo = hi - k + 1, .hi = hi + 1 });
}
return best;
}
/// `shortestAtLeast` with a row per move.
///
/// A `grow` row shows the value that entered and the window after it. A `shrink`
/// row shows the window that just qualified and the value about to leave it, so
/// the candidate the loop recorded is the one on the page.
fn traceShortestAtLeast(out: *std.Io.Writer, items: []const i32, target: i64) !?Span {
try out.writeAll(" move value sum len window\n");
var lo: usize = 0;
var sum: i64 = 0;
var best: ?Span = null;
for (items, 0..) |value, hi| {
sum += value;
try writeRow(out, "grow", value, sum, @intCast(hi + 1 - lo), items, .{ .lo = lo, .hi = hi + 1 });
while (sum >= target) {
const span: Span = .{ .lo = lo, .hi = hi + 1 };
if (best == null or span.len() < best.?.len()) best = span;
try writeRow(out, "shrink", items[lo], sum, @intCast(span.len()), items, span);
sum -= items[lo];
lo += 1;
}
}
return best;
}
/// Print a span as the values it covers, or say there is no answer.
fn writeSpan(out: *std.Io.Writer, items: []const i32, maybe: ?Span) !void {
const span = maybe orelse {
try out.writeAll("no run reaches the target\n");
return;
};
try out.print("items[{d}..{d}], {d} values summing to ", .{ span.lo, span.hi, span.len() });
var sum: i64 = 0;
for (items[span.lo..span.hi]) |value| sum += value;
try out.print("{d}\n", .{sum});
}
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 value_storage: [64]i32 = undefined;
var param_storage: [2]i32 = undefined;
const values = try readRow(&reader, &value_storage);
const params = try readRow(&reader, ¶m_storage);
const k: usize = @intCast(params[0]);
const target: i64 = params[1];
try out.print("{d} values parsed from the input\n", .{values.len});
try out.writeAll(" idx ");
for (0..values.len) |i| try out.print("{d:>4}", .{i});
try out.writeAll("\n val ");
for (values) |v| try writeCell(out, v, 4);
try out.writeAll("\n\n");
// A fixed window. The sum is repaired rather than rebuilt.
try out.print("largest sum of {d} consecutive values\n", .{k});
const traced = try traceSlidingMaxWindow(out, values, k);
var sliding_adds: usize = 0;
var resum_adds: usize = 0;
const rolled = slidingMaxWindow(values, k, &sliding_adds).?;
const resummed = resumMaxWindow(values, k, &resum_adds).?;
try out.print(" best {d}, traced {d}, re-summed {d}\n", .{ rolled, traced, resummed });
try out.print(
" {d} additions sliding, {d} re-summing every window\n\n",
.{ sliding_adds, resum_adds },
);
// A window that changes size. The right edge grows, the left edge shrinks.
try out.print("shortest run of values summing to {d} or more\n", .{target});
const shortest = try traceShortestAtLeast(out, values, target);
try out.writeAll(" answer: ");
try writeSpan(out, values, shortest);
var moves: usize = 0;
var starts: usize = 0;
const windowed = shortestAtLeast(values, target, &moves);
const brute = everyStartAtLeast(values, target, &starts);
try out.print(
" window and every-start agree -> {}\n",
.{windowed.?.len() == brute.?.len()},
);
// The amortised argument, as two numbers rather than a claim. Every index
// enters the window once and leaves at most once, so the moves cannot pass
// 2n however the inner loop happens to run.
try out.print(
" {d} moves over {d} values, ceiling 2n = {d}\n",
.{ moves, values.len, 2 * values.len },
);
try out.print(" {d} additions to try every starting index instead\n\n", .{starts});
// The shrink rule needs the sum to fall when a value leaves on the left.
try out.writeAll("the same window on an array with a negative value\n");
try out.writeAll(" val ");
for (&with_a_negative) |v| try writeCell(out, v, 4);
try out.writeAll("\n");
var ignored: usize = 0;
var also_ignored: usize = 0;
try out.writeAll(" window says ");
try writeSpan(out, &with_a_negative, shortestAtLeast(&with_a_negative, 5, &ignored));
try out.writeAll(" every start says ");
try writeSpan(out, &with_a_negative, everyStartAtLeast(&with_a_negative, 5, &also_ignored));
// The fixed window is unaffected: it never asks which values to keep.
var negative_adds: usize = 0;
try out.print(
" largest sum of 2 consecutive values is still {d}\n",
.{slidingMaxWindow(&with_a_negative, 2, &negative_adds).?},
);
try out.flush();
}Re-summing is the whole cost
/// Largest sum of `k` consecutive values, by summing every window.
///
/// `adds` counts the additions, because the cost is the point. Each of the
/// `n - k + 1` windows costs `k` additions, so the work grows with the product
/// and a wide window on a long array is slow for no reason.
fn resumMaxWindow(items: []const i32, k: usize, adds: *usize) ?i64 {
if (k == 0 or k > items.len) return null;
var best: i64 = std.math.minInt(i64);
for (0..items.len - k + 1) |lo| {
var sum: i64 = 0;
for (items[lo..][0..k]) |value| {
sum += value;
adds.* += 1;
}
best = @max(best, sum);
}
return best;
}Nothing here is wrong. Seven windows, four additions each, and the answer comes
back as 24. The cost is the problem. There are n - k + 1 windows and each one
costs k additions, so the work grows with the product of the two. On 200,000
values with a window of 1,000 that is about 200 million additions for an answer
the judge expects in under a second.
The waste is easy to see once you look at two windows in a row. They overlap in
k - 1 values. Summing the second one adds up a run that was already added up a
moment ago, and then throws the total away.
One addition, one subtraction
/// The same answer, from one addition and one subtraction per step.
///
/// Two windows a step apart differ by two values: the one that entered on the
/// right and the one that left on the left. Everything between them is in both
/// sums, so re-adding it is work already done. Sum the first window, then keep
/// the total and repair it.
///
/// `i64` and not `i32`. Ten small values fit either way, and a contest array of
/// a hundred thousand values near the limit of `i32` does not.
fn slidingMaxWindow(items: []const i32, k: usize, adds: *usize) ?i64 {
if (k == 0 or k > items.len) return null;
var sum: i64 = 0;
for (items[0..k]) |value| {
sum += value;
adds.* += 1;
}
var best = sum;
for (k..items.len) |hi| {
sum += items[hi]; // entering on the right
sum -= items[hi - k]; // leaving on the left
adds.* += 2;
best = @max(best, sum);
}
return best;
}Sum the first window the plain way. After that, keep the total and repair it. Two neighbouring windows differ by exactly two values, so one addition puts the entering value in and one subtraction takes the leaving value out:
move enter leave sum window
first . . 19 [ 2 7 1 9] 4 3 8 5 6 2
slide 4 2 21 2 [ 7 1 9 4] 3 8 5 6 2
slide 3 7 17 2 7 [ 1 9 4 3] 8 5 6 2
slide 8 1 24 2 7 1 [ 9 4 3 8] 5 6 2
Row three gains 3 and loses 7, so the total drops by four. The two values in the middle were never touched. The program counts the additions both ways:
16 additions sliding, 28 re-summing every window
Sixteen against twenty-eight is not much on ten values. The rates are the point.
Sliding costs 2n - k additions, so a wider window makes it slightly cheaper.
Re-summing costs k(n - k + 1), which peaks when the window is half the array.
The running total is i64 while the values are i32. Ten small numbers fit
either type. A hundred thousand values near the top of i32 do not, and a sum
that overflows partway along is a wrong answer with no crash to point at it.
The window that changes size
/// Shortest run of values summing to `target` or more.
///
/// The right edge grows unconditionally, one value per pass. The left edge only
/// moves while the window still qualifies, and every window it passes through is
/// a candidate. `lo` never goes backwards, so each index enters once and leaves
/// at most once and the inner loop cannot run more than `items.len` times over
/// the whole call.
///
/// The shrink rule is what needs the values to be non-negative: dropping a value
/// from the left has to lower the sum, or a window that stopped qualifying might
/// have qualified again later.
fn shortestAtLeast(items: []const i32, target: i64, moves: *usize) ?Span {
var lo: usize = 0;
var sum: i64 = 0;
var best: ?Span = null;
for (items, 0..) |value, hi| {
sum += value;
moves.* += 1;
while (sum >= target) {
const span: Span = .{ .lo = lo, .hi = hi + 1 };
if (best == null or span.len() < best.?.len()) best = span;
sum -= items[lo];
lo += 1;
moves.* += 1;
}
}
return best;
}The shortest run reaching a target has no fixed width to slide, so the two edges move on different rules. The right edge advances once per pass, unconditionally. The left edge advances only while the window still reaches the target, and every window it passes through on the way is a candidate.
A shrink row in the trace shows the window that just qualified and the value
about to leave it:
grow 6 22 4 2 7 1 9 4 [ 3 8 5 6] 2
shrink 3 22 4 2 7 1 9 4 [ 3 8 5 6] 2
shrink 8 19 3 2 7 1 9 4 3 [ 8 5 6] 2
grow 2 13 3 2 7 1 9 4 3 8 [ 5 6 2]
Adding 6 takes the sum to 22, past the target, so four values are recorded. Dropping the 3 leaves 8, 5 and 6 summing to 19, still at the target and one value shorter, so the best length falls to three. Dropping the 8 takes the sum to 11, the window stops qualifying, and the right edge takes over again.
answer: items[6..9], 3 values summing to 19
Shrinking first and recording afterwards would miss that. The window has to be recorded while it still qualifies, which is why the candidate is taken at the top of the loop body and the subtraction happens after it.
The inner loop is not a second pass
/// The same answer, from every starting index in turn.
///
/// This one is genuinely quadratic. The outer loop fixes a start, the inner one
/// extends until the sum reaches the target, and the next start throws away
/// everything the previous one learned. `sums` counts the additions so the two
/// numbers can be compared rather than asserted.
fn everyStartAtLeast(items: []const i32, target: i64, sums: *usize) ?Span {
var best: ?Span = null;
for (0..items.len) |lo| {
var sum: i64 = 0;
for (lo..items.len) |hi| {
sum += items[hi];
sums.* += 1;
if (sum >= target) {
const span: Span = .{ .lo = lo, .hi = hi + 1 };
if (best == null or span.len() < best.?.len()) best = span;
break;
}
}
}
return best;
}A while inside a for reads as quadratic, and this version is. Each starting
index extends a fresh sum until it reaches the target, and the next starting
index throws away everything the previous one learned.
The window version has the same nesting and is not quadratic, because lo never
goes backwards. The outer loop adds each index to the sum once. The inner loop
subtracts each index at most once, ever, across the whole call. Neither counter
can move more than n times, so the total number of moves cannot pass 2n
whatever the inner loop does on any single pass. Some passes shrink twice, some
do not shrink at all, and the bound holds regardless:
17 moves over 10 values, ceiling 2n = 20
34 additions to try every starting index instead
Ten grows and seven shrinks, against a ceiling of twenty. The counters cost four
lines and settle a question the reasoning usually gets wrong. The nested loop in
the window is bounded by the array. The nested loop above it, which looks the
same on the page, is bounded by n^2 / 2.
That argument transfers. Any inner loop that only advances a counter the outer loop never resets is amortised away, however deeply it is nested.
When the shrink rule stops holding
/// A second array, for the case the shrinking window gets wrong.
///
/// One negative value is enough. `2 + 3` reaches 5 in two values, and the
/// window never sees that pair, because it only ever shrinks a window it has
/// already grown past.
const with_a_negative = [_]i32{ 2, -1, 2, 3 };The shrink loop stops the moment the sum falls under the target, and moves on having decided that no shorter window starting further left could reach it. That decision needs one property: removing a value from the left has to lower the sum. Non-negative values buy that property, and nothing else does.
One negative value takes it away:
val 2 -1 2 3
window says items[0..4], 4 values summing to 6
every start says items[2..4], 2 values summing to 5
The whole array sums to 6, so the window records a length of four. Then it drops
the leading 2, the sum falls to 3, and the shrinking stops with the -1 still
inside. The real answer is the last two values, and the window never looks at
that pair on its own. The every-start version gets it right and pays the
quadratic price for it.
The fixed window is untouched by this. It never decides anything. It adds one value and subtracts another whatever their signs, so the same run still gets the right answer on this array:
largest sum of 2 consecutive values is still 5
So the test for a window problem is not that the answer is contiguous. The condition has to be monotone in the width, meaning growing the window can only push it one way and shrinking can only push it back. A sum of non-negative values qualifies, and so do the count of distinct characters in a run and the number of times a forbidden value appears. Once that fails, the tool is a prefix sum, a deque or a heap instead.
Two indices that start at opposite ends of a sorted array and walk towards each other are a different pattern with a different invariant. Two Pointers covers those.