线性 DP

线性动态规划是动态规划中最基础、应用最广泛的一类问题,其核心特征是问题的状态空间具有明显的“线性”推导顺序,状态通常沿着数组的下标(一维或多维)从左到右、从前到后依次递推。

线性 DP 的核心特征

  1. 阶段明显:问题的求解过程可以划分为若干个阶段,阶段之间有严格的先后顺序(满足无后效性)。
  2. 状态依赖:当前阶段的状态 \(f_i\) 依赖于之前阶段的状态,如 \(f_{i-1}\),\(f_j \ (j \lt i)\)。
  3. 最优子结构:大问题的最优解可以由子问题的最优解组合而成。

单序列模型

通常以一维数组 \(A_{1 \dots n}\) 为基础,状态 \(f_i\) 往往定义为“以第 \(i\) 个元素结尾的最优解”或“考虑前 \(i\) 个元素的最优解”。

例题:P1115 最大子段和

除了前缀和、分治等做法以外,最大子段和问题也可以使用动态规划的思想来解决,这个解法被称为 Kadane's Algorithm。

算法的核心思想是:当从左到右遍历数组时,以当前元素 \(a_i\) 结尾的连续子段的最大和是多少?

定义 \(f_i\) 为以第 \(i\) 个元素 \(a_i\) 结尾的所有连续子段中,和最大的那个子段的和。

对于 \(f_i\),有两种选择来构成以 \(a_i\) 结尾的子段:

  1. 自成一段:这个子段只包含 \(a_i\) 这一个元素,此时子段和就是 \(a_i\)。
  2. 延续上一段:将 \(a_i\) 接在“以 \(a_{i-1}\) 结尾的最大子段”的后面,此时子段和为 \(f_{i-1}+a_i\)。

应该选择这两种情况中和最大的那一种,因此,状态转移方程为 \(f_i = \max (a_i, f_{i-1}+a_i)\)。

这个方程的直观理解是:如果以 \(a_{i-1}\) 结尾的最大子段和 \(f_{i-1}\) 是一个正数,那么 \(f_{i-1}+a_i\) 会比 \(a_i\) 大,选择延续;如果 \(f_{i-1}\) 是一个负数,它只会拖累总和,不如从 \(a_i\) 重新开始一个子段。

\(f_i\) 仅仅是以 \(i\) 为结尾的最大子段和,而整个序列的最大子段和可能在任何位置结尾。因此,需要的最终答案,是所有 \(f_i\) 值中的最大值。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;

const int N = 2e5 + 5;
int a[N];
// dp[i] 表示以第 i 个元素结尾的连续子段的最大和
int dp[N];

int main()
{
    int n; 
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
    }

    // 初始化 base case
    dp[1] = a[1];
    // ans 用于记录全局的最大子段和,需要初始化为 dp[1]
    int ans = dp[1];

    // 从第 2 个元素开始进行动态规划
    for (int i = 2; i <= n; i++) {
        // 状态转移方程
        // 以 a[i] 结尾的最大子段和,有两种可能:
        // 1. 子段仅包含 a[i] 本身。
        // 2. 子段是在“以 a[i-1] 结尾的最大子段”的基础上,加上 a[i]。
        // 在这两种可能中取较大值。
        dp[i] = max(dp[i - 1] + a[i], a[i]);

        // dp[i] 只是以 i 结尾的最大值,而全局最大值可能在任何位置结尾。
        // 因此,需要一个 ans 变量来不断更新和记录全局的最大值。
        ans = max(ans, dp[i]);
    }

    printf("%d\n", ans);
    return 0;
}

例题:B3637 最长上升子序列

设原始数组为 \(a\),定义状态 \(dp_i\) 表示以 \(a_i\) 结尾的上升子序列的最大长度。注意这个状态定义中有两个重点,第一个重点是 \(dp_i\) 只维护所有原始序列中以 \(a_i\) 结尾的上升子序列的信息。这样可以发现,对于每个上升子序列,都会唯一被归类到 \(dp\) 的某个状态中。第二个重点是对于所有以 \(a_i\) 结尾的上升子序列,只记录长度最长的那个子序列的长度。这是因为最优子结构性质,如果以 \(a_i\) 结尾有很多上升子序列,肯定是保留最长的那个更划算,因为它后面接数字之后能得到更长的上升子序列。而且这种方式能够满足无后效性,因为如果在所有以 \(a_i\) 结尾的上升子序列后面再接数字,能接哪个数字完全取决于 \(a_i\),跟 \(a_i\) 前面的数无关。所以这种状态定义方式同时满足无后效性和最优子结构。

考虑如何进行状态转移,也就是寻找一个递推关系,用之前计算过的某些 \(dp\) 值来计算 \(dp_i\)。考虑 \(dp_i\) 这个状态要以 \(a_i\) 结尾,只需要关心它能接到前面哪些子序列的后面。一种情况是,自成一段,则长度为 \(1\),那么 \(dp_i = 1\);另一种情况是,对于所有 \(i\) 前面的位置 \(j\),且满足 \(a_j < a_i\) 的,\(dp_i = dp_j + 1\),即在以 \(a_j\) 结尾的最长上升子序列的基础上,再增加一个自己带来的长度 \(1\)。为了使得 \(dp_i\) 的值最大,显然应该对于所有 \(j\),取 \(dp_j + 1\) 的最大值。即 \(dp_i = \max (dp_j + 1)\),其中要满足 \(j < i\) 并且 \(a_j < a_i\)。

最终的答案就是所有 \(dp_i\) 中的最大值,因为不能确定整个序列的最长上升子序列是以哪个数结尾的,所以每个数作为结尾都要考虑一遍。本算法的时间复杂度为 \(O(n^2)\):因为要枚举以第 \(i\) 个数结尾的情况去计算 \(dp_i\),因此需要枚举 \(n\) 次;而在计算每个 \(dp_i\) 时,又需要把 \(i\) 前面的每个位置 \(j\) 枚举一遍。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 5005;
int a[N], dp[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        dp[i] = 1;
        for (int j = 1; j < n; j++) {
            if (a[j] <  a[i]) dp[i] = max(dp[i], dp[j] + 1);
        }
        ans = max(ans, dp[i]);
    }
    printf("%d\n", ans);
    return 0;
}

还有一个时间复杂度更低的做法。用 \(dp_i\) 表示长度为 \(i\) 的上升子序列中最小的结尾。注意,这个 \(dp_i\) 的定义与前一种方式不同。如果有多个长度为 \(i\) 的上升子序列,记录所有这样的子序列中结尾最小的那个。这满足最优子结构,因为拥有最小结尾的上升子序列,更有可能被后面的数接上,形成更长的上升子序列。

在一开始,只考虑 \(a_1\),这时候有唯一的长度为 \(1\) 的上升子序列,它的结尾是 \(a_1\)。

假设数组 \(a\) 等于 \([1, 7, 3, 5, 9, 4, 8]\)。接下来,一个数一个数考虑,把数组 \(a\) 中每个数字考虑进来,分析 \(dp\) 数组的变化。下一个数是 \(a_2 = 7\),它可以接在前面的 \(1\) 的后面,形成长度为 \(2\) 的上升子序列,结尾是 \(7\)。因为之前没有过长度为 \(2\) 的上升子序列,所以直接在 \(dp_2\) 位置写入 \(7\)。

下一个数是 \(a_3 = 3\),目前长度为 \(1\) 的子序列是以 \(1\) 结尾的,长度为 \(2\) 的子序列最小结尾是 \(7\),那么新来的这个 \(3\) 肯定不能接在 \(7\) 后面,只能接在 \(1\) 后面,得到一个长度为 \(2\) 的上升子序列,结尾是 \(3\),比之前的 \(dp_2 = 7\) 要小,所以修改 \(dp_2 = 3\)。

下一个数是 \(a_4 = 5\),它可以接在长度为 \(2\) 结尾为 \(3\) 的子序列后面,得到长度为 \(3\),结尾为 \(5\) 的上升子序列。

下一个数是 \(a_5 = 9\),它可以接在长度为 \(3\) 结尾为 \(5\) 的子序列后面,得到长度为 \(4\),结尾为 \(9\) 的上升子序列。

到目前为止,大概可以总结出一个算法。一个接一个地考虑数组 \(a\) 中的每个数,对于当前的 \(a_i\),首先看它是否比 \(dp\) 中目前最后一个有效元素大,如果是,那么就可以接在最后面,相当于得到了一个更长的子序列,以 \(a_i\) 结尾;如果 \(a_i\) 不比 \(dp\) 最后一个有效元素大,那么就在 \(dp\) 中,从右往左找到最靠右边的、比 \(a_i\) 小的数,接到它的后面。相当于把 \(dp\) 中最靠左的第一个大于或等于 \(a_i\) 的数修改为 \(a_i\)。

例如,下一个考虑的数是 \(a_6 = 4\),就会将 \(dp_3\) 替换成 \(4\)。

同理,对于 \(a_7 = 8\),它会替换 \(dp_4\)。

image

最终,最长上升子序列的长度是 \(4\),并且最小以 \(8\) 结尾。

分析一下这个做法的时间复杂度,对于每个 \(a_i\),要么接在 \(dp\) 的末尾,要么遍历数组 \(dp\) 寻找最靠左的大于或等于 \(a_i\) 的数进行替换,最坏情况下时间复杂度是 \(O(n)\),总的时间复杂度是 \(O(n^2)\),看起来并没有变优。

实际上,可以发现 \(dp\) 是单调的,所以“遍历 \(dp\) 寻找最靠左的大于或等于 \(a_i\) 的数进行替换”这一操作,是不需要完整遍历的,可以在有序数组上进行二分查找,每次查找的时间复杂度变为 \(O(\log n)\),总的时间复杂度为 \(O(n \log n)\)。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
using std::lower_bound;
const int N = 5005;
int a[N], dp[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    int ans = 0; // 记录最长上升子序列的长度
    for (int i = 1; i <= n; i++) {
        // 在dp[1]~dp[ans]间进行二分查找
        int idx = lower_bound(dp + 1, dp + ans + 1, a[i]) - dp; 
        if (idx > ans) ans++; // 可以接在dp数组最后一个有效元素后面,长度加1
        dp[idx] = a[i]; // 将二分出的位置替换为a[i]
    }
    printf("%d\n", ans);
    return 0;
}

