我今天刚刚刷了一道很水很水的题,这是一道动态规划。尽管这题很水,我还是在研究这道题上面花费了很长时间,因为我觉得这道题可以作为分析“递推方式”的一个范例。我用了三种方法解决了这道水题,希望看博客的同学们能有所启发。
这是我在“CodeVS”上刷到的一道水题,叫做“1048 石子归并”,是一道经典的区间DP水题。大家先看一下这道题:
题目描述 Description
有n堆石子排成一列,每堆石子有一个重量w[i], 每次合并可以合并相邻的两堆石子,一次合并的代价为两堆石子的重量和w[i]+w[i+1]。问安排怎样的合并顺序,能够使得总合并代价达到最小。输入描述 Input Description
第一行一个整数n(n<=100)第二行n个整数w1,w2…wn (wi <= 100)
输出描述 Output Description
一个整数表示最小合并代价样例输入 Sample Input
44 1 1 4
样例输出 Sample Output
18
我们用f[i][j]表示合并把区间[i,j]合并所需要的最小代价,用sum(i,j)表示闭区间[i,j]中所有石子对的重量和,那么则有f[i][j]=min{f[i][k]+f[k+1][j]+sum(i,j)|k∈[i,j-1],i < j}(特别地,f[i][i]=0 表示一堆石子不需要合并)。这些分析都是非常容易的,但是如何建立递推却不是那么容易的(想一想,为什么。)
sum(i,j)是非常好求的,因为这是一个简单的“静态区间和”,可以用pre数组O(n)地记录一下前缀和,pre[i]表示区间[1,i]的和,因此sum(i,j)=pre[j]-pre[i-1],pre[i]=pre[i-1]+w[i](pre[0]=0)。
我在题解中看到一种这样的题解(方法是别人的,代码是我写的):
#include
#include
using namespace std;
#define INF (2147483647)
int w[101];//表示每堆石子的重量
int pre[101];//表示石子重量的前缀和
int f[101][101];//递推数组
int main()
{
int n;cin>>n;//输入石头的总堆数
for(int i=1;i<=n;i++)
{
cin>>w[i];//输入石子重量
pre[i]=pre[i-1]+w[i];//计算前缀和
}
for(int i=1;i<=n;i++)
for(int j=i-1;j>=1;j--)//注意看这里,看这个三层循环的循环方式
{
f[j][i]=INF;
for(int k=j;k1][i]+pre[i]-pre[j-1]);
}//这是一种神奇的递推方式
cout<1][n]<"pause");
return 0;
}
(说实话,最开始用这个方法的时候,我也感到十分的懵β。)
我们在计算f[i][j]的时候调用到了f[i][k]和f[k+1][j]的内容,为了保证递推结果的正确,必须设计一种循环顺序(顺推或逆推)使得在计算f[i][j]之前它的所有前缀以及它的所有后缀都已经经过了计算。保证前缀,可以采用顺推“右边界”的方法,这样在左边界相同的情况下就能满足条件。保证后缀,可以采用逆推左边界的方法,这样在右边界相同的情况下,后缀会先得到计算。伪代码就是这样的:
For j=1 to n
For i=j-1 downto 1
f[i][j] = INF
For k=i to j-1
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+sum(i,j))
(注意:上文C++代码中用i表示的是“右边界”,j表示的是“左边界”,与伪代码中不同。)
这是一种非常考验算法思想的递推方式,希望同学们完全理解之后再继续往下读。
记忆化搜索就是一种用来替代递推的好方法。在记忆化搜索中,我们不必花费很长的时间去思考递推的方法。
回到一个非常本质的问题上:为什么我们要使用递推。我们之所以要使用递推,就是为了代替递归。在递归的过程中,同一个数据被计算了无数次,而递推就是保证每一个数据只被计算一次然后就储存起来,下一次需要的时候就直接调用而非重新计算。
其实我们可以利用另一种方法对“递归”进行优化,如果既能使用递归,又能对算出的数据加以记录,那真是极好的。因此我们可以定义一个visit数组,visit[i][j]表示f[i][j]是否被计算过。当我们需要f[i][j]的值的时候,判断我们之前是否计算过f[i][j]。如果计算过则直接返回f[i][j],否则把visit[i][j]赋成1,然后像“递归”一样计算出f[i][j]的值然后再返回。这种带有记忆功能的“递归”就叫做“记忆化搜索”。这种方法在这个问题中最大的优势就是同递推一样,保证了每一个f[i][j]值只被计算一次,时间复杂度和递推是完全相同的(就是常数略大一点,因为里面有许多递归调用的函数)。
记忆化搜索在这道题中没有什么时间上的优势,但是在一些问题中,它会比递推快得多。递推会求出全部状态对应的结果,但是“记忆化搜索”只会去计算我们需要的状态(像是一个牛β的剪枝)。
请看伪代码:
Sovef(int i,int j)
if visit[i][j] == 0
visit[i][j] = 1
f[i][j]=INF
For k=i to j-1
f[i][j]=min(f[i][j],f[i][k]+f[k+1][j]+sum(i,j))
return f[i][j]
请看代码:
#include
#include
#include
using namespace std;
#define INF (2147483647)
int pre[101];
int f[101][101];
int Sovef(int i,int j)
{
if(f[i][j]==-1)
if(i==j)
f[i][j]=0;
else{
f[i][j]=INF;
for(int k=i;k1,j)+pre[j]-pre[i-1]);
}
return f[i][j];
}
int main()
{
memset(f,255,sizeof(f));
int n;cin>>n;
for(int i=1;i<=n;i++)
{
int w;cin>>w;
pre[i]=pre[i-1]+w;
}
cout<1,n)<return 0;
}
“记忆化搜索”不失为一种“选择恐惧症”患者的不错选择(这样就不用纠结于递推循环的顺序了)。
第一种方法中,我们能够保证在计算一个区间的时候它的所有前缀和所有后缀已经经过了计算。仔细思考之后,其实这么思考问题真的是太死板了。为何不考虑按照区间的长度来确定循环的方法呢?不管是[i,j]的前缀还是[i,j]的后缀,因为它们是[i,j]本身的一部分,所以它们的长度一定要比[i,j]要短。如果我们能按照区间的长度,从短到长地去计算每一个每一个区间的f值,先求出所有长度为2的区间,再去求长度为3、为4、为5…一直到长度为n的区间,就可以完美地解决这个问题。而因为以每一个位置作为开始的一个固定长度的字符串只可能被我计算到一次,所以时间复杂度不会发生改变。
伪代码是这样的:
For k=2 to n
For i=1 to n-k+1
f[i][i+k-1]=INF
For j=1 to i+k-2
f[i][i+k-1]=min(f[i][i+k-1],f[i][j]+f[j+1][i+k-1]+sum(i,i+k-1))
代码就是这样的:
#include
using namespace std;
int pre[101];
int f[101][101];
int main()
{
int n;cin>>n;
for(int i=1;i<=n;i++)
{
int w;cin>>w;
pre[i]=pre[i-1]+w;
}
for(int k=2;k<=n;k++)
for(int i=1;i+k-1<=n;i++)
{
f[i][i+k-1]=2147483647;
for(int j=i;j1;j++)
f[i][i+k-1]=min(f[i][i+k-1],f[i][j]+f[j+1][i+k-1]+pre[i+k-1]-pre[i-1]);
}
cout<1][n]<return 0;
}
相比于第一种方法这种方法更加易于理解,而且还是递推思想的体现,也是在这道题中我推荐同学们使用的方法。
总管上面三种解决这道问题的方法,你是否感觉有所启示呢?一个问题会有多种方法解决,“此乃自然之理”。而各种方法也是各有优劣的,希望同学们自己细细品味。而最重要的就是,写代码的时候要有一种“宏观”的思想,更要有一种“洒脱”。不要让思维被问题所局限,陷入思维定式,这样才能找到更好的解决问题的方法。
【2017.12.24】七个月后的今天,我得知这道题可以通过“平行四边形优化”成 O(n2) ,代码如下:
#include
#include
#include
#include
using namespace std;
const int maxn=3000+10;
int w[maxn],pre[maxn],f[maxn][maxn],p[maxn][maxn];
int geti(){
int ans=0,flag=0;char c=getchar();
while(!isdigit(c)){flag|=c=='-';c=getchar();}
while( isdigit(c)){ans=ans*10+c-'0';c=getchar();}
return flag?-ans:ans;
}
inline int puti(int x){
if(x<0)x=-x,putchar('-');
if(x>9)puti(x/10); putchar(x%10+'0');
}
int main(){
int n=geti();
for(int i=1;i<=n;i++)w[i]=geti(),pre[i]=pre[i-1]+w[i];
for(int i=1;i<=n;i++){
f[i][i]=0;p[i][i]=i;
}
for(int len=1;len//枚举区间长度
for(int i=1;i+len<=n;i++){
int end=i+len,tmp=0x7f7f7f7f,k;
for(int j=p[i][end-1];j<=p[i+1][end];j++){//根据凸四边形不等式和决策单调性可知
if(f[i][j]+f[j+1][end]+pre[end]-pre[i-1]1][end]+pre[end]-pre[i-1];
k=j;
}
}
f[i][end]=tmp;p[i][end]=k;
}
}
printf("%d\n",f[1][n]);
return 0;
}
题目见:
codevs 3002 石子归并3
今天听说这道题还可以使用一些神奇的贪心算法,可以使时间复杂度变成 O(nlogn) ,比如GarsiaWachs算法和圆方贪心。