算法导论第二章——算法基础

2.0开始

这一章有些可以学的东西,当然还是比较基础的。

2.1插入排序

定义

这里不需要什么正规的定义,我就来给大家把这个概念讲清楚,大家理解插入排序是个怎么回事就行了。
它就像是摸牌、理牌一样,比如我们摸到了“7”,那么我们会把它插在“6”、“8”之间(如果有的话,没有的话就可以插在比如“5”和“9”之间),也就是说我们每次取出一个新的元素依次和左边的元素进行比较,然后确定它的位置。

伪代码

为什么要采用伪代码(如果不知道伪代码,自行百度)?
很明显,本书的名字叫《算法导论》,而不是《Java算法导论》或是《C++算法导论》,所以本书是面向各种编程语言的,所以采用伪代码。
这里我们延续作者的习惯,依然采用伪代码。

INSERTION-SORT(A)
for j = 2 to A.length
    key = A[j]
    //将A[j]插入有序(已经排好序)的数列A[1..j-1]
    i = j-1
    while i>0 and A[i]>key
        A[i+1]=A[i]
        i=i-1
    A[i+1]=key

原书的注释是英文的,这里我把它翻译过来了。
这里理解起来应该还是比较好理解的。
就是对那个原理的直接模拟,没有什么需要过多解释的。

循环不变式

这个东西是用来证明算法的正确性的。
看上去是不是很高大上。
那么什么是循环不变式?
明确的定义跟我们无关(原数也没讲),我们会使用即可。
它既然是用来证明算法的正确性的,那么我们应该怎么证明呢?
我们需要证明一下三条性质:

  1. 初始化:循环的第一次迭代之前,它为真
  2. 保持:如果循环的某次迭代之前它为真,那么下一次迭代之前它为真。
  3. 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。

是不是有点迷糊?“它”是什么?
别急,让我为各位做个使用的例子。
其实它有点像数学归纳法。
现在我们要证明插入排序的正确性。

  1. 初始化:在第一次循环前,我们有序数列里只有一个数字,所以一定是有序的,所以循环不变式成立。
  2. 保持:如果本次循环开始前,有序数列是有序的,那么通过我们的一次循环,我们可以将新元素加入到正确的位置,使得在下次循环之前,有序序列依然是有序的。
  3. 终止:当循环结束时,我们的有序序列是A[1..n],也就是整个序列,也就是说目标序列已经有序了,所以排序完成。

这就是我们神奇的证明过程。
应该还是比较好懂的,懂了就好。

2.2分析算法

时空观

我们在OI中,一般会分析算法的两个方面,那就是时间和空间。
很显然,因为题目通常会从这两个方面限制我们,迫使我们采取更优的算法。
那么我们如何分析算法呢?
空间方面,自己估算一下即可,看看自己开了多大的数组(我们一般都会看这个,因为我们在算法程序中主要且经常用到的就是数组了,小的变量就不必要计算了,因为你通常不会把空间卡的这么死,出题人也不会),然后用不同变量的大小,计算一下即可。
举个例子,计算一个10000的int数组。
一个int是32bit,也就是4Byte,所以数组占用空间也就是40000Byte,也就是39.0625KB.
所以说,这个是很简单的。
下面我们看看怎么算时间。
时间,我们以基本运算次数和它对应的代价的乘积来衡量。
我们以插入排序算法为例。
以下是它每一步的基本运算次数和代价。

INSERTION-SORT(A)                                代价    次数
for j = 2 to A.length                            a      n
    key = A[j]                                   b      n-1
    //将A[j]插入有序(已经排好序)的数列A[1..j-1]    0      n-1
    i = j-1                                      c      n-1
    while i>0 and A[i]>key                       d      每次寻找次数之和
        A[i+1]=A[i]                              e      每次寻找次数减一之和
        i=i-1                                    f      每次寻找次数减一之和
    A[i+1]=key                                   g      n-1

