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
12 changes: 11 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 (127 algorithms)
## Catalog (137 algorithms)

| Module | Topic | What it demonstrates |
| --- | --- | --- |
Expand Down Expand Up @@ -164,6 +164,16 @@ install `.[dev]` and run `ruff check .` and `ruff format --check .`.
| [bounded_compositions](algorithm_lab/bounded_compositions.py) | Combinatorics | Enumerate fixed-sum tuples under per-position upper bounds |
| [subset_zeta_transform](algorithm_lab/subset_zeta_transform.py) | Combinatorics | Sum values over every subset of each bit mask |
| [subset_mobius_transform](algorithm_lab/subset_mobius_transform.py) | Combinatorics | Invert subset sums by Mobius inversion on bit masks |
| [edit_script](algorithm_lab/edit_script.py) | Strings | Reconstruct a minimum-cost Levenshtein alignment |
| [shortest_common_supersequence](algorithm_lab/shortest_common_supersequence.py) | Strings | Reconstruct a shortest string containing two subsequences |
| [longest_palindromic_subsequence](algorithm_lab/longest_palindromic_subsequence.py) | Strings | Reconstruct a longest palindromic subsequence |
| [count_distinct_subsequences](algorithm_lab/count_distinct_subsequences.py) | Strings | Count distinct subsequence strings including the empty string |
| [coin_change_count](algorithm_lab/coin_change_count.py) | Dynamic programming | Count unordered ways to make change with unlimited coins |
| [bounded_knapsack](algorithm_lab/bounded_knapsack.py) | Dynamic programming | Maximum integer value with bounded item quantities |
| [rod_cutting](algorithm_lab/rod_cutting.py) | Dynamic programming | Optimal complete rod cutting with a length witness |
| [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 |

## Validation

Expand Down
31 changes: 31 additions & 0 deletions algorithm_lab/bounded_knapsack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Maximum integer value with bounded item quantities.

Items are (positive weight, integer value, nonnegative count); capacity is a
nonnegative integer, excluding bool throughout. Return the maximum value;
taking nothing is allowed. Binary grouping uses O(capacity*sum(log(count+1)))
time and O(capacity) space. Counts exceeding capacity are safely capped.

>>> bounded_knapsack([(2, 3, 2), (3, 5, 1)], 5)
8
"""

from algorithm_lab._validation import integer


def bounded_knapsack(items, capacity):
integer(capacity, "capacity", minimum=0)
dp = [0] * (capacity + 1)
for weight, value, count in items:
integer(weight, "weight", minimum=1)
integer(value, "value")
integer(count, "count", minimum=0)
count = min(count, capacity // weight)
group = 1
while count:
take = min(group, count)
cost, reward = take * weight, take * value
for remaining in range(capacity, cost - 1, -1):
dp[remaining] = max(dp[remaining], dp[remaining - cost] + reward)
count -= take
group *= 2
return dp[capacity]
24 changes: 24 additions & 0 deletions algorithm_lab/coin_change_count.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""Count unordered ways to make change with unlimited coins.

Coins are positive integers; repeated denominations are deduplicated. The
target is a nonnegative integer. Bool is rejected. The empty combination
counts once for target=0. O(target*number_of_distinct_coins) time and O(target)
space, plus sorted coin storage. Return an exact count, not a reconstruction.

>>> coin_change_count([1, 2, 5], 5)
4
"""

from algorithm_lab._validation import integer


def coin_change_count(coins, target):
integer(target, "target", minimum=0)
coins = list(coins)
for coin in coins:
integer(coin, "coin", minimum=1)
ways = [1] + [0] * target
for coin in sorted(set(coins)):
for amount in range(coin, target + 1):
ways[amount] += ways[amount - coin]
return ways[target]
19 changes: 19 additions & 0 deletions algorithm_lab/count_distinct_subsequences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Count distinct subsequence strings including the empty string.

Accept a Unicode string without normalization. Repeated characters share a
last-contribution count to avoid counting identical strings twice. Return an
exact integer. O(n) dictionary operations and O(number of distinct characters)
stored integers; arithmetic cost grows with the result's bit length.

>>> count_distinct_subsequences('aba')
7
"""


def count_distinct_subsequences(text):
if not isinstance(text, str):
raise ValueError("input must be a string")
total, last = 1, {}
for character in text:
total, last[character] = 2 * total - last.get(character, 0), total
return total
47 changes: 47 additions & 0 deletions algorithm_lab/edit_script.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Reconstruct a minimum-cost Levenshtein alignment.

Accept two strings and return (distance, operations). Each operation is
(kind, source_character, target_character), using '' for absent characters.
Kinds are equal, replace, delete, insert. Unit-cost ties prefer diagonal,
then deletion, then insertion. Unicode code points are not normalized.
O(len(source)*len(target)) time and space.

>>> edit_script('cat', 'cut')
(1, [('equal', 'c', 'c'), ('replace', 'a', 'u'), ('equal', 't', 't')])
"""


def edit_script(source, target):
if not isinstance(source, str) or not isinstance(target, str):
raise ValueError("inputs must be strings")
m, n = len(source), len(target)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m + 1):
dp[i][0] = i
for j in range(n + 1):
dp[0][j] = j
for i in range(1, m + 1):
for j in range(1, n + 1):
dp[i][j] = min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (source[i - 1] != target[j - 1]),
)
i, j, operations = m, n, []
while i or j:
if i and j and dp[i][j] == dp[i - 1][j - 1] + (source[i - 1] != target[j - 1]):
operations.append(
(
"equal" if source[i - 1] == target[j - 1] else "replace",
source[i - 1],
target[j - 1],
)
)
i, j = i - 1, j - 1
elif i and dp[i][j] == dp[i - 1][j] + 1:
operations.append(("delete", source[i - 1], ""))
i -= 1
else:
operations.append(("insert", "", target[j - 1]))
j -= 1
return dp[m][n], operations[::-1]
38 changes: 38 additions & 0 deletions algorithm_lab/longest_palindromic_subsequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Reconstruct a longest palindromic subsequence.

