在《算法导论》一书中,插入排序作为一个例子是第一个出现在该书中的算法。

插入排序:

对于少量元素的排序,它是一个有效的算法。

插入排序的工作方式像许多人排序一手扑克牌。开始时,我们手中牌为空,我们每次从牌堆中取出一张牌并将其放入正确的位置。为了找到一张牌的正确位置,我们从左到右将它与手中已有的每张牌进行比较。

将其伪代码过程命名为 INSERTION-SORT,参数是一个数组A,具体如下:

INSERTION-SORT(A):

  for j = 2 to A.length

    key = A[j]   // Insert A[j] into the second sequence A[1..j - 1]. 

    i = j - 1       // A[i]、A[j]

    while i > 0 and A[i] > key

      A[i + 1] = A[i]

      i = i - 1

    A[i + 1] = key

伪代码解释:下标 j 指出正被插入到手中的牌,在for循环的每次迭代开始,A[j]将数组A分为了两部分,一部分是A[1..j - 1]的子数组构成当前排序好的手中的牌,剩余的子数组A[j + 1..n]对应仍在桌上的牌堆。

我在刚看到插入排序的工作方式后,也写了一下伪代码。采用牌堆模拟的方法,但在具体的实现过程,我用一个数组表示牌堆,用一个空vector表示手中的空手牌,看了书中的伪代码才直到原来只用一个数组就行,减少了近一半的内存占用。这也算是这段伪代码的一个亮点。其实仔细想想,这种对空间复杂度的优化不算少见,非常典型的例子就是背包问题的一维、二维数组实现。背包问题在进行动态规划时,我们使用一个矩阵来记录它的状态,通过不断更新矩阵进行状态转移,由于我们只需要最后一行矩阵,而且每次更新矩阵的一行时只有矩阵上一行的值确定,故可采用二维数组优化,两个数组不断交换更新即可。对于01背包问题,可以更近一步使用一维数组(由于01背包的状态转移方程指示出,当一维数组的值更新时一定由该数组前面的值给出,我们每次从后向前更新数组即可动态更新整个矩阵)。

所以在以后,执行对数组的一些操作前可以想一想是否可以利用数组自身的状态更新自身,减少不必要的内存消耗。

 


该书在之后介绍了一个非常重要的概念:循环不变式

循环不变式主要用来证明算法的正确性。关于循环不变式,我们必须证明三条性质:

初始化:循环的第一次迭代之前,它为真。

保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。(即对这一次迭代循环不变式保持为真)

终止:循环终止时,不变式为我们提供了一个有用的性质,该性质有助于证明算法的正确性。

类似于数学归纳法,为证明某条性质成立,需要证明一个基本情况和一个归纳步。

对插入排序而言,

初始化:第一次循环迭代之前,此时 j=2,子数组A[1..j - 1]仅有单个元素A[1]组成,已排序,循环不变式为真;

保持:非形式化地(指理解描述,未用数学符号严格表示),对某次迭代而言,for循环体的第4~7行语句将A[j - 1]、A[j - 2]、A[j - 3]等向右移动一个位置,直到找到A[j]的适当位置,第8行语句将A[j]的值插入该位置。这时子数组A[1..j]中的元素组成已排好序,在该次迭代中,循环不变式保持为真。

终止:导致for循环终止的条件是j > A.length。每次for迭代 j 增加一,那么循环结束时必有 j=n+1。在循环不变式的表述中用将 j 用 n+1 替换,我们有,子数组A[1..n]中的元素已排好序,子数组A[1..n]就是整个数组,因此我们推断整个数组已排好序。因此算法正确。

 


以插入排序算法为例进行进一步分析,本书给出了“输入规模”、“运行时间”两个重要概念。

输入规模:输入规模依赖于研究的问题,如插入排序算法,最自然的量度是输入数据的项数,如果是两个整数相乘,输入规模的最佳量度是用通常的二进制记号表示输入所需的总位数。但有时需要用两个数而不是一个数来描述输入规模可能更合适,最典型的情况就是算法输入为一张图时,此时输入规模可以用图中的顶点数和边数来描述。

运行时间:一个算法在特定输入上的运行时间是指执行的基本操作数和步数。我们目前若假设,执行每行伪代码需要常量时间,即我们假定第 i 行的每次执行需要时间 ci (c是常量)。

对插入排序算法而言,外层循环次数定为 n ,内层循环次数定为 tj,算法运行时间是执行每条语句的运行时间之和,则有:

    T(n) = c1n + c2(n - 1) + c4(n  - 1) + c5j=2tj + c6j=2(tj - 1) + c7j=2(tj - 1) + c8(n - 1)    (注:c3 = 0)

若输入数组已经排好序,则出现最佳情况,这时:

    T(n) = (c1 + c2 + c4 + c5 + c8) n - (c2 + c4 + c5 + c8)

我们可以把该运行时间表示为an + b,其中常量 a 和 b 依赖于语句代价ci。因此它是 n 的线性函数。

