第二章 算法基础

目录

2.1 插入排序

(1)伪代码

(2)循环不变式验证算法的正确性

【练习题 2.1】

2.1-1 说明 INSERT-SORT 在数组  A={31,41,59,26,41,58} 上的执行过程。

2.1-2 重写 INSERT-SORT,使之按照非升序排序。

2.1-3 考虑以下查找问题:【输入】 A={a1,a2,...,an} 和一个值 v。【输出】下标 i 使得 v = A[i],或当 v 不在 A 中出现时,v = NIL。【要求】写出线性查找的伪代码,使用循环不变式证明此算法的正确性。

2.1-4 考虑把两个 n 位二进制整数加起来的问题。这两个整数分别存储在两个元数组 A、B 中,它们的和应该按照二进制形式存储在一个 (n+1) 元数组 C 中。给出伪代码。

2.2 算法分析

(1)只看最坏情况

(2)更进一步抽象

【练习题 2.2】

 2.2-1 用 Θ 记号表示函数 ​编辑。

2.2-2 考虑存储在 A 数组中的 n 个数:首先找出 A 中最小元素并与 A[1] 交换;紧接着找出 A 中次小元素与 A[2] 交换。对 A 数组前 n-1 个元素按照此方式继续。该算法被称为选择排序算法。写出其伪代码。该算法维持的循环不变式是什么?为什么只针对前 n-1 个元素?用 Θ 记号给出最好情况和最坏情况的运行时间。

2.2-3 再次考虑线性查找问题(习题2.1-3)。假设元素均匀分布,平均需要检查输入序列的多少元素?最坏情况又如何呢?用 Θ 记号给出平均情况和最坏情况的运行时间,并证明你的答案。

2.2-4 我们可以如何修改几乎任意算法来使之具有良好的最好情况运行时间?

2.3 设计算法

(1)分治法介绍

以归并排序为例

(2)分析分治算法 

【练习题 2.3】

2.3-1 以图 2-4 作为模型,说明归并排序在数组 A = {3,41,52,26,38,57,9,49} 上的操作。

2.3-2 重写过程 MERGE,使之不用哨兵,而是一旦数组 L 或 R 的所有元素均被复制回 A 时就立刻停止,并把另一个数组的剩余所有元素复制回 A。

2.3-3 使用数学归纳法证明:当 n 恰好是 2 的幂时,以下递归式的解是 ​编辑

2.3-4 我们可以把插入排序表示为如下的一个递归过程:为了排序 A[1...n],我们递归地排序 A[1...n-1],然后把 A[n] 插入到已排序的数组 A[1...n-1] 中。为这个算法的最坏情况运行时间写出一个递归式。

2.3-5 回顾查找问题(练习2.1-3),注意到,如果 A 已经排好序,就可以使用二分查找了。写出二分查找的迭代和递归的伪代码,并证明,二分查找的最坏情况运行时间为 ​编辑。

2.3-6 注意到,2.1节的 INSERTION-SORT 的 5~7 行的 while 循环采用一种线性查找来扫描有序子序列 A[1...j-1]。我们可以采用二分查找来把这个算法的最坏情况运行时间缩短至 ​编辑 吗?

2.3-7 描述一个运行时间为 ​编辑 的算法,给定 n 个整数的集合 S 和另一个整数 x,该算法能确定 S 中是否存在两个其和刚好为 x 的元素。


2.1 插入排序

  • 少量数据,很有效率。
  • 扑克牌。

(1)伪代码

INSERT-SORT(A)
    for j = 2 to A.length
        key = A[j]
        // Insert A[j] into the sorted senquence 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

(2)循环不变式验证算法的正确性

  • 循环不变式是指在循环中保持不变的式子,例如上述插入算法中的 A[1 ... j-1],在循环中始终代表已经有序的序列。
  • 循环不变式需要证明三条特性:
    • 初始化:循环第一次迭代前,它为 TRUE。
    • 保持:如果循环某次迭代前,它为 TRUE,那么在下次迭代前,它仍为 TRUE。
    • 终止:循环终止时,不变式便可以证明算法的正确性。
  • 对于上述的插入排序:
    • 初始化:j = 2 时,A[1...j-1]中只有一个元素,即 A[1],自然是有序的。成立。
    • 保持:每次 A[j] 向前比较时,都会插入到首次比它小的元素后面,因此随着 j 的自增,A[1...j-1]始终有序。成立。
    • 终止:循环终止时,j = n+1,由前两点可以说明 A[1...n]是有序的。因此算法正确。

