数据结构和算法-堆

175 阅读6分钟

定义

  • 堆是一个完全二叉树
  • 堆中每一个节点的值都必须大于等于(或者小于等于)子树中每个节点的值

每一个节点的值都必须大于等于子树中每个节点的值的堆称为大顶堆,反之为小顶堆

实现

因为堆是一个完全二叉树,所以非常适合使用数组实现。

设当前节点数组的索引为curIndex,左子节点的为leftIndex,右子节点的为rightIndex,则有

//若curIndex 从0开始
leftIndex = curIndex * 2 + 1
rightIndex = curIndex * 2 + 2

依次类推,堆中每个节点按层次都可以依次存储到数组中,空间利用率非常高。

对数组的遍历,即树的层次遍历。

算法

以下算法基于大顶堆

插入元素

/*
时间复杂度:O(logn)
空间复杂度:O(1)
*/
func insert(heap []int, val int) {
    heap = append(heap, val)
    i := len(heap)
    //自低向上堆化
    for i/2 > 0 && a[i-1] > a[i/2-1] {
        swap(heap, i-1, i/2-1)
        i /= 2
    }
}

func swap(heap []int, i, j int) {
    tmp = heap[i]
    heap[i] = heap[j]
    heap[j] = temp
}

删除堆顶元素

删除之后必须保证堆还是一个完全二叉树。

/*
时间复杂度:O(logn)
空间复杂度:O(1)
*/
func removeTop(heap []int) {
    if len(heap) == 0 {
        return
    }
    //将最小值赋给堆顶
    min := heap[len(heap)-1]
    heap[0] = min
    //移除堆中最小元素
    heap = heap[:len(heap)-1]
    //自上而下堆化
    heapify(heap, len(heap), 0)
}

func heapify(heap []int, n, i int) {
    for {
        maxPos := i
        //左节点的值是否大于双亲节点节点的值
        if i*2+1 < n && heap[maxPos] < heap[i*2+1] {
            maxPos = i*2+1
        }
        //右节点的值是否大于当前最大节点的值
        if i*2+2 < n && heap[maxPos] < heap[i*2+2] {
            maxPos = i*2+2
        }
        //如果左右节点的值都小于双亲节点的值,则堆化结束
        if maxPos == i {
            break
        }
        //交换节点
        swap(heap, i, maxPos)
        //继续向下堆化
        i = maxPos
    }
}

func swap(heap []int, i, j int) {
    tmp := heap[i]
    heap[i] = heap[j]
    heap[j] = tmp
}

应用

堆排序

堆排序大致可以分为两个步骤

  1. 建堆
/*
方式1:自底向上
时间复杂度:O(nlogn)
空间复杂度:O(n)
*/
func buildHeap(nums []int) {
    heap := []int{}
    for i:=0; i<len(nums); i++ {
        //使用插入,自底向上建堆
        insesrt(heap, nums[i])
    }
    for i:=0; i<len(nums); i++ {
        nums[i] = heap[i]
    }
}

/*
方式2:自上往下
时间复杂度:O(nlogn)
空间复杂度:O(1)
*/
func buildHeap(nums []int) {
    for i:= len(nums)/2-1; i>=0; i-- {
        heapify(nums, len(nums), i)
    }
}

  1. 排序
/*
时间复杂度:O(nlogn)
空间复杂度:O(1)
是否稳定:否
*/
func heapSort(nums []int) {
    //建堆
    buildHeap(nums)
    n := len(nums)
    for n > 0 {
        n -= 1
        //将大顶堆的最大元素和最小元素交换
        swap(nums, 0, n)
        //继续堆化
        heapify(nums, n, 0)
    }
}

优先级队列

优先级队列本质就是一个堆,比如Java的PriorityQueue

合并有序小文件到大文件

  1. 内存维护一个小顶堆,
  2. 第一次从各个小文件中读取一条数据插入小顶堆
  3. 删除堆顶元素,写入大文件
  4. 从对应文件取下一条数据插入小顶堆,回到3,知道所有文件读取完毕。

例子:合并K个升序链表

/*
给你一个链表数组,每个链表都已经按升序排列。
请你将所有链表合并到一个升序链表中,返回合并后的链表。
*/
/**
 * Definition for singly-linked list.
 * public class ListNode {
 *     int val;
 *     ListNode next;
 *     ListNode() {}
 *     ListNode(int val) { this.val = val; }
 *     ListNode(int val, ListNode next) { this.val = val; this.next = next; }
 * }
 */
class Solution {
    public ListNode mergeKLists(ListNode[] lists) {
        if (lists.length == 0) {
            return null;
        }
        //1. 构造一个小顶堆
        PriorityQueue<ListNode> heap 
            = new PriorityQueue<>(lists.length, (i1, i2) -> i1.val- i2.val);
        //2. 取第一条数据入堆
        for (int i=0; i< lists.length; i++) {
            ListNode p = lists[i];
            if (p != null) {
                heap.offer(p);
            }
        }

        ListNode result = new ListNode();
        ListNode p = result;
        
        while (heap.size() > 0) {
            //3. 移除堆顶元素,写入结果
            ListNode top = heap.poll();
            p.next = top;
            p = p.next;
            //4. 插入当前链表下一条记录到小顶堆
            if (top.next != null) {
                heap.offer(top.next);
            }
        }

        return result.next;
    }
}

高性能定时器

对定时任务以执行时间为比较元素构建小顶堆

  1. 读取堆顶任务,以当前时间点和实现时间点间隔创建一个定时任务
  2. 定时任务到点移除堆顶任务,并执行,同时回到1