习题:P1091 [NOIP 2004 提高组] 合唱队形

给定 \(n \ (2 \le n \le 100)\) 位同学的身高序列 \(t_1, t_2, \dots, t_n \ (130 \le t_i \le 230)\),要求从序列中剔除最少数量的同学(即保留尽可能多的同学),使得剩下的同学排成一个合唱队形。合唱队形的定义为:设 \(k\) 位同学从左到右依次编号为 \(1, 2, \dots, k\),他们的身高满足 \(t_1 \lt \cdots \lt t_i \gt t_{i+1} \gt \cdots \gt t_k \ (1 \le i \le k)\)。要求计算最少出列的同学人数。

解题思路

在合唱队形中,必然存在一个“顶峰同学” \(i\),在 \(i\) 左侧(包含 \(i\) 本身)的同学构成一个严格单调递增子序列,在 \(i\) 右侧(包含 \(i\) 本身)的同学构成一个严格单调递减子序列。

可以枚举每一个可能的“顶峰”同学 \(i\),分别求出以 \(i\) 结尾的最长上升子序列长度 \(f_i\),以及以 \(i\) 开头的最长下降子序列长度 \(g_i\),那么以 \(i\) 位顶峰时能保留的最长合唱队形长度即为 \(f_i + g_i - 1\),因为 \(i\) 在左侧与右侧均被统计了一次,需扣除重复的 \(1\) 次。

最终最少需要出列的人数为 \(n - \max \limits_{1 \le i \le n} \{ f_i + g_i - 1 \}\)。

参考代码

习题:AT_dp_a Frog 1

解题思路

令 \(dp_i\) 表示在 \(h_i\) 时的最小代价,那么状态可以从 \(dp_{i-1}\) 和 \(dp_{i-2}\) 转移过来。

状态转移方程:\(dp_i = \min (dp_{i-1} + |h_i - h_{i-1}|, dp_{i-2} + |h_i - h_{i-2}|)\)。

初始化 \(dp_1 = 0, \ dp_2 = |h_1 - h_2|\)。

时间复杂度 \(O(n)\)。

参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
using std::min;
const int N = 100005;
int h[N], dp[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%d", &h[i]);
    dp[1] = 0; dp[2] = abs(h[1] - h[2]);
    for (int i = 3; i <= n; i++) {
        dp[i] = min(dp[i - 1] + abs(h[i] - h[i - 1]), dp[i - 2] + abs(h[i] - h[i - 2]));
    }
    printf("%d\n", dp[n]);
    return 0;
}

习题:AT_dp_b Frog 2

解题思路

和上一题非常类似。只是在跳跃时有了更多的选择,即 \(dp_i\) 的状态可以从 \(dp_{i-k}, \dots, dp_{i-1}\) 转移过来,只需用循环实现这个转移过程即可。时间复杂度 \(O(nk)\)。

参考代码
#include <cstdio>
#include <algorithm>
#include <cmath>
using std::min;
const int N = 100005;
const int INF = 1e9;
int h[N], dp[N];
int main()
{
    int n, k; scanf("%d%d", &n, &k);
    for (int i = 1; i <= n; i++) scanf("%d", &h[i]);
    for (int i = 2; i <= n; i++) {
        dp[i] = INF;
        for (int j = 1; j <= k; j++) {
            if (i - j <= 0) break;
            dp[i] = min(dp[i], dp[i - j] + abs(h[i] - h[i - j]));
        }
    }
    printf("%d\n", dp[n]);
    return 0;
}

例题:P2642 最大双子段和

这是一个最大子段和问题的变体,由于 \(N\) 的范围很大,需要一个高效的算法。

问题的核心约束是“两个子段”和“至少间隔一个数”,这引导我们去思考如何将原问题分解。一个自然的想法是枚举分割点,由于两个子段之间必须有间隔,可以枚举这个“间隔”的位置。

假设这个间隔位于第 \(i\) 个元素,那么第一个子段必须在 \(a_{1 \dots i-1}\) 的范围内选取,而第二个子段必须在 \(a_{i+1 \dots n}\) 的范围内选取。为了使总和最大,应该在 \(a_{1 \dots i-1}\) 中选取最大子段和,并在 \(a_{i+1 \dots n}\) 中也选取最大子段和。

因此,对于一个给定的间隔点 \(i\),候选答案是两边的最大子段和相加。

为了找到全局最优解,需要遍历所有可能的间隔点 \(i\)(从 \(2\) 到 \(n-1\)),并计算上述和,然后取所有结果中的最大值。

这个策略需要能够快速查询任何前缀 \([1 \dots k]\) 和任何后缀 \([k \dots n]\) 的最大子段和,这可以通过动态规划进行预处理,在线性时间内完成。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

typedef long long LL;
const int N = 1000005;

LL a[N];
// dp1[i]: 以 a[i] 结尾的最大子段和
// dp2[i]: 以 a[i] 为开头的最大子段和
LL dp1[N], dp2[N];
// pre[i]: 在区间 [1..i] 上的最大子段和
// suf[i]: 在区间 [i..n] 上的最大子段和
LL pre[N], suf[N];

int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%lld", &a[i]);

    // 1. 从左到右 DP,预处理出所有的前缀最大子段和 pre[i]
    dp1[1] = a[1];
    pre[1] = a[1];
    for (int i = 2; i <= n; i++) {
        // Kadane's 算法:以 a[i] 结尾的最大子段和
        dp1[i] = max(a[i], dp1[i - 1] + a[i]);
        // [1..i] 上的最大子段和,是 [1..i-1] 的最大值与以 a[i] 结尾的最大值中的较大者
        pre[i] = max(pre[i - 1], dp1[i]);
    }

    // 2. 从右到左 DP,预处理出所有的后缀最大子段和 suf[i]
    dp2[n] = a[n];
    suf[n] = a[n];
    for (int i = n - 1; i >= 1; i--) {
        // Kadane's 算法(反向):以 a[i] 为开头的最大子段和
        dp2[i] = max(a[i], dp2[i + 1] + a[i]);
        // [i..n] 上的最大子段和
        suf[i] = max(suf[i + 1], dp2[i]);
    }

    // 3. 枚举分割点,合并结果
    // 两个子段至少间隔一个数,可以枚举这个“间隔”的位置。
    // 假设间隔点为 i,则第一段在 [1..i-1] 中取,第二段在 [i+1..n] 中取。
    // 此时的最大和为 pre[i-1] + suf[i+1]。
    
    // 初始化答案,考虑 i=2 作为间隔点的情况
    // 注意 n 可能小于3,但题目数据保证了可以选出两段,所以 n 至少为3。
    // 样例2 (n=3) 的间隔点只能是2,ans = pre[1] + suf[3] = 83 + (-13) = 70
    LL ans = pre[1] + suf[3];

    // 遍历所有可能的间隔点 i (从 3 到 n-1)
    // i=2 的情况已经初始化,所以从 i=3 开始
    for (int i = 3; i < n; i++) {
        ans = max(ans, pre[i - 1] + suf[i + 1]);
    }

    printf("%lld\n", ans);
    return 0;
}

例题:P1020 [NOIP1999 提高组] 导弹拦截

分析:先考虑第 \(1\) 问,只有 \(1\) 套系统的话,最多可以拦截多少导弹。题目要求“每一发炮弹都不能高于前一发的高度”,其实就是找一个最长的子序列,满足子序列中后一个元素不能比前一个大,只能比前一个小或相等,可以称为最长不上升子序列。

题目第 \(2\) 问是需要多少套系统可以拦截所有的导弹,其实是问最少使用多少个不上升子序列可以覆盖整个区间。针对这类问题,有一个 Dilworth 定理。要求这样的子序列最少多少个,等价于求原序列的最长上升子序列的长度。

参考代码
#include <cstdio>
#include <algorithm>
using std::lower_bound;
using std::upper_bound;
const int N = 100005;
int a[N], dp[N];
int main()
{
    int n = 0, x;
    while (scanf("%d", &x) != -1) {
        a[++n] = x;
    }
    // 第1问
    // 求最长不上升子序列的长度,相当于倒过来求最长不下降子序列的长度
    int ans = 0;
    for (int i = n; i >= 1; i--) {
        // 注意:最长上升子序列是lower_bound,最长不下降子序列是upper_bound 
        int idx = upper_bound(dp + 1, dp + ans + 1, a[i]) - dp;
        if (idx > ans) ans++;
        dp[idx] = a[i];
    }
    printf("%d\n", ans);
    // 第2问
    // 等价于求最长上升子序列的长度
    ans = 0;
    for (int i = 1; i <= n; i++) {
        int idx = lower_bound(dp + 1, dp + ans + 1, a[i]) - dp;
        if (idx > ans) ans++;
        dp[idx] = a[i];
    }
    printf("%d\n", ans);
    return 0;
}

习题:P1052 [NOIP 2005 提高组] 过河

解题思路

如果 \(S=T\),那么青蛙每次跳跃的距离是固定的,它只能落在坐标为 \(k \times S \ (k \in \mathbb{N})\) 的点上。此时不需要 DP,直接遍历所有石子,判断石子坐标是否是 \(S\) 的倍数即可。如果是,则累加计数。

如果 \(S \lt T\),考虑 DP。这里的主要难点在于独木桥的长度 \(L\) 非常大,最大可达 \(10^9\),直接开一个 \(10^9\) 大小的数组来记录 DP 状态是不可能的,\(O(L)\) 的时间复杂度也会超时。

观察到虽然 \(L\) 很大,但石子的数量 \(M\) 非常少,且青蛙单次跳跃的距离 \(S,T\) 也很小。这意味着在很长的一段桥面上,中间可能根本没有石子。

当两个石子之间的距离非常远时,可以认为两者之间的“相对可达性”模式是循环的。可以通过路径压缩将过长的空白距离缩短,从而将 \(L\) 的范围缩小到可以接受的程度。