【练习题 2.1】

2.1-1 说明 INSERT-SORT 在数组  A={31,41,59,26,41,58} 上的执行过程。

2.1-2 重写 INSERT-SORT,使之按照非升序排序。

INSERT-SORT(A)
    // nondecreasing order
    for i = 2 to A.length
        key = A[i]
        j = i - 1
        while j > 0 and A[j] < key
            A[j+1] = A[j]
            j--
        A[j+1] = key

2.1-3 考虑以下查找问题:【输入】 A={a1,a2,...,an} 和一个值 v。【输出】下标 i 使得 v = A[i],或当 v 不在 A 中出现时,v = NIL。【要求】写出线性查找的伪代码,使用循环不变式证明此算法的正确性。

LINTER_SEARCH(A, v)
    for i = 1 to A.length
        if A[i] == v
            return i
    return NIL
  • 初始化:循环开始前,结果为空。成立。
  • 保持:若第 k 次循环前,结果为空,那么在第 k+1 次循环前,结果保持为空。成立。
  • 终止:循环终止时, 可能是查找成功(从 k 处跳出,结果为 k),也可能保持为空(i = n+1,结果为NIL)。算法有效。

2.1-4 考虑把两个 n 位二进制整数加起来的问题。这两个整数分别存储在两个元数组 A、B 中,它们的和应该按照二进制形式存储在一个 (n+1) 元数组 C 中。给出伪代码。

ADD_BINARY(A, B)
    carry = 0
    for i = 1 to A.length
        sum = A[i] + B[i] + carry
        C[i] = sum % 2
        carry = sum / 2
    C[A.length+1] = carry
    return C

2.2 算法分析

这一节主要讲了如何进行算法分析。

首先论述了采用RAM模型进行分析优缺点。即,RAM分析虽然能够很好地在硬件维度预测实际性能,但时这种方式太复杂了,对于具有普遍意义的算法分析来讲很不现实。

紧接着引入了“输入规模”+“运行时间”分析法,并以上一节的插入算法为例,进行简单分析。

针对运行时间这一维度,本节分析了最好时间(输入序列已经正序)和最坏时间(输入序列完全倒序)两种情况。

  • 最好情况:线性一次函数
  • 最坏情况:二次函数

(1)只看最坏情况

对于大部分算法,通常采用最坏情况运行时间作为算法效率优劣的标准之一。给出了三点理由:

  1. 做最坏的的打算:最坏情况代表了运行时间的一个上界,知道了这个界,就能确保算法绝不会需要更长时间。
  2. 最坏情况经常出现:检索算法一旦没有检索到目标,情况便是最坏的了。某些应用中对缺失信息的检索很频繁。
  3. 平均情况与最坏情况半斤八两:以插入排序为例,平均运行时间同样为二次函数,这与最坏情况在数量级上是一致的。

(2)更进一步抽象

为了是算法分析更加容易,本节提出了只看**增长量级**这一方式,即只看最高阶项,而忽略低阶项和常数项。

最坏情况运行时间用 Θ() 表示。

【练习题 2.2】

 2.2-1 用 Θ 记号表示函数 n^3/1000-100n^2-100n+3

  • Θ(n^3)

2.2-2 考虑存储在 A 数组中的 n 个数:首先找出 A 中最小元素并与 A[1] 交换;紧接着找出 A 中次小元素与 A[2] 交换。对 A 数组前 n-1 个元素按照此方式继续。该算法被称为选择排序算法。写出其伪代码。该算法维持的循环不变式是什么?为什么只针对前 n-1 个元素?用 Θ 记号给出最好情况和最坏情况的运行时间。

  • 伪代码:
SELECT-SORT(A)
    for i = 1 to A.length-1
        minIndex = i
        for j = i+1 to A.length
            if A[j] < A[minIndex]
                minIndex = j
        swap(A[minIndex], A[i])
  • 循环不变式:A[1...n-1] 代表了已经有序的小序列。
  • 前 n-1 个元素都有序后,第 n 个自然是最大的了,也就自然都有序了。
  • θ(n^2)

