区间 DP、环形 DP

区间 DP

区间 DP 是可以由小区间的结果往两边扩展一位得到大区间的结果,或者由两个小区间的结果可以拼出大区间的结果的一类 DP 问题

往往设 \(dp[i][j]\) 表示处理完 \([i,j]\) 区间得到的答案,按长度从小到大转移

因此一般是先写一层循环从小到大枚举长度 \(len\),再写一层循环枚举左端点 \(i\),算出右端点 \(j\),然后写 \(dp[i][j]\) 的状态转移方程

区间 DP 也可以由大区间推到小区间,此时可以从大到小枚举 \(len\)

区间 DP 的提示信息:

  1. 从两端取出或在两端插入,这就是大区间变到小区间或者小区间变到大区间
  2. 合并相邻的,这样的一步相当于把已经处理好的两个小区间得到的结果合并为当前大区间的结果
  3. 消去连续一段使两边接起来,可以枚举最后一次消哪个区间,这样就可以把大区间拆成小区间
  4. 两个东西可以配对消掉,这时往往可以按左端点和哪个东西配对,把当前区间拆成两个子区间的问题
  5. 时间复杂度通常为 \(O(n^2)\)\(O(n^3)\)

例题:P2858 [USACO06FEB] Treats for the Cows G/S

解题思路

考虑操作过程,以第一步为例,你会把 \([1,n]\) 通过拿走最左边一个或最右边一个变为 \([2,n]\)\([1,n-1]\),这就是区间的变化

我们可以考虑最后一次拿零食,此时一定是只剩一件零食了,这就是长度为 \(1\) 的区间,由于它一定是最后一天出售,此时它的售价为 \(n*v[i]\)

由此我们设计 \(dp[i][j]\) 表示卖光 \([i,j]\) 区间内的零食的最大售价

那么状态转移方程就是 \(dp[i][j] = \max (dp[i+1][j] + v[i] * (n-(j-i)), dp[i][j-1]+v[j]*(n-(j-i)))\)

初始化 \(dp[i][i]=v[i]*n\),从小区间往大区间推,最后答案为 \(dp[1][n]\)

时间复杂度 \(O(n^2)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 2005;
int v[N], dp[N][N];
int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &v[i]);
        dp[i][i] = v[i] * n;
    }
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1, a = n - len + 1;
            dp[i][j] = max(dp[i + 1][j] + v[i] * a, dp[i][j - 1] + v[j] * a);
        }
    }
    printf("%d\n", dp[1][n]);
    return 0;
}

习题:P9232 [蓝桥杯 2023 省 A] 更小的数

解题思路

当选择子串 \(\text{num}_{i \dots j}\) 并将其反转时,字符串的其余部分 \(\text{num}_{0 \dots i-1}\)\(\text{num}_{j+1 \dots n-1}\) 保持不变。新字符串 \(\text{num}_{\text{new}}\) 要想比原字符串 \(\text{num}\) 小,其第一个与 \(\text{num}\) 不同的字符必须小于 \(\text{num}\) 中对应的字符。

由于只修改了 \(\text{num}_{i \dots j}\) 这一段,所以第一个可能出现不同的位置就在索引 \(i\) 处。因此,\(\text{num}_{\text{new}} \lt \text{num}\) 的充要条件是,被反转的子串 \(\text{reverse}(\text{num}_{i \dots j})\) 在字典序上要小于原始子串 \(\text{num}_{i \dots j}\)

所以,问题被转化为:计算有多少个子串 \(\text{num}_{i \dots j}\) 满足 \(\text{reverse}(\text{num}_{i \dots j}) \lt \text{num}_{i \dots j}\)

一个朴素的解法是遍历所有 \(O(n^2)\) 个子串,对每个子串都进行反转和比较,每次比较耗时 \(O(n)\),总时间复杂度为 \(O(n^3)\),对于 \(n=5000\) 的数据规模来说太慢了。

需要一个更高效的方法来判断 \(\text{reverse}(S) \lt S\) 是否成立,这种涉及子串/区间的性质判定的问题,通常可以考虑使用动态规划。

定义一个二维布尔数组 \(f_{i,j}\),其中 \(f_{i,j} = \text{true}\) 表示将子串 \(\text{num}_{i \dots j}\) 反转后得到的新子串比原子串 \(\text{num}_{i \dots j}\) 的字典序更小,否则 \(f_{i,j} = \text{false}\)

为了确定 \(f_{i,j}\) 的值,需要比较 \(\text{reverse}(\text{num}_{i \dots j})\)\(\text{num}_{i \dots j}\)

  • 比较的第一步是看它们的首字符,\(\text{num}_i\) 的首字符是 \(\text{num}_i\),而 \(\text{reverse}(\text{num}_{i \dots j})\) 的首字符是 \(\text{num}_j\)
  • 情况一\(\text{num}_i \ne \text{num}_j\)
    • 如果 \(\text{num}_j \lt \text{num}_i\),那么反转后的子串首字符更小,整个反转子串的字典序必然更小,所以 \(f_{i,j} = \text{true}\)
    • 如果 \(\text{num}_j \gt \text{num}_i\),那么反转后的子串首字符更大,整个反转子串的字典序必然更大,所以 \(f_{i,j} = \text{false}\)
    • 可以将这两种情况统一为 \(f_{i,j} = (\text{num}_i \gt \text{num}_j)\)
  • 情况二\(\text{num}_i = \text{num}_j\)
    • 如果首尾字符相同,那么子串的大小关系就取决于去掉首尾字符后的中间部分。即,\(\text{reverse}(\text{num}_{i \dots j})\)\(\text{num}_{i \dots j}\) 的大小关系等价于 \(\text{reverse}(\text{num}_{i+1 \dots j-1})\)\(\text{num}_{i+1 \dots j-1}\) 的大小关系。
    • 这恰好是子问题 \(f_{i+1,j-1}\),因此,\(f_{i,j} = f_{i+1,j-1}\)

状态 \(f_{i,j}\) 依赖于 \(f_{i+1,j-1}\),这是一个更短的子区间。因此,应该按照区间长度从小到大的顺序来计算 DP 表。

在计算完整个 DP 表后,遍历所有可能的子串区间 \((i,j)\)(其中 \(i \lt j\)),累加所有 \(f_{i,j}\)\(\text{true}\) 的情况,即可得到最终答案。

DP 表的计算需要两层循环,遍历区间长度和起始位置,总共 \(O(n^2)\) 个状态,每个状态的计算是 \(O(1)\)。统计答案需要遍历 DP 表,也是 \(O(n^2)\)。总体时间复杂度为 \(O(n^2)\),对于 \(n=5000\) 可以通过。

参考代码
#include <cstdio>
#include <cstring>

const int N = 5005;
char num[N];
bool ok[N][N]; // DP 数组