为了保证压缩后的距离对所有可能的步长都不改变可达性,可以选取 \(1\) 到 \(10\) 的最小公倍数 \(\text{LCM}(1,2,\dots,10) = 2520\) 作为模数。

对于相邻两颗石子,如果它们之间的距离 \(d\) 很大,可以将其压缩为 \(d' = (d \bmod 2520) + 2520\),这里加上 \(2520\) 是为了保留一段足够的缓冲距离,防止压缩过头导致状态计算错误。

经过压缩后,总的桥长 \(L'\) 大约为 \(M \times 2 \times 2520 \approx 5 \times 10^5\),这个范围完全可以进行线性 DP。

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;

// 常量定义
const int M = 105;      // 最大石子数量
const int MOD = 2520;   // 路径压缩模数,1到10的最小公倍数(LCM)
                        // 用于处理两个石子间距过大的情况,保持相对可达性不变
const int L = 550005;   // 压缩后的最大路径长度 (大约 M * 2 * MOD + T)
const int INF = 1e9;    // 无穷大初始值

int st[M], pos[M], dp[L]; // st: 原始石子位置, pos: 压缩后石子位置, dp: 动态规划数组
bool f[L];                // f[i]: 压缩后的位置 i 是否有石子

int main()
{
    int l, s, t, m;
    scanf("%d%d%d%d", &l, &s, &t, &m);
    for (int i = 0; i < m; i++) {
        scanf("%d", &st[i]);
    }

    // 特殊情况:S == T
    // 此时青蛙的跳跃距离固定,只能到达 k * S 的位置
    // 直接统计这些位置上有多少个石子即可
    if (s == t) {
        int cnt = 0;
        for (int i = 0; i < m; i++) {
            if (st[i] % s == 0) cnt++;
        }
        printf("%d\n", cnt);
    } else {
        // 一般情况:S < T,使用路径压缩 + 动态规划
        sort(st, st + m); // 对石子位置从小到大排序

        int last = 0; // 上一个位置的原始坐标(初始为起点0)
        int cur = 0;  // 上一个位置在压缩后的坐标(初始为起点0)

        // 对每两个相邻石子之间的距离进行压缩
        for (int i = 0; i < m; i++) {
            int dist = st[i] - last;
            // 如果距离大于 MOD,则压缩为 dist % MOD + MOD
            // 加上 MOD 是为了保证有足够的缓冲空间让不同步长的跳跃“展开”
            if (dist > MOD) dist = dist % MOD + MOD;
            
            cur += dist;       // 更新当前石子的压缩后坐标
            pos[i] = cur;      // 记录下来
            last = st[i];      // 更新 last 为当前石子的原始坐标
        }

        // 处理最后一个石子到终点 L 的距离
        int dist = l - last;
        if (dist > MOD) dist = dist % MOD + MOD;
        l = cur + dist; // 更新压缩后的桥长 L

        // 在压缩后的坐标轴上标记石子位置
        for (int i = 0; i < m; i++) {
            f[pos[i]] = true;
        }

        // 动态规划初始化
        dp[0] = 0;
        // 青蛙跳跃的目标范围:从终点 L 到 L + T - 1 (因为只要 >= L 就算过河)
        int limit = l + t - 1; 

        // DP 转移
        for (int i = 1; i <= limit; i++) {
            dp[i] = INF;
            // 尝试所有可能的跳跃步长
            for (int step = s; step <= t; step++) {
                if (i >= step) {
                    // 如果当前位置 i 有石子,则 cost 为 1,否则为 0
                    int cost = f[i] ? 1 : 0;
                    dp[i] = min(dp[i], dp[i - step] + cost);
                }
            }
        }

        // 寻找最优解
        // 答案是跳到 L 及 L 之后任意位置的最小花费
        int ans = INF;
        for (int i = l; i <= limit; i++) {
            ans = min(ans, dp[i]);
        }
        printf("%d\n", ans);
    }
    return 0;
}

习题:P4310 绝世好题

给定一个长度为 \(n \ (1 \le n \le 100000)\) 的正整数序列 \(a_1, a_2, \ldots, a_n \ (a_i \le 10^9)\),要求在保持原序列相对顺序的前提下,选出一个最长的子序列 \(b_1, b_2, \ldots, b_k\),使得子序列中任意相邻的两个元素按位与的结果均不为 \(0\),即 \(b_i \land b_{i-1} \neq 0 \quad (2 \le i \le k)\),求满足条件的最长子序列长度 \(k\)。

解题思路

容易想到类似于“最长上升子序列”的动态规划解法,定义 \(f_i\) 表示以第 \(i\) 个数 \(a_i\) 结尾的最长满足条件的子序列长度,状态转移方程为 \(f_i = \max \{ f_j \} + 1\),其中 \(j\) 需要满足 \(1 \le j \lt i\) 并且 \(a_j \land a_i \ne 0\)。在此状态下,需要使用两层循环遍历所有点对 \((j,i)\),总时间复杂度为 \(O(n^2)\),需要找到更快速确定“谁能与 \(a_i\) 产生按位与非零”的方法。

根据位运算的性质:\(a_i \land a_j \neq 0 \iff\) 存在至少一个二进制位 \(k\),使得 \(a_i\) 和 \(a_j\) 在第 \(k\) 位上均为 \(1\)。因为数字范围 \(a_i \lt 10^9 \lt 2^{30}\),所以数字的二进制展开最多只有 \(30\) 位(从第 \(0\) 位到第 \(29\) 位)。因此,不需要关心前面究竟是哪个具体数字接上了当前数字,而只需要关心在第 \(k\) 位二进制为 \(1\) 的所有数中,目前能够达到的最长子序列长度是多少,这就将遍历范围从之前的 \(O(n)\) 项压缩到了二进制位的 \(O(\log ( \max a_i ))\) 项。

参考代码
#include <iostream>
#include <algorithm>
using namespace std;
// f[i] 表示当前第 i 位二进制位为 1 的合法子序列的最大长度
// 10^9 < 2^30 因此二进制最多占据 0~29 共 30 位
int f[30];
int main()
{
    int n; cin >> n;
    int ans = 0; // 记录全局最长子序列长度
    while (n--) {
        int x; cin >> x;
        int best = 0; // 记录与当前 x 可以连接的上一个状态的最大长度
        // 第一轮遍历
        // 查询 x 对应为 1 的各个二进制位上能够接上的最长子序列
        for (int i = 0; i < 30; i++) {
            if ((x >> i) & 1) {
                best = max(best,f[i]);
            }
        }
        // 当前数接在最佳前缀后面,长度加 1
        best++;
        // 第二轮遍历
        // 用新构成的最长长度 best 更新 x 对应二进制位的 f 数组
        for (int i = 0; i < 31; i++) {
            if ((x >> i) & 1) {
                f[i] = max(f[i], best);
            }
        }
        // 更新全局最大答案
        ans = max(ans, best);
    }
    cout << ans << "\n";
    return 0;
}

习题:P2687 [USACO4.3] 逢低吸纳 Buy Low, Buy Lower

在给定的 \(N \ (1 \le N \le 5000)\) 天股价序列中,求出:

  1. 最长严格递减子序列的长度。
  2. 长度等于 LDS 长度的、价格序列不相同的方案总数。

两个方案不同当且仅当它们的价格序列不同。例如,序列 (68, 64, 62) 和另一个 (68, 64, 62) 即使在原序列中的位置不同,也被视为同一种方案。

解题思路

设 \(f_i\) 表示以第 \(i\) 个数结尾的最长递减子序列的长度,转移方程为 \(f_i = \max(\{f_j \mid 1 \le j \lt i, a_j \gt a_i\} \cup \{0\}) + 1\)。

设 \(c_i\) 为以第 \(i\) 个数结尾、长度为 \(f_i\) 的不同价格序列的方案数。

  1. 如果 \(f_i = 1\),则 \(c_i = 1\)。
  2. 如果 \(f_i \gt 1\),则 \(c_i = \sum_{1 \le j \lt i, \ a_j \gt a_i, \ f_j + 1 = f_i} c_j\)。

题目要求“价格序列不同”,如果存在 \(j \lt i\) 使得 \(a_j = a_i\) 且 \(f_j = f_i\),说明以第 \(j\) 个数结尾的所有长度为 \(f_j\) 的价格序列,在第 \(i\) 个数这里都会产生完全相同的副本。为了避免重复计算,在处理第 \(i\) 个数时,如果发现前面的第 \(j\) 个数与 \(a_i\) 相等且长度相同,就将 \(c_j\) 置为 0,这样可以保证每种价格序列只被统计一次。

由于方案数可能非常大,需要实现高精度加法。

参考代码
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
const int N = 5005;
struct BigInt {
    vector<int> d;
    BigInt() { d.push_back(0); }
    BigInt(int v) {
        if (v == 0) d.push_back(0);
        while (v > 0) {
            d.push_back(v % 10);
            v /= 10;
        }
    }
    void add(const BigInt& o) {
        vector<int> res;
        int carry = 0, n = max(d.size(), o.d.size());
        for (int i = 0; i < n || carry; i++) {
            int cur = carry;
            if (i < (int)d.size()) cur += d[i];
            if (i < (int)o.d.size()) cur += o.d[i];
            res.push_back(cur % 10);
            carry = cur / 10;
        }
        d = res;
    }
    void print() {
        for (int i = (int)d.size() - 1; i >= 0; i--) printf("%d", d[i]);
    }
};
int a[N], dp[N];
BigInt cnt[N];
int main()
{
    int n; scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        dp[i] = 1;
    }
    for (int i = 1; i <= n; i++) {
        // 计算 dp[i]
        for (int j = 1; j < i; j++)
            if (a[j] > a[i]) dp[i] = max(dp[i], dp[j] + 1);
        // 计算 cnt[i] 并去重
        BigInt sum(0);
        for (int j = 1; j < i; j++) {
            if (a[j] > a[i] && dp[j] + 1 == dp[i]) {
                sum.add(cnt[j]);
            } else if (a[j] == a[i] && dp[j] == dp[i]) {
                cnt[j] = 0; // 去重:相同的价格和长度,只保留最新的位置
            }
        }
        if (dp[i] == 1) cnt[i] = 1;
        else cnt[i] = sum;
    }
    int ans = 0; 
    for (int i = 1; i <= n; i++) ans = max(ans, dp[i]);
    printf("%d ", ans);
    BigInt sum(0);
    for (int i = 1; i <= n; i++) {
        if (dp[i] == ans) sum.add(cnt[i]);
    }
    sum.print();
    printf("\n");
    return 0;
}

习题:P8591 『JROI-8』颅脑损伤 2.0

给定 \(n \ (n \le 3000)\) 条闭区间线段 \([l_i, r_i] \ (-10^9 \le l_i,r_i \le 10^9)\),需要将每条线段染成红色或黑色,并满足以下两个条件:

  1. 红红不交:任意两条红色线段不能相交。
  2. 黑红相交:任意一条黑色线段必须至少与一条红色线段相交。

线段 \([l_i, r_i]\) 的长度定义为 \(r_i - l_i\),求所有红色线段长度之和的最小值。

解题思路

如果将线段按右端点 \(r\) 排序,若线段 \(j\) 与线段 \(i \ (j \lt i)\) 均为红色,则必须满足 \(r_j \lt l_i\)。

假设选定了一组红色线段,对于任意一条黑色线段 \(k\),必须至少存在一条红色线段 \(x\) 使得 \(l_k \le r_x\) 且 \(l_x \le r_k\)。

对线段按右端点升序排序(右端点相同则按左端点升序),设 \(f_i\) 表示选定第 \(i\) 条线段为红色线段,且前 \(i\) 条线段的染色方案均合法时,已选红色线段的最小长度和。

针对当前线段 \(i\) 作为最新的红色线段,从 \(j=i-1\) 开始递减。如果 \(r_j \ge l_i\),说明 \(j\) 与 \(i\) 相交,\(j\) 不能作为上一个红色线段,只能作为黑色线段,先跳过这些线段。

倒序寻找上一个合法红色线段 \(j\),在 \(r_j \lt l_i\) 的范围内继续向左遍历 \(j\),维护一个下界变量 \(b\),表示未被 \(i\) 覆盖的所有中间黑色线段的最大左端点。只要当前 \(r_j \ge b\),说明线段 \(j\) 能够覆盖这期间的所有黑色线段,\(j\) 就是一个合法的上一步转移状态。\(f_i\) 可以从所有这里涉及到的合法的 \(j\) 转移过来,即 \(f_i = \max \{ f_j \} + (r_i - l_i)\)。全局答案为所有满足 \(r_i \ge \max \{ l \}\) 的 \(f_i\) 的最小值。

image

参考代码
#include <iostream>
#include <utility>
#include <algorithm>
using namespace std;
using pi = pair<int, int>;
const int N = 3005;
const int INF = (int)2e9;
pi a[N]; // 左、右端点
int f[N]; // f[i]: 以第 i 条线段作为最后一个红色线段时的最小红线段总长度
int main()
{
    int n; cin >> n;
    int maxl = -INF; // 记录所有线段中的最大左端点
    for (int i = 1; i <= n; i++) {
        cin >> a[i].first >> a[i].second;
        maxl = max(maxl, a[i].first);
    } 
    // 按右端点 r 升序排序,右端点相同时按左端点 l 升序排序
    sort(a + 1, a + n + 1, [](pi x, pi y) {
        return x.second != y.second ? x.second < y.second : x.first < y.first;
    });
    // 虚拟边界节点
    a[0] = {-INF, -INF};
    int ans = INF;
    for (int i = 1; i <= n; i++) {
        int j = i - 1;
        // 跳过与线段 i 相交的线段
        while (j >= 0 && a[j].second >= a[i].first) j--;
        // 倒序寻找合法的上一个合法线段 j
        int bound = -INF; // 记录未与 i 相交的黑色线段的最大左端点
        int cur = INF; // 记录合法的 min(f[j])
        while (j >= 0 && a[j].second >= bound) {
            cur = min(cur, f[j]);
            bound = max(bound, a[j].first); // 维护中间黑色线段需要的最小 r[j] 门槛
            j--;
        }
        // 转移状态
        f[i] = cur + (a[i].second - a[i].first);
        // 若当前红色线段能覆盖最靠右的线段,则可作为全局终点更新答案
        if (a[i].second >= maxl) ans = min(ans, f[i]); 
    }
    cout << ans << "\n";
    return 0;
}

例题:P3365 改造二叉树

给定一棵包含 \(n \ (1 \le n \le 10^5)\) 个节点的二叉树,每个节点 \(i\) 上有一个初始整数权值 \(a_i \ (1 \le a_i \lt 2^{31})\)。可以修改若干个节点的权值,修改后的权值必须仍为整数,可以是负数或 \(0\)。要求以最少的修改次数,将这棵二叉树改造成一棵严格的二叉搜索树。二叉搜索树定义:对每个节点,其左子树中所有节点的权值严格小于当前节点,右子树中所有节点的权值严格大于当前节点。

一棵二叉树是严格的二叉搜索树,当且仅当对其进行中序遍历得到的节点权值序列是一个严格单调递增序列。

设对原二叉树进行中序遍历后得到的权值序列为 \(s_1, s_2, \dots, s_n\),由于节点的权值限定为整数,若 \(s_1, s_2, \dots, s_n\) 严格单调递增,则对于任意位置 \(i \lt j\),相邻节点的权值差至少为 \(1\),即 \(s_j - s_i \ge j - i\),移项整理可得 \(s_j - j \ge s_i - i\)。

令 \(c_i = s_i - i\),则原条件“序列 \(s\) 满足相邻元素至少相差 \(1\)”等价于“新序列 \(c_1, c_2, \dots, c_n\) 是一个非严格单调递增序列”,即 \(c_i \le c_{i+1}\)。

要使修改的节点数量最少,等价于让保持不修改的节点数量最大化。在转换后的序列 \(c\) 中,所有不需要修改的节点必须构成一个非严格单调递增子序列。因此,计算 \(c_i = s_i - i\),求出序列 \(c\) 的最长不下降子序列长度 \(L\),最少修改次数即为 \(n-L\)。

参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = (int)1e5 + 5;
int a[N]; // 节点原始数值
int l[N]; // 左孩子节点编号
int r[N]; // 右孩子节点编号
int idx; // 中序遍历结果序列的下标计数
int s[N]; // 中序遍历得到的权值序列
int f[N]; // DP 数组,f[k] 表示长度为 k 的最长不下降子序列的最小结尾值
// 中序遍历二叉树,提取节点权值
// u 当前访问的节点编号
void dfs(int u) {
    if (u == 0) return;
    dfs(l[u]); // 递归遍历左子树
    s[++idx] = a[u]; // 记录当前节点权值
    dfs(r[u]); // 递归遍历右子树
}
int main()
{
    int n; cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
    }
    // 读取 n - 1 条父子关系
    for (int i = 2; i <= n; i++) {
        int fa, ch; cin >> fa >> ch;
        if (ch == 0) l[fa] = i; // ch = 0 为左孩子
        else r[fa] = i; // ch = 1 为右孩子
    }
    // 从根节点 1 开始中序遍历
    dfs(1);
    int ans = 0; // ans 记录最长不下降子序列的长度
    // 求解 c[i] = s[i] - i 的最长不下降子序列
    for (int i = 1; i <= n; i++) {
        int c = s[i] - i;
        // 使用 upper_bound 查找 f 数组中第一个严格大于 c 的位置
        int idx = upper_bound(f + 1, f + ans + 1, c) - f;
        if (idx > ans) f[++ans] = c; // 可以延长最长不下降子序列
        else f[idx] = c; // 替换旧值,保持结尾值尽可能小
    }
    // 最少修改次数 = 总节点数 - 最多可保留的节点数
    cout << n - ans << "\n";
    return 0;
}

