LogoCSP Wiki By Yundou
动态规划

线性序列DP

最长公共子序列

最长公共子序列问题

给定一个长度为 nn 的序列 AA 和一个 长度为 mm 的序列 BBn,m5000n,m \leq 5000),求出一个最长的序列,使得该序列既是 AA 的子序列,也是 BB 的子序列。

子序列是什么?一个简要的例子:字符串 abcde 与字符串 acde 的公共子序列有 acdeacadaecdcedeadeacecdeacde,最长公共子序列的长度是 4。

图片描述
上为子序列,下为子串,二者不同

f(i,j)f(i,j) 表示只考虑 AA 的前 ii 个元素,BB 的前 jj 个元素时的最长公共子序列的长度,求这时的最长公共子序列的长度就是 子问题f(i,j)f(i,j) 就是我们所说的 状态,则 f(n,m)f(n,m) 是最终要达到的状态,即为所求结果。

对于每个 f(i,j)f(i,j),存在三种决策:如果 Ai=BjA_i=B_j,则可以将它接到公共子序列的末尾;另外两种决策分别是跳过 AiA_i 或者 BjB_j。状态转移方程如下:

f(i,j)={f(i1,j1)+1Ai=Bjmax(f(i1,j),f(i,j1))AiBjf(i,j)=\begin{cases}f(i-1,j-1)+1&A_i=B_j\\\max(f(i-1,j),f(i,j-1))&A_i\ne B_j\end{cases}

可参考 SourceForge 的 LCS 交互网页 来更好地理解 LCS 的实现过程。

该做法的时间复杂度为 O(nm)O(nm)

另外,本题存在 O(nmw)O\left(\dfrac{nm}{w}\right) 的算法。有兴趣的同学可以自行探索。

示例代码

int a[MAXN], b[MAXM], f[MAXN][MAXM];
 
int dp() {
  for (int i = 1; i <= n; i++)
    for (int j = 1; j <= m; j++)
      if (a[i] == b[j])
        f[i][j] = f[i - 1][j - 1] + 1;
      else
        f[i][j] = std::max(f[i - 1][j], f[i][j - 1]);
  return f[n][m];
}

最长不下降子序列

最长不下降子序列问题

给定一个长度为 nn 的序列 AAn5000n \leq 5000),求出一个最长的 AA 的子序列,满足该子序列的后一个元素不小于前一个元素。

算法一

f(i)f(i) 表示以 AiA_i 为结尾的最长不下降子序列的长度,则所求为 max1inf(i)\max_{1 \leq i \leq n} f(i)

计算 f(i)f(i) 时,尝试将 AiA_i 接到其他的最长不下降子序列后面,以更新答案。于是可以写出这样的状态转移方程:f(i)=max1j<i,AjAi(f(j)+1)f(i)=\max_{1 \leq j < i, A_j \leq A_i} (f(j)+1)

容易发现该算法的时间复杂度为 O(n2)O(n^2)

示例代码

int a[MAXN], d[MAXN];
 
int dp() {
  d[1] = 1;
  int ans = 1;
  for (int i = 2; i <= n; i++) {
    d[i] = 1;
    for (int j = 1; j < i; j++)
      if (a[j] <= a[i]) {
        d[i] = max(d[i], d[j] + 1);
        ans = max(ans, d[i]);
      }
  }
  return ans;
}

算法二

nn 的范围扩大到 n105n \leq 10^5 时,第一种做法就不够快了,下面给出了一个 O(nlogn)O(n \log n) 的做法。

回顾一下之前的状态:(i,l)(i, l)

但这次,我们不是要按照相同的 ii 处理状态,而是直接判断合法的 (i,l)(i, l)

再看一下之前的转移:(j,l1)(i,l)(j, l - 1) \rightarrow (i, l),就可以判断某个 (i,l)(i, l) 是否合法。

初始时 (1,1)(1, 1) 肯定合法。

那么,只需要找到一个 ll 最大的合法的 (i,l)(i, l),就可以得到最终最长不下降子序列的长度了。

那么,根据上面的方法,我们就需要维护一个可能的转移列表,并逐个处理转移。

所以可以定义 a1ana_1 \dots a_n 为原始序列,did_i 为所有的长度为 ii 的不下降子序列的末尾元素的最小值,lenlen 为子序列的长度。

初始化:d1=a1,len=1d_1=a_1,len=1

现在我们已知最长的不下降子序列长度为 1,那么我们让 ii 从 2 到 nn 循环,依次求出前 ii 个元素的最长不下降子序列的长度,循环的时候我们只需要维护好 dd 这个数组还有 lenlen 就可以了。关键在于如何维护。

考虑进来一个元素 aia_i

  1. 元素大于等于 dlend_{len},直接将该元素插入到 dd 序列的末尾。
  2. 元素小于 dlend_{len},找到 第一个 大于它的元素,用 aia_i 替换它。

为什么:

  • 对于步骤 1:

    由于我们是从前往后扫,所以说当元素大于等于 dlend_{len} 时一定会有一个不下降子序列使得这个不下降子序列的末项后面可以再接这个元素。如果 dd 不接这个元素,可以发现既不符合定义,又不是最优解。

  • 对于步骤 2:

    同步骤 1,如果插在 dd 的末尾,那么由于前面的元素大于要插入的元素,所以不符合 dd 的定义,因此必须先找到 第一个 大于它的元素,再用 aia_i 替换。

步骤 2 如果采用暴力查找,则时间复杂度仍然是 O(n2)O(n^2) 的。但是根据 dd 数组的定义,又由于本题要求不下降子序列,所以 dd 一定是 单调不减 的,因此可以用二分查找将时间复杂度降至 O(nlogn)O(n\log n).

图片描述

参考代码如下:

示例代码

for (int i = 0; i < n; ++i) scanf("%d", a + i);
memset(dp, 0x1f, sizeof dp);
mx = dp[0];
for (int i = 0; i < n; ++i) {
  *std::upper_bound(dp, dp + n, a[i]) = a[i];
}
ans = 0;
while (dp[ans] != mx) ++ans;

注意

对于最长上升子序列问题,类似地,可以令 did_i 表示所有长度为 ii 的最长上升子序列的末尾元素的最小值。

需要注意的是,在步骤 2 中,若 aidlena_i \leq d_{len},由于最长上升子序列中相邻元素不能相等,需要在 dd 序列中找到 第一个 不小于 aia_i 的元素,用 aia_i 替换之。

在实现上(以 C++ 为例),需要将 upper_bound 函数改为 lower_bound

例题

On this page