算法导论第三版代码python实现与部分习题答案-第八章:线性时间排序(一)

第八章 线性时间排序

8.1 排序算法的下界

Exercise 8.1-1

题目:
在一棵比较排序算法的决策树中,一个叶结点可能的最小深度是多少?

解答:

考虑一个对 $ n $ 个元素进行排序的比较排序算法,其执行过程可以用一棵决策树表示:

  • 每个内部节点对应一次比较操作(如 $ A[i] \leq A[j] $)
  • 每条边表示比较的两种可能结果(是/否)
  • 每个叶节点对应一种可能的输出排列

由于 $ n $ 个互异元素有 $ n! $ 种排列,决策树必须至少有 $ n! $ 个叶节点。

我们要求的是一个叶节点可能的最小深度,即从根到某个叶节点的最短路径长度(比较次数)。

分析:

最小深度对应于某个特定输入下算法所需的最少比较次数。

是否存在一种输入,使得排序算法能以极少的比较完成?

答案是肯定的。例如,当输入数组已经有序时,某些排序算法(如插入排序)只需 $ n-1 $ 次比较即可确认顺序。

但我们需要从决策树模型出发进行严格分析。

下界:

要确定 $ n $ 个元素的全序关系,至少需要 $ n-1 $ 次比较。
原因:可以把元素看作图中的顶点,每次比较建立一个有向边(如 $ A[i] < A[j] $)。要形成一条贯穿所有顶点的路径(即全序),至少需要 $ n-1 $ 条边。

可达性:
存在比较排序算法(如优化的插入排序)在已排序输入上仅需 $ n-1 $ 次比较即可确认顺序并终止。
因此,在其决策树中,对应已排序输入的叶节点深度为 $ n-1 $。

结论:

一个叶结点可能的最小深度是:
n−1 n - 1 n1

这表示:在最佳情况下,比较排序算法至少需要 $ n-1 $ 次比较来完成排序,且该下界是可达到的。

Exercise 8.1-2

题目:
不用斯特林近似公式,给出 $ \lg(n!) $ 的渐近紧确界。利用 A.2 节中介绍的技术来求累加和 $ \sum_{k=1}^{n} \lg k $。

解答:

我们要求 $ \lg(n!) = \sum_{k=1}^{n} \lg k $ 的渐近紧确界。

由于 $ \lg k $ 是单调递增函数,可使用积分估计法(见附录 A.2)来界定该和:

对于单调递增函数 $ f(k) = \lg k $,有:
∫1nlg⁡x dx≤∑k=1nlg⁡k≤∫1n+1lg⁡x dx \int_1^n \lg x \, dx \leq \sum_{k=1}^{n} \lg k \leq \int_1^{n+1} \lg x \, dx 1nlgxdxk=1nlgk1n+1lgxdx

计算积分(使用 $ \lg x = \frac{\ln x}{\ln 2} $):
∫lg⁡x dx=1ln⁡2(xln⁡x−x)+C \int \lg x \, dx = \frac{1}{\ln 2} (x \ln x - x) + C lgxdx=ln21(xlnxx)+C

下界:
∫1nlg⁡x dx=1ln⁡2[xln⁡x−x]1n=1ln⁡2(nln⁡n−n+1) \int_1^n \lg x \, dx = \frac{1}{\ln 2} [x \ln x - x]_1^n = \frac{1}{\ln 2} (n \ln n - n + 1) 1nlgxdx=ln21[xlnxx]1n=ln21(nlnnn+1)

上界:
∫1n+1lg⁡x dx=1ln⁡2[xln⁡x−x]1n+1=1ln⁡2((n+1)ln⁡(n+1)−(n+1)+1)=(n+1)ln⁡(n+1)−nln⁡2 \int_1^{n+1} \lg x \, dx = \frac{1}{\ln 2} [x \ln x - x]_1^{n+1} = \frac{1}{\ln 2} ((n+1)\ln(n+1) - (n+1) + 1) = \frac{(n+1)\ln(n+1) - n}{\ln 2} 1n+1lgxdx=ln21[xlnxx]1n+1=ln21((n+1)ln(n+1)(n+1)+1)=ln2(n+1)ln(n+1)n

因此:
nln⁡n−n+1ln⁡2≤∑k=1nlg⁡k≤(n+1)ln⁡(n+1)−nln⁡2 \frac{n \ln n - n + 1}{\ln 2} \leq \sum_{k=1}^{n} \lg k \leq \frac{(n+1)\ln(n+1) - n}{\ln 2} ln2nlnnn+1k=1nlgkln2(n+1)ln(n+1)n

两边均可表示为 $ \Theta(n \log n) $,故:
lg⁡(n!)=Θ(nlg⁡n) \lg(n!) = \Theta(n \lg n) lg(n!)=Θ(nlgn)