int main()
{
    scanf("%s", num);
    int n = strlen(num);

    // ok[i][j] 表示将子串 num[i..j] 反转后,是否比原串 num[i..j] 字典序更小
    // 状态转移方程:
    // 1. 如果 num[i] != num[j],则反转后的串与原串的大小关系由 num[j] 和 num[i] 决定。
    //    反转后,num[j] 在前,num[i] 在后。若 num[j] < num[i],则反转后更小。
    //    即 ok[i][j] = (num[i] > num[j])
    // 2. 如果 num[i] == num[j],则大小关系由去掉首尾的子串 num[i+1..j-1] 决定。
    //    即 ok[i][j] = ok[i+1][j-1]

    // 按区间长度从小到大进行 DP
    for (int len = 2; len <= n; len++) { // len 是当前处理的子串长度
        for (int i = 0; i <= n - len; i++) { // i 是子串的起始位置
            int j = i + len - 1; // j 是子串的结束位置

            if (num[i] != num[j]) {
                ok[i][j] = (num[i] > num[j]);
            } else {
                // 对于长度为 2 或 3 的串,如果首尾相等,中间部分为空或单个字符,
                // 反转后不变,所以 ok[i+1][j-1] 默认为 false,结果正确。
                ok[i][j] = ok[i+1][j-1];
            }
        }
    }

    int ans = 0;
    // 遍历所有可能的子串区间 [i, j]
    for (int i = 0; i < n; i++) {
        for (int j = i + 1; j < n; j++) {
            // 如果反转子串 [i, j] 会使整个数变小,则方案数加一
            // (因为只要子串反转后变小,整个数就一定会变小)
            if (ok[i][j]) {
                ans++;
            }
        }
    }

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

例题:P3205 [HNOI2010] 合唱队

解题思路

每次插入到队伍最左边或最右边,也就是说如果 \([i,j]\) 排好了,接下来一个人插到左边就排好了 \([i-1,j]\) 区间,如果插到右边就排好了 \([i,j+1]\) 区间,这就是小区间推到大区间

但是能不能插进来还要看这次加入的数和上一次插入的数是否符合对应的大小关系,因此我们还需要知道插入的最后一个数是最左边的还是最右边的

可以设 \(dp_{i,j,0/1}\) 表示把 \([i,j]\) 区间排好且最后一个人是在左边/右边的方案数,初始化 \(dp_{i,i,0}=1\),即只有一个人的时候强制认为它是插入在左边

考虑转移,对于 \(dp_{i,j,0}\),此时就是看 \(h_i\) 插进来的时候能否符合题目中的条件,此时需要知道 \([i+1,j]\) 中最后插进来的是哪个,如果是 \(h_{i+1}\),并且 \(h_i \lt h_{i+1}\),那么 \(dp_{i,j,0}\) 加上 \(dp_{i+1,j,0}\),如果是 \(h_j\),并且 \(h_i \lt h_j\),那么 \(dp_{i,j,0}\) 加上 \(dp_{i+1,j,1}\)

对于 \(dp_{i,j,1}\),此时就是看 \(h_j\) 插进来的时候能否符合题目中的条件,此时需要知道 \([i,j-1]\) 中最后插进来的是哪个,如果是 \(h_i\),并且 \(h_j \gt h_i\),那么 \(dp_{i,j,1}\) 加上 \(dp_{i,j-1,0}\),如果是 \(h_{j-1}\),并且 \(h_j \gt h_{j-1}\),那么 \(dp_{i,j,1}\) 加上 \(dp_{i,j-1,1}\)

时间复杂度 \(O(n^2)\)

参考代码
#include <cstdio>
const int N = 1005;
const int MOD = 19650827;
int dp[N][N][2], h[N];
int main()
{
	int n;
	scanf("%d", &n);
	for (int i = 1; i <= n; i++) {
		scanf("%d", &h[i]);
		dp[i][i][0] = 1;
	}
	for (int len = 2; len <= n; len++) {
		for (int i = 1; i <= n - len + 1; i++) {
			int j = i + len - 1;
			// [i,j] from [i+1,j] [i,j-1]
			if (h[i] < h[i+1]) dp[i][j][0] = (dp[i][j][0] + dp[i+1][j][0]) % MOD;
			if (h[i] < h[j]) dp[i][j][0] = (dp[i][j][0] + dp[i+1][j][1]) % MOD;
			if (h[j] > h[i]) dp[i][j][1] = (dp[i][j][1] + dp[i][j-1][0]) % MOD;
			if (h[j] > h[j-1]) dp[i][j][1] = (dp[i][j][1] + dp[i][j-1][1]) % MOD;
		}
	}
	printf("%d\n", (dp[1][n][0] + dp[1][n][1]) % MOD);
	return 0;
}

例题:P1775 石子合并(弱化版)

这是一个经典的区间动态规划问题,问题的核心在于,无论选择怎样的合并顺序,对于一个连续的石子区间 \([i,j]\),将其合并成一堆的最后一步,必然是把两个已经合并好的、相邻的子堆进行合并,这两个子堆必定是由原序列中的 \([i \dots k]\)\([k+1 \dots j]\)(其中 \(i \le k \lt j\))合并而来的。

假设要计算区间 \([i,j]\) 的石子合并成一堆的最小代价,如果确定了最后一步是合并 \([i \dots k]\)\([k+1 \dots j]\),那么总代价就等于合并 \([i \dots k]\) 的最小代价加上合并 \([k+1 \dots j]\) 的最小代价再加上合并这两大堆的代价

其中,“合并这两大堆的代价”就是这两大堆的总质量,也就是原序列中从 \(i\)\(j\) 所有石子的质量之和。这个代价是固定的,与 \(k\) 的选择无关。

为了求得 \([i,j]\) 的最小总代价,需要遍历所有可能的分割点 \(k\),并找出使上述总和最小的那个 \(k\),这天然地形成了动态规划的结构。

定义 \(f_{i,j}\) 为:将第 \(i\) 堆石子到第 \(j\) 堆石子(闭区间 \([i,j]\))合并成一堆所需的最小代价。最终目标是求解 \(f_{1,n}\)

根据问题分析,为了计算 \(f_{i,j}\),枚举最后一个合并步骤的分割点 \(k\),其中 \(i \le k \lt j\)。对于每个 \(k\),总代价的计算如下:

  • 合并区间 \([i,k]\) 的最小代价为 \(f_{i,k}\)
  • 合并区间 \([k+1,j]\) 的最小代价为 \(f_{k+1,j}\)
  • 最后一次合并的代价为区间 \([i,j]\) 内所有石子的总质量,记为 \(\text{sum}(i,j)\)

因此,对于一个固定的 \(k\),总代价为 \(f_{i,k}+f_{k+1,j}+\text{sum}(i,j)\)

需要在所有可能的 \(k\) 中取最小值,所以状态转移方程为 \(f_{i,j} = \min \limits_{i \le k \lt j} \{ f_{i,k} + f_{k+1,j} + \text{sum}(i,j) \}\)

当区间长度为 1 时,即 \(i=j\),石子已经是一堆了,无需合并,所以代价为 0,即 \(f_{i,i}=0\)

在状态转移中,需要频繁计算 \(\text{sum}(i,j)\)。如果每次都重新求和,会导致时间复杂度增加。可以预处理一个前缀和数组 \(S\),其中 \(S_i\) 存储前 \(i\) 堆石子的总重量。这样,\(\text{sum}(i,j)\) 就可以通过 \(S_j - S_{i-1}\)\(O(1)\) 时间内计算出来。

时间复杂度为 \(O(n^3)\)

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

const int N = 305;
const int INF = 1e9;

int m[N]; // m 数组用于存储石子质量的前缀和
// dp[i][j] 表示将第 i 堆石子到第 j 堆石子合并成一堆的最小代价
int dp[N][N];

int main()
{
    int n; 
    scanf("%d", &n);

    // 读入数据并计算前缀和
    // m[i] 存储的是从第 1 堆到第 i 堆石子的总质量
    for (int i = 1; i <= n; i++) {
        int mass;
        scanf("%d", &mass);
        m[i] = m[i - 1] + mass;
    }

    // 初始化 dp 数组
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= n; j++) {
            // base case: 将一堆石子合并成一堆的代价是 0
            if (i == j) {
                dp[i][j] = 0;
            } else {
                dp[i][j] = INF; // 其他情况初始化为无穷大
            }
        }
    }

    // 区间 DP
    // l 是区间的长度
    for (int l = 2; l <= n; l++) {
        // i 是区间的起始位置
        for (int i = 1; i <= n - l + 1; i++) {
            int j = i + l - 1; // j 是区间的结束位置

            // k 是区间 [i, j] 的分割点
            // 枚举最后一次合并的位置
            for (int k = i; k < j; k++) {
                // 状态转移方程:
                // dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum(i, j))
                // 其中 sum(i, j) 是 i 到 j 的总质量,用前缀和 m[j] - m[i-1] 计算
                int current_cost = dp[i][k] + dp[k + 1][j] + (m[j] - m[i - 1]);
                dp[i][j] = min(dp[i][j], current_cost);
            }
        }
    }

    printf("%d\n", dp[1][n]);
    return 0;
}

习题:P1040 [NOIP 2003 提高组] 加分二叉树

解题思路

