【算法】动态规划 - 数字三角形模型

目录

经典例题:数字三角形

​解法一:从上往下推导状态转移方程

解法二:从下往上推导状态转移方程

题型特点

用集合角度分析dp问题概述

状态表示概述

状态计算概述

集合划分的原则:

场景例题:摘花生

状态表示

状态计算

场景例题扩展:最短通行证

场景例题扩展:方格取数

错误思路

四维状态表示

四维状态计算

四维代码

三维状态表示

三维状态计算

场景例题扩展:传纸条

解法一:无需证明强行解

解法一代码

解法二:证明的思路

解法二代码


经典例题:数字三角形

本题是该章节之后所有题型的基础,不管后续如何扩展,都是基于本题基础上进行的扩展

题目如图所示:

解法一:从上往下推导状态转移方程

首先我们考虑状态推导应该从哪层开始,假设我们以三角形的第一层为起点,走到最下层,那么我们要求的就是坐标为(x,y)的点是由上层的哪一个点推导出来的。

实际上,我们根据输入样例可以很明显的看出,要走到(x,y)点,只能由(x-1,y)和(x-1,y-1)走到,根据题意我们要求的就是max(f(x-1,y) , f(x-1,y-1))

如下代码:

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;
const int N = 510;
int matrix[N][N];
int f[N][N];
int main()
{
    int n;
    cin >> n;
 // 数组下标从1开始 , 若从0开始求状态时则会出现越界问题
    for(int i = 1 ; i <= n ; ++i)
    {
        for(int j = 1 ; j <= i ; ++j)
        {
            cin >> matrix[i][j];
        }
    }
    
 // 当我们求(x,y)的值时,(x-1,y)和(x-1,y-1)必须已经被算出来了
 // 所以应该从上往下计算,但从左到右或者从右到左则没要求
    
 //我们求(2,1)这个点时,他会依赖(1,0)这个点的状态,但这不是一个有效点,必须保证没有取到这个点
 //求状态时使用的是max,那么我们可以让无效点值为-1e9(<-10000都可以),那么就一定取不到无效点
    memset(f,-0x3f,sizeof(f));
    f[0][0] = 0; // f[0][0] = 0 , f[0][1] = -1e9 ,那么f[1][1]一定为matrix[1][1];
    //同样的,当我们求f[2][1]时,f[1][0] = -1e9, f[1][1] = matrix[1][1] > -10000 ,选取的一定是有效点
    for(int i = 1 ; i <= n ; ++i)
    {
        for(int j = 1 ; j <= i ; ++j)
        {
            f[i][j] = max(f[i-1][j] , f[i-1][j-1]) + matrix[i][j];
        }
    }
 // 推导完以后对最后一层的每一个点都表示从第一层到该点的最大值,对最后一层取一个max就行
    int res = -1e9;
    for(int i = 1 ; i <= n ; ++i)
    {
        res = max(res , f[n][i]);
    }
    cout << res << endl;
    return 0;
}

解法二:从下往上推导状态转移方程

如果我们从最后一层走到第一层,那么我们考虑(x,y)点的状态由且仅由(x+1,y)和(x+1,y+1)来决定,而我们要求的值是最大值,那么f(x,y) = max(f(x+1,y) , f(x+1,y+1)) + matrix(x,y)

需要注意的是,如果我们是从上往下求,那么我们第一层和第一列可以是0,即x = 0 , y = 0,不会出现越界问题,只要保证第n层和第0列是存在的即可,无效点的初始化必须为0,因为我们要保证第n层推出来的值必须是输入矩阵原本的值

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;
const int N = 510;
int matrix[N][N];
int f[N][N];