双序列模型

通常涉及两个序列 \(A_{1 \dots n}\) 和 \(B_{1 \dots m}\),状态 \(f_{i,j}\) 通常定义为“考虑 \(A\) 的前 \(i\) 项与 \(B\) 的前 \(j\) 项”。

例题:P2758 编辑距离

给定两个仅由小写英文字母组成的字符串 \(A\) 和 \(B \ (1 \le |A|, |B| \le 2000)\),允许使用以下三种字符操作:

  1. 删除一个字符;
  2. 插入一个字符;
  3. 将一个字符替换为另一个字符。

求将字符串 \(A\) 转换为字符串 \(B\) 所需要的最少操作次数。

要计算将字符串 \(A\) 转化为字符串 \(B\) 的最少步数,可以将其分解为规模更小的子问题:通过匹配 \(A\) 的前缀 \(A_{1 \dots i}\) 和 \(B\) 的前缀 \(B_{1 \dots j}\),推出全局最优解。

定义 \(f_{i,j}\) 表示将字符串 \(A\) 的前 \(i\) 个字符转换为字符串 \(B\) 的前 \(j\) 个字符所需的最少操作次数。对于当前匹配位置 \(A_i\) 和 \(B_j\):

  1. 若当前字符匹配,则当前字符不需要任何编辑操作,最少编辑次数直接继承自前缀 \(A_{1 \dots i-1}\) 转换为 \(B_{1 \dots j-1}\) 的步数,即 \(f_{i,j} = f_{i-1,j-1}\)。
  2. 若当前字符不匹配,可以通过三种操作之一完成转换,并取三种方案中的最小值加 \(1\)。删除操作:删除 \(A_i\),问题化简为将 \(A_{1 \dots i-1}\) 转换为 \(B_{1 \dots j}\),步数为 \(f_{i-1,j}+1\);插入操作:在 \(A\) 的末尾插入字符 \(B_j\),问题化简为将 \(A_{1 \dots i}\) 转换为 \(B_{1 \dots j-1}\),步数为 \(f_{i,j-1} + 1\);替换操作:将 \(A_i\) 替换为 \(B_j\),问题化简为将 \(A_{1 \dots i-1}\) 转换为 \(B_{1 \dots j-1}\),步数为 \(f_{i-1,j-1}+1\)。

