《算法导论》第二章 算法基础 个人心得——从插入排序到初识分治法

算法基础

写在前面的废话 :

  1. 这是我基于算法导论(第三版)这本书写的第一篇心得。我写博客完全是随缘的状态,想写什么写什么。正好最近开始看《算法导论》,于是就以此书为基础,将在我学习过程中的所见所闻、心得体会写在博客之中。一方面为同样在学习这本书的人提供一个参考,另一方面也让我自己可以清楚的梳理脉络,提高学习效率——个人认为,学习的最有效率的方法是讲出来。当你能给别人讲明白时,说明自己也明白了。为什么不从第一章开始写???因为第一章主要是概念类的东西,看看就好,此处不赘述。

  2. 《算法导论》还是《算法(第四版)》???

    在这里插入图片描述 在这里插入图片描述

    这两本书都是写算法的神书,两个没有必要都学。之前也纠结过一段时间,综合了网上的各种评价,《算法导论》数学证明比较多,偏学术;《算法》重点在于工程应用。对于编程者来说,直接用《算法》就可以了。对于我来说,由于我在读研究生,因此多了解一些算法中的数学思想是很有帮助的,因此我选算法导论。当然,你要是看到了这里,说明你也大概率学了算法导论。要是两个都学的请收下我的膝盖,或许我会在学完这本书之后有时间啃一啃另外一本,现在应该没什么机会。

  3. 我在学习算法导论前的知识基础:虽然刚开始学,但是我之前大致翻了一下,这本书里面的一些数学论证用到了高等数学的知识。此外对于栈、队列、图之类的数据结构基础讲的没那么细。因此,《算法导论》这本书,上来就让你啃,你肯定会觉得难,学着学着就慢慢就放弃了学习的信心。所以在学习算法导论之前,应该先看看一些预备知识的书。首先,大学的高数、线代、概率论要好好学学,不仅对这本书,对于别的方向也有很大作用。此外,我之前学习了《数据结构》(清华大学严蔚敏版本),和《算法图解》。《数据结构》是官方教材,是我在考研时学的。由于当时我本科并不是计算机专业,而要跨考计算机研究生,所以学起来很吃力。而《算法图解》就容易的多了。我由于有了数据结构的底子(其实也没多少,学得不好QAQ),以及当时正在学python,所以对于这本书我学起来还是很容易的(其实没有基础学起来也不费劲)。《算法图解》通俗易懂,把算法的一些概念方法用生活中通俗的例子以图画的形式来表达,更容易引起学习兴趣。对于代码方面也有一定底子,像C、Python等。当然这些都没看过,想要硬看这本书也不是不可能,有人也成功过。但是我觉得,磨刀不误砍柴工,有了知识储备,你能学习的更好,对于一些知识理解的也会更快更深刻。

  4. 我不是什么大牛,只是刚起步的新人。刚开始学习,难免有错误之处,恳请批评指正,大家一同进步,一同提高。

下面,正文开始。

在这一章当中,首先给出了一个排序方法——插入排序,用该方法来引入‘’循环不变式‘’这一思想,说明如何证明算法正确并作为例子分析运行时间。然后,引出算法中一个非常重要的方法——分治法,同时,利用分治法的思想,开发出归并排序,并进行分析。

一、插入排序

首先了解一下什么是插入排序:插入排序是一种排序方法,指在待排序的序列<a1,a2,…an>中,假设前面i-1(其中i>=2)个数已经是排好顺序的,现将第i个数插到前面已经排好的序列中,然后按照要求(升序或降序)找到合适自己的位置,使得插入第i个数的这个序列也是排好顺序的,按照此法对所有元素进行插入,直到整个序列排为有序的过程。最终输出为<a1’,a2’,…an’>,满足a1’≤a2’≤…≤an’(或a1’≥a2’≥…≥an’)。其中,希望被排序的数成为关键字。由于i>=2,所以一般关键字于第二位开始依次向后。

