各位老铁,阿扩又来啦!
前面我们聊了各种数据结构和算法,从基础的排序查找,到复杂的图算法、动态规划,再到巧妙的Trie树和布隆过滤器。今天,我们要再次回到排序算法的舞台,但这次的主角,可不是简单的“冒泡”或“选择”,而是一位在乱序中能高效组织、精准定位的“指挥家”——堆排序 (Heap Sort)!
你可能听说过快速排序、归并排序,它们都是 O(N log N) 级别的排序算法。堆排序也同样拥有这个优秀的性能,而且它还是一种原地排序算法(不需要额外的大量空间),这在内存受限的场景下显得尤为珍贵。
那么,这位“指挥家”是如何在混乱的数据中,一步步建立秩序,最终奏响有序的乐章呢?来,跟着阿扩,一起揭开堆排序的奥秘!
堆排序是一种基于堆 (Heap) 这种特殊数据结构的排序算法。在深入堆排序之前,我们得先搞清楚什么是“堆”。
堆 (Heap) 是一种特殊的完全二叉树 (Complete Binary Tree)。它满足以下两个条件之一:
通常,堆会用数组来表示。对于一个数组 arr
:
i
的左子节点是 2*i + 1
。i
的右子节点是 2*i + 2
。j
的父节点是 (j - 1) / 2
(向下取整)。堆排序的整个过程可以分为两大步:
N-1
次:
N-1
个元素重新调整堆,使其再次满足堆的性质(即,将新的堆顶元素下沉到正确的位置)。通过不断地将最大元素“提取”出来并放到数组的末尾,最终整个数组就会变得有序。
假设我们有一个数组 arr = [4, 10, 3, 5, 1, 2]
,我们要进行升序排序(使用大顶堆)。
第一步:建堆
从最后一个非叶子节点开始(索引 (N/2)-1 = (6/2)-1 = 2
,即元素 3
)。
[4, 10, 3, 5, 1, 2]
-> [4, 10, 3, 5, 1, 2]
(3比子节点5小,交换) -> [4, 10, 5, 3, 1, 2]
[4, 10, 5, 3, 1, 2]
-> [4, 10, 5, 3, 1, 2]
(10比子节点3,1大,不变)[4, 10, 5, 3, 1, 2]
-> [10, 4, 5, 3, 1, 2]
(4比子节点10小,交换) -> [10, 5, 4, 3, 1, 2]
(4比子节点3,1大,不变)最终建堆完成:[10, 5, 4, 3, 1, 2]
第二步:排序
现在数组是 [10, 5, 4, 3, 1, 2]
。
10
和末尾 2
:[2, 5, 4, 3, 1, 10]
10
视为已排序部分。[2, 5, 4, 3, 1]
重新调整堆:[5, 3, 4, 2, 1]
5
和末尾 1
:[1, 3, 4, 2, 5, 10]
5
视为已排序部分。[1, 3, 4, 2]
重新调整堆:[4, 3, 1, 2]
4
和末尾 2
:[2, 3, 1, 4, 5, 10]
4
视为已排序部分。[2, 3, 1]
重新调整堆:[3, 2, 1]
3
和末尾 1
:[1, 2, 3, 4, 5, 10]
3
视为已排序部分。[1, 2]
重新调整堆:[2, 1]
2
和末尾 1
:[1, 2, 3, 4, 5, 10]
2
视为已排序部分。[1]
重新调整堆:[1]
(只剩一个元素,自然有序)最终,数组变为 [1, 2, 3, 4, 5, 10]
,排序完成!
堆排序的核心是 heapify
函数,它负责将一个子树调整为堆。
Java 版本:
import java.util.Arrays;
public class HeapSort {
/**
* 堆排序主函数
* @param arr 待排序数组
*/
public static void heapSort(int[] arr) {
int n = arr.length;
// 1. 建堆 (Build heap)
// 从最后一个非叶子节点开始向上调整,确保每个子树都满足大顶堆性质
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 2. 排序 (Extract elements from heap)
// 每次将堆顶元素(最大值)与当前堆的最后一个元素交换,然后重新调整堆
for (int i = n - 1; i > 0; i--) {
// 将当前堆顶元素(最大值)与堆的最后一个元素交换
swap(arr, 0, i);
// 对剩余的 i 个元素重新调整堆(因为最大值已经放到正确位置)
heapify(arr, i, 0);
}
}
/**
* 调整函数:将以 rootIndex 为根的子树调整为大顶堆
* @param arr 数组
* @param heapSize 当前堆的大小(有效元素的数量)
* @param rootIndex 当前子树的根节点索引
*/
private static void heapify(int[] arr, int heapSize, int rootIndex) {
int largest = rootIndex; // 假设根节点是最大的
int leftChild = 2 * rootIndex + 1; // 左子节点索引
int rightChild = 2 * rootIndex + 2; // 右子节点索引
// 如果左子节点在堆范围内且比当前最大值大
if (leftChild < heapSize && arr[leftChild] > arr[largest]) {
largest = leftChild;
}
// 如果右子节点在堆范围内且比当前最大值大
if (rightChild < heapSize && arr[rightChild] > arr[largest]) {
largest = rightChild;
}
// 如果最大值不是根节点,则交换并递归调整受影响的子树
if (largest != rootIndex) {
swap(arr, rootIndex, largest);
heapify(arr, heapSize, largest); // 递归调整被交换的子树
}
}
// 交换数组中两个元素的值
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[j] = arr[i];
arr[i] = temp;
}
public static void main(String[] args) {
int[] arr1 = {4, 10, 3, 5, 1, 2};
System.out.println("Original array: " + Arrays.toString(arr1));
heapSort(arr1);
System.out.println("Sorted array: " + Arrays.toString(arr1)); // Expected: [1, 2, 3, 4, 5, 10]
int[] arr2 = {12, 11, 13, 5, 6, 7};
System.out.println("Original array: " + Arrays.toString(arr2));
heapSort(arr2);
System.out.println("Sorted array: " + Arrays.toString(arr2)); // Expected: [5, 6, 7, 11, 12, 13]
int[] arr3 = {1};
System.out.println("Original array: " + Arrays.toString(arr3));
heapSort(arr3);
System.out.println("Sorted array: " + Arrays.toString(arr3)); // Expected: [1]
int[] arr4 = {};
System.out.println("Original array: " + Arrays.toString(arr4));
heapSort(arr4);
System.out.println("Sorted array: " + Arrays.toString(arr4)); // Expected: []
}
}
Python 版本:
from typing import List
def heap_sort(arr: List[int]):
n = len(arr)
# 1. 建堆 (Build heap)
# 从最后一个非叶子节点开始向上调整,确保每个子树都满足大顶堆性质
# 最后一个非叶子节点的索引是 n // 2 - 1
for i in range(n // 2 - 1, -1, -1):
_heapify(arr, n, i)
# 2. 排序 (Extract elements from heap)
# 每次将堆顶元素(最大值)与当前堆的最后一个元素交换,然后重新调整堆
for i in range(n - 1, 0, -1):
# 将当前堆顶元素(最大值)与堆的最后一个元素交换
arr[i], arr[0] = arr[0], arr[i]
# 对剩余的 i 个元素重新调整堆(因为最大值已经放到正确位置)
_heapify(arr, i, 0)
def _heapify(arr: List[int], heap_size: int, root_index: int):
"""
调整函数:将以 root_index 为根的子树调整为大顶堆
:param arr: 数组
:param heap_size: 当前堆的大小(有效元素的数量)
:param root_index: 当前子树的根节点索引
"""
largest = root_index # 假设根节点是最大的
left_child = 2 * root_index + 1 # 左子节点索引
right_child = 2 * root_index + 2 # 右子节点索引
# 如果左子节点在堆范围内且比当前最大值大
if left_child < heap_size and arr[left_child] > arr[largest]:
largest = left_child
# 如果右子节点在堆范围内且比当前最大值大
if right_child < heap_size and arr[right_child] > arr[largest]:
largest = right_child
# 如果最大值不是根节点,则交换并递归调整受影响的子树
if largest != root_index:
arr[root_index], arr[largest] = arr[largest], arr[root_index] # 交换
_heapify(arr, heap_size, largest) # 递归调整被交换的子树
if __name__ == "__main__":
arr1 = [4, 10, 3, 5, 1, 2]
print(f"Original array: {arr1}")
heap_sort(arr1)
print(f"Sorted array: {arr1}") # Expected: [1, 2, 3, 4, 5, 10]
arr2 = [12, 11, 13, 5, 6, 7]
print(f"Original array: {arr2}")
heap_sort(arr2)
print(f"Sorted array: {arr2}") # Expected: [5, 6, 7, 11, 12, 13]
arr3 = [1]
print(f"Original array: {arr3}")
heap_sort(arr3)
print(f"Sorted array: {arr3}") # Expected: [1]
arr4 = []
print(f"Original array: {arr4}")
heap_sort(arr4)
print(f"Sorted array: {arr4}") # Expected: []
时间复杂度:O(N log N)
heapify
是 O(log N),但实际上,由于大部分节点都在底层,它们的 heapify
操作深度很小,所以总和是 O(N)。heapify
操作。heapify
的时间复杂度是 O(log N)。所以总和是 O(N log N)。空间复杂度:O(1)
稳定性:不稳定
[10, 10', 5]
,如果 10
和 5
交换,10'
的相对位置就变了。堆排序,这位乱序中的“指挥家”,凭借其独特的“堆”结构,实现了高效排序:
堆排序虽然不如快速排序那样“快”(平均情况),但它稳定的 O(N log N) 性能和 O(1) 的空间复杂度,使其在某些特定场景下成为非常优秀的选择。
老铁们,今天的“乱序指挥家”——堆排序,是不是让你对排序算法有了更全面的认识?如果觉得阿扩讲得还行,点赞、收藏、转发就是对我最大的鼓励!
算法专栏的硬核内容到这里就告一段落了。在最后一章,我们将进行一场面试通关“最终形态”:算法思维大串讲与实战模拟。阿扩会带大家回顾整个专栏的知识体系,并分享如何在面试中灵活运用这些算法思维,祝你斩获心仪的Offer!我们下期不见不散!