若输入数组开始时为反向排序,则导致最坏情况,此时∑j=2tj = ∑j=2j = n(n + 1) / 2 - 1、∑j=2(j - 1) = n(n - 1) / 2。

将以上两个式子代进最初的T(n)函数中,可得:

T(n) = (c5/2 + c6/2 + c7/2) n2 + (c1 + c2 + c4 + c5/2 - c6/2 - c7/2 + c8)n - (c2 + c4 + c5 + c8)

我们可以把该最坏情况的运行时间表示为 an2+bn+c ,其中a、b、c依赖于语句代价ci。因此它是 n 的二次函数。

通过上述对插入排序算法的分析,应该要认识到对一个算法运行时间的分析应该要清晰而细致,认真分析每一个指令,判断是否是常量时间,判断循环执行该指令的次数。然后在熟练的基础上对一些简单指令可以只做简略处理。此外还应当补充,在分析一个算法的平均情况的时候,可能要对所谓“平均”输入使用随机化算法,它做出一些随机的选择,以允许进行概率分析并产生某个期望的运行时间。


插入排序使用了增量方法,接下来介绍一种重要的算法设计思想:分治法

分治法:将原问题分解为几个规模较小但类似于原问题的子问题,递归地求解这些子问题,然后在合并这些子问题的解来建立原问题的解。

分治模式在每层递归时都有三个步骤:

分解:分解原问题为若干子问题,,这些子问题是原问题的规模较小的实例。

解决:解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。

合并:合并这些子问题的解成原问题的解。

归并排序算法完全遵循分治模式:

分解:分解待排序的 n 个元素的序列成各具 n/2 个元素的两个子序列。

解决:使用归并排序递归地排序两个子序列。

合并:合并两个已排序的子序列以产生已排序的答案。

归并排序算法的关键是“合并”步骤。我们将其定义为 MERGE(A, p, q, r) 来完成合并。其中A是一个数组,p、q、r是数组下标(p ≤ q < r),三个下标将数组分为左右两个数组,A[p..q]和A[q + 1..r]。

我们假设桌上有两堆输入牌,每堆都已排好序,最小的牌在顶上。每次我们取出两堆牌的顶上两张牌中较小的一张,将此牌移除并将其放置到输出堆。为避免在每个基本步骤必须检查是否有堆为空,《算法导论》给出了一种处理这种情况的方法:哨兵牌。在每个堆的底部放置一张哨兵牌,它是一个特殊的值,这里我们使用 ∞ 作为哨兵值,结果每当显露一张值为 ∞ 的牌,它不可能为较小的牌,除非两个堆已显露出其哨兵牌。但一旦发生这种情况,我们可以通过 for 循环控制循环次数为 r - p + 1,因为我们事先知道刚好有 r - p + 1张牌将被放置到输出堆上,所以一旦执行 r - p + 1个基本步骤,算法就刚好停止。

伪代码如下:

MERGE(A, p, q, r)

  n1 = q - p + 1

  n2 = r - q

  let L[1..n1 + 1] and 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

算法导论》用循环不变式对该算法的正确性进行了稍显复杂的证明,但非形式化地理解该算法的正确性并不太难,故此处略去。

现在我们可以把过程 MERGE 作为归并排序算法中的一个子程序来用。MERGE-SORT(A, p, r) 排序数组A[p..r]中的元素。若 p>=r,则该子数组最多一个元素,此时已排好顺序。否则,分解步骤。伪代码如下:

MERGE-SORT(A, p, r)

  if p < r

    q = (p + r) / 2

    MERGE-SORT(A, p, q)

    MERGE-SORT(A, q + 1, r)

    MERGE(A, p, q, r)  

《算法导论》在给出了归并排序算法的伪代码后,对归并排序算法进行了一定的分析,初步引入了递归式递归树。关于分治策略的严格数学形式,即递归式和递归树的一些数学描述将在《算法导论》第四章-分治策略 一章里讨论。

(¬_¬) 好像到这里篇幅不是很多,那就来些闲话做结束语吧:

到目前为止,已经看了《算法导论》里的两个算法,这两个算法给我的感觉都写得非常精简,没有赘余。在刷题过程中我经常会苦思一些程序细节,苦于没有较好的方法处理这些细节,只能多写一些代码讨论情况,处理细节。这样就导致了代码量增大,而多出的指令既可能增加程序运行时间,又可能让思路混乱,出bug概率也大......

~额,看来我的问题有点大呢......