插入排序的工作方法如下图:
插入排序工作流程
严格来讲图中所谓的“空位置”其实并不空。按照代码,在插入之前以前的数字仍然存在,“插入”只是用关键字覆盖原来数字。但是为了便于理解,在此将移动后的位置看作为“空位置”。同时,对于第一步,你也可以把索引为0的单独一个数看成有序。
工作原理搞清楚了,接下来是代码。由于原书给的是伪代码,这里就不展示了。在此基础上我用Python3写了个代码。

array = [5,2,4,6,1,3]
length = len(array)                             #数列长度
#升序排列
for j in range(1,length):                       #从第二个到最后一个迭代
    key = array[j]                              #选为关键字
    i = j-1                                     #从关键字的前一个开始
    while i>=0 and array[i]>key:                #与关键字比较,且限制在数列内                          
        array[i+1] = array[i]                   #大于关键字,将该数字向后移动(前后对调)
        i-=1                                    #继续判断前一个与关键字的大小关系
    array[i+1] = key                            #将关键字插入空位置,
    print(array)

#降序排列
for i in range(1,length):
    key = array[i]
    j = i-1
    while j>=0 and array[j]<key:                #原理相同,判定条件更改
        array[j+1] = array[j]
        j-=1
    array[j+1] = key
    print(array)

以上代码可以用列表解析等继续优化,或者是改为手动输入序列以及封装函数,有兴趣可以进一步探讨。
而在插入排序的过程中,我们注意到,在算法每次循环后,前n个数一定是排好顺序的(n为循环次数),即在算法的循环过程中总是存在一个维持不变的特性,这个特性一直保持到循环结束乃至算法结束。这就引出了一种思想,我们称为循环不变式。

二、循环不变式

当然,以上是我个人理解。书中对于循环不变式的概念给的很模糊。看了很多这方面的文章,不同作者理解的角度也不同。我们重点是知道循环不变式用来帮助理解算法的正确性。关于循环不变式,我们必须证明三条性质:
初始化:循环的第一次迭代之前,它为真。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
循环不变式类似于我们在数学中学过的数学归纳法,我们来复习一下:

最简单和常见的数学归纳法是证明当n等于任意一个自然数时某命题成立。证明分下面两步:
证明当n= 1时命题成立。
假设n=m时命题成立,那么可以推导出在n=m+1时命题也成立。(m代表任意自然数)
这种方法的原理在于:首先证明在某个起点值时命题成立,然后证明从一个值到下一个值的过程有效。当这两点都已经证明,那么任意值都可以通过反复使用这个方法推导出来。

而循环不变式的前两条性质正对应于数学归纳法的两步。首先证明一个基本情况成立,其次证明一个归纳步成立。
循环不变式的第三个性质应该是最重要的,也是他区别于数学归纳法的一点。在数学归纳法中,归纳步基本要无限的使用,因此没有终止。而在算法中,一般不能无限的循环下去,必须要有终止步。当循环终止时,停止“归纳”。

让我们以插入排序为例,看看如何证明这些性质成立。
初始化:首先证明在第一次循环迭代之前,循环不变式成立。对于A=[0,1,2,…,n-1],在第一次循环之前,即下标j=1时,子数组 A[0,…,j-1] 仅由单个元素 A[0] 组成,该子数组是有序的(显然有序),其中,A[j] 为关键字(这里是 A[2] )。这表明第一次循环之前循环不变式成立。
保持:其次证明第二条性质,证明每次迭代保持循环不变式。for循环内从关键字左边第一个开始,将 A[j-1] 、A[j-2] 、A[j-3] 等依次向右移动一个位置,直到A[j]找到合适位置,把A[j]插入到该位置。而这时子数组A[0…j]仍然由原来的A[0…j]中的元素组成,但已按序排列。因此,对for循环的下一次迭代增加j将保持循环不变式。
终止:下面看看循环终止条件:j>A.length-1=n-1。因为每次循环j都会+1,那么最终必有 j=n。将j用 n代替,即 A[0…j-1] 变为 A[0…n-1](为什么是 A[0…j-1] 而不是 A[0…j]?因为每次迭代关键字都是j,在循环迭代之前即前j-1个数,故是 A[0…j-1]。),得到:子数组 A[0…n-1] 由原来的 A[0…n-1] 中的元素组成,但已有序。由于 A[0…n-1] 就是整个数组,所以整个数组已排序。因此算法正确。