乍一看是在一棵树上的问题,但由于其特殊的结构,可以转化为区间动态规划来解决。

问题的核心在于中序遍历是固定的这一条件,对于一棵二叉树,如果它的中序遍历是 \((1, 2, \dots, n)\),那么对于任意一个节点 \(k\) 作为树的根,所有编号小于 \(k\) 的节点必定在它的左子树中,所有编号大于 \(k\) 的节点必定在它的右子树中。

更进一步地,对于由连续节点 \(\{ i, i+1, \dots, j \}\) 构成的子树,如果选择节点 \(k\)\(i \le k \le j\))作为这棵子树的根,那么它的左子树必定由节点 \(\{ i, \dots, k-1 \}\) 构成,右子树必定由节点 \(\{ k+1, \dots, j \}\) 构成。

这样,一个大问题(求解区间 \([i,j]\) 的最优树)就可以被分解为两个独立的子问题(求解区间 \([i,k-1]\)\([k+1,j]\) 的最优树),这完全符合动态规划中“最优子结构”和“重叠子问题”的特性。

采用区间动态规划来解决此问题。

\(f_{i,j}\) 表示由中序遍历为 \((i, i+1, \dots, j)\) 的节点所能构成的所有二叉树中,加分最高的那一棵树的加分值。\(r_{i,j}\) 记录 \(f_{i,j}\) 取到最大值时,该子树的根节点是哪个节点,这个数组是后续输出前序遍历的关键。

按照区间长度从小到大的顺序来计算 DP 表。

为了计算 \(f_{i,j}\),枚举区间 \([i,j]\) 中所有可能的根节点 \(k\)\(i \le k \le j\))。

对于每一个枚举的根 \(k\)

  • 左子树由节点 \(\{ i, \dots, k-1 \}\) 构成,其最大加分为 \(f_{i,k-1}\)
  • 右子树由节点 \(\{ k+1, \dots, j \}\) 构成,其最大加分为 \(f_{k+1,j}\)
  • 根据题目定义,当根为 \(k\) 时,这棵树的加分为:\(f_{i,k-1} \times f_{k+1,j} + s_k\)(其中 \(s_k\) 是节点 \(k\) 的分数)。

需要在所有可能的 \(k\) 中选择一个,使得上述加分最大。因此,状态转移方程为:\(f_{i,j} = \max \{ f_{i,k-1} \times f_{k+1,j} + s_k \}\)(对于所有 \(k \in [i,j]\))。

区间长度为 1 时,\(f_{i,i} = s_i\)。此时子树只有一个节点(叶子),加分就是它自身的分数,对应的根 \(r_{i,i}=i\)

在计算状态转移方程时,如果左子树或右子树为空(例如,当 \(k=i\) 时左子树为空,当 \(k=j\) 时右子树为空),其加分按题目规定为 1。

\(f\)\(r\) 都计算完毕后,\(f_{1,n}\) 就是最终的答案。为了输出前序遍历,可以编写一个递归函数 \(\text{traverse}(i,j)\)

  1. 首先输出当前区间的根 \(r_{i,j}\)
  2. 然后递归调用 \(\text{traverse}(i, r_{i,j}-1)\) 来输出左子树的前序遍历。
  3. 最后递归调用 \(\text{traverse}(r_{i,j}+1, j)\) 来输出右子树的前序遍历。

初始调用为 \(\text{traverse}(1,n)\)

时间复杂度为 \(O(n^3)\)

参考代码
#include <cstdio>

typedef long long LL;
const int MAXN = 35;

LL s[MAXN]; // 存储每个节点的分数
// dp[i][j] 表示由中序遍历为 i, i+1, ..., j 的节点构成的子树的最大加分
LL dp[MAXN][MAXN];
// r[i][j] 记录在中序遍历 i..j 的子树中,取得最大加分时的根节点是 k
LL r[MAXN][MAXN];

// 递归函数,用于输出前序遍历结果
void traverse(int p, int q) {
    if (p > q) return; // 如果区间无效,则返回

    // 前序遍历:根 -> 左 -> 右
    printf("%lld ", r[p][q]); // 1. 打印根节点
    traverse(p, r[p][q] - 1); // 2. 递归遍历左子树
    traverse(r[p][q] + 1, q); // 3. 递归遍历右子树
}

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

    // 初始化:长度为 1 的区间,即叶子节点
    for (int i = 1; i <= n; i++) {
        dp[i][i] = s[i]; // 单个节点的子树加分就是其本身的分数
        r[i][i] = i;     // 根节点就是它自己
    }

    // 区间 DP
    // len 是区间的长度
    for (int len = 2; len <= n; len++) {
        // i 是区间的起始位置
        for (int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1; // j 是区间的结束位置

            // k 是在区间 [i, j] 中枚举的根节点
            for (int k = i; k <= j; k++) {
                // 计算以 k 为根时的子树加分
                // 左子树的加分。如果左子树为空 (k-1 < i),则加分为 1
                LL ltree = (k - 1 >= i) ? dp[i][k - 1] : 1;
                // 右子树的加分。如果右子树为空 (k+1 > j),则加分为 1
                LL rtree = (k + 1 <= j) ? dp[k + 1][j] : 1;
                
                // 当前加分 = 左子树加分 * 右子树加分 + 根节点分数
                LL p = ltree * rtree + s[k];

                // 如果找到了一个更大的加分,则更新 dp[i][j] 和根节点 r[i][j]
                if (p > dp[i][j]) {
                    dp[i][j] = p;
                    r[i][j] = k;
                }
            } 
        }
    }

    // 输出最终结果
    printf("%lld\n", dp[1][n]); // 整个树 [1, n] 的最大加分
    traverse(1, n); // 输出前序遍历

    return 0;
}

习题:P3146 [USACO16OPEN] 248 G

解题思路

可以设 \(dp_{i,j}\) 表示把 \([i,j]\) 合并得到的最大数,如果这一段无法合并成一个数,则 \(dp\) 值为 \(0\)

初始化 \(dp_{i,i}=a_i\)

考虑转移,对于区间 \([i,j]\),我们需要枚举分界点 \(k\),将 \([i,j]\) 拆成 \([i,k]\)\([k+1,j]\) 这两部分,先让 \([i,k]\) 合成一个数,\([k+1,j]\) 合成一个数,再让这两个数合并

这样就可以写出状态转移方程:如果 \(dp_{i,k}\)\(dp_{k+1,j}\) 相等且非 \(0\),则 \(dp_{i,j}= \max (dp_{i,j}, dp_{i,k}+1)\),最后答案为 \(\max \{dp_{i,j}\}\)

时间复杂度 \(O(n^3)\)

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

习题:P3147 [USACO16OPEN] 262144 P

解题思路

这是一个典型的动态规划问题,需要找到一种方法来记录和利用子问题的解,以构建更大问题的解。

一个直观的想法是定义 \(f_{i,j}\) 为子序列 \(a_{i \dots j}\) 能合成的最大数,但这种定义方式下,为了计算 \(f_{i,j}\),需要枚举一个分割点 \(k\),然后看 \(a_{i \dots k}\)\(a_{k+1 \dots j}\) 能否合成两个相邻且相等的数。这会导致时间复杂度过高(\(O(N^3)\)),无法通过本题。

需要转换思路,与其问“一个区间能合成什么数”,不如问“要合成某个特定的数,需要哪个区间?”。

可以定义一个不同维度的 DP 状态来解决这个问题。

定义 \(f_{v,i}\) 表示从序列的第 \(i\) 个位置开始,能够合成数字 \(v\)最短连续子序列的长度。如果 \(f_{v,i}=0\),则表示无法从位置 \(i\) 开始合成数字 \(v\)

对于输入序列中的每一个数,例如第 \(i\) 个位置的数是 \(x\),它本身就可以看作是一个长度为 1、合成了数字 \(x\) 的序列。因此,初始化 \(f_{x,i}=1\)