2.2-3 再次考虑线性查找问题(习题2.1-3)。假设元素均匀分布,平均需要检查输入序列的多少元素?最坏情况又如何呢?用 Θ 记号给出平均情况和最坏情况的运行时间,并证明你的答案。

  • 元素均匀的情况下,平均需要检查 n/2 个元素。
  • 最坏情况是找不到目标元素,需要检查 n 个元素。
  • θ(n)

2.2-4 我们可以如何修改几乎任意算法来使之具有良好的最好情况运行时间?

  • 可以通过增加最好用例的方式来构造具有最好情况运行时间的算法。如果输入匹配了这个最好用例,那么直接返回这个用例,此时最好情况运行时间是常量级别的。

2.3 设计算法

对前文插入排序算法归类为:“增量法”,而本节则主要探讨另一种:“分治法”。

(1)分治法介绍

分治法中一个常见的思想是递归。

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

  • 分解:将原问题分解为若干子问题,这些子问题都是原问题的规模较小的实例;
  • 解决:递归求解这些子问题,当其规模足够小时,可以直接求解;
  • 合并:将子问题的解合并成原问题的解。

以归并排序为例

分治策略:

  • 分解:将待排序的 n 个元素序列分解为 n/2 个元素的序列;
  • 解决:使用归并排序递归地排序两个子序列;
  • 合并:合并两个有序子序列为整体有序序列。

伪代码:

// A 是待排序数组,p/q/r 是数组的下标,满足 p <= q < r
// 该过程假设子数组 A[p...q] 和 A[q+1...r] 都已经排好序了
MERGE(A, p, q, r)
    m = q - p + 1
    n = r - q
    let L[1...m+1] and R[1...n+1] be new arrays
    for i = 1 to m
        L[i] = A[p + i - 1]
    for j = 1 to m
        R[i] = A[q + j]
    L[m + 1] = INF // INF 代指无穷
    R[n + 1] = INF
    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:

MERGE-SORT(A, p, r)
    if p <= r
        q = floor((p + r) / 2)
        MERGE-SORT(A, p, q)
        MERGE-SORT(A, q+1, r)
        MERGE(A, p, q, r)

(2)分析分治算法 

分治算法是基于递归的,因此首先需要来看递归算法的运行时间分析模式:

T(n) = \begin{cases} \Theta(1), & n<=c \\ aT(n/b) + D(n) + C(n), & else\\ \end{cases}

即,规模足够小时(n <= c),耗时为常数级;否则,耗时应为 a 个子问题耗时加总 T(n/b),同分解耗时 D(n) 以及合并耗时 C(n) 之和。

应用于归并排序,则有:

T(n) = \begin{cases} \Theta(1), & n=1 \\ 2T(n/2) + \Theta(n), & n>1\\ \end{cases}

若每一个可以直接计算的子问题耗时为 c,即 \Theta(1) = c ,那么 T(n) 的总代价为 cnlog_2n + cn

可以做下述理解:

 递归过程是一棵二叉树,每一层节点耗时之和都是一个 T(n)(即 cn),故递归的本质可以理解为:将总代价为 cn 的过程逐层分解成 n 个代价为 c 的 过程并逐层解决。因此整棵树的总耗时便为:cn * (log_2n + 1)

在此基础上,忽略低阶项和常量,便可以得到期望的最坏耗时结果(log_2 简写为 lg):

\Theta(nlgn)

【练习题 2.3】

2.3-1 以图 2-4 作为模型,说明归并排序在数组 A = {3,41,52,26,38,57,9,49} 上的操作。

  • 操作描述如下:

[3]\qquad[41]\qquad[52]\qquad[26]\qquad[38]\qquad[57]\qquad[9]\qquad[49]

[3, 41]\qquad[26,52]\qquad[38,57]\qquad[9,49]

[3,26,41,52]\qquad[9,38,49,57]

[3,9,26,38,41,49,52,57]

2.3-2 重写过程 MERGE,使之不用哨兵,而是一旦数组 L 或 R 的所有元素均被复制回 A 时就立刻停止,并把另一个数组的剩余所有元素复制回 A。

MERGE(A, p, q, r)
    m = q - p + 1
    n = r - q
    let L[1...m+1] and R[1...n+1] be new arrays
    for i = 1 to m
        L[i] = A[p + i - 1]
    for j = 1 to m
        R[i] = A[q + j]
    i = 1
    j = 1
    for k = p to r
        if i > m
            A[k] = R[j]
            j = j + 1
        else if j > n
            A[k] = L[i]
            i = i + 1
        else if L[i] <= R[j]
            A[k] = L[i]
            i = i + 1
        else 
            A[k] = R[j]
            j = j + 1

