前几日,老师布置了一道LeetCode的题目(198. 打家劫舍),过后呢,我也看了与其同类型的(17.16.按摩师),现总结如下:
所谓动态规划问题,就这两道题讲,对于向我这样的初学者,重要的是理解动态规划的内涵、理解如何定义子问题、理解并写出状态转移方程。
以198. 打家劫舍为例(题目自查):
首先,要知道一个概念叫子问题。所谓子问题就是和原问题相似,但规模较小的问题。例如对于接下来讨论,记第 k个房子偷与不偷时的金额分别为f[k][1]和 f[k][0],则原问题是“第n个房子偷与不偷时的金额”,分别记为f[n][1]和f[n][0],那么我们将问题的规模缩小,子问题就是“第 n-1个房子偷与不偷时的金额”,分别记为f[n-1][1]和f[n-1][0],同时记第k个房子的钱数为m[k],至于为什么这样定义原问题和子问题,接下来讨论。
我们研究题目可以发现,从全部n个房子中能偷到的最大金额有两种可能,第一是第n个房子偷后有最大金额,第二是第n个房子不偷后有最大金额。又根据规定可以得出,如果偷第n个房子,则第n-1个房子不能偷;如果不偷第n个房子,则第n-1个房子可以偷也可以不偷。因此,可以将经过第n-1个房子后的金额分为两种,偷了第n-1个房子,金额记为f[n-1][1]和没偷第n-1个房子,金额记为f[n-1][0],当我们知道了第n-1个房子这两种状态下的金额时我们就可以轻易得出:当打算偷第n个房子时,f[n][1] = m[n]+f[n-1][0];而打算不偷第n个房子时,f[n][0] = f[n-1][0]与f[n-1][1]之间的最大值(因为题目中要求最大金额)。
到这一步,我们就把问题转化为了 “第n-1个房子偷与不偷时的金额”,但是我们仍然无法计算,所以我们需要向下递推。将上述的原理中的n替换为n-1,将n-1替换为n-2,则问题就又可以转化为“第n-2个房子偷与不偷时的金额”,以此类推,最后递推到“第1个房子偷与不偷时的金额”。此时,整个逻辑就完善了,我们只需要从底向上推导,就可以从k = 1根据上面的思路反推导至k = n,将上述思维进行代码化如下:
int dajiajieshejieyi(vector<int>& m)
{
if (m.empty())//注意:判断一定要在变量定义后,不然空的情况执行f[0][1] = m[0]语句错误,没有nums[0]的值
{
return 0;
}
vector<int[2]> f(m.size());//用来保存每一家偷或不偷的结果
f[0][0] = 0;//初始化第1个房子不偷时的金额,为0
f[0][1] = m[0];//初始化第1个房子偷时的金额,为m(0)
for (size_t i = 1; i < m.size(); ++i)
{ //max为求最大值函数,下面是状态转移方程
f[i][0] = max(f[i - 1][0], f[i - 1][1]);
f[i][1] = f[i - 1][0] + f[i];
}
return max(f[m.size() - 1][0], f[m.size() - 1][1]);//返回k = n 的结果
}
以上的方法,使用了二位数组保存了所有运算过的偷与不偷的结果,空间复杂度较高,但我们观察可以发现,计算f(k)(0)和f(k)(1)时,只需要f[k-1][0]和f[k-1][1]即可,所以没有必要用数组,用两个变量存储f[k-1][0]和f[k-1][1]即可,代码如下:
int dajiajieshejiesan(vector<int>& m)
{
if (m.empty())
{
return 0;
}
int alltou0 = 0;//存储f[k-1][0],初始值为0,因为要在循环外返回所以定义在循环外
int alltou1 = 0;//存储f[k-1][1],初始值为0
for (size_t i = 0; i < m.size(); ++i)
{
int tou0 = max(alltou0, alltou1);//f[k][0]=max(f[k-1][0], f[k-1][1])
int tou1 = alltou0 + m[i];//f(k)(1) = f(k-1)(0) + m(k)
alltou0 = tou0;//保存f(k)(0)
alltou1 = tou1;//保存f(k)(1)
}
return max(alltou1, alltou0);//返回k=n时的最大值。
}
以上的两种方法,实际上都是计算出k-1时偷与不偷的情况并分别保存,用于计算k偷与不偷的情况。
这里还有另一种更简洁的思路,使用DP数组,也可以叫”子问题数组”,因为 DP 数组中的每一个元素都对应一个子问题。对于第k个房子来说,如果我们不偷的话,其值等于第k-1个房子偷与不偷的最大值;而如果我们偷的话,其值等于第k-1个房子不偷的值加上m(k),其中第k-1个房子不偷的值等于第k-2个房子偷与不偷的最大值,所以,计算f[k]需要第k-1和k-2个房子金额的最大值,以此类推至k=1,我们只需要知道第1个房子的最大值,并设第0个房子最大值为0,即可算出第k(k >= 2)个房子的最大值然后保存。这种思路的状态转移方程也就为 f[k] = max(f[k-1], m[k-1] + f[k-2]),因为k依赖于k-1和k-2计算,因此自底向上更为方便,代码如下:
int dajiajieshejiesi(vector<int>& m) {
if (m.empty()) {//必须放在变量定义前,不然 f[1] = m[0]会出错
return 0;
}
vector<int> f(m.size()+1, 0);
f[0] = 0;//第0个房子,没房子最大值为0
f[1] = m[0];//第一个房子,只有一个房子,最大值为m[0]
for (int k = 2; k <= m.size(); ++k) {
f[k] = max(f[k-1], m[k-1] + f[k-2]);//从第二个开始计算第k个的最大值
}
return f[m.size()];
}
同样的,这个思路也可以简化空间复杂度,计算f[k]时,只需要f[k-1]和f[k-2]即可,那么用两个变量保存其值即可,代码如下:
int dajiajieshejiewu(vector<int>& m) {
int f1 = 0;
int f2 = 0;
for (int i = 0; i < m.size(); ++i) {
int k = max(f2, f1 + m[i]);
f1 = f2;
f2 = k;
} return f2;
}
就我见到的而言,上面的方法是最简洁的。