Day2 区间DP 学生讲义_预习

Day2 区间DP 学生讲义

今日目标:掌握区间 DP 的核心思想与套路,能独立识别区间 DP 题型,写出状态转移方程并 AC


一、什么是区间 DP?

1.1 用一张表来算合并石子

有 4 堆石子:[2, 5, 3, 1]。我们用 dp[l][r] 表示把第 l 堆到第 r 堆合并成一堆的最小代价

先算最简单的情况——相邻两堆直接合并(长度=2):

  • dp[1][2] = 合并 2 和 5 = 2+5 = 7
  • dp[2][3] = 合并 5 和 3 = 5+3 = 8
  • dp[3][4] = 合并 3 和 1 = 3+1 = 4

对角线(长度=1)都是 0(一堆不需要合并):

dp[l][r] 1 2 3 4
1 0 7 ? ?
2 0 8 ?
3 0 4
4 0

现在算长度=3 的区间 [1,3](石子 2,5,3)。最后一次合并,中间一定有个分割点 k,把区间切成两块:

  • 切在 k=1:[1] + [2,3]dp[1][1] + dp[2][3] + 总和(2+5+3=10) = 0 + 8 + 10 = 18
  • 切在 k=2:[1,2] + [3]dp[1][2] + dp[3][3] + 总和(10) = 7 + 0 + 10 = 17

取最小,dp[1][3] = 17。同理算 dp[2][4](石子 5,3,1):

  • 总和 = 5+3+1 = 9
  • k=2: 0 + dp[3][4] + 9 = 0 + 4 + 9 = 13
  • k=3: dp[2][3] + 0 + 9 = 8 + 0 + 9 = 17
  • dp[2][4] = 13

填表更新:

dp[l][r] 1 2 3 4
1 0 7 17 ?
2 0 8 13
3 0 4
4 0

最后算总长度 [1,4](全部4堆,总和=11):

  • k=1: [1] + [2,4] → 0 + 13 + 11 = 24
  • k=2: [1,2] + [3,4] → 7 + 4 + 11 = 22 ← 最小!
  • k=3: [1,3] + [4] → 17 + 0 + 11 = 28

dp[1][4] = 22,这就是最小总代价(先合3和1得4,再合2和5得7,最后合7和4得11,总 4+7+11=22)。


1.2 发现规律

每次算 dp[l][r],都要把区间在某点 k 切成 [l,k][k+1,r],加上这段的总和:

\(dp[l][r] = min( dp[l][k] + dp[k+1][r] ) + sum(l 到 r)\)

重要发现:算 dp[1][4] 时,我们用到了 dp[1][1]dp[2][4]dp[1][2] 等——这些都是长度更短的区间!所以必须先算小区间,再算大区间

这就引出了区间 DP 的标准套路:外层按长度从小到大枚举


1.3 标准代码模板

// n堆石子,前缀和数组 s[i] 方便快速求 sum(l,r) = s[r]-s[l-1]
for (int i = 1; i <= n; i++) dp[i][i] = 0;   // 长度为1代价为0

// 外层:枚举区间长度(从2开始)
for (int len = 2; len <= n; len++) {
    // 中层:枚举左端点 l
    for (int l = 1; l + len - 1 <= n; l++) {
        int r = l + len - 1;           // 算出右端点
        dp[l][r] = 很大的数;            // 初始化
        int total = s[r] - s[l-1];     // 区间总和
        // 内层:枚举分割点 k
        for (int k = l; k < r; k++) {
            dp[l][r] = min(dp[l][r], dp[l][k] + dp[k+1][r] + total);
        }
    }
}
cout << dp[1][n];   // 全部合并的最小代价

为什么外层必须是长度?

因为 dp[l][r] 依赖的两个子区间 [l,k][k+1,r] 长度都比 len 小。只有从小到大枚举长度,才能保证算大区间时,小区间都已经算好了。如果乱序枚举,可能用到还没算出来的 0/无穷大,结果就错了。


1.4 三要素总结

要素 含义 例子(石子合并)
状态 dp[l][r] 表示什么 区间 [l,r] 合并成一堆的最小代价
转移 怎么切分 枚举分割点 k,左右相加再加区间总和
顺序 先小后大 外层枚举长度 len,从小到大

二、讲解例题


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

一句话题意

