从青铜到王者:LeetCode-Go中的堆应用实战指南
你是否还在为TopK问题绞尽脑汁?是否在滑动窗口中位数计算中迷失方向?本文将带你深入理解Go语言中堆(Heap)的强大应用,通过优先队列(Priority Queue)轻松解决TopK与中位数问题,让你的算法效率提升10倍!读完本文,你将掌握堆的核心原理、实战技巧以及LeetCode-Go项目中的最佳实践。
堆与优先队列:数据结构中的效率王者
堆(Heap)是一种特殊的完全二叉树,它具有以下特性:
- 大顶堆:每个节点的值都大于或等于其子节点的值
- 小顶堆:每个节点的值都小于或等于其子节点的值
优先队列(Priority Queue)是一种基于堆实现的数据结构,它能够保证每次取出的元素都是队列中优先级最高的。在Go语言中,我们可以通过container/heap包来实现自定义堆。
LeetCode-Go项目中大量使用了堆结构来解决各种复杂问题,例如0630.课程表III、0692.前K个高频单词等。
TopK问题完美解决方案
TopK问题是面试中的常客,它要求在一组数据中找出前K个最大或最小的元素。使用堆来解决TopK问题,时间复杂度可以达到O(n log K),远优于O(n log n)的排序方法。
实战案例:前K个高频单词
在0692.前K个高频单词问题中,我们需要找出出现频率最高的K个单词。如果频率相同,则按字母顺序排列。
解决方案步骤:
- 使用哈希表统计每个单词的出现频率
- 使用小顶堆来维护前K个高频单词
- 当堆的大小超过K时,弹出频率最低的元素
- 最后将堆中的元素逆序输出
核心代码实现:
type wordCount struct {
word string
cnt int
}
type PQ []*wordCount
func (pq PQ) Len() int { return len(pq) }
func (pq PQ) Swap(i, j int) { pq[i], pq[j] = pq[j], pq[i] }
func (pq PQ) Less(i, j int) bool {
if pq[i].cnt == pq[j].cnt {
return pq[i].word > pq[j].word // 频率相同时按字母逆序排列
}
return pq[i].cnt < pq[j].cnt // 小顶堆
}
func topKFrequent(words []string, k int) []string {
m := map[string]int{}
for _, word := range words {
m[word]++
}
pq := &PQ{}
heap.Init(pq)
for w, c := range m {
heap.Push(pq, &wordCount{w, c})
if pq.Len() > k {
heap.Pop(pq) // 保持堆的大小为k
}
}
res := make([]string, k)
for i := k - 1; i >= 0; i-- {
res[i] = heap.Pop(pq).(*wordCount).word
}
return res
}
滑动窗口中位数:双堆协作方案
计算滑动窗口的中位数是一个更具挑战性的问题。我们可以使用两个堆来解决:
- 大顶堆存储窗口中较小的一半元素
- 小顶堆存储窗口中较大的一半元素
通过维护两个堆的平衡,我们可以在O(1)时间内获取中位数,插入和删除操作的时间复杂度为O(log n)。
实战案例:滑动窗口中位数
在0480.滑动窗口中位数问题中,我们需要计算一个滑动窗口中的中位数。
解决方案核心思想:
- 使用两个堆:大顶堆(maxH)和小顶堆(minH)
- 大顶堆存储窗口中较小的一半元素,小顶堆存储较大的一半元素
- 保持小顶堆的大小等于或比大顶堆大1
- 中位数为小顶堆的堆顶元素(当窗口大小为奇数时)或两个堆顶元素的平均值(当窗口大小为偶数时)
关键实现代码:
// 用两个堆记录窗口内的值
// 大顶堆里面的元素都比小顶堆里面的元素小
// 如果 k 是偶数,那么两个堆都有 k/2 个元素,中间值就是两个堆顶的元素
// 如果 k 是奇数,那么小顶堆比大顶堆多一个元素,中间值就是小顶堆的堆顶元素
func medianSlidingWindow1(nums []int, k int) []float64 {
ans := []float64{}
minH := MinHeapR{}
maxH := MaxHeapR{}
for i := range nums {
// 根据当前元素大小插入到对应的堆中
if minH.Len() == 0 || nums[i] >= minH.Top() {
minH.Push(nums[i])
} else {
maxH.Push(nums[i])
}
// 移除窗口外的元素
if i >= k {
if nums[i-k] >= minH.Top() {
minH.Remove(nums[i-k])
} else {
maxH.Remove(nums[i-k])
}
}
// 平衡两个堆的大小
if minH.Len() > maxH.Len()+1 {
maxH.Push(minH.Pop())
} else if minH.Len() < maxH.Len() {
minH.Push(maxH.Pop())
}
// 计算中位数
if minH.Len()+maxH.Len() == k {
if k%2 == 0 {
ans = append(ans, float64(minH.Top()+maxH.Top())/2.0)
} else {
ans = append(ans, float64(minH.Top()))
}
}
}
return ans
}
堆的更多应用场景
堆不仅可以解决TopK和中位数问题,还有许多其他应用场景:
1. 合并K个排序链表
在合并K个排序链表时,可以使用小顶堆来每次选择最小的节点,时间复杂度为O(N log K),其中N是总节点数,K是链表数。
2. 任务调度问题
在0630.课程表III问题中,我们需要根据课程的截止时间来安排学习计划,使用大顶堆来选择耗时最长的课程进行替换,以最大化可以完成的课程数量。
// 使用大顶堆来存储已选择的课程时长
func scheduleCourse(courses [][]int) int {
sort.Slice(courses, func(i, j int) bool {
return courses[i][1] < courses[j][1]
})
maxHeap := &IntHeap{}
heap.Init(maxHeap)
time := 0
for _, c := range courses {
if time+c[0] <= c[1] {
time += c[0]
heap.Push(maxHeap, c[0])
} else if maxHeap.Len() > 0 && c[0] < (*maxHeap)[0] {
// 如果当前课程时长小于堆中最长课程,进行替换
time -= heap.Pop(maxHeap).(int) - c[0]
heap.Push(maxHeap, c[0])
}
}
return maxHeap.Len()
}
3. 数据流中的中位数
与滑动窗口中位数类似,我们可以使用两个堆来实时计算数据流中的中位数,实现O(1)时间复杂度的中位数获取和O(log n)时间复杂度的插入操作。
总结与提升
堆作为一种高效的数据结构,在解决TopK、中位数、任务调度等问题时表现出色。通过本文的学习,你已经掌握了堆的核心原理和实战应用,特别是在LeetCode-Go项目中的最佳实践。
为了进一步提升你的堆应用能力,建议深入研究以下问题:
- 0295.数据流的中位数
- 0480.滑动窗口中位数
- 1439.有序矩阵中的第k个最小数组和
记住,熟练掌握堆的应用,将为你的算法能力带来质的飞跃,让你在面试和实际工作中应对各种复杂问题时游刃有余!
如果你觉得本文对你有帮助,请点赞、收藏并关注LeetCode-Go项目,我们将持续推出更多优质的算法解析文章。下期预告:"深度剖析动态规划在LeetCode中的应用",敬请期待!
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



