diff --git a/README.md b/README.md index cc7346d..ab49ac2 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 (127 algorithms) +## Catalog (137 algorithms) | Module | Topic | What it demonstrates | | --- | --- | --- | @@ -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 diff --git a/algorithm_lab/bounded_knapsack.py b/algorithm_lab/bounded_knapsack.py new file mode 100644 index 0000000..4310c7c --- /dev/null +++ b/algorithm_lab/bounded_knapsack.py @@ -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] diff --git a/algorithm_lab/coin_change_count.py b/algorithm_lab/coin_change_count.py new file mode 100644 index 0000000..6614637 --- /dev/null +++ b/algorithm_lab/coin_change_count.py @@ -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] diff --git a/algorithm_lab/count_distinct_subsequences.py b/algorithm_lab/count_distinct_subsequences.py new file mode 100644 index 0000000..c720097 --- /dev/null +++ b/algorithm_lab/count_distinct_subsequences.py @@ -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 diff --git a/algorithm_lab/edit_script.py b/algorithm_lab/edit_script.py new file mode 100644 index 0000000..6b0e8bd --- /dev/null +++ b/algorithm_lab/edit_script.py @@ -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] diff --git a/algorithm_lab/longest_palindromic_subsequence.py b/algorithm_lab/longest_palindromic_subsequence.py new file mode 100644 index 0000000..e1144d5 --- /dev/null +++ b/algorithm_lab/longest_palindromic_subsequence.py @@ -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]) diff --git a/algorithm_lab/maximal_square.py b/algorithm_lab/maximal_square.py new file mode 100644 index 0000000..2661bbe --- /dev/null +++ b/algorithm_lab/maximal_square.py @@ -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 diff --git a/algorithm_lab/minimum_jumps.py b/algorithm_lab/minimum_jumps.py new file mode 100644 index 0000000..9697d83 --- /dev/null +++ b/algorithm_lab/minimum_jumps.py @@ -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 diff --git a/algorithm_lab/rod_cutting.py b/algorithm_lab/rod_cutting.py new file mode 100644 index 0000000..0bb8315 --- /dev/null +++ b/algorithm_lab/rod_cutting.py @@ -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 diff --git a/algorithm_lab/shortest_common_supersequence.py b/algorithm_lab/shortest_common_supersequence.py new file mode 100644 index 0000000..2904941 --- /dev/null +++ b/algorithm_lab/shortest_common_supersequence.py @@ -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:] diff --git a/algorithm_lab/triangle_min_path.py b/algorithm_lab/triangle_min_path.py new file mode 100644 index 0000000..9c58103 --- /dev/null +++ b/algorithm_lab/triangle_min_path.py @@ -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 diff --git a/tests/test_bounded_knapsack.py b/tests/test_bounded_knapsack.py new file mode 100644 index 0000000..3345583 --- /dev/null +++ b/tests/test_bounded_knapsack.py @@ -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) diff --git a/tests/test_coin_change_count.py b/tests/test_coin_change_count.py new file mode 100644 index 0000000..0f5464f --- /dev/null +++ b/tests/test_coin_change_count.py @@ -0,0 +1,21 @@ +import unittest +from itertools import product + +from algorithm_lab.coin_change_count import coin_change_count + + +class Tests(unittest.TestCase): + def test_quantity_enumeration(self): + for coins in [[], [2], [1, 3], [2, 3, 5], [1, 1, 2]]: + unique = sorted(set(coins)) + for target in range(16): + expected = sum( + sum(c * k for c, k in zip(unique, counts, strict=True)) == target + for counts in product(*(range(target // c + 1) for c in unique)) + ) + self.assertEqual(coin_change_count(coins, target), expected) + + def test_invalid(self): + for coins, target in [([0], 1), ([True], 1), ([2], -1), ([2], False)]: + with self.assertRaises(ValueError): + coin_change_count(coins, target) diff --git a/tests/test_count_distinct_subsequences.py b/tests/test_count_distinct_subsequences.py new file mode 100644 index 0000000..a66b6de --- /dev/null +++ b/tests/test_count_distinct_subsequences.py @@ -0,0 +1,19 @@ +import unittest +from itertools import product + +from algorithm_lab.count_distinct_subsequences import count_distinct_subsequences + + +class Tests(unittest.TestCase): + def test_set_oracle(self): + for n in range(8): + for text in product("ab", repeat=n): + expected = { + tuple(c for i, c in enumerate(text) if mask >> i & 1) for mask in range(1 << n) + } + self.assertEqual(count_distinct_subsequences("".join(text)), len(expected)) + self.assertEqual(count_distinct_subsequences("x" * 2000), 2001) + + def test_invalid(self): + with self.assertRaises(ValueError): + count_distinct_subsequences([]) diff --git a/tests/test_edit_script.py b/tests/test_edit_script.py new file mode 100644 index 0000000..ae0b252 --- /dev/null +++ b/tests/test_edit_script.py @@ -0,0 +1,21 @@ +import unittest +from itertools import product + +from algorithm_lab.edit_script import edit_script +from algorithm_lab.levenshtein import levenshtein + + +class Tests(unittest.TestCase): + def test_alignment_and_distance(self): + words = ["".join(p) for n in range(4) for p in product("ab", repeat=n)] + ["é🙂"] + for a in words: + for b in words: + distance, operations = edit_script(a, b) + self.assertEqual("".join(op[1] for op in operations), a) + self.assertEqual("".join(op[2] for op in operations), b) + self.assertEqual(sum(op[0] != "equal" for op in operations), distance) + self.assertEqual(distance, levenshtein(a, b)) + + def test_invalid(self): + with self.assertRaises(ValueError): + edit_script([], "") diff --git a/tests/test_longest_palindromic_subsequence.py b/tests/test_longest_palindromic_subsequence.py new file mode 100644 index 0000000..b137a4c --- /dev/null +++ b/tests/test_longest_palindromic_subsequence.py @@ -0,0 +1,23 @@ +import unittest +from itertools import product + +from algorithm_lab.longest_palindromic_subsequence import longest_palindromic_subsequence + + +class Tests(unittest.TestCase): + def test_exhaustive_subsequence_oracle(self): + for n in range(8): + for letters in product("ab", repeat=n): + text = "".join(letters) + subs = { + "".join(c for i, c in enumerate(text) if mask >> i & 1) + for mask in range(1 << n) + } + result = longest_palindromic_subsequence(text) + self.assertIn(result, subs) + self.assertEqual(result, result[::-1]) + self.assertEqual(len(result), max(len(s) for s in subs if s == s[::-1])) + + def test_invalid(self): + with self.assertRaises(ValueError): + longest_palindromic_subsequence([]) diff --git a/tests/test_maximal_square.py b/tests/test_maximal_square.py new file mode 100644 index 0000000..b2598b3 --- /dev/null +++ b/tests/test_maximal_square.py @@ -0,0 +1,29 @@ +import unittest +from itertools import product + +from algorithm_lab.maximal_square import maximal_square + + +class Tests(unittest.TestCase): + def test_enumerate_squares(self): + for values in product(range(2), repeat=9): + matrix = [list(values[i : i + 3]) for i in range(0, 9, 3)] + candidates = [ + (size, i, j) + for size in range(1, 4) + for i in range(4 - size) + for j in range(4 - size) + if all(matrix[r][c] for r in range(i, i + size) for c in range(j, j + size)) + ] + expected = ( + min(candidates, key=lambda x: (-x[0], x[1], x[2])) + if candidates + else (0, None, None) + ) + self.assertEqual(maximal_square(matrix), expected) + self.assertEqual(maximal_square([[]]), (0, None, None)) + + def test_invalid(self): + for matrix in [[[1], [1, 0]], [[True]], [[2]]]: + with self.assertRaises(ValueError): + maximal_square(matrix) diff --git a/tests/test_minimum_jumps.py b/tests/test_minimum_jumps.py new file mode 100644 index 0000000..4f0c74a --- /dev/null +++ b/tests/test_minimum_jumps.py @@ -0,0 +1,29 @@ +import unittest +from collections import deque +from itertools import product + +from algorithm_lab.minimum_jumps import minimum_jumps + + +class Tests(unittest.TestCase): + def test_bfs_oracle(self): + for n in range(7): + for values in product(range(3), repeat=n): + queue, seen, expected = deque([(0, 0)]), {0}, None + if not values: + expected = 0 + while queue and values: + i, distance = queue.popleft() + if i == n - 1: + expected = distance + break + for j in range(i + 1, min(n, i + values[i] + 1)): + if j not in seen: + seen.add(j) + queue.append((j, distance + 1)) + self.assertEqual(minimum_jumps(values), expected) + + def test_invalid(self): + for values in [[-1], [True], [1.5]]: + with self.assertRaises(ValueError): + minimum_jumps(values) diff --git a/tests/test_rod_cutting.py b/tests/test_rod_cutting.py new file mode 100644 index 0000000..6ee12ed --- /dev/null +++ b/tests/test_rod_cutting.py @@ -0,0 +1,26 @@ +import random +import unittest + +from algorithm_lab.rod_cutting import rod_cutting + + +class Tests(unittest.TestCase): + def test_cut_mask_oracle(self): + rng = random.Random(235) + self.assertEqual(rod_cutting([]), (0, [])) + for n in range(1, 9): + prices = [rng.randrange(-5, 11) for _ in range(n)] + revenues = [] + for mask in range(1 << (n - 1)): + ends = [0] + [i for i in range(1, n) if mask >> (i - 1) & 1] + [n] + revenues.append( + sum(prices[b - a - 1] for a, b in zip(ends, ends[1:], strict=False)) + ) + revenue, pieces = rod_cutting(prices) + self.assertEqual(revenue, max(revenues)) + self.assertEqual(sum(pieces), n) + self.assertEqual(sum(prices[size - 1] for size in pieces), revenue) + + def test_invalid(self): + with self.assertRaises(ValueError): + rod_cutting([True]) diff --git a/tests/test_shortest_common_supersequence.py b/tests/test_shortest_common_supersequence.py new file mode 100644 index 0000000..e0b2c1a --- /dev/null +++ b/tests/test_shortest_common_supersequence.py @@ -0,0 +1,26 @@ +import unittest +from itertools import product + +from algorithm_lab.shortest_common_supersequence import shortest_common_supersequence + + +def subsequences(text): + return { + "".join(c for i, c in enumerate(text) if mask >> i & 1) for mask in range(1 << len(text)) + } + + +class Tests(unittest.TestCase): + def test_exhaustive_length_and_witness(self): + words = ["".join(p) for n in range(4) for p in product("ab", repeat=n)] + for a in words: + for b in words: + result = shortest_common_supersequence(a, b) + lcs = max(map(len, subsequences(a) & subsequences(b))) + self.assertEqual(len(result), len(a) + len(b) - lcs) + self.assertIn(a, subsequences(result)) + self.assertIn(b, subsequences(result)) + + def test_invalid(self): + with self.assertRaises(ValueError): + shortest_common_supersequence("", []) diff --git a/tests/test_triangle_min_path.py b/tests/test_triangle_min_path.py new file mode 100644 index 0000000..b432e7c --- /dev/null +++ b/tests/test_triangle_min_path.py @@ -0,0 +1,28 @@ +import random +import unittest +from itertools import product + +from algorithm_lab.triangle_min_path import triangle_min_path + + +class Tests(unittest.TestCase): + def test_path_enumeration(self): + rng = random.Random(238) + self.assertEqual(triangle_min_path([]), (0, [])) + for n in range(1, 10): + rows = [[rng.randrange(-5, 8) for _ in range(i + 1)] for i in range(n)] + totals = [] + for steps in product(range(2), repeat=n - 1): + column, total = 0, rows[0][0] + for i, step in enumerate(steps, 1): + column += step + total += rows[i][column] + totals.append(total) + score, path = triangle_min_path(rows) + self.assertEqual(score, min(totals)) + self.assertEqual(sum(rows[i][j] for i, j in enumerate(path)), score) + + def test_invalid(self): + for rows in [[[]], [[1], [2]], [[True]]]: + with self.assertRaises(ValueError): + triangle_min_path(rows)