N 堆石子排成一排,每次只能合并相邻两堆,代价为两堆质量之和。求合并为一堆的最小总代价\(N \le 300\),每堆质量 \(\le 1000\)

思考过程

引入:这是区间 DP 的"Hello World",几乎所有区间 DP 的入门题。

核心洞察:考虑最后一次合并——无论前面怎么合并,最后一步一定是将"左边一堆"和"右边一堆"合并成最终的一堆。左边那堆是由 [l, k] 合并而来的,右边那堆是由 [k+1, r] 合并而来的。

状态定义dp[l][r] = 将第 l 到 r 堆石子合并为一堆的最小代价

转移方程

\(dp[l][r] = min(dp[l][k] + dp[k+1][r]) + sum(l, r)\)

其中 k 是分割点(\(l \le k < r\)),sum(l, r) 是区间 [l, r] 所有石子的总质量(即最后一次合并的代价)。

如何快速求 sum(l, r)前缀和\(sum(l, r) = pre[r] - pre[l-1]\),O(1) 查询。

初始化

  • \(dp[i][i] = 0\)(只有一堆,不需要合并,代价为 0)
  • 其他 \(dp[l][r] = +\infty\)(因为求最小值)

枚举顺序:按区间长度从小到大,确保算大区间时小区间已算完。

答案dp[1][n]

完整代码

#include <bits/stdc++.h>
using namespace std;

const int N = 305;
const int INF = 1e9;
int m[N], pre[N];       // m[i] 第 i 堆质量,pre[i] 前缀和
int dp[N][N];          // dp[l][r] 合并 l~r 的最小代价

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> m[i];
        pre[i] = pre[i - 1] + m[i];  // 前缀和
    }

    // 初始化:求最小值,先设为正无穷
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = INF;

    // 长度为 1 的区间,不需要合并,代价为 0
    for (int i = 1; i <= n; i++)
        dp[i][i] = 0;

    // 枚举区间长度(从小到大)
    for (int len = 2; len <= n; len++) {
        // 枚举左端点
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;  // 右端点
            // 枚举分割点
            for (int k = l; k < r; k++) {
                int cost = pre[r] - pre[l - 1];  // 区间总质量(前缀和 O(1))
                dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r] + cost);
            }
        }
    }

    cout << dp[1][n] << endl;
    return 0;
}

时间复杂度:O(n³),n ≤ 300 → 约 2700 万次运算,完全可行。


例题 2:P1880 [NOI1995] 石子合并

一句话题意

N 堆石子围成一圈,每次只能合并相邻两堆,代价为两堆石子数之和。求合并成一堆的最小得分和最大得分\(N \le 100\),每堆石子数 \(\le 20\)

思考过程

引入:这道题和例题 1 几乎一样,唯一的区别是——石子围成了一个环

问题:环意味着第 1 堆和第 N 堆也是相邻的,但我们的 dp[l][r] 只能处理线性的区间,怎么办?

破环成链:这是处理环形问题的经典技巧。把环从某处"剪开",变成一条长度为 N 的链。但环可以从任意位置开始合并,所以我们需要枚举起点

巧妙做法:将原来的数组复制一份接在后面,得到长度为 2N 的数组。这样,任何一个长度为 N 的连续区间就对应了环上从某个起点开始的 N 堆石子。

例如原数组 [4, 5, 9, 4],复制后变成 [4, 5, 9, 4, 4, 5, 9, 4]

  • [4,5,9,4](起点0):原封不动
  • [5,9,4,4](起点1):从第2堆开始绕一圈
  • [9,4,4,5](起点2):从第3堆开始绕一圈
  • [4,4,5,9](起点3):从第4堆开始绕一圈

状态定义

  • f[l][r] = 合并区间 [l, r]最小得分
  • g[l][r] = 合并区间 [l, r]最大得分

转移方程(和例题 1 完全一样):

f[l][r] = min(f[l][k] + f[k+1][r]) + sum(l, r)   // 求最小
g[l][r] = max(g[l][k] + g[k+1][r]) + sum(l, r)   // 求最大

答案

  • 最小得分 = \(\min(f[i][i+N-1])\),其中 \(1 \le i \le N\)
  • 最大得分 = \(\max(g[i][i+N-1])\),其中 \(1 \le i \le N\)

完整代码

#include <bits/stdc++.h>
using namespace std;

