Skip to content
Closed
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
155 changes: 133 additions & 22 deletions dynamic_programming/knapsack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
59 changes: 48 additions & 11 deletions sorts/merge_sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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:
Expand Down
54 changes: 44 additions & 10 deletions sorts/quick_sort.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand All @@ -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(",")]
Expand Down
Loading