前言
这篇文章是以介绍对角线填表的滚动数组为主,以区间dp为典型应用并且以USACO3.3.5游戏为引例展开
题目
【问题描述】
有如下一个双人游戏:
N个正整数的序列放在一个游戏平台上,游戏由玩家1开始,两人轮流从序列的两端取数,取数后该数字被去掉并累加到本玩家的得分中,当数取尽时,游戏结束。以最终得分多者为胜。
编一个执行最优策略的程序,最优策略就是使自己能得到在当前情况下最大的可能的总分的策略。你的程序要始终为两位玩家执行最优策略。
【输入格式】
第一行: 正整数N, 表示序列中正整数的个数。
第二行至末尾: 用空格分隔的N个正整数(大小为1-200)。
【输出格式】
只有一行,用空格分隔的两个整数: 依次为玩家一和玩家二最终的得分。
【输入样例】
6
4 7 2 9 5 2
【输出样例】
18 11
【数据范围】
2 <= N <= 100
分析
很典型很简单的区间dp,其中以先手最优值作为一个典型的终止条件,同时后手可以简单的算出
方程:
f(i,j)表示在区间i~j里面先取的那个人可以得到的最高分数
ans: f(1,n) sum-f(1,n)
f(i,i)=a[i]
f(i,j)=max(a[i]+sum(i,j)-f(i+1,j),a[j]+sum(i,j)-f(i,j-1))
代码
#include<cmath>
#include<queue>
#include<cstdio>
#include<cctype>
#include<vector>
#include<cstring>
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn=105;
int n,sum[maxn],a[maxn],d[maxn][maxn];
void Init()
{
scanf("%d",&n);
for(int i=1;i<=n;i++)
{
scanf("%d",&a[i]);
sum[i]=sum[i-1]+a[i];
}
}
void dp()
{
for(int i=1;i<=n;i++)
{
d[i][i]=a[i];
}
for(int k=1;k<n;k++)
{
for(int i=1;i+k<=n;i++)
{
int j=k+i;
int t1=a[i]+(sum[j]-sum[i])-d[i+1][j];
int t2=a[j]+(sum[j-1]-sum[i-1])-d[i][j-1];
d[i][j]=max(t1,t2);
}
}
printf("%d %d\n",d[1][n],sum[n]-d[1][n]);
}
int main()
{
freopen("in.txt","r",stdin);
freopen("out.txt","w",stdout);
Init();
dp();
return 0;
}
关于填表
沿主对角线填表基础填法
来看到这个矩形,代表着我们需要填表的位置,需要填表的位置在图片右上部分,代表区间i<=j,蓝色部分就是我们填表的边界部分,然后按照红->橙->黄的顺序填表,那么如何才能遍历红色部分呢?
利用主对角线的性质,可知同一条对角线上横纵坐标之差是相等的,从0~n-1,因此我们可以列举这个差值,0是边界
因此令j-i=k,则j=k+i,i每次都是从1开始的,因此有代码
for(int k=1;k<n;k++)
{
for(int i=1;i+k<=n;i++)
{
}
}
二维滚动
对于二维的数组很容易卡内存,因此滚动数组成为我们有力的工具,如果单纯的用坐标的形式是基本上无法滚动的,由上图我们看出,如果把矩形逆时针旋转45度,看起来就是可以滚动的形式了
由于是以对角线为基础的滚动,因此我们要修改一下对每个坐标的表示,我就直接结果了,第一位是k,也就是横纵坐标之差,第二位是横纵坐标之和/2,首先,k不同的点都是在同一对角线的,我们来看同一对角线是否会冲突,相邻对角线的两个格子横纵坐标都相差1,那么横纵坐标之和就相差2,那么再/2依旧可以保证对角相邻的两个格子第二维刚好相差1,不会发生冲突,也不会浪费。
再说一点,数组的大小是不用*根号2的,因为对角线上也只有n个元素,应该从曼哈顿距离去考虑
代码
void dp()
{
for(int i=1;i<=n;i++)
{
d[0][i]=a[i];
}
for(int k=1;k<n;k++)
{
for(int i=1;i+k<=n;i++)
{
int j=k+i;
int t1=a[i]+(sum[j]-sum[i])-d[(k-1)&1][(i+j+1)/2];
int t2=a[j]+(sum[j-1]-sum[i-1])-d[(k-1)&1][(i+j-1)/2];
d[k&1][(i+j)/2]=max(t1,t2);
}
}
printf("%d %d\n",d[(n-1)&1][(1+n)/2],sum[n]-d[(n-1)&1][(1+n)/2]);
}
一维滚动
二维滚动都可以感觉以为一位滚动差不多是吧,其实不然。
这里和传统的二维填表最大的不同在于这是个金字塔状的填表,如果用二维滚动的编码方式,因为单双数的原因会造成预先占用(一个格子的值还没有被使用完就被占用了,造成答案错误),那么如何避免这个问题呢,应该可以用宏定义讨论一下,但是我不会哎,其它的方法呢,不要除以2呗,同时我们可能会发现一个很微妙的事实,两排相邻对角线之间的横纵坐标之和一个是奇数一个是偶数,那么就刚刚好避免冲突,从左上往右下填表是对的,从右下往左上也是对的。
性能分析及注意:数组大小要开两倍,空间上相比二维滚动没有减少,但是对横纵坐标差k的判断被除去了,第二维不用/2,减少了一些运算,而且代码比较好写,清爽,因此只要数组开够了是个很好的方法
代码
int d[maxn<<1];
void dp()
{
for(int i=1;i<=n;i++)
{
d[i+i]=sum[i]-sum[i-1];
}
for(int k=1;k<n;k++)
{
for(int i=1;i+k<=n;i++)
{
int j=k+i;
int t1=sum[j]-sum[i-1]-d[i+j+1];
int t2=sum[j]-sum[i-1]-d[i+j-1];
d[i+j]=max(t1,t2);
}
}
printf("%d %d\n",d[1+n],sum[n]-d[1+n]);
}
再谈编码
关于横纵坐标编码
有时候对于一个矩阵我们只需要将横纵坐标的值表示出来即可,我们本来是很耿直的用(x,y),现在我们将它改为(x+y,x-y),可以解出唯一解,因此这可编码是很合理的,(/2的话因为计算机省略小数的特性出现了冲突),我们来分析一下空间,横坐标:-n~n,纵坐标:0~2n因此用了4n^2的空间表示了n^2直接表示的空间。
原因:
1、和差奇偶性相同,当横坐标为奇数时,纵坐标为偶数的部分全部浪费掉,因此浪费了一半,
2、这是个被反转的,把新矩形看成菱形的话,只有中间的矩形部分被运用到了,比如x-y=n-1,则x和y只能是n和1,又浪费了一半
如下图,只有蓝色部分是才是真正被用到的部分(和被用到的部分可以刚好一一对应),知道为什么浪费了这么多了吧(真正的图要把菱形往右平移,左边的那个角和正方形中间靠左的那个边的终点在同一点上,其实这样挺好看的
(其实是我不想画了)
)
话说回来用(x+y)/2将会减少一半的浪费,可是我真的不会这样编码哎
实际利用:
1、n*n矩阵实际上只用了一半,用以上编码可以少定义一半(y-x始终非负)
2、这是滚动数组哎,优化的起止这么点?
谈谈编码问题(不对应多个元素、减少浪费)
对于不同的元素进行区分,我们一般要用到编码,最好最常用的编码是按计数的方法一一对应,不会对应多个元素也不会浪费。
可是有时候我们为了对一些共同特点的元素进行统一处理,我们要按照特征进行重新编码,不对应多个元素是基本要求(hash这种东西除外啦),不浪费最好,比如这道题我们利用的就是对横纵坐标差进行的编码,为了满足不重复对应多个元素,我们用x+y作为另一维得到唯一解,当然我觉得应该是有更优秀的编码的,毕竟这种浪费比较大。