const int N = 210;  // 2倍大小
const int INF = 1e9;
int a[N], pre[N];          // a[i] 石子数,pre[i] 前缀和
int f[N][N], g[N][N];    // f 最小,g 最大

int main() {
    ios::sync_with_stdio(0);
    cin.tie(0);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> a[i];
        a[i + n] = a[i];  // 复制一份,破环成链
    }

    // 前缀和(长度为 2n)
    for (int i = 1; i <= 2 * n; i++)
        pre[i] = pre[i - 1] + a[i];

    // 初始化
    for (int i = 1; i <= 2 * n; i++) {
        for (int j = 1; j <= 2 * n; j++) {
            f[i][j] = INF;
            g[i][j] = -INF;
        }
        f[i][i] = g[i][i] = 0;  // 单堆不需要合并
    }

    // 区间 DP(长度只枚举到 n,因为我们只关心长度为 n 的区间)
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= 2 * n; l++) {
            int r = l + len - 1;
            for (int k = l; k < r; k++) {
                int cost = pre[r] - pre[l - 1];
                f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + cost);
                g[l][r] = max(g[l][r], g[l][k] + g[k + 1][r] + cost);
            }
        }
    }

    // 枚举所有起点,找最优
    int minAns = INF, maxAns = -INF;
    for (int i = 1; i <= n; i++) {
        minAns = min(minAns, f[i][i + n - 1]);
        maxAns = max(maxAns, g[i][i + n - 1]);
    }

    cout << minAns << endl;
    cout << maxAns << endl;
    return 0;
}

核心技巧:环形 → 复制数组 → 长度 2N 的链 → 枚举所有长度为 N 的区间。


例题 3:P1063 能量项链

一句话题意

N 颗珠子围成一圈,每颗珠子有两个值(头标记 h 和尾标记 t)。相邻两颗珠子可以合并,得分 = h₁ × t₁ × t₂(其中 t₁ = h₂,因为相邻珠子首尾相连)。合并后新珠子的头 = h₁,尾 = t₂。求释放的最大总能量\(N \le 100\),标记值 \(\le 100\)

思考过程

引入:这道题和例题 2 非常相似——也是环形结构,也是合并相邻两个,也是求最大得分。区别在于合并的得分计算方式不同

举例:珠子 A(1, 2)、B(2, 3)、C(3, 1) 排成一圈。

  • 先合并 A 和 B:得分 = 1×2×3 = 6,新珠子 A'(1, 3)
  • 再合并 A' 和 C:得分 = 1×3×1 = 3,新珠子 A''(1, 1)
  • 总分 = 6 + 3 = 9

破环成链:和例题 2 一样,将珠子数组复制一份。如果有 N 颗珠子,我们需要合并 N-1 次才能变成一颗。所以区间长度枚举到 N。

状态定义dp[l][r] = 将区间 [l, r] 的珠子合并为一颗的最大能量

转移方程:枚举分割点 k,将 [l, r] 分成 [l, k][k+1, r]

\(dp[l][r] = max(dp[l][r], dp[l][k] + dp[k+1][r] + h[l] * t[k] * t[r])\)
其中 h[l] 是区间最左珠子的头标记,t[k] 是左半部分最后一颗珠子的尾标记,t[r] 是右半部分最后一颗珠子的尾标记。

关键:合并 [l,k] 后,这颗大珠子的尾标记就是原来第 k 颗珠子的尾标记。合并 [k+1,r] 后,这颗大珠子的头标记就是原来第 k+1 颗珠子的头标记 = 第 k 颗珠子的尾标记。所以最后一步合并的得分 = \(h[l] \times t[k] \times t[r]\)

初始化\(dp[i][i] = 0\)(一颗珠子无法合并,能量为 0)

答案max(dp[i][i+N-1]),其中 \(1 \le i \le N\)

完整代码

#include <bits/stdc++.h>
using namespace std;

