diff --git a/dynamic_programming/knapsack.py b/dynamic_programming/knapsack.py index 28c5b19dbe36..c1db1a91ac1a 100644 --- a/dynamic_programming/knapsack.py +++ b/dynamic_programming/knapsack.py @@ -4,29 +4,91 @@ Note that only the integer weights 0-1 knapsack problem is solvable using dynamic programming. + +This module provides multiple approaches: +- ``knapsack``: Bottom-up DP with O(n*W) space and solution reconstruction. +- ``knapsack_with_example_solution``: Wrapper that returns optimal value and subset. +- ``knapsack_optimized``: Space-optimized bottom-up DP using O(W) space (value only). +- ``mf_knapsack``: Top-down memoized approach (memory function) with no global state. """ -def mf_knapsack(i, wt, val, j): +def mf_knapsack( + i: int, + wt: list[int], + val: list[int], + j: int, + memo: list[list[int]] | None = None, +) -> int: """ - This code involves the concept of memory functions. Here we solve the subproblems - which are needed unlike the below example - F is a 2D array with ``-1`` s filled up + Solve the 0-1 knapsack problem using top-down memoization (memory function). + + Unlike the previous implementation, this version does **not** rely on a global + ``f`` table. The memoization table is passed explicitly or created on first call. + + :param i: Number of items to consider (1-indexed). + :param wt: List of item weights. + :param val: List of item values. + :param j: Remaining knapsack capacity. + :param memo: Optional pre-allocated memoization table of shape ``(i+1) x (j+1)`` + initialised with ``-1`` for unsolved sub-problems and ``0`` for base cases. + When ``None`` a table is created automatically. + :return: Maximum obtainable value considering items ``1..i`` with capacity ``j``. + + Examples: + >>> mf_knapsack(4, [4, 3, 2, 3], [3, 2, 4, 4], 6) + 8 + >>> mf_knapsack(0, [1, 2], [10, 20], 5) + 0 + >>> mf_knapsack(3, [1, 3, 5], [10, 20, 100], 10) + 130 + >>> mf_knapsack(1, [5], [50], 3) + 0 + >>> mf_knapsack(1, [5], [50], 5) + 50 """ - global f # a global dp table for knapsack - if f[i][j] < 0: - if j < wt[i - 1]: - val = mf_knapsack(i - 1, wt, val, j) - else: - val = max( - mf_knapsack(i - 1, wt, val, j), - mf_knapsack(i - 1, wt, val, j - wt[i - 1]) + val[i - 1], - ) - f[i][j] = val - return f[i][j] + if memo is None: + memo = [[0] * (j + 1)] + [[0] + [-1] * j for _ in range(i)] + + if i == 0 or j == 0: + return 0 + if memo[i][j] >= 0: + return memo[i][j] -def knapsack(w, wt, val, n): + if j < wt[i - 1]: + memo[i][j] = mf_knapsack(i - 1, wt, val, j, memo) + else: + memo[i][j] = max( + mf_knapsack(i - 1, wt, val, j, memo), + mf_knapsack(i - 1, wt, val, j - wt[i - 1], memo) + val[i - 1], + ) + return memo[i][j] + + +def knapsack( + w: int, wt: list[int], val: list[int], n: int +) -> tuple[int, list[list[int]]]: + """ + Solve the 0-1 knapsack problem using bottom-up dynamic programming. + + :param w: Maximum knapsack capacity. + :param wt: List of item weights. + :param val: List of item values. + :param n: Number of items. + :return: A tuple ``(optimal_value, dp_table)`` where ``dp_table`` can be used + for solution reconstruction via ``_construct_solution``. + + Examples: + >>> knapsack(6, [4, 3, 2, 3], [3, 2, 4, 4], 4)[0] + 8 + >>> knapsack(10, [1, 3, 5, 2], [10, 20, 100, 22], 4)[0] + 142 + >>> knapsack(0, [1, 2], [10, 20], 2)[0] + 0 + >>> knapsack(5, [], [], 0)[0] + 0 + """ dp = [[0] * (w + 1) for _ in range(n + 1)] for i in range(1, n + 1): @@ -36,10 +98,51 @@ def knapsack(w, wt, val, n): else: dp[i][w_] = dp[i - 1][w_] - return dp[n][w_], dp + return dp[n][w], dp -def knapsack_with_example_solution(w: int, wt: list, val: list): +def knapsack_optimized(w: int, wt: list[int], val: list[int], n: int) -> int: + """ + Solve the 0-1 knapsack problem using space-optimized bottom-up DP. + + Uses a single 1-D array of size ``w + 1`` instead of a 2-D ``(n+1) x (w+1)`` + table, reducing space complexity from O(n*W) to O(W). + + .. note:: + This variant returns only the optimal value; it does **not** support + solution reconstruction (i.e. which items are included). + + :param w: Maximum knapsack capacity. + :param wt: List of item weights. + :param val: List of item values. + :param n: Number of items. + :return: Maximum obtainable value. + + Examples: + >>> knapsack_optimized(6, [4, 3, 2, 3], [3, 2, 4, 4], 4) + 8 + >>> knapsack_optimized(10, [1, 3, 5, 2], [10, 20, 100, 22], 4) + 142 + >>> knapsack_optimized(0, [1, 2], [10, 20], 2) + 0 + >>> knapsack_optimized(5, [], [], 0) + 0 + >>> knapsack_optimized(50, [10, 20, 30], [60, 100, 120], 3) + 220 + """ + dp = [0] * (w + 1) + + for i in range(n): + # Traverse capacity in reverse so each item is used at most once + for capacity in range(w, wt[i] - 1, -1): + dp[capacity] = max(dp[capacity], dp[capacity - wt[i]] + val[i]) + + return dp[w] + + +def knapsack_with_example_solution( + w: int, wt: list[int], val: list[int] +) -> tuple[int, set[int]]: """ Solves the integer weights knapsack problem returns one of the several possible optimal subsets. @@ -94,13 +197,15 @@ def knapsack_with_example_solution(w: int, wt: list, val: list): raise TypeError(msg) optimal_val, dp_table = knapsack(w, wt, val, num_items) - example_optional_set: set = set() + example_optional_set: set[int] = set() _construct_solution(dp_table, wt, num_items, w, example_optional_set) return optimal_val, example_optional_set -def _construct_solution(dp: list, wt: list, i: int, j: int, optimal_set: set): +def _construct_solution( + dp: list[list[int]], wt: list[int], i: int, j: int, optimal_set: set[int] +) -> None: """ Recursively reconstructs one of the optimal subsets given a filled DP table and the vector of weights @@ -135,14 +240,20 @@ def _construct_solution(dp: list, wt: list, i: int, j: int, optimal_set: set): """ Adding test case for knapsack """ + import doctest + + doctest.testmod() + val = [3, 2, 4, 4] wt = [4, 3, 2, 3] n = 4 w = 6 - f = [[0] * (w + 1)] + [[0] + [-1] * (w + 1) for _ in range(n + 1)] optimal_solution, _ = knapsack(w, wt, val, n) print(optimal_solution) - print(mf_knapsack(n, wt, val, w)) # switched the n and w + print(mf_knapsack(n, wt, val, w)) + + # Space-optimized knapsack + print(f"Optimized: {knapsack_optimized(w, wt, val, n)}") # testing the dynamic programming problem with example # the optimal subset for the above example are items 3 and 4 diff --git a/sorts/merge_sort.py b/sorts/merge_sort.py index 11c202788035..cd7b59ad779b 100644 --- a/sorts/merge_sort.py +++ b/sorts/merge_sort.py @@ -9,8 +9,10 @@ python merge_sort.py """ +from typing import Any -def merge_sort(collection: list) -> list: + +def merge_sort(collection: list[Any]) -> list[Any]: """ Sorts a list using the merge sort algorithm. @@ -27,21 +29,56 @@ def merge_sort(collection: list) -> list: [] >>> merge_sort([-2, -5, -45]) [-45, -5, -2] + >>> merge_sort([1]) + [1] + >>> merge_sort([1, 2, 3, 4, 5]) + [1, 2, 3, 4, 5] + >>> merge_sort([5, 4, 3, 2, 1]) + [1, 2, 3, 4, 5] + >>> merge_sort([3, 3, 3, 3]) + [3, 3, 3, 3] + >>> merge_sort(['d', 'a', 'b', 'e', 'c']) + ['a', 'b', 'c', 'd', 'e'] + >>> merge_sort([1.1, 0.5, 3.3, 2.2]) + [0.5, 1.1, 2.2, 3.3] + >>> import random + >>> collection_arg = random.sample(range(-50, 50), 100) + >>> merge_sort(collection_arg) == sorted(collection_arg) + True """ - def merge(left: list, right: list) -> list: + def merge(left: list[Any], right: list[Any]) -> list[Any]: """ - Merge two sorted lists into a single sorted list. + Merge two sorted lists into a single sorted list using index-based + traversal instead of pop(0) to achieve O(n) merge performance. + + :param left: Left sorted collection + :param right: Right sorted collection + :return: Merged sorted result - :param left: Left collection - :param right: Right collection - :return: Merged result + >>> merge([1, 3, 5], [2, 4, 6]) + [1, 2, 3, 4, 5, 6] + >>> merge([], [1, 2]) + [1, 2] + >>> merge([1], []) + [1] + >>> merge([], []) + [] """ - result = [] - while left and right: - result.append(left.pop(0) if left[0] <= right[0] else right.pop(0)) - result.extend(left) - result.extend(right) + result: list[Any] = [] + left_index, right_index = 0, 0 + + while left_index < len(left) and right_index < len(right): + if left[left_index] <= right[right_index]: + result.append(left[left_index]) + left_index += 1 + else: + result.append(right[right_index]) + right_index += 1 + + # Append any remaining elements from either list + result.extend(left[left_index:]) + result.extend(right[right_index:]) return result if len(collection) <= 1: diff --git a/sorts/quick_sort.py b/sorts/quick_sort.py index 374d52e75c81..5ba58536dfe6 100644 --- a/sorts/quick_sort.py +++ b/sorts/quick_sort.py @@ -10,14 +10,18 @@ from __future__ import annotations -from random import randrange +from typing import Any -def quick_sort(collection: list) -> list: +def quick_sort(collection: list[Any]) -> list[Any]: """A pure Python implementation of quicksort algorithm. + This implementation does not mutate the original collection. It uses + median-of-three pivot selection for improved performance on sorted or + nearly-sorted inputs. + :param collection: a mutable collection of comparable items - :return: the same collection ordered in ascending order + :return: a new list with the same elements ordered in ascending order Examples: >>> quick_sort([0, 5, 3, 2, 2]) @@ -26,24 +30,54 @@ def quick_sort(collection: list) -> list: [] >>> quick_sort([-2, 5, 0, -45]) [-45, -2, 0, 5] + >>> quick_sort([1]) + [1] + >>> quick_sort([1, 2, 3, 4, 5]) + [1, 2, 3, 4, 5] + >>> quick_sort([5, 4, 3, 2, 1]) + [1, 2, 3, 4, 5] + >>> quick_sort([3, 3, 3, 3]) + [3, 3, 3, 3] + >>> quick_sort(['d', 'a', 'b', 'e', 'c']) + ['a', 'b', 'c', 'd', 'e'] + >>> quick_sort([1.1, 0.5, 3.3, 2.2]) + [0.5, 1.1, 2.2, 3.3] + >>> original = [3, 1, 2] + >>> sorted_list = quick_sort(original) + >>> original + [3, 1, 2] + >>> sorted_list + [1, 2, 3] + >>> import random + >>> collection_arg = random.sample(range(-50, 50), 100) + >>> quick_sort(collection_arg) == sorted(collection_arg) + True """ # Base case: if the collection has 0 or 1 elements, it is already sorted if len(collection) < 2: return collection - # Randomly select a pivot index and remove the pivot element from the collection - pivot_index = randrange(len(collection)) - pivot = collection.pop(pivot_index) + # Use median-of-three pivot selection for better worst-case performance. + # Compare the first, middle, and last elements and pick the median value. + first = collection[0] + middle = collection[len(collection) // 2] + last = collection[-1] + pivot = sorted((first, middle, last))[1] - # Partition the remaining elements into two groups: lesser or equal, and greater - lesser = [item for item in collection if item <= pivot] + # Partition elements into three groups without mutating the original list + lesser = [item for item in collection if item < pivot] + equal = [item for item in collection if item == pivot] greater = [item for item in collection if item > pivot] - # Recursively sort the lesser and greater groups, and combine with the pivot - return [*quick_sort(lesser), pivot, *quick_sort(greater)] + # Recursively sort the lesser and greater groups, and combine with equal + return [*quick_sort(lesser), *equal, *quick_sort(greater)] if __name__ == "__main__": + import doctest + + doctest.testmod() + # Get user input and convert it into a list of integers user_input = input("Enter numbers separated by a comma:\n").strip() unsorted = [int(item) for item in user_input.split(",")]