diff --git a/README.md b/README.md index b63695a..84a0265 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ The architectural choices, trade-offs, and design patterns for each algorithm ar * [ADR 0013: Union by Rank with Path Compression for Union-Find](docs/adr/0013-use-union-by-rank-with-path-compression-for-union-find.md) * [ADR 0014: Bottom-Up Divide-and-Conquer Merge for Merge Sort](docs/adr/0014-use-bottom-up-divide-and-conquer-merge-for-merge-sort.md) * [ADR 0015: Iterative Pointer Rewiring for Singly Linked List Reversal](docs/adr/0015-use-iterative-pointer-rewiring-for-singly-linked-list-reversal.md) +* [ADR 0016: Bottom-Up Tabulation with Backtracking for 0/1 Knapsack](docs/adr/0016-use-bottom-up-tabulation-with-backtracking-for-01-knapsack.md) --- @@ -116,4 +117,5 @@ To ensure uniformity, this repository follows strict standards derived from **PE 11. **Service Layer Input Validation:** The HTTP API validates request bodies via Pydantic schemas and translates algorithm-level `ValueError`s into HTTP 400 responses rather than leaking stack traces; both the MCP server and HTTP API are stateless per call, so no client-supplied data persists across requests. 12. **Fixed Element Universe:** Union-Find validates every `find()`/`union()` call against its initial element set and raises a `ValueError` for unknown elements, preventing silent creation of untracked entries. 13. **Worst-Case DoS Mitigation:** Merge Sort guarantees $O(n \log n)$ even on adversarial input, making it the safer default over Quicksort when sorting untrusted, attacker-influenced data where worst-case scaling matters. -14. **Bounded Traversal Footprint:** The Singly Linked List's `search`/`delete`/`reverse` operations are strictly O(n) iterative walks with no recursion, preventing stack-depth exhaustion on very large untrusted input lists. \ No newline at end of file +14. **Bounded Traversal Footprint:** The Singly Linked List's `search`/`delete`/`reverse` operations are strictly O(n) iterative walks with no recursion, preventing stack-depth exhaustion on very large untrusted input lists. +15. **Iterative DP, No Recursion Limits:** The 0/1 Knapsack solver uses bottom-up tabulation rather than top-down recursion, avoiding Python's `RecursionError` on large item counts. \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 0c99e3f..0b5c2e3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,7 +30,7 @@ This document serves as the long-term architectural roadmap for this learning re * **Singly Linked List**: Pointer-chained sequential collection supporting traversal, insertion, and reversal operations. (Completed) ### Phase 5: Dynamic Programming & Sequence Analysis -* **0/1 Knapsack Problem**: Tabular matrix memoization framework designed to resolve finite profit boundaries. +* **0/1 Knapsack Problem**: Tabular matrix memoization framework designed to resolve finite profit boundaries. (Completed) * **Longest Common Subsequence (LCS)**: Relational alignment mapping tracking matching sub-segments within strings. --- diff --git a/docs/adr/0016-use-bottom-up-tabulation-with-backtracking-for-01-knapsack.md b/docs/adr/0016-use-bottom-up-tabulation-with-backtracking-for-01-knapsack.md new file mode 100644 index 0000000..f569102 --- /dev/null +++ b/docs/adr/0016-use-bottom-up-tabulation-with-backtracking-for-01-knapsack.md @@ -0,0 +1,10 @@ +# 16. Use Bottom-Up Tabulation with Backtracking for 0/1 Knapsack + +* **Status:** Approved +* **Context:** Phase 5 required a solver for the classic 0/1 Knapsack Problem — selecting a subset of items, each usable at most once, to maximize total value without exceeding a fixed weight capacity. A brute-force approach is exponential ($O(2^n)$), motivating a polynomial dynamic programming solution. +* **Decision:** We implemented **bottom-up tabulation**: a `(items + 1) x (capacity + 1)` matrix where `table[i][c]` holds the best value achievable using the first `i` items within capacity `c`, built iteratively rather than via top-down memoized recursion. A backtracking pass then walks the completed matrix in reverse to recover which specific items were selected. +* **Consequences:** + * Achieves $O(n \times \text{capacity})$ time and space complexity — pseudo-polynomial, but efficient for any bounded capacity encountered in practice. + * The iterative tabulation approach avoids Python's recursion depth limits entirely, unlike a naive top-down memoized recursive solution which could hit `RecursionError` on a large item count. + * *Trade-off:* The full `(items + 1) x (capacity + 1)` matrix is retained in memory to support backtracking; a space-optimized single-row variant would reduce memory to $O(\text{capacity})$ but would lose the ability to reconstruct which items were chosen without additional bookkeeping. + diff --git a/service/http_app.py b/service/http_app.py index da18200..235f6ab 100644 --- a/service/http_app.py +++ b/service/http_app.py @@ -53,6 +53,12 @@ class LinkedListRequest(BaseModel): reverse: bool = False +class KnapsackRequest(BaseModel): + weights: List[int] + values: List[int] + capacity: int + + class WeightedGraphRequest(BaseModel): graph: Dict[str, List[List[object]]] source: str @@ -145,6 +151,14 @@ def build_and_query_linked_list(request: LinkedListRequest) -> Dict: ) +@app.post("/dynamic-programming/knapsack") +def dp_knapsack_01(request: KnapsackRequest) -> Dict: + """Selects a subset of items maximizing total value within a fixed weight capacity.""" + return _call( + tools.dp_knapsack_01, request.weights, request.values, request.capacity + ) + + @app.post("/graphs/dijkstra") def graph_dijkstra(request: WeightedGraphRequest) -> Dict: """Computes single-source shortest paths using Dijkstra's algorithm.""" diff --git a/service/mcp_server.py b/service/mcp_server.py index e81b5ab..bf39e71 100644 --- a/service/mcp_server.py +++ b/service/mcp_server.py @@ -69,6 +69,12 @@ def build_and_query_linked_list( return tools.build_and_query_linked_list(values, search_for, reverse) +@mcp.tool() +def dp_knapsack_01(weights: List[int], values: List[int], capacity: int) -> Dict: + """Selects a subset of items maximizing total value within a fixed weight capacity.""" + return tools.dp_knapsack_01(weights, values, capacity) + + @mcp.tool() def graph_dijkstra(graph: tools.WeightedGraph, source: str) -> Dict: """Computes single-source shortest paths using Dijkstra's algorithm.""" diff --git a/service/tools.py b/service/tools.py index 3d0b56f..4f49b8a 100644 --- a/service/tools.py +++ b/service/tools.py @@ -18,6 +18,7 @@ from src.data_structures.dijkstra import dijkstra from src.data_structures.linked_list import SinglyLinkedList from src.data_structures.union_find import UnionFind +from src.dynamic_programming.knapsack import knapsack_01 from src.graphs.a_star import a_star from src.graphs.bellman_ford import bellman_ford from src.graphs.topological_sort import topological_sort @@ -120,6 +121,12 @@ def build_and_query_linked_list( return result +def dp_knapsack_01(weights: List[int], values: List[int], capacity: int) -> Dict: + """Selects a subset of items maximizing total value within a fixed weight capacity.""" + max_value, selected_indices = knapsack_01(weights, values, capacity) + return {"max_value": max_value, "selected_indices": selected_indices} + + def graph_dijkstra(graph: WeightedGraph, source: str) -> Dict: """Computes single-source shortest paths using Dijkstra's algorithm.""" distances, predecessors = dijkstra(_to_adjacency_tuples(graph), source) diff --git a/src/dynamic_programming/__init__.py b/src/dynamic_programming/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/dynamic_programming/knapsack.py b/src/dynamic_programming/knapsack.py new file mode 100644 index 0000000..bd7c587 --- /dev/null +++ b/src/dynamic_programming/knapsack.py @@ -0,0 +1,62 @@ +"""0/1 Knapsack Problem solver using bottom-up tabular dynamic programming memoization.""" + +from typing import List, Tuple + + +def knapsack_01( + weights: List[int], values: List[int], capacity: int +) -> Tuple[int, List[int]]: + """Selects a subset of items maximizing total value within a fixed weight capacity. + + Each item may be taken at most once (0/1 constraint, as opposed to the + unbounded/fractional variants). + + Complexity Analysis: + Time Complexity: O(n * capacity) where n = number of items. + Space Complexity: O(n * capacity) for the tabulation matrix. + """ + if len(weights) != len(values): + raise ValueError("Weights and values lists must be the same length.") + + # Guard clause against malicious or invalid negative-magnitude inputs + if ( + capacity < 0 + or any(weight < 0 for weight in weights) + or any(value < 0 for value in values) + ): + raise ValueError("Capacity, weights, and values must be non-negative.") + + item_count = len(weights) + + # table[i][c] = best achievable value using the first i items within capacity c + table = [[0] * (capacity + 1) for _ in range(item_count + 1)] + + for i in range(1, item_count + 1): + weight, value = weights[i - 1], values[i - 1] + for c in range(capacity + 1): + # Excluding the current item always remains a valid baseline option + table[i][c] = table[i - 1][c] + + # Including the current item is only possible if it fits within capacity + if weight <= c: + table[i][c] = max(table[i][c], table[i - 1][c - weight] + value) + + selected_indices = _backtrack_selection(table, weights, capacity) + return table[item_count][capacity], selected_indices + + +def _backtrack_selection( + table: List[List[int]], weights: List[int], capacity: int +) -> List[int]: + """Walks the completed tabulation matrix backward to recover which items were chosen.""" + selected_indices: List[int] = [] + remaining_capacity = capacity + + for i in range(len(weights), 0, -1): + # A changed value versus the row above means item (i - 1) was included + if table[i][remaining_capacity] != table[i - 1][remaining_capacity]: + selected_indices.append(i - 1) + remaining_capacity -= weights[i - 1] + + selected_indices.reverse() + return selected_indices diff --git a/tests/test_dynamic_programming.py b/tests/test_dynamic_programming.py new file mode 100644 index 0000000..b057e1d --- /dev/null +++ b/tests/test_dynamic_programming.py @@ -0,0 +1,56 @@ +"""Comprehensive evaluation suite tracking 0/1 Knapsack Problem resolution.""" + +import pytest +from src.dynamic_programming.knapsack import knapsack_01 + + +def test_knapsack_typical_selection(): + """Verifies the optimal value and item selection for a classic textbook scenario.""" + weights = [1, 3, 4, 5] + values = [1, 4, 5, 7] + max_value, selected = knapsack_01(weights, values, capacity=7) + + assert max_value == 9 + assert selected == [1, 2] + + +def test_knapsack_zero_capacity(): + """Ensures zero capacity yields zero value and no selected items.""" + max_value, selected = knapsack_01([1, 2], [10, 20], capacity=0) + + assert max_value == 0 + assert selected == [] + + +def test_knapsack_empty_items(): + """Ensures an empty item set yields zero value regardless of capacity.""" + max_value, selected = knapsack_01([], [], capacity=10) + + assert max_value == 0 + assert selected == [] + + +def test_knapsack_item_exceeding_capacity_is_excluded(): + """Ensures an item heavier than the capacity is never selected.""" + max_value, selected = knapsack_01([10], [100], capacity=5) + + assert max_value == 0 + assert selected == [] + + +def test_knapsack_mismatched_lengths_raises(): + """Ensures mismatched weights/values lengths raise a ValueError safely.""" + with pytest.raises(ValueError, match="same length"): + knapsack_01([1, 2], [10], capacity=5) + + +def test_knapsack_rejects_negative_inputs(): + """Ensures negative capacity, weights, or values raise a ValueError safely.""" + with pytest.raises(ValueError, match="non-negative"): + knapsack_01([1], [10], capacity=-1) + + with pytest.raises(ValueError, match="non-negative"): + knapsack_01([-1], [10], capacity=5) + + with pytest.raises(ValueError, match="non-negative"): + knapsack_01([1], [-10], capacity=5) diff --git a/tests/test_service_http_app.py b/tests/test_service_http_app.py index 87d383d..b99c568 100644 --- a/tests/test_service_http_app.py +++ b/tests/test_service_http_app.py @@ -75,6 +75,16 @@ def test_http_build_and_query_linked_list(): assert response.json() == {"values": [3, 2, 1], "found": True} +def test_http_dp_knapsack_01(): + """Verifies the Knapsack endpoint returns the optimal value and selected indices.""" + response = client.post( + "/dynamic-programming/knapsack", + json={"weights": [1, 3, 4, 5], "values": [1, 4, 5, 7], "capacity": 7}, + ) + assert response.status_code == 200 + assert response.json() == {"max_value": 9, "selected_indices": [1, 2]} + + def test_http_graph_dijkstra(): """Verifies the Dijkstra endpoint computes shortest path distances.""" response = client.post( diff --git a/tests/test_service_mcp_server.py b/tests/test_service_mcp_server.py index 4938d63..829848b 100644 --- a/tests/test_service_mcp_server.py +++ b/tests/test_service_mcp_server.py @@ -22,6 +22,7 @@ def test_mcp_tool_registry_contains_all_algorithms(): "build_and_query_avl_tree", "build_and_query_union_find", "build_and_query_linked_list", + "dp_knapsack_01", "graph_dijkstra", "graph_bellman_ford", "graph_a_star", @@ -81,6 +82,14 @@ def test_mcp_build_and_query_linked_list(): assert result == {"values": [3, 2, 1], "found": True} +def test_mcp_dp_knapsack_01(): + """Verifies the Knapsack tool returns the optimal value and selected item indices.""" + result = mcp_server.dp_knapsack_01( + weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7 + ) + assert result == {"max_value": 9, "selected_indices": [1, 2]} + + def test_mcp_graph_dijkstra(): """Verifies the Dijkstra tool computes shortest path distances.""" graph = {"A": [["B", 1]], "B": [["C", 2]], "C": []} diff --git a/tests/test_service_tools.py b/tests/test_service_tools.py index 90920f6..bf9bec6 100644 --- a/tests/test_service_tools.py +++ b/tests/test_service_tools.py @@ -73,6 +73,12 @@ def test_build_and_query_linked_list_without_search_or_reverse(): assert "found" not in result +def test_dp_knapsack_01(): + """Verifies Knapsack wrapper returns the optimal value and selected item indices.""" + result = tools.dp_knapsack_01(weights=[1, 3, 4, 5], values=[1, 4, 5, 7], capacity=7) + assert result == {"max_value": 9, "selected_indices": [1, 2]} + + def test_graph_dijkstra(): """Verifies Dijkstra wrapper converts JSON edge lists and computes distances.""" graph = {"A": [["B", 1]], "B": [["C", 2]], "C": []}