const int N = 205;  // 2倍大小
int h[N], t[N];     // h[i] 头标记,t[i] 尾标记
int dp[N][N];      // dp[l][r] 合并 l~r 的最大能量

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    int n;
    cin >> n;
    for (int i = 1; i <= n; i++) {
        cin >> h[i];
        h[i + n] = h[i];  // 复制
    }

    // 构造头尾标记
    // 第 i 颗珠子的头 = h[i],尾 = h[i+1](因为相邻珠子首尾相连)
    for (int i = 1; i <= 2 * n; i++) {
        t[i] = h[i + 1];
    }

    // 初始化
    for (int i = 1; i <= 2 * n; i++)
        for (int j = 1; j <= 2 * n; j++)
            dp[i][j] = 0;

    // 区间 DP,长度从 2 到 n(合并 n-1 次)
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= 2 * n; l++) {
            int r = l + len - 1;
            for (int k = l; k < r; k++) {
                dp[l][r] = max(dp[l][r],
                    dp[l][k] + dp[k + 1][r] + h[l] * t[k] * t[r]);
            }
        }
    }

    // 找最大值
    int ans = 0;
    for (int i = 1; i <= n; i++)
        ans = max(ans, dp[i][i + n - 1]);

    cout << ans << endl;
    return 0;
}

对比 P1880:同样是环形合并,P1880 的合并代价是区间和,P1063 的合并代价是头×尾×尾。状态定义和枚举方式完全一样,只是 cost 的计算不同。


例题 4:P1435 [IOI2000] 回文字串

一句话题意

给定一个字符串,每次可以在任意位置插入一个字符,求使其变成回文串的最少插入次数。区分大小写。\(|S| \le 1000\)

思考过程

引入:回文串的特点是"正着读和反着读一样"。比如 "Ab3bd" 不是回文,但插入 2 个字符可以变成 "Adb3bdA" 或 "A3bdb3A" 等回文串。

区间 DP 视角:考虑区间 [l, r] 对应的子串,怎样用最少插入变成回文?

状态定义dp[l][r] = 将子串 s[l..r] 变成回文串的最少插入次数

转移方程:看两端字符 s[l]s[r] 是否相等。

  1. s[l] == s[r]:两端已经对称,不需要为这两端插入任何字符。问题缩小为 s[l+1..r-1]
    \(dp[l][r] = dp[l+1][r-1]\)

  2. s[l] != s[r]:两端不对称,需要在某一端插入一个字符来匹配另一端:

    • 在右端插入 s[l] → 左端匹配了,问题变成 s[l+1..r],操作数 +1
    • 在左端插入 s[r] → 右端匹配了,问题变成 s[l..r-1],操作数 +1
      \(dp[l][r] = min(dp[l+1][r], dp[l][r-1]) + 1\)

初始化

  • \(dp[i][i] = 0\)(单个字符本身就是回文,不需要插入)
  • 其他 \(dp[l][r] = +\infty\)

枚举顺序:从小到大枚举长度(转移用到的是更短区间)。

答案dp[1][n]

完整代码

#include <bits/stdc++.h>
using namespace std;

const int N = 1010;
char s[N];
int dp[N][N];   // dp[l][r] 将 s[l..r] 变成回文的最少插入次数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s + 1);                 // 下标从 1 开始
    int n = strlen(s + 1);

    // 初始化:所有区间设为一个很大的数
    memset(dp, 0x3f, sizeof(dp));

    // 长度为 1 的子串已经是回文
    for (int i = 1; i <= n; i++)
        dp[i][i] = 0;

    // 枚举区间长度
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            if (s[l] == s[r]) {
                // 两端相等:内部区间如果是空或已处理,直接继承内部结果
                if (l + 1 <= r - 1)
                    dp[l][r] = dp[l + 1][r - 1];
                else
                    dp[l][r] = 0;   // 区间长度 ≤ 2 且两端相等,已是回文
            } else {
                // 两端不等:在左端或右端插入一个字符
                dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]) + 1;
            }
        }
    }

    cout << dp[1][n] << endl;
    return 0;
}

另一种思路:最长回文子序列(LPS)

核心洞察:要在原串 S 中插入最少的字符使之成为回文串,等价于保留原串中最长的回文子序列,然后在缺失的位置补上对应的字符。因此:

最少插入次数 = 原串长度 - 最长回文子序列的长度

例如 S = "Ab3bd",最长回文子序列可以是 "b3b"(长度3),那么需要插入 \(5-3=2\) 个字符。

如何求最长回文子序列(LPS)?

同样使用区间DP:

  • 状态定义lps[l][r] = 子串 s[l..r] 中最长回文子序列的长度。
  • 转移
    • s[l] == s[r]lps[l][r] = lps[l+1][r-1] + 2
    • 否则:lps[l][r] = max(lps[l+1][r], lps[l][r-1])
  • 初始化lps[i][i] = 1(单个字符是长度为1的回文子序列)
  • 答案n - lps[1][n]