int main()
{
    int n;
    cin >> n;
    // 可以从(0,0)开始
    for(int i = 0 ; i < n ; ++i)
    {
        for(int j = 0 ; j <= i ; ++j)
        {
            cin >> matrix[i][j];
        }
    }
    // 推导(x,y)的值时,(x+1,y)和(x+1,y+1)必须已经推导出来了,所以应该从下往上推导
    // 从左往右和从右往左则没讲究
    for(int i = n - 1 ; i >= 0 ; --i)
    {
        for(int j = i ; j >= 0 ; --j)
        {
            f[i][j] = max(f[i+1][j] , f[i+1][j+1]) + matrix[i][j];
        }
    }
    
    cout << f[0][0] << endl;
    return 0;
}

题型特点

对于数字三角形模型题目的统一特点是比较好区分出来的,我们再推导(x,y)点的路径问题的时候,可以根据有限个其他点推导出第(x,y)点的值,那么这类题型我们可以归类为数字三角形模型,而接下来我们要看的就是该模型在带题目背景的一类题中的应用

用集合角度分析dp问题概述

对于本题,我们可以考虑"用集合角度分析dp问题"的方法

实际上,对于我们见过的大多数dp问题来说,他们的本质都是暴搜。但为什么用dp会比暴搜快那么多呢?dp强在哪?

首先,对于dp来说,它是把很多种情况全部归为一个数字,比如说在数字三角形中,我们把f(x,y)归类为所有到达该点(x,y)的路径中的最大值。在数学上表示的就是一个集合。

而对于暴搜来说,他需要一个一个枚举所有的情况,即每一次搜索只能排除一种情况。那么显然他搜索的范围会成指数级增长,以数字三角形为例,假如我们要用暴搜,我们需要枚举2^x种情况(x为层数)

理解了上述这点,我们来看看用集合角度分析dp问题是如何做的:

首先该方法在大方向分为两步,第一步是状态表示,第二步是状态计算

状态表示概述

状态表示:状态表示分为两部分,第一是集合,第二是属性。在路径问题中,集合表示的是到某个点的所有路径,它包含了非常多种情况。而属性表示的是我们要求的是什么,即从集合中选取一条唯一路径的依据就是属性。属性通常有三种情况:最大值/最小值/数量

比如在数字三角形解法一中,f(x,y)的集合表示的是从(1,1)到达(x,y)的所有路径。属性是最大值

状态计算概述

我们拿到一个dp问题,并求出了他的状态表示,但此时的集合还是非常大的(f(x,y)包含了多种情况,我们无法一下求出),那么我们此时采用的方法就是分而治之,将一个大的集合划分为多个小的集合,划分到我们可以拿到一个公式,求出当前f(x,y)的状态表示为止

那么我们划分集合的依据是什么呢?怎么划分呢?

划分依据:根据"最后一步"来进行划分,还是以数字三角形为例,当我们求f(x,y)这个集合的时候,看看怎么样才能走到这个点,即最后一步是怎么走的。很显然,解法一中是从(x-1,y)和(x-1,y-1)走过来的,那么我们划分集合时,就能把集合f(x,y)划分为集合f(x-1,y)和f(x-1,y-1),如下图

我们遇到的dp问题,基本上百分之80都是以最后一步来划分集合的!

通过上面的集合图,我们就能很明显的看出,f(x,y) = max(f(x-1,y) , f(x-1,y-1)) + matrix(x,y)

集合划分的原则:

原则一:不漏,集合包含的情况必须涵盖所有可能的情况,这一原则是必须要满足的

原则二:不重复,这个原则并不一定要满足,当我们的属性为数量时,这一原则必须要满足,但当我们的属性是最大值或最小值时这一属性可以不满足。

举个例子:

集合A = {1,2,3,4,5} , 集合B = {4,5,6,7,8}

我们求这两个集合的最大值,并不会因为他们同时有4和5,最大值和最小值就会发生变化

场景例题:摘花生

题目如图所示

状态表示

假设左上角的点坐标是(1,1),那么集合表示的是:从(1,1)点开始走,走到(x,y)点的所有路径

以(2,3)点为例,他的集合包含如下路径:

(1,1)->(1,2)->(1,3)->(2,3)

(1,1)->(2,1)->(2,2)->(2,3)

(1,1)->(1,2)->(2,2)->(2,3)

