第八章 线性时间排序
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
n−1
这表示:在最佳情况下,比较排序算法至少需要 $ 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 $,有:
∫1nlgx dx≤∑k=1nlgk≤∫1n+1lgx dx
\int_1^n \lg x \, dx \leq \sum_{k=1}^{n} \lg k \leq \int_1^{n+1} \lg x \, dx
∫1nlgxdx≤k=1∑nlgk≤∫1n+1lgxdx
计算积分(使用 $ \lg x = \frac{\ln x}{\ln 2} $):
∫lgx dx=1ln2(xlnx−x)+C
\int \lg x \, dx = \frac{1}{\ln 2} (x \ln x - x) + C
∫lgxdx=ln21(xlnx−x)+C
下界:
∫1nlgx dx=1ln2[xlnx−x]1n=1ln2(nlnn−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[xlnx−x]1n=ln21(nlnn−n+1)
上界:
∫1n+1lgx dx=1ln2[xlnx−x]1n+1=1ln2((n+1)ln(n+1)−(n+1)+1)=(n+1)ln(n+1)−nln2
\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[xlnx−x]1n+1=ln21((n+1)ln(n+1)−(n+1)+1)=ln2(n+1)ln(n+1)−n
因此:
nlnn−n+1ln2≤∑k=1nlgk≤(n+1)ln(n+1)−nln2
\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}
ln2nlnn−n+1≤k=1∑nlgk≤ln2(n+1)ln(n+1)−n
两边均可表示为 $ \Theta(n \log n) $,故:
lg(n!)=Θ(nlgn)
\lg(n!) = \Theta(n \lg n)
lg(n!)=Θ(nlgn)
更精确地,有:
lg(n!)=nlgn−nlge+O(lgn)
\lg(n!) = n \lg n - n \lg e + O(\lg n)
lg(n!)=nlgn−nlge+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=0∑k2d=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=0∑c1n2d=2c1n+1−1<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都成立的简单下界,我们注意到:
- 当n为偶数时:$ n! \geq \left( \frac{n}{2} \right)^{n/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/2≤2c1n+1
取对数:
n2lgn2−1≤(c1n+1)lg2=O(n)
\frac{n}{2} \lg \frac{n}{2} - 1 \leq (c_1 n + 1) \lg 2 = O(n)
2nlg2n−1≤(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]
决策树是这个样子的
- 决策树必须至少有 $ (k!)^{n/k} $ 个叶节点(因为有这么多输入)
- 高度为 $ 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!)
h≥lg((k!)n/k)=knlg(k!)
[!IMPORTANT]
使用不等式 $ \lg(k!) = \Theta(k \lg k) $,更准确地说:
lg(k!)≥c⋅klgk(对某个正常数 c) \lg(k!) \geq c \cdot k \lg k \quad \text{(对某个正常数 } c \text{)} lg(k!)≥c⋅klgk(对某个正常数 c)或者直接使用积分估计:
lg(k!)=∑i=1klgi≥∫1klgx dx=klnk−k+1ln2=Ω(klgk) \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=1∑klgi≥∫1klgxdx=ln2klnk−k+1=Ω(klgk)
由 $ \lg(k!) = \Theta(k \lg k) $,有:
h≥nk⋅Ω(klgk)=Ω(nlgk)
h \geq \frac{n}{k} \cdot \Omega(k \lg k) = \Omega(n \lg k)
h≥kn⋅Ω(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
关键观察:
- 循环从数组末尾向前遍历(j 从 n−1 到 0 )
- 因为 i<j ,所以 j 更靠后,A[j] 先于 A[i] 被处理
处理过程:
- 处理 A[j] 时:
- A[j]=k ,将其放入输出数组 B 的位置 C[k]−1
- 然后执行 C[k]=C[k]−1 ,为下一个值为 k 的元素预留前一个位置
- 处理 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[a−1]
这是一个常数时间操作
算法实现
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 $ 出现的次数:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| C₀[i] | 2 | 1 | 2 | 3 | 0 | 1 | 0 | 1 |
步骤2:计算累积计数
构建累积数组 $ C $,其中 $ C[i] = C[i-1] + C_0[i] $:
| i | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| C[i] | 2 | 3 | 5 | 8 | 8 | 9 | 9 | 10 |
此时 $ 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[a−1]
其中定义 $ 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 基数排序
算法实现
基数排序是一种非比较排序算法,适用于整数或固定长度字符串的排序。它从最低位到最高位依次对每一位进行稳定排序(如计数排序),最终得到全局有序序列。
算法步骤
- 找出数组中的最大值,确定最大位数
- 从最低位(个位)到最高位,依次对每一位进行稳定排序
- 每轮排序使用计数排序(或其他稳定排序)
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)
算法步骤:
- 对每一位执行一次计数排序
- 共执行 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
题目:
下面的排序算法中哪些是稳定的:插入排序、归并排序、堆排序和快速排序?给出一个能使任何排序算法都稳定的方法。该方法带来的额外时间和空间开销是多少?
解答:
- 各算法的稳定性分析:
-
插入排序:稳定
相等元素不会被交换到前面,相对顺序保持不变。 -
归并排序:稳定
在合并两个有序子数组时,若左右元素相等,优先取左边,保持相对顺序。 -
堆排序:不稳定
MAX-HEAPIFY过程中,相等元素可能被交换,破坏相对顺序。 -
快速排序:标准实现不稳定
PARTITION过程中,相等元素可能被重新排列。但可以修改实现使其稳定。
- 使任意排序算法稳定的方法
我们可以预处理输入数组,将每个元素替换为一个有序对:
原元素 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=b且i<j)
这样,即使两个元素值相同,也能通过索引区分,且索引小的排在前面。
效果:任何排序算法在此表示下都变为稳定
- 额外开销
-
空间开销:$ \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 $ 前。
分两种情况:
-
第 $ i $ 位不同:$ a_i < b_i $
- 第 $ i $ 轮排序时,$ x $ 的第 $ i $ 位更小
- 所以 $ x $ 会被排在 $ y $ 前面
- 正确
-
第 $ 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 $ 的表示。
步骤:
-
将每个数转换为以 $ 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 nb−1≥n3−1⇒b≥3
所以最多需要 3 位 $ n $ 进制数 -
使用基数排序
- 每轮对一位进行排序
- 使用计数排序作为稳定排序方法
- 每位的取值范围是 $ 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 $(固定)

922

被折叠的 条评论
为什么被折叠?



