算法导论第三版代码python实现与部分习题答案-第六章:堆排序
第六章 堆排序
6.1 堆
Exercise 6.1-1
题目:
在高度为 $ h $ 的堆中,元素个数最多和最少分别是多少?
解答:
- 最少元素个数:$ 2^h $
- 最多元素个数:$ 2^{h+1} - 1 $
解释:
-
最少情况:当堆的前 $ h $ 层构成一个深度为 $ h-1 $ 的完全二叉树(共 $ 2^h - 1 $ 个节点),第 $ h+1 $ 层仅有一个最左侧的叶节点。此时总节点数为:
(2h−1)+1=2h (2^h - 1) + 1 = 2^h (2h−1)+1=2h -
最多情况:当堆是一棵深度为 $ h $ 的完全二叉树(即满二叉树),所有层都完全填满。此时总节点数为:
∑i=0h2i=2h+1−1 \sum_{i=0}^{h} 2^i = 2^{h+1} - 1 i=0∑h2i=2h+1−1
注:此处“高度”定义为从根到最远叶节点的边数(即根的高度为 0)。
Exercise 6.1-2
题目:
证明:含 $ n $ 个元素的堆的高度为 $ \lfloor \lg n \rfloor $。
解答:
设堆的高度为 $ h $。根据 Exercise 6.1-1 的结论,高度为 $ h $ 的堆满足:
2h≤n<2h+1
2^h \leq n < 2^{h+1}
2h≤n<2h+1
对不等式取以 2 为底的对数:
h≤lgn<h+1
h \leq \lg n < h + 1
h≤lgn<h+1
因此:
h=⌊lgn⌋
h = \lfloor \lg n \rfloor
h=⌊lgn⌋
故含 $ n $ 个元素的堆的高度为 $ \lfloor \lg n \rfloor $。
Exercise 6.1-3
题目:
证明:在最大堆的任一子树中,该子树所包含的最大元素在该子树的根结点上。
解答:
采用反证法。假设存在某个子树,其最大元素不在根节点,而在某个非根节点 $ x $ 上。
由于 $ x $ 不是子树的根,则它在该子树中有一个父节点 $ p $。根据最大堆性质,有:
A[p]≥A[x]
A[p] \geq A[x]
A[p]≥A[x]
但 $ A[x] $ 是子树中的最大元素,故 $ A[x] > A[p] $(若相等则仍不违反,但最大值仍应在根路径上;关键在于 $ A[x] $ 严格大于其祖先中某些值)。
特别地,从 $ x $ 到子树根的路径上,每个父节点都应 ≥ 其子节点。因此,子树根的值 ≥ 路径上所有节点的值,包括 $ x $,即:
A[root]≥A[x]
A[\text{root}] \geq A[x]
A[root]≥A[x]
这与 $ A[x] $ 是子树中最大元素且不在根矛盾(除非相等,但即使相等,最大值也在根可达路径上,而堆性质要求根 ≥ 所有后代)。
更直接地说:若最大值不在根,则存在某个节点 $ x $ 满足 $ A[x] > A[\text{root}] $,但堆性质要求根 ≥ 所有后代,矛盾。
因此,子树中的最大元素必须位于该子树的根节点上。
Exercise 6.1-4
题目:
假设一个最大堆的所有元素都不相同,那么该堆的最小元素应该位于哪里?
解答:
最小元素必须位于某个叶节点上。
证明:
采用反证法。假设最小元素位于非叶节点 $ x $ 上,并设 $ y $ 是 $ x $ 的一个子节点。
根据最大堆性质,有:
A[x]≥A[y]
A[x] \geq A[y]
A[x]≥A[y]
由于所有元素互不相同,该不等式是严格的:
A[x]>A[y]
A[x] > A[y]
A[x]>A[y]
但这与 $ A[x] $ 是堆中最小元素矛盾(因为 $ A[y] < A[x] $)。
因此,包含最小元素的节点不可能有子节点,即必须是叶节点。
Exercise 6.1-5
题目:
一个已排好序的数组是一个最小堆吗?
解答:
是的,一个升序排列的数组是一个最小堆。
解释:
考虑使用 1-based 索引的数组 $ A[1…n] $,且 $ A[1] \leq A[2] \leq \cdots \leq A[n] $。
对于任意非叶节点 $ i $(即 $ i \leq \lfloor n/2 \rfloor $),其左子节点为 $ 2i $,右子节点为 $ 2i+1 $(若存在)。
由于 $ i < 2i < 2i+1 \leq n $,且数组升序,有:
A[i]≤A[2i],A[i]≤A[2i+1]
A[i] \leq A[2i], \quad A[i] \leq A[2i+1]
A[i]≤A[2i],A[i]≤A[2i+1]
因此,每个节点的值都不大于其子节点的值,满足最小堆性质。
Exercise 6.1-6
题目:
值为 $ \langle 23, 17, 14, 6, 13, 10, 1, 5, 7, 12 \rangle $ 的数组是一个最大堆吗?
解答:
不是,该数组不是一个最大堆。
解释:
使用 1-based 索引分析:
- 元素 $ 7 $ 位于第 9 个位置($ A[9] = 7 $)
- 其父节点位置为 $ \lfloor 9/2 \rfloor = 4 $,对应元素 $ A[4] = 6 $
- 但 $ 7 > 6 $,即子节点值大于父节点值,违反了最大堆性质(父节点应 ≥ 子节点)
因此,该数组不满足最大堆性质。
Exercise 6.1-7
题目:
证明:当用数组表示存储 $ n $ 个元素的堆时,叶结点下标分别是 $ \lfloor n/2 \rfloor + 1, \lfloor n/2 \rfloor + 2, \ldots, n $。
解答:
只需证明:节点 $ i $ 是叶节点 当且仅当 它没有子节点,即 $ 2i > n $。
证明:
-
若 $ i \geq \lfloor n/2 \rfloor + 1 $,则 $ i $ 是叶节点:
此时:
i>n/2⇒2i>n i > n/2 \Rightarrow 2i > n i>n/2⇒2i>n
因此左子节点 $ 2i $ 已超出数组范围,右子节点 $ 2i+1 $ 更是如此。故 $ i $ 无子节点,为叶节点。 -
若 $ i $ 是叶节点,则 $ i \geq \lfloor n/2 \rfloor + 1 $:
若 $ i $ 是叶节点,则无子节点,即:
2i>n⇒i>n/2 2i > n \Rightarrow i > n/2 2i>n⇒i>n/2
由于 $ i $ 是整数,有:
i≥⌊n/2⌋+1 i \geq \lfloor n/2 \rfloor + 1 i≥⌊n/2⌋+1
综上,叶节点的下标集合为:
{⌊n/2⌋+1,⌊n/2⌋+2,…,n}
\{ \lfloor n/2 \rfloor + 1, \lfloor n/2 \rfloor + 2, \ldots, n \}
{⌊n/2⌋+1,⌊n/2⌋+2,…,n}
6.2 维护堆的性质
算法实现
算法说明:
MAX-HEAPIFY 是堆排序中的核心算法,用于维护最大堆的性质。它假设节点 i 的左右子树都已经满足最大堆性质,但节点 i 可能违反了最大堆性质,需要将其"下沉"到正确位置。
# 此时提到的数组从0开始 也就是0-based
def left(i):"""计算节点i的左子节点索引(0-based索引)"""return 2 * i + 1def right(i):"""计算节点i的右子节点索引(0-based索引)"""return 2 * i + 2def max_heapify(A, i, heap_size):"""维护最大堆性质的函数参数:A: 数组(列表),表示堆结构(使用0-based索引)i: 需要维护堆性质的节点索引heap_size: 堆的有效大小(数组中前heap_size个元素构成堆)功能:确保以节点i为根的子树满足最大堆性质"""# 获取左子节点和右子节点的索引l = left(i) r = right(i) # 找到节点i、左子节点、右子节点中值最大的节点索引if l < heap_size and A[l] > A[i]: largest = l else:largest = i if r < heap_size and A[r] > A[largest]: largest = r # 如果最大值不在节点i上,则交换并递归处理if largest != i: # 交换节点i和最大值节点的值A[i], A[largest] = A[largest], A[i] # 递归维护被交换节点的子树堆性质max_heapify(A, largest, heap_size) # 使用示例:
# arr = [16, 4, 10, 14, 7, 9, 3, 2, 8, 1]
# heap_size = len(arr)
# max_heapify(arr, 1, heap_size) # 从索引1开始维护堆性质
16 → 16 → 16/ \ / \ / \4 10 14 10 14 10/ \ / \ swap(1,3) / \ / \ swap(3,8) / \ / \14 7 9 3 ─────────> 4 7 9 3 ─────────> 8 7 9 3/ \ / / \/ / \/
2 8 1 2 8 1 2 4 1A[1]=4 < A[3]=14 A[3]=4 < A[8]=8 已满足堆性质需下沉 → 交换 继续下沉 → 交换 停止
时间复杂度分析
- 递归式建立
MAX-HEAPIFY
在每次调用中:
- 执行常数时间操作(比较与交换)
- 最多递归处理一个子树
设 $ T(n) $ 为处理 $ n $ 个节点的运行时间。
最坏情况下,递归发生在最大的子树上。
可以证明:最大子树的大小至多为 $ \frac{2n}{3} $(最坏情况:堆的最底层恰好半满,且在左子树)。
因此,递归式为:
T(n)≤T(2n3)+O(1)
T(n) \leq T\left(\frac{2n}{3}\right) + O(1)
T(n)≤T(32n)+O(1)
考虑递归式:
T(n)=T(2n3)+O(1)
T(n) = T\left(\frac{2n}{3}\right) + O(1)
T(n)=T(32n)+O(1)
与主定理形式 $ T(n) = aT(n/b) + f(n) $ 对比:
- $ a = 1 $
- $ b = \frac{3}{2} $(因为 $ n/b = 2n/3 \Rightarrow b = 3/2 $)
- $ f(n) = O(1) $
计算 $ n^{\log_b a} = n^{\log_{3/2} 1} = n^0 = 1 $
比较 $ f(n) = O(1) $ 与 $ n^{\log_b a} = 1 $:
- $ f(n) = \Theta(n^{\log_b a}) $,属于主定理情况 2
因此:
T(n)=Θ(logn)
T(n) = \Theta(\log n)
T(n)=Θ(logn)
更精确地,由于每层只递归一次且工作量为常数:
T(n)=O(logn)
T(n) = O(\log n)
T(n)=O(logn)
MAX-HEAPIFY
的最坏运行时间为:
O(logn)
\boxed{O(\log n)}
O(logn)
即,与堆的高度成正比。若堆高为 $ h $,则 $ T(n) = O(h) = O(\lg n) $。
Exercise 6.2-2
题目:
参考过程 MAX-HEAPIFY,写出能够维护相应最小堆的 MIN-HEAPIFY(A, i)
的代码,并比较 MIN-HEAPIFY
与 MAX-HEAPIFY
的运行时间。
解答:
MIN-HEAPIFY 代码:
def left(i):"""返回左子节点索引(0-based)"""return 2 * i + 1def right(i):"""返回右子节点索引(0-based)"""return 2 * i + 2def min_heapify(A, i, heap_size):"""维护最小堆性质:父节点 ≤ 子节点"""l = left(i)r = right(i)# 找出 i, l, r 中值最小的节点if l < heap_size and A[l] < A[i]:smallest = lelse:smallest = iif r < heap_size and A[r] < A[smallest]:smallest = r# 如果最小值不在当前节点,则交换并递归下沉if smallest != i:A[i], A[smallest] = A[smallest], A[i]min_heapify(A, smallest, heap_size)
MIN-HEAPIFY
的运行时间满足以下递归式:
T(n)=T(2n3)+O(1) T(n) = T\left(\frac{2n}{3}\right) + O(1) T(n)=T(32n)+O(1)
- $ O(1) $:每层执行的比较和交换操作为常数时间
- $ T\left(\frac{2n}{3}\right) $:最坏情况下递归处理最大子树,其规模至多为 $ \frac{2n}{3} $(当堆的最底层恰好半满时)
应用主定理(Master Theorem, 定理 4.1):
-
形式:$ T(n) = aT(n/b) + f(n) $
-
此处:$ a = 1 $, $ b = \frac{3}{2} $, $ f(n) = O(1) $
-
计算:
logba=log3/21=0⇒nlogba=n0=1 \log_b a = \log_{3/2} 1 = 0 \quad \Rightarrow \quad n^{\log_b a} = n^0 = 1 logba=log3/21=0⇒nlogba=n0=1 -
$ f(n) = O(1) = \Theta(n^{\log_b a}) $,属于主定理情况 2
因此,解为:
T(n)=Θ(logn)
\boxed{T(n) = \Theta(\log n)}
T(n)=Θ(logn)
即:
O(logn)
\boxed{O(\log n)}
O(logn)
MIN-HEAPIFY
的最坏运行时间为 $ O(\log n) $,与 MAX-HEAPIFY
完全相同,仅比较方向不同,结构与时间复杂度一致。
Exercise 6.2-3
题目:
当元素 $ A[i] $ 比其孩子的值都大时,调用 MAX-HEAPIFY(A, i)
会有什么结果?
解答:
数组保持不变。
解释:
MAX-HEAPIFY
的作用是维护最大堆性质:父节点 ≥ 子节点。
如果 $ A[i] $ 已经大于其所有子节点的值,则在比较过程中:
- $ A[i] \geq A[l] $ 且 $ A[i] \geq A[r] $
- 因此
largest
会被设为i
- 条件
if largest != i
不成立 - 不执行交换,也不进行递归调用
所以函数直接返回,堆结构无任何变化。
Exercise 6.2-4
题目:
当 $ i > \text{heap_size}/2 $ 时,调用 MAX-HEAPIFY(A, i)
会有什么结果?
解答:
不会有任何操作。
解释:
在基于 0-based 索引的堆中:
- 非叶节点(有子节点)的最大索引为 $ \lfloor \text{heap_size}/2 \rfloor - 1 $(若使用 0-based)
- 更清晰地:叶节点的索引从 $ \lfloor \text{heap_size}/2 \rfloor $ 开始到 $ \text{heap_size} - 1 $
因此,当 $ i > \text{heap_size}/2 $ 时(特别地,$ i \geq \lfloor \text{heap_size}/2 \rfloor $),节点 $ i $ 是叶节点,没有左子或右子节点。
在 MAX-HEAPIFY
中:
l = 2*i + 1
和r = 2*i + 2
都 $ \geq \text{heap_size} $- 所以
l < heap_size
和r < heap_size
均为假 largest
被设为i
if largest != i
为假,不交换也不递归
函数直接返回,无任何操作。
Exercise 6.2-5
题目:
MAX-HEAPIFY
的代码效率较高,但递归调用可能例外,它可能使某些编译器产生低效的代码。请用循环控制结构取代递归,重写 MAX-HEAPIFY
代码。
解答:
迭代版本的 MAX-HEAPIFY(使用循环代替递归)
def left(i):"""返回左子节点索引(0-based)"""return 2 * i + 1def right(i):"""返回右子节点索引(0-based)"""return 2 * i + 2def max_heapify_loop(A, i, heap_size):"""使用循环实现的 MAX-HEAPIFY,避免递归开销"""while True:l = left(i)r = right(i)# 找出 i, l, r 中最大值的索引if l < heap_size and A[l] > A[i]:largest = lelse:largest = iif r < heap_size and A[r] > A[largest]:largest = r# 如果最大值就是当前节点,堆性质已满足,退出循环if largest == i:break# 否则交换并继续在被交换的子节点上维护堆性质A[i], A[largest] = A[largest], A[i]# 更新 i 为被交换的子节点,模拟递归下沉i = largest# 继续循环
说明:
- 核心思想:将递归版本中“递归调用
max_heapify(A, largest, heap_size)
”的过程,改为通过更新当前节点索引i = largest
并继续循环来模拟节点在堆中逐层下沉的过程。 - 终止条件:当
largest == i
时,表示当前节点已大于或等于其子节点,最大堆性质满足,循环结束。 - 时间复杂度:仍为 $ O(\log n) $,因为最坏情况下需从根节点下沉至叶子节点,路径长度为树高。
- 空间复杂度:由递归版本的 $ O(\log n) $(函数调用栈深度)降低为 $ O(1) $,仅使用常量额外空间。
Exercise 6.2-6
题目:
证明:对一个大小为 $ n $ 的堆,MAX-HEAPIFY
的最坏情况运行时间为 $ \Omega(\lg n) $。
解答:
构造最坏情况输入
我们构造一个大小为 $ n $ 的最大堆(数组表示),使得 MAX-HEAPIFY
被调用时,触发最长可能的下沉路径:
- 设根节点 $ A[1] = 1 $(使用 1-based 索引)
- 所有其他节点 $ A[i] = 2 $,其中 $ 2 \leq i \leq n $
即:
A=[1,2,2,2,…,2]
A = [1, 2, 2, 2, \dots, 2]
A=[1,2,2,2,…,2]
此时:
- 除根外,所有节点值均为 2
- 根节点值为 1,明显小于其子节点
- 堆的结构是一棵完全二叉树
运行过程分析
调用 MAX-HEAPIFY(A, 1, n)
(从根开始维护堆性质):
- 由于 $ A[1] = 1 < 2 = A[2] $ 和 $ A[3] $,
largest
将指向左或右子节点(值相同,任选其一)。 - 交换 $ A[1] $ 与 $ A[2] $(假设选左子),此时 $ A[2] = 1 $
- 递归调用
MAX-HEAPIFY(A, 2, n)
- 在节点 2 处,其子节点 $ A[4], A[5] $ 均为 2 > 1,继续交换
- 节点值 1 持续“下沉”,直到到达某条路径的叶节点
由于每次比较都发现当前节点小于子节点,交换持续发生,节点 1 会从根沿一条最长路径一直下沉到叶子。
路径长度分析
- 完全二叉堆的高度为 $ h = \lfloor \lg n \rfloor $
- 从根到最远叶节点的路径长度(边数)为 $ h $
- 因此,
MAX-HEAPIFY
至少执行 $ h = \lfloor \lg n \rfloor $ 次递归调用(或循环迭代)
即运行时间至少与树高成正比。
结论
存在一种输入情况(如上述构造),使得 MAX-HEAPIFY
的运行时间达到 $ \Theta(\lg n) $。
因此,其最坏情况运行时间为:
Ω(lgn)
\boxed{\Omega(\lg n)}
Ω(lgn)
结合已知上界 $ O(\lg n) $,可得 MAX-HEAPIFY
的最坏时间复杂度为:
Θ(lgn)
\boxed{\Theta(\lg n)}
Θ(lgn)
6.3 建堆
算法实现
算法说明:
BUILD-MAX-HEAP 算法通过从最后一个非叶节点开始,自底向上地对每个节点调用 MAX-HEAPIFY,从而将一个无序数组转换为最大堆。
def build_max_heap(A):"""将数组A构建为最大堆参数:A: 数组(列表),需要转换为最大堆算法:1. 设置堆大小等于数组长度2. 从最后一个非叶节点开始,向前对每个节点调用MAX-HEAPIFY"""heap_size = len(A) # 第1行:A.heap-size = A.length# 最后一个非叶节点的索引是 floor(n/2) - 1(0-based索引)for i in range(heap_size // 2 - 1, -1, -1):# 第3行:MAX-HEAPIFY(A, i)max_heapify(A, i, heap_size)
时间复杂度分析
- 简单上界:$ O(n \log n) $
-
每次调用
MAX-HEAPIFY
的最坏时间复杂度为 $ O(\lg n) $ -
BUILD-MAX-HEAP
调用MAX-HEAPIFY
共 $ \lfloor n/2 \rfloor = O(n) $ 次 -
因此总时间的简单上界为:
O(n)×O(lgn)=O(nlgn) O(n) \times O(\lg n) = O(n \lg n) O(n)×O(lgn)=O(nlgn)
这个上界是正确的,但不是渐近紧确的(即过于宽松)。
- 精确分析:实际时间复杂度为 $ O(n) $
为了得到更紧确的界,我们利用以下关键性质:
- 堆的高度:含 $ n $ 个元素的堆,其高度为 $ \lfloor \lg n \rfloor $
- 高度为 $ h $ 的节点数:最多为 $ \left\lceil \dfrac{n}{2^{h+1}} \right\rceil $
- MAX-HEAPIFY 在高度 $ h $ 节点上的代价:$ O(h) $
注意:这里的“高度”指从该节点到其最远叶节点的边数(即叶节点高度为 0)。
3.总代价求和
我们将所有节点按其高度 $ h $ 分组,计算每组的总代价并求和:
∑h=0⌊lgn⌋(高度为 h 的节点数)×O(h)≤∑h=0⌊lgn⌋n2h+1⋅O(h)=O(n∑h=0⌊lgn⌋h2h) \sum_{h=0}^{\lfloor \lg n \rfloor} \left( \text{高度为 } h \text{ 的节点数} \right) \times O(h) \leq \sum_{h=0}^{\lfloor \lg n \rfloor} \frac{n}{2^{h+1}} \cdot O(h) = O\left( n \sum_{h=0}^{\lfloor \lg n \rfloor} \frac{h}{2^h} \right) h=0∑⌊lgn⌋(高度为 h 的节点数)×O(h)≤h=0∑⌊lgn⌋2h+1n⋅O(h)=Onh=0∑⌊lgn⌋2hh
由于级数 $ \sum_{h=0}^{\infty} \frac{h}{2^h} $ 收敛,我们可以将其扩展到无穷级数(上界更松但便于计算):
[!IMPORTANT]
幂级数是一类形式为 ∑n=0∞an(x−c)n\sum_{n=0}^{\infty} a_n (x - c)^n∑n=0∞an(x−c)n 的无穷级数,其中 ana_nan 是系数,ccc 是展开中心。当 c=0c = 0c=0 时,称为麦克劳林级数;一般情形称为泰勒级数。
最基本的幂级数是几何级数:
∑k=0∞xk=1+x+x2+x3+⋯ \sum_{k=0}^{\infty} x^k = 1 + x + x^2 + x^3 + \cdots k=0∑∞xk=1+x+x2+x3+⋯
当 ∣x∣<1|x| < 1∣x∣<1 时收敛,其和为:
∑k=0∞xk=11−x \sum_{k=0}^{\infty} x^k = \frac{1}{1 - x} k=0∑∞xk=1−x1
当 ∣x∣≥1|x| \geq 1∣x∣≥1 时发散。几何级数的部分和(前 nnn 项)为:
∑k=0n−1xk=1−xn1−x,x≠1 \sum_{k=0}^{n-1} x^k = \frac{1 - x^n}{1 - x}, \quad x \ne 1 k=0∑n−1xk=1−x1−xn,x=1对几何级数两边关于 xxx 求导,得到:
ddx(∑k=0∞xk)=∑k=1∞kxk−1=1(1−x)2 \frac{d}{dx} \left( \sum_{k=0}^{\infty} x^k \right) = \sum_{k=1}^{\infty} k x^{k-1} = \frac{1}{(1 - x)^2} dxd(k=0∑∞xk)=k=1∑∞kxk−1=(1−x)21
两边乘以 xxx,得:
∑k=1∞kxk=x(1−x)2,∣x∣<1 \sum_{k=1}^{\infty} k x^k = \frac{x}{(1 - x)^2}, \quad |x| < 1 k=1∑∞kxk=(1−x)2x,∣x∣<1
这是算法分析中常见的级数,例如在堆构建时间复杂度分析中出现。将 x=12x = \frac{1}{2}x=21 代入上式:
∑k=1∞k(12)k=12(1−12)2=1214=2 \sum_{k=1}^{\infty} k \left( \frac{1}{2} \right)^k = \frac{\frac{1}{2}}{\left(1 - \frac{1}{2}\right)^2} = \frac{\frac{1}{2}}{\frac{1}{4}} = 2 k=1∑∞k(21)k=(1−21)221=4121=2
因此:
∑k=0∞k2k=0+∑k=1∞k2k=2 \sum_{k=0}^{\infty} \frac{k}{2^k} = 0 + \sum_{k=1}^{\infty} \frac{k}{2^k} = 2 k=0∑∞2kk=0+k=1∑∞2kk=2类似地,可以计算更高阶的幂级数。例如:
∑k=1∞k2xk=x(1+x)(1−x)3,∣x∣<1 \sum_{k=1}^{\infty} k^2 x^k = \frac{x(1 + x)}{(1 - x)^3}, \quad |x| < 1 k=1∑∞k2xk=(1−x)3x(1+x),∣x∣<1
该结果可通过对 ∑kxk\sum k x^k∑kxk 再次求导并整理得到。幂级数在 ∣x∣<1|x| < 1∣x∣<1 内具有良好的分析性质:可以逐项求导、逐项积分,并且收敛半径内的和函数是连续可导的。
在算法分析中,常利用幂级数估算求和式,尤其是当项的形式为 k⋅rkk \cdot r^kk⋅rk 且 0<r<10 < r < 10<r<1 时,其无穷级数收敛于一个常数倍的 nnn,从而帮助证明线性时间复杂度。
例如,在
BUILD-MAX-HEAP
的时间复杂度分析中,总代价可表示为:
∑h=0⌊lgn⌋n2h+1⋅O(h)=O(n∑h=0∞h2h)=O(n⋅2)=O(n) \sum_{h=0}^{\lfloor \lg n \rfloor} \frac{n}{2^{h+1}} \cdot O(h) = O\left( n \sum_{h=0}^{\infty} \frac{h}{2^h} \right) = O(n \cdot 2) = O(n) h=0∑⌊lgn⌋2h+1n⋅O(h)=O(nh=0∑∞2hh)=O(n⋅2)=O(n)
其中利用了 ∑h=0∞h2h=2\sum_{h=0}^{\infty} \frac{h}{2^h} = 2∑h=0∞2hh=2 这一结论。幂级数的收敛性由比值判别法或根值判别法判断。对于 ∑akxk\sum a_k x^k∑akxk,收敛半径 RRR 为:
R=1lim supk→∞∣ak∣1/k R = \frac{1}{\limsup_{k \to \infty} |a_k|^{1/k}} R=limsupk→∞∣ak∣1/k1
或当极限存在时:
R=limk→∞∣akak+1∣ R = \lim_{k \to \infty} \left| \frac{a_k}{a_{k+1}} \right| R=k→∞limak+1ak常见函数的幂级数展开包括:
- $ \frac{1}{1 - x} = \sum_{k=0}^{\infty} x^k, \quad |x| < 1 $
- $ e^x = \sum_{k=0}^{\infty} \frac{x^k}{k!}, \quad \text{对所有 } x $
- $ \ln(1 - x) = -\sum_{k=1}^{\infty} \frac{x^k}{k}, \quad |x| < 1 $
- $ \sin x = \sum_{k=0}^{\infty} (-1)^k \frac{x^{2k+1}}{(2k+1)!}, \quad \text{对所有 } x $
- $ \cos x = \sum_{k=0}^{\infty} (-1)^k \frac{x^{2k}}{(2k)!}, \quad \text{对所有 } x $
这些展开在近似计算和渐近分析中具有重要作用。
收敛性判别方法
比值判别法(Ratio Test)
对级数 ∑ak\sum a_k∑ak,令:
L=limk→∞∣ak+1ak∣ L = \lim_{k \to \infty} \left| \frac{a_{k+1}}{a_k} \right| L=k→∞limakak+1
- 若 L<1L < 1L<1,级数绝对收敛;
- 若 L>1L > 1L>1,级数发散;
- 若 L=1L = 1L=1,无法判断。
应用于幂级数 ∑akxk\sum a_k x^k∑akxk,收敛半径为:
R=limk→∞∣akak+1∣(若极限存在) R = \lim_{k \to \infty} \left| \frac{a_k}{a_{k+1}} \right| \quad \text{(若极限存在)} R=k→∞limak+1ak(若极限存在)根值判别法(Root Test)
令:
L=lim supk→∞∣ak∣1/k L = \limsup_{k \to \infty} |a_k|^{1/k} L=k→∞limsup∣ak∣1/k
- 若 L<1L < 1L<1,收敛;
- 若 L>1L > 1L>1,发散;
- 若 L=1L = 1L=1,无法判断。
收敛半径为:
R=1lim supk→∞∣ak∣1/k R = \frac{1}{\limsup_{k \to \infty} |a_k|^{1/k}} R=limsupk→∞∣ak∣1/k1比较判别法
若 0≤ak≤bk0 \leq a_k \leq b_k0≤ak≤bk 且 ∑bk\sum b_k∑bk 收敛,则 ∑ak\sum a_k∑ak 收敛;若 ∑ak\sum a_k∑ak 发散,则 ∑bk\sum b_k∑bk 发散。积分判别法
若 f(k)=akf(k) = a_kf(k)=ak 是正的、单调递减函数,则 ∑k=1∞ak\sum_{k=1}^{\infty} a_k∑k=1∞ak 与 ∫1∞f(x)dx\int_1^{\infty} f(x) dx∫1∞f(x)dx 同敛散。
如何求幂级数的和
从已知级数出发
利用基本级数(如几何级数)通过代数变换、求导、积分等操作构造目标级数。例如,已知:
∑k=0∞xk=11−x \sum_{k=0}^{\infty} x^k = \frac{1}{1 - x} k=0∑∞xk=1−x1
两边求导得:
∑k=1∞kxk−1=1(1−x)2⇒∑k=1∞kxk=x(1−x)2 \sum_{k=1}^{\infty} k x^{k-1} = \frac{1}{(1 - x)^2} \Rightarrow \sum_{k=1}^{\infty} k x^k = \frac{x}{(1 - x)^2} k=1∑∞kxk−1=(1−x)21⇒k=1∑∞kxk=(1−x)2x逐项积分或求导
若级数形式类似于某函数的导数或积分,可通过逆向操作求和。例如,由:
11−x=∑k=0∞xk \frac{1}{1 - x} = \sum_{k=0}^{\infty} x^k 1−x1=k=0∑∞xk
两边从 0 到 xxx 积分:
∫0x11−tdt=−ln(1−x)=∑k=0∞∫0xtkdt=∑k=0∞xk+1k+1=∑k=1∞xkk \int_0^x \frac{1}{1 - t} dt = -\ln(1 - x) = \sum_{k=0}^{\infty} \int_0^x t^k dt = \sum_{k=0}^{\infty} \frac{x^{k+1}}{k+1} = \sum_{k=1}^{\infty} \frac{x^k}{k} ∫0x1−t1dt=−ln(1−x)=k=0∑∞∫0xtkdt=k=0∑∞k+1xk+1=k=1∑∞kxk
所以:
ln(1−x)=−∑k=1∞xkk \ln(1 - x) = -\sum_{k=1}^{\infty} \frac{x^k}{k} ln(1−x)=−k=1∑∞kxk构造生成函数
将序列 {an}\{a_n\}{an} 的生成函数表示为幂级数,并利用代数方法求闭式。利用递推关系
若序列满足线性递推,可设其生成函数为 G(x)=∑anxnG(x) = \sum a_n x^nG(x)=∑anxn,代入递推式解出 G(x)G(x)G(x)。
∑h=0∞h2h=1/2(1−1/2)2=1/2(1/2)2=1/21/4=2 \sum_{h=0}^{\infty} \frac{h}{2^h} = \frac{1/2}{(1 - 1/2)^2} = \frac{1/2}{(1/2)^2} = \frac{1/2}{1/4} = 2 h=0∑∞2hh=(1−1/2)21/2=(1/2)21/2=1/41/2=2
因此:
O(n∑h=0⌊lgn⌋h2h)≤O(n∑h=0∞h2h)=O(n⋅2)=O(n)
O\left( n \sum_{h=0}^{\lfloor \lg n \rfloor} \frac{h}{2^h} \right)
\leq O\left( n \sum_{h=0}^{\infty} \frac{h}{2^h} \right)
= O(n \cdot 2) = O(n)
Onh=0∑⌊lgn⌋2hh≤O(nh=0∑∞2hh)=O(n⋅2)=O(n)
结论
尽管直观上 BUILD-MAX-HEAP
似乎是 $ O(n \lg n) $,但通过按高度分层分析可得:
BUILD-MAX-HEAP 的时间复杂度为 O(n) \boxed{ \text{BUILD-MAX-HEAP 的时间复杂度为 } O(n) } BUILD-MAX-HEAP 的时间复杂度为 O(n)
这意味着:我们可以在与数组长度成正比的时间内,将一个无序数组构造为一个最大堆。
Exercise 6.3-2
题目:
对于 BUILD-MAX-HEAP
中第的循环控制变量 $ i $ 来说,为什么我们要求它是从 $ \lfloor A.\text{length}/2 \rfloor $ 到 1 递减,而不是从 1 到 $ \lfloor A.\text{length}/2 \rfloor $ 递增呢?
解答:
核心原因:
MAX-HEAPIFY
的正确性依赖于一个前提:在调用 MAX-HEAPIFY(A, i)
时,节点 $ i $ 的左右子树都已经是最大堆。
为了满足这一前提,必须采用自底向上的处理顺序。
自底向上(递减顺序)—— 正确方式
- 循环从 $ i = \lfloor n/2 \rfloor $ 递减到 1(1-based 索引)
- 处理节点 $ i $ 时,其所有后代节点(子树中的节点)索引均大于 $ i $
- 由于我们是从后往前处理,所有下标大于 $ i $ 的节点已经被处理过,其子树已满足最大堆性质
- 因此,调用
MAX-HEAPIFY(A, i)
的前提条件成立
结论:
必须从 $ \lfloor n/2 \rfloor $ 递减到 1,以确保在处理每个节点时,其子树已经满足堆性质。这是 BUILD-MAX-HEAP
算法正确性的关键。
Exercise 6.3-3
题目:
证明:对于任一包含 $ n $ 个元素的堆,至多有 $ \left\lceil \frac{n}{2^{h+1}} \right\rceil $ 个高度为 $ h $ 的节点。
解答:
设堆中高度为 $ h $ 的节点有 $ k $ 个。
- 每个高度为 $ h $ 的节点,其子树至少包含 $ 2^h $ 个叶节点
- 不同子树的叶节点互不重叠
- 堆的总叶节点数为 $ \left\lceil \frac{n}{2} \right\rceil $
因此:
k⋅2h≤⌈n2⌉≤n+12
k \cdot 2^h \leq \left\lceil \frac{n}{2} \right\rceil \leq \frac{n + 1}{2}
k⋅2h≤⌈2n⌉≤2n+1
解得:
k≤n+12h+1
k \leq \frac{n + 1}{2^{h+1}}
k≤2h+1n+1
由于 $ k $ 为整数,且 $ \left\lceil \frac{n}{2^{h+1}} \right\rceil $ 是不小于 $ \frac{n}{2^{h+1}} $ 的最小整数,可得:
k≤⌈n2h+1⌉
k \leq \left\lceil \frac{n}{2^{h+1}} \right\rceil
k≤⌈2h+1n⌉
结论:
高度为 $ h $ 的节点数至多为 $ \left\lceil \frac{n}{2^{h+1}} \right\rceil $。
6.4 堆排序
算法实现
def left(i):"""计算节点 i 的左子节点索引(0-based)"""return 2 * i + 1def right(i):"""计算节点 i 的右子节点索引(0-based)"""return 2 * i + 2def max_heapify(A, i, heap_size):"""维护最大堆性质参数:A: 待调整的数组i: 当前节点索引heap_size: 当前堆的有效大小"""l = left(i)r = right(i)# 找出 i、l、r 中最大值的索引if l < heap_size and A[l] > A[i]:largest = lelse:largest = iif r < heap_size and A[r] > A[largest]:largest = r# 如果最大值不在当前节点,则交换并继续下沉if largest != i:A[i], A[largest] = A[largest], A[i]max_heapify(A, largest, heap_size)def build_max_heap(A):"""将数组 A 构建为最大堆(自底向上)"""if len(A) <= 1:returnheap_size = len(A)# 从最后一个非叶节点开始向前处理start_index = len(A) // 2 - 1for i in range(start_index, -1, -1):max_heapify(A, i, heap_size)def heapsort(A):"""堆排序主函数(原地排序)步骤:1. 构建最大堆2. 重复将根节点(最大值)与末尾元素交换,并缩小堆规模3. 对新根调用 max_heapify 恢复堆性质"""if len(A) <= 1:return# 构建初始最大堆build_max_heap(A)heap_size = len(A)# 从最后一个元素开始,逐步将最大值放到正确位置for i in range(len(A) - 1, 0, -1):A[0], A[i] = A[i], A[0] # 交换根与当前末尾heap_size -= 1 # 缩小堆大小 因为放在末尾的是已经固定好了的max_heapify(A, 0, heap_size) # 恢复堆性质
Exercise 6.4-2
题目:
试分析在使用下列循环不变量时,HEAPSORT 的正确性:在算法的 for 循环每次迭代开始时,子数组 A[1…i]是一个包含了数组A[1…n]中第i小元素的最大堆,而子数组A[i+1…n]包含了数组A[1…n]中已排序的n-i个最大元素。
证明:
初始化:
当 $ i = n $ 时:
- A[1…n] 是 BUILD-MAX-HEAP 构建的最大堆,包含所有未排序元素
- A[n+1…n] 为空,已排序部分为空
- 循环不变量成立
保持:
假设第 $ i $ 次迭代前不变量成立。
- A[1] 是 A[1…i] 的最大值,即当前未排序部分的最大元素
- 交换 A[1] 与 A[i] 后,该最大值被移至 A[i]
- 将堆大小减一,A[1…i-1] 为当前堆,A[i…n] 为已排序部分
- 调用 MAX-HEAPIFY(A, 1) 恢复 A[1…i-1] 的堆性质
- 下次迭代(i 减 1)开始时,不变量仍成立
终止:
当 $ i = 1 $ 时:
- A[1…1] 包含最小元素
- A[2…n] 包含其余元素且已升序排列
- 整个数组 A[1…n] 按升序排列
结论:
HEAPSORT 正确。
Exercise 6.4-3
题目:
对于一个按升序排列的包含 $ n $ 个元素的有序数组 $ A $ 来说,HEAPSORT 的时间复杂度是多少?如果 $ A $ 是降序呢?
解答:
升序数组:
- BUILD-MAX-HEAP 需要 $ \Theta(n) $ 时间
- 虽然数组升序,但
BUILD-MAX-HEAP
从最后一个非叶节点向上调用MAX-HEAPIFY
- 尽管每个节点可能需要下沉,但总时间仍为线性(见练习 6.3-3 的分析)
- 虽然数组升序,但
- for 循环执行 $ n-1 $ 次,每次
MAX-HEAPIFY
耗时 $ \Theta(\log n) $- 每次交换后,根节点的值很小(如最小值),需下沉到底层
- 总时间复杂度:$ \Theta(n \log n) $
降序数组:
- BUILD-MAX-HEAP 需要 $ \Theta(n) $ 时间
- 数组降序时,已是最大堆,每个
MAX-HEAPIFY
调用立即返回 - 但仍需遍历 $ \lfloor n/2 \rfloor $ 个节点,耗时 $ \Theta(n) $
- 数组降序时,已是最大堆,每个
- for 循环执行 $ n-1 $ 次,每次
MAX-HEAPIFY
耗时 $ \Theta(\log n) $- 每次交换后,新根可能破坏堆性质,需调整
- 总时间复杂度:$ \Theta(n \log n) $
结论:
无论输入数组是升序还是降序,HEAPSORT 的时间复杂度均为 $ \Theta(n \log n) $。
堆排序在所有情况下都表现出稳定的最坏-case 性能。### Exercise 6.4-3
Exercise 6.4-4
题目:
对于一个按升序排列的包含 $ n $ 个元素的有序数组 $ A $ 来说,HEAPSORT 的时间复杂度是多少?如果 $ A $ 是降序呢?
解答:
升序数组:
- BUILD-MAX-HEAP 需要 $ \Theta(n) $ 时间
- 虽然数组升序,但
BUILD-MAX-HEAP
从最后一个非叶节点向上调用MAX-HEAPIFY
- 尽管每个节点可能需要下沉,但总时间仍为线性(见练习 6.3-3 的分析)
- 虽然数组升序,但
- for 循环执行 $ n-1 $ 次,每次
MAX-HEAPIFY
耗时 $ \Theta(\log n) $- 每次交换后,根节点的值很小(如最小值),需下沉到底层
- 总时间复杂度:$ \Theta(n \log n) $
降序数组:
- BUILD-MAX-HEAP 需要 $ \Theta(n) $ 时间
- 数组降序时,已是最大堆,每个
MAX-HEAPIFY
调用立即返回 - 但仍需遍历 $ \lfloor n/2 \rfloor $ 个节点,耗时 $ \Theta(n) $
- 数组降序时,已是最大堆,每个
- for 循环执行 $ n-1 $ 次,每次
MAX-HEAPIFY
耗时 $ \Theta(\log n) $- 每次交换后,新根可能破坏堆性质,需调整
- 总时间复杂度:$ \Theta(n \log n) $
结论:
无论输入数组是升序还是降序,HEAPSORT 的时间复杂度均为 $ \Theta(n \log n) $。
堆排序在所有情况下都表现出稳定的最坏-case 性能。
Exercise 6.4-4
题目:
证明:在最坏情况下,HEAPSORT 的时间复杂度是 $ \Omega(n \log n) $。
解答:
考虑输入数组为严格降序排列的情况。
-
建堆阶段:
数组已是最大堆,BUILD-MAX-HEAP
中每个MAX-HEAPIFY
调用立即返回,耗时 $ \Theta(n) $。 -
排序阶段:
对于 $ i = n, n-1, \ldots, 2 $:- 交换
A[1]
与A[i]
- 新根
A[1]
是原A[i]
,值较小 - 需通过
MAX-HEAPIFY
下沉至叶节点附近 - 当前堆大小为 $ i $,下沉深度为 $ \lfloor \lg i \rfloor $,耗时 $ \Omega(\lg i) $
[!IMPORTANT]
求和分析笔记:∑i=2nΩ(lgi)=Ω(nlgn)\sum_{i=2}^{n} \Omega(\lg i) = \Omega(n \lg n)∑i=2nΩ(lgi)=Ω(nlgn)
我们要分析堆排序中
MAX-HEAPIFY
的总调用时间,其形式为:
∑i=2nΩ(lgi) \sum_{i=2}^{n} \Omega(\lg i) i=2∑nΩ(lgi)
目标是证明这个和为 Ω(nlgn)\Omega(n \lg n)Ω(nlgn)。
- 符号说明
- lgi=log2i\lg i = \log_2 ilgi=log2i
- Ω(lgi)\Omega(\lg i)Ω(lgi) 表示某个函数 f(i)f(i)f(i) 满足 f(i)≥c⋅lgif(i) \geq c \cdot \lg if(i)≥c⋅lgi 对足够大的 iii 成立(c>0c > 0c>0 为常数)
- 因此 ∑i=2nΩ(lgi)\sum_{i=2}^{n} \Omega(\lg i)∑i=2nΩ(lgi) 可理解为 Ω(∑i=2nlgi)\Omega\left( \sum_{i=2}^{n} \lg i \right)Ω(∑i=2nlgi)
即:
∑i=2nΩ(lgi)=Ω(∑i=2nlgi) \sum_{i=2}^{n} \Omega(\lg i) = \Omega\left( \sum_{i=2}^{n} \lg i \right) i=2∑nΩ(lgi)=Ω(i=2∑nlgi)
- 计算 ∑i=2nlgi\sum_{i=2}^{n} \lg i∑i=2nlgi
我们有:
∑i=2nlgi=lg2+lg3+⋯+lgn=lg(2⋅3⋅…⋅n)=lg(n!)−lg1=lg(n!) \sum_{i=2}^{n} \lg i = \lg 2 + \lg 3 + \cdots + \lg n = \lg (2 \cdot 3 \cdot \ldots \cdot n) = \lg (n!) - \lg 1 = \lg (n!) i=2∑nlgi=lg2+lg3+⋯+lgn=lg(2⋅3⋅…⋅n)=lg(n!)−lg1=lg(n!)因为 lg1=0\lg 1 = 0lg1=0,所以:
∑i=2nlgi=lg(n!) \sum_{i=2}^{n} \lg i = \lg (n!) i=2∑nlgi=lg(n!)
- 使用斯特林近似(Stirling’s Approximation)
斯特林公式给出:
n!∼2πn(ne)n n! \sim \sqrt{2\pi n} \left( \frac{n}{e} \right)^n n!∼2πn(en)n取对数:
lg(n!)=lg(2πn(ne)n)=lg2πn+lg(nnen)=12lg(2πn)+nlgn−nlge \lg(n!) = \lg \left( \sqrt{2\pi n} \left( \frac{n}{e} \right)^n \right) = \lg \sqrt{2\pi n} + \lg \left( \frac{n^n}{e^n} \right) = \frac{1}{2} \lg (2\pi n) + n \lg n - n \lg e lg(n!)=lg(2πn(en)n)=lg2πn+lg(ennn)=21lg(2πn)+nlgn−nlge展开:
lg(n!)=nlgn−nlge+12lg(2πn) \lg(n!) = n \lg n - n \lg e + \frac{1}{2} \lg (2\pi n) lg(n!)=nlgn−nlge+21lg(2πn)其中:
- nlgnn \lg nnlgn 是主导项
- −nlge-n \lg e−nlge 是线性项(lge≈1.44\lg e \approx 1.44lge≈1.44)
- 12lg(2πn)\frac{1}{2} \lg (2\pi n)21lg(2πn) 是对数项
因此:
lg(n!)=nlgn−O(n) \lg(n!) = n \lg n - O(n) lg(n!)=nlgn−O(n)
- 渐近下界分析
由于:
lg(n!)=nlgn−O(n) \lg(n!) = n \lg n - O(n) lg(n!)=nlgn−O(n)
而 O(n)O(n)O(n) 比 nlgnn \lg nnlgn 增长得慢,所以存在常数 c>0c > 0c>0 和 n0n_0n0,使得对所有 n≥n0n \geq n_0n≥n0:
lg(n!)≥c⋅nlgn \lg(n!) \geq c \cdot n \lg n lg(n!)≥c⋅nlgn即:
lg(n!)=Ω(nlgn) \lg(n!) = \Omega(n \lg n) lg(n!)=Ω(nlgn)
- 最终结论
∑i=2nΩ(lgi)=Ω(∑i=2nlgi)=Ω(lg(n!))=Ω(nlgn) \sum_{i=2}^{n} \Omega(\lg i) = \Omega\left( \sum_{i=2}^{n} \lg i \right) = \Omega(\lg(n!)) = \Omega(n \lg n) i=2∑nΩ(lgi)=Ω(i=2∑nlgi)=Ω(lg(n!))=Ω(nlgn)
- 补充:无需斯特林也可证明
即使不用斯特林公式,也可以通过积分估计:
∑i=2nlgi≥∫1nlgx dx=∫1nlnxln2 dx=1ln2[xlnx−x]1n=1ln2(nlnn−n+1) \sum_{i=2}^{n} \lg i \geq \int_{1}^{n} \lg x \, dx = \int_{1}^{n} \frac{\ln x}{\ln 2} \, dx = \frac{1}{\ln 2} \left[ x \ln x - x \right]_1^n = \frac{1}{\ln 2} (n \ln n - n + 1) i=2∑nlgi≥∫1nlgxdx=∫1nln2lnxdx=ln21[xlnx−x]1n=ln21(nlnn−n+1)
转换为以 2 为底:
=nlgn−nln2+1ln2=nlgn−O(n) = n \lg n - \frac{n}{\ln 2} + \frac{1}{\ln 2} = n \lg n - O(n) =nlgn−ln2n+ln21=nlgn−O(n)同样得到:
∑i=2nlgi=Ω(nlgn) \sum_{i=2}^{n} \lg i = \Omega(n \lg n) i=2∑nlgi=Ω(nlgn)
总结
因此,堆排序在排序阶段的总时间至少为 Ω(nlgn)\Omega(n \lg n)Ω(nlgn),证明其最坏情况时间复杂度为 Ω(nlgn)\Omega(n \lg n)Ω(nlgn)。
总时间为:
∑i=2nΩ(lgi)=Ω(∑i=2nlgi)=Ω(nlgn) \sum_{i=2}^{n} \Omega(\lg i) = \Omega\left( \sum_{i=2}^{n} \lg i \right) = \Omega(n \lg n) i=2∑nΩ(lgi)=Ω(i=2∑nlgi)=Ω(nlgn)
(因为 $ \sum_{i=1}^{n} \lg i = \lg(n!) = \Theta(n \lg n) $) - 交换
-
结论:
存在输入使得 HEAPSORT 运行时间为 $ \Omega(n \lg n) $,故其最坏情况时间复杂度为 $ \Omega(n \lg n) $。
6.5 优先队列
下次一定写