1. 斐波那契数列
分治法是通过将原问题不断分解为多个子问题,通过对这些子问题求解再将子问题合并得到原问题的解,这样求解可以不断减少数据的规模,不断的简化原问题直到能够轻易求出解。因此分治法是一个很简单的算法也是很基础的算法,但是分治法并不是在所有问题上都很有效的,对于一些问题,使用分治法求解可能会消耗大量的时间,这时使用分治法是不合适的。
比如下面这道题,使用分治法就会非常的耗时,以至于超出一些题目给定的时间范围导致运行超时。
问题:
求解斐波那契数列
斐波那契数列简单说就是第i个数等于第i-1和第i-2个数之和,也就是斐波那契数列中的每个数都是前两个数之和,不包括第1个第2个数(第1和第2个数都为1)。
使用分治法的过程是,将求解斐波那契数列第i个数分解为求解第i-1和第i-2个数,将这二者之和相加即为第i个数的值。
public int getFibo(int n){
if(n == 1 || n == 2)
return 1;
return getFibo(n - 1) + getFibo(n - 2);
}
这样求解会出现什么问题呢?我们先看看使用此方法求解第6个数的运行流程。
从上图中我们可以看到函数运行过程中有大量的重复计算,getFibo(4)、getFibo(3)都计算了两次以上,对于数据规模较小的情况下,这种重复计算影响并不大,但当数据规模非常大的时候,这些重复计算的时间消耗就会非常大。
比如这个算法的时间复杂度:
T(1) = T(0) = 1
T(n) = T(n - 1) + T(n - 2) + 1
= T(n - 2) + T(n - 3) + T(n - 3) + T(n - 4) + 3
= T(n - 3) + T(n - 4) + T(n - 4) + T(n - 5) + T(n - 4) + T(n - 5) + T(n - 5) + T(n - 6) + 7
= T(n - k) + T(n - 1 - k) + 2^k - 1
令n - k = 1,T(n) = T(1) + T(0) + 2^k - 1 = n + 2^(n-1) = O(2^n)
可以看到这个时间复杂度达到了数据规模的指数级,这样的时间复杂度是我们不能接受的。
那么对于这种问题我们该使用什么样的算法来解决呢?
对于类似上面这种原问题由子问题的解得到,若干个子问题之间相互重叠的问题,我们通常会使用动态规划法来求解。
动态规划法的求解步骤如下:
首先将待求解问题划分为若干个相互重叠的子问题,根据子问题之间的重叠关系找到子问题满足的递推关系(动态规划函数)。
接着对每个子问题进行求解最优解,再将子问题的最优解填入动态规划表中,再之后需要再次求解该子问题的最优解时就可以直接从表中取出该解,避免了子问题的重复计算。
根据上面的步骤我们可以得到动态规划法求解斐波那契数列的步骤:
1. 划分子问题:将原问题分解为求解第i-1个数和第i-2个数;
2. 动态规划函数:本问题求解没有最优解,只需将两个子问题相加即可,递推关系式是getFibo(i) = getFibo(i-1) + getFibo(i-2);
3. 填写表格:根据动态规划函数设计表格,将每次计算的子问题的解填入表中。
可以看到,动态规划法与分治法有相似性,都是将原问题进行分解,对子问题进行求解来得到原问题的解,但与分治法不同的是,动态规划法有动态规划函数,通过动态规划函数来求解每个子问题的最优解,有每个子问题的最优解来得到原问题的最优解,这是动态规划法的关键。此外,动态规划法的填表也使得算法减少了很多重复的计算,避免了分治法的缺点。
下面我们来看看具体的代码实现:
public int getFibo(int n){
if(n == 1 || n == 2)
return 1;
if(a[n] != 0)
return a[n];
a[n] = getFibo(n - 1) + getFibo(n - 2);
return a[n];
}
斐波那契数列求解不涉及最优解的问题,因此动态规划函数与分治法的合并相同,都是getFibo(i) = getFibo(i-1) + getFibo(i-2);不同的是动态规划法在求解子问题后会将解填入动态规划表中,对应代码:a[n] = getFibo(n - 1) + getFibo(n - 2),在下一次再次求解该子问题的解时就可以直接表中找到该解,避免了重复计算,具体步骤流程如下图:
斐波那契数列递归求解的过程类似二叉树的后序遍历,先求左子树,再求右子树,最后得到原结点,如上图,在getFibo(5)中左子树求解完getFibo(3)后,右子树getFibo(3)可以直接从表中取出该问题的解,无需再次计算,getFibo(4)也是同理,这样的执行过程就相当于剪去了右子树,只计算左子树,使得时间复杂度规模直接从O(2^n)直接降到O(log2(2^n)),即O(n),提升时非常巨大的。
2. 挖金矿问题
上面使用动态规划法对斐波那契数列进行求解并不能体现动态规划法最优子结构的特性,即每个子问题的解都是最优解,从子问题的最优解推到出原问题的最优解。下面我们来用一道例题来详细解释这个过程以及动态规划法的关键:动态规划函数 如何得出。
问题:
有多个金矿,每个金矿各有其价值,每个金矿各需一定数量的工人工作,现有n个工人,如何指派工人工作使得挖金矿获得的价值最大。
对于上面这个问题,假设金矿数为goldNum,工人数为workerNum,第i个金矿所需工人数为wNum[i], 从递归的角度理解,goldNum个金矿,workerNum个工人所能获得的最优解是(goldNum-1个金矿,workerNum-wNum[i]个工人所能取得的最大价值加上第i个金矿的价值之和) 与(goldNum-1个金矿,workerNum个工人所能取得的最大价值)二者的最大值。
如何理解上面这个动态规划函数呢?我们可以从原问题解的取值中理解,对于是否开第i个金矿,选择有两个,一个是派wNum[i]个工人去第i个金矿上工作,一种是不派工人去第i个金矿上工作。
前者的最优解相当于只有i-1个金矿,workerNum - wNum[i]个工人这个问题的最优解,再加上第i个金矿的价值,因为派工人去该金矿上工作了,所以能得到该金矿的价值,此时还剩下i-1个金矿,workerNum-wNum[i]个工人;
后者的最优解相当于i-1个金矿,workerNum个工人所能取得的最大价值,因为不开这个金矿,相当于没有这个金矿,工人人数不变。
这两种选择各能得到一个解,取二者的最大值即得到原问题的最优解。
两种选择的价值的计算同样是选择是否开剩下的金矿,得出最优解。
或者我们可以换一个角度理解,对每一个金矿我们都去考虑是否要开这个金矿,计算出开这个金矿的价值和不开这个金矿的价值,根据二者的值来判断是否要派工人去开这个矿,遍历每一个金矿,得到最优解。
从上面这个例子我们能看出动态规划的过程就是根据子问题的递推关系式计算出每个子问题的最优解,再由子问题的最优解得到原问题的解。
现在我们可以给出上述问题的解题步骤:
算法描述:
算法:挖金矿问题mining
输入:金矿数goldNum,工人数workerNum,金矿价值数组gVal[],金矿所需工人数组wNum[]
输出:挖金矿的最大价值
过程:
- 定义二维数组val[][]存储i座金矿,j个工人所能取得的最大价值并初始化val[i][0]和val[0][i]为0;
- 定义循环变量i并初始化为1,i从1到goldNum,重复执行如下操作:
- 定义循环变量j并初始化为1,j从1到workerNum,重复执行如下操作:
- 如果当前工人数小于当前矿所需工人数,则相当于没有开这个矿,val[i][j]=val[i-1][j]。
- 否则,val[i][j]等于不开这个矿的价值与开这个矿的价值的较大者。
- j++;
- i++;
- val[goldNum][workerNum];
算法实现:
这里采用的是填表法,而非递归,填表法的步骤即将每个不同取值的goldNum和workerNum的不同组合的解都计算出来,最终计算到原问题要求的goldNum和workerNum取值的解。
public int[][] mining(int goldNum, int workerNum, int[] gVal,
int[] wNum){
int i, j;
int[][] val = new int[goldNum + 1][workerNum + 1]; //存放i座矿,j个工人的最大价值
//java中整型默认为0,无需将val[i][0]和val[0][i]初始化为0
for(i = 1; i <= goldNum; i++){
for(j = 1; j <= workerNum; j++){
//当前工人数小于所需工人数则此矿无价值
if(j < wNum[i - 1]) //wNum从0开始存放
val[i][j] = val[i - 1][j];
//不挖这个矿的价值为val[i-1][j],挖这个矿的价值为val[i - 1][j - wNum[i - 1]] + gVal[i],
//需要取他们中的最大值
else
val[i][j] = Math.max(val[i - 1][j - wNum[i - 1]] + gVal[i - 1], val[i - 1][j]);
}
}
return val;
}
注意上面gVal[]数组是从0开始存储数据的,因此gVal[i-1]实际是第i个金矿的价值,wNum[]同理
测试数据:
得到的动态规划表如下:
从这个表中我们可以看出在i=1,j=5之前的所有子问题的解都是0,因为在工人数为5之前都不满足第一个矿的要求,因此无法开这个矿,接着在金矿数不变的情况下,不断增加工人数,判断能否再继续开第2个矿,如果能则判断开这个矿的收益与不开这个矿的收益哪个更大,取最大值(虽然在当前金矿数不变的情况下肯定是开这个矿价值更高,但这是在有多个金矿时就不一定了)。接着增加金矿数,在当前金矿数下不断增加工人数量,判断开这个矿的最大价值和不开的最大价值哪个高(开和不开的价值都是上面给出的动态规划函数计算得出)。这样循环遍历每个子集就能得到最终的答案了,从上图我们可以很直观的看出最终的结果是900。
3. 多段图最短路径问题
最后我们再来求解一道动态规划的例题。
问题:多段图问题最短路径问题
上图每个圆圈代表一个图的一个结点,每个箭头代表一条有向边,要求求解从结点0到结点9的最短路径。
解题步骤:
划分:
求解起点到终点之间的最短路径首先要求解起点到与终点有边的几个点之间最短距离,然后加上这几个点到终点的距离,则这几段距离中的最短路径即为起点到终点的最短距离。
比如下面这个多段图要求解0到9的最短距离首先要求解0到7的最短距离d1和0到8的最短距离d2,再将d1加上结点7到9的距离9,d2加上8到9的距离3,则原问题的解就是这二者之间的最小值。
逆向推导:
如图要求解0到9的最短路径,首先要求0到7的最短路径和0到8的最短路径,然后再取0到7到9和0到8到9这两段距离中的最短距离。而要求0到7的最短距离首先要求0到4、0到5、0到6的最短距离……直到0到1、0到2、0到3。这样就能得到0到9的最短距离了。
正向计算:
用一个辅助数组d[n]来存储0到i点的最短距离,i从1到n-1,每次循环计算0到该点的前几个点最短距离加上这几个点到该点的距离的最小值,这个最小值即为0到该点的最小值。
本题是从0开始填表,是自底向上的做法。
算法描述:
算法:多段图最段路径mutipleGraph
输入:图G(V,E)
输出:起点到终点的最段路径
过程:
- 定义一维数组d[]存储起点到其他点的距离,并将d[i]初始化为图中起点到该点的距离(没有路径则为无穷大)
- 定义循环变量i并初始化为1,i从1到n-1,重复执行如下操作:
- 定义循环变量j并初始化为1,j从0到i-1,重复执行如下操作:
- 如果d[i]的值小于起点到j再到i的距离,ze修改d[i]为d[j]+c[j][i]。
- j++;
- i++;
- 返回d[n-1]。
算法实现:
public static int fun(int[][] c, int n){
int[] d = new int[100]; //起点到其他点的距离
int i, j;
// int s, f; //起点和终点
input(0, n);
for(i=0; i<n; i++)
d[i] = c[0][i];
//System.out.println( d[n-1]);
for(i=1; i<n; i++)
for(j=0; j<i; j++){
if(d[j] + c[j][i] < d[i]){
d[i] = d[j] + c[j][i];
}
}
//System.out.println("起点到终点的最短距离为:" + d[n-1]);
for(i=0; i<n; i++)
System.out.println(d[i]);
//System.out.println( d[n-1]);
return d[n-1];
}
运行结果:
本题的图为特殊图(图的顶点分段排列,每段的顶点只与前一段的顶点有入边,与其他段及本段的点皆无入边),只需要考虑部分情况,因此该算法只适用于类似的多段图。可以看出该算法与Dijkstra算法中的权值更新类似,都是通过新加入的点来更新起点到其他点的最短距离,不过因此本题图的特殊性,本算法加入的点只需根据序号依次加入,无需像Dijkstra算法一样选取最短边。
通过上面的例题我们可以看出动态规划法有两种实现方法,一种是备忘录式,一种是填表法。
备忘录式(递归)是自顶向下的方法实现,对部分没有用到的子问题不会计算,程序实现起来简单。填表法是自底向上的的方法实现,需要对所有的子问题求解,可能会计算到部分无用的子问题。
比如上面的例题挖金矿问题就是填表法,对一些无用的子问题也进行求解了,比如上面表中第二行后面的子问题都是无用的子问题,因为当前情况是金矿只有一个,无论增加多少工人都不能增加最大价值,使用递归法就不会计算到这个问题。