边界条件为 \(f_{0,i} = i\) 以及 \(f_{i,0} = i\),这是因为当字符串 \(A\) 为空时,转换为相应长度的字符串 \(B\) 只能通过连续执行相应次插入操作;反过来,当字符串 \(B\) 为空时,把相应长度的字符串 \(A\) 转换为空串只能通过连续执行相应次删除操作。

参考代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 2005;
char a[N], b[N];
int f[N][N]; // f[i][j] 表示将 a[1..i] 转换为 b[1..j] 所需的最少编辑步数
int main()
{
    cin >> (a + 1) >> (b + 1);
    int n = strlen(a + 1), m = strlen(b + 1);
    // 初始化边界条件
    // a 为空串时,转换为 b[1..i] 需插入 i 个字符
    for (int i = 0; i <= m; i++) f[0][i] = i;
    // b 为空串时,a[1..i] 转换为空串需删除 i 个字符
    for (int i = 0; i <= n; i++) f[i][0] = i;
    // 动态规划状态转移
    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];
            } else {
                // 当前字符不同,尝试三种操作
                f[i][j] = min({f[i - 1][j], f[i][j - 1], f[i - 1][j - 1]}) + 1;
            }
        }
    }
    // 输出最少操作次数
    cout << f[n][m] << "\n";
    return 0;
}

例题:最长公共子序列

给出两个字符串,求最长的这样的子序列,要求满足子序列的每个字符都能在两个原字符串中找到,而且每个字符的先后顺序和原字符串中的先后顺序一致。
例如,两个字符串分别是 abcfbc 和 abfcab,它们的最长公共子序列长度是 \(4\),如 abfc。

设两个字符串分别为 \(s1\) 和 \(s2\),长度分别为 \(len1\) 和 \(len2\)。定义二维状态 \(dp_{i,j}\) 表示 \(s1\) 的前 \(i\) 个字符串形成的子串与 \(s2\) 的前 \(j\) 个字符形成的子串的最长公共子序列的长度。

这个状态定义,还是遵循最优子结构的思想。要解决的是两个比较长的字符串之间的问题,对两个字符串各自截取前若干个字符形成的子串,看看子串里面的答案能否计算出来。如果能,把子串延长一些,看看能否转移,最终计算出的 \(dp_{len1,len2}\) 就是想求的结果。

状态转移方程:\(dp_{i,j} = \begin{cases} dp_{i-1,j-1} + 1, & s1_i = s2_j \\ \max (dp_{i,j-1}, dp_{i-1,j}), & s1_i \ne s2_j \end{cases}\)

考虑两个子串的最后一位 \(s1_i\) 和 \(s2_j\),如果它们相等,那么就可以对答案贡献 \(1\) 的长度。\(s1\) 的前 \(i-1\) 个字符与 \(s2\) 的前 \(j-1\) 个字符能形成的最长公共子序列的长度,再接上新贡献的 \(1\),也就是 \(dp_{i-1,j-1} + 1\)。

若两个子串的最后一位 \(s1_i\) 和 \(s2_j\) 不想等,既然它们不能配对为答案做出贡献,不如丢弃其中的某一个。如丢弃 \(s2\) 的第 \(j\) 个字符,看 \(s1\) 的前 \(i\) 个字符与 \(s2\) 的前 \(j-1\) 个字符能够形成的答案是多少,再考虑 \(s1\) 的前 \(i-1\) 位和 \(s2\) 的前 \(j\) 位形成的答案是多少,比较这两个里面哪个更大,那么就构成当前的结果,也就是 \(\max (dp_{i,j-1}, dp_{i-1,j})\)。

考虑边界情况,容易发现 \(i=0\) 或 \(j=0\) 时是初始状态,显然这些结果都是 \(0\),因为此时至少有其中一个是空串,无法形成公共子序列。

总的时间复杂度是 \(O(n^2)\)。


选择题:最长公共子序列长度常常用来衡量两个序列的相似度。其定义如下:给定两个序列 \(X = x_1, x_2, x_3, \dots, x_m\) 和 \(Y = y_1, y_2, y_3, \dots, y_n\),最长公共子序列(LCS)问题的目标是找到一个最长的新序列 \(Z = z_1, z_2, z_3, \dots, z_k\),使得序列 \(Z\) 既是序列 \(X\) 的子序列,又是序列 \(Y\) 的子序列,且序列 \(Z\) 的长度 \(k\) 在满足上述条件的序列里是最大的。(注:序列 \(A\) 是序列 \(B\) 的子序列,当且仅当在保持序列 \(B\) 元素顺序的情况下,从序列 \(B\) 中删除若干元素,可以使得剩余的元素构成序列 \(A\)。)则序列 ABCAAAABA 和 ABABCBABA 的最长公共子序列长度为?

  • A. 4
  • B. 5
  • C. 6
  • D. 7
答案

C。最长公共子序列为 ABCABA。


习题:P1140 [ICPC 2001 Taejon R] 相似基因

给定两个仅由字符 ACGT 组成的基因序列 \(a\) 和 \(b\),长度分别为 \(n\) 和 \(m \ (1 \le n,m \le 100)\)。可以在序列中插入空碱基 -,使得两个序列对齐(但不允许将两边的空碱基 - 相互对齐)。对齐后,每个位置上的碱基对都会根据指定的得分矩阵产生一个相似度得分。要求通过合理插入空碱基 -,使得两个序列对齐后的总相似度得分最大,输出该最大值。

解题思路

求两序列在加入若干空碱基后的最大得分,可以划分为子问题:考虑 \(a\) 的前 \(i\) 个字符与 \(b\) 的前 \(j\) 个字符的最优对齐方式。

定义 \(f_{i,j}\) 表示序列 \(a\) 的前 \(i\) 个字符与序列 \(b\) 的前 \(j\) 个字符对齐后所能获得的最大相似度得分。

在确定 \(f_{i,j}\) 时,末尾字符的匹配仅有以下三种可能性,从三种决策中取能获得最大得分的方案:

  1. \(a_i\) 与 \(b_j\) 相互对齐,此时前缀 \(a_{1 \dots i-1}\) 与 \(b_{1 \dots j-1}\) 已对齐,得分增加 \(a_i\) 与 \(b_j\) 的匹配得分 \(S_{a_i, b_j}\),即 \(f_{i,j} = f_{i-1,j-1} + S_{a_i, b_j}\)。
  2. \(a_i\) 与空碱基 - 对齐:此时序列 \(b\) 的前 \(j\) 个字符已全部匹配完成,序列 \(a\) 的第 \(i\) 个字符只能与空碱基匹配,得分增加 \(S_{a_i, -}\),即 \(f_{i,j} = f_{i-1,j} + S_{a_i, -}\)。
  3. 空碱基 - 与 \(b_j\) 对齐:此时序列 \(a\) 的前 \(i\) 个字符已全部匹配完成,序列 \(b\) 的第 \(j\) 个字符只能与空碱基匹配,得分增加 \(S_{-, b_j}\),即 \(f_{i,j} = f_{i,j-1} + S_{-, b_j}\)。

边界初始化:\(f_{0,0} = 0\),因为两个空序列对齐的得分为 \(0\)。当序列 \(b\) 为空时,序列 \(a\) 的前 \(i\) 个字符只能全部与空碱基对齐,有 \(f_{i,0} = f_{i-1,0} + S_{a_i, -}\)。当序列 \(a\) 为空时,序列 \(b\) 的前 \(i\) 个字符只能全部与空碱基对齐,有 \(f_{0,i} = f_{0,i-1} + S_{-, b_i}\)。其余状态初始化为极小值 \(- \infty\),以防止无效状态影响计算。

参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = 105;
const int INF = (int)1e9;
// 碱基得分矩阵 S[5][5]
// 下标 0~4 分别代表 ACGT-
const int S[5][5] = {
    {5, -1, -2, -1, -3},
    {-1, 5, -3, -2, -4},
    {-2, -3, 5, -2, -2},
    {-1, -2, -2, 5, -1},
    {-3, -4, -2, -1, 0}
};
char a[N], b[N];
int f[N][N]; // f[i][j] 表示 a[1..i] 与 b[1..j] 对齐的最大相似度
// 将字符映射为得分矩阵的下标
int get(char c) {
    if (c == 'A') return 0;
    if (c == 'C') return 1;
    if (c == 'G') return 2;
    if (c == 'T') return 3;
    return 4;
}
int main()
{
    int n, m; cin >> n >> (a + 1) >> m >> (b + 1);
    // 初始化 DP 数组为负无穷
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            f[i][j] = -INF;
        }
    }
    f[0][0] = 0;
    // 初始化边界情况:序列 b 为空,a 全部与 - 对齐
    for (int i = 1; i <= n; i++) f[i][0] = f[i - 1][0] + S[get(a[i])][4];
    // 初始化边界情况:序列 a 为空,b 全部与 - 对齐
    for (int i = 1; i <= m; i++) f[0][i] = f[0][i - 1] + S[4][get(b[i])];
    // 动态规划状态转移
    for (int i = 1; i <= n; i++) {
        int x = get(a[i]);
        for (int j = 1; j <= m; j++) {
            int y = get(b[j]);
            // a[i] 与 b[j] 对齐
            f[i][j] = max(f[i][j], f[i - 1][j - 1] + S[x][y]);
            // a[i] 与 - 对齐
            f[i][j] = max(f[i][j], f[i - 1][j] + S[x][4]);
            // - 与 b[j] 对齐
            f[i][j] = max(f[i][j], f[i][j - 1] + S[4][y]);
        }
    }
    cout << f[n][m] << "\n";
    return 0;
}

