算法研究(二) (刚刚写完,博文还没有完善好,大家先试着看,我明晚完善好,对不起大家了)
小议动态规划(DP)
首先声明一下,本人只是一个小菜鸟而已。。。。
写这些一方面为了加深自己的记忆,另一方面为了和大家共同探讨,仅此而已。
简单的通过比较典型的三个小型的动态规划问题来小议一下动态规划。几个例子的整个程序,我会在另一篇完整给出
问题一:装配线的调度问题:
问题描述:一个汽车工厂,有两条装配线,每个装配线上有n个转配站。每个装配站完成相同的工作,但由于历史的原因,各个装配站完成安装的时间不同。在正常情况下,两条装配线共同工作。但是在有急单的情况下就会在两个工作站之间挑一条最快的安装路线,以完成急单的要求,问题就是找出该最快的路线:
用i表示装配线标号的范围,用j表示装配站的范围。
工厂的装配线工作流程如下:
流程图注释:ai,j表示在经过装配站所用的时间,ti,j表示汽车从一个装配站所使用的时间。而e,x分别表示进入和出装配站所使用的时间。
问题分析:
寻找一条最快路径也就是寻找一条满足时间最短的最优解,我们的目的就是找到一个最优解,可以满足我们的要求
为了便于分析,我们用Si,j表示装配站。
假设S1,j为最快路线的一个站点。即可以通过S1,j来找到最优解。可以细想一下,则最快路线肯定经过S2,j-1或是S1,j-1,并且经过 S2,j-1或是S1,j-1的也肯定为最快路线。因为可以假设该路径不是最快路线,有另一条最快的路线。如果真的存在这样的一条路径,则我们可以将前段路径去除而接上这条最快路径,因为我们找的就是一条最快的路径(这种方法叫做剪贴思想,总结时会详细叙述)
通过简单的分析,我们又把寻找最优解的重担放在了S2,j-1或是S1,j-1上,经过同样的分析,我们还可以把最优解的问题向前推进,并且每次的问题都是一样的。这种反复求解最优解的过程,和我们的递归过程很类似,让我们想到了可以用递归的方法去做。
在递归的过程中,在j=1时,无论通过装配线1还是2,都是首先将装配线送入装配线上。设以装配线1开头的花费的时间为f1[n],
装配线2开头的是f2[n]
我们根据分析写出相关的递归公式:
通过上面给出的递归式很容易能够写出相应的递归算法,但是和一般的递归算法一样,它的执行效率非常的低,执行时间是n的指数级别的,很明显这样做不符合我们的要求,我们必须想办法改进。
我们的办法就是通过建立一张表格将我们每次用的数据存储起来,不必要每次都去计算,这样就可以大大的节省运行所需的时间。
根据我们的思维可以做出以下的操作:
//每次求得的都是花费时间最短的结点
for(int j = 2; j <= n; j++)
{
int m1 = f1[j-1] + a1[j];
int n1 = f2[j-1] + t2[j-1]+ a1[j];
if(m1 > n1)
f1[j] = n1;
else f1[j] = m1;
int m2 = f2[j-1] + a2[j];
int n2 = f1[j-1] + t1[j-1]+ a2[j];
if(m2 > n2)
f2[j] = n2;
else f2[j] = m2;
}
f1[n] += x[1];
f2[n] += x[2];
int min = f1[n] > f2[n] ? f2[n]: f1[n];
通过对上面一个简单的例子叙述,相信大家对动态规划(Dynamic Programming)有了一定的认识。
现在就来具体讲解一下动态规划的概念:
和分治法一样,动态规划是通过组合子问题而来解决整个问题的。分治算法是指将问题分成一些独立的子问题,递归的求解各个子问题。然后合并子问题的解而得到原问题的解。
动态规划不同的是,动态规划,适用于子问题不是独立的情况,也就是各子问题包含公共的子子问题。在这种情况下,若用分治法会造成许多不必要的工作,即重复地求解公共的子问题。动态规划算法对每个子问题只求解一次,并将其结果保存在表中(如上例子中为求最短路线的记录而设计的表),从而避免每次遇到的各个子问题是重新计算(递归的方法即是重复计算)。
要设计一个动态规划,可分为以下四个步骤:
1、 描述最优解的结构
2、 递归定义最优解的值
3、 按自底向上的方式计算最优解的值
4、 由计算出的结果构造一个最优解(比如上个例子的最快路线)
我们又是不用构造一个最优解,即第四步不一定要用到,只要求得最优解即可。我们上面的例子就是一个。在下面的例子中我们将会都涉及到构造一个最优解。
动态规划会经常用到一种判断最优的方法:
剪贴法:如果我们认为这个不是最优的,则我们一定可以通过一定的方法将现在不是最优的方式剪下来从而粘贴上最优的结构
二、矩阵链乘法的最少计算次数
先来跟大家介绍一下相关的问题及概念:
我们不经要问是不是每种相乘所用的运算次数都是一样的?
答案很明显是不一样的。
举个例子来说明一下:三个矩阵的链 ,维数分别是10X100,100X5,5X50。
如果按照 这种顺序来做乘法,总共需要运算次数:
10X100X5 + 10X5X50 = 7500
按照另一种顺序:
总共需要的运算次数:100X5X50 + 10X100X50 = 75000
通过上个例子可以看出来不同的运算次数运算次数竟然相差了十倍,要是维数更大矩阵链更长,则会导致运算次数有着很大的区别。所以有必要通过算法求出最少的运算序列。
在讲解通过动态规划求出最佳的加括号的方法前,我们应该能够认识到通过列举所有的加括号的方案不是一个好的算法。
利用动态规划的步骤讲解矩阵链乘法的加括号的方法:
1、最优加括号的子结构
2、根据所得的最优子结构求出一个递归的解
通过上面的分析我们可以很容易写出上面的递归的解的递归式:
3、计算最优代价:
现在我们把最优子结构和相应的递归式都写完了,就是求解最优代价的时候了。我们在第一个问题装配线中就已经提到了,如果直接写递归函数,那么递归函数的运行时间将会是指数级的。还是同样的办法,我们通过一张表格将其中计算的结果记录起来,不用每次运算时都重新进行计算,从而节省运算时间。
代码:
for(int l = 2; l<= n; l++)
{
for(int i = 1; i <= n -l + 1; i++)
{
int j = i + l - 1;
m[i][j] = INT_MAX;
for(int k = i; k<= j-1; k++)
{
int q =m[i][k] + m[k+1][j] + p[i-1]*p[k]*p[j];
if(q <m[i][j])
{
m[i][j]= q;
s[i][j]= k;
}
}
}
}
4、构造一个最优解
这一步根据不同的需要可能需要也可能不需要,并且身为菜鸟的我,这一步我一直没法总结的很好,因为感觉这里挺难想的,要是有幸被大牛看到,可以告诉我一下这里该怎么去想。小鸟定当感激不尽!所以这里我就不多说了,直接上代码:
void PRINT_RESULT(ints[20][20], int i, int j)
{
if(i && i == j)
cout<<"A"<<i;
else
{
cout<<"(";
PRINT_RESULT(s, i, s[i][j]);
PRINT_RESULT(s, s[i][j] + 1, j);
cout<<")";
}
}
四、最优二叉查找树
最优二叉查找树和赫夫曼编码有几分相似,思维方式也有一点像,但是赫夫曼编码不是二叉查找树,还有就是一个是贪心一个是动态规划而已。赫夫曼编码我在下一篇博文会详细分析,这里就不再多说了,到时大家自己可以比较看看吧。
最优二叉查找树的定义:给你n关键字互异的序列,并且每个关键字出现的概率给出,利用这些结点并根据其概率组建一棵二叉查找树,使其查找各个结点所用的代价最小,该二叉查找树就叫做最优二叉查找树。(完全个人总结-见谅)
在未分析以前,先给出一些有关最优二叉查找树的相关需要知道的知识:
3、 由每次搜索要么成功,要么失败可以得出:
4、 由上面的分析可以写出一棵树的搜索代价(深度+1表示搜索结点的个数,因为第一个深度为0):
该式可由公式一整合得到
画一幅图说明一下:
了解了必要的相关知识后,我们现在来根据动态规划的步骤一步步解决这个问题:
1、 最优二叉树的结构:
2、 写出一个递归解
了解了最优子结构后,我们就可以根据最优子结构写出我们想要的递归解了。
这里要思考一下,当一棵树成为一个结点(注意只是一个结点)的子树时,它的搜索代价会怎么变化呢?
因为是成为一个结点的子树,所以该子树中所有结点的深度增加1,由上面的公式二可知,这个子树的期望的搜索代价增加了一个值,而该值正是该子树中所有概率的和(为什么呢?可能有的读者没法理解,我给大家说明一下:原本一棵树的代价是由公式二求得的,现在又在该子树的根结点之上增加了一个结点,求解的时候公式变为这种形式: ,而根据公式一,该公式还可以再变形为 这不就相当于在原来的基础上增加了所有结点的概率之和了吗,这是搜索关键字的形式,再加上虚拟键的部分就是所有键的概率之和了,这下应该就能够明白了吧)。
3、 计算最优二叉查找树的搜索代价的期望
还是同样的问题,我们不可能直接根据递归公式写一个递归函数,那样运算时间太大,很明显是不满足要求的。我们还是采用填表的方式,将数据存储在表中。
再用一个表格用于存储选择的根结点的位置:
根据分析可得下列代码:
for(int l = 1; l <= n; l++)
{
for(i= 1; i <= n - l + 1; i++)
{
intj = i + l -1;//每次比较的范围:i~j
e[i][j]= 100000;//假设不大于10000
w[i][j]= w[i][j-1] + p[j] + q[j];
for(intr = i; r <= j; r++)
{//逐个试探根结点
doublet = e[i][r-1] + e[r+1][j] + w[i][j];
if(t< e[i][j])
{
e[i][j]= t;
root[i][j]= r;
}
}
}
}
大家是不是感觉这个代码和上一个问题矩阵链乘法有点相似呢?大家可以仔细揣摩一下,两个代码的不同之处?我发现分界点就不一样,哈哈,大家的意见呢?
五、最长公共子序列(LCS)
相信大部分读者对这个算法都有一定了解或是很熟悉,不管怎么样,我还是简要分析一下吧,已经很熟悉的可以略过。。。。
值得大家注意的就是:最长公共子序列和最长公共子串的区别。说的简要一点就是子串是连续的而子序列是可以不连续的。
首先大家得了解一个定理(英文字母后面跟的是下标):
设X =<x1, x2, x3…xm>和Y = <y1,y2,y3…yn>为两个序列,并设Z= <z1,z2,z3..zk>为X,Y的任意一个LCS。
(1)如果xm == yn,那么zk == xm == yn,而且Zk-1是Xm-1和Yn-1的一个LCS.
(2)如果xm != yn,那么zk != xm蕴含Z是Xm-1和Yn的一个LCS
(3)如果xm != yn,那么zk != xn蕴含Z是Xm和Yn-1的一个LCS
证明:(1)如果zk != xm,则可以添加xm = yn到Z中,以得到X和Y的一个长度为k+1的公共子序列,这与Z是X和Y的最长公共子序列的假设相矛盾。因此,必有zk = xm = yn.此时前缀Zk-1是Xm-1和Yn-1的长度为(k-1)的公共子序列,证明它就是LCS,为导出矛盾,假设Xm-1和Yn-1是一个长度大于k-1的公共子序列W,那么将xm = yn添加到W上就会产生一个X和Y的长度大于k的公共子序列,得到矛盾。
(2)如果zk != xm,那么Z是Xm-1和Y的一个公共子序列。如果Xm-1和Y有一个长度大于k的公共子序列W,则W也应该是Xm和Y的一个公共子序列,这与Z为X和Y的一个LCS的假设矛盾。
(3)证明和(2)基本相同不再赘述
根据这个定理就可以直接写出相关的递归表达式:
根据递归式,同样用一张表格来记录每次运算的数据,从而可以得出下列代码:
for(i = 1; i <= m; i++)
{
for(j= 1; j <= n; j++)
{
if(X[i]== Y[j])
c[i][j]= c[i-1][j-1] + 1;
else
{
if(c[i-1][j]>= c[i][j-1])
c[i][j]= c[i-1][j];
elsec[i][j] = c[i][j-1];
}
}
}