上一文中,我们浅析了分治策略、动态规划、贪婪选择以及递归之间的关系,下面我们通过一个实例,硬币找零问题,来分别设计分治算法、动态规划算法、贪心算法。
硬币找零问题:现存在一堆面值为 v1、v2、v3 … 个单位的硬币,问最少需要多少个硬币才能找出总值为x单位的零钱?这里我们假设v[]={0, 1, 2, 5, 10, 20, 50}。0是用来充位数的,这样v1、v2与下标1、2对上。这里v1必须为1,若不为1的话,给定一个x,可能无法得到一个解,即找不开。比如v[]={2, 5, 10, 20, 50}, x=18,没有解。这里我们为了方便,也设定数组v是排序好的,如果没有排序,用一个排序算法即可搞定。
1、分治策略
在设计分治策略算法之前,我们必须得到解决该问题的一个递推式,即如何将一个大问题划分为若干个小问题。
(1)第一种方法:(这是我自个找出的递推式,慎用,呵呵)
f(x, i)表示用v[1],v[2],...,v[i]来找零x所需的最少硬币数,可得递推式如下:
f(x, i)=min( f(x, i-1), x/v[i] + f(x%v[i], vmax(x%v[i], v)))
其中vmax(x, v)返回i,且i满足v[i]<=x<v[i+1],即找到v数组中可找零的最大的那个面值
这里在求f(x, i)这个大问题的时候,我们对它进行分解:(Divide)
case1,我们跳过v[i],而直接用v[1],v[2],...,v[i-1]来找零x,即f(x, i-1);(Conquer)
case2,我们要用v[i]来找零,可得找零数为x/v[i] + f(x%v[i], vmax(x%v[i], v))(Conquer)
那么case1,case2最小的那个即是f(x, i)。
代码如下:
/*change.h*/ int min(int a, int b) { if(a < b) return a; return b; } /* x:要找零的数 v:找零面值数组 length:数组的长度 return i, v[i]<=x<v[i+1] */ int vmax(int x, int v[], int length) { int i = 0; while((x >= v[i]) && (i < length)) { i++; } return i-1; } /* v:找零面值数组 x:要找零的数 i:f(x,i)中的i,表示从v[1],v[2],...,v[i]中找零 length:找零数组的长度 */ int change_dc(int v[], int x, int i, int length) { if(x == 1) return 1; if(x == 0) return 0; if(i == 1) //当i为1时,必须有这个出口;没有的话在下一个f(x,i-1)中,i会为0 return x; return min(change_dc(v, x, i-1, length), x/v[i]+change_dc(v, x%v[i], vmax(x,v, length), length)); }
(2)第二种方法:
(取自http://www.ccs.neu.edu/home/jaa/CSG713.04F/Information/Handouts/dyn_prog.pdf)
递推式如下:
c(x) = min(1+c(x-v[i])),i的取值范围为i:x>=v[i],这个限制条件必须有,否则会出现负数。
上面给出的文档应该说的很详细了,不多说,直接上代码。
/*change.h*/ int change_dc2(int v[], int x, int length) { int i; int min; int temp; if(x == 0) return 0; min = x; for(i=2; i<length; i++) { if(x >= v[i]) { temp = change_dc2(v, x-v[i], length) + 1; if(temp < min) min = temp; } } return min; }
2、动态规划
可能你已经发现了,f(1, 1)、f(1, 2),c(1)、 c(2)等很多子问题会被计算多次,那么我们就可以用动态规划来解决此类问题了。这里有两种不同的方式,一种为自顶向下的备忘录方式(memoization),一种为自底向上的方式。
(1)自顶向下的备忘录方式
我们可以很容易的将分治策略算法改为自顶向下的备忘录方式的算法,因为分治策略算法一般都用递归的思想,而递归就是自顶向下的,这里我们只要加入备忘录的机制就ok了。就是在每求解一个子问题时,我们都判断该子问题时候已求解,若已求解,直接给出解;若没有,我们求解该子问题,并保存该子问题的解,那么在下次求解这个问题的时候可以直接用。
分治策略(1)的自顶向下的备忘录方式:
/*change.h*/ #define MAX 20 /* v:找零面值数组 x:需要找零的数 i:f(x, i)中的i,表示用v[1],v[2],...,v[i]中找零x length:找零面值数组的长度 c:保存f(x, i)的值,c[x][i]对应f(x, i),调用该函数之前对其所有元素赋值-1,表示没有被求解 */ int change_dp_memoization(int v[], int x, int i, int length, int c[][MAX]) { if(c[x][i] >= 0) return c[x][i]; if(x == 1) { c[x][i] = 1; return c[x][i]; } if(x == 0) { c[x][i] = 0; return c[x][i]; } if(i == 1) { c[x][i] = x; return c[x][i]; } c[x][i] = (change_dp_memoization(v, x, i-1, length, c), x/v[i]+change_dp_memoization(v, x%v[i], vmax(x,v, length), length, c)); return c[x][i]; }
分治策略(2)的自顶向下的备忘录方式:
/*change.h*/ /* c:保存c(x)的值,c[i]与c(i)相对应,调用之前所有元素赋值为-1表示没有被求解 */ int change_dp_memoization2(int v[], int x, int length, int c[]) { int i; int temp; if(c[x] >= 0) //if c[x] has caculated return c[x]; if(x == 0) return 0; c[x] = x; for(i=1; i<length; i++) { if(x >= v[i]) { temp = change_dp_memoization2(v, x-v[i], length, c) + 1; if(temp < c[x]) c[x] = temp; } } return c[x]; }
(2)自底向上的方式
自底向上的方式与自顶向下刚好相反,我们先求解规模小的问题,然后逐层递进,在求规模稍大的问题时,需要用的规模较小的问题已被解决。这里我们要区分自顶向下和自底向上两种不同的方式。自顶向下的方式在求解大问题时,其需要的小问题可能还没求解,可能已被其他稍大的问题所解决,所以我们需要一个数组来保存那些已求解的。而自底向上的方式在求解一个稍大问题的之前,保证比其规模小的所有问题都已解决,这样再求解大问题时,其所包含的小问题都已求解,直接拿来用。还有一个就是,自顶向下一般用递归实现,因为在求解大问题的时候,有的小问题还没有求解,只能递归计算,上面我们已经看到了,可以直接拿分治策略的算法加上备忘录机制就行了,而自底向上一般用迭代实现,逐层向上,直至你所求的问题。
分治策略(1)的自底向上的方式:
/*change.h*/ //int所能表示的最大整数 #define INFINITY (unsigned int)(1<<(sizeof(int)*8-1))-1 /* c:调用之前赋值为INFINITY,表示没有被求解过 */ int change_dp_bottomup(int v[], int x, int i, int length, int c[][MAX]) { int m, n; int imax; //当x=0时,不管m为多少,都c[0][m]=0,后面稍大的问题会用到此解,必须事先赋值 for(m=0; m<length; m++) c[0][m] = 0; for(m=1; m<=x; m++) { for(n=1; n<=i; n++) { imax = vmax(m, v, length); //这里必须做此判断,因为要保证是从v[1],...,v[i]中找零x,而不是从v[1],...,v[imax]中找零 if(imax > n) c[m][n] = min(c[m][n-1], m/v[n] + c[m%v[n]][n]); else c[m][n] = min(c[m][n-1], m/v[imax] + c[m%v[imax]][n]); } } return c[x][i]; }
分之策略(2)的自底向上的方式:
/*change.h*/ int change_dp_bottomup2(int v[], int x, int length, int c[]) { int i, j; int temp; for(i=1; i<=x; i++) { c[i] = i; for(j=2; j<length; j++) { if(i >= v[j]) { temp = c[i-v[j]] + 1; if(c[i] > temp) c[i] = temp; } } } return c[x]; }
3、贪心选择
这里我们给出的找零面值数组v[]是满足贪心选择性质的,这里就不证明了。其实只要2v[i]<=v[i+1],就满足贪心选择性质。如果不满足2v[i]<=v[i+1],则找零问题不能用贪心算法解决。比如v[]={0, 1, 2, 5, 8, 10, 20, 50}, x=16, 最优解应该为2,即用两个8面值的硬币找零,而贪心算法的解则为10, 5, 1。下面给出贪心算法的代码:
/*change.h*/ int change_greedy(int v[], int x, int length) { int count = 0; int i; while(x > 0) { i = vmax(x, v, length); count += x/v[i]; x = x%v[i]; } return count; }