更精确地,有:
lg⁡(n!)=nlg⁡n−nlg⁡e+O(lg⁡n) \lg(n!) = n \lg n - n \lg e + O(\lg n) lg(n!)=nlgnnlge+O(lgn)

得证。

Exercise 8.1-3

题目:
证明:对 $ n! $ 种长度为 $ n $ 的输入中的至少一半,不存在能达到线性运行时间的比较排序算法。如果只要求对 $ 1/n $ 的输入达到线性时间呢?$ 1/2^n $ 呢?

解答:

[!NOTE]

输入指的就是由nnn个元素的排列

假设存在一个比较排序算法,使得至少一半的输入能在 $ O(n) $ 时间内完成,即比较次数 ≤ $ c_1 n $。

则其决策树中至少有 $ \frac{1}{2} n! $ (原始输入的一半)个叶节点的深度 ≤ $ c_1 n $。

[!NOTE]

在一棵二叉树中:

  • 深度为 0(根):最多 $ 2^0 = 1 $ 个节点
  • 深度为 1:最多 $ 2^1 = 2 $ 个节点
  • 深度为 2:最多 $ 2^2 = 4 $ 个节点
  • 深度为 $ d $:最多 $ 2^d $ 个节点

我们要求的是:从深度 0 到深度 $ k $ 的所有叶节点的总数上限。

即使所有节点都是叶节点(最坏情况),总数为:
∑d=0k2d=20+21+22+⋯+2k \sum_{d=0}^{k} 2^d = 2^0 + 2^1 + 2^2 + \cdots + 2^k d=0k2d=20+21+22++2k

深度 ≤ $ c_1 n $ 的叶节点最多有:
∑d=0c1n2d=2c1n+1−1<2c1n+1 \sum_{d=0}^{c_1 n} 2^d = 2^{c_1 n + 1} - 1 < 2^{c_1 n + 1} d=0c1n2d=2c1n+11<2c1n+1
因此:
12n!≤2c1n+1 \frac{1}{2} n! \leq 2^{c_1 n + 1} 21n!2c1n+1

[!NOTE]

补充详细推导过程

您说得对,这个结论确实太突然了。让我补充详细的推导过程:

详细推导:

考虑 $ n! = 1 \times 2 \times 3 \times \cdots \times n $

我们将这些因子按位置分为两部分:

情况1:n为偶数

  • 前半部分:$ 1, 2, 3, \ldots, \frac{n}{2} $ (共 $ \frac{n}{2} $ 个因子)
  • 后半部分:$ \frac{n}{2} + 1, \frac{n}{2} + 2, \ldots, n $ (共 $ \frac{n}{2} $ 个因子)

情况2:n为奇数

  • 前半部分:$ 1, 2, 3, \ldots, \frac{n-1}{2} $ (共 $ \frac{n-1}{2} $ 个因子)
  • 后半部分:$ \frac{n+1}{2}, \frac{n+3}{2}, \ldots, n $ (共 $ \frac{n+1}{2} $ 个因子)

关键观察:
后半部分的每个因子都大于等于后半部分的最小值。

  • 当n为偶数时:后半部分最小值为 $ \frac{n}{2} + 1 $
  • 当n为奇数时:后半部分最小值为 $ \frac{n+1}{2} $

建立不等式:

为了得到一个统一的下界,我们使用 $ \frac{n}{2} $ 作为比较基准。

对于n为偶数:

  • 后半部分有 $ \frac{n}{2} $ 个因子
  • 每个因子都 $ \geq \frac{n}{2} + 1 > \frac{n}{2} $
  • 因此:$ n! \geq \left( \frac{n}{2} \right)^{n/2} $

对于n为奇数:

  • 后半部分有 $ \frac{n+1}{2} $ 个因子
  • 每个因子都 $ \geq \frac{n+1}{2} $
  • 由于 $ \frac{n+1}{2} > \frac{n}{2} $,所以每个因子都 $ > \frac{n}{2} $
  • 因此:$ n! \geq \left( \frac{n}{2} \right)^{(n+1)/2} $

统一处理:

为了得到一个对所有n都成立的简单下界,我们注意到:

  1. 当n为偶数时:$ n! \geq \left( \frac{n}{2} \right)^{n/2} $
  2. 当n为奇数时:$ n! \geq \left( \frac{n}{2} \right)^{(n+1)/2} > \left( \frac{n}{2} \right)^{n/2} $

因此,对于所有正整数n:
n!≥(n2)n/2n! \geq \left( \frac{n}{2} \right)^{n/2}n!(2n)n/2

