定义
动态规划算法,是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推的方式去处理的一种解决问题的方法。(摘自“百度百科”)
入门级的动态规划题
“ 十级楼梯,一步上1或者2级,问,有多少种走法走完?”——最简单的动态规划题。
模拟法(其实就是暴力解法)
模拟走楼梯,递归地把走10级楼梯的过程模拟出来,累计就能得出次数了——费时。
----------------------------------------
用动态规划的思维去思考:
走10级楼梯,是一个问题;
站在第n级台阶上,是一个状态;
走1级或者走2级,退1级或者退2级——是状态转化的操作;是问题的增量单元,放在问题最初就是边界
站在第n级台阶,走1级(或者2级),则到达下一个状态——这是状态递推;
站在第n级台阶,退1级(或者2级),则到达上一个状态——这是状态回退;
用回退的观点看待 “站在第10级楼梯上” 这么一个状态,它的上一个状态必然是站在第9级或者第8级台阶上。那么,走10级楼梯这个大问题,其实就是走8级楼梯和走9级楼梯的两个子问题了,因为最后无非都是迈1步。——这是在明确上面几个概念之后,对问题的分解。
用数学表达式表示,设站到第n级台阶的解法是F(n)种,则站到第10级台阶的问题可以通过以下公式拆分:
F(10) = F(9)+F(8)
= [F(8)+F(7)]+[F(7)+F(6)]
= [[F(7)+F(6)]+[F(6)+F(5)]]+[[F(6)+F(5)]+[F(5)+F(4)]]
= F(1) + ... + F(2)(最终就是由若干个F(1)和F(2)组成)
上面的分解过程就像一棵生长的二叉树,对于树,经典的解法就是递归了。
只要递归地把F(9)和F(8)算出来,就能解决问题了。
但是解法还不能只是简单的递归,因为那样会产生大量重复计算,那样的时间复杂度仍然可观。
有两种办法避免重复计算,这是动态规划问题的两种常见思路:
一种是自上而下,递归,在过程中被动建立备忘录(没有了备忘录,这种解法也能解开问题,就是慢而已;备忘录是被动建立的,目的是加速递归)。
把算过的F(x)记录下来,每次递归都应该先查“备忘录”,如果有记录,则省去下面的递归过程了;否则才需要递归地计算)
1 #include <iostream> 2 #define N 10 3 #define INVALID 0 4 5 int reminder[N + 1] = { INVALID, 1, 2 };//1 对F(1)和F(2)赋初值(为了方便表述,第0位空着不用了) 6 7 int F(int n){ 8 if (reminder[n] != INVALID) { 9 return reminder[n];//3 备忘录查到记录,直接取用 10 } 11 else { 12 return reminder[n] = F(n - 1) + F(n - 2);//2 递归,顺带更新备忘录 13 } 14 } 15 16 int main(){ 17 std::cout << F(10) << std::endl; 18 return 0; 19 }
一种是自下而上,递推解开所有子问题,主动建立备忘录(备忘录在这种解法里面不可或缺)。
当然是有顺序讲究的:从最小的子问题开始,问题由小到大,递推解开所有子问题,并最终解决F(10)。
1 #include <iostream> 2 #define N 10 3 #define INVALID 0 4 5 int reminder[N] = { INVALID, 1 , 2 };//1 对基础操作(F(1)/F(2))赋初值 6 7 int main(){ 8 //2 递推解决1-10级的问题 9 for (int i = 3; i <= N; i++) { 10 reminder[i] = reminder[i - 1] + reminder[i - 2]; 11 } 12 //3 直接查备忘录,输出结果 13 std::cout << reminder[10] << std::endl; 14 return 0; 15 }
好啦,这个问题,正确答案是 89 种走法。
入门级的动态规划问题(10级楼梯走法求解)至此解毕。
难度加深一点的入门级动态规划题
上面的题目,其实只是一个一元函数的问题:y=f(n)
下面这道题就厉害了,是一个二元函数的问题:y=f(n, w):
5个金矿,挖矿需要的人力如下表:
现有10人,如何分配人力,挖到最多矿藏?
( 限制条件:人力不可重复使用,一人只能对应一矿;矿藏要么不挖要么全挖——“现实中不会这样子的,现在是做题...” )
分析
数学表达
这就是一个二元的题目了,设人力为n,矿藏个数w,挖矿最优解问题可以表述为解一个二元函数 y=f(n,w),具体到这道题就是二元函数求值y=f(10,5)。
问题的分解
最后一个矿不管是哪一个,它都是最后一个,编号5(我可不从0开始的喔),这个矿需要的人力是p(5),矿藏量g(5);
那么剩余分配到剩下4个矿的人力就是10-p(5),就有 y=f(10,5)=f(10-p(5), 4)+g(5);
不过有可能最后一个矿不挖,所有人力集中到前4个矿里面,挖矿量更大喔:y=f(10,5)=f(10,4);
所以综上,y = f(10,5) = max { f( 10-p(5) + g(5) ), f(10,4) }(情况1)
再仔细考虑一下,如果最后一个矿的人力需求p(5)已经超过能提供的人力w,那就没有选择的余地了:y=f(10,5)=f(10,4)(情况2)
注意,直到这里,都没说最后一个矿是哪一个喔...(后面讨论)
递推公式(关键)
就是对上面“问题分解”的抽象,过程省略:
y = f(w,n) = max { f( w-p(n) + g(n) ), f(w, n-1) }(情况1,w>=p(n))
y = f(w,n)=f(w,n-1)(情况2,w<p(n))
边界(关键)
就是w个人挖1个矿(从高等数学角度来看,1个人挖n个矿也是一个边界,不过我们这个问题是人数分配,只剩一个人就没意思了)。
既然只有一个矿,编号肯定就是1啦。该矿人力p(1),矿藏量g(1),挖矿量y=f(1,w)。
既然是边界条件,这个函数自然好解,细心就好:
当w>=p(1)时,y=f(1,w)=g(1);当w<p(1)时,y=f(1,w)=0;
注意,直到这里,都没说边界这一个矿是哪一个喔...(后面讨论)
递推解决问题
接下来就是画表格表述问题的增长过程了。这张表格可以确定就是“人数&矿”组成的,这个没有异议。
人数是增长的,所以由1排到10,这个也没啥异议;
那么,有个问题了,矿要怎么排?
“哪一个矿放在边界情况上,矿藏最多那个,人力要求最多那个?”
“哪一个矿来做最后一个矿,矿藏最多那个,人力要求最多那个?”
事实上,无所谓的,这就是动态规划的魅力所在:我们的动态规划求解基于子问题的解,但我们最终并不关心子问题的解,我们最终只关心手头上这个问题的解。
最初一个矿和最后一个矿是什么无所谓,我们可以确定最后递推解出来的解,是针对5座矿,10个人力的就行了。
上面的话,我自己都明白自己在说什么—。—||,来个数学大师证明一下?
总而言之,人数有增长就行,矿的排序无所谓。所以,不管了,直接动手。
根据边界条件:当w>=p(1)时,y=f(1,w)=g(1);当w<p(1)时,y=f(1,w)=0;下面是我建立的边界初始化完毕的表格:
接下来是处理第二行,根据:
y = f(w,n) = max { f( w-p(n) + g(n) ), f(w, n-1) }(情况1,w>=p(n))
y = f(w,n)=f(w,n-1)(情况2,w<p(n))
1-4个人,属于情况2;5-9个人,属于情况1;10个人也属于情况1,表格进一步完善为:
以此类推,最终表格完善为:
过程怎么样不管,我们只想得到答案:人力10人,最大能挖到矿藏900,挖的是500(5)和400(5)两座矿。
事实上,可以试一下,随便打乱矿藏顺序,按照上面的推导过程,可以得到下表:
过程变化了,但是最终问题不变,答案也不变:人力10人,最大能挖到矿藏900,挖的是500(5)和400(5)两座矿。
好啦,题目到此解毕,代码如下,很粗糙:
1 #include <iostream> 2 typedef struct 3 { 4 int worker; 5 int resource; 6 }MINE; 7 typedef struct 8 { 9 int maxMine; 10 bool dig; 11 }STRAGECY; 12 //五座矿 13 #define NUM_OF_MINE 5 14 #define NUM_OF_WORKER 10 15 const MINE mines[NUM_OF_MINE] = 16 { 17 { 5, 500 },{ 5, 400 }, 18 {3, 350},{4, 300},{3, 200} 19 }; 20 bool digOrNot[NUM_OF_MINE] = { 0 }; 21 22 int getMaxMine(int workers) 23 { 24 int maxMine = 0; 25 STRAGECY maxMineMatrix[NUM_OF_MINE][NUM_OF_WORKER]; 26 27 for (int i = 0; i < NUM_OF_MINE; i++) 28 { 29 for (int j = 0; j < NUM_OF_WORKER; j++) { 30 maxMineMatrix[i][j].dig = false; 31 maxMineMatrix[i][j].maxMine = 0; 32 } 33 } 34 for (int i = 0; i < NUM_OF_MINE; i++) 35 { 36 for (int j = 0; j < workers; j++) 37 { 38 int worker = j + 1; 39 //计算:如果挖当前矿,那么剩下人数最多能挖多少矿? 40 int maxMineIfDig = 0; 41 if (worker >= mines[i].worker) 42 { 43 maxMineIfDig = mines[i].resource; 44 if (worker - mines[i].worker > 0) 45 { 46 int max = 0; 47 for (int k = 0; k < i; k++) 48 { 49 max = (max > maxMineMatrix[k][worker - mines[i].worker - 1].maxMine) ? 50 max : maxMineMatrix[k][worker - mines[i].worker - 1].maxMine; 51 } 52 maxMineIfDig += max; 53 } 54 } 55 //计算:如果不挖当前矿,那么所有人数挖前面几个矿能挖最多多少出来? 56 int maxMineIfNotDig = 0; 57 for (int k = 0; k < i; k++) 58 { 59 maxMineIfNotDig = (maxMineIfNotDig > maxMineMatrix[k][j].maxMine) ? 60 maxMineIfNotDig : maxMineMatrix[k][j].maxMine; 61 } 62 //策略比较 和 二维数组更新: 63 if (maxMineIfDig > maxMineIfNotDig) 64 { 65 maxMineMatrix[i][j].maxMine = maxMineIfDig; 66 maxMineMatrix[i][j].dig = true; 67 } 68 else 69 { 70 maxMineMatrix[i][j].maxMine = maxMineIfNotDig; 71 maxMineMatrix[i][j].dig = false; 72 } 73 } 74 } 75 //获得人数分配的策略: 76 int workerLeft = workers; 77 int mineIndex = NUM_OF_MINE - 1; 78 memset(digOrNot, 0, NUM_OF_MINE); 79 while (mineIndex >= 0 && workerLeft>0) 80 { 81 if (maxMineMatrix[mineIndex][workerLeft-1].dig) 82 { 83 workerLeft -= mines[mineIndex].worker; 84 digOrNot[mineIndex] = true; 85 } 86 mineIndex--; 87 } 88 return maxMineMatrix[NUM_OF_MINE-1][workers-1].maxMine; 89 } 90 91 int main() 92 { 93 int maxMine = 0, worker = 0; 94 while (1) { 95 scanf("%d", &worker); 96 maxMine = getMaxMine(worker); 97 std::cout << "max mine: " << maxMine << std::endl; 98 for (int i = 0; i < NUM_OF_MINE; i++) 99 { 100 std::cout << digOrNot[i] << " "; 101 } 102 std::cout<<std::endl; 103 } 104 return 0; 105 }
(题目参考了 https://www.sohu.com/a/153858619_466939 )