2.3-3 使用数学归纳法证明:当 n 恰好是 2 的幂时,以下递归式的解是 T(n) = nlgn

T(n) = \begin{cases} 2, & n=2 \\ 2T(n/2), & n = 2^k,k>1 \end{cases}

(1)初始情况:

当 k=1 时,n=2T(n) = 2lg2 = 2,成立。

(2)假设:

若 k>1 时某次成立,则有 n = 2^k, T(n) =T(2^k) = nlgn = 2^k lg(2^k) = k2^k

(3)推演:

那么,当第 k+1 次时,n = 2^{k+1},T(n) = 2T(2^{k+1}/2)+2^{k+1} = 2T(2^k)+2^{k+1},

代入 T(2^k) = k2^k,得

T(n) = 2k 2^k + 2^{k+1} = (k+1)2^{k+1} = 2^{k+1}lg2^{k+1} = nlgn.

证毕。

2.3-4 我们可以把插入排序表示为如下的一个递归过程:为了排序 A[1...n],我们递归地排序 A[1...n-1],然后把 A[n] 插入到已排序的数组 A[1...n-1] 中。为这个算法的最坏情况运行时间写出一个递归式。

// 递归版本
INSERTION-SORT(A, n)
    if n < 2
        return
    INSERTION-SORT(A, n-1)
    temp = A[n]
    i = n - 1;
    while i > 0
        if A[i] < temp
            break
        A[i + 1] = A[i]
        i = i - 1
    A[i + 1] = temp

 T(n) = \begin {cases} 0, & n=1 \\ T(n-1)+n-1, & n>1\\ \end {cases}

2.3-5 回顾查找问题(练习2.1-3),注意到,如果 A 已经排好序,就可以使用二分查找了。写出二分查找的迭代和递归的伪代码,并证明,二分查找的最坏情况运行时间为 \Theta(lgn)

// 迭代版本
ITERATIVE-BINARY-SEARCH(A, v, low, high)
    while low ≤ high
        mid = low + (high - low) / 2
        if v == A[mid]
            return mid
        else if v > A[mid]
            low = mid + 1
        else high = mid - 1
    return NIL
// 递归版本
RECURSIVE-BINARY-SEARCH(A, v, low, high)
    if low > high
        return NIL
    mid = low + (high - low) / 2
    if v == A[mid]
        return mid
    else if v > A[mid]
        return RECURSIVE-BINARY-SEARCH(A, v, mid + 1, high)
    else return RECURSIVE-BINARY-SEARCH(A, v, low, mid - 1)
  •  证明:根据递归树,最差情况是从根节点出发,找到最底层深度最深的叶子结点。运行时间为二叉树高度,即 \Theta(lgn)

2.3-6 注意到,2.1节的 INSERTION-SORT 的 5~7 行的 while 循环采用一种线性查找来扫描有序子序列 A[1...j-1]。我们可以采用二分查找来把这个算法的最坏情况运行时间缩短至 \Theta(nlgn) 吗?

  • 可以使用二分查找优化,但无法使这个整体最坏情况运行时间缩短至 \Theta(nlgn)
  • 因为插入排序在查找的过程中,需要将不在其位的元素后移一位,因此每一次查找最坏移动 j 个元素,耗时 \Theta(n)
  • 整个插入排序算法共进行 n-1 次循环,因此一共耗时为:

T(n) = (n-1)(\Theta(lgn) + \Theta(n)) = \Theta(n^2)

2.3-7 描述一个运行时间为 \Theta(nlgn) 的算法,给定 n 个整数的集合 S 和另一个整数 x,该算法能确定 S 中是否存在两个其和刚好为 x 的元素。

  •  思路:排序 \Theta(nlgn) + 双指针 \Theta(n)
TEO-SUM(S, x)
    sort(S) // 排序 O(nlgn)
    let res[2] as the result and init
    l = 0
    r = S.length - 1
    while l < r
        sum = S[l] + S[r]
        if sum == x
            res = {l, r}
            return
        if sum < x
            l = l + 1
        else r = r - 1
    return res

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值