目标是计算 \(f_{v,i}\),为了从位置 \(i\) 开始合成数字 \(v\),必须先合成两个相邻的、值为 \(v-1\) 的数。

  1. 第一个 \(v-1\) 必须从位置 \(i\) 开始合成,根据 DP 定义,这个过程将消耗掉长度为 \(f_{v-1,i}\) 的子序列,称这个长度为 \(l_1\)
  2. 如果 \(l_1 \gt 0\)(即从 \(i\) 开始可以合成 \(v-1\)),那么这个子序列占据的区间是 \([i, i+l_1-1]\)
  3. 第二个 \(v-1\) 必须紧接着从位置 \(k=i+l_1\) 开始合成,这个过程将消耗掉长度为 \(f_{v-1,k}\) 的子序列,称这个长度为 \(l_2\)
  4. 如果 \(l_2 \gt 0\)(即从 \(k\) 开始也可以合成 \(v-1\)),那么就成功地找到了两个相邻的 \(v-1\),它们可以合并成一个从位置 \(i\) 开始的数字 \(v\)
  5. 这个新合成的数字 \(v\) 所占的序列总长度为 \(l_1 + l_2\)

综上,状态转移方程为 \(f_{v,i} = f_{v-1,i} + f_{v-1,k}\),当 \(f_{v-1,i} \gt 0\)\(dp_{v-1,k} \gt 0\),其中 \(k = i + f_{v-1,i}\)

状态 \(f_{v,i}\) 的计算依赖于 \(dp_{v-1, \dots}\) 的值,因此,需要将 \(v\) 作为 DP 的外层循环,从小到大进行计算。

关于 DP 外层循环上限 \(V_{\max}\) 的分析:要合成数字 \(v\),至少需要两个 \(v-1\),四个 \(v-2\),以此类推。假设从一个初始值为 \(x\) 的数开始合并,为了合成 \(v\),至少需要 \(2^{v-x}\) 个初始值为 \(x\) 的数(或由更小的数合成)。由于序列总长度为 \(N\),所以必须有 \(2^{v-x} \le N\)。为了让 \(v\) 尽可能大,应该从最大的初始值 \(x\) 开始考虑,即 \(x_{\max} = 40\)。因此,\(2^{v-40} \le N\)。两边取对数,得到 \(v-40 \le \log_2(N)\)。因为题目给出 \(N \le 262144 = 2^{18}\),所以 \(\log_2(N) \le 18\)。代入得 \(v-40 \le 18\),即 \(v \le 58\),这意味着能合成的最大数理论上不会超过 58。

在整个 DP 计算过程中,只要成功计算出一个非零的 \(f_{v,i}\),就意味着数字 \(v\) 是可以被合成的,只需在循环中不断更新最大值即可。

参考代码
#include <cstdio>

const int N = 262150; // 序列的最大长度 N
const int M = 60;   // 能够合成的最大数字的上限,40 + log2(N) approx 40+18=58

// dp[i][j] 表示:从位置 j 开始,能够合成数字 i 的连续子序列的长度。
// 如果为 0,表示无法从 j 开始合成数字 i。
int dp[M][N];

int main() {
	int n;
	scanf("%d", &n);

	// 初始化 DP 数组(基础情况)
	// 对于输入的每个数 x,在它的位置 i,它可以被看作一个长度为 1 的、能合成数字 x 的序列。
	for (int i = 1; i <= n; i++) {
		int x;
		scanf("%d", &x);
		dp[x][i] = 1;
	}

	int ans = 0;
	// 状态转移
	// i 代表想要合成的数字
	for (int i = 2; i < M; i++) {
		// j 代表合成的起始位置
		for (int j = 1; j <= n; j++) {
			// 状态转移方程:
			// 要从 j 开始合成数字 i,需要先从 j 开始合成一个数字 i-1,
			// 然后紧接着这个序列,再合成一个数字 i-1。

			// k 是第一个 i-1 序列结束后的下一个位置
			// dp[i-1][j] 是从 j 开始合成 i-1 所需的长度
			int k = j + dp[i - 1][j];

			// 如果从 j 能合成 i-1 (dp[i-1][j] != 0),
			// 并且从 k 能合成 i-1 (dp[i-1][k] != 0),
			// 那么就可以从 j 开始合成数字 i。
			if (dp[i-1][j] != 0 && dp[i-1][k] != 0) {
				// 新序列的长度是两个 i-1 序列长度之和
				dp[i][j] = dp[i-1][j] + dp[i-1][k];
			}

			// 如果 dp[i][j] 不为 0,说明成功合成了数字 i
			// 更新能得到的最大数字
			if (dp[i][j] != 0) {
				ans = i;
			}
		}
	}

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

例题:P4170 [CQOI2007] 涂色

解题思路

考虑染色过程,因为是一段一段染的,长段可以看成是两个短段拼起来,并且如果某一次染了一段之后,可以在这段内部继续染色,这都提示我们可以考虑区间 DP

\(dp_{i,j}\) 表示染完 \(i\)\(j\) 的最少次数,初始化 \(dp_{i,i}=1\),一段拆成两段染,则有 \(dp_{i,j} = \min \{dp_{i,k}+dp_{k+1,j}\}\)

特殊情况:如果 \(s_i = s_j\),可以在染完 \([i+1,j]\)\([i,j-1]\) 的时候顺带把 \(i\)\(j\) 染了,这样的结果一定优于拆两段,此时 \(dp_{i,j}= \min (dp_{i,j-1}, dp_{i+1,j})\)

分析:拆段意味着存在两步分别染 \([i,x_1]\)\([x_2,j]\),而不拆段则可以直接改成在第一步染一次 \([i,j]\),染这一次也不会干扰到后续染色过程,因为后续染色是直接覆盖中间的某段区域,所以之前被这次 \([i,j]\) 染过也没有关系

对于其他题目,有可能出现在可以不拆段时依然是拆段取到最优解的情况,注意分析,如果想简化分析过程可以统一枚举拆段转移最优解的过程,因为在可能需要拆段的题目中这样做不会影响时间复杂度

时间复杂度 \(O(n^3)\)

参考代码
#include <cstdio>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 55;
char s[N];
int dp[N][N];
int main()
{
    scanf("%s", s + 1);
    int n = strlen(s + 1);
    for (int i = 1; i <= n; i++) dp[i][i] = 1;
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1;
            dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + (s[i] != s[j]);
            for (int k = i; k < j; k++) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
        }
    }
    printf("%d\n", dp[1][n]);
    return 0;
}

例题:CF607B Zuma

解题思路

\(dp_{i,j}\) 为移除 \([i,j]\) 的最短时间,则有初始化

\(\begin{cases} dp_{i,i}=1 & \\ dp_{i,i+1}=1 & c_i=c_{i+1} \\ dp_{i,i+1}=2 & c_i \ne c_{i+1} \end{cases}\)

状态转移方程

\(\begin{cases} dp_{i,j}=dp_{i+1,j-1} & c_i=c_j \\ dp_{i,j}=\min \{ dp_{i,k}+dp_{k+1,j} \} \end{cases}\)

注意:就算是 \(c_i=c_j\),也有可能是拆段更优,比如 \([1, 2, 1, 1, 3, 1]\)

因此无论 \(c_i\) 是否等于 \(c_j\),都必须做拆段的这种转移,时间复杂度 \(O(n^3)\)

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 505;
int c[N], dp[N][N];
int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &c[i]);
        dp[i][i] = 1; 
    }
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1;
            dp[i][j] = min(dp[i + 1][j], dp[i][j - 1]) + 1;
            if (c[i] == c[j]) dp[i][j] = min(dp[i][j], len == 2 ? 1 : dp[i + 1][j - 1]);
            for (int k = i; k < j; k++) dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j]);
        }
    }
    printf("%d\n", dp[1][n]);
    return 0;
}

例题:CF149D Coloring Brackets

解题思路

\(dp[l][r][cl][cr]\) 代表对 \([l,r]\) 区间染色且端点 \(l\) 处颜色为 \(cl\),端点 \(r\) 处颜色为 \(cr\) 情况下的染色方案数,这里颜色的取值可以设在 \(0 \sim 2\) 之间,如 \(0\) 代表不染色,\(1\) 代表染红色,\(2\) 代表染蓝色

