算法竞赛之方格取数系列--跨越20年的经典题型

方格取数是个老题,生命力极强,最早出现在2000年noip,20年后的2020年CSP普及组又考了,本文围绕方格取数的三种变化,讲解一些基本解题方法,包括暴力搜索,记忆化搜索,动态规划等。

一、基本型

最简单的,简单到算法竞赛的题库都不屑收录,leetcode有此题:

64. 最小路径和

给定一个包含非负整数的 m*n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。
说明:每次只能向下或者向右移动一步。
题目简化以后就是从左上角到右下角找一条路径,路径上的数和最小。

1、先来暴力搜

考察右下角,只能从它上面或者左面过来,哪条路的和比较小,就选哪条,其它格子也都一样。

#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

class Solution {
public:
    int minPathSumUtil(vector<vector<int> >& grid, int x, int y){
        if (x < 0 || y < 0 || x >= grid.size() || y >= grid[0].size()){
        //超出范围了,返回一个大数,min的时候肯定不会选择它。
            return 0x7fffffff;
        }

        if (x == 0 && y == 0){
            return grid[0][0];
        }
        return min(minPathSumUtil(grid, x, y - 1), minPathSumUtil(grid, x - 1, y)) + grid[x][y];
    }
    int minPathSum(vector<vector<int> >& grid) {
        return minPathSumUtil(grid, grid.size() - 1, grid[0].size() - 1);
    }
};
2、记忆化搜索

上面代码会超时,因为很多格子重复计算了,比如一个中间的格子,从下面来也算一次,从右面来也算一次。加速办法是如果第一次算好就存起来,下次直接用。如下代码可通过leetcode:

class Solution {
    int f[200][200];
public:
    int minPathSumUtil(vector<vector<int> >& grid, int x, int y){
        if (x < 0 || y < 0 || x >= grid.size() || y >= grid[0].size()){
            return 0x7fffffff;
        }

        if (f[x][y] == -1){
             f[x][y] = min(minPathSumUtil(grid, x, y - 1), minPathSumUtil(grid, x - 1, y)) + grid[x][y];
        }
        return f[x][y];
    }
    int minPathSum(vector<vector<int> >& grid) {
        memset(f, -1, sizeof(f));
        f[0][0] = grid[0][0];
        return minPathSumUtil(grid, grid.size() - 1, grid[0].size() - 1);
    }
};
3、简单动态规划(dp)

一般来说记忆话搜索和简单动态规划可以相互转化,此题应用初级动态规划,设计状态为f(i,j),表示从左上角到坐标为(i,j)的格子最小路径的和,显然f(m,n)就是最终结果。状态转移方程:
f(i,j) = min(f(i-1, j), f(i, j-1)) + grid(i,j)
f(i-1,j)是它上面格子的状态,f(i,j-1)是左边格子的状态,一个格子只能从上或者左转移过来。f(i,j)同时具备子问题最优解和无后效性。

#include <cstdio>
#include <vector>
#include <cstring>
#include <algorithm>
using namespace std;

class Solution {
    int f[200][200];
public:
    int minPathSum(vector<vector<int> >& grid) {
        f[0][0] = grid[0][0];
        //先填充第0行,不能从上面来,只能从左边转移过来
        for(int j = 1; j < grid[0].size(); j++){
            f[0][j] = f[0][j-1] + grid[0][j];
        }
        //再填充第一列,不能从左边来,只能从上边来
        for(int i = 1; i < grid.size(); i++){
            f[i][0] = f[i-1][0] + grid[i][0];
        }
        for(int i = 1; i < grid.size(); i++){
            for(int j = 1; j < grid[0].size(); j++){
                f[i][j] = min(f[i-1][j], f[i][j-1]) + grid[i][j];
            }
        }
        return f[grid.size()-1][grid[0].size()-1];
    }
};

此处可以优化一下空间,变二维数组为一维数组,也叫滚动数组,因为f(i,j)只和f(i-1,j),f(i,j-1)有关,也就是只和它左边或者上面1行有关,上面1行的上面存多了也没什么用。

class Solution {
    int f[200];
public:
    int minPathSum(vector<vector<int> >& grid) {
        f[0] = grid[0][0];
        //先填充第0行,不能从上面来,只能从左边转移过来
        for(int j = 1; j < grid[0].size(); j++){
            f[j] = f[j-1] + grid[0][j];
        }
        for(int i = 1; i < grid.size(); i++){
            f[0] += grid[i][0];
            for(int j = 1; j < grid[0].size(); j++){
                f[j] = min(f[j-1], f[j]) + grid[i][j];
            }
        }
        return f[grid[0].size()-1];
    }
};

二、双人型

基本型太简单了,考试肯定不考,需要增加难度:不是求一条路径,而是两条,数被一条路径取走以后就变成0,另一条路径就不能取了。题目是NOIP2000提高组真题,详见洛谷p1004
两个人跑路比一个人难多了,上面说的方法统统失效,搜索不会写,记忆化也无从谈起,简单dp找不出状态。既然简单dp不行,那就来个复杂dp。还是状态转移,本题状态比原来更复杂一些,但还是能转移的,用一个思维数组表示状态:
f(i,j,k,l)表示第一条路径走到(i,j),第二条路径走到(k,l)时,取得数的和最大值。(i,j)只能从(i-1,j)和(i,j-1)转移而来,(k,l)同理,所以f(i,j,k,l)只能从以下4种状态转移而来:
1、f(i-1,j,k-1,l)
2、f(i-1,j,k,l-1)
3、f(i,j-1,k-1,l)
4、f(i,j-1,k,l-1)
转移方程:等一下,既然知道怎么转移了,直接撸代码吧,老写转移方程干什么?近代西方数学极速发展的一个重要原因,就是因为引入了统一符号。方程写出来,非常有助于人的思考。做动态规划题,必须严格写出转移方程,才能真正提高算法水平,否则做了也是白做,遇到新题还是两眼一抹黑。
言归正传,写方程:
f(i,j,k,l) = max(f(i-1,j,k-1,l), f(i-1,j,k,l-1), f(i,j-1,k-1,l), f(i,j-1,k,l-1)) + a(i,j) + a(k,l)
注意边界条件,ij和kl相同时,加一次即可,因为两条路径公共部分的数只能被一条路径取走。

