9.1 数字三角形
动态规划的理论性和实践性都比较强,重点理解状态,状态转移,最优子结构,重叠子问题等概念,另一方面又需要根据题目的条件灵活设计算法
对动态规划的掌握程度在很大程度上能直接影响一个选手的分析和建模能力
动态规划是一种用途很广的问题求解方法,它本身并不是一个特定的算法,而是一种思想,一种手段
9.1.1 问题描述与状态定义
如果熟悉回溯法,可能回立刻发现这是一个动态的决策问题,每次有两种选择——左下或者右下。如果用回溯法求出所有可能的路线,就可以从中选出最优路线(可以通过暴力搜索解决结点数较少的该类题目),不过回溯法的效率过低,一个n层数字的数字三角形的完整路线由2^n-1条,当n很大时回溯法的速度将让人无法接受
记忆化搜索的思想开始了(很明显,我们自己模拟的时候会发现,每次回溯,在d层,d-1层的和已经算过,但是如果不加记忆,那么就会重复算2^d-1次,造成非常大的浪费)
此时需要用比较抽象的思维来思考问题:把当前的位置(i,j)看成一个状态(隐式图的思想?),然后定义状态(i,j)的指标函数d(i,j)为从格子(i,j)出发时能得到的最大和(包括格子(i,j)本身的值)
在这个状态顶一下,原问题的解是d(1,1)
下面是对不同状态转移之间的讨论,从格子(i,j)出发由两种决策。如果往左走,则走到(i+1,j)后需要求"从(i+1,j)出发后能得到的最大和"这个问题,即d(i+1,j)。类似的,往右走之后需要求解d(i+1,j+1)。由于可以在这两个决策中自由选择,所以应选择d(i+1,j)和d(i+1,j+1)中较大的一个。换句话说,我们因此得到了状态转移方程:
d(i,j)=a(i,j)+max{d(i+1,j), d(i+1,j+1)}
本质上其实该题的思想回搜索类似,但是多了记忆化搜索(空间换时间?),同时注意状态转移方程的合理性以及边界条件的规定
如果往走左,那么最好的情况就是a(i,j)+d(i+1,j),注意这边是最大的,如果从d(i+1,j)不是从(i+1,j)出发得到的最大和,那么加上a(i,j)后也不会是最大的,这个性质称为最优子结构(optimal substructure),也可以描述为“全局最优解包含局部最优解”(贪心的思想?)
总之状态和转移方程一起完整的描述了具体的算法
动态规划的核心是状态和状态转移方程
9.1.2 记忆化搜索与递推
接下来讲述对于状态转移方程的程序实现:
方法1:递归计算(需要注意边界条件的处理)
点击查看代码
int solve(int i, int j) {
return a[i][j] + (i==n ? 0 : max(solve(i+1, j), solve(i+1, j+1)));
}
这样做显然是正确的,但时间效率太低,其原因在于重复计算
读者可以自行尝试标记每次搜索到的结点,会发现其重复的是一颗子树,如果原来的三角形有n层,则调用关系树也会有n层,一共有2^n-1个结点,那么很明显,中间的结点都会被重复计算,造成比较严重的资源消耗
用直接递归的方法计算状态转移方程,效率往往十分低下,其原因是相同的子问题被重复计算多次(注意需要保证该子问题的答案已经完毕,与后续计算无关才行)
方法2:递推计算(仍然需要注意边界处理):
点击查看代码
int i, j;
for(j = 1; j <= n; j++) d[n][j] = a[n][j];//边界点初始化
for(i = n-1; i >= 1; i--)
for(j = 1; j <= i; j++)
d[i][j] = a[i][j] + max(d[i+1][j], d[i+1][j+1]);//开始不断往上不断刷新结点的值,记忆化搜索
程序的时间复杂度显然是O(n * n),但为什么可以这样计算,原因在于:i是逆序枚举的,因此在计算d[i][j]之前,他所需要的d[i+1][j]和d[i+1][j+1]已经算出来了,这也是递推形式的魅力,类似离散数学的归纳构造方法
可以用递推方法计算状态转移方程。递推的关键是边界和计算顺序。在多数情况下,递推法的时间复杂度是:状态总数 * 每个状态的决策数 * 决策时间。如果不同状态的决策个数不同,需要具体问题具体分析,本质上可以简单记忆为对于循环个数的统计
方法3:记忆化搜索
程序分成两部分,首先用"memset(d, -1, sizeof(d);"把d全部初始化为-1,然后编写递归函数
(注意memset的使用,并不是如果填的不是-1而是-2,那么d中的所有元素都成为-2,符合该种条件的常用参数仅包括-1和0,即一般只用0和-1作为批量赋值的参数)
点击查看代码
int solve(int i, int j) {
if(d[i][j] >= 0) return d[i][j];//判断结点是否计算过
return d[i][j] = a[i][j] + (i==n ? 0 : max(solve(i+1, j), solve(i+1, j+1)));//对于d[i][j]的赋值
//注意这边巧妙的通赋值运算的特性从而简化代码
}
上述程序已然是递归,但同时也把计算结果保存在数组d中,题目中说各个数都是非负的,因此如果已经计算过某个d[i][j],则它应该是非负的。这样,只需把所有d初始化为-1,即可通过判断是否d[i][j]>=0得知它是否已经被计算过
当然记忆化搜素最重要的就是对于记忆的实现,一定要将计算结果存放在d[i][j]中,根据C语言“赋值语句本身有返回值”的规定,可以把保存d[i][j]的工作合并到函数的返回语句中
上述程序的方法称为记忆化(memoization),它虽然不想递推法那样显示的指明了计算顺序,但仍然可以保证每个结点只访问一次(那么者之间就很有可能存在hash进行一一映射实现更加快速的查找访问插入)
由于i和j在1-n之间,所有不相同的结点一共只有O(n * n)个。无论以怎样的顺序访问,时间复杂度均为O(n * n),几次是访问的时间复杂度已经与实现方法无关,而与结点数,策略数,决策所耗费时间有关,从2^n->n * n是一个巨大的优化,这正是利用了数字三角形具有大量重叠子问题的特点
可以用记忆化搜索的方法计算状态转移方程,当采用记忆化搜索时,不必实现确定各状态的计算顺序,但需要记录每个状态是否已经计算过
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)