(一)区间动态规划简介
用算法解决问题时经常采用“化简”的方式,将一个大问题拆分成若干个独立的子问题。如果一个问题最终被分解成为两个连续的部分(一个序列被分割为两个子段),然后再合并,那么几乎可以确定是区间规划问题。当然,分析过程才是算法的难点。
区间动态规划用于求区间上的最优值,对于整个区间的最优值来说,通过枚举区间左右两部分的分割点(合并点),将问题分解成为左右两部分的子问题,最后将左右两个子问题的最优值进行合并计算得到原问题的最优值。
(二)区间动规的算法实现(OJ19182)
石子合并是区间动态规划的模板题。
设有 N(N≤300) 堆石子排成一排,其编号为1,2,3,⋯,N。每堆石子有一定的质量 mi(mi≤1000)。 现在要将这N堆石子合并成为一堆。每次只能合并相邻的两堆,合并的代价为这两堆石子的质量之和,合并后与这两堆石子相邻的石子将和新堆相邻。 合并时由于选择的顺序不同,合并的总代价也不相同。试找出一种合理的方法,使总的代价最小,并输出最小代价。
发挥递归的思想(还记得文章“动态规划一”中介绍的动规算法四步分析吗?)。N个石子归并代价如果定义为f(1,N),那么在最后一次合并前,一定已经合并为2堆,这两堆的分割当然有很多种可能性。比如左边1个石子单独一堆,右边N-1个一堆。那么问题就转换为f(1,N)=f(1,1)+f(2,n)。当然,也可能是f(1,N)=f(1,2)+f(3,n),或者f(1,N)=f(1,3)+f(4,n)...........
这样我们就得到解决问题的递归算法公式: sum函数计算1至N石子的和(合并代价)。
递归属于逆向思考,用正向方式定义dp[i][j]为归并区间[i,j]的最小代价,那么状态转移方程:
题目的最优解为dp[1][N],如果求石子合并最大代价同理。
#include <iostream> #include<cstring>
using namespace std;
int n,a[305],sum[305],dp[305][305]= {0}; /**< dp[i][j]是区间[i,j]的最小代价 */
int main()
{ ios::sync_with_stdio(0),cin.tie(0);
int i,j,k,m,n,p;
cin>>n;
for(i=1; i<=n; i++) /**< sum为前缀和数组 */
cin>>a[i],sum[i]=sum[i-1]+a[i];
for(p=2; p<=n; p++)/**< p枚举区间长度 */
{
for(i=1; i<=n-k+1; i++)
{ /**< 求dp[i][j]最小值 */
j=i+p-1; /**< 区间[i,j]长度为p */
dp[i][j]=1e9;/**< 初始化大值 */
for(k=i; k<j; k++)
dp[i][j]= min(dp[i][j], dp[i][k]+dp[k+1][j]+sum[j]-sum[i-1]);
}
}
cout<<dp[1][n];
return 0;}
(三)区间动规的一些经典例题
(1)11078 不能移动的石子合并
有n堆石子A1,A2,...,An形成首位相连的一个环形,An和A1相邻,每堆石头个数记为ai(1<=i<=n),相邻两堆可合并,合并的分值为新堆的石子数。求合并为一堆的最低得分和最高得分。
关于环形数据的处理,常用的作法是双倍存储,也就是说如果原数据存储在a[1].....a[n],那么让a[n+1]=a[1],a[n+2]=a[2].......a[2*n]=a[n]。这样环的n-1种拆分(把环变成线性)都可以在这个2n的数组中体现出来。如果在1和n进行拆分,那么问题就和传统的石子合并一样,即dp[1][n],如果在1和2之间拆分,那么答案就是dp[2][n+1],同理,枚举2n数组中所有长度为n的区间的最优值,必然能找到环的最优解。
由于OJ11078代码较长,此处用洛谷P1063 能量项链的代码进行演示:
#include <stdio.h>
#include <algorithm>
using namespace std;
int a[205],dp[205][205];
int main()
{
int n,ans = 0;;
scanf("%d",&n);
for(int i = 1;i <= n;i++){
scanf("%d",a+i);
a[i+n] = a[i]; /**< 双倍造环 */
}
for(int p = 2;p <= n;p++){
for(int i = 1,j = p;i <= 2*n-1 && j <= 2*n+1;i++,j++){ /**< dp[i][j]为长度p的区间 */
for(int k = i;k < j;k++){
dp[i][j] = max(dp[i][j],dp[i][k]+dp[k+1][j]+a[i]*a[k+1]*a[j+1]);
}
}
}
for(int i = 1;i <= n;i++) /**< 枚举所有长度为n的区间 */
ans = max(ans,dp[i][n+i-1]);
printf("%d\n",ans);
return 0; }
(2)洛谷P4170涂色
分析一下,按题目给定的涂色方式,如果想涂色次数尽可能的少,只能采用先外后内的方式,例如颜色RGGR,显然要先涂区间[1,4]为R,然后[2,3]为G。如果是RGRGR,可以先涂[1,5]R,然后[2,4]G,然后[3,3]R。另一种涂法是[1,5]R,[2,2]G,[4,4]G。最少需要3次涂色。
题目并没有明确表现出两两合并的特性,但进一步分析如果想要完成区间[i,j]的涂色,那么就只有两类可能,(1)a[i]==a[j],此时可以先处理区间[i,j-1],因为涂色是涂两端,在涂a[i]时可以顺路涂好a[j],当然也可能是先处理[i+1,j]。(2)a[i]!=a[j],因此无法用一次涂色处理a[i]和a[j]两个点,那么可以先对a[i]涂色,然后对[i+1,j]涂色,或者是对a[j]涂色,然后对[i,j-1]涂色。问题是如果用这种分析法样例中AABB答案会是4。实际上应该设置断点,此时问题转换为明显区间合并问题。a[i]和a[j]不一样,他们涂色一定是独立的,那么可以先对[i,k]涂色,再对[k+1,j]涂色。枚举这个k,答案可得。
#include <bits/stdc++.h>
using namespace std;
int main()
{
int i,j,k,n,p,dp[105][105];
char a[105];
memset(dp,127/3,sizeof dp);/**< dp数组初始化大值 */
a[0]='\0';
scanf("%s",a+1);
n=strlen(a+1);
for(i=1;i<=n;i++)/**< 区间长度1的值特殊处理 */
dp[i][i]=1;
for(p=2;p<=n;p++)
{
for(i=1,j=i+p-1;j<=n;i++,j++)
{
if(a[i]==a[j])/**< 可以在涂i或j时不消耗涂色次数 */
dp[i][j]=min(dp[i+1][j],dp[i][j-1]);
else
{
for(k=i;k<j;k++)/**< 枚举分割点 */
dp[i][j]=min(dp[i][j],dp[i][k]+dp[k+1][j]);
}
}
}
cout<<dp[1][n];
return 0;}
(3)力扣877 石子游戏
leetcode的题目就不提供代码了,仅做分析。
博弈论的问题同样可以用递归思想去考虑每一步选择后的变化。本题目由于只能拿两端石子,那么A玩家拿走左边第一个石子时,问题就变成[2,N]的问题,B玩家是先手,A玩家变成后手。如果设定f(1,N)为先手玩家的最优值,那么只有两种可能:
第一种:f(1,N)= a[1] +( sum[2,N]-f(2,N)) A先手拿了a[1]石子,那么后手的B对于剩下的[2,N]区间来说是先手,B拿剩下的石子属于A玩家。
第二种:(1,N)= a[N] +( sum[1,N-1]-f(1,N-1)) A先手拿了a[N]石子,那么后手的B对于剩下的[1,N-1]区间来说是先手,同样B拿剩下的属于A玩家。
如此一来问题仍然是区间最大值问题,此题目先手不一定必胜。
dp[i][j]的公式本文不再给出,由读者们自行推导。