#include <cstdio>
#include <algorithm>
#define MAX 9
using namespace std;

int n, g[MAX+1][MAX+1], f[MAX+1][MAX+1][MAX+1][MAX+1];
int main(){
    scanf("%d", &n);
    int x, y, v;
    do{
        scanf("%d%d%d", &x, &y, &v);
        g[x][y] = v;
    } while(x != 0 || y != 0 || v != 0);
    f[1][1][1][1] = g[1][1];
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= n; j++){
            for(int k = 1; k <= i + j - 1; k++){
                int l = i + j - k;
                f[i][j][k][l] = max(f[i-1][j][k-1][l], max(f[i-1][j][k][l-1], max(f[i][j-1][k-1][l], f[i][j-1][k][l-1])));
                f[i][j][k][l] += (i == k) ? g[i][j] : g[i][j] + g[k][l];
            }
        }
    }
    printf("%d\n", f[n][n][n][n]);
    return 0;
}

注意上面k和l的取值,两条路径同步进行,i+j总是等于k+l,并且k取1到i+j-1,超过这个范围没有意义。

三、新瓶旧酒三方向

双人型方格取数沉寂了整整20年后,2020年csp普及组又考方格取数,这次是一条路径,但行进方向由原来的下和右变成左,下,右。详见洛谷p7074
先粗略估计一下,最优解大概是一种蛇形路线,先下来,再上去,再下来,再上去......有了上面介绍的两种方格取数的经验,三方向就有据可循了。先利用基本型的经验,坚定此题可用dp解决的信心;再利用双人型的经验和启发,确定状态。基本型只两种来源方向,左和上,比较一下取最大值就能得到最终结果并保存起来;现在多了一个来源方向,下,根据双人型的经验,把状态数组再加一维,变成f(i,j,k),用第三维k记录来源方向,考虑转移方程:
f(i,j,上) = max(f(i-1,j,上), f(i-1,j,左)) + a(i,j)
f(i,j,左) = max(f(i,j-1,左), f(i,j-1,上), f(i,j-1,下)) + a(i,j)
f(i,j,下) = max(f(i+1,j,左), f(i+1,j,下)) + a(i,j)
考察第一行,除了第一个格子,都不可能从上面来,从上面来的值是负无穷;从左面来和左面的有关,一时间无法确定;从下面来也不好确定。
考察第一列,除了第一个元素,都不可能从左面来,从左面来的值是负无穷;从上面来的结果肯定可以根据转移方程计算,因为它上面的格子的上,左都可确定;从下面来的结果也是负无穷,因为是第一列,不可能从下面来。此时第一列的三种状态全部确定,从第二列开始,每一列的每个方格都从左边的列转移而来。
考察第二列第一个方格,上面来确定是负无穷,左面可转移而来;再考察第二列所有方格,和第一个方格类似,从上往下填充,上面可转移而来;左面可转移而来,因为它左面第一列的三种状态全部确定;
考察第二列最后一行元素,从左面来可确定,从下面来可确定负无穷,再考察第二列从下往上填充,下面也可转移而来,此时,第二列所有3种状态全部填充完毕。以后3~m列依此类推,最后max(f(n,m,左),f(n,m,上))就是答案。

#include <cstdio>
#include <algorithm>
#include <climits>
#define MAX 1000
typedef long long ll;
using namespace std;

int g[MAX+1][MAX+1];
ll f[MAX+1][MAX+1][3];
int n, m;
int main(){
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= n; i++){
        for(int j = 1; j <= m; j++){
            scanf("%d", &g[i][j]);
            f[i][j][0] = f[i][j][1] = f[i][j][2] = LLONG_MIN;
        }
    }
    //0表示从上面来,1从下面来,2从左面来
    //先初始化第一个格子,让他能从上面,左面来
    f[1][1][0] = f[1][1][2] = g[1][1];
    //初始化第一列从上面来的值,从下面和从左面都不可能,都是负无穷
    for(int i = 2; i <= n; i++){
        f[i][1][0] = f[i-1][1][0] + g[i][1];
    }
    //此时第一列三种状态全部初始化完毕,后面每列都右它左边的列转移而来
    for(int j = 2; j <= m; j++){
        for(int i = 1; i <= n; i++){
            //转移从左边来的值
            f[i][j][2] = max(max(f[i][j-1][0], f[i][j-1][1]), f[i][j-1][2]) + g[i][j];
            if(i > 1){//下面填充从上面来的,第一行肯定是负无穷,跳过
                f[i][j][0] = max(f[i-1][j][0], f[i-1][j][2]) + g[i][j];
            }
        }
        //下面从下往上递推转移从下边来的值
        for(int i = n-1; i >= 1; i--){
            f[i][j][1] = max(f[i+1][j][1], f[i+1][j][2]) + g[i][j];
        }
    }
    printf("%lld\n", max(f[n][m][0], f[n][m][2]));
    return 0;
}

如代码所示,对于每一列,从上到下推一遍,得到上和左,再从下到上推一遍,得到下。三种状态全部确定后,推下一列。
最后,动态规划最关键的就是设计状态,写出转移方程,看到转移方程可以启发出递推方法,在编码的时候看着转移方程编也不会出错,必须掌握这种技术。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容