习题:P1279 [CHCI 2002 National Competition #2 Seniors] 字串距离

给定两个仅由小写字母组成的字符串 \(A\) 和 \(B \ (1 \le |A|,|B| \le 2000)\),可以分别在 \(A\) 和 \(B\) 的首尾或中间插入任意数量的空格,使得扩展后的两个字符 \(A'\) 与 \(B'\) 长度相同。两个扩展字符串 \(A'\) 与 \(B'\) 在同一位置上的字符距离计算规则如下:

  1. 两个非空格字符 \(c_1, c_2\) 之间的距离为它们的 ASCII 码差值的绝对值 \(\vert{}c_1 - c_2\vert{}\)。
  2. 一个空格字符与一个非空格字符之间的距离为给定的常量 \(K \ (1 \le K \le 100)\)。
  3. 两个空格字符之间的距离为 \(0\)。

求在所有合法的扩展方式中,使得 \(A'\) 与 \(B'\) 各位置距离之和最小的数值。

解题思路

在最优解中,绝对不需要在扩展串 \(A'\) 和 \(B'\) 的同一位置上同时放置空格。

原因

若 \(A'\) 和 \(B'\) 在某个位置都是空格,该位置的距离贡献为 \(0\),删去这两个空格后,\(A'\) 与 \(B'\) 的长度依然相等,且其余位置的距离和完全没有发生改变,因此同时加空格对减少整体距离没有任何帮助。

有了这个性质,对于字符串 \(A\) 的第 \(i\) 个字符 \(A_i\) 和字符串 \(B\) 的第 \(j\) 个字符 \(B_j\),在对齐时仅由以下三种可能的决策:

  1. 字符配对:将 \(A_i\) 与 \(B_j\) 对齐,产生的距离增量为 \(\vert{}A_i - B_j\vert{}\)。
  2. \(A_i\) 与空格配对:在 \(B\) 中插入空格与 \(A_i\) 对齐(即消耗 \(A_i\),而 \(B_j\) 留到后续位置匹配),产生的距离增量为 \(K\)。
  3. 空格与 \(B_j\) 配对:在 \(A\) 中插入空格与 \(B_j\) 对齐(即消耗 \(B_j\),而 \(A_i\) 留到后续位置匹配),产生的距离增量为 \(K\)。
参考代码
#include <iostream>
#include <cstring>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 2005;
char a[N], b[N];
int f[2][N]; // 滚动数组,f[u][j] 表示当前状态下的最小距离
int main()
{
    int k; cin >> (a + 1) >> (b + 1) >> k;
    int n = strlen(a + 1), m = strlen(b + 1);
    // 边界条件初始化:f[0][i] 表示 A 长度为 0 时,B 的前 i 个字符与空格匹配的代价
    for (int i = 0; i <= m; i++) f[0][i] = i * k;
    for (int i = 1; i <= n; i++) {
        int u = i & 1; // 当前行的滚动索引
        int v = u ^ 1; // 上一行的滚动索引
        // 边界条件:f[i][0] 表示 B 长度为 0 时,A 的前 i 个字符与空格匹配的代价
        f[u][0] = i * k;
        for (int j = 1; j <= m; j++) {
            // A[i] 与 B[j] 匹配
            int match = f[v][j - 1] + abs(a[i] - b[j]);
            // A[i] 与空格匹配
            int ga = f[v][j] + k;
            // 空格与 B[j] 匹配
            int gb = f[u][j - 1] + k;
            // 取三种方案的最小值
            f[u][j] = min({match, ga, gb});
        }
    }
    // 输出最终答案:f[n][m] 的对应滚动结果
    cout << f[n & 1][m] << "\n";
    return 0;
}

习题:P2543 [AHOI2004] 奇怪的字符串

给定两个仅由字符 \(0\) 和字符 \(1\) 组成的字符串 \(X\) 和 \(Y \ (|X|,|Y| \le 10000)\),要求计算这两个字符串的最长公共子序列的长度,内存限制 \(125\text{ MB}\)。

解题思路

设字符串 \(X\) 的长度为 \(n\),字符串 \(Y\) 的长度为 \(m\),普通的二维 DP 需要维护一个大小为 \(n \times m\) 的表格,空间复杂度为 \(O(n \times m)\)。在本题中,\(n,m \le 10000\),若开辟 \(10000 \times 10000\) 的 int 数组,将占用约 \(400\text{ MB}\) 内存,大幅超过了题目规定的 \(125\text{ MB}\) 内存限制。

在状态表示中,\(f_{i,j}\) 表示字符串 \(X\) 的前 \(i\) 个字符与字符串 \(Y\) 的前 \(j\) 个字符的最长公共子序列长度。观察状态转移方程可知,计算 \(f_{i,j}\) 时,仅需要上一行(即 \(i-1\) 行)的状态 \(f_{i-1,j-1}\) 与 \(f_{i-1,j}\),以及当前行(即 \(i\) 行)左侧的状态 \(f_{i,j-1}\)。因此,可以采用滚动数组优化:只需要保留 \(2\) 行状态,从而将空间复杂度降至 \(O(m)\)。

参考代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 10005;
char x[N], y[N];
// 使用 2 行交替滚动数组存储 LCS 状态,将空间复杂度降至 O(m)
int f[2][N];
int main()
{
    cin >> (x + 1) >> (y + 1);
    int n = strlen(x + 1), m = strlen(y + 1);
    // u 表示当前行的索引,v 表示上一行的索引
    int u = 1, v = 0;
    // 动态规划计算 LCS
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (x[i] == y[j]) {
                // 当前字符相同,继承左上角状态并 +1
                f[u][j] = f[v][j - 1] + 1;
            } else {
                // 当前字符不同,取“忽略 x[i]”与“忽略 y[j]”的最大值
                f[u][j] = max(f[u][j - 1], f[v][j]);
            }
        }
        // 完成第 i 行计算后,交换当前行与上一行的指针
        swap(u, v);
    }
    // 最终答案存储在最后一次滚动后的上一行 v 中
    cout << f[v][m] << "\n";
    return 0;
}

习题:AT_dp_f LCS

解题思路

本题需要在求最长公共子序列时把这个序列找出来。一个直观的想法是:除了记录每个状态的最长公共子序列的长度,再配一个相应的数组记录每个状态对应的字符串。状态转移时,除了转移长度,也转移相应的字符串。由于涉及到大量的字符串复制,这个做法比较慢,并且要占用很大的空间。

另一个思路是,记录每个状态是转移自前面的哪个状态的,也就是记录每个状态的父亲状态。在状态转移方程中,可以看到,对于 \(dp_{i,j}\),它的值是从 \(dp_{i-1,j-1}, dp_{i-1,j}, dp_{i,j-1}\) 三个中的某一个转移过来的。所以对于每个状态,可以区分这三种转移来源。最后的结果是看 \(dp_{len1, len2}\),则根据该状态是三种转移中的哪一种倒推回去,直到边界条件。在这个过程中,每当发现某个 \(dp_{i,j}\) 的来源是 \(dp_{i-1,j-1}\) 时就说明最长公共子序列中包含 \(s_i\) 或 \(t_j\) 这个字符(因为此时两者相等,取哪个都一样),把这个过程中涉及到的字符连起来倒序输出即为答案(因为第一个连接到的字符实际上是整个最长公共子序列中的最后一个)。

参考代码
#include <cstdio>
#include <cstring>
const int N = 3005;
char s[N], t[N], ans[N];
int dp[N][N], from[N][N];
int main()
{
    scanf("%s%s", s + 1, t + 1);
    int lens = strlen(s + 1), lent = strlen(t + 1);
    for (int i = 1; i <= lens; i++) {
        for (int j = 1; j <= lent; j++) {
            if (s[i] == t[j]) {
                dp[i][j] = dp[i - 1][j - 1] + 1;
                from[i][j] = 0; 
            } else {
                if (dp[i - 1][j] > dp[i][j - 1]) {
                    dp[i][j] = dp[i - 1][j];
                    from[i][j] = 1;
                } else {
                    dp[i][j] = dp[i][j - 1];
                    from[i][j] = 2;
                }
            }
        }
    }
    int x = lens, y = lent;
    int n = 0;
    while (x > 0 && y > 0) {
        if (from[x][y] == 0) { // 转移来源标记等于0表示是一次公共字符
            ans[++n] = s[x];
            x--; y--;
        } else if (from[x][y] == 1) {
            x--;
        } else {
            y--;
        }
    }
    for (int i = n; i >= 1; i--) printf("%c", ans[i]);
    return 0;
}

例题:P1439 两个排列的最长公共子序列

给出 \(1, 2, \ldots, n \ (n \le 10^5)\) 的两个全排列 \(P_1\) 和 \(P_2\),要求计算它们的 LCS 的长度。

对于通用的两个序列求 LCS,通常采用动态规划解法:\(f_{i,j}\) 表示第一个序列的前 \(i\) 个元素与第二个序列的前 \(j\) 个元素的最长公共子序列长度。时间复杂度 \(O(n^2)\),空间复杂度经过滚动数组优化后可降至 \(O(n)\)。

而本题 \(n \le 10^5\),这个通用算法会超时,需要寻找效率更高的优化算法。

题目给出的关键特殊条件是两个序列均为 \(1 \sim n\) 的全排列,这意味着每个数字在两个序列中都有且仅出现一次。

由于 \(P_1\) 中的元素互不相同,可以把 \(P_1\) 中每个元素的出现位置(下标)作为一种全序参考标准:

  1. 建立一个映射数组 \(p\),记录 \(P_1\) 中数值 \(x\) 出现的位置,即 \(p_x\) 等于 \(x\) 在 \(P_1\) 中的下标。
  2. 将 \(P_2\) 中的每个数值 \(y\) 替换为其在 \(P_1\) 中对应的下标 \(p_y\),得到一个新的位置序列 \(A\)。

此时,在 \(P_1\) 中,按从左到右的顺序,对应的下标序列天然是严格单调递增的 \(1, 2, 3, \ldots, n\)。在序列 \(A\) 中选取一个公共子序列,要求这些元素在 \(P_1\) 中出现的相对顺序与在 \(P_2\) 中一致,这等价于要求这些元素在 \(P_1\) 中的下标也是严格单调递增的。

因此,求 \(P_1\) 和 \(P_2\) 的 LCS,等价于求序列 \(A\) 的 LIS。