三、分析算法

当一个算法设计好之后,我们要进行算法的分析。分析算法的作用是预测算法所需要的资源。在这里我们主要指的是计算时间。分析算法必须有一个要使用的实现技术的模型。同时,我们想要一种书写和处理都比较简单的表示方法,而且能表明算法资源需求的重要特征。

一般来说,算法需要的时间和输入规模呈正相关——规模越大,所需要的时间越多。“输入规模”在不同问题中的定义也不同,最自然的度量是输入中的项数。“运行时间"指的是执行的基本操作数或步数。下面,以插入排序为例,分析一下算法运行时间。在这里,我们假设第 i 行每次执行需要代价 Ci。首先我们给出插入排序核心部分中每条语句执行时间和次数,其中 n =A的长度(A.length),tj 表示对 j 执行while循环的次数,取值为1~j。for或while循环退出时,执行头部测试的次数比循环体次数多1。我们可以得到:
在这里插入图片描述

该算法运行时间是每条语句运行时间之和。每条语句运行时间是代价×次数。总运行时间为:
在这里插入图片描述
当然,以上只是普遍情况下插入排序算法所需要的时间。若输入数组已经排好序,则出现最佳情况。这时,对每个 j=1,2…,n-1,在while判定时,当 i 取初始值 j-1 时,都有 A[j]<=key。对任意 j ,有 tj=1。故最佳运行时间为:
T(n)=C1n+C2(n-1)+C3(n-1)+C4(n-1)+C7(n-1) = (C1+C2+C3+C4+C7)n-(C2+C3+C7)
可表示为an+b,为n的线性函数。

若输入的数组反向排序,即递减序,则出现最坏情况。我们必须将每个元素 A[j] 与整个子数组 A[0…j-1]中的每个元素进行比较。所以对于 j=1,2,3…,n-1,有 tj=j。注意到:
在这里插入图片描述
在最坏情况下,插入排序运行时间为:T(n)=C1n+C2(n-1)+C3(n-1)+C4(n(n+1)/2-1)+C5(n(n-1)/2)+C6(n(n-1)/2)+C7(n-1) = (C4/2+C5/2+C6/2)n²+(C1+C2+C3+C4/2-C5/2-C6/2+C7)n-(C2+C3+C4+C7)
最坏时间可以表示为an²+bn+c,他是n的二次函数。

由于1、算法最坏时间给出了算法运行时间的上界,确保了算法不会超过这个界限;2、而且对于某些算法,最坏时间经常出现(比如对缺失信息的检索,每次都需要检索全部信息才能得出信息缺失的结论,即每次都是最坏情况);3、平均情况往往与最坏情况一样坏。(在插入排序中,按照平均情况,在子数组 A[0…j-1] 的一半处插入关键字A[j],这使得 tj 大约为 j/2 ,导致平均情况运行时间也是输入规模的二次函数),所以我们往往集中于只求最坏情况运行时间。