\(l\) 位置的括号和 \(r\) 位置的括号形成匹配关系时,此时可以将问题转化为先去计算 \(dp[l+1][r-1]\) 的值,再处理匹配括号、相邻扩号的约束关系

\(l\) 位置的括号和 \(r\) 位置的括号不构成匹配关系时,此时应当将问题转化为两段独立形成括号匹配的串的染色方案合并起来的结果,为了方便拆分,可以预处理出原串中每个括号的对应匹配关系

以上两种转移方式要想组合到一起不容易用循环来实现,实际上这里更容易写的方式是利用递归回溯的过程完成计算

参考代码
#include <cstdio>
#include <cstring>
#include <stack>
using namespace std;
const int N = 705;
const int MOD = 1000000007;
char s[N];
// 0: none, 1: red, 2: blue
int dp[N][N][3][3], match[N];
void dfs(int l, int r) {
    if (l >= r) return;
    if (l + 1 == r) {
        // 会执行到这个位置只能是 ()
        dp[l][r][0][1] = dp[l][r][0][2] = 1;
        dp[l][r][1][0] = dp[l][r][2][0] = 1;
        return;
    }
    if (match[l] != r) {
        dfs(l, match[l]);
        dfs(match[l] + 1, r);
        for (int c1 = 0; c1 < 3; c1++) {
            for (int c2 = 0; c2 < 3; c2++) {
                for (int c3 = 0; c3 < 3; c3++) {
                    if (c2 == c3 && c2 != 0) continue;
                    for (int c4 = 0; c4 < 3; c4++) {
                        int left = dp[l][match[l]][c1][c2];
                        int right = dp[match[l] + 1][r][c3][c4];
                        dp[l][r][c1][c4] += 1ll * left * right % MOD;
                        dp[l][r][c1][c4] %= MOD;
                    }
                }  
            }               
        }          
    } else {
        dfs(l + 1, r - 1);
        for (int c1 = 0; c1 < 3; c1++) {
            for (int c2 = 0; c2 < 3; c2++) {
                if (c1 == c2 && c1 != 0) continue;
                for (int c3 = 0; c3 < 3; c3++) {
                    for (int c4 = 0; c4 < 3; c4++) {
                        if (c3 == c4 && c3 != 0) continue;
                        if (c1 == 0 && c4 == 0) continue;
                        if (c1 != 0 && c4 != 0) continue;
                        dp[l][r][c1][c4] += dp[l + 1][r - 1][c2][c3];
                        dp[l][r][c1][c4] %= MOD;
                    }
                }
            }
        }
    }
}
int main()
{
    scanf("%s", s + 1);
    int n = strlen(s + 1);
    stack<int> stk;
    for (int i = 1; i <= n; i++) {
        dp[i][i][0][0] = dp[i][i][1][1] = dp[i][i][2][2] = 1;
        if (s[i] == '(') {
            stk.push(i);
        } else if (s[i] == ')') {
            int t = stk.top(); stk.pop();
            match[i] = t; match[t] = i;
        }
    }
    dfs(1, n);
    int ans = 0;
    for (int i = 0; i < 3; i++)
        for (int j = 0; j < 3; j++)
            ans = (ans + dp[1][n][i][j]) % MOD;
    printf("%d\n", ans);
    return 0;
}

例题:P7914 [CSP-S 2021] 括号序列

解题思路

我们设符合要求的序列成为 \(A\) 型序列,连续不超过 \(k\)\(*\) 的序列为 \(S\) 型序列

根据题意,符合要求的括号序列可以分成 \((),(A),(S),(AS),(SA),AA,ASA\)

对于 \((),(A),(S)\),区间 \([i,j]\) 的结果可以由区间 \([i+1,j-1]\) 的结果转移而来

对于 \((AS),(SA),AA,ASA\),需要枚举断点,把区间 \([i,j]\) 拆成两个子区间,对于 \(ASA\),可以拆成 \(A\)\(SA\) 两部分,因此同时需要维护 \(SA\) 型序列的方案数

然而,对于 \(AA\) 型序列,如 \(()()()\)\([1,2]\) 符合 \(A\) 型序列,\([3,6]\) 符合 \(A\) 型序列,而 \([1,4]\) 符合 \(A\) 型序列,\([5,6]\) 符合 \(A\) 型序列,枚举断点时产生了重复计算;同理对于 \(ASA\) 型序列,如 \(()*()*()\),也会产生重复计算

解决方法:计算 \(AA\) 型序列方案数时,将其拆分为 \(A'A\),其中 \(A\) 型序列指的是所有符合要求的括号序列,而 \(A'\) 型序列指的是最外层为一对匹配括号条件下的 \(A\) 型序列,即这种序列不是由 \(AA\)\(ASA\) 拼接得到的合法括号序列,这样枚举断点时不会导致重复计算;同理 \(ASA\) 型序列的计算可以拆成 \(A'\) 型序列的计算结果和 \(SA\) 型序列的计算结果的组合

参考代码
#include <cstdio>
const int N = 505;
const int MOD = 1000000007;
char s[N];
int dp_a[N][N], dp_s[N][N], dp_ba[N][N], dp_sa[N][N];
bool check(int idx, char ch) {
    return s[idx] == '?' || s[idx] == ch;
}
int main()
{
    int n, k;
    scanf("%d%d%s", &n, &k, s + 1);
    for (int i = 1; i <= n; i++)
        if (check(i, '*')) dp_s[i][i] = 1;
    if (k >= 2) {
        for (int i = 1; i < n; i++)
            if (check(i, '*') && check(i + 1, '*')) dp_s[i][i + 1] = 1;
        for (int len = 3; len <= k; len++) {
            for (int i = 1; i <= n - len + 1; i++) {
                int j = i + len - 1;
                if (check(i, '*') && check(j, '*')) 
                    dp_s[i][j] = dp_s[i + 1][j - 1];
            }
        }
    }
    for (int i = 1; i < n; i++) {
        if (check(i, '(') && check(i + 1, ')')) 
            dp_ba[i][i + 1] = dp_a[i][i + 1] = 1;
    }
    for (int len = 3; len <= n; len++) {
        for (int i = 1; i <= n - len + 1; i++) {
            int j = i + len - 1; 
            if (check(i, '(') && check(j, ')')) {
                // (A)
                dp_ba[i][j] += dp_a[i + 1][j - 1];
                dp_ba[i][j] %= MOD;
                // (S)
                dp_ba[i][j] += dp_s[i + 1][j - 1];
                dp_ba[i][j] %= MOD;
                // (AS) (SA)
                for (int k = i + 1; k < j - 1; k++) {
                    dp_ba[i][j] += 1ll * dp_a[i + 1][k] * dp_s[k + 1][j - 1] % MOD;
                    dp_ba[i][j] %= MOD;
                    dp_ba[i][j] += 1ll * dp_s[i + 1][k] * dp_a[k + 1][j - 1] % MOD;
                    dp_ba[i][j] %= MOD;
                }
                dp_a[i][j] = dp_ba[i][j];
            }   
            // AA ASA
            for (int k = i; k < j; k++) {
                dp_a[i][j] += 1ll * dp_ba[i][k] * dp_a[k + 1][j] % MOD;
                dp_a[i][j] %= MOD;
                dp_a[i][j] += 1ll * dp_ba[i][k] * dp_sa[k + 1][j] % MOD;
                dp_a[i][j] %= MOD;
                dp_sa[i][j] += 1ll * dp_s[i][k] * dp_a[k + 1][j] % MOD;
                dp_sa[i][j] %= MOD;
            }
        }
    }
    printf("%d\n", dp_a[1][n]);
    return 0;
}

环形 DP

有时我们会面临输入是环形数组的情况,即认为 \(a[n]\)\(a[1]\) 是相邻的,此时应该如何处理?

  • 如果是线性 DP,比如选数问题,可以对 \(a[n]\) 是否选进行分类,假设 \(a[n]\) 不选,把 \(dp[1][\dots]\) 初始化好,最后推到 \(dp[n][\dots]\) 时,只留下 \(a[n]\) 不选的情况计入答案;再假设 \(a[n]\) 选,把 \(dp[1][\dots]\) 初始化好,最后推到 \(dp[n][\dots]\) 时,只留下 \(a[n]\) 选的情况计入答案

  • 如果是区间 DP,一种常见的方法是破环成链,将数组复制一倍接在原数组之后,然后对这个长度为 \(2n\) 的数组进行长度不超过 \(n\) 的区间 DP

    \(dp[i][j]\)\(i \le n\)\(j>n\) 的情况就是把 \(a[n]\)\(a[1]\) 也看成了相邻的,比如 \(dp[2][n+1]\),就代表原数组 \(a[2],\dots,a[n],a[1]\) 这样一个环上的结果

例题:P1880 [NOI1995] 石子合并

解题思路

破环成链后,对产生的长度为 \(2n\) 的数组中区间长度 \(\le n\) 的所有区间进行 DP

\(dpmax_{i,j}\) 表示将 \([i,j]\) 区间合并的最大得分,则有 \(dpmax_{i,j} = \max \{ dpmax_{i,k} + dpmax_{k+1,j} + (a_i + \dots + a_j) \}\)

\(dpmin_{i,j}\) 表示将 \([i,j]\) 区间合并的最小得分,则有 \(dpmin_{i,j} = \min \{ dpmin_{i,k} + dpmin_{k+1,j} + (a_i + \dots + a_j) \}\)

其中 \(a_i + \dots + a_j\) 可以利用前缀和预处理 \(O(1)\) 得到,总时间复杂度 \(O(n^3)\),最后答案为 \(dpmax_{i,i+n-1}\) 中的最大值和 \(dpmin_{i,i+n-1}\) 中的最小值

参考代码
#include <cstdio>
#include <algorithm>
using namespace std;
const int N = 205;
const int INF = 1e9;
int a[N], sum[N];
int dp1[N][N];
int dp2[N][N];
int main()
{
    int n;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        sum[i] = sum[i - 1] + a[i];
        a[i + n] = a[i];
    }
    for (int i = n + 1; i <= 2 * n; i++) sum[i] = sum[i - 1] + a[i];
    for (int i = 1; i < 2 * n; i++)
        for (int j = 1; j < 2 * n; j++) {
            dp1[i][j] = INF;
            dp2[i][j] = 0;
        }
    for (int i = 1; i < 2 * n; i++) dp1[i][i] = 0;
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i <= 2 * n - len; i++) {
            int j = i + len - 1;
            for (int k = i; k < j; k++) {
				int total = sum[j] - sum[i - 1];
                dp1[i][j] = min(dp1[i][j], dp1[i][k] + dp1[k + 1][j] + total);
                dp2[i][j] = max(dp2[i][j], dp2[i][k] + dp2[k + 1][j] + total);
            }
        }
    }
    int ans1 = INF, ans2 = 0;
    for (int i = 1; i <= n; i++) {
        ans1 = min(ans1, dp1[i][i + n - 1]);
        ans2 = max(ans2, dp2[i][i + n - 1]);
    }
    printf("%d\n%d\n", ans1, ans2);
    return 0;
}

