From e9e2994fe53ae2f7a925322f6d5a70d994e905f7 Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 21:55:24 -0700 Subject: [PATCH 1/5] Refactor LongestCommonSubsequence: add recursive and space-optimized implementations, add tests Co-Authored-By: Claude Opus 4.6 --- .../dp/LongestCommonSubsequence.java | 237 +++++++++++++++--- .../java/com/williamfiset/algorithms/dp/BUILD | 11 + .../dp/LongestCommonSubsequenceTest.java | 148 +++++++++++ 3 files changed, 364 insertions(+), 32 deletions(-) create mode 100644 src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java index 0477fab94..ba16192c8 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java @@ -1,59 +1,109 @@ +package com.williamfiset.algorithms.dp; + /** - * This file contains an implementation of finding the Longest Common Subsequence (LCS) between two - * strings using dynamic programming. + * Longest Common Subsequence (LCS) + * + * Given two strings A and B, find the longest subsequence present in both. + * A subsequence is a sequence that appears in the same relative order but + * not necessarily contiguously (unlike a substring). + * + * Three implementations are provided: + * 1. Iterative (bottom-up DP) — builds an (n+1) x (m+1) table, then + * backtracks to recover one LCS. See {@link #lcsIterative(char[], char[])}. + * 2. Recursive (top-down DP with memoization) — explores subproblems on + * demand and caches results. See {@link #lcsRecursive(char[], char[])}. + * 3. Space-optimized length-only — computes just the LCS length using + * O(min(n, m)) space. See {@link #lcsLength(char[], char[])}. * - *

Time Complexity: O(nm) + * Tested against: https://leetcode.com/problems/longest-common-subsequence + * + * Time: O(n*m) + * Space: O(n*m) * * @author William Fiset, william.alexandre.fiset@gmail.com */ -package com.williamfiset.algorithms.dp; - public class LongestCommonSubsequence { - // Returns a non unique Longest Common Subsequence - // between the strings str1 and str2 in O(nm) + /** + * Finds one Longest Common Subsequence between A and B. + * Defaults to the iterative implementation. + * + * @param A - first string + * @param B - second string + * @return one LCS string, or null if either input is null + */ + public static String lcs(String A, String B) { + if (A == null || B == null) return null; + return lcsIterative(A.toCharArray(), B.toCharArray()); + } + + /** + * Finds one Longest Common Subsequence between A and B. + * Defaults to the iterative implementation. + * + * @param A - first character array + * @param B - second character array + * @return one LCS string, or null if either input is null + */ public static String lcs(char[] A, char[] B) { + return lcsIterative(A, B); + } + // ==================== Implementation 1: Iterative (bottom-up) ==================== + + /** + * Finds one Longest Common Subsequence between A and B using bottom-up DP. + * + * Builds a table dp[i][j] = length of LCS of A[0..i-1] and B[0..j-1], + * then backtracks through the table to reconstruct the actual subsequence. + * + * @param A - first character array + * @param B - second character array + * @return one LCS string, or null if either input is null + * + * Time: O(n*m) + * Space: O(n*m) + */ + public static String lcsIterative(char[] A, char[] B) { if (A == null || B == null) return null; final int n = A.length; final int m = B.length; - if (n == 0 || m == 0) return null; + if (n == 0 || m == 0) return ""; int[][] dp = new int[n + 1][m + 1]; - // Suppose A = a1a2..an-1an and B = b1b2..bn-1bn + // Fill the DP table for (int i = 1; i <= n; i++) { for (int j = 1; j <= m; j++) { - - // If ends match the LCS(a1a2..an-1an, b1b2..bn-1bn) = LCS(a1a2..an-1, b1b2..bn-1) + 1 - if (A[i - 1] == B[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1; - - // If the ends do not match the LCS of a1a2..an-1an and b1b2..bn-1bn is - // max( LCS(a1a2..an-1, b1b2..bn-1bn), LCS(a1a2..an-1an, b1b2..bn-1) ) - else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + // If characters match, extend the LCS from the diagonal + if (A[i - 1] == B[j - 1]) + dp[i][j] = dp[i - 1][j - 1] + 1; + // Otherwise take the best LCS excluding one character from either string + else + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); } } + // Backtrack to reconstruct the LCS string int lcsLen = dp[n][m]; char[] lcs = new char[lcsLen]; int index = 0; - - // Backtrack to find a LCS. We search for the cells - // where we included an element which are those with - // dp[i][j] != dp[i-1][j] and dp[i][j] != dp[i][j-1]) int i = n, j = m; - while (i >= 1 && j >= 1) { + while (i >= 1 && j >= 1) { int v = dp[i][j]; - // The order of these may output different LCSs - while (i > 1 && dp[i - 1][j] == v) i--; - while (j > 1 && dp[i][j - 1] == v) j--; + // Walk up/left while the value doesn't change — these cells + // did not contribute a character to the LCS + while (i > 1 && dp[i - 1][j] == v) + i--; + while (j > 1 && dp[i][j - 1] == v) + j--; - // Make sure there is a match before adding - if (v > 0) lcs[lcsLen - index++ - 1] = A[i - 1]; // or B[j-1]; + if (v > 0) + lcs[lcsLen - index++ - 1] = A[i - 1]; i--; j--; @@ -62,14 +112,137 @@ public static String lcs(char[] A, char[] B) { return new String(lcs, 0, lcsLen); } + // ==================== Implementation 2: Recursive (top-down with memoization) ==================== + + /** + * Finds one Longest Common Subsequence between A and B using top-down DP + * with memoization. + * + * Recursively computes the LCS length, caching results in a memo table, + * then backtracks through the memo to reconstruct the subsequence. + * + * @param A - first character array + * @param B - second character array + * @return one LCS string, or null if either input is null + * + * Time: O(n*m) + * Space: O(n*m) + */ + public static String lcsRecursive(char[] A, char[] B) { + if (A == null || B == null) return null; + + final int n = A.length; + final int m = B.length; + + if (n == 0 || m == 0) return ""; + + // Use Integer[][] so we can distinguish "not computed" (null) from 0 + Integer[][] memo = new Integer[n][m]; + int lcsLen = lcsHelper(A, B, n - 1, m - 1, memo); + + // Backtrack through the memo table to reconstruct the LCS + char[] lcs = new char[lcsLen]; + int index = lcsLen - 1; + int i = n - 1, j = m - 1; + + while (i >= 0 && j >= 0) { + if (A[i] == B[j]) { + // This character is part of the LCS + lcs[index--] = A[i]; + i--; + j--; + } else if (i > 0 && memo[i - 1][j] != null && (j == 0 || memo[i - 1][j] >= getMemo(memo, i, j - 1))) { + // Moving up gives a longer or equal LCS + i--; + } else { + j--; + } + } + + return new String(lcs, 0, lcsLen); + } + + /** + * Recursively computes the LCS length of A[0..i] and B[0..j]. + */ + private static int lcsHelper(char[] A, char[] B, int i, int j, Integer[][] memo) { + if (i < 0 || j < 0) return 0; + if (memo[i][j] != null) return memo[i][j]; + + if (A[i] == B[j]) + memo[i][j] = 1 + lcsHelper(A, B, i - 1, j - 1, memo); + else + memo[i][j] = Math.max(lcsHelper(A, B, i - 1, j, memo), lcsHelper(A, B, i, j - 1, memo)); + + return memo[i][j]; + } + + /** Safe memo lookup that returns 0 for out-of-bounds indices. */ + private static int getMemo(Integer[][] memo, int i, int j) { + if (i < 0 || j < 0) return 0; + return memo[i][j] != null ? memo[i][j] : 0; + } + + // ==================== Implementation 3: Space-optimized LCS Length ==================== + + /** + * Computes the length of the Longest Common Subsequence between A and B. + * This implementation uses only O(min(n, m)) space by keeping a single + * row of the DP table at a time. + * + * Note: this only returns the length, not the actual subsequence. + * + * @param A - first character array + * @param B - second character array + * @return the length of the LCS, or 0 if either input is null or empty + * + * Time: O(n*m) + * Space: O(min(n, m)) + */ + public static int lcsLength(char[] A, char[] B) { + if (A == null || B == null || A.length == 0 || B.length == 0) return 0; + + // Ensure B is the shorter array to minimize space usage + if (A.length < B.length) { + char[] temp = A; + A = B; + B = temp; + } + + final int n = A.length; + final int m = B.length; + int[] dp = new int[m + 1]; + + for (int i = 1; i <= n; i++) { + int prev = 0; // equivalent to dp[i-1][j-1] from the 2D table + for (int j = 1; j <= m; j++) { + int temp = dp[j]; + if (A[i - 1] == B[j - 1]) + dp[j] = prev + 1; + else + dp[j] = Math.max(dp[j], dp[j - 1]); + prev = temp; + } + } + + return dp[m]; + } + + // ==================== Main ==================== + public static void main(String[] args) { + String s1 = "AXBCY"; + String s2 = "ZAYWBC"; + + // LCS: ABC + System.out.println("LCS: " + lcs(s1, s2)); + System.out.println("LCS Length: " + lcsLength(s1.toCharArray(), s2.toCharArray())); - char[] A = {'A', 'X', 'B', 'C', 'Y'}; - char[] B = {'Z', 'A', 'Y', 'W', 'B', 'C'}; - System.out.println(lcs(A, B)); // ABC + s1 = "398397970"; + s2 = "3399917206"; - A = new char[] {'3', '9', '8', '3', '9', '7', '9', '7', '0'}; - B = new char[] {'3', '3', '9', '9', '9', '1', '7', '2', '0', '6'}; - System.out.println(lcs(A, B)); // 339970 + // LCS: 339970 + System.out.println("LCS Iterative: " + lcsIterative(s1.toCharArray(), s2.toCharArray())); + System.out.println("LCS Recursive: " + lcsRecursive(s1.toCharArray(), s2.toCharArray())); } } diff --git a/src/test/java/com/williamfiset/algorithms/dp/BUILD b/src/test/java/com/williamfiset/algorithms/dp/BUILD index c93b6beee..8c6048b94 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/BUILD +++ b/src/test/java/com/williamfiset/algorithms/dp/BUILD @@ -72,5 +72,16 @@ java_test( deps = TEST_DEPS, ) +# bazel test //src/test/java/com/williamfiset/algorithms/dp:LongestCommonSubsequenceTest +java_test( + name = "LongestCommonSubsequenceTest", + srcs = ["LongestCommonSubsequenceTest.java"], + main_class = "org.junit.platform.console.ConsoleLauncher", + use_testrunner = False, + args = ["--select-class=com.williamfiset.algorithms.dp.LongestCommonSubsequenceTest"], + 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/LongestCommonSubsequenceTest.java b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java new file mode 100644 index 000000000..f04878ddb --- /dev/null +++ b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java @@ -0,0 +1,148 @@ +package com.williamfiset.algorithms.dp; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.jupiter.api.Test; + +public class LongestCommonSubsequenceTest { + + // ==================== Null and empty input tests ==================== + + @Test + public void testNullInputs() { + assertThat(LongestCommonSubsequence.lcs((String) null, "abc")).isNull(); + assertThat(LongestCommonSubsequence.lcs("abc", (String) null)).isNull(); + assertThat(LongestCommonSubsequence.lcs((char[]) null, "abc".toCharArray())).isNull(); + assertThat(LongestCommonSubsequence.lcsIterative(null, "abc".toCharArray())).isNull(); + assertThat(LongestCommonSubsequence.lcsRecursive(null, "abc".toCharArray())).isNull(); + assertThat(LongestCommonSubsequence.lcsLength(null, "abc".toCharArray())).isEqualTo(0); + } + + @Test + public void testEmptyInputs() { + assertThat(LongestCommonSubsequence.lcs("", "abc")).isEmpty(); + assertThat(LongestCommonSubsequence.lcs("abc", "")).isEmpty(); + assertThat(LongestCommonSubsequence.lcs("", "")).isEmpty(); + assertThat(LongestCommonSubsequence.lcsIterative("".toCharArray(), "abc".toCharArray())).isEmpty(); + assertThat(LongestCommonSubsequence.lcsRecursive("".toCharArray(), "abc".toCharArray())).isEmpty(); + assertThat(LongestCommonSubsequence.lcsLength("".toCharArray(), "abc".toCharArray())).isEqualTo(0); + } + + // ==================== Single character tests ==================== + + @Test + public void testSingleCharMatch() { + assertThat(LongestCommonSubsequence.lcs("X", "X")).isEqualTo("X"); + assertThat(LongestCommonSubsequence.lcsLength("X".toCharArray(), "X".toCharArray())).isEqualTo(1); + } + + @Test + public void testSingleCharNoMatch() { + assertThat(LongestCommonSubsequence.lcs("X", "Y")).isEmpty(); + assertThat(LongestCommonSubsequence.lcsLength("X".toCharArray(), "Y".toCharArray())).isEqualTo(0); + } + + // ==================== Standard cases ==================== + + @Test + public void testBasicLCS() { + assertThat(LongestCommonSubsequence.lcs("AXBCY", "ZAYWBC")).isEqualTo("ABC"); + } + + @Test + public void testLcsCharArrayOverload() { + assertThat(LongestCommonSubsequence.lcs("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) + .isEqualTo("ABC"); + } + + @Test + public void testLcsIterative() { + assertThat(LongestCommonSubsequence.lcsIterative("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) + .isEqualTo("ABC"); + } + + @Test + public void testLcsRecursive() { + assertThat(LongestCommonSubsequence.lcsRecursive("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) + .isEqualTo("ABC"); + } + + @Test + public void testNumericSequence() { + char[] A = "398397970".toCharArray(); + char[] B = "3399917206".toCharArray(); + // LCS length is 6, though the exact LCS may vary (not unique) + assertThat(LongestCommonSubsequence.lcsIterative(A, B).length()).isEqualTo(6); + assertThat(LongestCommonSubsequence.lcsRecursive(A, B).length()).isEqualTo(6); + assertThat(LongestCommonSubsequence.lcsLength(A, B)).isEqualTo(6); + } + + @Test + public void testNoCommonSubsequence() { + assertThat(LongestCommonSubsequence.lcs("ABC", "XYZ")).isEmpty(); + assertThat(LongestCommonSubsequence.lcsLength("ABC".toCharArray(), "XYZ".toCharArray())).isEqualTo(0); + } + + @Test + public void testIdenticalStrings() { + assertThat(LongestCommonSubsequence.lcs("ABCDE", "ABCDE")).isEqualTo("ABCDE"); + assertThat(LongestCommonSubsequence.lcsLength("ABCDE".toCharArray(), "ABCDE".toCharArray())).isEqualTo(5); + } + + /** One string is a subsequence of the other. */ + @Test + public void testOneIsSubsequence() { + assertThat(LongestCommonSubsequence.lcs("abcde", "ace")).isEqualTo("ace"); + } + + /** One string is a prefix of the other. */ + @Test + public void testPrefixMatch() { + assertThat(LongestCommonSubsequence.lcs("ABCXYZ", "ABC")).isEqualTo("ABC"); + } + + /** One string is a suffix of the other. */ + @Test + public void testSuffixMatch() { + assertThat(LongestCommonSubsequence.lcs("XYZABC", "ABC")).isEqualTo("ABC"); + } + + // ==================== Cross-validation: all implementations agree on length ==================== + + @Test + public void testAllImplementationsAgreeOnLength() { + String[][] pairs = { + {"AGGTAB", "GXTXAYB"}, + {"ABCBDAB", "BDCAB"}, + {"AAAA", "AA"}, + {"ABAB", "BABA"}, + }; + for (String[] pair : pairs) { + char[] A = pair[0].toCharArray(); + char[] B = pair[1].toCharArray(); + int lenOnly = LongestCommonSubsequence.lcsLength(A, B); + assertThat(LongestCommonSubsequence.lcsIterative(A, B).length()).isEqualTo(lenOnly); + assertThat(LongestCommonSubsequence.lcsRecursive(A, B).length()).isEqualTo(lenOnly); + } + } + + @Test + public void testLcsLengthSpaceOptimized() { + assertThat(LongestCommonSubsequence.lcsLength("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) + .isEqualTo(3); + assertThat(LongestCommonSubsequence.lcsLength("abc".toCharArray(), "def".toCharArray())) + .isEqualTo(0); + assertThat(LongestCommonSubsequence.lcsLength("AAAA".toCharArray(), "AA".toCharArray())) + .isEqualTo(2); + } + + /** Verifies lcsLength swaps arrays when A is shorter than B. */ + @Test + public void testLcsLengthSwapsForSpace() { + char[] shorter = "AB".toCharArray(); + char[] longer = "XAXBX".toCharArray(); + // Should work the same regardless of argument order + assertThat(LongestCommonSubsequence.lcsLength(shorter, longer)).isEqualTo(2); + assertThat(LongestCommonSubsequence.lcsLength(longer, shorter)).isEqualTo(2); + } +} From ce7e55fe60c49c06844fe2561c8b6693cd6071ef Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 22:05:27 -0700 Subject: [PATCH 2/5] Remove space-optimized lcsLength implementation and related tests Co-Authored-By: Claude Opus 4.6 --- .../dp/LongestCommonSubsequence.java | 50 +------------------ .../dp/LongestCommonSubsequenceTest.java | 37 ++------------ 2 files changed, 6 insertions(+), 81 deletions(-) diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java index ba16192c8..9a1799b4e 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java @@ -7,13 +7,11 @@ * A subsequence is a sequence that appears in the same relative order but * not necessarily contiguously (unlike a substring). * - * Three implementations are provided: + * Two implementations are provided: * 1. Iterative (bottom-up DP) — builds an (n+1) x (m+1) table, then * backtracks to recover one LCS. See {@link #lcsIterative(char[], char[])}. * 2. Recursive (top-down DP with memoization) — explores subproblems on * demand and caches results. See {@link #lcsRecursive(char[], char[])}. - * 3. Space-optimized length-only — computes just the LCS length using - * O(min(n, m)) space. See {@link #lcsLength(char[], char[])}. * * Tested against: https://leetcode.com/problems/longest-common-subsequence * @@ -183,51 +181,6 @@ private static int getMemo(Integer[][] memo, int i, int j) { return memo[i][j] != null ? memo[i][j] : 0; } - // ==================== Implementation 3: Space-optimized LCS Length ==================== - - /** - * Computes the length of the Longest Common Subsequence between A and B. - * This implementation uses only O(min(n, m)) space by keeping a single - * row of the DP table at a time. - * - * Note: this only returns the length, not the actual subsequence. - * - * @param A - first character array - * @param B - second character array - * @return the length of the LCS, or 0 if either input is null or empty - * - * Time: O(n*m) - * Space: O(min(n, m)) - */ - public static int lcsLength(char[] A, char[] B) { - if (A == null || B == null || A.length == 0 || B.length == 0) return 0; - - // Ensure B is the shorter array to minimize space usage - if (A.length < B.length) { - char[] temp = A; - A = B; - B = temp; - } - - final int n = A.length; - final int m = B.length; - int[] dp = new int[m + 1]; - - for (int i = 1; i <= n; i++) { - int prev = 0; // equivalent to dp[i-1][j-1] from the 2D table - for (int j = 1; j <= m; j++) { - int temp = dp[j]; - if (A[i - 1] == B[j - 1]) - dp[j] = prev + 1; - else - dp[j] = Math.max(dp[j], dp[j - 1]); - prev = temp; - } - } - - return dp[m]; - } - // ==================== Main ==================== public static void main(String[] args) { @@ -236,7 +189,6 @@ public static void main(String[] args) { // LCS: ABC System.out.println("LCS: " + lcs(s1, s2)); - System.out.println("LCS Length: " + lcsLength(s1.toCharArray(), s2.toCharArray())); s1 = "398397970"; s2 = "3399917206"; diff --git a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java index f04878ddb..7f933f3d2 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java +++ b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java @@ -15,7 +15,6 @@ public void testNullInputs() { assertThat(LongestCommonSubsequence.lcs((char[]) null, "abc".toCharArray())).isNull(); assertThat(LongestCommonSubsequence.lcsIterative(null, "abc".toCharArray())).isNull(); assertThat(LongestCommonSubsequence.lcsRecursive(null, "abc".toCharArray())).isNull(); - assertThat(LongestCommonSubsequence.lcsLength(null, "abc".toCharArray())).isEqualTo(0); } @Test @@ -25,7 +24,6 @@ public void testEmptyInputs() { assertThat(LongestCommonSubsequence.lcs("", "")).isEmpty(); assertThat(LongestCommonSubsequence.lcsIterative("".toCharArray(), "abc".toCharArray())).isEmpty(); assertThat(LongestCommonSubsequence.lcsRecursive("".toCharArray(), "abc".toCharArray())).isEmpty(); - assertThat(LongestCommonSubsequence.lcsLength("".toCharArray(), "abc".toCharArray())).isEqualTo(0); } // ==================== Single character tests ==================== @@ -33,13 +31,11 @@ public void testEmptyInputs() { @Test public void testSingleCharMatch() { assertThat(LongestCommonSubsequence.lcs("X", "X")).isEqualTo("X"); - assertThat(LongestCommonSubsequence.lcsLength("X".toCharArray(), "X".toCharArray())).isEqualTo(1); } @Test public void testSingleCharNoMatch() { assertThat(LongestCommonSubsequence.lcs("X", "Y")).isEmpty(); - assertThat(LongestCommonSubsequence.lcsLength("X".toCharArray(), "Y".toCharArray())).isEqualTo(0); } // ==================== Standard cases ==================== @@ -74,19 +70,16 @@ public void testNumericSequence() { // LCS length is 6, though the exact LCS may vary (not unique) assertThat(LongestCommonSubsequence.lcsIterative(A, B).length()).isEqualTo(6); assertThat(LongestCommonSubsequence.lcsRecursive(A, B).length()).isEqualTo(6); - assertThat(LongestCommonSubsequence.lcsLength(A, B)).isEqualTo(6); } @Test public void testNoCommonSubsequence() { assertThat(LongestCommonSubsequence.lcs("ABC", "XYZ")).isEmpty(); - assertThat(LongestCommonSubsequence.lcsLength("ABC".toCharArray(), "XYZ".toCharArray())).isEqualTo(0); } @Test public void testIdenticalStrings() { assertThat(LongestCommonSubsequence.lcs("ABCDE", "ABCDE")).isEqualTo("ABCDE"); - assertThat(LongestCommonSubsequence.lcsLength("ABCDE".toCharArray(), "ABCDE".toCharArray())).isEqualTo(5); } /** One string is a subsequence of the other. */ @@ -107,10 +100,10 @@ public void testSuffixMatch() { assertThat(LongestCommonSubsequence.lcs("XYZABC", "ABC")).isEqualTo("ABC"); } - // ==================== Cross-validation: all implementations agree on length ==================== + // ==================== Cross-validation: iterative vs recursive lengths agree ==================== @Test - public void testAllImplementationsAgreeOnLength() { + public void testIterativeAndRecursiveAgreeOnLength() { String[][] pairs = { {"AGGTAB", "GXTXAYB"}, {"ABCBDAB", "BDCAB"}, @@ -120,29 +113,9 @@ public void testAllImplementationsAgreeOnLength() { for (String[] pair : pairs) { char[] A = pair[0].toCharArray(); char[] B = pair[1].toCharArray(); - int lenOnly = LongestCommonSubsequence.lcsLength(A, B); - assertThat(LongestCommonSubsequence.lcsIterative(A, B).length()).isEqualTo(lenOnly); - assertThat(LongestCommonSubsequence.lcsRecursive(A, B).length()).isEqualTo(lenOnly); + int iterLen = LongestCommonSubsequence.lcsIterative(A, B).length(); + int recLen = LongestCommonSubsequence.lcsRecursive(A, B).length(); + assertThat(iterLen).isEqualTo(recLen); } } - - @Test - public void testLcsLengthSpaceOptimized() { - assertThat(LongestCommonSubsequence.lcsLength("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) - .isEqualTo(3); - assertThat(LongestCommonSubsequence.lcsLength("abc".toCharArray(), "def".toCharArray())) - .isEqualTo(0); - assertThat(LongestCommonSubsequence.lcsLength("AAAA".toCharArray(), "AA".toCharArray())) - .isEqualTo(2); - } - - /** Verifies lcsLength swaps arrays when A is shorter than B. */ - @Test - public void testLcsLengthSwapsForSpace() { - char[] shorter = "AB".toCharArray(); - char[] longer = "XAXBX".toCharArray(); - // Should work the same regardless of argument order - assertThat(LongestCommonSubsequence.lcsLength(shorter, longer)).isEqualTo(2); - assertThat(LongestCommonSubsequence.lcsLength(longer, shorter)).isEqualTo(2); - } } From fcb07e737a2f90de36e80e3163a79b16762c561f Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 22:06:44 -0700 Subject: [PATCH 3/5] Remove recursive implementation, consolidate back to single lcs() method Co-Authored-By: Claude Opus 4.6 --- .../dp/LongestCommonSubsequence.java | 108 +----------------- .../dp/LongestCommonSubsequenceTest.java | 55 ++------- 2 files changed, 15 insertions(+), 148 deletions(-) diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java index 9a1799b4e..f7b75c069 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java @@ -7,11 +7,8 @@ * A subsequence is a sequence that appears in the same relative order but * not necessarily contiguously (unlike a substring). * - * Two implementations are provided: - * 1. Iterative (bottom-up DP) — builds an (n+1) x (m+1) table, then - * backtracks to recover one LCS. See {@link #lcsIterative(char[], char[])}. - * 2. Recursive (top-down DP with memoization) — explores subproblems on - * demand and caches results. See {@link #lcsRecursive(char[], char[])}. + * Builds an (n+1) x (m+1) DP table where dp[i][j] = length of the LCS of + * A[0..i-1] and B[0..j-1], then backtracks to recover one LCS string. * * Tested against: https://leetcode.com/problems/longest-common-subsequence * @@ -24,7 +21,6 @@ public class LongestCommonSubsequence { /** * Finds one Longest Common Subsequence between A and B. - * Defaults to the iterative implementation. * * @param A - first string * @param B - second string @@ -32,23 +28,9 @@ public class LongestCommonSubsequence { */ public static String lcs(String A, String B) { if (A == null || B == null) return null; - return lcsIterative(A.toCharArray(), B.toCharArray()); + return lcs(A.toCharArray(), B.toCharArray()); } - /** - * Finds one Longest Common Subsequence between A and B. - * Defaults to the iterative implementation. - * - * @param A - first character array - * @param B - second character array - * @return one LCS string, or null if either input is null - */ - public static String lcs(char[] A, char[] B) { - return lcsIterative(A, B); - } - - // ==================== Implementation 1: Iterative (bottom-up) ==================== - /** * Finds one Longest Common Subsequence between A and B using bottom-up DP. * @@ -62,7 +44,7 @@ public static String lcs(char[] A, char[] B) { * Time: O(n*m) * Space: O(n*m) */ - public static String lcsIterative(char[] A, char[] B) { + public static String lcs(char[] A, char[] B) { if (A == null || B == null) return null; final int n = A.length; @@ -110,91 +92,13 @@ public static String lcsIterative(char[] A, char[] B) { return new String(lcs, 0, lcsLen); } - // ==================== Implementation 2: Recursive (top-down with memoization) ==================== - - /** - * Finds one Longest Common Subsequence between A and B using top-down DP - * with memoization. - * - * Recursively computes the LCS length, caching results in a memo table, - * then backtracks through the memo to reconstruct the subsequence. - * - * @param A - first character array - * @param B - second character array - * @return one LCS string, or null if either input is null - * - * Time: O(n*m) - * Space: O(n*m) - */ - public static String lcsRecursive(char[] A, char[] B) { - if (A == null || B == null) return null; - - final int n = A.length; - final int m = B.length; - - if (n == 0 || m == 0) return ""; - - // Use Integer[][] so we can distinguish "not computed" (null) from 0 - Integer[][] memo = new Integer[n][m]; - int lcsLen = lcsHelper(A, B, n - 1, m - 1, memo); - - // Backtrack through the memo table to reconstruct the LCS - char[] lcs = new char[lcsLen]; - int index = lcsLen - 1; - int i = n - 1, j = m - 1; - - while (i >= 0 && j >= 0) { - if (A[i] == B[j]) { - // This character is part of the LCS - lcs[index--] = A[i]; - i--; - j--; - } else if (i > 0 && memo[i - 1][j] != null && (j == 0 || memo[i - 1][j] >= getMemo(memo, i, j - 1))) { - // Moving up gives a longer or equal LCS - i--; - } else { - j--; - } - } - - return new String(lcs, 0, lcsLen); - } - - /** - * Recursively computes the LCS length of A[0..i] and B[0..j]. - */ - private static int lcsHelper(char[] A, char[] B, int i, int j, Integer[][] memo) { - if (i < 0 || j < 0) return 0; - if (memo[i][j] != null) return memo[i][j]; - - if (A[i] == B[j]) - memo[i][j] = 1 + lcsHelper(A, B, i - 1, j - 1, memo); - else - memo[i][j] = Math.max(lcsHelper(A, B, i - 1, j, memo), lcsHelper(A, B, i, j - 1, memo)); - - return memo[i][j]; - } - - /** Safe memo lookup that returns 0 for out-of-bounds indices. */ - private static int getMemo(Integer[][] memo, int i, int j) { - if (i < 0 || j < 0) return 0; - return memo[i][j] != null ? memo[i][j] : 0; - } - // ==================== Main ==================== public static void main(String[] args) { - String s1 = "AXBCY"; - String s2 = "ZAYWBC"; - // LCS: ABC - System.out.println("LCS: " + lcs(s1, s2)); - - s1 = "398397970"; - s2 = "3399917206"; + System.out.println("LCS: " + lcs("AXBCY", "ZAYWBC")); // LCS: 339970 - System.out.println("LCS Iterative: " + lcsIterative(s1.toCharArray(), s2.toCharArray())); - System.out.println("LCS Recursive: " + lcsRecursive(s1.toCharArray(), s2.toCharArray())); + System.out.println("LCS: " + lcs("398397970", "3399917206")); } } diff --git a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java index 7f933f3d2..dc7b4e40d 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java +++ b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java @@ -6,15 +6,11 @@ public class LongestCommonSubsequenceTest { - // ==================== Null and empty input tests ==================== - @Test public void testNullInputs() { assertThat(LongestCommonSubsequence.lcs((String) null, "abc")).isNull(); assertThat(LongestCommonSubsequence.lcs("abc", (String) null)).isNull(); assertThat(LongestCommonSubsequence.lcs((char[]) null, "abc".toCharArray())).isNull(); - assertThat(LongestCommonSubsequence.lcsIterative(null, "abc".toCharArray())).isNull(); - assertThat(LongestCommonSubsequence.lcsRecursive(null, "abc".toCharArray())).isNull(); } @Test @@ -22,12 +18,8 @@ public void testEmptyInputs() { assertThat(LongestCommonSubsequence.lcs("", "abc")).isEmpty(); assertThat(LongestCommonSubsequence.lcs("abc", "")).isEmpty(); assertThat(LongestCommonSubsequence.lcs("", "")).isEmpty(); - assertThat(LongestCommonSubsequence.lcsIterative("".toCharArray(), "abc".toCharArray())).isEmpty(); - assertThat(LongestCommonSubsequence.lcsRecursive("".toCharArray(), "abc".toCharArray())).isEmpty(); } - // ==================== Single character tests ==================== - @Test public void testSingleCharMatch() { assertThat(LongestCommonSubsequence.lcs("X", "X")).isEqualTo("X"); @@ -38,38 +30,20 @@ public void testSingleCharNoMatch() { assertThat(LongestCommonSubsequence.lcs("X", "Y")).isEmpty(); } - // ==================== Standard cases ==================== - @Test public void testBasicLCS() { assertThat(LongestCommonSubsequence.lcs("AXBCY", "ZAYWBC")).isEqualTo("ABC"); } @Test - public void testLcsCharArrayOverload() { + public void testCharArrayOverload() { assertThat(LongestCommonSubsequence.lcs("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) .isEqualTo("ABC"); } - @Test - public void testLcsIterative() { - assertThat(LongestCommonSubsequence.lcsIterative("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) - .isEqualTo("ABC"); - } - - @Test - public void testLcsRecursive() { - assertThat(LongestCommonSubsequence.lcsRecursive("AXBCY".toCharArray(), "ZAYWBC".toCharArray())) - .isEqualTo("ABC"); - } - @Test public void testNumericSequence() { - char[] A = "398397970".toCharArray(); - char[] B = "3399917206".toCharArray(); - // LCS length is 6, though the exact LCS may vary (not unique) - assertThat(LongestCommonSubsequence.lcsIterative(A, B).length()).isEqualTo(6); - assertThat(LongestCommonSubsequence.lcsRecursive(A, B).length()).isEqualTo(6); + assertThat(LongestCommonSubsequence.lcs("398397970", "3399917206")).isEqualTo("339970"); } @Test @@ -82,40 +56,29 @@ public void testIdenticalStrings() { assertThat(LongestCommonSubsequence.lcs("ABCDE", "ABCDE")).isEqualTo("ABCDE"); } - /** One string is a subsequence of the other. */ @Test public void testOneIsSubsequence() { assertThat(LongestCommonSubsequence.lcs("abcde", "ace")).isEqualTo("ace"); } - /** One string is a prefix of the other. */ @Test public void testPrefixMatch() { assertThat(LongestCommonSubsequence.lcs("ABCXYZ", "ABC")).isEqualTo("ABC"); } - /** One string is a suffix of the other. */ @Test public void testSuffixMatch() { assertThat(LongestCommonSubsequence.lcs("XYZABC", "ABC")).isEqualTo("ABC"); } - // ==================== Cross-validation: iterative vs recursive lengths agree ==================== + @Test + public void testRepeatedCharacters() { + assertThat(LongestCommonSubsequence.lcs("AAAA", "AA")).isEqualTo("AA"); + } @Test - public void testIterativeAndRecursiveAgreeOnLength() { - String[][] pairs = { - {"AGGTAB", "GXTXAYB"}, - {"ABCBDAB", "BDCAB"}, - {"AAAA", "AA"}, - {"ABAB", "BABA"}, - }; - for (String[] pair : pairs) { - char[] A = pair[0].toCharArray(); - char[] B = pair[1].toCharArray(); - int iterLen = LongestCommonSubsequence.lcsIterative(A, B).length(); - int recLen = LongestCommonSubsequence.lcsRecursive(A, B).length(); - assertThat(iterLen).isEqualTo(recLen); - } + public void testInterleavedPattern() { + // LCS of "ABAB" and "BABA" is length 3 + assertThat(LongestCommonSubsequence.lcs("ABAB", "BABA").length()).isEqualTo(3); } } From 64e872e2f36223ce5c65009cdf10a9a8d3fd4925 Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 22:09:31 -0700 Subject: [PATCH 4/5] Simplify LCS backtracking to standard approach Co-Authored-By: Claude Opus 4.6 --- .../dp/LongestCommonSubsequence.java | 27 +++++++------------ .../dp/LongestCommonSubsequenceTest.java | 3 ++- 2 files changed, 12 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java index f7b75c069..8d9d7683d 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java @@ -67,29 +67,22 @@ public static String lcs(char[] A, char[] B) { } // Backtrack to reconstruct the LCS string - int lcsLen = dp[n][m]; - char[] lcs = new char[lcsLen]; - int index = 0; + StringBuilder sb = new StringBuilder(); int i = n, j = m; - while (i >= 1 && j >= 1) { - int v = dp[i][j]; - - // Walk up/left while the value doesn't change — these cells - // did not contribute a character to the LCS - while (i > 1 && dp[i - 1][j] == v) + while (i > 0 && j > 0) { + if (A[i - 1] == B[j - 1]) { + sb.append(A[i - 1]); i--; - while (j > 1 && dp[i][j - 1] == v) j--; - - if (v > 0) - lcs[lcsLen - index++ - 1] = A[i - 1]; - - i--; - j--; + } else if (dp[i - 1][j] >= dp[i][j - 1]) { + i--; + } else { + j--; + } } - return new String(lcs, 0, lcsLen); + return sb.reverse().toString(); } // ==================== Main ==================== diff --git a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java index dc7b4e40d..57f060f23 100644 --- a/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java +++ b/src/test/java/com/williamfiset/algorithms/dp/LongestCommonSubsequenceTest.java @@ -41,9 +41,10 @@ public void testCharArrayOverload() { .isEqualTo("ABC"); } + /** The LCS is not unique for this input; just verify the length. */ @Test public void testNumericSequence() { - assertThat(LongestCommonSubsequence.lcs("398397970", "3399917206")).isEqualTo("339970"); + assertThat(LongestCommonSubsequence.lcs("398397970", "3399917206").length()).isEqualTo(6); } @Test From 0b3d8fe3138f5d4e126cbabd8bd9ce0eb297326b Mon Sep 17 00:00:00 2001 From: William Fiset Date: Tue, 10 Mar 2026 22:11:07 -0700 Subject: [PATCH 5/5] Add comment explaining LCS backtracking reconstruction Co-Authored-By: Claude Opus 4.6 --- .../algorithms/dp/LongestCommonSubsequence.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java index 8d9d7683d..72cd602e7 100644 --- a/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java +++ b/src/main/java/com/williamfiset/algorithms/dp/LongestCommonSubsequence.java @@ -66,7 +66,11 @@ public static String lcs(char[] A, char[] B) { } } - // Backtrack to reconstruct the LCS string + // Backtrack from dp[n][m] to reconstruct the LCS string. + // At each cell, if the characters match, that character is part of + // the LCS — take it and move diagonally. Otherwise, move toward + // the neighbor with the larger value (up or left) to stay on the + // path that produced the optimal length. StringBuilder sb = new StringBuilder(); int i = n, j = m;