Accept a string and return one longest palindromic subsequence. When dropping
either endpoint yields the same length, drop the right endpoint. Empty input
returns ''. Operates on Unicode code points; O(n^2) time and space.

>>> longest_palindromic_subsequence('bbbab')
'bbbb'
"""


def longest_palindromic_subsequence(text):
if not isinstance(text, str):
raise ValueError("input must be a string")
n = len(text)
if not n:
return ""
dp = [[0] * n for _ in range(n)]
for i in range(n - 1, -1, -1):
dp[i][i] = 1
for j in range(i + 1, n):
dp[i][j] = (
2 + dp[i + 1][j - 1] if text[i] == text[j] else max(dp[i + 1][j], dp[i][j - 1])
)
i, j, left, right = 0, n - 1, [], []
while i <= j:
if i == j:
left.append(text[i])
break
if text[i] == text[j]:
left.append(text[i])
right.append(text[j])
i, j = i + 1, j - 1
elif dp[i][j - 1] >= dp[i + 1][j]:
j -= 1
else:
i += 1
return "".join(left + right[::-1])
31 changes: 31 additions & 0 deletions algorithm_lab/maximal_square.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Largest all-one square with deterministic coordinates.

Accept a rectangular matrix of integer 0/1 entries, excluding bool. Return
(side_length, top_row, left_column); no square returns (0, None, None).
Equal-size squares prefer the smallest (top_row, left_column). O(rows*cols)
time and copied input space, with O(cols) dynamic-programming workspace.

>>> maximal_square([[1, 1, 0], [1, 1, 1]])
(2, 0, 0)
"""


def maximal_square(matrix):
rows = [list(row) for row in matrix]
width = len(rows[0]) if rows else 0
if any(len(row) != width for row in rows):
raise ValueError("matrix must be rectangular")
if any(type(value) is not int or value not in (0, 1) for row in rows for value in row):
raise ValueError("entries must be integer zero or one")
previous, best = [0] * (width + 1), (0, None, None)
for i, row in enumerate(rows):
current = [0] * (width + 1)
for j, value in enumerate(row):
if value:
size = 1 + min(previous[j], previous[j + 1], current[j])
current[j + 1] = size
candidate = (size, i - size + 1, j - size + 1)
if size > best[0] or (size == best[0] and candidate[1:] < best[1:]):
best = candidate
previous = current
return best
29 changes: 29 additions & 0 deletions algorithm_lab/minimum_jumps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Fewest forward jumps to the final array position.

Each entry is a nonnegative integer maximum jump length, excluding bool.
Return the minimum number of jumps from index 0 to the last index, or None
if unreachable. Empty and one-element inputs require 0 jumps. Greedy breadth
layers take O(n) time and O(n) copied input storage.

