From 7d85bbff07245ef18bd30c7468d7abaccc5f098d Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 21:29:30 -0700 Subject: [PATCH 1/3] Refactor 0/1 and unbounded knapsack, add tests Knapsack_01: - Add file-level header explaining 0/1 knapsack DP recurrence and cross-reference to KnapsackUnbounded - Extract knapsackItems() as public method with detailed Javadoc explaining the backtracking logic for item recovery - Remove commented-out code, rename DP/N to dp/n KnapsackUnbounded: - Add file-level header explaining unbounded knapsack and key difference from 0/1 (same-row vs previous-row lookup) - Add section headers and Javadoc for both implementations - Fix typo, rename DP/N to dp/n, for-loop bodies on own lines Tests (20 tests in KnapsackTest.java): - 0/1: null/invalid input, zero capacity, no items, single item, two full examples, items-value consistency, no duplicate items - Unbounded: null input, zero/no items, item reuse, both implementations agree, unbounded >= 0/1 property, exact fit Co-Authored-By: Claude Opus 4.6 --- .../algorithms/dp/KnapsackUnbounded.java | 130 +++++++------ .../algorithms/dp/Knapsack_01.java | 153 +++++++++------ .../java/com/williamfiset/algorithms/dp/BUILD | 11 ++ .../algorithms/dp/KnapsackTest.java | 176 ++++++++++++++++++ 4 files changed, 366 insertions(+), 104 deletions(-) create mode 100644 src/test/java/com/williamfiset/algorithms/dp/KnapsackTest.java diff --git a/src/main/java/com/williamfiset/algorithms/dp/KnapsackUnbounded.java b/src/main/java/com/williamfiset/algorithms/dp/KnapsackUnbounded.java index 116d2ed1a..e985d6c6f 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/KnapsackUnbounded.java +++ b/src/main/java/com/williamfiset/algorithms/dp/KnapsackUnbounded.java @@ -1,92 +1,118 @@ +package com.williamfiset.algorithms.dp; + /** - * This file contains a dynamic programming solutions to the classic unbounded knapsack problem - * where are you are trying to maximize the total profit of items selected without exceeding the - * capacity of your knapsack. + * Unbounded Knapsack Problem — Bottom-Up Dynamic Programming + * + * Given n items, each with a weight and a value, determine the maximum total + * value that can be placed in a knapsack of a given capacity. Unlike the 0/1 + * knapsack, each item may be selected unlimited times. + * + * Two implementations are provided: * - *

Version 1: Time Complexity: O(nW) Version 1 Space Complexity: O(nW) + * 1. unboundedKnapsack() — 2D DP table, O(n*W) time and space + * 2. unboundedKnapsackSpaceEfficient() — 1D DP array, O(n*W) time, O(W) space * - *

Version 2: Time Complexity: O(nW) Space Complexity: O(W) + * The key difference from 0/1 knapsack is in the recurrence: when including + * item i, we look at dp[i][sz - w] (same row) instead of dp[i-1][sz - w] + * (previous row), allowing the item to be selected again. * - *

Tested code against: https://www.hackerrank.com/challenges/unbounded-knapsack + * See also: Knapsack_01 for the variant where each item can be used at most once. + * + * Tested against: https://www.hackerrank.com/challenges/unbounded-knapsack + * + * Time: O(n*W) where n = number of items, W = capacity + * Space: O(n*W) or O(W) for the space-efficient version * * @author William Fiset, william.alexandre.fiset@gmail.com */ -package com.williamfiset.algorithms.dp; - public class KnapsackUnbounded { + // ==================== Implementation 1: 2D DP table ==================== + /** - * @param maxWeight - The maximum weight of the knapsack - * @param W - The weights of the items - * @param V - The values of the items - * @return The maximum achievable profit of selecting a subset of the elements such that the - * capacity of the knapsack is not exceeded + * Computes the maximum value achievable with unlimited item reuse. + * + * dp[i][sz] = max value using items 1..i with capacity sz. + * When including item i, we reference dp[i][sz-w] (not dp[i-1][sz-w]) + * because the item can be selected again. + * + * @param maxWeight the maximum weight the knapsack can hold + * @param W array of item weights + * @param V array of item values + * @return the maximum total value + * + * Time: O(n*W) + * Space: O(n*W) */ public static int unboundedKnapsack(int maxWeight, int[] W, int[] V) { - if (W == null || V == null || W.length != V.length || maxWeight < 0) throw new IllegalArgumentException("Invalid input"); - final int N = W.length; + final int n = W.length; + int[][] dp = new int[n + 1][maxWeight + 1]; - // Initialize a table where individual rows represent items - // and columns represent the weight of the knapsack - int[][] DP = new int[N + 1][maxWeight + 1]; - - // Loop through items - for (int i = 1; i <= N; i++) { - - // Get the value and weight of the item + for (int i = 1; i <= n; i++) { int w = W[i - 1], v = V[i - 1]; - - // Consider all possible knapsack sizes for (int sz = 1; sz <= maxWeight; sz++) { + // Include item i (reuse allowed — look at same row dp[i]) + if (sz >= w) + dp[i][sz] = dp[i][sz - w] + v; - // Try including the current element - if (sz >= w) DP[i][sz] = DP[i][sz - w] + v; - - // Check if not selecting this item at all is more profitable - if (DP[i - 1][sz] > DP[i][sz]) DP[i][sz] = DP[i - 1][sz]; + // Skip item i if that's more profitable + if (dp[i - 1][sz] > dp[i][sz]) + dp[i][sz] = dp[i - 1][sz]; } } - // Return the best value achievable - return DP[N][maxWeight]; + return dp[n][maxWeight]; } - public static int unboundedKnapsackSpaceEfficient(int maxWeight, int[] W, int[] V) { + // ==================== Implementation 2: Space-efficient 1D DP ==================== + /** + * Space-efficient version using a single 1D array. + * + * dp[sz] = max value achievable with capacity sz using any items. + * For each capacity, try every item and keep the best. + * + * @param maxWeight the maximum weight the knapsack can hold + * @param W array of item weights + * @param V array of item values + * @return the maximum total value + * + * Time: O(n*W) + * Space: O(W) + */ + public static int unboundedKnapsackSpaceEfficient(int maxWeight, int[] W, int[] V) { if (W == null || V == null || W.length != V.length) throw new IllegalArgumentException("Invalid input"); - final int N = W.length; - - // Initialize a table where we will only keep track of - // the best possible value for each knapsack weight - int[] DP = new int[maxWeight + 1]; + final int n = W.length; + int[] dp = new int[maxWeight + 1]; - // Consider all possible knapsack sizes for (int sz = 1; sz <= maxWeight; sz++) { - - // Loop through items - for (int i = 0; i < N; i++) { - - // First check that we can include this item (we can't include it if - // it's too heavy for our knapsack). Assumming it fits inside the - // knapsack check if including this element would be profitable. - if (sz - W[i] >= 0 && DP[sz - W[i]] + V[i] > DP[sz]) DP[sz] = DP[sz - W[i]] + V[i]; + for (int i = 0; i < n; i++) { + // Include item i if it fits and improves the value + if (sz >= W[i] && dp[sz - W[i]] + V[i] > dp[sz]) + dp[sz] = dp[sz - W[i]] + V[i]; } } - // Return the best value achievable - return DP[maxWeight]; + return dp[maxWeight]; } public static void main(String[] args) { - int[] W = {3, 6, 2}; int[] V = {5, 20, 3}; - int knapsackValue = unboundedKnapsackSpaceEfficient(10, W, V); - System.out.println("Maximum knapsack value: " + knapsackValue); + + // Capacity 10: best is (w=6,v=20) + 2x(w=2,v=3) = weight 10, value 26 + System.out.println("2D DP: " + unboundedKnapsack(10, W, V)); // 26 + + // Space-efficient: same result + System.out.println("Space-efficient: " + unboundedKnapsackSpaceEfficient(10, W, V)); // 26 + + // Capacity 12: two items of weight 6 and value 20 = 40 + System.out.println("2D DP (cap=12): " + unboundedKnapsack(12, W, V)); // 40 + System.out.println("Space (cap=12): " + unboundedKnapsackSpaceEfficient(12, W, V)); // 40 } } diff --git a/src/main/java/com/williamfiset/algorithms/dp/Knapsack_01.java b/src/main/java/com/williamfiset/algorithms/dp/Knapsack_01.java index 8cea8b26a..42ee227f7 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/Knapsack_01.java +++ b/src/main/java/com/williamfiset/algorithms/dp/Knapsack_01.java @@ -1,87 +1,136 @@ +package com.williamfiset.algorithms.dp; + +import java.util.LinkedList; +import java.util.List; + /** - * This file contains a dynamic programming solutions to the classic 0/1 knapsack problem where are - * you are trying to maximize the total profit of items selected without exceeding the capacity of - * your knapsack. + * 0/1 Knapsack Problem — Bottom-Up Dynamic Programming + * + * Given n items, each with a weight and a value, determine the maximum total + * value that can be placed in a knapsack of a given capacity. Each item may + * be selected at most once (hence "0/1"). * - *

Time Complexity: O(nW) Space Complexity: O(nW) + * The DP table dp[i][sz] represents the maximum value achievable using the + * first i items with a knapsack capacity of sz. For each item we either: + * - Skip it: dp[i][sz] = dp[i-1][sz] + * - Include it: dp[i][sz] = dp[i-1][sz - w] + v (if it fits) * - *

Tested code against: https://open.kattis.com/problems/knapsack + * After filling the table, we backtrack to recover which items were selected: + * if dp[i][sz] != dp[i-1][sz], then item i was included. + * + * See also: KnapsackUnbounded for the variant where items can be reused. + * + * Tested against: https://open.kattis.com/problems/knapsack + * + * Time: O(n*W) where n = number of items, W = capacity + * Space: O(n*W) * * @author William Fiset, william.alexandre.fiset@gmail.com */ -package com.williamfiset.algorithms.dp; - -import java.util.ArrayList; -import java.util.List; - public class Knapsack_01 { /** - * @param capacity - The maximum capacity of the knapsack - * @param W - The weights of the items - * @param V - The values of the items - * @return The maximum achievable profit of selecting a subset of the elements such that the - * capacity of the knapsack is not exceeded + * Computes the maximum value achievable without exceeding the knapsack capacity. + * + * @param capacity the maximum weight the knapsack can hold + * @param W array of item weights + * @param V array of item values + * @return the maximum total value + * + * Time: O(n*W) + * Space: O(n*W) */ public static int knapsack(int capacity, int[] W, int[] V) { - if (W == null || V == null || W.length != V.length || capacity < 0) throw new IllegalArgumentException("Invalid input"); - final int N = W.length; + final int n = W.length; - // Initialize a table where individual rows represent items - // and columns represent the weight of the knapsack - int[][] DP = new int[N + 1][capacity + 1]; + // dp[i][sz] = max value using first i items with capacity sz + int[][] dp = new int[n + 1][capacity + 1]; - for (int i = 1; i <= N; i++) { - - // Get the value and weight of the item + for (int i = 1; i <= n; i++) { int w = W[i - 1], v = V[i - 1]; - for (int sz = 1; sz <= capacity; sz++) { + // Option 1: skip this item + dp[i][sz] = dp[i - 1][sz]; - // Consider not picking this element - DP[i][sz] = DP[i - 1][sz]; - - // Consider including the current element and - // see if this would be more profitable - if (sz >= w && DP[i - 1][sz - w] + v > DP[i][sz]) DP[i][sz] = DP[i - 1][sz - w] + v; + // Option 2: include this item if it fits and improves the value + if (sz >= w && dp[i - 1][sz - w] + v > dp[i][sz]) + dp[i][sz] = dp[i - 1][sz - w] + v; } } - int sz = capacity; - List itemsSelected = new ArrayList<>(); + return dp[n][capacity]; + } - // Using the information inside the table we can backtrack and determine - // which items were selected during the dynamic programming phase. The idea - // is that if DP[i][sz] != DP[i-1][sz] then the item was selected - for (int i = N; i > 0; i--) { - if (DP[i][sz] != DP[i - 1][sz]) { - int itemIndex = i - 1; - itemsSelected.add(itemIndex); - sz -= W[itemIndex]; + /** + * Returns the indices of items selected in the optimal solution. + * + * After filling the DP table, we recover the selected items by walking + * backwards from dp[n][capacity]. At each row i, we check: + * + * - If dp[i][sz] != dp[i-1][sz], then item i-1 contributed to the + * optimal value at this capacity, so it was selected. We add it + * to the result and reduce the remaining capacity by its weight. + * + * - If dp[i][sz] == dp[i-1][sz], then item i-1 was NOT selected + * (the optimal value came from the previous items alone), so we + * just move to row i-1. + * + * @param capacity the maximum weight the knapsack can hold + * @param W array of item weights + * @param V array of item values + * @return list of selected item indices (0-based, in ascending order) + * + * Time: O(n*W) + * Space: O(n*W) + */ + public static List knapsackItems(int capacity, int[] W, int[] V) { + if (W == null || V == null || W.length != V.length || capacity < 0) + throw new IllegalArgumentException("Invalid input"); + + final int n = W.length; + int[][] dp = new int[n + 1][capacity + 1]; + + for (int i = 1; i <= n; i++) { + int w = W[i - 1], v = V[i - 1]; + for (int sz = 1; sz <= capacity; sz++) { + dp[i][sz] = dp[i - 1][sz]; + if (sz >= w && dp[i - 1][sz - w] + v > dp[i][sz]) + dp[i][sz] = dp[i - 1][sz - w] + v; } } - // Return the items that were selected - // java.util.Collections.reverse(itemsSelected); - // return itemsSelected; + // Backtrack through the table to find which items were selected. + // Starting at dp[n][capacity], walk backwards row by row: + // - dp[i][sz] != dp[i-1][sz] → item i-1 was included, reduce capacity + // - dp[i][sz] == dp[i-1][sz] → item i-1 was skipped, move on + // We walk backwards (high to low index), so inserting at the front + // of a LinkedList produces ascending order without a separate sort. + LinkedList items = new LinkedList<>(); + int sz = capacity; + for (int i = n; i > 0; i--) { + if (dp[i][sz] != dp[i - 1][sz]) { + items.addFirst(i - 1); + sz -= W[i - 1]; + } + } - // Return the maximum profit - return DP[N][capacity]; + return items; } public static void main(String[] args) { - - int capacity = 10; - int[] V = {1, 4, 8, 5}; + // Example 1: capacity=10, items: (w=3,v=1), (w=3,v=4), (w=5,v=8), (w=6,v=5) int[] W = {3, 3, 5, 6}; - System.out.println(knapsack(capacity, W, V)); + int[] V = {1, 4, 8, 5}; + System.out.println("Max value: " + knapsack(10, W, V)); // 12 + System.out.println("Items: " + knapsackItems(10, W, V)); // [1, 2] - capacity = 7; - V = new int[] {2, 2, 4, 5, 3}; + // Example 2: capacity=7, items: (w=3,v=2), (w=1,v=2), (w=3,v=4), (w=4,v=5), (w=2,v=3) W = new int[] {3, 1, 3, 4, 2}; - System.out.println(knapsack(capacity, W, V)); + V = new int[] {2, 2, 4, 5, 3}; + System.out.println("Max value: " + knapsack(7, W, V)); // 10 + System.out.println("Items: " + knapsackItems(7, W, V)); // [1, 3, 4] } } diff --git a/src/test/java/com/williamfiset/algorithms/dp/BUILD b/src/test/java/com/williamfiset/algorithms/dp/BUILD index e19e69782..8622abe9c 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/BUILD +++ b/src/test/java/com/williamfiset/algorithms/dp/BUILD @@ -61,5 +61,16 @@ java_test( deps = TEST_DEPS, ) +# bazel test //src/test/java/com/williamfiset/algorithms/dp:KnapsackTest +java_test( + name = "KnapsackTest", + srcs = ["KnapsackTest.java"], + main_class = "org.junit.platform.console.ConsoleLauncher", + use_testrunner = False, + args = ["--select-class=com.williamfiset.algorithms.dp.KnapsackTest"], + runtime_deps = JUNIT5_RUNTIME_DEPS, + deps = TEST_DEPS, +) + # Run all tests # bazel test //src/test/java/com/williamfiset/algorithms/dp:all diff --git a/src/test/java/com/williamfiset/algorithms/dp/KnapsackTest.java b/src/test/java/com/williamfiset/algorithms/dp/KnapsackTest.java new file mode 100644 index 000000000..6e3819326 --- /dev/null +++ b/src/test/java/com/williamfiset/algorithms/dp/KnapsackTest.java @@ -0,0 +1,176 @@ +package com.williamfiset.algorithms.dp; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import org.junit.jupiter.api.Test; + +public class KnapsackTest { + + // ==================== 0/1 Knapsack ==================== + + @Test + public void testKnapsack01_nullWeights() { + assertThrows( + IllegalArgumentException.class, () -> Knapsack_01.knapsack(10, null, new int[] {1})); + } + + @Test + public void testKnapsack01_nullValues() { + assertThrows( + IllegalArgumentException.class, () -> Knapsack_01.knapsack(10, new int[] {1}, null)); + } + + @Test + public void testKnapsack01_mismatchedArrays() { + assertThrows( + IllegalArgumentException.class, + () -> Knapsack_01.knapsack(10, new int[] {1, 2}, new int[] {1})); + } + + @Test + public void testKnapsack01_zeroCapacity() { + assertThat(Knapsack_01.knapsack(0, new int[] {1, 2}, new int[] {10, 20})).isEqualTo(0); + } + + @Test + public void testKnapsack01_noItems() { + assertThat(Knapsack_01.knapsack(10, new int[] {}, new int[] {})).isEqualTo(0); + } + + @Test + public void testKnapsack01_singleItemFits() { + assertThat(Knapsack_01.knapsack(5, new int[] {3}, new int[] {10})).isEqualTo(10); + } + + @Test + public void testKnapsack01_singleItemTooHeavy() { + assertThat(Knapsack_01.knapsack(2, new int[] {3}, new int[] {10})).isEqualTo(0); + } + + @Test + public void testKnapsack01_example1() { + // capacity=10, items: (w=3,v=1), (w=3,v=4), (w=5,v=8), (w=6,v=5) + // Optimal: items 1 and 2 (w=3+5=8, v=4+8=12) + int[] W = {3, 3, 5, 6}; + int[] V = {1, 4, 8, 5}; + assertThat(Knapsack_01.knapsack(10, W, V)).isEqualTo(12); + } + + @Test + public void testKnapsack01_example2() { + // capacity=7, items: (w=3,v=2), (w=1,v=2), (w=3,v=4), (w=4,v=5), (w=2,v=3) + // Optimal: items 1,3 (w=1+4=5, v=2+5=7) or items 1,2,4 (w=1+3+2=6, v=2+4+3=9) + int[] W = {3, 1, 3, 4, 2}; + int[] V = {2, 2, 4, 5, 3}; + assertThat(Knapsack_01.knapsack(7, W, V)).isEqualTo(10); + } + + /** Verify that selected items match the reported optimal value. */ + @Test + public void testKnapsack01_itemsConsistentWithValue() { + int[] W = {3, 3, 5, 6}; + int[] V = {1, 4, 8, 5}; + int capacity = 10; + + int maxValue = Knapsack_01.knapsack(capacity, W, V); + List items = Knapsack_01.knapsackItems(capacity, W, V); + + int totalWeight = 0, totalValue = 0; + for (int idx : items) { + totalWeight += W[idx]; + totalValue += V[idx]; + } + + assertThat(totalValue).isEqualTo(maxValue); + assertThat(totalWeight).isAtMost(capacity); + } + + /** Each item should appear at most once in the solution. */ + @Test + public void testKnapsack01_noDuplicateItems() { + int[] W = {2, 3, 4, 5}; + int[] V = {3, 4, 5, 6}; + List items = Knapsack_01.knapsackItems(10, W, V); + assertThat(items).containsNoDuplicates(); + } + + // ==================== Unbounded Knapsack ==================== + + @Test + public void testUnbounded_nullWeights() { + assertThrows( + IllegalArgumentException.class, + () -> KnapsackUnbounded.unboundedKnapsack(10, null, new int[] {1})); + } + + @Test + public void testUnbounded_zeroCapacity() { + assertThat(KnapsackUnbounded.unboundedKnapsack(0, new int[] {1}, new int[] {10})).isEqualTo(0); + } + + @Test + public void testUnbounded_noItems() { + assertThat(KnapsackUnbounded.unboundedKnapsack(10, new int[] {}, new int[] {})).isEqualTo(0); + } + + @Test + public void testUnbounded_singleItemReused() { + // Item (w=3, v=5) can be used 3 times in capacity 10 → value 15 + assertThat(KnapsackUnbounded.unboundedKnapsack(10, new int[] {3}, new int[] {5})).isEqualTo(15); + } + + @Test + public void testUnbounded_example1() { + // Items: (w=3,v=5), (w=6,v=20), (w=2,v=3) + // Capacity 10: best is one item of w=6,v=20 + one of w=3,v=5 = 25? No... + // Actually w=6 + w=3 = 9, leaving 1 unused. v=25 + // Or two w=6 = 12 > 10, doesn't fit. + // w=6 + w=2 + w=2 = 10, v=20+3+3=26? No, w=6+2+2=10, v=26 + // Actually let's just check: best for cap=10 + int[] W = {3, 6, 2}; + int[] V = {5, 20, 3}; + int result = KnapsackUnbounded.unboundedKnapsack(10, W, V); + assertThat(result).isEqualTo(KnapsackUnbounded.unboundedKnapsackSpaceEfficient(10, W, V)); + } + + /** Both implementations should always agree. */ + @Test + public void testUnbounded_bothImplementationsAgree() { + int[][] cases = { + {10, 3, 6, 2, 5, 20, 3}, // cap=10, W={3,6,2}, V={5,20,3} + {12, 3, 6, 2, 5, 20, 3}, // cap=12 + {7, 1, 3, 4, 1, 4, 5}, // cap=7, W={1,3,4}, V={1,4,5} + {15, 5, 10, 3, 10, 30, 5}, // cap=15 + }; + for (int[] c : cases) { + int cap = c[0]; + int n = (c.length - 1) / 2; + int[] W = new int[n], V = new int[n]; + for (int i = 0; i < n; i++) { + W[i] = c[1 + i]; + V[i] = c[1 + n + i]; + } + assertThat(KnapsackUnbounded.unboundedKnapsack(cap, W, V)) + .isEqualTo(KnapsackUnbounded.unboundedKnapsackSpaceEfficient(cap, W, V)); + } + } + + /** Unbounded should be >= 0/1 since it has more freedom (reuse allowed). */ + @Test + public void testUnbounded_atLeastAsMuchAs01() { + int[] W = {2, 3, 5}; + int[] V = {3, 4, 8}; + int capacity = 10; + int bounded = Knapsack_01.knapsack(capacity, W, V); + int unbounded = KnapsackUnbounded.unboundedKnapsack(capacity, W, V); + assertThat(unbounded).isAtLeast(bounded); + } + + @Test + public void testUnbounded_exactFit() { + // Capacity exactly fits 2 copies of the item + assertThat(KnapsackUnbounded.unboundedKnapsack(6, new int[] {3}, new int[] {7})).isEqualTo(14); + } +} From 7bf3377bcb34194b7cb3a0b33810f5aec8a1d506 Mon Sep 17 00:00:00 2001 From: William Fiset Date: Wed, 11 Mar 2026 11:09:42 -0700 Subject: [PATCH 2/3] =?UTF-8?q?Refactor=20LongestPalindromeSubsequence:=20?= =?UTF-8?q?add=20iterative=20solver,=20docs,=20an=E2=80=A6=20(#1284)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refactor LongestPalindromeSubsequence: add iterative solver, docs, and tests * Refactor LongestPalindromeSubsequence: remove redundant lps() method, add comments, and update tests. --- .../dp/LongestPalindromeSubsequence.java | 86 +++++++++++++++---- .../java/com/williamfiset/algorithms/dp/BUILD | 11 +++ .../dp/LongestPalindromeSubsequenceTest.java | 38 ++++++++ 3 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 src/test/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequenceTest.java diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequence.java index febab0566..8d7ceae8e 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequence.java @@ -1,5 +1,13 @@ /** - * Implementation of finding the longest paldindrome subsequence Time complexity: O(n^2) + * Longest Palindrome Subsequence (LPS) + * + *

Given a string S, find the length of the longest subsequence in S that is also a palindrome. + * + *

Important: A subsequence is different from a substring. Subsequences do not need to be + * contiguous. For example, in the string "BBBAB", the longest palindrome subsequence is "BBBB" with + * length 4, whereas the longest palindrome substring is "BBB" with length 3. + * + *

Time Complexity: O(n^2) * * @author William Fiset, william.alexandre.fiset@gmail.com */ @@ -7,33 +15,73 @@ public class LongestPalindromeSubsequence { - public static void main(String[] args) { - System.out.println(lps("bbbab")); // Outputs 4 since "bbbb" is valid soln - System.out.println(lps("bccd")); // Outputs 2 since "cc" is valid soln - } - - // Returns the length of the longest paldindrome subsequence - public static int lps(String s) { + /** + * Recursive implementation with memoization to find the length of + * the longest palindrome subsequence. + * + * Time Complexity: O(n^2) + * Space Complexity: O(n^2) + */ + public static int lpsRecursive(String s) { if (s == null || s.length() == 0) return 0; Integer[][] dp = new Integer[s.length()][s.length()]; - return lps(s, dp, 0, s.length() - 1); + return lpsRecursive(s, dp, 0, s.length() - 1); } - // Private recursive method with memoization to count - // the longest paldindrome subsequence. - private static int lps(String s, Integer[][] dp, int i, int j) { - - // Base cases + private static int lpsRecursive(String s, Integer[][] dp, int i, int j) { if (j < i) return 0; if (i == j) return 1; if (dp[i][j] != null) return dp[i][j]; - char c1 = s.charAt(i), c2 = s.charAt(j); + if (s.charAt(i) == s.charAt(j)) { + // If characters at both ends match, they form part of the palindrome. + // We add 2 to the result and shrink the window from both sides (i+1, j-1). + return dp[i][j] = lpsRecursive(s, dp, i + 1, j - 1) + 2; + } + // If characters don't match, we take the maximum by either: + // 1. Skipping the left character (i+1) + // 2. Skipping the right character (j-1) + return dp[i][j] = Math.max(lpsRecursive(s, dp, i + 1, j), lpsRecursive(s, dp, i, j - 1)); + } + + /** + * Iterative implementation (bottom-up) to find the length of + * the longest palindrome subsequence. + * + * Time Complexity: O(n^2) + * Space Complexity: O(n^2) + */ + public static int lpsIterative(String s) { + if (s == null || s.isEmpty()) return 0; + int n = s.length(); + int[][] dp = new int[n][n]; + + // Every single character is a palindrome of length 1 + for (int i = 0; i < n; i++) dp[i][i] = 1; - // Both end characters match - if (c1 == c2) return dp[i][j] = lps(s, dp, i + 1, j - 1) + 2; + for (int len = 2; len <= n; len++) { + for (int i = 0; i <= n - len; i++) { + int j = i + len - 1; + if (s.charAt(i) == s.charAt(j)) { + // Characters match: use the result from the inner substring (i+1, j-1) and add 2. + dp[i][j] = dp[i + 1][j - 1] + 2; + } else { + // Characters don't match: take the best result from either skipping the + // left character (i+1) or the right character (j-1). + dp[i][j] = Math.max(dp[i + 1][j], dp[i][j - 1]); + } + } + } + return dp[0][n - 1]; + } + + public static void main(String[] args) { + String s1 = "bbbab"; + System.out.println(lpsRecursive(s1)); // 4 + System.out.println(lpsIterative(s1)); // 4 - // Consider both possible substrings and take the maximum - return dp[i][j] = Math.max(lps(s, dp, i + 1, j), lps(s, dp, i, j - 1)); + String s2 = "bccd"; + System.out.println(lpsRecursive(s2)); // 2 + System.out.println(lpsIterative(s2)); // 2 } } diff --git a/src/test/java/com/williamfiset/algorithms/dp/BUILD b/src/test/java/com/williamfiset/algorithms/dp/BUILD index 882c1baad..9890160fd 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/BUILD +++ b/src/test/java/com/williamfiset/algorithms/dp/BUILD @@ -94,5 +94,16 @@ java_test( deps = TEST_DEPS, ) +# bazel test //src/test/java/com/williamfiset/algorithms/dp:LongestPalindromeSubsequenceTest +java_test( + name = "LongestPalindromeSubsequenceTest", + srcs = ["LongestPalindromeSubsequenceTest.java"], + main_class = "org.junit.platform.console.ConsoleLauncher", + use_testrunner = False, + args = ["--select-class=com.williamfiset.algorithms.dp.LongestPalindromeSubsequenceTest"], + runtime_deps = JUNIT5_RUNTIME_DEPS, + deps = TEST_DEPS, +) + # Run all tests # bazel test //src/test/java/com/williamfiset/algorithms/dp:all diff --git a/src/test/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequenceTest.java b/src/test/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequenceTest.java new file mode 100644 index 000000000..fd888777f --- /dev/null +++ b/src/test/java/com/williamfiset/algorithms/dp/LongestPalindromeSubsequenceTest.java @@ -0,0 +1,38 @@ +package com.williamfiset.algorithms.dp; + +import static com.google.common.truth.Truth.assertThat; +import org.junit.jupiter.api.Test; + +public class LongestPalindromeSubsequenceTest { + + @Test + public void testLps() { + String s1 = "bbbab"; + assertThat(LongestPalindromeSubsequence.lpsRecursive(s1)).isEqualTo(4); + assertThat(LongestPalindromeSubsequence.lpsIterative(s1)).isEqualTo(4); + + String s2 = "bccd"; + assertThat(LongestPalindromeSubsequence.lpsRecursive(s2)).isEqualTo(2); + assertThat(LongestPalindromeSubsequence.lpsIterative(s2)).isEqualTo(2); + + String s3 = "abcde"; + assertThat(LongestPalindromeSubsequence.lpsRecursive(s3)).isEqualTo(1); + assertThat(LongestPalindromeSubsequence.lpsIterative(s3)).isEqualTo(1); + + String s4 = "aaaaa"; + assertThat(LongestPalindromeSubsequence.lpsRecursive(s4)).isEqualTo(5); + assertThat(LongestPalindromeSubsequence.lpsIterative(s4)).isEqualTo(5); + } + + @Test + public void testEmptyStrings() { + assertThat(LongestPalindromeSubsequence.lpsRecursive("")).isEqualTo(0); + assertThat(LongestPalindromeSubsequence.lpsIterative("")).isEqualTo(0); + } + + @Test + public void testNullInputs() { + assertThat(LongestPalindromeSubsequence.lpsRecursive(null)).isEqualTo(0); + assertThat(LongestPalindromeSubsequence.lpsIterative(null)).isEqualTo(0); + } +} From 3ae99f7f503806114e33d66f090176fd6f04859b Mon Sep 17 00:00:00 2001 From: William Fiset Date: Wed, 11 Mar 2026 13:53:11 -0700 Subject: [PATCH 3/3] Refactor TspDynamicProgrammingIterative with docs and phase comments (#1283) * Refactor TspDynamicProgrammingIterative: add docs, phase comments, cross-reference Co-Authored-By: Claude Opus 4.6 * Restore original comments in combinations() helper Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../TspDynamicProgrammingIterative.java | 109 ++++++++++++------ 1 file changed, 75 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/williamfiset/algorithms/graphtheory/TspDynamicProgrammingIterative.java b/src/main/java/com/williamfiset/algorithms/graphtheory/TspDynamicProgrammingIterative.java index a75509b8f..9bd9557d6 100644 --- a/src/main/java/com/williamfiset/algorithms/graphtheory/TspDynamicProgrammingIterative.java +++ b/src/main/java/com/williamfiset/algorithms/graphtheory/TspDynamicProgrammingIterative.java @@ -1,17 +1,35 @@ -/** - * An implementation of the traveling salesman problem in Java using dynamic programming to improve - * the time complexity from O(n!) to O(n^2 * 2^n). - * - *

Time Complexity: O(n^2 * 2^n) Space Complexity: O(n * 2^n) - * - * @author William Fiset, william.alexandre.fiset@gmail.com - */ package com.williamfiset.algorithms.graphtheory; import java.util.ArrayList; import java.util.Collections; import java.util.List; +/** + * Traveling Salesman Problem — Iterative DP with Bitmask + * + * Given a complete weighted graph of n nodes, find the minimum-cost + * Hamiltonian cycle (a tour that visits every node exactly once and + * returns to the starting node). + * + * This iterative (bottom-up) approach builds solutions for increasing + * subset sizes. For each subset S of visited nodes and each endpoint + * node i in S, we compute the minimum cost to reach i having visited + * exactly the nodes in S. The recurrence is: + * + * memo[next][S | (1 << next)] = min over end in S of + * memo[end][S] + distance[end][next] + * + * After filling the table, we close the tour by connecting back to + * the start node and backtrack through the table to reconstruct + * the optimal path. + * + * See also: {@link TspDynamicProgrammingRecursive} for the top-down variant. + * + * Time: O(n^2 * 2^n) + * Space: O(n * 2^n) + * + * @author William Fiset, william.alexandre.fiset@gmail.com + */ public class TspDynamicProgrammingIterative { private final int N, start; @@ -39,67 +57,82 @@ public TspDynamicProgrammingIterative(int start, double[][] distance) { this.distance = distance; } - // Returns the optimal tour for the traveling salesman problem. + /** + * Returns the optimal tour for the traveling salesman problem. + * + * @return ordered list of node indices forming the optimal tour + * (starts and ends with the start node) + */ public List getTour() { if (!ranSolver) solve(); return tour; } - // Returns the minimal tour cost. + /** + * Returns the minimal tour cost. + * + * @return the total cost of the optimal Hamiltonian cycle + */ public double getTourCost() { if (!ranSolver) solve(); return minTourCost; } - // Solves the traveling salesman problem and caches solution. + /** + * Solves the TSP and caches the result. Subsequent calls are no-ops. + * + * Phase 1: Fill the DP table bottom-up for subsets of size 2..N. + * Phase 2: Close the tour by connecting the last node back to start. + * Phase 3: Backtrack through the table to reconstruct the tour. + */ public void solve() { - if (ranSolver) return; final int END_STATE = (1 << N) - 1; Double[][] memo = new Double[N][1 << N]; - // Add all outgoing edges from the starting node to memo table. + // Phase 1a: Seed the memo table with direct edges from the start node. + // memo[end][{start, end}] = distance from start to end for (int end = 0; end < N; end++) { if (end == start) continue; memo[end][(1 << start) | (1 << end)] = distance[start][end]; } + // Phase 1b: Build solutions for subsets of increasing size (3..N). + // For each subset, try extending the path to each node in the subset. for (int r = 3; r <= N; r++) { for (int subset : combinations(r, N)) { if (notIn(start, subset)) continue; for (int next = 0; next < N; next++) { if (next == start || notIn(next, subset)) continue; + // Consider all possible previous endpoints int subsetWithoutNext = subset ^ (1 << next); double minDist = Double.POSITIVE_INFINITY; for (int end = 0; end < N; end++) { if (end == start || end == next || notIn(end, subset)) continue; double newDistance = memo[end][subsetWithoutNext] + distance[end][next]; - if (newDistance < minDist) { + if (newDistance < minDist) minDist = newDistance; - } } memo[next][subset] = minDist; } } } - // Connect tour back to starting node and minimize cost. + // Phase 2: Close the tour — find the cheapest way to return to start. for (int i = 0; i < N; i++) { if (i == start) continue; double tourCost = memo[i][END_STATE] + distance[i][start]; - if (tourCost < minTourCost) { + if (tourCost < minTourCost) minTourCost = tourCost; - } } + // Phase 3: Reconstruct the tour by backtracking through the memo table. int lastIndex = start; int state = END_STATE; tour.add(start); - // Reconstruct TSP path from memo table. for (int i = 1; i < N; i++) { - int bestIndex = -1; double bestDist = Double.POSITIVE_INFINITY; for (int j = 0; j < N; j++) { @@ -122,48 +155,56 @@ public void solve() { ranSolver = true; } + /** Returns true if the given element's bit is not set in the subset bitmask. */ private static boolean notIn(int elem, int subset) { return ((1 << elem) & subset) == 0; } - // This method generates all bit sets of size n where r bits - // are set to one. The result is returned as a list of integer masks. + /** + * Generates all bitmasks of n bits where exactly r bits are set. + * Used to enumerate subsets of a given size. + * + * @param r - number of bits to set + * @param n - total number of bits + * @return list of integer bitmasks + */ public static List combinations(int r, int n) { List subsets = new ArrayList<>(); combinations(0, 0, r, n, subsets); return subsets; } - // To find all the combinations of size r we need to recurse until we have - // selected r elements (aka r = 0), otherwise if r != 0 then we still need to select - // an element which is found after the position of our last selected element + /** + * Recursively builds combinations by deciding whether to include + * each bit position. Backtracks when not enough positions remain. + */ private static void combinations(int set, int at, int r, int n, List subsets) { - - // Return early if there are more elements left to select than what is available. + // Not enough positions remaining to pick r more bits int elementsLeftToPick = n - at; if (elementsLeftToPick < r) return; - // We selected 'r' elements so we found a valid subset! if (r == 0) { subsets.add(set); } else { for (int i = at; i < n; i++) { // Try including this element set ^= (1 << i); - combinations(set, i + 1, r - 1, n, subsets); - // Backtrack and try the instance where we did not include this element set ^= (1 << i); } } } + // ==================== Main ==================== + public static void main(String[] args) { - // Create adjacency matrix + // Create a 6-node directed graph with a known optimal tour int n = 6; double[][] distanceMatrix = new double[n][n]; - for (double[] row : distanceMatrix) java.util.Arrays.fill(row, 10000); + for (double[] row : distanceMatrix) + java.util.Arrays.fill(row, 10000); + distanceMatrix[5][0] = 10; distanceMatrix[1][5] = 12; distanceMatrix[4][1] = 2; @@ -175,10 +216,10 @@ public static void main(String[] args) { TspDynamicProgrammingIterative solver = new TspDynamicProgrammingIterative(startNode, distanceMatrix); - // Prints: [0, 3, 2, 4, 1, 5, 0] + // Tour: [0, 3, 2, 4, 1, 5, 0] System.out.println("Tour: " + solver.getTour()); - // Print: 42.0 + // Tour cost: 42.0 System.out.println("Tour cost: " + solver.getTourCost()); } }