1. 什么是堆?
堆(Heap)是一种特殊的完全二叉树,分为两种:
- 大根堆:每个节点都大于或等于其子节点
- 小根堆:每个节点都小于或等于其子节点
2. 堆的基本操作流程
- 建堆过程
graph TD
A[从最后一个非叶子节点开始]
B[比较当前节点与其子节点]
C[需要交换吗?]
D[与较大/较小的子节点交换]
E[继续向下调整]
F[完成]
A --> B
B --> C
C -->|是| D
D --> E
E --> B
C -->|否| F
3. 代码实现
public class HeapSort {
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 建立大根堆
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
int heapSize = arr.length;
swap(arr, 0, --heapSize);
// 调整堆
while (heapSize > 0) {
heapify(arr, 0, heapSize);
swap(arr, 0, --heapSize);
}
}
// 向上调整
private static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// 向下调整
private static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1;
while (left < heapSize) {
int largest = left + 1 < heapSize && arr[left + 1] > arr[left]
? left + 1
: left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
我来用更生动的方式讲解堆排序代码。想象我们在组织一场比赛的排名。
1. 核心代码解析
public class HeapSort {
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) return;
// 第一阶段:建立大根堆(就像选出各组冠军)
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
// 第二阶段:排序(确定最终排名)
int heapSize = arr.length;
swap(arr, 0, --heapSize); // 第一名确定
while (heapSize > 0) {
heapify(arr, 0, heapSize); // 重新比赛
swap(arr, 0, --heapSize); // 确定下一名
}
}
2. 图解过程
graph TD
A[开始] --> B[建立大根堆]
B --> C[交换堆顶和末尾元素]
C --> D[向下调整堆]
D --> E{堆大小>0?}
E -->|是| C
E -->|否| F[结束]
3. 生动示例:运动会排名
想象我们在组织一场运动会,需要给100名运动员排名。
第一阶段:建立大根堆(heapInsert)
private static void heapInsert(int[] arr, int index) {
// 类比:新选手不断挑战自己的上级
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
就像这样:
- 新选手来了,先和自己的直接领导比赛
- 赢了就可以继续挑战更高级的领导
- 直到遇到比自己强的或到达最顶端
图解:
graph TD
A[新选手加入] --> B[与上级比赛]
B --> C{赢了吗?}
C -->|是| D[交换位置]
D --> B
C -->|否| E[停止]
第二阶段:向下调整(heapify)
这块代码的意思是 看下当前index节点的左右节点哪个大 选出个大的互换下
private static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1;
while (left < heapSize) {
// 找出两个孩子中较大的
int largest = left + 1 < heapSize && arr[left + 1] > arr[left]
? left + 1
: left;
// 和父节点比较
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) break;
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
类比运动会:
- 冠军退役后,需要在剩下的选手中选出新冠军
- 主管需要和自己的两个下属比赛
- 输给谁就和谁交换位置
- 继续和新的下属比赛,直到找到合适的位置
4. 完整流程图
graph TD
A[开始] --> B[检查数组长度]
B --> C[建立大根堆]
C --> D[第一名确定]
D --> E[调整剩余选手]
E --> F{还有选手未确定名次?}
F -->|是| G[确定下一名次]
G --> E
F -->|否| H[结束]
5. 实际应用例子
想象你是一个学校的老师,需要给100个学生按考试成绩排名:
-
建堆阶段:
- 学生一个个来报成绩
- 每来一个学生,就和已有的排名比较
- 最终形成一个"金字塔",最高分在顶端
-
排序阶段:
- 确定第一名(最顶端的学生)
- 将这个学生移到最终位置
- 在剩下的学生中继续找第二名
- 重复直到所有学生都有了排名
6. 时间复杂度分析
- 建堆:O(N)
- 排序:O(NlogN)
- 总体:O(NlogN)
7. 使用建议
-
适用场景:
- 需要动态维护最值的场景
- 需要进行原地排序的场景
- TopK问题
-
注意事项:
- 建堆过程很重要,影响后续排序效率
- 交换操作要准确,否则会破坏堆的性质
- 堆的大小要正确维护
4. 堆排序的核心步骤
-
建堆
- 从最后一个非叶子节点开始,依次向上进行调整
- 时间复杂度:O(N)
-
排序
- 将堆顶元素与末尾元素交换
- 将剩余元素重新调整为大根堆
- 重复以上步骤
- 时间复杂度:O(NlogN)
5. 堆的应用场景
-
优先级队列
- 操作系统的作业调度
- 网络路由器的数据包调度
-
TopK问题
- 从海量数据中找出前K个最大/最小的数
- 使用小根堆维护K个最大的数
-
中位数问题
- 使用一个大根堆和一个小根堆维护数据流的中位数
6. 堆的特点
- 优势:
- 建堆时间复杂度:O(N)
- 插入和删除的时间复杂度:O(logN)
- 获取最大/最小值的时间复杂度:O(1)
- 劣势:
- 不支持随机访问
- 不适合查找特定元素
7. 实际应用建议
- 当需要动态维护一个序列的最大值/最小值时,优先考虑堆
- 需要排序时,如果空间复杂度要求高,可以考虑堆排序(原地排序)
- 处理TopK问题时,堆是最佳选择之一
8. 详细解释 (index - 1) / 2 这个公式
让我详细解释 (index - 1) / 2 这个公式。
1. 完全二叉树的特性
在堆(完全二叉树)中,如果我们用数组存储节点:
- 父节点和子节点之间存在固定的关系
- 假设一个节点的索引是
i- 它的左子节点索引:
2i + 1 - 它的右子节点索引:
2i + 2 - 它的父节点索引:
(i - 1) / 2
- 它的左子节点索引:
2. 图解示例
graph TD
A[0] --> B[1]
A --> C[2]
B --> D[3]
B --> E[4]
C --> F[5]
C --> G[6]
用数组表示:[0, 1, 2, 3, 4, 5, 6]
让我们验证几个例子:
-
索引4的节点
- 父节点索引:(4-1)/2 = 1 ✓
父节点(1) ↑ 当前节点(4) -
索引5的节点
- 父节点索引:(5-1)/2 = 2 ✓
父节点(2) ↑ 当前节点(5)
3. 为什么是 (index - 1) / 2?
让我们推导一下:
-
对于左子节点 (2i + 1):
- 已知子节点索引 index = 2i + 1
- 求父节点索引 i
- 2i + 1 = index
- 2i = index - 1
- i = (index - 1) / 2
-
对于右子节点 (2i + 2):
- 已知子节点索引 index = 2i + 2
- 求父节点索引 i
- 2i + 2 = index
- 2i = index - 2
- i = (index - 2) / 2
关键点:在Java中,整数除法会自动向下取整,所以无论是左子节点还是右子节点,使用 (index - 1) / 2 都能正确找到父节点!
4. 实际例子
假设有一个数组:[50, 45, 40, 20, 25, 35, 30]
graph TD
A[50] --> B[45]
A --> C[40]
B --> D[20]
B --> E[25]
C --> F[35]
C --> G[30]
验证一下:
- 节点25(索引4)的父节点:(4-1)/2 = 1,对应值45 ✓
- 节点35(索引5)的父节点:(5-1)/2 = 2,对应值40 ✓
- 节点30(索引6)的父节点:(6-1)/2 = 2,对应值40 ✓
5. 代码示例
public void printParent(int[] arr, int childIndex) {
if (childIndex <= 0) {
System.out.println("这是根节点,没有父节点");
return;
}
int parentIndex = (childIndex - 1) / 2;
System.out.printf("子节点值: %d, 父节点值: %d\n",
arr[childIndex], arr[parentIndex]);
}
6. 记忆技巧
想象一个家族树:
- 每个父亲最多有两个孩子
- 要找爸爸,就用
(index - 1) / 2 - 要找孩子,就用:
- 左孩子:
2 * index + 1 - 右孩子:
2 * index + 2
- 左孩子:
这样的设计让我们可以:
- 在数组中高效地存储树形结构
- 快速找到任意节点的父节点和子节点
- 不需要使用额外的指针或引用
这个公式是堆操作的基础,理解它对于理解整个堆的操作都很重要!
9. heapify方法详解
这块代码的意思是 看下当前index节点的左右节点哪个大 选出个大的互换下
让我详细解释 heapify 方法的参数和作用。
1. 参数解释
private static void heapify(int[] arr, int index, int heapSize) {
// arr: 待处理的数组
// index: 当前需要向下调整的节点位置
// heapSize: 堆的有效大小(不一定等于数组长度)
2. 参数作用图解
graph TD
A[参数作用]
B[arr - 数组]
C[index - 起始位置]
D[heapSize - 堆大小]
A --> B
A --> C
A --> D
B --> B1[存储所有元素]
C --> C1[需要向下调整的节点]
D --> D1[有效的堆范围]
3. 生动示例
想象一个公司的层级结构:
graph TD
A[总经理index=0] --> B[部门经理1]
A --> C[部门经理2]
B --> D[组长1]
B --> E[组长2]
C --> F[组长3]
C --> G[组长4]
arr(数组)
- 就像公司的员工花名册
- 例如:
[90, 80, 85, 60, 70, 75, 65] - 数字代表每个人的能力值
index(索引)
- 指定从哪个位置开始调整
- 比如总经理离职了(index=0),需要从最高层开始重新调整
heapSize(堆大小)
- 表示当前参与比较的有效范围
- 就像公司正在进行的晋升比赛,有些人已经确定了最终职位(不参与后续比较)
4. 实际运行示例
假设原始数组:[90, 80, 85, 60, 70, 75, 65]
// 示例代码
private static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1; // 左子节点
while (left < heapSize) { // 当左子节点在有效范围内
// 1. 找到左右子节点中较大的
int largest = left + 1 < heapSize && arr[left + 1] > arr[left]
? left + 1
: left;
// 2. 与父节点比较
largest = arr[largest] > arr[index] ? largest : index;
// 3. 如果父节点最大,就停止
if (largest == index) break;
// 4. 否则交换位置
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
让我用图解方式来解释为什么要比较 left < heapSize:
// 假设有一个数组转换成的堆
// 4
// / \
// 10 3
// / \
// 5 1
// 数组表示:[4, 10, 3, 5, 1]
// 索引对应:[0, 1, 2, 3, 4]
// heapSize = 5
在堆中:
- 对于索引
index - 其左子节点索引为
left = index * 2 + 1 - 其右子节点索引为
right = left + 1(即index * 2 + 2)
比如:
// 当 index = 1 (值为10)时
left = 1 * 2 + 1 = 3 // 左子节点索引
// 检查 left < heapSize (3 < 5) 为true,说明左子节点存在
// 当 index = 2 (值为3)时
left = 2 * 2 + 1 = 5 // 左子节点索引
// 检查 left < heapSize (5 < 5) 为false,说明左子节点不存在
graph TD
A[index=1<br>值=10] --> B[left=3<br>值=5]
A --> C[right=4<br>值=1]
D[检查 left < heapSize<br>3 < 5 为true<br>说明左子节点存在]
所以 left < heapSize 的比较是为了:
- 确保左子节点在数组范围内
- 防止数组越界
- 判断是否还需要继续向下调整堆
如果 left >= heapSize,说明当前节点已经是叶子节点,不需要继续调整。
5. 执行流程图
graph TD
A[开始heapify] --> B[计算左子节点位置]
B --> C{左子节点在堆范围内?}
C -->|是| D[找出左右子节点中较大的]
D --> E{子节点比父节点大?}
E -->|是| F[交换位置]
F --> G[更新索引]
G --> B
E -->|否| H[结束]
C -->|否| H
6. 具体例子
假设我们要对以下数据进行调整:
初始状态:[90, 80, 85, 60, 70, 75, 65]
index = 0(从90开始调整)
heapSize = 7(所有元素都参与)
调整过程:
- 90与其子节点80和85比较
- 85是子节点中最大的,但小于90
- 无需调整,保持原位
如果换成:
初始状态:[60, 80, 85, 70, 75, 90, 65]
index = 0(从60开始调整)
heapSize = 7
调整过程:
- 60与子节点80和85比较
- 85是子节点中最大的,且大于60
- 交换60和85
- 继续向下比较...
7. 使用场景
-
堆排序过程中:
- 每次取出堆顶元素后
- 需要重新调整剩余元素
-
优先队列:
- 删除最大/最小元素后
- 需要重新调整堆结构
-
动态维护数据:
- 当某个节点的值改变时
- 需要向下调整保持堆的性质
10. 再来个形象的过程
好的,让我用一个具体的例子,详细展示堆排序的完整过程。
假设我们要对数组 [4, 6, 8, 5, 9] 进行堆排序。
1. 建立大根堆过程 (heapInsert)
graph TD
A1[第1步: 4] --> A2[插入6]
A2 --> A3[插入8]
A3 --> A4[插入5]
A4 --> A5[插入9]
逐步插入过程:
步骤1: [4]
4
步骤2: [4, 6]
6
/
4
6比4大,交换位置:[6, 4]
步骤3: [6, 4, 8]
8
/ \
4 6
8比6大,交换位置
步骤4: [8, 4, 6, 5]
8
/ \
4 6
/
5
5比4大,交换位置:[8, 5, 6, 4]
步骤5: [8, 5, 6, 4, 9]
8
/ \
5 6
/ \
4 9
9比4大,交换;9比5大,继续交换;9比8大,继续交换 最终变成:[9, 5, 8, 4, 6]
9
/ \
5 8
/ \
4 6
2. 详细的排序过程
第1轮:
- 交换9和6:
[6, 5, 8, 4, |9](|后面表示已排序部分)
6
/ \
5 8
/
4 |9|
- heapify后:
[8, 5, 6, 4, |9]
8
/ \
5 6
/
4 |9|
第2轮:
- 交换8和4:
[4, 5, 6, |8, 9]
4
/ \
5 6
|8, 9|
- heapify后:
[6, 5, 4, |8, 9]
6
/ \
5 4
|8, 9|
第3轮:
- 交换6和4:
[4, 5, |6, 8, 9]
4
/ \
5 |6, 8, 9|
- heapify后:
[5, 4, |6, 8, 9]
5
/
4 |6, 8, 9|
第4轮:
- 交换5和4:
[4, |5, 6, 8, 9]
最终结果:[4, 5, 6, 8, 9]
完整代码实现
public class HeapSortExample {
public static void main(String[] args) {
int[] arr = {4, 6, 8, 5, 9};
heapSort(arr);
}
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) return;
System.out.println("原始数组: " + Arrays.toString(arr));
// 1. 建立大根堆
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);
}
System.out.println("建堆完成: " + Arrays.toString(arr));
// 2. 排序过程
这块代码的意思是 看下当前index节点的左右节点哪个大 选出个大的互换下
int heapSize = arr.length;
while (heapSize > 0) {
// 交换堆顶和最后一个元素
swap(arr, 0, --heapSize);
System.out.println("交换后: " + Arrays.toString(arr) +
" (|后为已排序区域: " +
Arrays.toString(Arrays.copyOfRange(arr, heapSize, arr.length)) + ")");
// 重新调整堆
heapify(arr, 0, heapSize);
System.out.println("调整后: " + Arrays.toString(arr) +
" (|后为已排序区域: " +
Arrays.toString(Arrays.copyOfRange(arr, heapSize, arr.length)) + ")");
}
System.out.println("最终结果: " + Arrays.toString(arr));
}
private static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
private static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1;
while (left < heapSize) {
int largest = left + 1 < heapSize && arr[left + 1] > arr[left]
? left + 1
: left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) break;
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
运行结果:
原始数组: [4, 6, 8, 5, 9
建堆完成: [9, 5, 8, 4, 6]
交换后: [6, 5, 8, 4, |9]
调整后: [8, 5, 6, 4, |9]
交换后: [4, 5, 6, |8, 9]
调整后: [6, 5, 4, |8, 9]
交换后: [4, 5, |6, 8, 9]
调整后: [5, 4, |6, 8, 9]
交换后: [4, |5, 6, 8, 9]
最终结果: [4, 5, 6, 8, 9]