而本题的属性表示的是路径的最大值,该属性使得我们唯一确定了上述三条路径中的一条

结果则是f(r,c),f(r,c)表示的是从(1,1)点开始走,走到(r,c)点的所有路径中的最大值,恰好就是我们要求的解

状态计算

同样的,状态计算我们从"最后一步"开始考虑

要到达(x,y)点,那么我们只能从上往下(x-1,y)或者从左往右走(x,y-1)

那么我们就能将集合f(x,y)划分为集合f(x-1,y)和集合f(x,y-1),同理也能计算出集合f(x-1,y)和集合f(x,y-1)划分后的集合。于是我们可以很容易得出f(x,y) = max(f(x-1,y) , f(x,y-1)) + matrix(x,y)

从哪个点开始呢?一般我考虑这个问题主要是根据状态计算公式来的,我们要确保不需要特殊处理的情况下,数组不要越界了。在本题中我们要求x -1 >= 0 且 y-1 >= 0 , 所以我们可以从(1,1)号点作为起点

初始化问题:初始化的原则就是不要在状态推导的时候选取到一些无效的点,比如我们以(1,1)起点开始,那么(1,0)和(0,1)都是无效点,推导的时候一定不能选到这些点

又由于本题的点的值一定是大于等于0的,我们求的max,所以不需要特殊处理无效点,也一定不选取到该点,除非推导时有效点 = 无效点 = 0,但这种情况也不影响

状态计算的顺序:由于我们计算f(x,y)时需要用到f(x-1,y)和f(x,y-1),那么这两个集合一定要先计算好,那么状态计算的顺序只能是从上到下,从左到右。

如下代码:

cpp 复制代码
#include <iostream>
using namespace std;
const int N = 110;
int matrix[N][N];
int f[N][N];

int main()
{
    int T;
    cin >> T;
    while(T--)
    {
        int r , c;
        cin >> r >> c;
        for(int i = 1 ; i <= r ; ++i)
        {
            for(int j = 1 ; j <= c ; ++j)
            {
                cin >> matrix[i][j];
                f[i][j] = max(f[i-1][j] , f[i][j-1]) + matrix[i][j];
            }
        }
        cout << f[r][c] << endl;
    }
    return 0;
}

场景例题扩展:最短通行证

题目如图所示:

对于算法题来说,我们需要把题干的有效信息进行翻译,可以看到题目中有一句话:商人必须在 (2N−1) 个单位时间穿越出去。

由于我们只能上下左右来走,那么我们应该是能想到曼哈顿距离的,即从左上角到右下角的那个点的最短距离是(N-1) + (N-1) = 2N - 2,同时我们要从最后一个点再走出去,那么还需要多一步,即2N-1,而这已经是两点之间的最短距离了,所以对于题干的这个信息可以翻译为:商人只能向右或向下走,不能回头!翻译完以后会发现本题几乎和摘花生一摸一样,不再过多赘述了!

初始化:由于是求最小值,所以我们需要对数组进行初始化,以免选到一些无效点,我们可以把无效点设置为正无穷即可,同时,需要把(1,0)或(0,1)点设置为0,才能进行后续推导

如下代码:

cpp 复制代码
#include <iostream>
using namespace std;
const int N = 110;
int matrix[N][N];
int f[N][N];
int main()
{
    int T;
    cin >> T;
    while(T--)
    {
        int r , c;
        cin >> r >> c;
        // f(x,y) = max(f(x-1,y) , f(x,y-1)) + matrix[x][y]
        for(int i = 1 ; i <= r ; ++i)
        {
            for(int j = 1 ; j <= c ; ++j)
            {
                cin >> matrix[i][j];
            }
        }
        
        for(int i = 1 ; i <= r ; ++i)
        {
            for(int j = 1 ; j <= c ; ++j)
            {
                f[i][j] = max(f[i-1][j] , f[i][j-1]) + matrix[i][j];
            }
        }
        cout << f[r][c] << endl;
    }
    return 0;
}

场景例题扩展:方格取数

题目如图所示:

错误思路

