Skip to content

Commit 915ea5f

Browse files
williamfisetclaude
andauthored
Refactor LongestCommonSubsequence (williamfiset#1282)
* Refactor LongestCommonSubsequence: add recursive and space-optimized implementations, add tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove space-optimized lcsLength implementation and related tests Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove recursive implementation, consolidate back to single lcs() method Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Simplify LCS backtracking to standard approach Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add comment explaining LCS backtracking reconstruction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 3fc56ab commit 915ea5f

3 files changed

Lines changed: 166 additions & 44 deletions

File tree

Lines changed: 70 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,101 @@
1+
package com.williamfiset.algorithms.dp;
2+
13
/**
2-
* This file contains an implementation of finding the Longest Common Subsequence (LCS) between two
3-
* strings using dynamic programming.
4+
* Longest Common Subsequence (LCS)
5+
*
6+
* Given two strings A and B, find the longest subsequence present in both.
7+
* A subsequence is a sequence that appears in the same relative order but
8+
* not necessarily contiguously (unlike a substring).
9+
*
10+
* Builds an (n+1) x (m+1) DP table where dp[i][j] = length of the LCS of
11+
* A[0..i-1] and B[0..j-1], then backtracks to recover one LCS string.
12+
*
13+
* Tested against: https://leetcode.com/problems/longest-common-subsequence
414
*
5-
* <p>Time Complexity: O(nm)
15+
* Time: O(n*m)
16+
* Space: O(n*m)
617
*
718
* @author William Fiset, william.alexandre.fiset@gmail.com
819
*/
9-
package com.williamfiset.algorithms.dp;
10-
1120
public class LongestCommonSubsequence {
1221

13-
// Returns a non unique Longest Common Subsequence
14-
// between the strings str1 and str2 in O(nm)
15-
public static String lcs(char[] A, char[] B) {
22+
/**
23+
* Finds one Longest Common Subsequence between A and B.
24+
*
25+
* @param A - first string
26+
* @param B - second string
27+
* @return one LCS string, or null if either input is null
28+
*/
29+
public static String lcs(String A, String B) {
30+
if (A == null || B == null) return null;
31+
return lcs(A.toCharArray(), B.toCharArray());
32+
}
1633

34+
/**
35+
* Finds one Longest Common Subsequence between A and B using bottom-up DP.
36+
*
37+
* Builds a table dp[i][j] = length of LCS of A[0..i-1] and B[0..j-1],
38+
* then backtracks through the table to reconstruct the actual subsequence.
39+
*
40+
* @param A - first character array
41+
* @param B - second character array
42+
* @return one LCS string, or null if either input is null
43+
*
44+
* Time: O(n*m)
45+
* Space: O(n*m)
46+
*/
47+
public static String lcs(char[] A, char[] B) {
1748
if (A == null || B == null) return null;
1849

1950
final int n = A.length;
2051
final int m = B.length;
2152

22-
if (n == 0 || m == 0) return null;
53+
if (n == 0 || m == 0) return "";
2354

2455
int[][] dp = new int[n + 1][m + 1];
2556

26-
// Suppose A = a1a2..an-1an and B = b1b2..bn-1bn
57+
// Fill the DP table
2758
for (int i = 1; i <= n; i++) {
2859
for (int j = 1; j <= m; j++) {
29-
30-
// If ends match the LCS(a1a2..an-1an, b1b2..bn-1bn) = LCS(a1a2..an-1, b1b2..bn-1) + 1
31-
if (A[i - 1] == B[j - 1]) dp[i][j] = dp[i - 1][j - 1] + 1;
32-
33-
// If the ends do not match the LCS of a1a2..an-1an and b1b2..bn-1bn is
34-
// max( LCS(a1a2..an-1, b1b2..bn-1bn), LCS(a1a2..an-1an, b1b2..bn-1) )
35-
else dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
60+
// If characters match, extend the LCS from the diagonal
61+
if (A[i - 1] == B[j - 1])
62+
dp[i][j] = dp[i - 1][j - 1] + 1;
63+
// Otherwise take the best LCS excluding one character from either string
64+
else
65+
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
3666
}
3767
}
3868

39-
int lcsLen = dp[n][m];
40-
char[] lcs = new char[lcsLen];
41-
int index = 0;
42-
43-
// Backtrack to find a LCS. We search for the cells
44-
// where we included an element which are those with
45-
// dp[i][j] != dp[i-1][j] and dp[i][j] != dp[i][j-1])
69+
// Backtrack from dp[n][m] to reconstruct the LCS string.
70+
// At each cell, if the characters match, that character is part of
71+
// the LCS — take it and move diagonally. Otherwise, move toward
72+
// the neighbor with the larger value (up or left) to stay on the
73+
// path that produced the optimal length.
74+
StringBuilder sb = new StringBuilder();
4675
int i = n, j = m;
47-
while (i >= 1 && j >= 1) {
48-
49-
int v = dp[i][j];
5076

51-
// The order of these may output different LCSs
52-
while (i > 1 && dp[i - 1][j] == v) i--;
53-
while (j > 1 && dp[i][j - 1] == v) j--;
54-
55-
// Make sure there is a match before adding
56-
if (v > 0) lcs[lcsLen - index++ - 1] = A[i - 1]; // or B[j-1];
57-
58-
i--;
59-
j--;
77+
while (i > 0 && j > 0) {
78+
if (A[i - 1] == B[j - 1]) {
79+
sb.append(A[i - 1]);
80+
i--;
81+
j--;
82+
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
83+
i--;
84+
} else {
85+
j--;
86+
}
6087
}
6188

62-
return new String(lcs, 0, lcsLen);
89+
return sb.reverse().toString();
6390
}
6491

65-
public static void main(String[] args) {
92+
// ==================== Main ====================
6693

67-
char[] A = {'A', 'X', 'B', 'C', 'Y'};
68-
char[] B = {'Z', 'A', 'Y', 'W', 'B', 'C'};
69-
System.out.println(lcs(A, B)); // ABC
94+
public static void main(String[] args) {
95+
// LCS: ABC
96+
System.out.println("LCS: " + lcs("AXBCY", "ZAYWBC"));
7097

71-
A = new char[] {'3', '9', '8', '3', '9', '7', '9', '7', '0'};
72-
B = new char[] {'3', '3', '9', '9', '9', '1', '7', '2', '0', '6'};
73-
System.out.println(lcs(A, B)); // 339970
98+
// LCS: 339970
99+
System.out.println("LCS: " + lcs("398397970", "3399917206"));
74100
}
75101
}

src/test/java/com/williamfiset/algorithms/dp/BUILD

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,16 @@ java_test(
7272
deps = TEST_DEPS,
7373
)
7474

75+
# bazel test //src/test/java/com/williamfiset/algorithms/dp:LongestCommonSubsequenceTest
76+
java_test(
77+
name = "LongestCommonSubsequenceTest",
78+
srcs = ["LongestCommonSubsequenceTest.java"],
79+
main_class = "org.junit.platform.console.ConsoleLauncher",
80+
use_testrunner = False,
81+
args = ["--select-class=com.williamfiset.algorithms.dp.LongestCommonSubsequenceTest"],
82+
runtime_deps = JUNIT5_RUNTIME_DEPS,
83+
deps = TEST_DEPS,
84+
)
85+
7586
# Run all tests
7687
# bazel test //src/test/java/com/williamfiset/algorithms/dp:all
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package com.williamfiset.algorithms.dp;
2+
3+
import static com.google.common.truth.Truth.assertThat;
4+
5+
import org.junit.jupiter.api.Test;
6+
7+
public class LongestCommonSubsequenceTest {
8+
9+
@Test
10+
public void testNullInputs() {
11+
assertThat(LongestCommonSubsequence.lcs((String) null, "abc")).isNull();
12+
assertThat(LongestCommonSubsequence.lcs("abc", (String) null)).isNull();
13+
assertThat(LongestCommonSubsequence.lcs((char[]) null, "abc".toCharArray())).isNull();
14+
}
15+
16+
@Test
17+
public void testEmptyInputs() {
18+
assertThat(LongestCommonSubsequence.lcs("", "abc")).isEmpty();
19+
assertThat(LongestCommonSubsequence.lcs("abc", "")).isEmpty();
20+
assertThat(LongestCommonSubsequence.lcs("", "")).isEmpty();
21+
}
22+
23+
@Test
24+
public void testSingleCharMatch() {
25+
assertThat(LongestCommonSubsequence.lcs("X", "X")).isEqualTo("X");
26+
}
27+
28+
@Test
29+
public void testSingleCharNoMatch() {
30+
assertThat(LongestCommonSubsequence.lcs("X", "Y")).isEmpty();
31+
}
32+
33+
@Test
34+
public void testBasicLCS() {
35+
assertThat(LongestCommonSubsequence.lcs("AXBCY", "ZAYWBC")).isEqualTo("ABC");
36+
}
37+
38+
@Test
39+
public void testCharArrayOverload() {
40+
assertThat(LongestCommonSubsequence.lcs("AXBCY".toCharArray(), "ZAYWBC".toCharArray()))
41+
.isEqualTo("ABC");
42+
}
43+
44+
/** The LCS is not unique for this input; just verify the length. */
45+
@Test
46+
public void testNumericSequence() {
47+
assertThat(LongestCommonSubsequence.lcs("398397970", "3399917206").length()).isEqualTo(6);
48+
}
49+
50+
@Test
51+
public void testNoCommonSubsequence() {
52+
assertThat(LongestCommonSubsequence.lcs("ABC", "XYZ")).isEmpty();
53+
}
54+
55+
@Test
56+
public void testIdenticalStrings() {
57+
assertThat(LongestCommonSubsequence.lcs("ABCDE", "ABCDE")).isEqualTo("ABCDE");
58+
}
59+
60+
@Test
61+
public void testOneIsSubsequence() {
62+
assertThat(LongestCommonSubsequence.lcs("abcde", "ace")).isEqualTo("ace");
63+
}
64+
65+
@Test
66+
public void testPrefixMatch() {
67+
assertThat(LongestCommonSubsequence.lcs("ABCXYZ", "ABC")).isEqualTo("ABC");
68+
}
69+
70+
@Test
71+
public void testSuffixMatch() {
72+
assertThat(LongestCommonSubsequence.lcs("XYZABC", "ABC")).isEqualTo("ABC");
73+
}
74+
75+
@Test
76+
public void testRepeatedCharacters() {
77+
assertThat(LongestCommonSubsequence.lcs("AAAA", "AA")).isEqualTo("AA");
78+
}
79+
80+
@Test
81+
public void testInterleavedPattern() {
82+
// LCS of "ABAB" and "BABA" is length 3
83+
assertThat(LongestCommonSubsequence.lcs("ABAB", "BABA").length()).isEqualTo(3);
84+
}
85+
}

0 commit comments

Comments
 (0)