左哥算法 - 堆及堆排序

192 阅读11分钟

1. 什么是堆?

堆(Heap)是一种特殊的完全二叉树,分为两种:

  • 大根堆:每个节点都大于或等于其子节点
  • 小根堆:每个节点都小于或等于其子节点

2. 堆的基本操作流程

  1. 建堆过程
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;
    }
}

就像这样:

  1. 新选手来了,先和自己的直接领导比赛
  2. 赢了就可以继续挑战更高级的领导
  3. 直到遇到比自己强的或到达最顶端

图解:

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;
    }
}

类比运动会:

  1. 冠军退役后,需要在剩下的选手中选出新冠军
  2. 主管需要和自己的两个下属比赛
  3. 输给谁就和谁交换位置
  4. 继续和新的下属比赛,直到找到合适的位置

4. 完整流程图

graph TD
    A[开始] --> B[检查数组长度]
    B --> C[建立大根堆]
    C --> D[第一名确定]
    D --> E[调整剩余选手]
    E --> F{还有选手未确定名次?}
    F -->|是| G[确定下一名次]
    G --> E
    F -->|否| H[结束]

5. 实际应用例子

想象你是一个学校的老师,需要给100个学生按考试成绩排名:

  1. 建堆阶段

    • 学生一个个来报成绩
    • 每来一个学生,就和已有的排名比较
    • 最终形成一个"金字塔",最高分在顶端
  2. 排序阶段

    • 确定第一名(最顶端的学生)
    • 将这个学生移到最终位置
    • 在剩下的学生中继续找第二名
    • 重复直到所有学生都有了排名

6. 时间复杂度分析

  • 建堆:O(N)
  • 排序:O(NlogN)
  • 总体:O(NlogN)

7. 使用建议

  1. 适用场景:

    • 需要动态维护最值的场景
    • 需要进行原地排序的场景
    • TopK问题
  2. 注意事项:

    • 建堆过程很重要,影响后续排序效率
    • 交换操作要准确,否则会破坏堆的性质
    • 堆的大小要正确维护

4. 堆排序的核心步骤

  1. 建堆

    • 从最后一个非叶子节点开始,依次向上进行调整
    • 时间复杂度:O(N)
  2. 排序

    • 将堆顶元素与末尾元素交换
    • 将剩余元素重新调整为大根堆
    • 重复以上步骤
    • 时间复杂度:O(NlogN)

5. 堆的应用场景

  1. 优先级队列

    • 操作系统的作业调度
    • 网络路由器的数据包调度
  2. TopK问题

    • 从海量数据中找出前K个最大/最小的数
    • 使用小根堆维护K个最大的数
  3. 中位数问题

    • 使用一个大根堆和一个小根堆维护数据流的中位数

6. 堆的特点

  1. 优势
  • 建堆时间复杂度:O(N)
  • 插入和删除的时间复杂度:O(logN)
  • 获取最大/最小值的时间复杂度:O(1)
  1. 劣势
  • 不支持随机访问
  • 不适合查找特定元素

7. 实际应用建议

  1. 当需要动态维护一个序列的最大值/最小值时,优先考虑堆
  2. 需要排序时,如果空间复杂度要求高,可以考虑堆排序(原地排序)
  3. 处理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]

让我们验证几个例子:

  1. 索引4的节点

    • 父节点索引:(4-1)/2 = 1 ✓
    父节点(1)
       ↑
    当前节点(4)
    
  2. 索引5的节点

    • 父节点索引:(5-1)/2 = 2 ✓
    父节点(2)
       ↑
    当前节点(5)
    

3. 为什么是 (index - 1) / 2?

让我们推导一下:

  1. 对于左子节点 (2i + 1):

    • 已知子节点索引 index = 2i + 1
    • 求父节点索引 i
    • 2i + 1 = index
    • 2i = index - 1
    • i = (index - 1) / 2
  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. 记忆技巧

想象一个家族树:

  1. 每个父亲最多有两个孩子
  2. 要找爸爸,就用 (index - 1) / 2
  3. 要找孩子,就用:
    • 左孩子:2 * index + 1
    • 右孩子:2 * index + 2

这样的设计让我们可以:

  1. 在数组中高效地存储树形结构
  2. 快速找到任意节点的父节点和子节点
  3. 不需要使用额外的指针或引用

这个公式是堆操作的基础,理解它对于理解整个堆的操作都很重要!

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

在堆中:

  1. 对于索引 index
  2. 其左子节点索引为 left = index * 2 + 1
  3. 其右子节点索引为 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 的比较是为了:

  1. 确保左子节点在数组范围内
  2. 防止数组越界
  3. 判断是否还需要继续向下调整堆

如果 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(所有元素都参与)

调整过程:

  1. 90与其子节点80和85比较
  2. 85是子节点中最大的,但小于90
  3. 无需调整,保持原位

如果换成:

初始状态:[60, 80, 85, 70, 75, 90, 65]
index = 0(从60开始调整)
heapSize = 7

调整过程:

  1. 60与子节点80和85比较
  2. 85是子节点中最大的,且大于60
  3. 交换60和85
  4. 继续向下比较...

7. 使用场景

  1. 堆排序过程中

    • 每次取出堆顶元素后
    • 需要重新调整剩余元素
  2. 优先队列

    • 删除最大/最小元素后
    • 需要重新调整堆结构
  3. 动态维护数据

    • 当某个节点的值改变时
    • 需要向下调整保持堆的性质

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轮:

  1. 交换9和6:[6, 5, 8, 4, |9] (|后面表示已排序部分)
     6
    / \
   5   8
  /
 4   |9|
  1. heapify后:[8, 5, 6, 4, |9]
     8
    / \
   5   6
  /
 4   |9|

第2轮:

  1. 交换8和4:[4, 5, 6, |8, 9]
     4
    / \
   5   6
  |8, 9|
  1. heapify后:[6, 5, 4, |8, 9]
     6
    / \
   5   4
  |8, 9|

第3轮:

  1. 交换6和4:[4, 5, |6, 8, 9]
     4
    / \
   5   |6, 8, 9|
  1. heapify后:[5, 4, |6, 8, 9]
     5
    /
   4   |6, 8, 9|

第4轮:

  1. 交换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]