代码实现

#include <bits/stdc++.h>
using namespace std;

const int N = 1005;
char s[N];
int lps[N][N];  // 最长回文子序列长度

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s + 1);
    int n = strlen(s + 1);

    // 初始化:长度为1的子串
    for (int i = 1; i <= n; i++)
        lps[i][i] = 1;

    // 枚举区间长度
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;
            if (s[l] == s[r]) {
                // 两端字符相等,可以同时加入回文子序列
                lps[l][r] = lps[l + 1][r - 1] + 2;
            } else {
                // 两端不等,取去掉左端或右端的较大者
                lps[l][r] = max(lps[l + 1][r], lps[l][r - 1]);
            }
        }
    }

    int ans = n - lps[1][n];
    cout << ans << endl;
    return 0;
}

两种思路对比

  • 第一种(直接求最少插入次数)更符合直觉,转移时 dp[l][r] = min(dp[l+1][r], dp[l][r-1]) + 1
  • 第二种(LPS)思维转换后代码更简洁,本质上是同一个问题的两种视角,时间复杂度均为 O(n²)。

例题 5:P4170 [CQOI2007] 涂色

一句话题意

给定一个目标字符串(仅大写字母),每次可以将连续一段涂成同一种颜色,后涂的颜色覆盖先涂的。求涂成目标字符串的最少涂色次数\(|S| \le 50\)

思考过程

引入:想象你有一块空白木板,每次可以选一段涂成某种颜色。比如目标串 "RGBGR",你可以:先涂全段为 'R'(RRRRR),再涂中间三段为 'G'(RGGGR),再涂第 3 段为 'B'(RGBGR),共 3 次。

状态定义dp[l][r] = 将区间 [l, r] 涂成目标颜色(即 s[l..r])的最少涂色次数

转移方程

  1. s[l] == s[r]:两端颜色相同。第一次涂色时可以把整个区间涂成这个颜色,然后只需要处理内部 s[l+1..r-1] 的涂色。等价于:

\(dp[l][r] = min(dp[l+1][r], dp[l][r-1])\)

为什么?因为第一次涂色覆盖 [l, r] 时,两端同时被涂好了。也可以先涂 [l, r-1](此时 s[r] 顺便被涂对了),再处理内部。取两种情况的最小值。

  1. s[l] != s[r]:两端颜色不同,最后一次涂色不可能同时覆盖两端。必须找一个分割点 k,分成两段分别涂:

\(dp[l][r] = min(dp[l][k] + dp[k+1][r]),其中 l ≤ k < r\)

初始化

  • \(dp[i][i] = 1\)(单个字符涂 1 次即可)
  • 其他 \(dp[l][r] = +\infty\)

答案dp[1][n]

完整代码

#include <bits/stdc++.h>
using namespace std;

const int N = 55;
char s[N];
int dp[N][N];  // dp[l][r] 将 l~r 涂成目标颜色的最少次数

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    cin >> (s + 1);
    int n = strlen(s + 1);

    // 初始化
    for (int i = 1; i <= n; i++)
        for (int j = 1; j <= n; j++)
            dp[i][j] = 1e9;

    // 单个字符涂 1 次
    for (int i = 1; i <= n; i++)
        dp[i][i] = 1;

    // 枚举区间长度
    for (int len = 2; len <= n; len++) {
        for (int l = 1; l + len - 1 <= n; l++) {
            int r = l + len - 1;

            if (s[l] == s[r]) {
                // 两端颜色相同:第一次涂色可以一起涂
                dp[l][r] = min(dp[l + 1][r], dp[l][r - 1]);
            }

            // 无论两端是否相同,都可以尝试分割
            for (int k = l; k < r; k++) {
                dp[l][r] = min(dp[l][r], dp[l][k] + dp[k + 1][r]);
            }
        }
    }

    cout << dp[1][n] << endl;
    return 0;
}

和石子合并的区别:石子合并的分割转移是"必须分割",涂色的分割转移是"可以选择分割"。而且涂色多了一个 s[l]==s[r] 的特殊优化。

posted @ 2026-08-10 21:01  jyt0217  阅读(14)  评论(0)    收藏  举报