线性 DP

例题:AT_dp_a Frog 1

一个很简单的朴素 DP。令 \(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;
}

习题: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;
}

例题: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;
}

习题: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;
}

最长上升子序列问题是一个经典的线性动态规划问题。

例题: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;
}

例题: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;
}

例题:最长公共子序列

给出两个字符串,求最长的这样的子序列,要求满足子序列的每个字符都能在两个原字符串中找到,而且每个字符的先后顺序和原字符串中的先后顺序一致。
例如,两个字符串分别是 abcfbcabfcab,它们的最长公共子序列长度是 \(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\)。)则序列 ABCAAAABAABABCBABA 的最长公共子序列长度为?

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

C。最长公共子序列为 ABCABA


例题: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;
}

分组 DP

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

解题思路

注意到 \(n,m,k\) 的取值范围都不算大,再加上其要求统计方案数,所以不难想到可能是计数 DP。

时间复杂度可能为 \(O(nmk)\),正好可在 1s 内通过。

考虑状态设计。令 \(dp_{i,j,cnt,flag}\) 表示当前遍历到字符串 A 前 \(i\) 个字符时使用 \(cnt\) 个互不重叠的子串来匹配字符串 B 的前 \(j\) 个字符且 A 的第 \(i\) 个字符选或不选(\(flag=1\) 表示选)时的方案数。

当第 \(i\) 个字符不选时,可以直接由 \(i-1\) 时的方案转移过来,即 \(dp_{i,j,cnt,0} = dp_{i-1,j,cnt,0} + dp_{i-1,j,cnt,1}\)

当第 \(i\) 个字符选时,若 \(a_i = b_j\),说明可以使用第 \(i\) 项来拼成 B 串。此时可以新开一个子串,也可以加入到第 \(cnt-1\) 个子串中,即 \(dp_{i,j,cnt,1} = dp_{i-1,j-1,cnt,1} + dp_{i-1,j-1,cnt-1,0} + dp_{i-1,j-1,cnt-1,1}\);若 \(a_i \ne b_j\),方案数为 \(0\)

注意取模。

参考代码
#include <cstdio>
const int N = 1005;
const int M = 205;
const int MOD = 1000000007;
char a[N], b[M];
int dp[2][M][M][2];
int main()
{
    int n, m, k; scanf("%d%d%d%s%s", &n, &m, &k, a + 1, b + 1);
    dp[0][0][0][0] = dp[1][0][0][0] = 1;
    for (int i = 1; i <= n; i++) {
        int u = i & 1, v = u ^ 1;
        for (int j = 1; j <= m; j++) {
            for (int cnt = 1; cnt <= i; cnt++) {
                dp[u][j][cnt][0] = (dp[v][j][cnt][0] + dp[v][j][cnt][1]) % MOD;
                int &res = dp[u][j][cnt][1];
                if (a[i] == b[j]) {
                    res = dp[v][j - 1][cnt][1];
                    res += dp[v][j - 1][cnt - 1][0]; res %= MOD;
                    res += dp[v][j - 1][cnt - 1][1]; res %= MOD;
                } else res = 0;
            }
        }
    }
    int ans = (dp[n & 1][m][k][0] + dp[n & 1][m][k][1]) % MOD;
    printf("%d\n", ans);
    return 0;
}

习题:P2365 任务安排

解题思路

由于本题涉及一段连续任务的总时间和总费用系数,先处理出两个前缀和 \(sumt\)\(sumf\)

\(dp_i\) 表示前 \(i\) 个任务分成若干批的最小费用,考虑从上一次分批的位置 \(j\) 再加上最后一批转移到 \(i\)

这里有一个棘手的点,\(dp_j\) 这个状态分了多少批对后续的代价计算有影响,因为分批数目决定了后续任务的结束时间。而如果再加一维状态表示批数那么整体的计算时间复杂度会达到 \(O(n^3)\),无法通过本题。

换一个思路,把费用计算提前,当目前决定把 \(j+1\) 位置到 \(i\) 位置的任务打包成一批时,实际上相当于 \(j+1\) 位置及以后的每个元素都要多出 \(s\) 秒的额外启动时间带来的费用。

这样有 \(dp_i = \min \limits_{j=0}^{i-1} \{ dp_j + (sumf_i - sumf_j) \times sumt_i + s \times (sumf_n - sumf_j) \}\),时间复杂度就降到了 \(O(n^2)\)

参考代码
#include <cstdio>
#include <algorithm>   
using ll = long long;
using std::min;
const int N = 5005;
int t[N], f[N];
ll dp[N];
int main()
{
    int n, s; scanf("%d%d", &n, &s);
    for (int i = 1; i <= n; i++) {
        scanf("%d%d", &t[i], &f[i]);
        t[i] += t[i - 1]; f[i] += f[i - 1];
    }
    for (int i = 1; i <= n; i++) {
        dp[i] = 1ll * f[i] * t[i] + 1ll * s * f[n];
        for (int j = 1; j < i; j++) {
            dp[i] = min(dp[i], dp[j] + 1ll * t[i] * (f[i] - f[j]) + 1ll * s * (f[n] - f[j]));
        }
    }
    printf("%lld\n", dp[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;
}
posted @ 2024-10-23 21:51  RonChen  阅读(124)  评论(0)    收藏  举报