>>> minimum_jumps([2, 3, 1, 1, 4])
2
"""

from algorithm_lab._validation import integer


def minimum_jumps(lengths):
lengths = list(lengths)
for length in lengths:
integer(length, "jump length", minimum=0)
end, farthest, jumps = 0, 0, 0
for i in range(len(lengths) - 1):
farthest = max(farthest, i + lengths[i])
if i == end:
if farthest == end:
return None
jumps += 1
end = farthest
if end >= len(lengths) - 1:
return jumps
return jumps
30 changes: 30 additions & 0 deletions algorithm_lab/rod_cutting.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Optimal complete rod cutting with a length witness.

prices[i-1] gives an integer price for length i, excluding bool. The rod has
length len(prices) and must be completely sold, even with negative prices.
Return (revenue, piece_lengths), preferring the smallest first cut on ties.
Empty prices returns (0, []). O(n^2) time and O(n) space.

>>> rod_cutting([1, 5, 8, 9])
(10, [2, 2])
"""

from algorithm_lab._validation import integer


def rod_cutting(prices):
prices = list(prices)
for price in prices:
integer(price, "price")
scores, cuts = [0], [0]
for length in range(1, len(prices) + 1):
best, cut = max(
(prices[size - 1] + scores[length - size], -size) for size in range(1, length + 1)
)
scores.append(best)
cuts.append(-cut)
remaining, pieces = len(prices), []
while remaining:
pieces.append(cuts[remaining])
remaining -= cuts[remaining]
return scores[-1], pieces
38 changes: 38 additions & 0 deletions algorithm_lab/shortest_common_supersequence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""Reconstruct a shortest string containing two subsequences.

Inputs are strings of Unicode code points without normalization. Return a
shortest common supersequence, preferring a character from the first string
on equal remaining lengths. O(m*n) time and space, plus output length.

>>> shortest_common_supersequence('ab', 'ac')
'abc'
"""


def shortest_common_supersequence(left, right):
if not isinstance(left, str) or not isinstance(right, str):
raise ValueError("inputs must be strings")
m, n = len(left), len(right)
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(m, -1, -1):
for j in range(n, -1, -1):
if i == m:
dp[i][j] = n - j
elif j == n:
dp[i][j] = m - i
elif left[i] == right[j]:
dp[i][j] = 1 + dp[i + 1][j + 1]
else:
dp[i][j] = 1 + min(dp[i + 1][j], dp[i][j + 1])
i, j, result = 0, 0, []
while i < m and j < n:
if left[i] == right[j]:
result.append(left[i])
i, j = i + 1, j + 1
elif dp[i + 1][j] <= dp[i][j + 1]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
return "".join(result) + left[i:] + right[j:]
33 changes: 33 additions & 0 deletions algorithm_lab/triangle_min_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Minimum sum down an integer triangle with path reconstruction.

Row i must contain i+1 integers, excluding bool. Each step goes to the same
or next column in the following row. Return (sum, column_indices), preferring
the left child on ties. Empty input returns (0, []). O(n^2) time and space
for n rows; inputs remain unchanged.

>>> triangle_min_path([[2], [3, 4], [6, 5, 7], [4, 1, 8, 3]])
(11, [0, 0, 1, 1])
"""

from algorithm_lab._validation import integer


def triangle_min_path(triangle):
rows = [list(row) for row in triangle]
for i, row in enumerate(rows):
if len(row) != i + 1:
raise ValueError("row i must have i+1 entries")
for value in row:
integer(value, "entry")
if not rows:
return 0, []
scores = rows[-1][:]
choices = [None] * (len(rows) - 1)
for i in range(len(rows) - 2, -1, -1):
choices[i] = [int(scores[j + 1] < scores[j]) for j in range(i + 1)]
scores = [rows[i][j] + scores[j + choices[i][j]] for j in range(i + 1)]
path, column = [0], 0
for row in choices:
column += row[column]
path.append(column)
return scores[0], path
26 changes: 26 additions & 0 deletions tests/test_bounded_knapsack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import random
import unittest
from itertools import product

from algorithm_lab.bounded_knapsack import bounded_knapsack


class Tests(unittest.TestCase):
def test_quantity_enumeration(self):
rng = random.Random(234)
for _ in range(100):
items = [
(rng.randrange(1, 5), rng.randrange(-3, 9), rng.randrange(4)) for _ in range(4)
]
capacity = rng.randrange(15)
expected = max(
sum(k * v for k, (_, v, _) in zip(counts, items, strict=True))
for counts in product(*(range(c + 1) for _, _, c in items))
if sum(k * w for k, (w, _, _) in zip(counts, items, strict=True)) <= capacity
)
self.assertEqual(bounded_knapsack(items, capacity), expected)

def test_invalid(self):
for items, capacity in [([(0, 1, 1)], 2), ([(1, 2, -1)], 2), ([(1, True, 1)], 2), ([], -1)]:
with self.assertRaises(ValueError):
bounded_knapsack(items, capacity)
Loading
Loading