对于这题,我打算介绍一个错误思路,因为我犯了qaq!

在刚拿到本题时,我的思路是首先对原矩阵进行摘花生类似的最大值,并记录dp的路径,当第一次dp完的时候沿着路径把值清0,然后进行第二次dp求出最大值,把两次的f[n][n]结果取和。

这实际上是一种贪心的思路,从局部最优解扩展到全局最优解,但本题这种贪心是不成立的!因为尽管你第一次取的是局部最优的最大值,但这条路径却影响了第二次走的结果,可能导致你第二次走时的值变得很小。但实际上或许存在一条路径,第一条路径不是最大的,但第二条路径足够大,使得结果最大!

四维状态表示

回顾一下摘花生的状态表示:从(1,1)点开始走,走到(x,y)点的所有路径的最大值

本题我们需要两条路径同时dp,那么我们可以进行如下扩展:

集合:从(1,1)点开始走,走到(x1,y1)和(x2,y2)的所有路径

属性:最大值

那么完整的状态表示f[x1][y1][x2][y2]就是:从(1,1)点开始走,走到(x1,y1)和(x2,y2)的所有路径的最大值

四维状态计算

对于状态计算,我们还是从最后一步开始考虑,我们可以分别考虑两条路径的最后一步,把状态表示的集合划分为如下四个部分:

第一条路径从上往下到达(x1,y1)点,第二条路径从上往下到达(x2,y2)点,即f[x1-1][y1][x2-1][y2]

第一条路径从上往下到达(x1,y1)点,第二条路径从左往右到达(x2,y2)点,即f[x1-1][y1][x2][y2-1]

第一条路径从左往右到达(x1,y1)点,第二条路径从上往下到达(x2,y2)点,即f[x1][y1-1][x2-1][y2]

第一条路径从左往右到达(x1,y1)点,第二条路径从左往右到达(x2,y2)点,即f[x1][y1-1][x2][y2-1]

我们对上面四个集合取max,并加上(x1,y1)和(x2,y2)的值后即可(注意:当这两点重合时只加一次)

四维代码

cpp 复制代码
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 15;
int matrix[N][N];
int f[N][N][N][N];
int main()
{
    int n;
    cin >> n;
    int x , y , c;
    while(cin>> x >> y >> c , x || y || c)
    {
        matrix[x][y] = c;
    }
    
    for(int x1 = 1 ; x1 <= n ; ++x1)
        for(int y1 = 1 ; y1 <= n ; ++ y1)
            for(int x2 = 1 ; x2 <= n ; ++ x2)
                for(int y2 = 1 ; y2 <= n ; ++y2)
                {
                    int w = matrix[x1][y1];
                    if(x1 != x2 || y1 != y2) w += matrix[x2][y2];
                    int& val = f[x1][y1][x2][y2];
                    val = max(val , f[x1-1][y1][x2-1][y2]);
                    val = max(val , f[x1-1][y1][x2][y2-1]);
                    val = max(val , f[x1][y1-1][x2-1][y2]);
                    val = max(val , f[x1][y1-1][x2][y2-1]);
                    val += w;
                }
    cout << f[n][n][n][n] << endl;
    return 0;
}

上面这种四维的方法,虽然理解很直观,但时间复杂度已经来到了O(n^4),如果数据量到达50那么就无法用了,有没有什么优化的方案呢?

三维状态表示

首先读题,我们发现题目中规定了小朋友只能往下或者往右走,那么他们每走一步要么x+1要么y+1,又由于终点和起点是确定的,那么两条路径如何走,走到终点的步数一定是曼哈顿距离。我们令两条路径是同时走的,那么就会有x1+y1 = x2+y2 = k,那么我们的四维状态表示就可以用三个变量来表示,如下(注意k表示的是走到(x1,y1)和(x2,y2)的横纵坐标之和):

集合:f[k][x1][x2] = 从(1,1)点开始走,走到(x1,k-x1) 和 (x2,k-x2)的所有路径

属性:最大值