算法导论 - 基础知识 - 算法基础(插入排序&归并排序)的更多相关文章

  1. 数据结构和算法(Golang实现)(10)基础知识-算法复杂度主方法

    算法复杂度主方法 有时候,我们要评估一个算法的复杂度,但是算法被分散为几个递归的子问题,这样评估起来很难,有一个数学公式可以很快地评估出来. 一.复杂度主方法 主方法,也可以叫主定理.对于那些用分治法 ...

  2. 数据结构和算法(Golang实现)(9)基础知识-算法复杂度及渐进符号

    算法复杂度及渐进符号 一.算法复杂度 首先每个程序运行过程中,都要占用一定的计算机资源,比如内存,磁盘等,这些是空间,计算过程中需要判断,循环执行某些逻辑,周而反复,这些是时间. 那么一个算法有多好, ...

  3. Linux基础知识与基础命令

    Linux基础知识与基础命令 系统目录 Linux只有一个根目录,没有盘符的概念,文件目录是一个倒立的树形结构. 常用的目录功能 bin 与程序相关的文件 boot 与系统启动相关 cdrom 与Li ...

  4. java线程基础知识----线程基础知识

    不知道从什么时候开始,学习知识变成了一个短期记忆的过程,总是容易忘记自己当初学懂的知识(fuck!),不知道是自己没有经常使用还是当初理解的不够深入.今天准备再对java的线程进行一下系统的学习,希望 ...

  5. day63:Linux:nginx基础知识&nginx基础模块

    目录 1.nginx基础知识 1.1 什么是nginx 1.2 nginx应用场景 1.3 nginx组成结构 1.4 nginx安装部署 1.5 nginx目录结构 1.6 nginx配置文件 1. ...

  6. 这些C++基础知识的基础知识你都学会了吗?

    一.C++基础知识 新的数据类型 C语言中的数据类型  C++中新的数据类型 思考:新的数据类型有什么好处?请看下面的代码:  可以见得:新的类型使整个程序更加简洁,程序变得易读易懂!这个就是bool ...

  7. 算法导论(第三版)Exercises2.3(归并排序、二分查找、计算集合中是否有和为X的2个元素)

    2.3-1: 3 9 26 38 41 49 52 59 3 26 41 52   9 38 49 57 3 41   52 26   38 57   9 49 3   41  52  26  38  ...

  8. Ceph基础知识和基础架构认识

    1  Ceph基础介绍 Ceph是一个可靠地.自动重均衡.自动恢复的分布式存储系统,根据场景划分可以将Ceph分为三大块,分别是对象存储.块设备存储和文件系统服务.在虚拟化领域里,比较常用到的是Cep ...

  9. Ceph 基础知识和基础架构认识

    1  Ceph基础介绍 Ceph是一个可靠地.自动重均衡.自动恢复的分布式存储系统,根据场景划分可以将Ceph分为三大块,分别是对象存储.块设备存储和文件系统服务.在虚拟化领域里,比较常用到的是Cep ...

随机推荐

  1. Install VMware Tools in CentOS 7 command line mode

    1.首先启动CentOS 7,在VMware中点击上方"VM",点击"Install VMware Tools..."(如已安装则显示"Reinsta ...

  2. 【C++ 调试】增量链接 Incremental Linking

    概述: Incremental Linking翻译成中文就是"增量链接",是一个链接的参数选项,作用就是为了提高链接速度的.什么意思呢?不选用增量链接时,每次修改或新增代码后进行链 ...

  3. 【C#基础概念】函数参数默认值和指定传参和方法参数

    函数参数默认值和指定传参 最近在编写代码时发现介绍C#参数默认值不能像PL/SQL那样直接设置default,网上也没有太多详细的资料,自己琢磨并试验后整理成果如下: C#允许在函数声明部分定义默认值 ...

  4. C# KeyValuePair<TKey,TValue>的用法

    命名空间:System.Collections.Generic 构造函数:public KeyValuePair (TKey key, TValue value); 属性:只读属性 Key ,只读属性 ...

  5. 什么是句柄C#

    话不多说,下面分享下我对句柄的看法. 如果没有意外的话,ABCDE他们将依次进行占用CPU资源.但是可能会发生如下情况 句柄,就是用来维护进程或者系统范围内的一个标识.就比如我们去访问一个文件的时候, ...

  6. Oracle 11g RAC运维总结

    转至:https://blog.csdn.net/qq_41944882/article/details/103560879 1 术语解释1.1 高可用(HA)什么是高可用?顾名思义我们能轻松地理解是 ...

  7. c++刷leetcode记录

    #include<iostream> #include<sstream> #include<vector> std::vector<int> split ...

  8. Azure DevOps 介绍

    伴随着敏捷的遍地开花,如今各个开发团队越来越希望可以实现敏捷在自己团队内的落地,但是往往单纯的依赖人力难以实现敏捷的各个环节的管理, 大家开始渐渐的意识到,为了按时交付软件产品和服务,开发和运营工作必 ...

  9. LeetCode-001-两数之和

    两数之和 题目描述:给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标. 你可以假设每种输入只会对应一个答 ...

  10. C# ProgressBar的简单使用

    ProgressBar控件(进度条)用于在win窗体中显示进度,由于它的值会不断更新,为了不让界面假死,一般都是采用多线程的方式对进度条进行管理.有关ProgressBar的理论基础跟详细知识我在这里 ...