显然,运行时间越少,证明算法越有效率。我们就自然想到了,如何比较运行时间,以考察算法的效率?
通过前面,我们可以看到,插入排序的最坏时间可以表示为an²+bn+c,其中a、b、c依赖于 ci。对于这个式子来说,存在这么多项,在比较的时候显然很麻烦,为了既能简便理解运行所需要的时间,又不过多舍弃掉有用的内容,我们做出一种抽象:运行时间的增长率增长量级。通过考虑公式中最重要的项(an²)来确定增长量级。因为当n的数很大时,二次项的增长速度远远高于其他低阶项,使其他低阶项看起来并不重要了。同时我们也忽略常数系数,因为对于大的输入,常数系数的影响也不如增长率重要。最后,只剩下了n²,我们记为θ(n²)。如果一个算法最坏情况下运行时间比另一个算法更低的增长量级,则我们说前者比后者更有效。例如,θ(n²)比θ(n³)更有效。

增长量级的提出使得我们进一步思考:如何减少算法的增长量级,使其更有效?以排序算法为例,如何设计算法,使得新的排序算法比插入排序有更低的增长量级?为此,我们提出一种将问题分而治之的方法——分治法。

四、简介分治法

分治法,顾名思义,将原问题分解为几个较小规模的子问题,通过解决子问题,再合并子问题的解,最终求出原问题。在结构上以递归为典型:为了解决给定问题,算法多次调用自身以解决相关的子问题。分治法在每层递归时都有三个步骤:
分解:原问题分解为若干子问题,这些子问题是原问题较小规模的实例。
解决:解决这些子问题,递归求解各子问题,若子问题规模足够小,则直接求解。
合并:合并子问题的解成原问题的解。
分治算法很容易确定运行时间,关于分治法的更多内容将在第四章讲解。这里我们利用分治法,设计一个新的排序算法:归并排序。

五、归并排序

归并排序

归并排序遵循分治法,其工作原理如下:
分解:分解待排序的n个元素的序列成为各具n/2个元素的子序列;
解决:使用归并排序递归排序两个子序列
合并:将已排序的两个子序列合并产生总排序序列
在这里插入图片描述
上图是归并排序原理(非原创),图源:https://www.52pojie.cn/thread-804839-1-1.html(侵删)

归并排序的关键步骤是“合并”。我们通过调用一个过程merge(A,p,q,r)来完成,其中A是数组,p、q、r是数组下标,满足 p<=q<r。即 A[p…q] 和 A[q+1…r] 是两个排好序的相邻子数组,将两个子数组排序、合并,最终得到排好序的数组A[p…r]。

可以看出,图片右侧表示的高度是数列元素个数的对数。当待排序的元素个数为1时,开始合并。
排序的过程如下:首先将两个排好序的子数组分别装入两个空数组中,各自从头开始,选择较小的数放入待排序数组中,直到一个子数组全部选择完毕,将剩下的子数组全部按顺序装入,得到一个有序的大数组。当然,这里需要在每一步骤开始前判定是否有子数组全部选择完,这样做很麻烦。为了避免这种情况,我们在子数组后面加一张哨兵牌,这里我们使用∞作为哨兵。由于子数组中的任何一个数必定小于∞,所以我们不用担心子数组会空(至少剩下一个∞),当两个子数组都只剩下∞时,所有原子数组都已拍好序,则算法完毕。因为我们最多执行n=r-p+1个基本步骤(即待排序元素个数),所以合并需要θ(n)的时间。

下面给出merge的python3代码:

def merge(A,p,q,r):
    n1 = q-p+1                      #第一个子数组个数
    n2 = r-q                        #第二个子数组个数
    l = []                          #定义两个空数组
    m = []
    for i in range(0,n1):           #存放第一个子数组
        l.append(A[p+i])
    for i in range(0,n2):           #存放第二个子数组
        m.append(A[q+i+1])
    l.append(float('inf'))          #在最后存放一张哨兵牌,这里float('inf')表示∞
    m.append(float('inf'))
    i = 0                           #双指针
    j = 0
    for k in range(p,r+1):          #排序,小的放在前面
        if l[i]<=m[j]:
            arr[k] = l[i]
            i+=1
        else:
            arr[k] = m[j]
            j+=1