那么完整的状态表示f[k][x1][x2]就是:从(1,1)点开始走, 走到(x1,k-x1) 和 (x2,k-x2)的所有路径的最大值

三维状态计算

实际上,三维和四维的状态计算分析方式简直一摸一样,不同的只是公式表达而已

我们将集合f[k][x1][x2]划分为四种情况,如下:

第一条路径从上往下到达(x1,y1)点,第二条路径从上往下到达(x2,y2)点,即f[k-1][x1-1][x2-1]

第一条路径从上往下到达(x1,y1)点,第二条路径从左往右到达(x2,y2)点,即f[k-1][x1-1][x2]

第一条路径从左往右到达(x1,y1)点,第二条路径从上往下到达(x2,y2)点,即f[k-1][x1][x2-1]

第一条路径从左往右到达(x1,y1)点,第二条路径从左往右到达(x2,y2)点,即f[k-1][x1][x2]

我们对上面四个集合取max,并加上(x1,y1)和(x2,y2)的值后即可(注意:当这两点重合时只加一次)

cpp 复制代码
#include <iostream>
using namespace std;
const int N = 15;
int matrix[N][N];
int f[N+N][N][N];

int main()
{
    int n;
    cin >> n;
    int x , y , c;
    while(cin >> x >> y >> c , x || y || c)
    {
        matrix[x][y] = c;
    }
    
    for(int k = 2 ; k <= n + n ; ++k)
        for(int x1 = 1 ; x1 <= n ; ++x1)
            for(int x2 = 1 ; x2 <= n ; ++x2)
            {
                int y1 = k - x1 , y2 = k - x2;
                if(y1 >= 1 && y1 <= n && y2 >= 1 && y2 <= n)
                {
                    int w = matrix[x1][y1];
                    if(x1 != x2 || y1 != y2) w += matrix[x2][y2];
                    
                    int& val = f[k][x1][x2];
                    val = max(val , f[k-1][x1][x2]);
                    val = max(val , f[k-1][x1-1][x2]);
                    val = max(val , f[k-1][x1-1][x2-1]);
                    val = max(val , f[k-1][x1][x2-1]);
                    val += w;
                }
            }
    cout << f[n + n][n][n] << endl;
    return 0;
}

场景例题扩展:传纸条

题目如图所示:

解法一:无需证明强行解

首先,如果我们学习了之前的方格取数题目一定能看出这实际上就是他的一个变种题,那么这题与方块取数的题目的区别是什么呢?

实际上,由于从右下角传纸条到左上角时,只能往上或往左走,那么我们对称来看,会发现从右下角到左上角的任意路径一定对应着某一条从左上角到右下角的路径,如图所示:

那么我们就可以把从右下角到左上角的路径看成是从左上角到右下角的路径,知道了这点,这题与方块取数已经非常像了,他们之间唯一的不同是这题来回的路径不能相交,大白话就是对于某个点(x1,y1),选取的两条路径不能同时经过这个点!

那么要做到这一点我们最简单能想到的思路就是状态推导时,把这些相交的点设置为无效点,使得后续状态推导时一定不选取这些点

但需要注意的是,不管怎么样,最终这两条路径一定会到达最右下角,即当k = n + m,尽管这两个路径相交,我们也不能将这个点设置为无效点,因为他就是最终结果!

对于本题的状态表示和状态计算就不再过多赘述了,与方块取数一模一样

解法一代码

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;
const int N = 55;
int f[N + N][N][N] , matrix[N][N];
int main()
{
    int n , m;
    cin >> n >> m;
    for(int i = 1 ; i <= n ; ++i)
        for(int j = 1 ; j <= m ; ++j)
            cin >> matrix[i][j];
            
    for(int k = 2 ; k <= n + m ; ++k)
    {
        for(int x1 = 1 ; x1 <= n ; ++x1)
        {
            for(int x2 = 1 ; x2 <= n ; ++x2)
            {
                int y1 = k - x1 , y2 = k - x2;
                if(y1 >= 1 && y1 <= m && y2 >= 1 && y2 <= m)
                {
                    if(x1 == x2 && k != n + m) {
                        f[k][x1][x2] = -0x3f3f3f3f;
                        continue;
                    }
                    int w = matrix[x1][y1] + matrix[x2][y2];
                    int& val = f[k][x1][x2];
                    val = max(val , f[k-1][x1][x2]);
                    val = max(val , f[k-1][x1-1][x2]);
                    val = max(val , f[k-1][x1-1][x2-1]);
                    val = max(val , f[k-1][x1][x2-1]);
                    val += w;
                }
            }
        }
    }
    cout << f[n + m][n][n] << endl;
    return 0;
}