使用不等式 $ n! \geq \left( \frac{n}{2} \right)^{n/2} $(前 $ n/2 $ 个因子均 ≥ $ n/2 $),有:
12(n2)n/2≤2c1n+1 \frac{1}{2} \left( \frac{n}{2} \right)^{n/2} \leq 2^{c_1 n + 1} 21(2n)n/22c1n+1

取对数:
n2lg⁡n2−1≤(c1n+1)lg⁡2=O(n) \frac{n}{2} \lg \frac{n}{2} - 1 \leq (c_1 n + 1) \lg 2 = O(n) 2nlg2n1(c1n+1)lg2=O(n)

左边为 $ \Omega(n \lg n) $,右边为 $ O(n) $,当 $ n $ 足够大时矛盾。

推广:

  • 对 $ 1/n $ 的输入
    要求 $ \frac{1}{n} n! \leq 2^{c_1 n + 1} \Rightarrow (n-1)! \leq 2^{c_1 n + 1} $
    但 $ (n-1)! $ 增长快于任何指数函数,矛盾。

  • 对 $ 1/2^n $ 的输入
    要求 $ 2^{-n} n! \leq 2^{c_1 n + 1} \Rightarrow n! \leq 2^{(c_1 + 1)n + 1} $
    仍矛盾($ n! $ 增长快于 $ c^n $)

结论:不存在比较排序算法,能在 $ O(n) $ 时间内处理 $ 1/2^n $ 甚至更小比例的输入

Exercise 8.1-4

题目:
假设一个长度为 $ n $ 的序列由 $ n/k $ 个子序列组成,每个子序列含 $ k $ 个元素,且前一个子序列的所有元素小于后一个子序列的所有元素。证明:排序所需比较次数的下界是 $ \Omega(n \lg k) $。

解答:

由于子序列间已有序(前一个子序列所有元素 < 后一个子序列所有元素),只需对每个子序列内部排序。

每个子序列有 $ k! $ 种内部排列方式,共 $ n/k $ 个子序列,且相互独立,因此总可能输入数为:
(k!)n/k (k!)^{n/k} (k!)n/k

设决策树高度为 $ h $,则必须满足:

[!NOTE]

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

决策树是这个样子的

  1. 决策树必须至少有 $ (k!)^{n/k} $ 个叶节点(因为有这么多输入)
  2. 高度为 $ h $ 的二叉树最多有 $ 2^h $ 个叶节点

因此,为了容纳所有输入,必须满足:
2h≥(k!)n/k 2^h \geq (k!)^{n/k} 2h(k!)n/k

否则,叶节点不够用,算法无法区分所有输入,就会出错。

2h≥(k!)n/k 2^h \geq (k!)^{n/k} 2h(k!)n/k

取对数:
h≥lg⁡((k!)n/k)=nklg⁡(k!) h \geq \lg \left( (k!)^{n/k} \right) = \frac{n}{k} \lg(k!) hlg((k!)n/k)=knlg(k!)

[!IMPORTANT]

