算法导论 - 基础知识 - 算法基础(插入排序&归并排序)

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

插入排序:

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

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

将其伪代码过程命名为 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概率也大......

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

posted on 2020-03-08 01:47  Black_x  阅读(337)  评论(0编辑  收藏  举报