引言
n设坐标以左上角为(0,0),向右记为R,为x轴的增方向;向下记为D,为y轴的增方向。当前坐标为C,表格的宽=高=S。
从(0,0)开始,我们有两种选择,R和D。
如果第一步选择R时,C=(1,0),此时又有两种选择,R和D,也就是说,与第一步是一样的。
同理,如果第一步选择的是D,也与第一步一致。
显然,这是一个递归的过程。边界条件就是当C的坐标到达(S-1,S-1)。
因此:
static const int gridSize = 10;int Recursivity_GetRoutes(int x, int y){ int count = 0; if (x < gridSize) count += Recursivity_GetRoutes(x + 1, y); if (y < gridSize) count += Recursivity_GetRoutes(x, y + 1); if (x == gridSize && y == gridSize) return 1; return count;}
问题引入 对于一个2×2的表格,从左上角走到右下角,过程只可以向右或向下移动,共有6种方式(如图),那么,对于一个10×10的表格呢?
递归解法
更高效的解法 上述的程序是针对10×10的表格,它运行的不算慢,所以我们再将表格的大小翻倍,测试下它的运行时间。通过在一台普通的计算机上测试,表格为20×20时,程序运行超过了30分钟(具体时间有兴趣的可以测试),为什么会这么慢呢? 我们知道,递归的过程会消耗一定的时间,但是也不足以将程序拖到运行30分钟之久。因此我们取表格中的一个点来分析,拿P点来说,到达这个点有下图中的路径: 我们看到,到达P点之后,这个点到终点的路径数是固定的(如图粉线所示),然而,递归调用中为用红线到达P和蓝线到达P分别计算了一次。 因此我们需要一个备忘录,或者说一个哈希表,来保存以前计算过的值,而且,尝试用迭代代替递归又能进一步增加效率和运行时的安全性。 注意观察会发现一个重要的性质: 因此,我们决定采用一个自底向上的,带有备忘录的迭代的解法:
long long Dp_GetRoutes() { long long arr[21][21] = {0}; for(int i = 0; i < 21; ++i) arr[i][20] = 1, arr[20][i] = 1; for(int i = 19; i >= 0; --i) for(int j = 19; j >= 0; --j) { arr[i][j] = arr[i + 1][j] + arr[i][j + 1]; } return arr[0][0]; }
当然,对于这个问题,有一个手算的方法,为了走到终点,必然会向右走20步,向下走20步。它们的全排列共有40!种。 又因为,20个相同的R及D进行全排列是没意义的,所以需要除以(20!×20!)。 结果为40! / (20!×20!)。
动态规划
动态规划中的子问题一定是相互独立的,一个子问题中的解不会影响其它子问题的解。比如将点(x,y)到终点的路径分割为从(x+1,y)到终点的所有路径加上从(x,y+1)到终点的所有路径。
考虑下图中的最短路和最长路径:
如果需要计算q到t的最短路径,可以将问题从r或者s处划分开,具体在哪里划分取决于r到t的路径和s到t的路径谁短。这样问题的两个部分是独立的。 如果需要计算q到t的最长路径,考虑从r处分开子问题,那么q到r的最长路径为 q->s->t->r,r到t的最长路径显然是r->q->s->t,合并二者,得到 q->s->t->r->q->s->t,显然是错误的。两种路径产生很大差别,原因是,最长路径的子问题不独立,选定了一个子问题的解之后,它所使用的点不应被另一个子问题再使用。
实际上,最长路径问题是NP完全的,也就是说,无法在多项式时间(一个问题的计算时间m(n)不大于问题大小n的多项式倍数)得到解决。
最优子结构
重叠子问题
如果问题的一个(最优)解中,包含了子问题的(最优)解,则该问题具有最优子结构。
结合引言中的例子,从底层开始迭代,比如取一个点,坐标为(x,y),那么,从这个点到终点的所有路径的总和就等于(x+1,y)到终点的所有路径和加上(x,y+1)到终点的所有路径和。
证明最优子结构可以使用“剪切粘贴”的方法。在后面讨论误用动态规划时的最短路时应用此方法。
用来解决原问题的算法会反复地解同样的子问题,而不是总产生新的问题。
就引言中的例子而言,随着迭代的进行,总会遇到相同的子问题,因为我们做了备忘,因此这些问题只被计算一次。
定义 动态规划(dynamic programming)是运筹学的一个分支,是求解决策过程最优化的数学方法。20世纪50年代初美国数学家R.E.Bellman等人在研究多阶段决 策过程的优化问题时,提出了著名的最优化原理,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解,创立了解决这类过程优化问题的新方 法——动态规划。
特征
提防误用
典型动态规划问题举例
最大取值 问题: 从下面的三角的顶端,每次只能移动到下一行的且与之临近的点上,其最大值是(红色表示路径):3 + 7 + 4 + 9 = 23。 那么,对于以下的三角呢?
分析:同样,我们从三角的倒数第二层开始分析,假如,此时我们在最左边的63上,按题意,此时只有两种选择,04和62,显然,为了得到最大值,我们会选择62,第二行的其余点也如此选择。 接着,我们上升一层,在91上,为了得到最大值,我们会选择63和66。 显然这是个动态规划的问题,在倒数第N层到底层内这个问题(最优子结构)是最优的。 当三角只有一层时,最大取值=底层点的值。static inlineint Max(int x, int y) { return (x > y) ? x : y; }static int arr[][15] = { /*layer1*/ { 75 }, /*layer2*/ { 95, 64 }, /*layer3*/ { 17, 47, 82 }, /*layer4*/ { 18, 35, 87, 10 }, /*layer5*/ { 20, 4, 82, 47, 65 }, /*layer6*/ { 19, 1, 23, 75, 3, 34 }, /*layer7*/ { 88, 2, 77, 73, 7, 63, 67 }, /*layer8*/ { 99, 65, 4, 28, 6, 16, 70, 92 }, /*layer9*/ { 41, 41, 26, 56, 83, 40, 80, 70, 33 }, /*layer10*/ { 41, 48, 72, 33, 47, 32, 37, 16, 94, 29 }, /*layer11*/ { 53, 71, 44, 65, 25, 43, 91, 52, 97, 51, 14 }, /*layer12*/ { 70, 11, 33, 28, 77, 73, 17, 78, 39, 68, 17, 57 }, /*layer13*/ { 91, 71, 52, 38, 17, 14, 91, 43, 58, 50, 27, 29, 48 }, /*layer14*/ { 63, 66, 4, 68, 89, 53, 67, 30, 73, 16, 69, 87, 40, 31 }, /*layer15*/ { 4, 62, 98, 27, 23, 9, 70, 98, 73, 93, 38, 53, 60, 4, 23 } };int Dp_GetMaxValueThroughTriangle() { const int limit = 15 - 1; for(int layer = limit; layer != 0; --layer) { const int& layerNumberCount = layer; for(int numberIndex = 0; numberIndex < layerNumberCount; ++numberIndex) arr[layer - 1][numberIndex] += Max(arr[layer][numberIndex], arr[layer][numberIndex + 1]); } return arr[0][0]; }
钱币组合方式 假设我们有无限多的1元,2元,5元,10元,20元,50元,100元,200元的钱币,那么为了组合成一个200元的钱币,共有多少种组合方式? 比如说: 200 = 1×100+1×50+2×20+1×5+1×2+3×1。 因为有了1元的钱币,这就使我们组成的任何不足200的数字可以整合为200。 为了说明问题,我们来观察这样的一个类似的小问题,用1元,2元,5元来组成5元。 如果只允许用1元,那么显然只有一种方案:1×5。 如果允许用1元和2元,那么,5元的组合方式有,1×5,2+1×3,2×2+1。增加了两种方案。 如果允许用1,2,5元,那么有: 1×5,2+1×3,2×2+1,5×1。 我们假设组成0元的方式有一种,那么,可选的最大币值为x,那么: 当x=1时: 当x=2时: 当x=5时:
从这个问题总结出:当目标币值为Y,现在可用的最大币值为1,2,5……X(X ≤Y),Z是小于X的最大币值,组成Y的方式为f(Y)那么 f(Y) = f(Y-X) + f(X-Z) 比如说,对于Y=3,X=2,可知Z=1,所以, f(3) = f(3-2) + f(2 - 1) = f(1) + f(1) = 1 + 1 = 2。特别地,f(0) = 1。static int faceValue[] = {1, 2, 5, 10, 20, 50, 100, 200};static const size_t factValueCount = 8;intDp_GetCountOfWaysCan200BeMadeUp() { // Dynamic Programming. const int destination = 200; int waysCount[destination + 1] = { 0 }; waysCount[0] = 1; for(int indexCoin2Give = 0; indexCoin2Give < factValueCount; ++indexCoin2Give) for(int indexValueNeed2Pay = faceValue[indexCoin2Give]; indexValueNeed2Pay <= destination; ++indexValueNeed2Pay) waysCount[indexValueNeed2Pay] += waysCount[indexValueNeed2Pay - faceValue[indexCoin2Give]]; return waysCount[200]; }
由于数据量不大,也可以用递归解决或者枚举法解决。
int Recursivity_GetCountOfWaysCan200BeMadeUp(int money, int maxCoin) { int coins[8] = {200, 100, 50, 20, 10, 5, 2, 1}; int result = 0; if(maxCoin == 7) // Only coins[1] = 1 is available. return 1; for(int i = maxCoin; i < 8; i++) { if (money - coins[i] == 0)//Done. result += 1; if (money - coins[i] > 0) result += Recursivity_GetCountOfWaysCan200BeMadeUp(money - coins[i], i); // else, money < coins[i], continue. } return result; }///int BruteFource_GetCountOfWays() { int ways = 0; const int dest = 200; for(int a = dest; a >= 0; a -= 200) //a个200。 for(int b = a; b >= 0; b -= 100) //b个100。 for(int c = b; c >= 0; c -= 50) //c个50。 for(int d = c; d >= 0; d -= 20) //d个20。 for(int e = d; e >= 0; e -= 10) //e个10。 for(int f = e; f >= 0; f -= 5) //f个5。 for(int g = f; g >= 0; g -= 2) //g个2。 ++ways; return ways; }
活动安排 假设有11个活动,需要占用同一个资源(比如说一个会议室),他们的活动时间按照结束时间递增的顺序,如下表所示: 如果两个活动不会同时占用一样资源,那么称他们是相容的。那么,共有最多可以有多少个活动相容? n假设S(i,j)表示一个集合,活动记为a, S(i,j)之中的元素代表活动,并且所有的活动都在a_i结束之后开始,在a_j开始之前结束。假设活动的编号从1开始,到n-1结束,定义哨兵事件,a_0为最开始的事件,a_n为最结束的事件。记c[i,j]为|S(i,j)|。 简言之,如果一个活动a_k是S(i,j)中的一个合法的活动,那么c[i,j]为k取(i,j)中的各个值,并通过c[i,k]+1+c[k,j]得到的最大结果。
#include
#include 题外话
前面我们假设活动按照结束时间递增的顺序排列,其实我们也可以不要这个限制,而在活动选择之前主动进行排序。观察活动结束时间的选择区间,显然是0~24的,那么,用计数排序是很合适的。下面的计数排序是兼容STL的:
#include
#include template staticvoid count_sort(RanIt first, RanIt last, int upper) { typedef std::iterator_traits ::value_type value_t; typedef std::iterator_traits ::difference_type distance_t; distance_t dis = std::distance(first, last); std::vector tmp(first, last); RanIt dest = tmp.begin(); int *host = new int[upper]; //fill temporary range with default value. std::fill(host, host + upper, 0); //host[i]: count of values which are equal to i. eg: host[2] == 1, //means there are two '2'. for(int i = 0; i < dis; ++i) ++host[*(first + i)]; //host[i]: count of values which are not greater than i. eg: host[2] = 3, //means there are 3 numbers less or equal to 2. for(int i = 1; i < upper; ++i) host[i] += host[i - 1]; //place the number backwardly to keep this algorithm stable. for(int i = (--dis); i >= 0; --i) { value_t temp = *(first + i); dest[host[temp] - 1] = temp, --host[temp]; } //copy result back into the input range. std::copy(tmp.begin(), tmp.end(), first); //clean up. delete host; }template inlinevoid CountSort(RanIt first, RanIt last, int upper) { //this algorithm use both the index and the value of an array to represent data. if(*(std::min_element(first, last)) >= 0 && *(std::max_element(first, last)) <= upper) count_sort(first, last, ++upper); else assert(false); } 当然,用动态规划解决活动选择问题是可以的,但是它还有另一个重要的性质,如果已经选定了活动x,那么,下一个被选择的活动一定是与x相容且结束时间最早的活动y。证明: 假设下一个选择的活动为z且z≠y,那么,将z换为y,因为y是最早结束的活动,并且y与x相容,所以y的结束时间必然≤z的结束时间,这就保证了它不与已选择的其他活动冲突。所以y在这个活动集中是合理的。 有了上述的性质,我们假设第一个活动是a_0,它的结束时间为0,选择下一个时,选择开始时间大于0且结束时间最早的,因为活动的结束时间已经按照递增排序,因此选择的是a_1,按照这个规则,同理,下一个选择的是a_4,然后是a_8,最后是a_11。 上述方法称为贪心算法,每次选取当前看来最优的解,从而获得全局的最优解。 贪心算法是一个与动态规划类似但是较动态规划更为简洁但却更容易误用的算法。