不需要遍历任务列表,也不需要轮询,性能也提高了。

TOPK问题

思路如下

  1. 创建一个大小为k的小顶堆
  2. 遍历数据源,依次入堆,入堆逻辑如下
  • 当堆 <= k, 数据元素直接入堆
  • 当堆 > k, 将数据元素与堆顶元素比较,如果大于则删除堆顶元素后入堆,否则跳过。
  1. 数据源遍历完后,就得到topK的堆。

例如: 前K个高频单词

//以下为伪代码
func topk(words []string, k int) []string{
    //统计单词次数 -> 数据源
    cntMap := map[word]int{}
    for _, word := range words {
        cntMap[word] += 1
    }
    
    //1. 创建一个大小为k的的堆,同时需要给一个比较规则
    heap := newWordCntHeap(k, compareFunc)
    
    //2. 遍历入堆
    for word, cnt := cntMap {
        //入堆逻辑封装在方法内部
        heap.Insert(word, cnt)
    }
    ans := []int{}
    //3. 获取topK
    for k > 0 {
        ans = append(ans, heap.RemoveTop().word)
        k -= 1
    }
    return ans
    
}

//比较函数
func compareFunc() int {
    // 以出现次数比大小
    // 当出现次数一样时,按字母字典排序规则比大小
}

上面topk问题的数据源为静态数据,并没有发挥出堆得优势,其实使用快排来求解topk性能更好;当数据源是动态变化的才能发挥出堆得优势,因为每当数据发生变化,堆排序是O(logn)即可完成堆化,保持topk,而快排则每次都需要O(nlogn)重新排序计算topk。

其实不只是topk问题,任何数据源为动态的且需要排序的问题都应该优先考虑堆解决。

  • 静态数据源:优先使用快排
  • 动态数据源:优先使用堆排序

中位数

思路:维护一个小顶堆,一个大顶堆,插入队列过程中动态维护两个堆得大小,保证大小相差<=1。

  • 当相差为0,取平均值
  • 当相差1,取打的一方的堆顶元素

例子:295. 数据流的中位数

class MedianFinder {

    PriorityQueue<Integer> minHeap =
        new PriorityQueue<>((i1,i2) -> i1.compareTo(i2));
    PriorityQueue<Integer> maxHeap = 
        new PriorityQueue<>((i1,i2) -> i2.compareTo(i1));

    public MedianFinder() {

    }
    
    public void addNum(int num) {
        if (maxHeap.size() == 0 && minHeap.size() == 0) {
            minHeap.offer(num);
        } else if (minHeap.peek() < num) {
            minHeap.offer(num);
        } else {
            maxHeap.offer(num);
        }
        //这里总是保证 最小堆size >= 最大堆size
        if (maxHeap.size() > minHeap.size()) {
            minHeap.offer(maxHeap.poll());
        }
        if (minHeap.size() > maxHeap.size()+1) {
            maxHeap.offer(minHeap.poll());
        }
    }
    
    public double findMedian() {
        if (maxHeap.size() == 0 && minHeap.size() == 0) {
            return 0.0;
        } else if (maxHeap.size() == 0) {
            return minHeap.peek();
        } else if (minHeap.size() == 0) {
            return maxHeap.peek();
        } else if (maxHeap.size() == minHeap.size()){
            return (minHeap.peek() + maxHeap.peek()) / 2.0;
        } else {
            return minHeap.peek();
        }
    }
}

/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder obj = new MedianFinder();
 * obj.addNum(num);
 * double param_2 = obj.findMedian();
 */

拓展思考

同样是O(nlogn)的原地排序,堆排序性能为什么没有比快速排序好?

  1. 堆排序是跳着访问数据,对CPU Cache不友好。
  2. 堆排序的交换次数大于快排:堆排序的建堆过程会打乱数据原有的相对顺序,导致数据的逆序度增加,即数据变得更无序。

如何求堆中最后一个非叶子节点和第一个叶子节点的数组下标索引?

为了方便推导,存储数据的数组索引从1开始,设某个节点的索引为k,左子节点为kleft,右子节点为kright,则有

kleft = 2 * k
kright = 2 * k + 1

设堆的节点树为n,最后一个叶子节点名为lln,索引为lli,其双亲节点为pn,索引为pi。

明显有(完全二叉树性质):lli = n

如果lln为pn的左子节点,那么有pi * 2 = n,可得pi = n/2

如果lln为pn的右子节点,那么有pi * 2 + 1 = n,可得pi = (n-1)/2,此时n为奇数,由于计算机的整数除法运算会自动砍掉余数部分,所以有(n-1)/2 == n/2

综上可得:pi = n/2

由完全二叉树的性质可知,pn即为最后一个非叶子节点,在pn这一层从左到右,pn的下一个节点就是第一个叶子节点。

所以堆的最后一个非叶子节点的索引为:n/2,第一个叶子节点的索引为n/2+1

当存储数据的数组索引从0开始时,向左偏移1即可,即堆的最后一个非叶子节点的索引为:n/2-1,第一个叶子节点的索引为n/2

数组成堆是从最后一个非叶子节点依次往前堆化的,所以开始索引就是n/2-1

/*
方式2:自上往下
时间复杂度:O(n)
空间复杂度:O(1)
*/
func buildHeap(nums []int) {
    for i:= len(nums)/2-1; i>=0; i-- {
        heapify(nums, len(nums), i)
    }
}