以样例为例,\(P_1 = [3, 2, 1, 4, 5]\),\(P_2 = [1, 2, 3, 4, 5]\)。记录 \(P_1\) 中各数值的位置 \(p\),有 \(p_3 = 1, \ p_2 = 2, \ p_1 = 3, \ p_4 = 4, \ p_5 = 5\)。将 \(P_2\) 中的每个元素按 \(p\) 映射替换,得到新映射序列 \(A = [3, 2, 1, 4, 5]\)。对序列 \(A\) 求 LIS,一个合法的最长上升子序列为 \([1, 4, 5]\),长度为 \(3\),对应的数值为 \([3, 4, 5]\),正是 \(P_1\) 和 \(P_2\) 的 LCS。

而求 LIS 有 \(O(n \log n)\) 时间复杂度的算法。

参考代码
#include <iostream>
#include <algorithm>
using namespace std;
const int N = (int)1e5 + 5;
int p[N]; // p[x] 记录数值 x 在第一个排列中出现的位置
int f[N]; // f[k] 表示长度为 k 的 LIS 的最小末尾元素位置值
int main()
{
    int n; cin >> n;
    // 读入第一个排列,建立 数值 -> 位置 的映射
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        p[x] = i; // 数值 x 在 P1 中的位置是 i
    }
    int ans = 0; // ans 记录 LIS 的长度
    // 边读入第二个排列,边转换为位置值并求解 LIS
    for (int i = 1; i <= n; i++) {
        int x; cin >> x;
        // 在 f 数组中二分查找第一个大于等于 p[x] 的位置 idx
        int idx = lower_bound(f + 1, f + ans + 1, p[x]) - f;
        if (idx > ans) {
            // 说明 p[x] 比当前找到的所有上升子序列结尾都大,可以延长 LIS 长度
            f[++ans] = p[x];
        } else {
            // 否则用更小的 p[x] 替换旧结尾,维持末尾元素尽可能小(贪心策略)
            f[idx] = p[x];
        }
    }
    cout << ans << "\n";
    return 0;
}

划分型模型

给定一个长度为 \(N\) 的序列,需要将其切分为若干个满足某种特定性质的连续子段,状态转移通常需要考虑最后一个子段的左端点在哪里。

例题:P1018 [NOIP 2000 提高组] 乘积最大

给定一个长度为 \(n \ (6 \le n \le 40)\) 的数字串以及乘号个数 \(k \ (1 \le k \le 6)\),要求在数字串中插入 \(k\) 个乘号,将数字串划分为 \(k+1\) 个非空部分,使得这 \(k+1\) 个部分的数值乘积最大。

由于要将长度为 \(n\) 的数字串分割成 \(k+1\) 段,并求各段数值之积的最大值,可以通过维护“前 \(i\) 个字符分割为 \(t\) 段的最优解”来进行递推求解。

设 \(f_{i,t}\) 表示将原数字串的前 \(i\) 个字符划分为 \(t\) 个部分(即使用 \(t-1\) 个乘号)所能获得的最大乘积,其中 \(1 \le i \le n\),\(1 \le t \le \min(i,k+1)\)。

为了计算前 \(i\) 个字符划分为 \(t\) 段的最大乘积,可以枚举最后一段(即第 \(t\) 段)在原串中的起始位置 \(j\),其中 \(1 \le j \le i\):第 \(t\) 段对应的数值记为 \(\text{num}(j,i)\),前 \(j-1\) 个字符被划分为 \(t-1\) 个部分,对应的最大乘积为 \(f_{j-1,t-1}\)。因此,因为要枚举最新一段的起始位置,转移方程为 \(f_{i,t} = \max \limits_{1 \le j \le i} \{ f_{j-1,t-1} \times \text{num}(j,i) \}\)。

\(f_{0,0}=1\) 作为递推基底,处理 \(0\) 个字符划分为 \(0\) 段时的乘积因子为 \(1\),其余状态默认初始化为 \(0\)。最终答案即为 \(f_{n,k+1}\),表示将前 \(n\) 个字符划分为 \(k+1\) 个部分的最大乘积。

此外,划分出的各部分乘积数值会很大,因此需要引入高精度大整数来实现乘法与大小比较。

参考代码
#include <iostream>
#include <vector>
#include <string>
using namespace std;
const int N = 40;
const int K = 10;
// 自定义高精度大整数结构体
struct BigInt {
	vector<int> d; // 倒序存储各位数字,d[0] 为最低位
	BigInt() { d.push_back(0); }
	BigInt(int x) {
		while (x > 0) {
			d.push_back(x % 10);
			x /= 10;
		}
	} 
	void clear() { d.clear(); }
    // 在高位追加一位数字
	void append(int x) {
		d.push_back(x);
	}
    // 高精度乘法
	BigInt operator*(const BigInt &other) const {
		BigInt res;
		int n = d.size(), m = other.d.size();
		res.d.resize(n + m);
		for (int i = 0; i < n; i++) {
			for (int j = 0; j < m; j++) {
				res.d[i + j] += d[i] * other.d[j];
				if (res.d[i + j] >= 10) {
					res.d[i + j + 1] += res.d[i + j] / 10;
					res.d[i + j] %= 10;
				}
			}
		}
        // 去除前导零
		int idx = n + m - 1;
		while (idx > 0 && res.d[idx] == 0) {
			res.d.pop_back();
			idx--;
		}
		return res;
	}
    // 重载小于号用于 max 比较
	bool operator<(const BigInt &other) const {
		if (d.size() != other.d.size()) return d.size() < other.d.size();
		int n = d.size();
		for (int i = n - 1; i >= 0; i--) {
			if (d[i] != other.d[i]) return d[i] < other.d[i];
		}
		return false;
	}
    // 输出结果
	void print(string s) {
		int n = d.size();
		for (int i = n - 1; i >= 0; i--) cout << d[i];
		if (s.length() > 0) cout << s;
	}
};
char a[N];
BigInt f[N][K];
int main()
{
	int n, k; cin >> n >> k >> (a + 1);
    // 边界条件:0 个字符切分为 0 段的乘积基底为 1
	f[0][0] = 1;
    // 动态规划求解
	for (int i = 1; i <= n; i++) {
		BigInt last; last.clear();
        // 倒序枚举最后一段的起点 j
		for (int j = i; j >= 1; j--) {
            // 将字符 a[j] 追加到 last 中,逐步构造子串 a[j..i] 的高精度数值
			last.append(a[j] - '0');
            // 枚举划分段数 t
			for (int t = 1; t <= min(i, k + 1); t++) {
				f[i][t] = max(f[i][t], f[j - 1][t - 1] * last);
			}
		}
	}
    // 输出分成 k+1 段的最大乘积
	f[n][k + 1].print("\n");
	return 0;
}

习题:P2365 任务安排

有 \(n \ (1 \le n \le 5000)\) 个任务等待在同一台机器上按顺序完成(顺序不能改变),这 \(n\) 个任务需要划分为若干批次,每批包含连续的若干个任务。从时刻 \(0\) 开始加工,每个任务单独完成所需时间为 \(t_i\),费用系数为 \(f_i \ (1 \le t_i,f_i \le 100)\)。在每批任务开始加工前,机器需要额外的启动(准备)时间 \(s \ (0 \le s \le 50)\)。同一批次内的所有任务在同一时刻完成,该时刻等于前面所有批次的加工总时间加启动时间,加上当前批次所有任务的加工时间之和,每个任务产生的费用为该任务的完成时刻 \(\times\) 它的费用系数 \(f_i\)。请确定一个最优的分组方案,使得所有任务产生的总费用最小。

解题思路

若直接设计 DP 状态,通常需要记录两个维度:

  1. 当前已处理完前 \(i\) 个任务。
  2. 当前已经划分成了几批(因为每新增一批都会产生一个启动时间 \(s\),影响后续所有任务的完成时刻)。

若把批次数作为 DP 的一维状态,状态总数达到 \(O(n^2)\),因为要枚举上一个批次的最后一个任务的位置,所以转移需 \(O(n)\),总时间复杂度变为 \(O(n^3)\),无法承受。

为了消除“批数”这一维度,可以采用费用提前计算的思想:当考虑前 \(i\) 个任务,在第 \(j\) 个任务后建立一个新的批次时(即当前批次包含第 \(j+1 \dots i\) 个任务),启动时间 \(s\) 不仅会延迟当前批次的完成时间,还会将后续所有未完成任务的完成时间都推迟 \(s\)。因此,可以将这笔由启动时间 \(s\) 带来的额外费用,在决定新建批次的当下直接计算并计入总费用,其值为 \(s \times \sum\limits_{k=j+1}^n f_k\)。通过这种“将未来的影响提前结算”的手法,DP 状态就不再需要关注之前具体划分了多少批次。

参考代码
#include <iostream>
#include <algorithm>
using namespace std;
using ll = long long;
const int N = 5005;
const ll INF = (ll)1e18;
int t[N], f[N]; // t: 时间前缀和,f: 费用系数前缀和
ll g[N]; // g[i]: 前 i 个任务的最小总费用
int main()
{
    int n, s; cin >> n >> s;
    // 读取输入并预处理前缀和
    for (int i = 1; i <= n; i++) {
        cin >> t[i] >> f[i];
        t[i] += t[i - 1]; // 时间前缀和
        f[i] += f[i - 1]; // 费用前缀和
    }
    g[0] = 0; // 边界条件:前 0 个任务费用为 0
    // 动态规划求解
    for (int i = 1; i <= n; i++) {
        g[i] = INF; // 初始化当前状态为无穷大
        for (int j = 0; j < i; j++) {
            // 转移方程:费用提前计算技巧
            // 当前批次包含 j+1 到 i 任务,对第 j+1 到 n 所有任务造成的推迟费用
            ll cost = g[j] + (ll)(t[i] - t[j] + s) * (f[n] - f[j]);
            g[i] = min(g[i], cost);
        }
    }
    // 输出前 n 个任务的最小总费用
    cout << g[n] << "\n";
    return 0;
}

习题:P9753 [CSP-S 2023] 消消乐

解题思路(35 分)

对于一个固定的字符串,怎么判断它“可消除”?

可以采用类似括号匹配的方法:维护一个栈,按顺序遍历字符串,若当前字符等于栈顶,则将栈顶弹出,否则将当前字符入栈。如果最终栈为空则说明整个串是“可消除的”。

因此最直接的做法就是枚举所有的子串,对每个子串用一个栈来模拟这个过程,验证是否“可消除”。

