计算机科学的终极目标是实现高效、低耗的计算。为此,我们需要设计出优良的数据结构来高效的利用计算机存储、组织、转化和传递数据。
计算是用来解决问题的手段,而算法则是解决问题的根本。因而,我们要在设计出正确的算法的同时,努力的降低相关计算的成本。
那么,我们就必须考虑两个问题:1.如何判断算法的正确性,即能否反映问题的需求并给出合理的答案?2.如何科学的估计(定性或定量)一套算法所产生的成本?
关于第一个问题,我们主要是依靠数学及形式逻辑的知识加以解决。显然,对于一个要解决问题的方法而言,具有正确性是基本的前提。但是这一方法是否可行,还取决于技术条件的限制,或者说,受到了计算能力的限制。如果一个方法需要的计算能力远大于现有水平,比如需要巨大的时间消耗和空间成本,那么它对于解决实际问题就毫无意义。
因此,我们就必须探讨第二个问题,如何比较不同算法之间的成本消耗,从而择优汰劣。算法的成本通常包括两方面,即时间成本和空间成本,前者往往是设计者最主要的考量。而对于算法的时间成本,我们所采用的估计方法是渐进分析。
所谓渐进分析,就是在RAM(randomaccess machine)计算模型下,估算操作次数T(n)相对于问题规模n的无穷阶数。在这里,我们采用大O记号、big theta 以及 big omega 这三种记号来分别表示当问题规模n趋于无穷大时,操作次数T(n)的上界无穷阶、等大无穷阶与下界无穷阶。通过这些符号的使用,有助于我们分析算法的时间复杂度,从而寻找出改良的途径。
鉴于所选实例的多样性和不确定性,我们通常考虑最坏情形下的时间复杂度,因此采用相应的大O 记号。常用的复杂度有常数阶O(1)、线性阶O(n)、平方阶O(n^2)、多项式阶O(n^m)、对数阶O(logn)、指数阶O(2^n)。通常认为,具有多项式阶及其以下复杂度的算法是有效的,而具有指数阶及其以上复杂度的算法则是无效的。
当我们设计算法时,往往采用这样的步骤:1.保证算法能运行(无语法错误)2.保证算法的正确性(无逻辑错误)3.提升算法的效率,降低成本。
一般来说,我们所处理的问题最大的难点往往在于问题的规模。为此,我们通常选择的方法是迭代和递归,而其核心的思想则是减治与分治这两种策略。
所谓减治,就是将规模为n的问题拆分成规模为(n-1)与规模为1的问题,并重复拆分直至问题规模退化为可处理的情形,这样能保证问题规模的递减性,从而使问题得到简化。我们经常使用的循环迭代与线性递归就是这一思想的运用。
所谓分治,就是将问题拆分成两个或多个规模更小的问题,同上,需重复拆分直至问题规模退化为一种或多种基本情形,再从基本情形出发,倒推出原问题的解。二分法就是这一思想的经典应用。
以上提及的两种思想其实都可以直接对应于递归算法,就是说,可以将问题的拆分策略及处理步骤直接“翻译”为递归的指令,而无须担心算法的正确性。但是,当我们从效率层面上来考察这种思路下的递归算法时,就会发现它的效率很可能是我们所不能接受的。原因就在于,这种直接翻译的算法中出现的每一个子问题都是独立处理的。这就意味着,一些可能完全相同的子问题将被多次处理,这就极大程度上的导致了效率的损失和资源的浪费。
例子1:fibonacci数列的表示
/*好了,唠唠叨叨的绪论大纲铺陈完毕,现在可以讲点有趣的算法和重要心得了*/
我们都知道,fibonacci数列的定义为每一项的值等于相邻前两项的值之和,其中头两项为0和1。乍一看,这一定义已经明显的给出了分而治之的思路,当我们求解第(n+2)项的值时,只需求出第(n+1)项和第n项的值,再求和就好;而每个子问题都可以靠相同的办法加以解决,如此一来只需用递归指令翻译这一过程即可。递归代码如下:
int fib (int n)
{
if (n< 2) //判断规模是否符合要求
{
if (n == 1)
return 1;
else
return 0;
}
returnfib(n-1)+fib(n-2); //若未达到规模要求,则分而治之,减小规模
}
事实上,这的确是正确的求法。但问题是,这一算法的可行性如何?我们会发现,当n逐渐增大时,算法的时间复杂度会接近指数阶O(2^n),原因是每进行一次问题的拆分,都会使得子问题的数量翻倍,如此一来,当问题规模最后缩减至1或0时,我们所面对的子问题数已经接近指数级了。而在现实的应用中,我们通常都认为指数级的复杂度是无法接受的。我们可以估算一下,当n取100时,2^n至少为10^10(实际上远大于此),而通常我们使用的CPU计算能力大概在10^9次方左右,也就是说,为了解决这一问题,我们至少要花费10s的时间。往下呢?随着n的继续增大,所付出的时间成本必然远大于此!因此,我们必须将这一算法进行改良。
仔细考察,不难看到前一算法的效率之所以低下,关键正是在于递归调用的过程中有大量的重复实例出现。实际上,当我们自底而上、由小而大的计算fibonacci数列每项的值时,就会发现每一步的计算其实都可以使用到前一步的计算结果。也就是说,只要将算法面向一个动态的计算过程,利用起每步的计算中所产生的结果,而非仅仅依赖于初始值,就可以避免重复的计算,从而实现效率的提升。这一思想就是动态规划。
按照这一想法,我们可以使用迭代的指令来改良算法。代码如下:
int fib (int n)
{
int i =1, f = 0, g = 1;
while(++i < n/2)
{
f = f + g; //计算奇数项
g = f + g; //计算偶数项
}
return(n % 2) ? f: g; //n为奇数时返回f,偶数时返回g
}
通过简单估算,我们发现,这一算法下的时间复杂度仅为O(n),相比于之前的算法简直是天壤之别!原因就在于动态规划实现了对过程中产生的结果的利用。
例子2:最大公共子串(LCS)
给定两个字符串a和b,求它们的最大公共字串的长度。若按照减而治之的思路,当a串的长度为m,b串的长度为n时,我们应当从将他们拆分成长度更短的字符串。具体地说,就是当a,b的末尾字符a[m-1]和b[n-1]相等时,去除末尾字符,使a、b的长度分别降为(m-1)和(n-1),之后再比较a串和b串的末尾;而当a[m-1]与b[n-1]不相等时,则可以分两种情形来讨论。一是去除a串的末尾,使a的长度为(m-1),将此时的a串的末尾再与b串相比较;二是去除b串的末尾,使b的长度为(n-1),将此时的b串的末尾再与a串相比较。如此一来,就可以保证a串和b串的规模一定缩减,直至退化为0,且涵盖了所有取公共子串的情形。因此,这一算法的正确性是毋庸置疑的。其代码如下:
int lcs (string a, string b)
{
int m,n;
m =a.size();
n =b.size();
stringc, d;
for (inti = 0; i < m - 1; i++) //取a串的(m-1)个字符
c[i] = a[i];
for (intj = 0; j < n - 1; j++) //取b串的(n-1)个字符
d[j] = b[j];
if (a[m-1]== b[n-1])
return lcs(c, d) + 1; //当a[m-1]等于b[n-1]时,长度计数加一
else
return (lcs(a,d) > lcs (b,c)) ? lcs(a, d): lcs(b, c);//当两个子串的lcs长度不同时,长度计数取更大者
}
然而,这里的减治算法的问题和之前fibonacci数列的分治算法是一样的,关键问题在于运行过程中产生了大量的重复实例。那么,我们的应对方法也是类似的,改用动态规划策略,将每个子串的公共项数都记录下来,从而利用起过程中产生的结果。具体实现如下:
int lcs (string a, string b, const int m,const int n) //m=a.size()+1, n=b,size()+1
{
intacc[m][n] = { 0 };
inti, j
for(i = 1; i < m - 1; i++)
for(j = 1; j < n - 1; j++)
{
if(acc[i][j-1] == acc[i-1][j])
{
if(a[i] == b[j])
acc[i][j]= acc[i][j-1] + 1; //当a[i]等于b[j]时,长度计数加一
else
acc[i][j]= acc[i][j-1]; //当二者不等时,长度计数不变
}
else
acc[i][j]= (acc[i][j-1] > acc[i-1][j]) ? acc[i][j-1]: acc[i-1][j];//当两个子串的lcs长度不同时,长度计数取更大者
}
}
容易发现,这一算法的时间复杂度仅为平方阶O(n^2),难度被大大的降低了。
以上两个例子给了我们关于算法设计的启示是,好的算法不仅应当恰当的反映问题的需求,并给出正确的解答,更关键的是要善于利用算法过程中的资源,避免重复浪费,从而达到事半功倍的效果。