j•jueansuyA LITTLE SPACE TO BUILD 回到实践笔记归档
PRACTICE NOTE / 007

最长公共子序列:
二维网格状态设计、栈溢出陷阱与回溯路径

这是动态规划中极其经典的双序列(2D Grid)模型(LeetCode 1143)。从一维子序列拓展到双字符串比对,核心在于理解前缀状态的无后效性;同时在 C 语言实现中避开局部大数组栈溢出、处理“前 i 个字符”与下标 i - 1 的对应关系,并利用填好的 DP 表反向回溯还原出真正的公共子序列。

01 / THE 2D DP MODEL

从一维到二维:两个字符串的前缀网格

给定两个字符串 text1 和 text2,返回这两个字符串的最长公共子序列(LCS)的长度。一个字符串的子序列是指这样一个新的字符串:它是由原字符串在不改变字符的相对顺序的情况下删除某些字符(也可以不删除任何字符)后组成的新字符串。

与单数组递增子序列(LIS)不同,此处涉及两个互不相同的序列推进。如果尝试穷举子序列,时间复杂度将达到指数级 $O(2^M \times 2^N)$,不可承受。解题的本质在于观察两个前缀子串的末尾字符关系:

二维状态转移核心:

定义 dp[i][j] 为 text1 的前 i 个字符与 text2 的前 j 个字符的最长公共子序列长度:

  • 若末尾字符相同(text1[i - 1] == text2[j - 1]):该字符必定可以成为公共子序列的新末尾,状态从左上角对角线无损转移并累加:dp[i][j] = dp[i - 1][j - 1] + 1;
  • 若末尾字符不同:两者不可能同时作为当前公共子序列的末尾,分别尝试丢弃其中一个末尾字符,从上方或左方取较大值:dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])。
02 / STEP THROUGH IT

二维表格步进,观察对角线与正交转移。

推演双重循环逐步填满二维网格的过程。当字符匹配时,观察高亮的左上角对角线跨越;当字符不同时,观察正交方向的极值继承。推演完成后,深紫色高亮展示反向回溯重构出的公共子序列路径。

二维网格 LCS 推演 · 步进演示 状态: dp[i][j] = match ? dp[i-1][j-1]+1 : max(dp[i-1][j], dp[i][j-1])
二维 DP 状态表格(行: text1 前缀,列: text2 前缀)
  • 当前计算单元格 (i, j)
  • 转移来源 (对角线或行列极值)
  • 最终 LCS 回溯路径
当前字符比对

—

当前 LCS 长度

0

推演步数

1 / 1

初始化状态:第 0 行与第 0 列空前缀全设为 0。

字符相同沿对角线推进并加 1;字符不同时继承上方或左方的更优历史。

03 / THREE CRITICAL DECISIONS

三个关键细节:内存栈溢出、下标偏移与回溯重构

  1. 01

    大二维数组为何必须加 static?

    LeetCode 1143 题目给定的字符串长度上限为 1000。在 C 语言中,若在函数内部声明 int dp[1005][1005];,该数组会占用约 $1000 \times 1000 \times 4 \approx 4\text{ MB}$ 的栈空间。而在许多系统或编译器默认环境(例如 Windows MSVC 默认栈仅 1MB,部分评测环境 2~8MB)中,此操作会直接导致栈溢出崩溃(Stack Overflow)。将数组声明为 static int dp[MAXN][MAXN]; 或放在全局数据段,能彻底消除栈溢出隐患。

  2. 02

    dp[i][j] 与 text[i - 1] 的“偏移 1”约定

    由于存在“空字符串”的基础边界,网格的第 0 行与第 0 列专门代表长度为 0 的前缀(此时 LCS 为 0)。因此,外层循环中的 i = 1..length1 表示考察“前 i 个字符”,而字符串在 C 语言中是 0-indexed 的,对应在数组中的访问下标恰好是 text1[i - 1]。切忌混淆 i 与 i - 1,否则会发生数组越界或少比对末尾字符。

  3. 03

    不仅求长度,如何重构具体字符串?

    求出 dp[m][n] 仅得到了长度。若要还原真正的 LCS 字符串,可以从右下角 (m, n) 出发倒序回溯:若 text1[i - 1] == text2[j - 1],则该字符属于 LCS,将其存入结果并移向 (i - 1, j - 1);否则比较 dp[i - 1][j] 与 dp[i][j - 1],沿着值更大的一侧后退。最终将收集到的字符逆序输出即可。

04 / THE C IMPLEMENTATION

真实 C 语言实现节选

选自 CSP 练习记录中已通过评测的源码。使用静态二维表,逻辑直观,评测表现优异。

033-lc1143-longest-common-subsequence / main.c 查看公开仓库 ↗
#include <stdio.h>
#include <string.h>

#define MAXN 1005

int longestCommonSubsequence(char *text1, char *text2) {
    // 关键细节 1:使用 static 避免 4MB 二维数组在函数调用栈中溢出
    static int dp[MAXN][MAXN];
    int length1 = strlen(text1);
    int length2 = strlen(text2);

    for (int i = 1; i <= length1; i++) {
        for (int j = 1; j <= length2; j++) {
            // 关键细节 2:前 i/j 个字符对应下标 i-1 与 j-1
            if (text1[i - 1] == text2[j - 1]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
            } else {
                if (dp[i - 1][j] > dp[i][j - 1]) {
                    dp[i][j] = dp[i - 1][j];
                } else {
                    dp[i][j] = dp[i][j - 1];
                }
            }
        }
    }
    return dp[length1][length2];
}
05 / COMPLEXITY & SPACE COMPRESSION

复杂度与滚动数组空间压缩

时间复杂度 O(M × N)

M 与 N 分别为两个字符串的长度。两重循环逐单元格填充,每个单元格只做常数次比较与赋值,总计算量完全确定。

空间复杂度与滚动数组 O(M × N) → O(min(M, N))

基础解法使用二维数组占 $O(M \times N)$ 空间。注意到计算第 i 行时仅依赖第 i - 1 行,因此可以使用大小为 2 行的滚动数组,甚至仅维护 1 行一维数组搭配一个临时变量记录左上角历史,即可将空间压缩至 $O(\min(M, N))$。