课程笔记 01:数据结构(清华) 绪论

本文探讨了高效算法设计的重要性,强调了算法正确性和成本控制,并通过斐波那契数列和最大公共子串问题,展示了动态规划如何优化算法效率。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

计算机科学的终极目标是实现高效、低耗的计算。为此,我们需要设计出优良的数据结构来高效的利用计算机存储、组织、转化和传递数据。

计算是用来解决问题的手段,而算法则是解决问题的根本。因而,我们要在设计出正确的算法的同时,努力的降低相关计算的成本。

那么,我们就必须考虑两个问题: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),难度被大大的降低了。

以上两个例子给了我们关于算法设计的启示是,好的算法不仅应当恰当的反映问题的需求,并给出正确的解答,更关键的是要善于利用算法过程中的资源,避免重复浪费,从而达到事半功倍的效果。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值