From 60896fa97fa5a657a0b5d546f21e2dd2ced3b5ce Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 01/11] feat: window minima with a monotonic deque --- README.md | 3 ++- algorithm_lab/sliding_window_min.py | 31 +++++++++++++++++++++++++++++ tests/test_sliding_window_min.py | 21 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/sliding_window_min.py create mode 100644 tests/test_sliding_window_min.py diff --git a/README.md b/README.md index ab49ac2..537c28d 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (137 algorithms) +## Catalog (138 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -174,6 +174,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [minimum_jumps](algorithm_lab/minimum_jumps.py) | Sequences | Fewest forward jumps to the final array position | | [maximal_square](algorithm_lab/maximal_square.py) | Dynamic programming | Largest all-one square with deterministic coordinates | | [triangle_min_path](algorithm_lab/triangle_min_path.py) | Dynamic programming | Minimum sum down an integer triangle with path reconstruction | +| [sliding_window_min](algorithm_lab/sliding_window_min.py) | Sequences | Window minima with a monotonic deque | ## Validation diff --git a/algorithm_lab/sliding_window_min.py b/algorithm_lab/sliding_window_min.py new file mode 100644 index 0000000..43dca44 --- /dev/null +++ b/algorithm_lab/sliding_window_min.py @@ -0,0 +1,31 @@ +"""Window minima with a monotonic deque. + +Values must be mutually comparable under a total order; NaN is unsupported. +width is an integer in 1..len(values), excluding bool. Return one minimum +per window; equal values retain the earliest candidate. O(n) time and O(n) +copied/output storage, with O(width) deque storage. + +>>> sliding_window_min([4, 2, 2, 5, 1], 3) +[2, 2, 1] +""" + +from collections import deque + +from algorithm_lab._validation import integer + + +def sliding_window_min(values, width): + values = list(values) + integer(width, "width", minimum=1) + if width > len(values): + raise ValueError("width must not exceed input length") + candidates, result = deque(), [] + for i, value in enumerate(values): + while candidates and candidates[0] <= i - width: + candidates.popleft() + while candidates and value < values[candidates[-1]]: + candidates.pop() + candidates.append(i) + if i + 1 >= width: + result.append(values[candidates[0]]) + return result diff --git a/tests/test_sliding_window_min.py b/tests/test_sliding_window_min.py new file mode 100644 index 0000000..3b89d6f --- /dev/null +++ b/tests/test_sliding_window_min.py @@ -0,0 +1,21 @@ +import random +import unittest + +from algorithm_lab.sliding_window_min import sliding_window_min + + +class Tests(unittest.TestCase): + def test_window_oracle(self): + rng = random.Random(239) + for n in range(1, 50): + values = [rng.randrange(-5, 6) for _ in range(n)] + for width in range(1, n + 1): + self.assertEqual( + sliding_window_min(values, width), + [min(values[i : i + width]) for i in range(n - width + 1)], + ) + + def test_invalid(self): + for values, width in [([], 1), ([1], 0), ([1], 2), ([1], True)]: + with self.assertRaises(ValueError): + sliding_window_min(values, width) From 792cfb3e01e66c323d619735e5b637116da14668 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 02/11] feat: count contiguous target-sum slices using prefix frequencies --- README.md | 3 ++- algorithm_lab/subarray_sum_count.py | 22 ++++++++++++++++++++++ tests/test_subarray_sum_count.py | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/subarray_sum_count.py create mode 100644 tests/test_subarray_sum_count.py diff --git a/README.md b/README.md index 537c28d..46a0139 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (138 algorithms) +## Catalog (139 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -175,6 +175,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [maximal_square](algorithm_lab/maximal_square.py) | Dynamic programming | Largest all-one square with deterministic coordinates | | [triangle_min_path](algorithm_lab/triangle_min_path.py) | Dynamic programming | Minimum sum down an integer triangle with path reconstruction | | [sliding_window_min](algorithm_lab/sliding_window_min.py) | Sequences | Window minima with a monotonic deque | +| [subarray_sum_count](algorithm_lab/subarray_sum_count.py) | Sequences | Count contiguous target-sum slices using prefix frequencies | ## Validation diff --git a/algorithm_lab/subarray_sum_count.py b/algorithm_lab/subarray_sum_count.py new file mode 100644 index 0000000..7e6d100 --- /dev/null +++ b/algorithm_lab/subarray_sum_count.py @@ -0,0 +1,22 @@ +"""Count contiguous target-sum slices using prefix frequencies. + +Values and target are integers excluding bool. Return the number of nonempty +contiguous slices whose sum equals target. Negative values and repeated +prefix sums are supported. O(n) expected dictionary time and O(n) space. + +>>> subarray_sum_count([0, 0, 0], 0) +6 +""" + +from algorithm_lab._validation import integer + + +def subarray_sum_count(values, target): + integer(target, "target") + frequencies, prefix, result = {0: 1}, 0, 0 + for value in values: + integer(value) + prefix += value + result += frequencies.get(prefix - target, 0) + frequencies[prefix] = frequencies.get(prefix, 0) + 1 + return result diff --git a/tests/test_subarray_sum_count.py b/tests/test_subarray_sum_count.py new file mode 100644 index 0000000..9a94bd5 --- /dev/null +++ b/tests/test_subarray_sum_count.py @@ -0,0 +1,20 @@ +import unittest +from itertools import product + +from algorithm_lab.subarray_sum_count import subarray_sum_count + + +class Tests(unittest.TestCase): + def test_all_slices_oracle(self): + for n in range(6): + for values in product([-1, 0, 1], repeat=n): + for target in range(-2, 3): + expected = sum( + sum(values[i:j]) == target for i in range(n) for j in range(i + 1, n + 1) + ) + self.assertEqual(subarray_sum_count(values, target), expected) + + def test_invalid(self): + for values, target in [([True], 0), ([1.5], 0), ([], True)]: + with self.assertRaises(ValueError): + subarray_sum_count(values, target) From b8c297b05afb2d6ead8810d3ed58964e07b914a5 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 03/11] feat: maximum contiguous integer product with signed extrema --- README.md | 3 ++- algorithm_lab/max_product_subarray.py | 26 ++++++++++++++++++++++++++ tests/test_max_product_subarray.py | 18 ++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/max_product_subarray.py create mode 100644 tests/test_max_product_subarray.py diff --git a/README.md b/README.md index 46a0139..96e7ec1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (139 algorithms) +## Catalog (140 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -176,6 +176,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [triangle_min_path](algorithm_lab/triangle_min_path.py) | Dynamic programming | Minimum sum down an integer triangle with path reconstruction | | [sliding_window_min](algorithm_lab/sliding_window_min.py) | Sequences | Window minima with a monotonic deque | | [subarray_sum_count](algorithm_lab/subarray_sum_count.py) | Sequences | Count contiguous target-sum slices using prefix frequencies | +| [max_product_subarray](algorithm_lab/max_product_subarray.py) | Sequences | Maximum contiguous integer product with signed extrema | ## Validation diff --git a/algorithm_lab/max_product_subarray.py b/algorithm_lab/max_product_subarray.py new file mode 100644 index 0000000..6aa1e2a --- /dev/null +++ b/algorithm_lab/max_product_subarray.py @@ -0,0 +1,26 @@ +"""Maximum contiguous integer product with signed extrema. + +Accept a nonempty iterable of integers excluding bool. Return the largest +product of any nonempty contiguous slice. Tracking both maximum and minimum +products handles negative factors and zeros. O(n) arithmetic steps and O(1) +stored integers; integer multiplication cost depends on operand bit length. + +>>> max_product_subarray([-2, 3, -4]) +24 +""" + +from algorithm_lab._validation import integer + + +def max_product_subarray(values): + low = high = best = None + for value in values: + integer(value) + if best is None: + low = high = best = value + else: + low, high = min(value, low * value, high * value), max(value, low * value, high * value) + best = max(best, high) + if best is None: + raise ValueError("input must be nonempty") + return best diff --git a/tests/test_max_product_subarray.py b/tests/test_max_product_subarray.py new file mode 100644 index 0000000..9db35ed --- /dev/null +++ b/tests/test_max_product_subarray.py @@ -0,0 +1,18 @@ +import unittest +from itertools import product +from math import prod + +from algorithm_lab.max_product_subarray import max_product_subarray + + +class Tests(unittest.TestCase): + def test_all_products(self): + for n in range(1, 6): + for values in product([-2, -1, 0, 2], repeat=n): + expected = max(prod(values[i:j]) for i in range(n) for j in range(i + 1, n + 1)) + self.assertEqual(max_product_subarray(values), expected) + + def test_invalid(self): + for values in [[], [True], [1.5]]: + with self.assertRaises(ValueError): + max_product_subarray(values) From f57717c8c21a96f0bb9de47fe7d924f5f674edcd Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 04/11] feat: maximum nonempty circular slice sum --- README.md | 3 ++- algorithm_lab/circular_max_subarray.py | 25 +++++++++++++++++++++++++ tests/test_circular_max_subarray.py | 21 +++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/circular_max_subarray.py create mode 100644 tests/test_circular_max_subarray.py diff --git a/README.md b/README.md index 96e7ec1..f4a6748 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (140 algorithms) +## Catalog (141 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -177,6 +177,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [sliding_window_min](algorithm_lab/sliding_window_min.py) | Sequences | Window minima with a monotonic deque | | [subarray_sum_count](algorithm_lab/subarray_sum_count.py) | Sequences | Count contiguous target-sum slices using prefix frequencies | | [max_product_subarray](algorithm_lab/max_product_subarray.py) | Sequences | Maximum contiguous integer product with signed extrema | +| [circular_max_subarray](algorithm_lab/circular_max_subarray.py) | Sequences | Maximum nonempty circular slice sum | ## Validation diff --git a/algorithm_lab/circular_max_subarray.py b/algorithm_lab/circular_max_subarray.py new file mode 100644 index 0000000..c9c1dae --- /dev/null +++ b/algorithm_lab/circular_max_subarray.py @@ -0,0 +1,25 @@ +"""Maximum nonempty circular slice sum. + +Accept nonempty integer values excluding bool. Each index may be used at +most once. Return the maximum sum over all nonempty circular slices. +All-negative inputs return their greatest element. O(n) time and O(n) copied +input storage; the Kadane recurrence itself uses O(1) integers. + +>>> circular_max_subarray([5, -3, 5]) +10 +""" + +from algorithm_lab._validation import integer + + +def circular_max_subarray(values): + values = list(values) + if not values: + raise ValueError("input must be nonempty") + for value in values: + integer(value) + high = low = best = worst = total = values[0] + for value in values[1:]: + high, low = max(value, high + value), min(value, low + value) + best, worst, total = max(best, high), min(worst, low), total + value + return best if best < 0 else max(best, total - worst) diff --git a/tests/test_circular_max_subarray.py b/tests/test_circular_max_subarray.py new file mode 100644 index 0000000..49ef2d7 --- /dev/null +++ b/tests/test_circular_max_subarray.py @@ -0,0 +1,21 @@ +import unittest +from itertools import product + +from algorithm_lab.circular_max_subarray import circular_max_subarray + + +class Tests(unittest.TestCase): + def test_circular_slice_oracle(self): + for n in range(1, 7): + for values in product([-2, 0, 3], repeat=n): + expected = max( + sum(values[(start + i) % n] for i in range(length)) + for start in range(n) + for length in range(1, n + 1) + ) + self.assertEqual(circular_max_subarray(values), expected) + + def test_invalid(self): + for values in [[], [True], [1.5]]: + with self.assertRaises(ValueError): + circular_max_subarray(values) From e68441b71d09a162472e951d73d1377abcc9493d Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 05/11] feat: count smaller successors for every position with a fenwick tree --- README.md | 3 ++- algorithm_lab/inversion_vector.py | 30 ++++++++++++++++++++++++++++++ tests/test_inversion_vector.py | 19 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/inversion_vector.py create mode 100644 tests/test_inversion_vector.py diff --git a/README.md b/README.md index f4a6748..6da96e1 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (141 algorithms) +## Catalog (142 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -178,6 +178,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [subarray_sum_count](algorithm_lab/subarray_sum_count.py) | Sequences | Count contiguous target-sum slices using prefix frequencies | | [max_product_subarray](algorithm_lab/max_product_subarray.py) | Sequences | Maximum contiguous integer product with signed extrema | | [circular_max_subarray](algorithm_lab/circular_max_subarray.py) | Sequences | Maximum nonempty circular slice sum | +| [inversion_vector](algorithm_lab/inversion_vector.py) | Sequences | Count smaller successors for every position with a Fenwick tree | ## Validation diff --git a/algorithm_lab/inversion_vector.py b/algorithm_lab/inversion_vector.py new file mode 100644 index 0000000..eb216b1 --- /dev/null +++ b/algorithm_lab/inversion_vector.py @@ -0,0 +1,30 @@ +"""Count smaller successors for every position with a Fenwick tree. + +Input contains integers excluding bool. Return result[i] as the number of +strictly smaller values to the right of i; equal values do not count. +Coordinate compression and a Fenwick tree take O(n log n) time and O(n) +space. The input remains unchanged. + +>>> inversion_vector([5, 2, 6, 1]) +[2, 1, 1, 0] +""" + +from algorithm_lab._validation import integer + + +def inversion_vector(values): + values = list(values) + for value in values: + integer(value) + ranks = {value: i + 1 for i, value in enumerate(sorted(set(values)))} + tree, result = [0] * (len(ranks) + 1), [0] * len(values) + for i in range(len(values) - 1, -1, -1): + index = ranks[values[i]] - 1 + while index: + result[i] += tree[index] + index -= index & -index + index = ranks[values[i]] + while index < len(tree): + tree[index] += 1 + index += index & -index + return result diff --git a/tests/test_inversion_vector.py b/tests/test_inversion_vector.py new file mode 100644 index 0000000..8095d08 --- /dev/null +++ b/tests/test_inversion_vector.py @@ -0,0 +1,19 @@ +import unittest +from itertools import product + +from algorithm_lab.inversion_vector import inversion_vector + + +class Tests(unittest.TestCase): + def test_pair_count_oracle(self): + for n in range(7): + for values in product([-1, 0, 1], repeat=n): + expected = [ + sum(other < value for other in values[i + 1 :]) + for i, value in enumerate(values) + ] + self.assertEqual(inversion_vector(values), expected) + + def test_invalid(self): + with self.assertRaises(ValueError): + inversion_vector([True]) From 258dce97b6cb2a1ff1eb75d88b5126a35bd2fb47 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 06/11] feat: reconstruct a strictly increasing then decreasing subsequence --- README.md | 3 +- algorithm_lab/longest_bitonic_subsequence.py | 40 ++++++++++++++++++++ tests/test_longest_bitonic_subsequence.py | 30 +++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/longest_bitonic_subsequence.py create mode 100644 tests/test_longest_bitonic_subsequence.py diff --git a/README.md b/README.md index 6da96e1..e42341e 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (142 algorithms) +## Catalog (143 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -179,6 +179,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [max_product_subarray](algorithm_lab/max_product_subarray.py) | Sequences | Maximum contiguous integer product with signed extrema | | [circular_max_subarray](algorithm_lab/circular_max_subarray.py) | Sequences | Maximum nonempty circular slice sum | | [inversion_vector](algorithm_lab/inversion_vector.py) | Sequences | Count smaller successors for every position with a Fenwick tree | +| [longest_bitonic_subsequence](algorithm_lab/longest_bitonic_subsequence.py) | Sequences | Reconstruct a strictly increasing then decreasing subsequence | ## Validation diff --git a/algorithm_lab/longest_bitonic_subsequence.py b/algorithm_lab/longest_bitonic_subsequence.py new file mode 100644 index 0000000..c600ef6 --- /dev/null +++ b/algorithm_lab/longest_bitonic_subsequence.py @@ -0,0 +1,40 @@ +"""Reconstruct a strictly increasing then decreasing subsequence. + +Accept integers excluding bool. Either slope may be empty. Return values of +one longest bitonic subsequence; ties choose the earliest peak and earliest +predecessor/successor found by ascending index scans. Empty input returns []. +O(n^2) time and O(n) space. + +>>> longest_bitonic_subsequence([1, 3, 5, 4, 2]) +[1, 3, 5, 4, 2] +""" + +from algorithm_lab._validation import integer + + +def longest_bitonic_subsequence(values): + values = list(values) + for value in values: + integer(value) + n = len(values) + if not n: + return [] + up, down, previous, following = [1] * n, [1] * n, [-1] * n, [-1] * n + for i in range(n): + for j in range(i): + if values[j] < values[i] and up[j] + 1 > up[i]: + up[i], previous[i] = up[j] + 1, j + for i in range(n - 1, -1, -1): + for j in range(i + 1, n): + if values[j] < values[i] and down[j] + 1 > down[i]: + down[i], following[i] = down[j] + 1, j + peak = max(range(n), key=lambda i: up[i] + down[i]) + left, i = [], peak + while i != -1: + left.append(values[i]) + i = previous[i] + result, i = left[::-1], following[peak] + while i != -1: + result.append(values[i]) + i = following[i] + return result diff --git a/tests/test_longest_bitonic_subsequence.py b/tests/test_longest_bitonic_subsequence.py new file mode 100644 index 0000000..f5ffd51 --- /dev/null +++ b/tests/test_longest_bitonic_subsequence.py @@ -0,0 +1,30 @@ +import unittest +from itertools import product + +from algorithm_lab.longest_bitonic_subsequence import longest_bitonic_subsequence + + +def bitonic(values): + return not values or any( + all(values[i] < values[i + 1] for i in range(peak)) + and all(values[i] > values[i + 1] for i in range(peak, len(values) - 1)) + for peak in range(len(values)) + ) + + +class Tests(unittest.TestCase): + def test_subsequence_enumeration(self): + for n in range(6): + for values in product(range(3), repeat=n): + subs = [ + tuple(v for i, v in enumerate(values) if mask >> i & 1) + for mask in range(1 << n) + ] + actual = tuple(longest_bitonic_subsequence(values)) + self.assertIn(actual, subs) + self.assertTrue(bitonic(actual)) + self.assertEqual(len(actual), max(len(s) for s in subs if bitonic(s))) + + def test_invalid(self): + with self.assertRaises(ValueError): + longest_bitonic_subsequence([True]) From 471c7a15863e2a3eec0b7ae61afdfc86b6e9b2b2 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 07/11] feat: greedy minimum-cardinality coverage of a closed target interval --- README.md | 3 +- algorithm_lab/minimum_interval_cover.py | 40 +++++++++++++++++++++++++ tests/test_minimum_interval_cover.py | 37 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/minimum_interval_cover.py create mode 100644 tests/test_minimum_interval_cover.py diff --git a/README.md b/README.md index e42341e..46289ea 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (143 algorithms) +## Catalog (144 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -180,6 +180,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [circular_max_subarray](algorithm_lab/circular_max_subarray.py) | Sequences | Maximum nonempty circular slice sum | | [inversion_vector](algorithm_lab/inversion_vector.py) | Sequences | Count smaller successors for every position with a Fenwick tree | | [longest_bitonic_subsequence](algorithm_lab/longest_bitonic_subsequence.py) | Sequences | Reconstruct a strictly increasing then decreasing subsequence | +| [minimum_interval_cover](algorithm_lab/minimum_interval_cover.py) | Intervals | Greedy minimum-cardinality coverage of a closed target interval | ## Validation diff --git a/algorithm_lab/minimum_interval_cover.py b/algorithm_lab/minimum_interval_cover.py new file mode 100644 index 0000000..8651593 --- /dev/null +++ b/algorithm_lab/minimum_interval_cover.py @@ -0,0 +1,40 @@ +"""Greedy minimum-cardinality coverage of a closed target interval. + +Input intervals and target endpoints are integers, excluding bool, with +start < stop. Return original indices of a minimum cover or None for a gap. +Touching endpoints cover continuously. Greedy ties prefer the lowest input +index among equal farthest endpoints. O(n log n) time and O(n) space. + +>>> minimum_interval_cover([(0, 2), (1, 4), (3, 5)], 0, 5) +[0, 1, 2] +""" + +from algorithm_lab._validation import integer + + +def minimum_interval_cover(intervals, start, stop): + integer(start, "start") + integer(stop, "stop") + if start >= stop: + raise ValueError("target must have positive length") + ordered = [] + for i, (left, right) in enumerate(intervals): + integer(left, "left") + integer(right, "right") + if left >= right: + raise ValueError("intervals must have positive length") + ordered.append((left, right, i)) + ordered.sort() + cursor, current, result = 0, start, [] + while current < stop: + farthest, index = current, None + while cursor < len(ordered) and ordered[cursor][0] <= current: + _, right, i = ordered[cursor] + if right > farthest or (right == farthest and index is not None and i < index): + farthest, index = right, i + cursor += 1 + if index is None: + return None + result.append(index) + current = farthest + return result diff --git a/tests/test_minimum_interval_cover.py b/tests/test_minimum_interval_cover.py new file mode 100644 index 0000000..21dc15e --- /dev/null +++ b/tests/test_minimum_interval_cover.py @@ -0,0 +1,37 @@ +import random +import unittest +from itertools import combinations + +from algorithm_lab.minimum_interval_cover import minimum_interval_cover + + +def covers(intervals): + current = 0 + for left, right in sorted(intervals): + if left > current: + break + current = max(current, right) + return current >= 5 + + +class Tests(unittest.TestCase): + def test_subset_oracle(self): + rng = random.Random(245) + for _ in range(100): + intervals = [] + for _ in range(7): + left = rng.randrange(-1, 6) + intervals.append((left, left + rng.randrange(1, 5))) + expected = next( + (k for k in range(8) if any(covers(sub) for sub in combinations(intervals, k))), + None, + ) + actual = minimum_interval_cover(intervals, 0, 5) + self.assertEqual(None if actual is None else len(actual), expected) + if actual is not None: + self.assertTrue(covers([intervals[i] for i in actual])) + + def test_invalid(self): + for intervals, start, stop in [([], 1, 1), ([(1, 1)], 0, 2), ([(True, 2)], 0, 2)]: + with self.assertRaises(ValueError): + minimum_interval_cover(intervals, start, stop) From a24427d647b46113a1c8202ba19710002751daf5 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 08/11] feat: sweep-line maximum overlap for half-open integer intervals --- README.md | 3 ++- algorithm_lab/interval_overlap_peak.py | 29 ++++++++++++++++++++++++++ tests/test_interval_overlap_peak.py | 25 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/interval_overlap_peak.py create mode 100644 tests/test_interval_overlap_peak.py diff --git a/README.md b/README.md index 46289ea..bf7e729 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (144 algorithms) +## Catalog (145 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -181,6 +181,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [inversion_vector](algorithm_lab/inversion_vector.py) | Sequences | Count smaller successors for every position with a Fenwick tree | | [longest_bitonic_subsequence](algorithm_lab/longest_bitonic_subsequence.py) | Sequences | Reconstruct a strictly increasing then decreasing subsequence | | [minimum_interval_cover](algorithm_lab/minimum_interval_cover.py) | Intervals | Greedy minimum-cardinality coverage of a closed target interval | +| [interval_overlap_peak](algorithm_lab/interval_overlap_peak.py) | Intervals | Sweep-line maximum overlap for half-open integer intervals | ## Validation diff --git a/algorithm_lab/interval_overlap_peak.py b/algorithm_lab/interval_overlap_peak.py new file mode 100644 index 0000000..9a5ff6f --- /dev/null +++ b/algorithm_lab/interval_overlap_peak.py @@ -0,0 +1,29 @@ +"""Sweep-line maximum overlap for half-open integer intervals. + +Intervals satisfy start < stop with integer endpoints excluding bool. Return +(maximum_count, earliest_coordinate), or (0, None) for no intervals. Ending +intervals do not overlap intervals beginning at the same coordinate. +O(n log n) time and O(n) space. + +>>> interval_overlap_peak([(0, 3), (2, 4), (3, 5)]) +(2, 2) +""" + +from algorithm_lab._validation import integer + + +def interval_overlap_peak(intervals): + events = {} + for start, stop in intervals: + integer(start, "start") + integer(stop, "stop") + if start >= stop: + raise ValueError("intervals must have positive length") + events[start] = events.get(start, 0) + 1 + events[stop] = events.get(stop, 0) - 1 + count, best, coordinate = 0, 0, None + for point, delta in sorted(events.items()): + count += delta + if count > best: + best, coordinate = count, point + return best, coordinate diff --git a/tests/test_interval_overlap_peak.py b/tests/test_interval_overlap_peak.py new file mode 100644 index 0000000..9ee4480 --- /dev/null +++ b/tests/test_interval_overlap_peak.py @@ -0,0 +1,25 @@ +import random +import unittest + +from algorithm_lab.interval_overlap_peak import interval_overlap_peak + + +class Tests(unittest.TestCase): + def test_integer_grid_oracle(self): + rng = random.Random(246) + for _ in range(150): + intervals = [] + for _ in range(rng.randrange(12)): + start = rng.randrange(-5, 6) + intervals.append((start, start + rng.randrange(1, 6))) + counts = [(sum(a <= x < b for a, b in intervals), x) for x in range(-5, 11)] + best = max(count for count, _ in counts) + expected = ( + (best, next(x for count, x in counts if count == best)) if best else (0, None) + ) + self.assertEqual(interval_overlap_peak(intervals), expected) + + def test_invalid(self): + for intervals in [[(1, 1)], [(2, 1)], [(False, 2)]]: + with self.assertRaises(ValueError): + interval_overlap_peak(intervals) From bab7ebfd7ea30ae5755474d27ab078b1b6d3976d Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 09/11] feat: intersect two sorted disjoint lists of closed integer intervals --- README.md | 3 ++- algorithm_lab/interval_intersection.py | 36 ++++++++++++++++++++++++++ tests/test_interval_intersection.py | 19 ++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/interval_intersection.py create mode 100644 tests/test_interval_intersection.py diff --git a/README.md b/README.md index bf7e729..90259db 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (145 algorithms) +## Catalog (146 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -182,6 +182,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [longest_bitonic_subsequence](algorithm_lab/longest_bitonic_subsequence.py) | Sequences | Reconstruct a strictly increasing then decreasing subsequence | | [minimum_interval_cover](algorithm_lab/minimum_interval_cover.py) | Intervals | Greedy minimum-cardinality coverage of a closed target interval | | [interval_overlap_peak](algorithm_lab/interval_overlap_peak.py) | Intervals | Sweep-line maximum overlap for half-open integer intervals | +| [interval_intersection](algorithm_lab/interval_intersection.py) | Intervals | Intersect two sorted disjoint lists of closed integer intervals | ## Validation diff --git a/algorithm_lab/interval_intersection.py b/algorithm_lab/interval_intersection.py new file mode 100644 index 0000000..e4c478d --- /dev/null +++ b/algorithm_lab/interval_intersection.py @@ -0,0 +1,36 @@ +"""Intersect two sorted disjoint lists of closed integer intervals. + +Each input has integer endpoints excluding bool, start <= stop, and strict +separation between successive intervals. Return closed intersections in +ascending order, including singleton endpoints. O(n+m) time and space for +copied inputs/output; the two-pointer scan uses O(1) auxiliary integers. + +>>> interval_intersection([(0, 2), (5, 8)], [(2, 6)]) +[(2, 2), (5, 6)] +""" + +from algorithm_lab._validation import integer + + +def interval_intersection(left, right): + def validate(intervals): + result = [] + for start, stop in intervals: + integer(start, "start") + integer(stop, "stop") + if start > stop or (result and start <= result[-1][1]): + raise ValueError("intervals must be sorted, disjoint, and closed") + result.append((start, stop)) + return result + + left, right = validate(left), validate(right) + i, j, result = 0, 0, [] + while i < len(left) and j < len(right): + start, stop = max(left[i][0], right[j][0]), min(left[i][1], right[j][1]) + if start <= stop: + result.append((start, stop)) + if left[i][1] < right[j][1]: + i += 1 + else: + j += 1 + return result diff --git a/tests/test_interval_intersection.py b/tests/test_interval_intersection.py new file mode 100644 index 0000000..5f52f61 --- /dev/null +++ b/tests/test_interval_intersection.py @@ -0,0 +1,19 @@ +import unittest + +from algorithm_lab.interval_intersection import interval_intersection + + +class Tests(unittest.TestCase): + def test_pairwise_oracle(self): + lists = [[], [(0, 0)], [(0, 2), (5, 8)], [(2, 6)], [(-5, -1), (1, 3), (7, 10)]] + for a in lists: + for b in lists: + expected = sorted( + (max(x, u), min(y, v)) for x, y in a for u, v in b if max(x, u) <= min(y, v) + ) + self.assertEqual(interval_intersection(a, b), expected) + + def test_invalid(self): + for values in [[(2, 1)], [(0, 2), (2, 3)], [(2, 3), (0, 1)], [(False, 1)]]: + with self.assertRaises(ValueError): + interval_intersection(values, []) From c2c9d5b6f243901f9630a5d9c45d88e0d5102506 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 10/11] feat: gale-shapley matching with complete strict preferences --- README.md | 3 ++- algorithm_lab/stable_matching.py | 41 ++++++++++++++++++++++++++++++++ tests/test_stable_matching.py | 35 +++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/stable_matching.py create mode 100644 tests/test_stable_matching.py diff --git a/README.md b/README.md index 90259db..8fe3220 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (146 algorithms) +## Catalog (147 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -183,6 +183,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [minimum_interval_cover](algorithm_lab/minimum_interval_cover.py) | Intervals | Greedy minimum-cardinality coverage of a closed target interval | | [interval_overlap_peak](algorithm_lab/interval_overlap_peak.py) | Intervals | Sweep-line maximum overlap for half-open integer intervals | | [interval_intersection](algorithm_lab/interval_intersection.py) | Intervals | Intersect two sorted disjoint lists of closed integer intervals | +| [stable_matching](algorithm_lab/stable_matching.py) | Graphs | Gale-Shapley matching with complete strict preferences | ## Validation diff --git a/algorithm_lab/stable_matching.py b/algorithm_lab/stable_matching.py new file mode 100644 index 0000000..fed2a8f --- /dev/null +++ b/algorithm_lab/stable_matching.py @@ -0,0 +1,41 @@ +"""Gale-Shapley matching with complete strict preferences. + +Each side is an n-by-n matrix whose rows are permutations of range(n). +Integers exclude bool. Return the receiver index matched to each proposer. +Proposers offer in index order through a FIFO queue; the result is proposer +optimal. Empty inputs return []. O(n^2) time and space; inputs are unchanged. + +>>> stable_matching([[0, 1], [0, 1]], [[1, 0], [0, 1]]) +[1, 0] +""" + +from collections import deque + +from algorithm_lab._validation import integer + + +def stable_matching(proposers, receivers): + proposers, receivers = [list(row) for row in proposers], [list(row) for row in receivers] + n = len(proposers) + if len(receivers) != n: + raise ValueError("both sides must have equal size") + for row in proposers + receivers: + for value in row: + integer(value) + if len(row) != n or set(row) != set(range(n)): + raise ValueError("each preference row must permute range(n)") + ranks = [{p: rank for rank, p in enumerate(row)} for row in receivers] + free, next_choice, partners, result = deque(range(n)), [0] * n, [-1] * n, [-1] * n + while free: + proposer = free.popleft() + receiver = proposers[proposer][next_choice[proposer]] + next_choice[proposer] += 1 + current = partners[receiver] + if current == -1 or ranks[receiver][proposer] < ranks[receiver][current]: + partners[receiver], result[proposer] = proposer, receiver + if current != -1: + result[current] = -1 + free.append(current) + else: + free.append(proposer) + return result diff --git a/tests/test_stable_matching.py b/tests/test_stable_matching.py new file mode 100644 index 0000000..a5c39a6 --- /dev/null +++ b/tests/test_stable_matching.py @@ -0,0 +1,35 @@ +import random +import unittest +from itertools import permutations + +from algorithm_lab.stable_matching import stable_matching + + +def stable(a, b, matching): + reverse = {r: p for p, r in enumerate(matching)} + return all( + not (a[p].index(r) < a[p].index(matching[p]) and b[r].index(p) < b[r].index(reverse[r])) + for p in range(len(a)) + for r in range(len(a)) + ) + + +class Tests(unittest.TestCase): + def test_stability_and_proposer_optimality(self): + rng = random.Random(248) + for n in range(5): + for _ in range(15): + a = [rng.sample(range(n), n) for _ in range(n)] + b = [rng.sample(range(n), n) for _ in range(n)] + result = stable_matching(a, b) + self.assertEqual(sorted(result), list(range(n))) + self.assertTrue(stable(a, b, result)) + for candidate in permutations(range(n)): + if stable(a, b, candidate): + for p in range(n): + self.assertLessEqual(a[p].index(result[p]), a[p].index(candidate[p])) + + def test_invalid(self): + for a, b in [([[0]], []), ([[True]], [[0]]), ([[0, 0], [0, 1]], [[0, 1], [1, 0]])]: + with self.assertRaises(ValueError): + stable_matching(a, b) From b81fb0436f202a8312f5d2b29e60be2b1af83a23 Mon Sep 17 00:00:00 2001 From: Pralav Singh <174412353+pralav-25@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:12:21 +0530 Subject: [PATCH 11/11] feat: shortest distances in directed graphs with zero-or-one edge costs --- README.md | 3 ++- algorithm_lab/zero_one_bfs.py | 39 +++++++++++++++++++++++++++++++++++ tests/test_zero_one_bfs.py | 28 +++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 algorithm_lab/zero_one_bfs.py create mode 100644 tests/test_zero_one_bfs.py diff --git a/README.md b/README.md index 8fe3220..fd596dd 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ python -m doctest algorithm_lab/*.py Optional installation: `python -m pip install -e .`. For linting and formatting, install `.[dev]` and run `ruff check .` and `ruff format --check .`. -## Catalog (147 algorithms) +## Catalog (148 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -184,6 +184,7 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`. | [interval_overlap_peak](algorithm_lab/interval_overlap_peak.py) | Intervals | Sweep-line maximum overlap for half-open integer intervals | | [interval_intersection](algorithm_lab/interval_intersection.py) | Intervals | Intersect two sorted disjoint lists of closed integer intervals | | [stable_matching](algorithm_lab/stable_matching.py) | Graphs | Gale-Shapley matching with complete strict preferences | +| [zero_one_bfs](algorithm_lab/zero_one_bfs.py) | Graphs | Shortest distances in directed graphs with zero-or-one edge costs | ## Validation diff --git a/algorithm_lab/zero_one_bfs.py b/algorithm_lab/zero_one_bfs.py new file mode 100644 index 0000000..69a0273 --- /dev/null +++ b/algorithm_lab/zero_one_bfs.py @@ -0,0 +1,39 @@ +"""Shortest distances in directed graphs with zero-or-one edge costs. + +The adjacency mapping stores iterables of (neighbor, weight), where weight +is integer 0 or 1 excluding bool. Vertices are hashable; neighbor-only +vertices are accepted. Return distances for reachable vertices, including +source at distance 0 even if absent. O(V+E) time and storage; all edges are +validated before traversal. A deque schedules zero-cost relaxations first. + +>>> zero_one_bfs({'a': [('b', 1), ('c', 0)], 'c': [('b', 0)]}, 'a') +{'a': 0, 'b': 0, 'c': 0} +""" + +from collections import deque + + +def zero_one_bfs(graph, source): + adjacency = {} + for vertex, edges in graph.items(): + adjacency[vertex] = [] + for neighbor, weight in edges: + hash(neighbor) + if type(weight) is not int or weight not in (0, 1): + raise ValueError("edge weights must be integer zero or one") + adjacency[vertex].append((neighbor, weight)) + distances, queue = {source: 0}, deque([(source, 0)]) + while queue: + vertex, distance = queue.popleft() + if distances[vertex] != distance: + continue + for neighbor, weight in adjacency.get(vertex, ()): + candidate = distance + weight + if neighbor not in distances or candidate < distances[neighbor]: + distances[neighbor] = candidate + item = (neighbor, candidate) + if weight: + queue.append(item) + else: + queue.appendleft(item) + return distances diff --git a/tests/test_zero_one_bfs.py b/tests/test_zero_one_bfs.py new file mode 100644 index 0000000..509b59b --- /dev/null +++ b/tests/test_zero_one_bfs.py @@ -0,0 +1,28 @@ +import random +import unittest + +from algorithm_lab.zero_one_bfs import zero_one_bfs + + +class Tests(unittest.TestCase): + def test_repeated_relaxation_oracle(self): + rng = random.Random(249) + for n in range(1, 10): + for _ in range(20): + graph = { + i: [(j, rng.randrange(2)) for j in range(n) if rng.random() < 0.2] + for i in range(n) + } + expected = {0: 0} + for _ in range(n): + for i, edges in graph.items(): + if i in expected: + for j, w in edges: + expected[j] = min(expected.get(j, n + 1), expected[i] + w) + self.assertEqual(zero_one_bfs(graph, 0), expected) + self.assertEqual(zero_one_bfs({"a": iter([("b", 0)])}, "a"), {"a": 0, "b": 0}) + + def test_invalid_unreachable_edge(self): + for weight in [-1, 2, True, 0.0]: + with self.assertRaises(ValueError): + zero_one_bfs({"unreachable": [("x", weight)]}, "start")