使用不等式 $ \lg(k!) = \Theta(k \lg k) $,更准确地说:
lg⁡(k!)≥c⋅klg⁡k(对某个正常数 c) \lg(k!) \geq c \cdot k \lg k \quad \text{(对某个正常数 } c \text{)} lg(k!)cklgk(对某个正常数 c

或者直接使用积分估计:
lg⁡(k!)=∑i=1klg⁡i≥∫1klg⁡x dx=kln⁡k−k+1ln⁡2=Ω(klg⁡k) \lg(k!) = \sum_{i=1}^{k} \lg i \geq \int_1^k \lg x \, dx = \frac{k \ln k - k + 1}{\ln 2} = \Omega(k \lg k) lg(k!)=i=1klgi1klgxdx=ln2klnkk+1=Ω(klgk)

由 $ \lg(k!) = \Theta(k \lg k) $,有:
h≥nk⋅Ω(klg⁡k)=Ω(nlg⁡k) h \geq \frac{n}{k} \cdot \Omega(k \lg k) = \Omega(n \lg k) hknΩ(klgk)=Ω(nlgk)

因此,任何比较排序算法在最坏情况下至少需要 $ \Omega(n \lg k) $ 次比较。

得证。

8.2 计数排序

算法实现

算法说明:
计数排序是一种非比较排序算法,适用于已知元素范围的整数排序。它通过计算每个元素出现的次数来实现排序。

def counting_sort(A, B, k):
    """
    计数排序算法实现
    
    参数:
    A: 输入数组(待排序的数组)
    B: 输出数组(排序后的数组)
    k: 数组中元素的最大值
    
    算法步骤:
    1. 统计每个元素出现的次数
    2. 计算累积计数
    3. 根据累积计数将元素放到正确位置
    """
    
    # 创建计数数组C
    # 初始化计数数组
    C = [0] * (k + 1)  # C[0..k]
    
    # 统计每个元素出现的次数
    for j in range(len(A)):
        C[A[j]] += 1
    # 现在C[i]包含等于i的元素个数
    
    # 算累积计数(a)->(b)
    # 记录比其小的元素个数加其本身个数
    for i in range(1, k + 1):
        C[i] += C[i - 1]
    # 现在C[i]包含小于等于i的元素个数
    
    # 根据累积计数将元素放到输出数组的正确位置
    for j in range(len(A) - 1, -1, -1):  # 从A.length downto 1
        B[C[A[j]] - 1] = A[j]  # 注意:Python是0-based索引
        C[A[j]] -= 1
        
    # B[C[A[7]] = A[7] -> B[C[3]] = 3 -> B[7] = 3 

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

  • A:原始数组
  • B:返回数组
  • C:计数数组

Exercise 8.2-2

题目:
试证明 COUNTING-SORT 是稳定的。

解答:

稳定性定义:

一个排序算法是稳定的,如果在输入中相等的元素,在输出数组中的相对顺序与输入中相同。


证明:

考虑输入数组 $ A $ 中两个相等的元素,分别位于位置 $ i $ 和 $ j $,其中 $ i < j $,且 $ A[i] = A[j] = k $。

我们分析这两个元素在 COUNTING-SORT 算法中的处理过程。

算法最后一步(第10–12行)为:

for j in range(len(A) - 1, -1, -1):  # 从后往前遍历
    B[C[A[j]] - 1] = A[j]
    C[A[j]] -= 1

关键观察:

  • 循环从数组末尾向前遍历(jn−1 到 0 )
  • 因为 i<j ,所以 j 更靠后,A[j] 先于 A[i] 被处理

处理过程:

  1. 处理 A[j] 时:
    • A[j]=k ,将其放入输出数组 B 的位置 C[k]−1
    • 然后执行 C[k]=C[k]−1 ,为下一个值为 k 的元素预留前一个位置
  2. 处理 A[i] 时:
    • 此时 C[k] 已经减 1
    • 所以 A[i] 被放入 B[C[k]−1] ,这个位置比之前 A[j] 的位置更靠前

结果:

  • 在输出数组 B 中,A[i] 位于 A[j] 的前面
  • 而在输入数组 A 中,A[i] 也位于 A[j] 的前面(因为 i<j
  • 因此,两个相等元素的相对顺序被保留

Exercise 8.2-3

题目: (使用的是1-based)
假设我们在 COUNTING-SORT 的第10行循环中,将代码从:

for j = A.length downto 1

改为

for j = 1 to A.length  # 从前往后遍历

试证明该算法仍然是正确的。它还稳定吗?

解答:

修改后的算法如下:

for j in range(len(A)):  # 从前往后处理每个元素
    B[C[A[j]] - 1] = A[j]
    C[A[j]] -= 1

该算法仍然能正确排序。原因在于计数数组 $ C $ 的含义未变:$ C[k] $ 表示输入中小于等于 $ k $ 的元素个数。因此,所有值为 $ k $ 的元素在输出数组 $ B $ 中应占据区间 $ [C[k-1], C[k] - 1] $(0-based)。无论遍历顺序如何,只要对每个值为 $ k $ 的元素,将其放入 $ C[k] - 1 $ 位置并递减 $ C[k] $,就能保证所有元素落在正确区间,最终 $ B $ 有序。

然而,算法不再稳定。稳定性要求相等元素在输出中的相对顺序与输入相同。原算法从后往前遍历,后出现的相等元素先被处理并放在靠后位置,从而保持顺序。修改后从前往后遍历,先出现的元素先被处理,但此时 $ C[k] $ 尚未递减,它被放在当前“最后可用位置”,而后出现的相同元素被放在更前的位置,导致相对顺序反转。

反例:设 $ A = [2, 2’, 2’'] $,初始 $ C[2] = 3 $。

  • $ j=0 $: $ A[0] = 2 $ → 放入 $ B[2] ,, C[2] = 2 $
  • $ j=1 $: $ A[1] = 2’ $ → 放入 $ B[1] ,, C[2] = 1 $
  • $ j=2 $: $ A[2] = 2’’ $ → 放入 $ B[0] ,, C[2] = 0 $

输出:$ B = [2’‘, 2’, 2] $,顺序反转,不稳定

尽管对纯整数排序时稳定性看似无关紧要,但在处理带附加信息的数据(如键值对)时,稳定性至关重要。因此,该修改破坏了算法的稳定性。

Exercise 8.2-4

题目:
设计一个算法,它能够对于任何给定的介于 0 到 $ k $ 之间的 $ n $ 个整数先进行预处理,然后在 $ O(1) $ 时间内回答输入的 $ n $ 个整数中有多少个落在区间 [a..b][a..b][a..b] 内。你设计的算法的预处理时间应为 $ \Theta(n+k) $。

解答:

算法设计思路

我们可以借鉴 计数排序(COUNTING-SORT) 中的累积计数思想来解决这个问题。

核心思想:

  • 先统计每个整数出现的次数

  • 构建一个累积计数数组 $ C $,其中 $ C[i] $ 表示输入中小于等于 $ i $ 的元素个数

  • 查询区间 [a..b][a..b][a..b] 内的元素个数时,只需计算:
    C[b]−C[a−1] C[b] - C[a-1] C[b]C[a1]
    这是一个常数时间操作


算法实现

def preprocess(A, k):
    """
    预处理函数:构建累积计数数组
    
    参数:
    A: 输入数组,包含 n 个在 [0, k] 范围内的整数
    k: 整数的最大值
    
    返回:
    C: 累积计数数组,C[i] = 小于等于 i 的元素个数
    """
    # 步骤1:创建计数数组 C,大小为 k+1
    C = [0] * (k + 1)
    
    # 步骤2:统计每个值的出现次数 —— 时间复杂度 O(n)
    for num in A:
        if 0 <= num <= k:
            C[num] += 1
    
    # 步骤3:计算累积计数 —— 时间复杂度 O(k)
    for i in range(1, k + 1):
        C[i] += C[i - 1]
    
    return C

def count_in_range(C, a, b):
    """
    查询函数:返回落在区间 [a..b] 内的元素个数 —— 时间复杂度 O(1)
    
    参数:
    C: 由 preprocess 生成的累积计数数组
    a, b: 查询区间的左右端点(包含)
    
    返回:
    区间 [a..b] 内的元素个数
    """
    if a > b:
        return 0
    
    # 处理边界情况
    if a == 0:
        return C[b] if b < len(C) else C[-1]
    else:
        # 小于等于 b 的个数 - 小于等于 a-1 的个数
        left = C[a - 1] if a - 1 < len(C) else C[-1]
        right = C[b] if b < len(C) else C[-1]
        return right - left

示例说明

设输入数组:
A=[2,5,3,0,2,3,0,3,7,1],k=7 A = [2, 5, 3, 0, 2, 3, 0, 3, 7, 1], \quad k = 7 A=[2,5,3,0,2,3,0,3,7,1],k=7

我们希望预处理后能快速查询任意区间 [a..b][a..b][a..b] 内的元素个数。

步骤1:统计频次

构建计数数组 $ C_0 $,其中 $ C_0[i] $ 表示值 $ i $ 出现的次数:

i01234567
C₀[i]21230101

步骤2:计算累积计数

构建累积数组 $ C $,其中 $ C[i] = C[i-1] + C_0[i] $:

i01234567
C[i]235889910

此时 $ C[i] $ 表示输入中 小于等于 $ i $ 的元素个数。

例如:

  • $ C[3] = 8 $:表示 ≤3 的元素有 8 个(0,0,1,2,2,3,3,3)
  • $ C[7] = 10 $:总共 10 个元素

正确性说明

要查询区间 [a..b][a..b][a..b] 内的元素个数,使用公式:
count=C[b]−C[a−1] \text{count} = C[b] - C[a-1] count=C[b]C[a1]
其中定义 $ C[-1] = 0 $。

举例查询:

  • 查询 [2,5][2,5][2,5]
    $ C[5] - C[1] = 9 - 3 = 6 $
    实际值:2,5,3,2,3,3 → 共6个

  • 查询 [0,1][0,1][0,1]
    $ C[1] - C[-1] = 3 - 0 = 3 $
    实际值:0,0,1 → 共3个

  • 查询 [4,6][4,6][4,6]
    $ C[6] - C[3] = 9 - 8 = 1 $
    实际值:5 → 共1个(注意6不存在)

公式成立。


结论

该算法通过预处理构建累积计数数组 $ C $,使得:

  • 预处理时间:$ \Theta(n + k) $
  • 查询时间:$ O(1) $
  • 空间复杂度:$ \Theta(k) $

正确性保障:
区间 [a..b][a..b][a..b] 内的元素个数 = (≤ b 的元素数) - (< a 的元素数) = $ C[b] - C[a-1] $

8.3 基数排序

算法实现

基数排序是一种非比较排序算法,适用于整数或固定长度字符串的排序。它从最低位到最高位依次对每一位进行稳定排序(如计数排序),最终得到全局有序序列。

算法步骤

  1. 找出数组中的最大值,确定最大位数
  2. 从最低位(个位)到最高位,依次对每一位进行稳定排序
  3. 每轮排序使用计数排序(或其他稳定排序)
def counting_sort_by_digit(A, exp):
    """
    按指定位(exp = 10^i)对数组进行计数排序
    exp 表示当前处理的位数:1(个位)、10(十位)、100(百位)...
    使用计数排序实现
    """
    n = len(A)
    output = [0] * n
    count = [0] * 10  # 数字 0-9

    # 统计当前位上各数字的出现次数
    for num in A:
        digit = (num // exp) % 10
        count[digit] += 1

    # 计算累积计数
    for i in range(1, 10):
        count[i] += count[i - 1]

    # 从后往前填入 output,保证稳定性
    for i in range(n - 1, -1, -1):
        num = A[i]
        digit = (num // exp) % 10
        output[count[digit] - 1] = num
        count[digit] -= 1

    return output


def radix_sort(A):
    """
    基数排序主函数
    参数:
        A: 待排序的非负整数数组
    返回:
        排序后的数组
    时间复杂度: O(d(n + k)), k=10(数字范围)
    空间复杂度: O(n + k)
    """
    if not A:
        return A

    # 找到最大值,确定最大位数
    max_val = max(A)
    
    # 从个位开始,逐位排序
    exp = 1  # exp = 10^i, i=0,1,2...
    while max_val // exp > 0:
        A = counting_sort_by_digit(A, exp)
        exp *= 10

    return A

时间复杂度分析

参数定义:

  • n: 元素个数
  • d: 最大数的位数
  • k: 每一位的可能取值范围(通常为 0-9,所以 k=10)

算法步骤:

  1. 对每一位执行一次计数排序
  2. 共执行 d 次计数排序

单次计数排序的时间复杂度: O(n + k) (创建长度为k的数组以及遍历长度为n的元素)

总时间复杂度:
T(n)=d×O(n+k)=O(d(n+k))T(n) = d \times O(n + k) = O(d(n + k))T(n)=d×O(n+k)=O(d(n+k))

引理 8.4

给定一组 b 位的整数(比如 32 位整数),我们可以用基数排序(Radix Sort)来排序。
如果我们每次用计数排序作为稳定排序方法,那么总运行时间是:
Θ(br(n+2r)) \Theta\left( \frac{b}{r} (n + 2^r) \right) Θ(rb(n+2r))
其中 $ r $ 是我们选择的“每轮处理的位数”。


基数排序的思想是:从低位到高位,逐位排序

例如:对 32 位整数排序,我们可以:

  • 每次处理 8 位(1 字节)
  • 共需处理 $ 32 / 8 = 4 $ 轮
  • 每轮用稳定排序(如计数排序)对这 8 位进行排序

最终整个数组就有序了。

  • $ b $:每个数的总位数(如 32)
  • $ r $:我们决定每轮处理多少位
  • $ d = \lceil b / r \rceil $:需要处理的轮数

例如:$ b=32, r=8 $ → $ d = 4 $ 轮


我们用计数排序作为每轮的稳定排序方法。

计数排序的时间是 $ \Theta(n + k) $,其中:

  • $ n $:元素个数
  • $ k $:当前位上可能的取值范围

现在的问题是:当处理 $ r $ 位时,$ k $ 是多少?

  • $ r $ 位能表示的最大数是 $ 2^r - 1 $
  • 所以取值范围是 $ 0 $ 到 $ 2^r - 1 $
  • 即 $ k = 2^r - 1 $,近似为 $ 2^r $

所以每轮排序时间是:
Θ(n+2r) \Theta(n + 2^r) Θ(n+2r)

总共有 $ d = b / r $ 轮,所以总时间是:
Θ(br(n+2r)) \Theta\left( \frac{b}{r} (n + 2^r) \right) Θ(rb(n+2r))


具体例子

设:$ b = 32 (32位整数),(32 位整数),32位整数), n = 1000 $

我们尝试不同的 $ r $:

情况1:r = 8

  • 每轮处理 8 位
  • 轮数:$ 32 / 8 = 4 $
  • 每轮 $ k = 2^8 = 256 $
  • 每轮时间:$ \Theta(n + 256) = \Theta(n) $
  • 总时间:$ \Theta(4 \cdot n) = \Theta(n) $

情况2:r = 16

  • 轮数:$ 32 / 16 = 2 $
  • $ k = 2^{16} = 65536 $
  • 每轮时间:$ \Theta(n + 65536) $
  • 若 $ n = 1000 $,则 $ 65536 $ 远大于 $ n $,时间由 $ 2^r $ 主导
  • 总时间:$ \Theta(2 \cdot 65536) = \Theta(2^r) $,比 $ r=8 $ 更慢

情况3:r = 32

  • 轮数:1
  • $ k = 2^{32} $,太大
  • 不现实

最优化

我们要最小化:
T=br(n+2r) T = \frac{b}{r} (n + 2^r) T=rb(n+2r)

情况1:如果 $ b \leq \lfloor \lg n \rfloor $

  • 意味着数据量 $ n $ 很大,而位数 $ b $ 相对较小
  • 例如:$ n = 1000 ,, \lg n \approx 10 ,, b = 8 $
  • 此时 $ 2^r \leq 2^b \leq n $,所以 $ n + 2^r = \Theta(n) $
  • 选择 $ r = b $(一次性处理所有位)
  • 轮数 $ d = 1 $
  • 总时间:$ \Theta(n) $,最优

情况2:如果 $ b \geq \lfloor \lg n \rfloor $

  • 数据量相对较小,位数较多
  • 选择 $ r = \lfloor \lg n \rfloor $
  • 此时 $ 2^r \approx n $,所以 $ n + 2^r = \Theta(n) $
  • 轮数 $ d = b / r $
  • 总时间:$ \Theta\left( \frac{b}{r} \cdot n \right) = \Theta\left( \frac{b n}{\lg n} \right) $

为什么选 $ r = \lg n $?

  • 如果 $ r $ 太小 → 轮数太多($ b/r $ 太大)
  • 如果 $ r $ 太大 → $ 2^r $ 太大,每轮太慢
  • $ r = \lg n $ 是一个平衡点

如何选择 $ r $?

  • 如果 $ b \leq \lg n $:选 $ r = b $,时间 $ \Theta(n) $
  • 如果 $ b > \lg n $:选 $ r = \lfloor \lg n \rfloor $,时间 $ \Theta\left( \frac{b n}{\lg n} \right) $

Exercise 8.3-2

题目:
下面的排序算法中哪些是稳定的:插入排序、归并排序、堆排序和快速排序?给出一个能使任何排序算法都稳定的方法。该方法带来的额外时间和空间开销是多少?

解答:

  1. 各算法的稳定性分析:
  • 插入排序:稳定
    相等元素不会被交换到前面,相对顺序保持不变。

  • 归并排序:稳定
    在合并两个有序子数组时,若左右元素相等,优先取左边,保持相对顺序。

  • 堆排序:不稳定
    MAX-HEAPIFY 过程中,相等元素可能被交换,破坏相对顺序。

  • 快速排序:标准实现不稳定
    PARTITION 过程中,相等元素可能被重新排列。但可以修改实现使其稳定。


  1. 使任意排序算法稳定的方法

我们可以预处理输入数组,将每个元素替换为一个有序对:
原元素 A[i]⇒(A[i],i) \text{原元素 } A[i] \quad \Rightarrow \quad (A[i], i) 原元素 A[i](A[i],i)
其中:

  • 第一个分量是元素值
  • 第二个分量是原始索引

排序时定义比较规则:
(a,i)<(b,j)当且仅当a<b或(a=b且i<j) (a, i) < (b, j) \quad \text{当且仅当} \quad a < b \quad \text{或} \quad (a = b \quad \text{且} \quad i < j) (a,i)<(b,j)当且仅当a<b(a=bi<j)

这样,即使两个元素值相同,也能通过索引区分,且索引小的排在前面。

效果:任何排序算法在此表示下都变为稳定

  1. 额外开销
  • 空间开销:$ \Theta(n) $
    每个元素增加一个索引字段,总空间增加 $ \Theta(n) $

  • 时间开销:$ O(1) $ 每次比较(多一次整数比较)
    总体时间复杂度渐近不变

例如:数组 [2, 1, 1, 3] 变为 [(2,0), (1,1), (1,2), (3,3)]
两个 1 通过索引 1 < 2 区分,排序后 (1,1)(1,2)


结论:

  • 稳定算法:插入排序、归并排序
  • 可以稳定的算法:快速排序(修改后)
  • 不稳定算法:堆排序
  • 可通过附加索引使任何算法稳定,空间开销 $ \Theta(n) $,时间开销不变

Exercise 8.3-3

题目:
利用归纳法证明基数排序是正确的。在证明中,哪里需要假设所用的底层排序算法是稳定的?

解答:

我们用数学归纳法证明基数排序的正确性。

设待排序的数有 $ d $ 位,从低位到高位依次为第 $ 1 $ 到第 $ d $ 位。

归纳命题 $ P(i) $:

在完成第 $ i $ 轮(按第 $ i $ 位)排序后,数组在最低 $ i $ 位上是有序的。


基础情况 $ i = 1 $:

  • 第 1 轮按个位排序
  • 排序后所有元素按个位升序排列
  • $ P(1) $ 成立

归纳假设:

假设 $ P(i-1) $ 成立,即前 $ i-1 $ 轮排序后,数组在最低 $ i-1 $ 位上有序。


归纳步骤(证明 $ P(i) $):

考虑任意两个元素 $ x $ 和 $ y $,设它们的最低 $ i $ 位分别为:

  • $ x $: $ a_i a_{i-1} \cdots a_1 $
  • $ y $: $ b_i b_{i-1} \cdots b_1 $

我们要证明:在第 $ i $ 轮排序后,若按最低 $ i $ 位比较 $ x < y $,则 $ x $ 在 $ y $ 前。

分两种情况:

  1. 第 $ i $ 位不同:$ a_i < b_i $

    • 第 $ i $ 轮排序时,$ x $ 的第 $ i $ 位更小
    • 所以 $ x $ 会被排在 $ y $ 前面
    • 正确
  2. 第 $ i $ 位相同:$ a_i = b_i $

    • 排序时,它们被视为"相等"
    • 关键点:如果底层排序是稳定的,则它们的相对顺序保持不变
    • 由归纳假设,前 $ i-1 $ 位已使它们有序
    • 稳定性保证第 $ i $ 位相等时,不破坏已建立的顺序
    • 正确

关键点:稳定性的作用

在第 $ i $ 位相同时,必须依赖底层排序的稳定性来保持低 $ i-1 $ 位的有序性

如果没有稳定性,相同高位的元素可能被重新打乱,破坏已有的低位顺序。

结论:

  • 基数排序正确
  • 稳定性是归纳步骤成立的关键条件

Exercise 8.3-4

题目:
说明如何在 $ O(n) $ 时间内,对 $ 0 $ 到 $ n^3 - 1 $ 区间内的 $ n $ 个整数进行排序。

解答:

思路:使用基数排序 + 进制转换

这些数最大为 $ n^3 - 1 $,可以用基数为 $ n $ 的表示。

步骤:

  1. 将每个数转换为以 $ n $ 为基的表示
    一个 $ b $ 位的 $ n $ 进制数可表示的最大值为 $ n^b - 1 $

    要表示 $ n^3 - 1 $,需要:
    nb−1≥n3−1⇒b≥3 n^b - 1 \geq n^3 - 1 \Rightarrow b \geq 3 nb1n31b3
    所以最多需要 3 位 $ n $ 进制数

  2. 使用基数排序

    • 每轮对一位进行排序
    • 使用计数排序作为稳定排序方法
    • 每位的取值范围是 $ 0 $ 到 $ n-1 $,共 $ n $ 个值
    • 每轮计数排序时间:$ O(n + n) = O(n) $
    • 共 3 轮 → 总时间:$ O(3n) = O(n) $

具体转换示例:

对于数字 $ x $,转换为 $ n $ 进制:

  • 第1位(最低位):$ x \bmod n $
  • 第2位:$ \lfloor x/n \rfloor \bmod n $
  • 第3位(最高位):$ \lfloor x/n^2 \rfloor $

示例:

设 $ n = 3 $,数在 $ 0 $ 到 $ 26 $ 之间

  • $ 25 = 2 \times 9 + 2 \times 3 + 1 \times 1 $ 表示为 $ (2, 2, 1)_3 $
  • 基数排序按位 1、位 2、位 3 排序,3 轮完成

结论:

  • 时间复杂度:$ O(n) $
  • 方法:将数转为 $ n $ 进制,3 位,基数排序

Exercise 8.3-5

题目:
在本节给出的第一个卡片排序算法中,为排序 $ d $ 位十进制数,在最坏情况下需要多少轮排序?在最坏情况下,操作员需要记录多少堆卡片?

解答:

卡片排序是一种物理实现的基数排序,每轮根据一位数字将卡片分到 10 个堆中(0–9)。

分析:

  • 每轮处理一位数字
  • 一个 $ d $ 位十进制数有 $ d $ 位
  • 必须从最低位到最高位处理所有 $ d $ 位

例如:324 → 先按个位(4)分堆,再按十位(2),再按百位(3)

答案:

  • 排序轮数总是 $ d $ 轮
    无论输入如何,都必须处理每一位,无法跳过任何一位

  • 需要记录的堆数总是 $ 10 $ 堆
    每位是 0–9,需要10个位置来存放卡片,这是固定需求

"最坏情况"的解释:

这里的"最坏情况"不是指算法性能的最坏情况,而是指:

  • 不管输入数据如何分布,都必须进行 $ d $ 轮排序
  • 不管哪一位数字,都需要准备10个堆的位置

操作员必须准备10个位置来存放卡片,这是固定的需求

结论:

  • 轮数:$ d $(固定)
  • 堆数:$ 10 $(固定)
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值