抱歉中间有几个懒得用公式了,直接汉字叙述。
也就是说我们只需要看一下一个语句要执行多少次,在和它对应的代价,进行综合运算即可。
这里的n是指问题规模,比如这里就是指输入数列(待排序数列)中数字的个数。
但是聪明的你一定会发现一个问题,那就是中间那几个汉字打的是不确定的,我们怎么去计算?
所以这里我们就要涉及到我们下一个话题了。

最坏情况和平均情况

我们一般情况下只关心最坏情况,特别是在竞赛中,原因有两点:

  1. 出题人如果这么写数据范围:“30%n<1000;100%n<100000000”,那么,我用我的做题经验告诉你,另外70%的n一定只比一亿少一点,基本不会低于90000000
  2. 依据第一点,你在算你的满分算法是不是满分,一定得按最坏的情况算,否则你会体验到什么叫“大起大落”

所以我们一般会计算最坏情况,偶尔会用到平均情况。
以下是作者的话:

在本书的余下部分中,我们往往集中于只求最坏情况运行时间。

增长量级

前面我们计算时间的时候计算的很详细,但是,其实我们在比赛中不会这么详细的计算(像我这样只能拿省一的蒟蒻根本没时间去算得那么精细),我们一般会把代价忽略掉,直接计算基本运算次数。
但是这个过程依然很烦。
我们先来看个例子。
假设一个算法的时间T(n)=n^10+n+1
我们列个表计算一下:

n                    T(n)
1                    1+1+1
2                    1024+2+1
3                    59049+3+1
4                    1048576+4+1
5                    9765625+5+1
6                    60466176+6+1  
…                   …
100                  100000000000000000000+100+1
…                   …

有什么发现?
仅仅到100,我们发现n^10这一项已经大的惊人,而n和1,相比之下却小得可怜。
我们试想一下,我们在竞赛题中,n的规模都是上百万的,试想一下,这时的差距,后面两个小得可以忽略。
为什么会这样?
我们看一下他的导数就知道了。
所以,我们往往只关注一个多项式中,随着n的增长,增长最快的一项。
比如对于那个例子,我们只关注n^10,我们记作Θ(n^10),称作那个算法的增长量级。

2.3设计算法

2.3.1分治法

我们这里用递归去实现分治算法。
分治算法中的递归,每一次递归都有三步:

  1. 分解原问题为若干子问题,这些子问题是原问题的规模较小的实例
  2. 解决这些子问题,递归地求解各个子问题。当子问题足够小时,直接求解
  3. 合并这些子问题的解成原问题的解

我们的递归算法也是这样:

  1. 分解:分解待排序的n个元素的序列成给具有n/2个元素的两个子序列。
  2. 解决:使用归并排序递归地排序两个子序列
  3. 合并:合并两个已排序的子序列以产生已排序的答案

我们先看一下代码:

MERGE(A,p,q,r)
n1=q-p+1
n2=r-q
let L[1..n1+1] ans R[1..n2+1] be new arrays
for i=1 to n1
    L[i]=A[p+i-1]
for j=1 to n2
    R[j]=A[q+j]
L[n1+1]=∞
R[n2+1]=∞
i=1
j=1
for k=p to r
    if L[i]≤R[j]
        A[k]=L[i]
        i=i+1
    else 
        A[k]=R[j]
        j=j+1

这就是全部算法。
我针对1个点讲一下。
就是中间的那个无穷大。
为什么要设计这个元素?
因为我们合并是一次比较两个子序列中剩下的元素中最小的那一个,那么我们怎么知道一个序列已经空了呢?
一个是我们可以去判断,但是为了更加方便,我们设置一个无穷大,即使这个序列已经空了,但是另一个非空的序列中的那些数字一定比无穷大小,所以会依次放进去,就不会影响了。
当然我们也可以用循环不变式去证明,这里就不详述了。

2.3.2分析分治算法

这里我们用递归树就可以分析。
也就是一棵每个子结点存放所需时间的树。
其实用数列求和就可以算出来了。
结果是Θ(nlogn)

2.∞结束语

这一章值得学习的东西还是不少的,大家多看一看,自己理解理解。
我对于书中某些重要知识点进行了扩展,希望各位能理解得更透彻。

猜你喜欢

转载自blog.csdn.net/cggwz/article/details/79313354