同样,我们来对循环不变式进行分析。在这里,循环不变式是指:子数组A[p…k-1]按照升序排列,包含两个数组L[1…n1+1]和R[1…n2+1]中的k-p个最小元素,进而L[i]和R[j]是各自数组中未被复制到A[p,k-1]中的最小元素。
初始化:在第一次迭代之前,子数组A为空,这里包含L和R的0个最小元素。此时 i = j = 1,L[ i ]和R[ i ]都是各自数组中排第一位的最小元素。
保持:在每次迭代时,将L[ i ]和R[ j ]比较,最小的元素复制到A中,原来A中有k-p个最小元素,复制之后,存在k-p+1个最小元素。通过for循环,增加k值之后,A仍然有k-p个最小元素。同时更新 i 或 j 的值,这时候就为下次迭代重新建立好了循环不变式。
终止:在终止时k=r+1。根据循环不变式,子数组A[p,k-1]就是A[p…r]且从小到大排列,包含L[1…n1+1]和R[1…n2+1]的r-p+1个最小元素。除了L和R之中人为添加的最大元素“哨兵”之外,所有元素均已复制到A中且排好序。

通过代码再次看运行时间。第一个for循环之前的代码需要常量时间。第一个和第二个for循环总共需要θ(n1+n2)=θ(n)的时间,第三个for循环需要迭代n次,每次需要常量时间,故总运行时间为θ(2n)。由于常数系数可以忽略,故为θ(n)。

merge已经分析完,下面是借助merge完成的归并排序的代码:

def merge_sort(A,p,r):
    if p < r:
        q=(p+q)//2
        merge_sort(A,p,q)
        merge_sort(A,q+1,r)
        merge(A,p,q,r)

在初次执行时,merge_sort中A为待排序数组、p为0、r为数组长度-1,排序过程见上图。

六、分析分治法

正如归并排序一样,当一个算法包含对自身的调用时(递归),我们用递归方程或递归式来描述运行时间。该方程根据在较小规模上的运行时间来描述规模为n时的总运行时间。我们可以根据数学工具求出算法性能的边界。
我们设T(n)为规模为n的问题运行所需要的时间。假设把问题分成a个子问题,,每个子问题的规模是原问题的1/b(在归并排序中,a=b,但是我们将看见在很多算法中,a不等于b)。求解规模n/b的子问题,需要T(n/b)的时间。所以a个子问题需要aT(n/b)的时间。如果分解问题需要时间D(n),合并子问题的解为原问题的解需要时间C(n),可以得到求解时间的递归式:
在这里插入图片描述
下面我们分析归并排序n个数在最坏情况下运行时间T(n)的递归式。当n>1时,我们分解如下:
分解:分解仅需要计算中间位置,需要常量时间,因此D(n)=θ(1).
解决:递归求解两个规模均为n/2的子问题,故时间为2T(n/2)。
合并:在具有n个元素的子数组上调用merge需要θ(n),所以C(n)=θ(n)。
D(n)+C(n)为线性函数,故取θ(n)。把他和2T(n/2)相加,得到:
在这里插入图片描述
进一步来研究这个式子。假设c为求解规模为1的问题所需要的时间以及在分解与合并处理每个数组元素所需时间,递归式又可以写为:
在这里插入图片描述
假设n刚好是2的幂,我们通过将递归式写成递归树的形式,扩展每个结点。从初始结点cn开始,两个子结点是两个较小的递归式T(n/2),代价为cn/2。继续推,分成四个结点,代价为cn/4…直到最后,问题规模下降为1,每个结点代价为c。图如下:
在这里插入图片描述
可见,每一层所需要的时间都为cn,注意,完全扩展的递归树叶子结点共n个,层数为lgn+1层,而高度为lgn。我们把所有代价相加,总代价为cn(lgn+1),即cnlgn+cn,忽略常数和低阶项,即为θ(nlgn)。

(本章结束)

  • 0
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值