解法二:证明的思路

先给出个结论,我们可以直接把方块取数的代码套用到本题上,只需要改变一下输入输出,结果也完全正确,即本题的不能相交的限制可以无视

证明:

我们想想两条路径如果出现相交会有哪些情况呢?实际上可以分为两种

情况一:最优解的两条路线是相互交叉经过的。也就是如下图这种情况

实际上,我们这一题考虑的是两种路径共同组成的最优路径,对于某个格子来说,先取和后取是没有任何区别的,所以我们可以把上面的蓝线替换为红色,把下面的红线替换为蓝色,就得到如下图:

此时情况一就转化为了情况二,我们统一处理即可

情况二:最优解的两条路线不交叉,但在某些点有重合,即如下图这种情况

由于相交的点,我们只会取一次,并且每个格子的值一定是大于等于0的,所以对于相交的点我们可以贪一波,不走相交的点,绕到其他没取到的点,即如下图所示:

由于原路径是最优解,而格子的值都大于等于0,所以我们一定可以通过贪心的方式得到另一条与该点不相交的路径

解法二代码

cpp 复制代码
#include <iostream>
#include <cstring>
using namespace std;
const int N = 55;
int f[N + N][N][N] , matrix[N][N];
int main()
{
    int n , m;
    cin >> n >> m;
    for(int i = 1 ; i <= n ; ++i)
        for(int j = 1 ; j <= m ; ++j)
            cin >> matrix[i][j];
            
    for(int k = 2 ; k <= n + m ; ++k)
    {
        for(int x1 = 1 ; x1 <= n ; ++x1)
        {
            for(int x2 = 1 ; x2 <= n ; ++x2)
            {
                int y1 = k - x1 , y2 = k - x2;
                if(y1 >= 1 && y1 <= m && y2 >= 1 && y2 <= m)
                {
                    int w = matrix[x1][y1];
                    if(x1 != x2) w += matrix[x2][y2];
                    int& val = f[k][x1][x2];
                    val = max(val , f[k-1][x1][x2]);
                    val = max(val , f[k-1][x1-1][x2]);
                    val = max(val , f[k-1][x1-1][x2-1]);
                    val = max(val , f[k-1][x1][x2-1]);
                    val += w;
                }
            }
        }
    }
    cout << f[n + m][n][n] << endl;
    return 0;
}
相关推荐
yong99902 小时前
基于小波分析与粒子群算法的电网潮流优化实现(MATLAB)
开发语言·算法·matlab
Christo32 小时前
2024《Three-way clustering: Foundations, survey and challenges》
人工智能·算法·机器学习·数据挖掘
艾醒2 小时前
大模型原理剖析——解耦RoPE(旋转位置编码)的基本原理
算法
@淡 定2 小时前
JVM内存区域划分详解
java·jvm·算法
M__332 小时前
动规入门——斐波那契数列模型
数据结构·c++·学习·算法·leetcode·动态规划
LYFlied3 小时前
Vue3虚拟DOM更新机制源码深度解析
前端·算法·面试·vue·源码解读
薛不痒3 小时前
机器学习算法之集成学习随机森林和贝叶斯
算法·机器学习·集成学习
竹一阁3 小时前
跟踪导论(十二)——卡尔曼滤波的启动:初始参数的设置
算法·信号处理·雷达·信号与系统
youngee113 小时前
hot100-48课程表
算法