例题:P1063 [NOIP2006 提高组] 能量项链

解题思路

与石子合并基本相同,先破环成链,然后设 \(dp_{i,j}\) 表示把 \([i,j]\) 的能量珠合成一个时释放的最大总能量

则有 \(dp_{i,j} = \max \{ dp_{i,k} + dp_{k+1,j} + a_i * a_{k+1} * a_{j+1} \}\)

需要注意要乘的三个数是哪三个,尤其是最后一个数,因为需要 \(a_{j+1}\),所以可以在刚开始复制 \(a\) 数组时多复制一位,让 \(a_{2*n+1}\) 也等于 \(a_1\)

最后答案为 \(\max \{ dp_{i,i+n-1} \}\),时间复杂度 \(O(n^3)\)

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

例题:P1121 环状最大两段子段和

对于“在环上取两个不重叠子段”的问题,可以分为两种情况来讨论。

情况一:两个子段不跨越环的边界(\(a_n\)\(a_1\) 的连接处)

这种情况下,问题等价于“在线性序列 \(a_1, a_2, \dots, a_n\) 中,找到两个不重叠的子段,使其和最大”。

情况二:两个子段中,有一个跨越了环的边界

一个子段跨越边界,例如它包含了 \(a_n, a_1, a_2\) 等,这不符合原始的“连续子段”的定义。实际上这种可以把它理解成一小段是原序列的后缀(如 \(a_i, \dots, a_n\)),另一小段是原序列的前缀(如 \(a_1, \dots, a_j\)),其中 \(j \lt i\)

这种情况有一种非常巧妙的等价转换:在环上选择两个子段,使其和最大,等价于在环上选择它们之间的“空隙”部分,使其和最小,这两个“空隙”在环上同样是两个不重叠的连续子段。因此,这种情况等价于“在线性序列中找到两个不重叠的子段,使其和最小”,然后用整个序列的总和减去这个最小和。

综上,只需要解决两个问题:

  1. 求线性序列上的最大两段子段和
  2. 求线性序列上的最小两段子段和

最终的答案就是“第一个问题的答案”和“序列总和减去第二个问题的答案”中的较大值。

而这两个问题的求解,类似于 P2642 最大双子段和

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

const int N = 200005;
int a[N];
int dp1[N], dp2[N]; // dp1[i]: 以i结尾的子段和, dp2[i]: 以i为开头的子段和
int pre[N], suf[N]; // pre[i]: [1..i]上的最大/最小子段和, suf[i]: [i..n]上的最大/最小子段和

int main()
{
    int n, sum = 0;
    scanf("%d", &n); 
    int neg = 0; // 记录负数个数
    for (int i = 1; i <= n; i++) {
        scanf("%d", &a[i]);
        sum += a[i];
        if (a[i] < 0) neg++;
    }

    // --- 情况一:求解线性数组上的最大两段子段和 ---
    // 从左到右计算前缀最大子段和
    dp1[1] = a[1];
    pre[1] = a[1];
    for (int i = 2; i <= n; i++) {
        dp1[i] = max(dp1[i - 1] + a[i], a[i]); // 以 a[i] 结尾的最大子段和
        pre[i] = max(pre[i - 1], dp1[i]);     // [1..i] 区间内的最大子段和
    }

    // 从右到左计算后缀最大子段和
    dp2[n] = a[n];
    suf[n] = a[n];
    for (int i = n - 1; i >= 1; i--) {
        dp2[i] = max(dp2[i + 1] + a[i], a[i]); // 以 a[i] 为开头的最大子段和
        suf[i] = max(suf[i + 1], dp2[i]);     // [i..n] 区间内的最大子段和
    }

    // 枚举分割点 i,最大两段子段和 = max(pre[i] + suf[i+1])
    int ans = pre[1] + suf[2]; // 初始化,避免 n=2 的情况
    for (int i = 2; i < n; i++) {
        ans = max(ans, pre[i] + suf[i + 1]);
    }

    // --- 情况二:求解环状数组上的最大两段子段和 ---
    // 这等价于用总和 sum 减去线性数组上的最小两段子段和
    
    // 从左到右计算前缀最小子段和
    dp1[1] = a[1];
    pre[1] = a[1];
    for (int i = 2; i <= n; i++) {
        dp1[i] = min(dp1[i - 1] + a[i], a[i]); // 以 a[i] 结尾的最小子段和
        pre[i] = min(pre[i - 1], dp1[i]);     // [1..i] 区间内的最小子段和
    }

    // 从右到左计算后缀最小子段和
    dp2[n] = a[n];
    suf[n] = a[n];
    for (int i = n - 1; i >= 1; i--) {
        dp2[i] = min(dp2[i + 1] + a[i], a[i]); // 以 a[i] 为开头的最小子段和
        suf[i] = min(suf[i + 1], dp2[i]);     // [i..n] 区间内的最小子段和
    }

    // 枚举分割点 i,找到最小的两段子段和 min_two_sum = min(pre[i] + suf[i+1])
    int min_two_sum = pre[1] + suf[2];
    for (int i = 2; i < n; i++) {
        min_two_sum = min(min_two_sum, pre[i] + suf[i + 1]);
    }

    // 环形的最大两段子段和 = sum - 线性最小两段子段和

    // 如果原数组全是负数或者只有一个不是,线性最小两段子段和会把所有负数选中
    // 留给环形最大两段子段和的数不足两个,这种情况非法
    // 此时情况一已经能够覆盖正确答案
    if (neg < n - 1) {
        ans = max(ans, sum - min_two_sum);
    }
    printf("%d\n", ans);
    return 0;
}