时间复杂度为 \(O(n^3)\),期望得分 \(35\) 分。

参考代码
#include <cstdio>
#include <stack>
using std::stack;
using ll = long long;
const int N = 2000005;
char s[N];
int main()
{
    int n; scanf("%d", &n);
    scanf("%s", s + 1);
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = i; j <= n; j++) {
            // 子串i~j
            stack<char> stk;
            for (int k = i; k <= j; k++) {
                if (!stk.empty() && stk.top() == s[k]) stk.pop();
                else stk.push(s[k]);
            }
            if (stk.empty()) ans++;
        }
    }
    printf("%lld\n", ans);
    return 0;
}
解题思路(50 分)

在前面那个做法中可以发现,考虑对于子串 \([i,j]\) 和 \([i,j+1]\) 的验证过程,除了第 \(j+1\) 个字符以外,其余字符处理的逻辑是一样的,所以不需要对每个子串都重新开始维护一个栈。当枚举某个左端点时,维护一个栈,遍历这个左端点右侧的每个字符,每当处理完当前字符后看栈是否为空即可判断是否“可消除”。

时间复杂度为 \(O(n^2)\),期望得分 \(50\) 分。

参考代码
#include <cstdio>
#include <stack>
using std::stack;
using ll = long long;
const int N = 2000005;
char s[N];
int main()
{
    int n; scanf("%d", &n);
    scanf("%s", s + 1);
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        stack<char> stk; 
        for (int j = i; j <= n; j++) {
            // 子串i~j
            if (!stk.empty() && stk.top() == s[j]) stk.pop();
            else stk.push(s[j]);
            
            if (stk.empty()) ans++;
        }
    }
    printf("%lld\n", ans);
    return 0;
}
解题思路

分析数据范围,站在常见的线性 DP 问题视角思考这个问题。

设 \(dp_i\) 表示以第 \(i\) 个字符结尾的“可消除”子串数量。

那么对于每个 \(dp_i\),从哪个位置转移过来呢?考虑 \(i\) 左侧的某个位置 \(j\),如果可以转移过来,说明 \([j+1, i]\) 是一个“可消除的”子串。对于计数问题,要保证不重不漏,则 \(j\) 需要是最后一个可以满足 \([j+1,i]\) “可消除”的子串。

设 \(last_i\) 表示以第 \(i\) 个字符结尾的最短“可消除”字符串,则 \(dp_i = dp_{last_i - 1} + 1\)。

考虑如何计算 \(last_i\),首先,因为是要最短的“可消除”字符串,那么必然有 \(s_{last_i} = s_i\),也就是说 \([last_i + 1, i - 1]\) 是一个“可消除”字符串。因此可以持续迭代 \(last_i \leftarrow last_{last_i} - 1\),其中初始值是 \(i-1\),直到 \(s_{last_i} = s_i\) 或跳出字符串范围(即说明以 \(s_i\) 结尾不可消除)。

image

这个做法的时间复杂度是 \(An\),其中 \(A\) 是字符集大小,在本题中为 \(26\)。

如何证明这个时间复杂度?可以参考 暴力跳做法的复杂度证明,可以证明每一个位置最多被后面 \(A\) 个位置跳过来。

参考代码
#include <cstdio>
#include <stack>
using std::stack;
using ll = long long;
const int N = 2000005;
char s[N];
int dp[N], last[N];
int main()
{
    int n; scanf("%d%s", &n, s + 1);
    ll ans = 0;
    for (int i = 1; i <= n; i++) {
        int j = i - 1;
        while (j > 0 && s[j] != s[i]) {
            j = last[j] - 1;
        }
        if (j > 0) {
            dp[i] = dp[j - 1] + 1;
            last[i] = j;
        }
        ans += dp[i];
    }
    printf("%lld\n", ans);
    return 0;
}

状态机模型

序列中的每个位置不仅代表一个阶段,还伴随着若干个相互转换的“业务状态”。需要在状态中除了位置以外引入附加维度,进行状态转移。

例题:AT_dp_c Vacation

设 \(dp_{i,a}\) 表示到第 \(i\) 天为止,且第 \(i\) 天做事件 A 所获得的最大总幸福度,那么 \(dp_{i,a}\) 可以从 \(dp_{i-1,b}\) 和 \(dp_{i-1,c}\) 转移而来,\(dp_{i,a} = \max \{ dp_{i-1,b}, dp_{i-1,c} \} + a_i\)。事件 B 和 C 同理。

时间复杂度为 \(O(n)\)。

参考代码
#include <cstdio>
#include <algorithm>
using std::max;
const int N = 100005;
int dp[N][3], a[3];
int main()
{
    int n; scanf("%d", &n);
    int ans = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = 0; j < 3; j++) {
            scanf("%d", &a[j]);
            for (int k = 0; k < 3; k++) {
                if (k == j) continue;
                dp[i][j] = max(dp[i][j], dp[i - 1][k] + a[j]);
            }       
            ans = max(ans, dp[i][j]);
        }
    }
    printf("%d\n", ans);
    return 0;
}

例题:P2679 [NOIP 2015 提高组] 子串

给定两个仅由小写字母组成的字符串 \(A\) 和 \(B\),长度分别为 \(n \ (1 \le n \le 1000)\) 和 \(m \ (1 \le m \le 200)\)。现在需要从字符串 \(A\) 中取出 \(k \ (1 \le k \le m \le 200)\) 个互不重叠的非空子串,按照它们在 \(A\) 中出现的顺序依次拼接得到一个新的字符串。求有多少种不同的选法方案,使得拼接出的新字符串恰好与字符串 \(B\) 相同。注意:

  1. 两个方案只要选出的子串在 \(A\) 中的起始/终止位置有任意一处不同,即视为不同的方案。
  2. 最终答案需对 \(10^9+7\) 取模。

可以通过考虑字符串 \(A\) 的前 \(i\) 个字符和字符串 \(B\) 的前 \(j\) 个字符的匹配情况,结合当前已经划分为 \(x\) 个子串这一维度来进行状态定义。在划分子串时,对于 \(A_i\) 是否参与匹配 \(B_j\),以及 \(A_i\) 是延续上一个子串还是作为新子串的开头,会导致不同的状态转移。为了清晰界定,可以设计两个相互依赖的 DP 状态。

设 \(f_{i,j,x}\) 表示在字符串 \(A\) 的前 \(i\) 个字符中选出 \(x\) 个子串,恰好按顺序拼出字符串 \(B\) 的前 \(j\) 个字符,且 \(A_i\) 必须被选中(作为第 \(x\) 个子串的一部分,即结尾)的方案数。\(g_{i,j,x}\) 表示在字符串 \(A\) 的前 \(i\) 个字符中选出 \(x\) 个子串,恰好按顺序拼出字符串 \(B\) 的前 \(j\) 个字符,不论 \(A_i\) 是否被选中的方案数,由定义可得 \(g_{i,j,x} = g_{i-1,j,x} + f_{i,j,x}\)。

针对当前字符 \(A_i\) 与 \(B_j\) 的比较,分为以下两种情况:

  1. 若 \(A_i \ne B_j\),由于末尾字符不匹配,\(A_i\) 绝对不能参与匹配 \(B_j\),因此 \(f_{i,j,x} = 0\)。
  2. 若 \(A_i = B_j\),此时 \(A_i\) 可以用来匹配 \(B_j\),分为两种选择。如果延续旧子串,\(A_i\) 与 \(A_{i-1}\) 在同一个子串内(即 \(A_{i-1}\) 必须被选中且与 \(B_{j-1}\) 匹配),对应方案数为 \(f_{i-1,j-1,x}\)。如果开启新子串,\(A_i\) 作为第 \(x\) 个子串的第一个字符,此时前 \(x-1\) 个子串已经匹配了 \(B_{1 \dots j-1}\),无论 \(A_{i-1}\) 是否被选中均可,对应方案数为 \(g_{i-1,j-1,x-1}\)。因此,转移方程为两种选择方案数的叠加。

初始化:用 \(0\) 个子串拼出 \(B\) 的空前缀有 \(1\) 种合法方案(即什么都不选),所以 \(g_{i,0,0} = 1 \ (0 \le i \le n)\),其他所有 \(f\) 和 \(g\) 初始均置为 \(0\)。

注意到在计算第 \(i\) 层状态时,仅依赖于第 \(i-1\) 层的状态。因此,可以将第一维 \(n\) 使用滚动数组优化,这样可以将空间开销从 \(O(n \times m \times k)\) 降至 \(O(m \times k)\)。

参考代码
#include <iostream>
using namespace std;
const int N = 1005;
const int M = 205;
const int MOD = (int)1e9 + 7;
char a[N], b[N];
int f[2][M][M], g[2][M][M];
int main()
{
    int n, m, k; cin >> n >> m >> k >> (a + 1) >> (b + 1);
    // 边界初始化:匹配 B 的前 0 个字符且划分为 0 个子串的方案数为 1
    g[0][0][0] = 1;
    for (int i = 1; i <= n; i++) {
        int u = i & 1; // 当前行索引
        int v = (i - 1) & 1; // 上一行索引
        // 刷新边界条件:匹配 0 个字符的情况
        for (int t = 0; t <= k; t++) {
            f[u][0][t] = 0;
            g[u][0][t] = (t == 0);
        }
        for (int j = 1; j <= m; j++) {
            for (int t = 1; t <= k; t++) {
                if (a[i] == b[j]) {
                    // 若字符相同:可开启新串,也可延续上一个子串
                    f[u][j][t] = (g[v][j - 1][t - 1] + f[v][j - 1][t]) % MOD;
                } else {
                    // 若字符不同:A[i] 绝对不能被选中
                    f[u][j][t] = 0;
                }
                // 更新总方案数 g
                g[u][j][t] = (g[v][j][t] + f[u][j][t]) % MOD;
            }
        }
    }
    // 输出最终答案:使用 A[1..n] 匹配 B[1..m] 并分为 k 个子串的总方案数
    cout << g[n & 1][m][k];
    return 0;
}
posted @ 2024-10-23 21:51  RonChen  阅读(202)  评论(0)    收藏  举报