【算法基础篇】(三十三)动态规划之区间 DP:从回文串到石子合并,吃透区间类问题的万能解法


目录

前言

[一、区间 DP 的核心思想与解题框架](#一、区间 DP 的核心思想与解题框架)

[1.1 什么是区间 DP?](#1.1 什么是区间 DP?)

[1.2 区间 DP 的解题四步曲](#1.2 区间 DP 的解题四步曲)

[步骤 1:定义状态dp[i][j]](#步骤 1:定义状态dp[i][j])

[步骤 2:推导状态转移方程](#步骤 2:推导状态转移方程)

[步骤 3:初始化 DP 表](#步骤 3:初始化 DP 表)

[步骤 4:确定填表顺序](#步骤 4:确定填表顺序)

[1.3 区间 DP 的时间复杂度分析](#1.3 区间 DP 的时间复杂度分析)

二、经典例题实战:从理论到代码

[2.1 例题 1:回文字串(洛谷 P1435)------ 基于端点的区间 DP](#2.1 例题 1:回文字串(洛谷 P1435)—— 基于端点的区间 DP)

题目描述

解题分析

[步骤 1:状态定义](#步骤 1:状态定义)

[步骤 2:状态转移方程](#步骤 2:状态转移方程)

[步骤 3:初始化](#步骤 3:初始化)

[步骤 4:填表顺序](#步骤 4:填表顺序)

[C++ 代码实现](#C++ 代码实现)

代码解释

[2.2 例题 2:Treats for the Cows(洛谷 P2858)------ 两端选择的区间 DP](#2.2 例题 2:Treats for the Cows(洛谷 P2858)—— 两端选择的区间 DP)

题目描述

解题分析

[步骤 1:状态定义](#步骤 1:状态定义)

[步骤 2:状态转移方程](#步骤 2:状态转移方程)

[步骤 3:初始化](#步骤 3:初始化)

[步骤 4:填表顺序](#步骤 4:填表顺序)

[C++ 代码实现](#C++ 代码实现)

代码解释

[2.3 例题 3:石子合并(弱化版,洛谷 P1775)------ 基于分割点的区间 DP](#2.3 例题 3:石子合并(弱化版,洛谷 P1775)—— 基于分割点的区间 DP)

题目描述

解题分析

[步骤 1:状态定义](#步骤 1:状态定义)

[步骤 2:状态转移方程](#步骤 2:状态转移方程)

[步骤 3:初始化](#步骤 3:初始化)

[步骤 4:填表顺序](#步骤 4:填表顺序)

[C++ 代码实现](#C++ 代码实现)

代码解释

[2.4 例题 4:248(洛谷 P3146)------ 合并相等元素的区间 DP](#2.4 例题 4:248(洛谷 P3146)—— 合并相等元素的区间 DP)

题目描述

解题分析

[步骤 1:状态定义](#步骤 1:状态定义)

[步骤 2:状态转移方程](#步骤 2:状态转移方程)

[步骤 3:初始化](#步骤 3:初始化)

[步骤 4:填表顺序](#步骤 4:填表顺序)

[C++ 代码实现](#C++ 代码实现)

代码解释

[三、区间 DP 的常见变形与优化技巧](#三、区间 DP 的常见变形与优化技巧)

[3.1 常见变形](#3.1 常见变形)

[1. 环形区间 DP](#1. 环形区间 DP)

[2. 多维约束的区间 DP](#2. 多维约束的区间 DP)

[3. 区间 DP 求方案数](#3. 区间 DP 求方案数)

[3.2 优化技巧](#3.2 优化技巧)

[1. 前缀和 / 后缀和优化](#1. 前缀和 / 后缀和优化)

[2. 状态压缩](#2. 状态压缩)

[3. 四边形不等式优化](#3. 四边形不等式优化)

总结


前言

在动态规划的大家族中,区间 DP 绝对是 "看似复杂,实则有章可循" 的典范。它不像线性 DP 那样按部就班地从左到右递推,也不像背包问题那样围绕 "选或不选" 做文章,而是以 "区间" 为核心,通过拆分大区间、求解小区间,最终拼凑出全局最优解。

如果你曾被 "回文串最少插入次数"、"石子合并最小代价" 这类问题难住,如果你想掌握一种能解决所有区间相关最优解问题的通用思路,那么区间 DP 就是你必须攻克的技能。它的核心思想极其优雅 ------大区间的解依赖于小区间的解,就像搭积木一样,先搭好小块,再用小块拼成大块。

本文将从区间 DP 的基本原理出发,结合 4 个经典例题(回文字串、Treats for the Cows、石子合并、248),手把手教你从状态定义到代码实现的完整流程。下面就让我们正式开始吧!


一、区间 DP 的核心思想与解题框架

1.1 什么是区间 DP?

**区间 DP(Interval Dynamic Programming)**是动态规划的一种特殊形式,其状态通常以区间的左右端点来定义,即dp[i][j]表示区间[i, j]上的最优解(最大 / 最小值、方案数等)。

它的核心逻辑源于分治思想:对于一个长度为len = j - i + 1的大区间[i, j],我们可以通过枚举分割点ki ≤ k < j),将其拆分为两个小区间[i, k][k+1, j],然后利用这两个小区间的最优解,结合当前区间的约束条件,推导出大区间[i, j]的最优解。

形象地说,区间 DP 就像切蛋糕:要吃完一块大蛋糕(大区间),我们可以先把它切成两块小蛋糕(小区间),吃完小蛋糕后,再把结果整合起来(状态转移)。

1.2 区间 DP 的解题四步曲

无论什么区间 DP 问题,都可以遵循以下四个核心步骤,堪称 "万能框架":

步骤 1:定义状态dp[i][j]

这是区间 DP 的灵魂,必须明确**dp[i][j]**表示的具体含义。常见的定义有:

  • dp[i][j]:区间[i, j]变成回文串的最小插入次数(回文字串问题)
  • dp[i][j]:合并区间[i, j]的石子所需的最小代价(石子合并问题)
  • dp[i][j]:取完区间[i, j]的零食能获得的最大收益(Treats for the Cows 问题)

关键原则:状态定义必须能覆盖 "区间" 的核心属性,并且让小区间的解能支撑大区间的推导。

步骤 2:推导状态转移方程

这是区间 DP 的核心难点,需要根据问题的具体约束,分析大区间如何从小区间推导而来。常见的推导方式有两种:

  1. 基于区间端点 :根据区间左右端点ij的关系直接推导(如回文字串问题中s[i]s[j]是否相等)
  2. 基于分割点 :枚举分割点k,将区间[i, j]拆分为[i, k][k+1, j],再整合两者的结果(如石子合并问题)

关键原则:转移方程必须保证 "无后效性",即小区间的解一旦确定,就不会被后续操作修改。

步骤 3:初始化 DP 表

由于区间 DP 是从小区间向大区间推导,需要先初始化长度为 1 的区间(i == j),因为长度为 1 的区间是最小的单位,其解通常是已知的:

  • 回文字串问题:dp[i][i] = 0(单个字符本身就是回文串,无需插入字符)
  • 石子合并问题:dp[i][i] = 0(单堆石子无需合并,代价为 0)
  • 零食问题:dp[i][i] = a[i] * n(最后一天取这包零食,乘数为总天数n

步骤 4:确定填表顺序

区间 DP 的填表顺序非常关键,必须保证在计算dp[i][j]时,所有依赖的小区间都已经计算完成。最常用的填表顺序是:

  1. 按区间长度len从小到大枚举(len从 2 到n
  2. 对于每个长度len,枚举所有可能的左端点i
  3. 计算右端点j = i + len - 1(确保区间长度为len
  4. 填充**dp[i][j]**的值

这种顺序能确保:当计算长度为len的区间时,所有长度小于len的小区间都已经计算完毕,完全符合 "小区间支撑大区间" 的逻辑。

1.3 区间 DP 的时间复杂度分析

假设问题的序列长度为n,则:

  • 枚举区间长度:O(n)len从 1 到n
  • 枚举左端点:O(n)i从 1 到n - len + 1
  • 枚举分割点(如需):O(n)kij-1

因此,区间 DP 的时间复杂度通常为O(n³)。对于n ≤ 300的问题(如石子合并),300³ = 27,000,000,完全在时间限制内;对于n ≤ 1000的问题(如回文字串),1000³ = 1e9,需要优化(但大部分题目会通过约束让n控制在 300 以内)。

二、经典例题实战:从理论到代码

2.1 例题 1:回文字串(洛谷 P1435)------ 基于端点的区间 DP

题目链接:https://www.luogu.com.cn/problem/P1435

题目描述

任意给定一个字符串,通过插入若干字符,将其变成回文词,求最少需要插入的字符数。注意区分大小写,例如Ab3bd需要插入 2 个字符变成回文串。

解题分析

步骤 1:状态定义

dp[i][j]:将字符串区间[i, j]变成回文串所需的最小插入次数。

步骤 2:状态转移方程
  • s[i] == s[j]:此时[i, j]的回文性由[i+1, j-1]决定,插入次数与[i+1, j-1]相同,即dp[i][j] = dp[i+1][j-1]
  • s[i] != s[j]:有两种选择:
    1. i左侧插入s[j],此时需要先将[i, j-1]变成回文串,再插入 1 个字符,代价为dp[i][j-1] + 1
    2. j右侧插入s[i],此时需要先将[i+1, j]变成回文串,再插入 1 个字符,代价为dp[i+1][j] + 1取两种选择的最小值,即dp[i][j] = min(dp[i][j-1], dp[i+1][j]) + 1
步骤 3:初始化

dp[i][i] = 0(单个字符无需插入);对于i > j的非法区间,dp[i][j] = 0(空区间也是回文串)。

dp表如下所示:

步骤 4:填表顺序

按区间长度len从小到大枚举,len从 2 到n,再枚举左端点i,计算右端点j = i + len - 1

C++ 代码实现

cpp 复制代码
#include <iostream>
#include <string>
#include <algorithm>
using namespace std;

const int N = 1010;
int dp[N][N];  // dp[i][j]表示区间[i,j]变成回文串的最小插入次数

int main() {
    string s;
    cin >> s;
    int n = s.size();
    s = " " + s;  // 字符串从1开始索引,方便区间表示
    
    // 初始化:长度为1的区间无需插入
    for (int i = 1; i <= n; ++i) {
        dp[i][i] = 0;
    }
    
    // 按区间长度从小到大枚举
    for (int len = 2; len <= n; ++len) {
        // 枚举左端点i
        for (int i = 1; i + len - 1 <= n; ++i) {
            int j = i + len - 1;  // 计算右端点j
            if (s[i] == s[j]) {
                dp[i][j] = dp[i+1][j-1];
            } else {
                dp[i][j] = min(dp[i][j-1], dp[i+1][j]) + 1;
            }
        }
    }
    
    cout << dp[1][n] << endl;
    return 0;
}

代码解释

  • 字符串预处理:将原字符串前面加一个空格,让索引从 1 开始,这样区间[i, j]的表示更直观,避免出现i=0的边界问题。
  • 填表顺序:严格按照 "长度从小到大" 的顺序,确保计算dp[i][j]时,dp[i+1][j-1]dp[i][j-1]dp[i+1][j]都已计算完成。
  • 时间复杂度:O(n²)(无需枚举分割点,仅枚举长度和端点),对于n=1000的字符串,1e6次运算完全可行。

2.2 例题 2:Treats for the Cows(洛谷 P2858)------ 两端选择的区间 DP

题目链接:https://www.luogu.com.cn/problem/P2858

题目描述

N份零食排成一列,每天可以从两端取一份零食出售。第i份零食的初始价值为V_i,如果在第a天出售,售价为V_i × a。求所有零食售出后的最大收益。

解题分析

步骤 1:状态定义

dp[i][j]:取完区间[i, j]的所有零食能获得的最大收益。

步骤 2:状态转移方程

区间[i, j]的长度为len = j - i + 1,已经取了len份零食,剩余n - len份零食未取,因此当前是第(n - len + 1)天(因为总天数为n)。

取零食有两种选择:

  1. 取左端点i :收益为V[i] × (n - len + 1) + dp[i+1][j](取当前零食的收益 + 取剩余区间[i+1, j]的最大收益)
  2. 取右端点j :收益为V[j] × (n - len + 1) + dp[i][j-1](取当前零食的收益 + 取剩余区间[i, j-1]的最大收益)

取两种选择的最大值,即:dp[i][j] = max(V[i] × cnt + dp[i+1][j], V[j] × cnt + dp[i][j-1]),其中cnt = n - len + 1

步骤 3:初始化

dp[i][i] = V[i] × n(长度为 1 的区间,最后一天取,乘数为n)。

步骤 4:填表顺序

按区间长度len从小到大枚举,len从 2 到n,确保计算dp[i][j]时,dp[i+1][j]dp[i][j-1]已计算完成。

C++ 代码实现

cpp 复制代码
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 2010;
int dp[N][N];  // dp[i][j]表示取完区间[i,j]零食的最大收益
int V[N];      // 零食的初始价值
int n;         // 零食总数

int main() {
    cin >> n;
    for (int i = 1; i <= n; ++i) {
        cin >> V[i];
        dp[i][i] = V[i] * n;  // 初始化:最后一天取这包零食
    }
    
    // 按区间长度从小到大枚举
    for (int len = 2; len <= n; ++len) {
        int cnt = n - len + 1;  // 当前是第几天(取第len包零食)
        for (int i = 1; i + len - 1 <= n; ++i) {
            int j = i + len - 1;  // 右端点
            // 取左端点或右端点,取最大值
            dp[i][j] = max(V[i] * cnt + dp[i+1][j], V[j] * cnt + dp[i][j-1]);
        }
    }
    
    cout << dp[1][n] << endl;
    return 0;
}

代码解释

  • 天数计算:cnt = n - len + 1是关键推导,例如len=1时,cnt=n(最后一天);len=2时,cnt=n-1(倒数第二天),符合 "每天取一份" 的逻辑。
  • 状态转移:无需枚举分割点,只需考虑两端的选择,时间复杂度为O(n²),对于n=20004e6次运算完全可控。
  • 贪心陷阱:本题不能用贪心(每次取两端较小的),例如序列4,1,5,3,贪心会得到 34,而区间 DP 能得到 35,因为贪心会 "鼠目寸光",忽略后续更大的收益。

2.3 例题 3:石子合并(弱化版,洛谷 P1775)------ 基于分割点的区间 DP

题目链接:https://www.luogu.com.cn/problem/P1775

题目描述

N堆石子排成一排,每次只能合并相邻的两堆,合并代价为两堆石子质量之和。求将所有石子合并成一堆的最小代价。

解题分析

步骤 1:状态定义

dp[i][j]:合并区间[i, j]的所有石子所需的最小代价。

步骤 2:状态转移方程

合并区间[i, j]的最后一步,必然是将两个相邻的子区间[i, k][k+1, j]合并(i ≤ k < j)。此时的代价为:

  • 合并[i, k]的代价:dp[i][k]
  • 合并[k+1, j]的代价:dp[k+1][j]
  • 合并这两个子区间的代价:区间[i, j]的总质量(因为合并两堆的代价是它们的质量和)

因此,状态转移方程为:dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum[i][j])

其中**sum[i][j]是区间[i, j]的石子总质量,为了快速计算,我们可以用前缀和数组pre_sum**预处理:sum[i][j] = pre_sum[j] - pre_sum[i-1]

步骤 3:初始化

dp[i][i] = 0(单堆石子无需合并);其他位置初始化为无穷大(0x3f3f3f3f),表示初始状态下无法合并。

步骤 4:填表顺序

按区间长度len从小到大枚举,len从 2 到n,枚举左端点i,计算右端点j,再枚举分割点ki ≤ k < j),更新dp[i][j]

C++ 代码实现

cpp 复制代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;

const int N = 310;
const int INF = 0x3f3f3f3f;
int dp[N][N];      // dp[i][j]表示合并[i,j]石子的最小代价
int pre_sum[N];    // 前缀和数组,pre_sum[i]表示前i堆石子的总质量
int n;             // 石子堆数

int main() {
    cin >> n;
    memset(dp, INF, sizeof(dp));  // 初始化所有状态为无穷大
    
    // 输入石子质量并计算前缀和
    for (int i = 1; i <= n; ++i) {
        int x;
        cin >> x;
        pre_sum[i] = pre_sum[i-1] + x;
        dp[i][i] = 0;  // 单堆石子无需合并
    }
    
    // 按区间长度从小到大枚举
    for (int len = 2; len <= n; ++len) {
        for (int i = 1; i + len - 1 <= n; ++i) {
            int j = i + len - 1;  // 右端点
            int sum = pre_sum[j] - pre_sum[i-1];  // [i,j]的总质量
            // 枚举分割点k
            for (int k = i; k < j; ++k) {
                dp[i][j] = min(dp[i][j], dp[i][k] + dp[k+1][j] + sum);
            }
        }
    }
    
    cout << dp[1][n] << endl;
    return 0;
}

代码解释

  • 前缀和优化:sum[i][j]用前缀和计算,时间复杂度从O(n)降到O(1),避免重复计算区间和。
  • 初始化:dp数组初始化为无穷大,确保未计算的状态不会影响最终结果,只有dp[i][i]设为 0(合法状态)。
  • 时间复杂度:O(n³),对于n=300300³=27e6次运算,完全在时间限制内。
  • 拓展:如果题目是环形石子合并(洛谷 P1880),可以用 "倍增数组" 技巧,将数组长度翻倍(s[i+n] = s[i]),然后在2n长度的数组上做区间 DP,最后取dp[i][i+n-1]i=1~n)的最小值和最大值。

2.4 例题 4:248(洛谷 P3146)------ 合并相等元素的区间 DP

题目链接:https://www.luogu.com.cn/problem/P3146

题目描述

给定一个包含N个正整数的序列,每次可以选择两个相邻且相等的数,将它们替换为一个比原数大 1 的数(例如两个 7 合并为 8)。求最终能生成的最大整数。

解题分析

步骤 1:状态定义

dp[i][j]:将区间[i, j]合并成一个元素后,能得到的最大数值;若无法合并成一个元素,dp[i][j] = 0

步骤 2:状态转移方程

要将[i, j]合并成一个元素,必须存在分割点ki ≤ k < j),使得:

  1. [i, k]能合并成一个元素(dp[i][k] != 0
  2. [k+1, j]能合并成一个元素(dp[k+1][j] != 0
  3. 两个元素相等(dp[i][k] == dp[k+1][j]

此时,[i, j]能合并成dp[i][k] + 1,因此状态转移方程为:dp[i][j] = max(dp[i][j], dp[i][k] + 1)(满足上述三个条件时)

步骤 3:初始化

dp[i][i] = a[i](长度为 1 的区间,合并后就是自身)。

步骤 4:填表顺序

按区间长度len从小到大枚举,len从 2 到n,确保计算dp[i][j]时,所有dp[i][k]dp[k+1][j]都已计算完成。

C++ 代码实现

cpp 复制代码
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;

const int N = 255;
int dp[N][N];  // dp[i][j]表示区间[i,j]合并成一个元素的最大数值(0表示无法合并)
int a[N];      // 原始序列
int n;         // 序列长度
int max_val;   // 记录最终的最大数值

int main() {
    cin >> n;
    memset(dp, 0, sizeof(dp));
    max_val = 0;
    
    // 初始化:长度为1的区间
    for (int i = 1; i <= n; ++i) {
        cin >> a[i];
        dp[i][i] = a[i];
        max_val = max(max_val, a[i]);  // 初始最大数值为单个元素的最大值
    }
    
    // 按区间长度从小到大枚举
    for (int len = 2; len <= n; ++len) {
        for (int i = 1; i + len - 1 <= n; ++i) {
            int j = i + len - 1;  // 右端点
            // 枚举分割点k
            for (int k = i; k < j; ++k) {
                // 只有两个子区间都能合并成一个元素,且数值相等时,才能合并
                if (dp[i][k] != 0 && dp[k+1][j] != 0 && dp[i][k] == dp[k+1][j]) {
                    dp[i][j] = max(dp[i][j], dp[i][k] + 1);
                    max_val = max(max_val, dp[i][j]);  // 更新最大数值
                }
            }
        }
    }
    
    cout << max_val << endl;
    return 0;
}

代码解释

  • 状态含义:dp[i][j] = 0表示该区间无法合并成一个元素,这是本题的关键约束,避免无效合并。
  • 最大数值更新:由于并非所有区间都能合并成一个元素,因此需要用max_val实时记录所有合法dp[i][j]的最大值。

三、区间 DP 的常见变形与优化技巧

3.1 常见变形

1. 环形区间 DP

如环形石子合并,核心技巧是 "倍增数组" ,将环形问题转化为线性问题。例如,将数组s扩展为s[1..2n],其中s[i+n] = s[i],然后在2n长度的数组上做区间 DP,最后取dp[i][i+n-1]i=1~n)的最优解。

2. 多维约束的区间 DP

如区间 DP 结合背包约束,此时状态定义会增加维度,例如dp[i][j][k]表示区间[i,j]在花费k的情况下的最优解,但本质还是遵循 "小区间支撑大区间" 的逻辑。

3. 区间 DP 求方案数

如 "摆花" 问题(洛谷 P1077),状态定义为dp[i][j]表示前i种花摆j盆的方案数,转移方程为dp[i][j] += dp[i-1][j-k]k为第i种花的盆数),本质是区间 DP 与多重背包的结合。

3.2 优化技巧

1. 前缀和 / 后缀和优化

如石子合并问题中,用前缀和快速计算区间和,避免重复计算,降低时间复杂度。

2. 状态压缩

对于某些区间 DP 问题,状态可以压缩维度。例如,当dp[i][j]只依赖于dp[i+1][j]dp[i][j-1]时,可以用一维数组压缩,但大部分区间 DP 问题需要二维状态。

3. 四边形不等式优化

对于满足四边形不等式的区间 DP 问题(如石子合并),可以将时间复杂度从O(n³)优化到O(n²)。核心思想是通过记录最优分割点,减少分割点的枚举范围,但实现较为复杂,适合n较大的场景。


总结

区间 DP 是动态规划中最具规律性的分支之一,它的核心思想 "小区间支撑大区间" 简单而优雅。无论问题多么复杂,只要遵循 "定义状态→推导转移方程→初始化→按长度填表" 的四步曲,就能迎刃而解。

本文通过 4 个经典例题,详细讲解了区间 DP 的解题流程,从基于端点的简单问题到基于分割点的复杂问题,覆盖了区间 DP 的主要应用场景。希望大家能通过本文的学习,彻底掌握区间 DP,在遇到区间类问题时能够游刃有余。

最后,送给大家一句话:动态规划的本质是 "记住过去的答案,避免重复计算",而区间 DP 则是将这句话发挥到了极致 ------ 记住所有小区间的答案,最终拼凑出大区间的最优解。

相关推荐
CoderYanger4 小时前
贪心算法:8.买卖股票的最佳时机
java·算法·leetcode·贪心算法·1024程序员节
lxmyzzs4 小时前
【图像算法 - 40】海洋监测应用:基于 YOLO 与 OpenCV 的高精度海面目标检测系统实现
opencv·算法·yolo·海上目标检测
风筝在晴天搁浅4 小时前
代码随想录 417.太平洋大西洋水流问题
算法
coderxiaohan5 小时前
【C++】无序容器unordered_set和unordered_map的使用
开发语言·c++
Zsy_0510035 小时前
【数据结构】排序
数据结构·算法·排序算法
青山的青衫5 小时前
【二分查找-开区间思维】
算法
Swift社区5 小时前
LeetCode 449 - 序列化和反序列化二叉搜索树
算法·leetcode·职场和发展
charlie1145141915 小时前
深入理解CC++的编译与链接技术9:动态库细节
c语言·开发语言·c++·学习·动态库
isyoungboy5 小时前
c++使用win新api替代DirectShow驱动uvc摄像头,可改c#驱动
开发语言·c++·c#