习题:P9505 『MGOI』Simple Round I | D. 魔法环

解题思路

这是一个在环上寻找最优选择的组合优化问题,通常可以考虑使用动态规划来解决。

环形 DP 的一个常用技巧是“破环成链”,如果枚举激活的第一个精灵是哪个之后就可以调整原始序列的顺序从而转化成线性 DP 问题。

定义 \(f_{i,j}\) 为在线性序列 \(a\) 上,已经激活了 \(j\) 个节点,并且最后一个(第 \(j\) 个)激活的节点是 \(a_i\) 时的最小总附魔值。

因为已经假定了激活 \(a_1\) 作为第 1 个,所以 \(f_{1,1} = {a_1}^2\),其他值都初始化为无穷大。

按顺序计算 \(f_{i,j}\),其中 \(i\) 是当前节点位置,\(j\) 是激活的节点数量。

  • 为了计算 \(f_{i,j}\),必须枚举上一个(第 \(j-1\) 个)被激活的节点 \(p\),其中 \(1 \le p \lt i\)
  • 状态转移方程为 \(f_{i,j} = \min \limits_{1 \le p \lt i} \{ f_{p,j-1} + \text{cost}(p, i) \}\),其中 \(\text{cost}(p,i)\) 指的是 \({a_i}^2 + \max(a_p,a_i) \times (1+2+ \cdots + (i-p-1))\),指的是 \([p+1,i]\) 这个区间中的精灵产生的总魔供值,这部分可以提前预处理好。

题目要求是激活至少 \(k\) 个,这意味着,当已经激活了 \(k\) 个之后,可以继续激活第 \(k+1, k+2, \dots\) 个。在 DP 状态中,可以让 \(f_{i,k}\) 表示激活了至少 \(k\) 个,并以 \(i\) 结尾的最小代价。

  • \(j \lt k\) 时,\(f_{i,j}\) 的含义是激活恰好 \(j\) 个。
  • \(j = k\) 时,\(f_{i,k}\) 的状态可以从两种情况转移而来:
    1. 从一个激活了 \(k-1\) 个的状态转移而来(这是第 \(k\) 个激活的节点)。
    2. 从一个已经激活了 \(k\) 个(或更多)的状态转移而来(这是第 \(k+1, \dots\) 个激活的节点)。
  • 因此,当 \(j=k\) 时,转移方程里还要考虑 \(f_{i,k} + \text{cost}(p,i)\)

DP 计算完成后,\(f_{i,k}\) 存储了在线性序列上激活至少 \(k\) 个,并以 \(i\) 结尾的最小代价。

为了构成一个完整的环,还需要计算从最后一个激活的节点 \(i\) 回到起点 \(a_1\) 的代价,这部分代价可以通过令 \(a_{n+1}=a_1\) 后用 \(\text{cost}(i,n+1)\) 来表示。

最小代价需要遍历所有可能的最后一个激活节点,并取加上最后一段代价后的最小值。

这样做一次 DP 时间复杂度 \(O(n^2k)\),但是因为不能确定第一个激活的是谁,需要遍历每一个精灵作为第一个被激活的,最终时间复杂度 \(O(n^3k)\),会导致超时。

实际上由于魔供值是 \(0 \sim n-1\) 的排列,可以证明选择 0 作为第一个强制激活的精灵是不会影响最优解的。

证明:假设存在一个最优解,其中没有激活值为 0 的节点。现在,在这个“最优解”的基础上,额外激活值为 0 的精灵,分析总附魔值的变化。

  1. 对于值为 0 的精灵本身:它从未激活状态变为了激活状态,由于两者的附魔值计算中都包含本身的附魔值作为一项因子,因此都是 0。
  2. 对于其他未激活的节点:假设 0 原本夹在 \(x\)\(y\) 两个被激活精灵的中间,首先,不在 \(x\)\(y\) 之间的未被激活的精灵的魔供值不变。而对于原来夹在 \(x\)\(y\) 之间的未被激活的精灵,原本它们在选择“目标精灵”时,选的是 \(x\)\(y\) 中较大的那个,不妨设 \(a_x \lt a_y\),那么原来这一段未被激活的精灵产生的总魔供值是 \(a_y \times (1 + 2 + \cdots + y-x-1)\),而现在以 0 分两边之后变成了一边在 \(a_x\) 和 0 之间选,另一边在 \(a_y\)\(0\) 之间选。因为它们总是选择魔供值较大的一个作为目标,所以它们绝对不会选择值为 0 的节点作为目标(除非其另一个最近的激活精灵的魔供值也是 0,但这不可能,因为魔供值是个排列)。因此,它们的总魔供值变为 \(a_y \times (1+2+\cdots) + a_x \times (1+2+\cdots)\),这个值显然不会大于原来的那个值。
  3. 对于其他已被激活的节点:它们的附魔值(自身魔供值的平方)保持不变。

综上所述,在一个不包含魔供值为 0 的精灵的“最优解”的基础上再激活 0 的那个,总的附魔值只会减少或不变。这意味着,可以放心地强制激活附魔值为 0 的精灵,这样一来就不需要枚举第一个强制激活的精灵。

将附魔值为 0 的那个精灵看成 \(a_1\) 之后破环成链,再按上面的方式进行 DP 计算,时间复杂度为 \(O(n^2k)\),在给定的数据范围下,符合题目的时间限制。

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

typedef long long LL;
const int N = 3005;
const int K = 105;
const LL INF = 1e12;

int magic[N]; // 原始输入
int a[N];     // 断环为链后的新序列
// dp[i][j]: 激活了 j 个精灵,且最后一个激活的是 a[i] 时的最小附魔值
LL dp[N][K];
// res[i][j]: 预处理的代价,表示 i 和 j 是相邻的激活精灵时,激活 j 以及它们之间所有未激活精灵的总代价
LL res[N][N];

// 计算当 l 和 r 是相邻激活精灵时,激活 r 以及它们之间所有未激活精灵的代价
LL calc(int l, int r) {
    // 两个激活精灵之间的距离为 r-l-1
    // 它们之间的所有未激活精灵都会选择 l 和 r 中魔供值较大的作为目标
    // 假设目标是 a[l],则代价为 a[l]*1 + a[l]*2 + ... + a[l]*(r-l-1) = a[l] * (1+...+(r-l-1))
    // 这个等差数列的和是 (r-l-1)*(r-l)/2
    LL interval_cost = 1ll * (r - l - 1) * (r - l) / 2 * max(a[l], a[r]);
    // 总代价还包括激活 r 本身的代价 a[r]^2
    return interval_cost + 1ll * a[r] * a[r];
}

