Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (148 algorithms)

| Module | Topic | What it demonstrates |
| --- | --- | --- |
Expand Down Expand Up @@ -174,6 +174,17 @@ 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 |
| [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 |
| [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 |
| [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

Expand Down
25 changes: 25 additions & 0 deletions algorithm_lab/circular_max_subarray.py
Original file line number Diff line number Diff line change
@@ -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)
36 changes: 36 additions & 0 deletions algorithm_lab/interval_intersection.py
Original file line number Diff line number Diff line change
@@ -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
29 changes: 29 additions & 0 deletions algorithm_lab/interval_overlap_peak.py
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions algorithm_lab/inversion_vector.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions algorithm_lab/longest_bitonic_subsequence.py
Original file line number Diff line number Diff line change
@@ -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
26 changes: 26 additions & 0 deletions algorithm_lab/max_product_subarray.py
Original file line number Diff line number Diff line change
@@ -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
40 changes: 40 additions & 0 deletions algorithm_lab/minimum_interval_cover.py
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions algorithm_lab/sliding_window_min.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions algorithm_lab/stable_matching.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 22 additions & 0 deletions algorithm_lab/subarray_sum_count.py
Original file line number Diff line number Diff line change
@@ -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
39 changes: 39 additions & 0 deletions algorithm_lab/zero_one_bfs.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading