Posted in

Go实现最小堆和最大堆(算法面试高频手撕代码题)

第一章:Go实现最小堆和最大堆(算法面试高频手撕代码题)

堆的基本概念

堆是一种特殊的完全二叉树结构,分为最小堆和最大堆。最小堆中父节点的值小于或等于子节点,根节点为最小值;最大堆则相反,根节点为最大值。堆常用于优先队列、Top K问题和堆排序等场景。

Go中最小堆的实现

使用Go语言实现最小堆时,通常借助切片模拟完全二叉树。节点索引从0开始,父节点i的左子节点为2*i + 1,右子节点为2*i + 2

type MinHeap []int

// 向堆中插入元素
func (h *MinHeap) Push(val int) {
    *h = append(*h, val)
    h.heapifyUp(len(*h) - 1)
}

// 调整堆以维持最小堆性质(自底向上)
func (h *MinHeap) heapifyUp(i int) {
    for i > 0 {
        parent := (i - 1) / 2
        if (*h)[parent] <= (*h)[i] {
            break
        }
        (*h)[parent], (*h)[i] = (*h)[i], (*h)[parent]
        i = parent
    }
}

// 弹出堆顶元素
func (h *MinHeap) Pop() int {
    if len(*h) == 0 {
        panic("heap is empty")
    }
    root := (*h)[0]
    (*h)[0] = (*h)[len(*h)-1]
    *h = (*h)[:len(*h)-1]
    h.heapifyDown(0)
    return root
}

// 自顶向下调整
func (h *MinHeap) heapifyDown(i int) {
    for {
        minIndex := i
        left, right := 2*i+1, 2*i+2
        if left < len(*h) && (*h)[left] < (*h)[minIndex] {
            minIndex = left
        }
        if right < len(*h) && (*h)[right] < (*h)[minIndex] {
            minIndex = right
        }
        if minIndex == i {
            break
        }
        (*h)[i], (*h)[minIndex] = (*h)[minIndex], (*h)[i]
        i = minIndex
    }
}

最大堆与标准库对比

最大堆实现方式与最小堆类似,仅需将比较条件反转。Go标准库 container/heap 提供了接口,支持自定义堆类型,适用于更复杂的数据结构。

实现方式 优点 缺点
手动实现 灵活控制逻辑 需自行维护调整函数
标准库 container/heap 快速集成,通用性强 需要实现接口方法

第二章:堆的基本概念与Go语言实现基础

2.1 堆的定义与完全二叉树特性分析

堆是一种特殊的完全二叉树结构,分为最大堆和最小堆。在最大堆中,父节点的值始终不小于子节点;最小堆则相反。堆的这一性质使其非常适合用于优先队列和堆排序等场景。

完全二叉树的结构优势

完全二叉树要求除最后一层外,其他层都被完全填满,且最后一层节点靠左对齐。这种结构可高效地用数组表示:对于索引 i 的节点,其左子节点为 2i + 1,右子节点为 2i + 2,父节点为 (i-1)/2

# 堆中获取父子节点索引
def parent(i): return (i - 1) // 2
def left_child(i): return 2 * i + 1
def right_child(i): return 2 * i + 2

上述代码实现了基于数组的堆结构中节点关系的计算。整数除法确保索引为整数,适用于从0开始的数组存储。

存储效率与操作性能

特性 描述
存储方式 数组连续存储,空间紧凑
构建时间 O(n)
插入/删除 O(log n)

mermaid 图描述了堆的层级结构:

graph TD
    A[10] --> B[8]
    A --> C[7]
    B --> D[5]
    B --> E[6]
    C --> F[2]

该结构体现了最大堆的典型形态,满足完全二叉树与堆序性双重约束。

2.2 最小堆与最大堆的结构差异与应用场景

结构特性对比

最小堆和最大堆均为完全二叉树结构,核心差异在于节点值的排列规则。在最小堆中,父节点的值始终不大于子节点(parent ≤ child),根节点为全局最小值;而在最大堆中,父节点值不小于子节点(parent ≥ child),根节点为最大值。

典型应用场景

  • 最小堆:适用于优先队列中任务按紧急程度排序、Dijkstra最短路径算法中的距离选取;
  • 最大堆:常用于求解数据流中第K大元素、堆排序等场景。

存储与操作示意图

使用数组存储时,父子节点索引关系如下:

节点 父节点索引 左子节点索引 右子节点索引
i (i-1)//2 2*i+1 2*i+2
def heapify_down(arr, n, i):
    largest = i
    left = 2 * i + 1
    right = 2 * i + 2

    if left < n and arr[left] > arr[largest]:
        largest = left  # 更新最大值索引

    if right < n and arr[right] > arr[largest]:
        largest = right

    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]  # 交换
        heapify_down(arr, n, largest)  # 向下调整

该代码实现最大堆的下沉操作,通过比较父节点与子节点值,确保最大值位于根位置,时间复杂度为 O(log n)。

2.3 Go语言中堆的数据结构设计:切片与父子节点索引关系

在Go语言中,堆通常基于切片实现,利用数组的连续内存特性高效模拟完全二叉树结构。通过数学索引映射,可在无指针的情况下定位父子节点。

父子节点的索引计算规则

对于任意节点索引 i(从0开始):

  • 左子节点索引:2*i + 1
  • 右子节点索引:2*i + 2
  • 父节点索引:(i - 1) / 2

这种映射方式确保了树形结构与线性存储的一致性。

示例:最小堆的节点关系

heap := []int{1, 3, 6, 5, 9, 8}

// 节点3(索引1)的左右子节点
left := 2*1 + 1   // 索引3 → 值5
right := 2*1 + 2  // 索引4 → 值9

上述代码展示了如何通过索引公式快速访问子节点。切片长度动态可变,配合扩容机制,天然适配堆的插入与删除操作。

节点索引 对应值 左子索引 右子索引 父索引
0 1 1 2
1 3 3 4 0
2 6 5 0

该设计避免了复杂的指针操作,提升了缓存命中率和运行效率。

2.4 下沉操作(heapify down)原理与代码实现

下沉操作是维护堆结构的关键步骤,主要用于删除根节点或调整元素后恢复堆的性质。当某个节点的优先级降低时,需将其向下移动,直到满足堆序性。

基本逻辑

从当前节点开始,比较其与子节点的值。若存在更大的子节点且大于当前节点(最大堆),则与其交换。重复该过程直至节点处于正确位置。

def heapify_down(heap, index):
    size = len(heap)
    while index < size:
        largest = index
        left = 2 * index + 1
        right = 2 * index + 2

        if left < size and heap[left] > heap[largest]:
            largest = left
        if right < size and heap[right] > heap[largest]:
            largest = right

        if largest == index:
            break  # 堆序已满足
        heap[index], heap[largest] = heap[largest], heap[index]
        index = largest

参数说明heap 为堆数组,index 为起始下标。循环中通过左右子节点索引计算(2*i+1, 2*i+2)定位子节点,并更新最大值索引。交换后继续下沉,确保局部堆性质重建完成。

2.5 上浮操作(heapify up)在插入元素中的应用

在二叉堆中插入新元素时,上浮操作确保堆性质的恢复。新元素被添加到堆底末尾后,通过不断与其父节点比较并交换位置,直至满足堆序性。

上浮过程详解

  • 新元素插入数组末尾(完全二叉树的最底层最右)
  • 比较当前节点与父节点值
  • 若违反堆序(如大顶堆中子 > 父),则交换
  • 重复至根节点或无需交换
def heapify_up(heap, index):
    while index > 0:
        parent = (index - 1) // 2
        if heap[index] <= heap[parent]:  # 大顶堆条件
            break
        heap[index], heap[parent] = heap[parent], heap[index]
        index = parent

参数说明:heap为堆数组,index为当前插入位置。循环中通过(index-1)//2计算父节点索引,逐步上浮。

步骤 当前节点 父节点 是否交换
1 15 8
2 15 12
3 15 20

触发条件

上浮仅在插入后执行,时间复杂度为O(log n),最坏情况从叶到根路径遍历。

graph TD
    A[插入新元素] --> B{是否大于父节点?}
    B -->|否| C[结束]
    B -->|是| D[与父节点交换]
    D --> E[更新当前索引]
    E --> B

第三章:最小堆的手写实现与测试验证

3.1 MinHeap结构体定义与核心方法声明

结构体设计与字段解析

MinHeap 是基于切片实现的完全二叉树结构,其核心在于维护堆序性:任意父节点值不大于子节点。结构体包含 data []int 存储元素,size int 跟踪当前元素个数。

type MinHeap struct {
    data []int
    size int
}
  • data:动态数组存储堆元素,索引从0开始,父子关系通过公式计算(父节点i的左子为2*i+1);
  • size:记录有效元素数量,用于插入与删除时边界判断。

核心方法概览

主要对外暴露以下方法:

  • NewMinHeap() *MinHeap:构造函数初始化空堆;
  • Insert(val int):插入新值并上浮调整;
  • ExtractMin() int:弹出最小值并下沉恢复堆性质;
  • heapifyUp()heapifyDown():内部维护堆序的核心逻辑。

方法调用流程示意

graph TD
    A[Insert] --> B[添加至末尾]
    B --> C[触发heapifyUp]
    C --> D[比较父节点]
    D --> E[若更小则交换]
    E --> F[直至根或满足堆序]

3.2 插入(Insert)与删除(ExtractMin)方法编码详解

在优先队列的核心操作中,InsertExtractMin 是构建堆行为的基础。理解其底层逻辑对掌握堆结构至关重要。

插入操作的实现逻辑

插入元素需维持堆的结构性和有序性。新元素添加至数组末尾后,通过“上浮”(heapify-up)调整位置。

def insert(self, val):
    self.heap.append(val)
    self._heapify_up(len(self.heap) - 1)

def _heapify_up(self, idx):
    while idx > 0:
        parent = (idx - 1) // 2
        if self.heap[parent] <= self.heap[idx]:
            break
        self.heap[idx], self.heap[parent] = self.heap[parent], self.heap[idx]
        idx = parent

_heapify_up 持续比较当前节点与父节点,若更小则交换,直至根节点或满足堆序。

删除最小元素的流程

ExtractMin 移除堆顶后,将末尾元素移至根部,执行“下沉”(heapify-down)操作。

def extract_min(self):
    if not self.heap:
        return None
    min_val = self.heap[0]
    self.heap[0] = self.heap.pop()
    self._heapify_down(0)
    return min_val

pop() 移除并返回最后一个元素,_heapify_down 向下比较左右子节点,选择较小者交换,恢复堆性质。

操作复杂度对比

操作 时间复杂度 空间复杂度 关键路径
Insert O(log n) O(1) 上浮至根
ExtractMin O(log n) O(1) 下沉至叶子

调整过程可视化

graph TD
    A[插入5] --> B[添加至末尾]
    B --> C{比较父节点}
    C --> D[交换]
    D --> E[继续上浮]
    E --> F[满足堆序,结束]

3.3 单元测试编写:边界条件与功能正确性验证

单元测试的核心在于验证函数在正常和极端输入下的行为是否符合预期。为确保代码鲁棒性,必须覆盖典型功能路径与边界条件。

边界条件的识别与覆盖

常见边界包括空输入、极值、临界阈值等。例如,对整数求和函数,需测试最小值、最大值及溢出场景。

功能正确性验证示例

def divide(a, b):
    if b == 0:
        raise ValueError("除数不能为零")
    return a / b

# 测试用例
def test_divide():
    assert divide(10, 2) == 5      # 正常情况
    assert divide(-6, 3) == -2     # 负数处理
    try:
        divide(5, 0)
    except ValueError as e:
        assert str(e) == "除数不能为零"  # 异常路径

该代码展示了基本功能、负数支持及异常处理。assert 验证返回值,try-except 确保错误输入被正确拦截,体现全面验证逻辑。

测试用例设计策略

  • 输入类型组合(正、负、零)
  • 异常流程覆盖
  • 返回值与状态变更校验
输入a 输入b 预期结果
10 2 5.0
-4 2 -2.0
5 0 抛出ValueError

第四章:最大堆的实现优化与常见陷阱规避

4.1 MaxHeap与MinHeap的对比实现策略

堆(Heap)是一种特殊的完全二叉树结构,广泛应用于优先队列、堆排序等场景。MaxHeap 和 MinHeap 的核心区别在于根节点的值:前者保证父节点不小于子节点,后者则确保父节点不大于子节点。

实现策略差异

通过调整比较逻辑,可复用同一套堆化代码实现两种行为:

class Heap:
    def __init__(self, max_heap=True):
        self.heap = []
        self.max_heap = max_heap  # 控制堆类型

    def compare(self, parent, child):
        if self.max_heap:
            return parent < child  # 最大堆:子大于父时交换
        else:
            return parent > child  # 最小堆:子小于父时交换

上述 compare 方法通过布尔标志动态切换比较方向,避免重复实现上浮(heapify-up)与下沉(heapify-down)逻辑。插入元素后调用上浮,删除根后调用下沉,维护堆性质。

性能对比表

操作 MaxHeap 时间复杂度 MinHeap 时间复杂度 说明
插入 O(log n) O(log n) 需上浮调整
删除根 O(log n) O(log n) 需下沉填补
查找极值 O(1) O(1) 根节点即极值

该设计体现了“策略模式”在数据结构中的应用,提升代码复用性与可维护性。

4.2 堆化(BuildHeap)过程的时间复杂度优化技巧

堆化是构建二叉堆的关键步骤,传统思路可能误认为其时间复杂度为 $O(n \log n)$,但实际上通过自底向上的下沉法(Heapify)可优化至 $O(n)$。

下沉法的数学优势

相较于逐个插入元素(上浮法),下沉法从最后一个非叶子节点反向遍历,对每个节点调用 heapify

def build_heap(arr):
    n = len(arr)
    for i in range(n // 2 - 1, -1, -1):  # 从最后一个非叶节点开始
        heapify(arr, n, i)

in//2 - 1 开始,因为前 n//2 个节点中有一半是叶子。循环方向必须自底向上,确保子树已满足堆性质。

时间复杂度分析

虽然单次 heapify 最坏耗时 $O(\log n)$,但多数节点位于底层,其高度小,加权计算后总和收敛于 $O(n)$。

节点高度 节点数量 单节点成本 总贡献
h ≤ n/2^{h+1} O(h) O(n·h/2^h)

级数 $\sum_{h=0}^{\log n} h/2^h$ 收敛于常数,故整体为 $O(n)$。

优化策略

  • 批量初始化:避免逐个插入;
  • 内存局部性优化:连续数组访问提升缓存命中率;
  • 并行堆化:在多核环境下分段处理子树(需同步机制)。
graph TD
    A[输入无序数组] --> B[定位最后非叶节点]
    B --> C{索引 >= 0?}
    C -->|是| D[执行heapify下沉]
    D --> E[索引--]
    E --> C
    C -->|否| F[堆化完成]

4.3 常见手撕代码错误分析:索引越界与逻辑颠倒

索引越界的典型场景

在数组或字符串操作中,访问 array[length] 是常见错误。例如:

for (int i = 0; i <= array.length; i++) {
    System.out.println(array[i]); // 当 i == length 时越界
}

分析:数组合法索引为 [0, length-1],循环条件应为 i < array.length<= 导致访问越界。

逻辑颠倒的隐蔽问题

条件判断顺序不当可能导致空指针或短路逻辑失效:

if (str != null && str.length() > 0) { /* 安全 */ }
// 若颠倒为 (str.length() > 0 && str != null),则可能抛出 NullPointerException

常见错误对照表

错误类型 示例 正确写法
索引越界 arr[arr.length] arr[arr.length - 1]
逻辑颠倒 s.length()>0 && s!=null s!=null && s.length()>0

防御性编程建议

使用边界检查、提前返回和单元测试可显著降低此类错误发生率。

4.4 利用Go接口实现通用堆(Generic Heap)的扩展思路

在Go语言中,container/heap 提供了堆的基础操作,但默认不支持泛型。通过接口(interface)抽象数据行为,可构建通用堆结构。

借助接口抽象堆元素

定义 HeapItem 接口,要求实现比较方法:

type HeapItem interface {
    Less(than HeapItem) bool
}

所有入堆类型需实现 Less,从而解耦具体类型与堆逻辑。

构建通用堆容器

type GenericHeap struct {
    items []HeapItem
}

func (h *GenericHeap) Push(item HeapItem) {
    h.items = append(h.items, item)
    heap.Fix(&h.items, len(h.items)-1) // 维护堆序
}

Push 接收任意 HeapItem 实现,实现类型安全的多态插入。

类型 是否实现 Less 可入堆
intWrapper
stringItem
struct

扩展方向

通过组合 interface{} 与运行时校验,或结合Go 1.18+泛型语法,可进一步提升类型安全性与复用效率。

第五章:堆在算法面试中的典型应用与高频题解析

在算法面试中,堆(Heap)作为一种高效的优先队列实现结构,频繁出现在各类高频题目中。其核心优势在于能够在 O(log n) 时间内完成插入和删除最值操作,特别适合处理“动态求极值”类问题。掌握堆的典型应用场景和解题模式,是突破中高级算法面试的关键。

从滑动窗口最大值看堆的实际运用

给定一个数组 nums 和整数 k,求每个长度为 k 的滑动窗口中的最大值。虽然单调队列是最优解法,但使用最大堆是一种直观且易于实现的替代方案。

import heapq
from collections import deque

def maxSlidingWindow(nums, k):
    heap = []
    result = []

    for i in range(len(nums)):
        # 将负值入堆(模拟最大堆)
        heapq.heappush(heap, (-nums[i], i))

        # 当窗口形成后开始收集结果
        if i >= k - 1:
            # 移除堆顶但不在窗口内的元素
            while heap[0][1] <= i - k:
                heapq.heappop(heap)
            result.append(-heap[0][0])

    return result

该方法时间复杂度为 O(n log n),虽不如单调队列的 O(n),但在面试初期快速实现并验证逻辑非常有效。

处理前K大/小元素的经典场景

在海量数据中找出 Top K 最大或最小元素,是堆最经典的使用场景之一。例如:给定一个未排序数组,返回其中第 k 个最大的元素。

方法 时间复杂度 空间复杂度 适用场景
排序 O(n log n) O(1) 小数据集
最小堆 O(n log k) O(k) 大数据流
快速选择 O(n) 平均 O(1) 一次性查询

使用最小堆维护大小为 k 的候选集:

import heapq

def findKthLargest(nums, k):
    heap = []
    for num in nums:
        heapq.heappush(heap, num)
        if len(heap) > k:
            heapq.heappop(heap)
    return heap[0]

利用堆合并多个有序链表

合并 k 个升序链表是 LeetCode 高频难题。通过构建最小堆存储各链表当前头节点,每次取出最小值并推进对应链表指针。

import heapq

def mergeKLists(lists):
    heap = []
    for i, l in enumerate(lists):
        if l:
            heapq.heappush(heap, (l.val, i, l))

    dummy = ListNode(0)
    curr = dummy

    while heap:
        val, idx, node = heapq.heappop(heap)
        curr.next = node
        curr = curr.next
        if node.next:
            heapq.heappush(heap, (node.next.val, idx, node.next))

    return dummy.next

此方法将暴力合并的 O(kN) 优化至 O(N log k),显著提升性能。

堆与贪心策略结合的调度问题

任务调度类题目常结合堆与贪心思想。例如“CPU任务调度”:给定任务列表与冷却间隔 n,求最短执行时间。

使用最大堆按任务频率排序,优先执行剩余次数最多的任务,在冷却期内跳过其他可执行任务以模拟等待。

from collections import Counter
import heapq

def leastInterval(tasks, n):
    count = Counter(tasks)
    heap = [-freq for freq in count.values()]
    heapq.heapify(heap)

    time = 0
    while heap:
        temp = []
        for _ in range(n + 1):
            if heap:
                freq = heapq.heappop(heap)
                if freq < -1:
                    temp.append(freq + 1)
        for f in temp:
            heapq.heappush(heap, f)
        time += n + 1 if heap else len(temp)
    return time

使用堆解决动态中位数问题

设计一个数据结构支持添加数字和获取中位数操作,可通过维护一个最大堆和一个最小堆实现。

  • 最大堆存储较小的一半元素
  • 最小堆存储较大的一半元素
  • 保持两堆大小差不超过1
import heapq

class MedianFinder:
    def __init__(self):
        self.small = []  # 最大堆(存储负值)
        self.large = []  # 最小堆

    def addNum(self, num):
        heapq.heappush(self.small, -num)
        if self.small and self.large and (-self.small[0] > self.large[0]):
            val = -heapq.heappop(self.small)
            heapq.heappush(self.large, val)

        # 调整大小平衡
        if len(self.small) > len(self.large) + 1:
            heapq.heappush(self.large, -heapq.heappop(self.small))
        if len(self.large) > len(self.small) + 1:
            heapq.heappush(self.small, -heapq.heappop(self.large))

    def findMedian(self):
        if len(self.small) > len(self.large):
            return -self.small[0]
        if len(self.large) > len(self.small):
            return self.large[0]
        return (-self.small[0] + self.large[0]) / 2

堆在图算法中的延伸应用

Dijkstra 最短路径算法依赖最小堆优化节点选择过程。每次从堆中提取距离源点最近的未访问节点,更新其邻居距离。

import heapq

def dijkstra(graph, start):
    dist = {node: float('inf') for node in graph}
    dist[start] = 0
    heap = [(0, start)]

    while heap:
        d, u = heapq.heappop(heap)
        if d > dist[u]:
            continue
        for v, w in graph[u]:
            new_dist = dist[u] + w
            if new_dist < dist[v]:
                dist[v] = new_dist
                heapq.heappush(heap, (new_dist, v))
    return dist

mermaid 流程图展示堆在 Dijkstra 中的角色:

graph TD
    A[初始化距离数组] --> B[将起点加入最小堆]
    B --> C{堆非空?}
    C -->|是| D[弹出距离最小节点]
    D --> E[遍历其邻居]
    E --> F[更新更短路径]
    F --> G[将新距离入堆]
    G --> C
    C -->|否| H[返回最短距离数组]

记录一位 Gopher 的成长轨迹,从新手到骨干。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注