int main()
{
    int n, k;
    scanf("%d%d", &n, &k);
    int zero_pos = 0;
    for (int i = 1; i <= n; i++) {
        scanf("%d", &magic[i]);
        if (magic[i] == 0) zero_pos = i;
    }

    // 1. 断环为链:以魔供值为 0 的精灵为断点,将其展开为线性序列
    // 并将 0 放在序列的开头 a[1],方便处理
    int current_idx = zero_pos;
    for (int i = 1; i <= n; i++) {
        a[i] = magic[current_idx];
        current_idx = current_idx % n + 1;
        // 初始化 dp 数组为无穷大
        for (int j = 1; j <= k; j++) dp[i][j] = INF;
    }

    // 2. 预处理代价
    // 为了处理环的闭合,在末尾设置一个值为 0 的虚拟节点
    a[n + 1] = 0;
    for (int i = 1; i <= n; i++) {
        for (int j = i + 1; j <= n + 1; j++) {
            res[i][j] = calc(i, j);
        }
    }

    // 3. 动态规划
    // 初始化:激活第一个精灵 a[1] (值为0),作为第1个激活的精灵,代价为 0^2 = 0
    dp[1][1] = 0;

    for (int i = 2; i <= n; i++) { // 枚举当前要激活的精灵 i
        int bound = min(i, k);
        for (int j = 2; j <= bound; j++) { // 枚举当前是第 j 个被激活的精灵
            for (int pre = 1; pre < i; pre++) { // 枚举上一个被激活的精灵 pre
                // 标准转移:从激活了 j-1 个精灵的状态,转移到激活 j 个精灵的状态
                dp[i][j] = min(dp[i][j], dp[pre][j - 1] + res[pre][i]);
                
                // 特殊转移,处理“至少k个”的条件:
                // 当已经激活了 k 个精灵时 (j==k),还可以继续激活更多精灵。
                // 此时,可以从一个已经激活了 k 个精灵的状态 dp[pre][k] 进行转移。
                // 这相当于在已有 k 个激活精灵的方案上,再激活一个 i,总数变为 k+1 个。
                // 仍然将这个状态的代价记录在 dp[i][k] 中,
                // 因为 dp[i][k] 的含义变成了“激活了至少k个精灵,并以i结尾”的最小代价。
                if (j == k) {
                    dp[i][j] = min(dp[i][j], dp[pre][j] + res[pre][i]);
                }
            }
        }
    }

    // 4. 统计最终答案
    LL ans = INF;
    // 枚举最后一个被激活的精灵 i (它激活了至少 k 个精灵)
    for (int i = 2; i <= n; i++) {
        // 总代价 = 线性序列的代价 dp[i][k] + 闭合环的代价
        // 闭合环的代价就是从最后一个激活的 i 到第一个激活的 a[1] (值为0) 之间的代价
        // 用虚拟节点 a[n+1]=0 来计算这个代价
        ans = min(ans, dp[i][k] + calc(i, n + 1));
    }

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

例题:P9119 [春季测试 2023] 圣诞树

前 6 个测试点(前 30 分),直接枚举全排列即可
前 12 个测试点(前 60 分),哈密顿路问题(后续在状态压缩 DP 中会讲)
特殊性质 B(额外 10 分),答案为 \(1, 2, 3, \dots, n\)

解题思路

首先要分析出一个重要结论

考虑将该凸多边形按最高点分为两边,最优路径一定不会出现交叉的情况,例如针对题图中的 \(3,4,6,7\) 这四个点,\(6 \rightarrow 3 \rightarrow 4 \rightarrow 7\) 这样的连线方式必然不如 \(6 \rightarrow 4 \rightarrow 3 \rightarrow 7\)

也不会出现 \(4 \rightarrow 2 \rightarrow 3\) 这样的路径,它显然不如 \(4 \rightarrow 3 \rightarrow 2\),且那样一来再从 \(3\) 出发连向另一边的点会导致出现交叉的情况

因此,最优路径一定会是先在一边从头按顺序走几步,再去另一边从头按顺序走几步,再回初始那一边从没到过的点开始再按着顺序走几步,再去另一边走几步,这样不断交替(注意第 \(n\) 个点可以接着走 \(1, 2, \dots\) 这些点,所以要考虑环形数组)

可以将输入的数据复制一份,破环成链以后最优路径走过的点一定是包含起点的一段长度为 \(n\) 的连续区间 \([l,r]\),且最后一定停在 \(l\) 或者 \(r\)

可以设 \(dp[i][j][0/1]\) 表示走完了 \([i,j]\) 区间,最后停在 \(i/j\) 时候的最优解,枚举最后一步是从 \(i+1\)\(i\),还是从 \(j\)\(i\),还是从 \(i\)\(j\),还是从 \(j-1\)\(j\) 完成转移,时间复杂度 \(O(n^2)\)

这道题最后不是要输出最优解的那个值,而是输出路径,因此我们需要在 DP 过程中记录方案

常用方法是开一个和 \(dp\) 一样大的 \(from\) 数组,记录转移点,比如这题 \(dp[i][j][0]\) 可以从 \(dp[i+1][j][0]\)\(dp[i+1][j][1]\) 转移过来,\(dp[i][j][1]\) 可以从 \(dp[i][j-1][0]\)\(dp[i][j-1][1]\) 转移过来,我们只需要在 \(from[i][j][0/1]\) 中存好 \(0/1\) 就知道它具体选的是哪种方案了

最后先扫一遍所有长度为 \(n\) 的区间,找到最优解,设为 \(dp[l][r][f]\),如果 \(f\)\(0\),代表当前点是 \(l\),如果 \(from[l][r][f]\) 也是 \(0\),就说明是从 \(dp[l+1][r][0]\) 且上一个点是 \(l+1\) 转移过来的,其它几种情况可以类似地判断,不断往前找,就能倒着把路径记录下来,再倒序输出即可

参考代码
#include <cstdio>
#include <cmath>
#include <algorithm>
using namespace std;
const int N = 2005;
const double INF = 1e12;
double x[N], y[N], dp[N][N][2];
// from 记录从上一个状态的“左端点”还是“右端点”转移过来
int from[N][N][2], ans[N];
double distance(int i, int j) {
    double dx = x[i] - x[j], dy = y[i] - y[j];
    return sqrt(dx * dx + dy * dy);
}
int main()
{
    int n, k = 1;
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%lf%lf", &x[i], &y[i]);
        x[i + n] = x[i]; y[i + n] = y[i];
        if (y[i] > y[k]) k = i;
    }
    for (int i = 1; i <= 2 * n; i++)
        for (int j = 1; j <= 2 * n; j++)
            dp[i][j][0] = dp[i][j][1] = INF;
    dp[k][k][0] = dp[k][k][1] = 0; 
    dp[k + n][k + n][0] = dp[k + n][k + n][1] = 0; 
    for (int len = 2; len <= n; len++) {
        for (int i = 1; i <= 2 * n - len + 1; i++) {
            int j = i + len - 1;
            // dp[i][j][0]: to i
            double tmp = dp[i + 1][j][0] + distance(i, i + 1); // i+1 -> i
            if (tmp < dp[i][j][0]) {
                dp[i][j][0] = tmp; from[i][j][0] = 0;
            }
            tmp = dp[i + 1][j][1] + distance(i, j); // j -> i
            if (tmp < dp[i][j][0]) {
                dp[i][j][0] = tmp; from[i][j][0] = 1;
            }
            // dp[i][j][1]: to j
            tmp = dp[i][j - 1][0] + distance(i, j); // i -> j
            if (tmp < dp[i][j][1]) {
                dp[i][j][1] = tmp; from[i][j][1] = 0;
            }
            tmp = dp[i][j - 1][1] + distance(j - 1, j); // j-1 -> j
            if (tmp < dp[i][j][1]) {
                dp[i][j][1] = tmp; from[i][j][1] = 1;
            }
        }
    }
    int mini = 0, f = 0;
    double mindis = INF;
    for (int i = 1; i <= n; i++) {
        if (dp[i][i + n - 1][0] < mindis) {
            mindis = dp[i][i + n - 1][0]; mini = i; f = 0;
        }
        if (dp[i][i + n - 1][1] < mindis) {
            mindis = dp[i][i + n - 1][1]; mini = i; f = 1;
        }
    }
    int l = mini, r = mini + n - 1;
    for (int i = 1; i <= n; i++) {
        if (f == 0) {
            ans[i] = l;
            f = from[l][r][0]; l++;
        } else {
            ans[i] = r;
            f = from[l][r][1]; r--;
        }
    }
    for (int i = n; i >= 1; i--) 
        printf("%d%c", ans[i] > n ? ans[i] - n : ans[i], i == 1 ? '\n' : ' ');
    return 0;
}
posted @ 2023-11-10 17:05  RonChen  阅读(698)  评论(0)    收藏  举报