《恋上数据结构第1季》二叉堆原理及实现、最小堆解决 TOP K 问题

简介: 《恋上数据结构第1季》二叉堆原理及实现、最小堆解决 TOP K 问题
数据结构与算法笔记目录《恋上数据结构》 笔记目录

想加深 Java 基础推荐看这个Java 强化笔记目录

我的《恋上数据结构》源码(第1季 + 第2季):https://github.com/szluyu99/Data_Structure_Note

堆(Heap)

堆的出现

设计一种数据结构,用来存放整数,要求提供 3 个接口:

  • 添加元素
  • 获取最大值
  • 删除最大值

用已学过的数据结构来对比一下时间复杂度:
在这里插入图片描述
为了迎合此需求出现了一种新的数据结构 ——

  • 获取最大值:$O(logn)$
  • 删除最大值:$O(logn)$
  • 添加元素:$O(logn)$

堆简介

(Heap)是一种 树状 的数据结构,常见的堆实现有

  • 二叉堆(Binary Heap,完全二叉堆
  • 多叉堆(D-heap、D-ary Heap)
  • 索引堆(Index Heap)
  • 二项堆(Binomial Heap)
  • 斐波那契堆(Fibonacci Heap)
  • 左倾堆(Leftist Heap,左式堆
  • 斜堆(Skew Heap)

堆的重要性质:任意节点的值总是 $\geq$ ( $\leqslant$) 子节点的值

  • 如果任意节点的值总是 $\geq$ 子节点的值,称为:最大堆、大根堆、大顶堆
  • 如果任意节点的值总是 $\leqslant$ 子节点的值,称为:最小堆、小根堆、小顶堆

堆中的元素必须具备可比较性(跟二叉搜索树)一样)
在这里插入图片描述

二叉堆(Binary Heap)

二叉堆的逻辑结构就是一棵完全二叉树,所以也叫完全二叉堆

鉴于完全二叉树的一些特性,二叉堆的底层(物理结构)一般用数组实现即可
在这里插入图片描述
索引 i 的规律(n 是元素数量)

  • 如果 i = 0,它是 节点
  • 如果 i > 0,它的 节点的索引为 floor( (i - 1) / 2 )
  • 如果 $2i + 1 \leqslant n - 1$,它的子节点的索引为 $2i + 1$
  • 如果 $2i + 1 >n - 1$,它无左子节点
  • 如果 $2i + 2 \leqslant n - 1$,它的子节点的索引为 $2i + 1$
  • 如果 $2i + 2 > n - 1$,它无右子节点

获取最大值

主要就是检测数组是否不能为空,为空则抛出异常。

public E get() {
    emptyCheck();
    return elements[0];
}
private void emptyCheck() {
    if (size == 0) {
        throw new IndexOutOfBoundsException("Heap is empty");
    }
}

最大堆 — 添加

思路是:

  • 将添加的节点放入数组最后
  • 循环执行上滤操作(Sift Up),即:

    • 如果 node > 父节点

    与父节点交换位置

    • 如果 node <= 父节点,或者 node 没有父节点

    退出循环

  • 时间复杂度:$O(logn)$

在这里插入图片描述


在这里插入图片描述

/**
 * 最大堆 — 添加
 */
public void add(E element) {
    // 检测传入的元素非空
    elementNotNullCheck(element);
    // 扩容操作,确保容量大于当前元素个数大小
    ensureCapacity(size + 1);
    // 将要添加的元素放到数组最后
    elements[size++] = element;
    // 上滤操作
    siftUp(size - 1);
}
/**
 * 让index位置的元素上滤
 */
private void siftUp(int index) {
    E e = elements[index]; // 要添加的节点
    while (index > 0) { // 直到比较到根结点
        int pindex = (index - 1) >> 1; // 父节点索引
        E p = elements[pindex]; // 添加节点的父节点
        if (compare(e, p) <= 0) return;
        
        // 交换index、pindex位置的内容
        E tmp = elements[index];
        elements[index] = elements[pindex];
        elements[pindex] = tmp;
        
        // 重新赋值index
        index = pindex;
    }
}

最大堆 — 添加优化

和上面的区别在于:

  • 并不是每次==节点的值 > 父节点的值==就直接交换
  • 而是将新添加的节点备份,确定最终位置才放上去

在这里插入图片描述

/**
 * 最大堆 — 添加
 */
public void add(E element) {
    // 检测传入的元素非空
    elementNotNullCheck(element);
    // 扩容操作,确保容量大于当前元素个数大小
    ensureCapacity(size + 1);
    // 将要添加的元素放到数组最后
    elements[size++] = element;
    // 上滤操作
    siftUp(size - 1);
}
/**
 * 让index位置的元素上滤
 */
private void siftUp(int index) {
    E element = elements[index];
    while (index > 0) {
        // 父节点索引 = (子节点索引-1) / 2
        int parentIndex = (index - 1) >> 1;
        E parent = elements[parentIndex];
        if (compare(element, parent) <= 0) break;
        
        // 将父元素存储在index位置
        elements[index] = parent;
        
        // 重新赋值index
        index = parentIndex;
    }
    elements[index] = element;
}

最大堆 — 删除

思路是:

  • 用最后一个节点覆盖根结点
  • 删除最后一个节点
  • 循环执行下滤操作(Sift Down),即:

    • 如果 node < 最大的子节点
      与最大的子节点交换位置
    • 如果 node >= 最大的子节点,或者 node 没有子节点

    退出循环

  • 时间复杂度:$O(logn)$
  • 交换位置的操作可以像添加那样进行优化

在这里插入图片描述
在这里插入图片描述

/**
 * 最大堆—删除堆顶元素
 */
public E remove() {
    // 检测数组不能为空
    emptyCheck(); 
    // 获取数组最后元素的索引
    int lastIndex = --size;
    // 获取要删除元素的节点
    E root = elements[0];
    elements[0] = elements[lastIndex];
    elements[lastIndex] = null;
    
    siftDown(0); // 下滤操作
    return root;
}
/**
 * 让index位置的元素下滤
 */
private void siftDown(int index) {
    E element = elements[index];
    int half = size >> 1; // 非叶子节点的数量
    // 第一个叶子节点的索引 == 非叶子节点的数量
    // index < 第一个叶子节点的索引
    // 必须保证index位置是非叶子节点
    while (index < half) { 
        // index的节点有2种情况
        // 1.只有左子节点
        // 2.同时有左右子节点
        
        // 默认为左子节点跟它进行比较
        int childIndex = (index << 1) + 1;
        E child = elements[childIndex];
        
        // 右子节点
        int rightIndex = childIndex + 1;
        
        // 选出左右子节点最大的那个
        if (rightIndex < size && compare(elements[rightIndex], child) > 0) {
            child = elements[childIndex = rightIndex];
        }
        
        if (compare(element, child) >= 0) break;

        // 将子节点存放到index位置
        elements[index] = child;
        // 重新设置index
        index = childIndex;
    }
    elements[index] = element;
}

replace

替换堆顶的元素

public E replace(E element) {
    elementNotNullCheck(element);
    
    E root = null;
    if (size == 0) {
        elements[0] = element;
        size++;
    } else {
        root = elements[0];
        elements[0] = element;
        siftDown(0);
    }
    return root;
}

最大堆 — 批量建堆(Heapify)

  • 自上而下的上滤
  • 自下而上的下滤

自上而下的上滤

在这里插入图片描述

自下而上的下滤

在这里插入图片描述

效率对比

  • 自上而下的上滤时间复杂度:$O(nlogn)$
  • 自下而上的下滤时间复杂度:$O(nlogk)$

在这里插入图片描述

二叉堆源码

堆的基本接口 Heap.java

public interface Heap<E> {
    int size();    // 元素的数量
    boolean isEmpty();    // 是否为空
    void clear();    // 清空
    void add(E element);     // 添加元素
    E get();    // 获得堆顶元素
    E remove(); // 删除堆顶元素
    E replace(E element); // 删除堆顶元素的同时插入一个新元素
}

抽象类 AbstractHeap.java

@SuppressWarnings("unchecked")
public abstract class AbstractHeap<E> implements Heap<E> {
    protected int size;
    protected Comparator<E> comparator;
    
    public AbstractHeap(Comparator<E> comparator) {
        this.comparator = comparator;
    }
    
    public AbstractHeap() {
        this(null);
    }
    
    @Override
    public int size() {
        return size;
    }

    @Override
    public boolean isEmpty() {
        return size == 0;
    }
    
    protected int compare(E e1, E e2) {
        return comparator != null ? comparator.compare(e1, e2) 
                : ((Comparable<E>)e1).compareTo(e2);
    }
}

二叉堆 BinaryHeap.java

/**
 * 二叉堆(最大堆)
 */
@SuppressWarnings("unchecked")
public class BinaryHeap<E> extends AbstractHeap<E> {
    private E[] elements;
    private static final int DEFAULT_CAPACITY = 10;
    
    public BinaryHeap(E[] elements, Comparator<E> comparator)  {
        super(comparator);
        
        if (elements == null || elements.length == 0) {
            this.elements = (E[]) new Object[DEFAULT_CAPACITY];
        } else {
            size = elements.length;
            int capacity = Math.max(elements.length, DEFAULT_CAPACITY);
            this.elements = (E[]) new Object[capacity];
            for (int i = 0; i < elements.length; i++) {
                this.elements[i] = elements[i];
            }
            heapify();
        }
    }
    
    public BinaryHeap(E[] elements)  {
        this(elements, null);
    }
    
    public BinaryHeap(Comparator<E> comparator) {
        this(null, comparator);
    }
    
    public BinaryHeap() {
        this(null, null);
    }

    @Override
    public void clear() {
        for (int i = 0; i < size; i++) {
            elements[i] = null;
        }
        size = 0;
    }

    @Override
    public void add(E element) {
        elementNotNullCheck(element);
        ensureCapacity(size + 1);
        elements[size++] = element;
        siftUp(size - 1);
    }

    @Override
    public E get() {
        emptyCheck();
        return elements[0];
    }

    /**
     * 删除堆顶元素
     */
    public E remove() {
        emptyCheck();
        
        int lastIndex = --size;
        E root = elements[0];
        elements[0] = elements[lastIndex];
        elements[lastIndex] = null;
        
        siftDown(0);
        return root;
    }

    @Override
    public E replace(E element) {
        elementNotNullCheck(element);
        
        E root = null;
        if (size == 0) {
            elements[0] = element;
            size++;
        } else {
            root = elements[0];
            elements[0] = element;
            siftDown(0);
        }
        return root;
    }
    
    /**
     * 批量建堆
     */
    private void heapify() {
        // 自上而下的上滤
//        for (int i = 1; i < size; i++) {
//            siftUp(i);
//        }
        
        // 自下而上的下滤
        for (int i = (size >> 1) - 1; i >= 0; i--) {
            siftDown(i);
        }
    }
    
    /**
     * 让index位置的元素下滤
     * @param index
     */
    private void siftDown(int index) {
        E element = elements[index];
        int half = size >> 1; // 非叶子节点的数量
        // 第一个叶子节点的索引 == 非叶子节点的数量
        // index < 第一个叶子节点的索引
        // 必须保证index位置是非叶子节点
        while (index < half) { 
            // index的节点有2种情况
            // 1.只有左子节点
            // 2.同时有左右子节点
            
            // 默认为左子节点跟它进行比较
            int childIndex = (index << 1) + 1;
            E child = elements[childIndex];
            
            // 右子节点
            int rightIndex = childIndex + 1;
            
            // 选出左右子节点最大的那个
            if (rightIndex < size && compare(elements[rightIndex], child) > 0) {
                child = elements[childIndex = rightIndex];
            }
            
            if (compare(element, child) >= 0) break;

            // 将子节点存放到index位置
            elements[index] = child;
            // 重新设置index
            index = childIndex;
        }
        elements[index] = element;
    }
    
    /**
     * 让index位置的元素上滤
     */
    private void siftUp(int index) {
//        E e = elements[index];
//        while (index > 0) {
//            int pindex = (index - 1) >> 1;
//            E p = elements[pindex];
//            if (compare(e, p) <= 0) return;
//            
//            // 交换index、pindex位置的内容
//            E tmp = elements[index];
//            elements[index] = elements[pindex];
//            elements[pindex] = tmp;
//            
//            // 重新赋值index
//            index = pindex;
//        }
        E element = elements[index];
        while (index > 0) {
            // 父节点索引 = (子节点索引-1) / 2
            int parentIndex = (index - 1) >> 1;
            E parent = elements[parentIndex];
            if (compare(element, parent) <= 0) break;
            
            // 将父元素存储在index位置
            elements[index] = parent;
            
            // 重新赋值index
            index = parentIndex;
        }
        elements[index] = element;
    }
    
    private void ensureCapacity(int capacity) {
        int oldCapacity = elements.length;
        if (oldCapacity >= capacity) return;
        
        // 新容量为旧容量的1.5倍
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        E[] newElements = (E[]) new Object[newCapacity];
        for (int i = 0; i < size; i++) {
            newElements[i] = elements[i];
        }
        elements = newElements;
    }
    
    private void emptyCheck() {
        if (size == 0) {
            throw new IndexOutOfBoundsException("Heap is empty");
        }
    }
    
    private void elementNotNullCheck(E element) {
        if (element == null) {
            throw new IllegalArgumentException("element must not be null");
        }
    }
}

构建一个最小堆

在写完最大堆以后,实现最小堆不需要修改源代码,只需要在创建堆时,传入与最大堆比较方式相反的比较器即可。

public static void main(String[] args) {
    Integer[] data = {88, 44, 53, 41, 16, 6, 70, 18, 85, 98, 81, 23, 36, 43, 37};
    BinaryHeap<Integer> heap = new BinaryHeap<>(data, new Comparator<Integer>() {
        public int compare(Integer o1, Integer o2) {
            return o2 - o1; // 与最大堆比较方式相反
        }
    });
}

TOP K 问题

什么是 TopK 问题

  • 从 n 个整数中,找出最大的前 k 个数(k << n)
  • 例如:从100万个整数中找出最大的100个整数

TopK 问题的解法之一:可以用数据结构 “” 来解决

如果使用排序算法进行全排序,需要 $O(nlogn)$ 的时间复杂度

如果使用二叉堆来解决,可以使用 $O(nlogk)$ 的时间复杂度来解决

  • 新建一个小顶堆
  • 扫描 n 个整数

    • 先将遍历到的前 k 个数放入堆中
    • 从第 k+1 个数开始,如果大于堆顶元素,就使用 replace 操作

    (删除堆顶元素,将第k+1个数添加到堆中)

  • 扫描完毕后,堆中剩下的就是最大的前 k 个数
public static void main(String[] args) {
    // 新建一个小顶堆
    BinaryHeap<Integer> heap = new BinaryHeap<>(new Comparator<Integer>() {
        public int compare(Integer o1, Integer o2) {
            return o2 - o1;
        }
    });
    
    // 找出最大的前k个数
    int k = 3;
    Integer[] data = {51, 30, 39, 92, 74, 25, 16, 93, 
            91, 19, 54, 47, 73, 62, 76, 63, 35, 18, 
            90, 6, 65, 49, 3, 26, 61, 21, 48};
    for (int i = 0; i < data.length; i++) {
        if (heap.size() < k) { // 前k个数添加到小顶堆
            heap.add(data[i]); // logk
        } else if (data[i] > heap.get()) { // 如果是第 k + 1 个数,并且大于堆顶元素
            heap.replace(data[i]); // logk
        }
    }
    // O(nlogk)
}

如果是找出最小的前 k 个数呢?

  • 用大顶堆
  • 如果小于堆顶元素,就使用 replace 操作
相关文章
|
22天前
|
存储 Java
【数据结构】优先级队列(堆)从实现到应用详解
本文介绍了优先级队列的概念及其底层数据结构——堆。优先级队列根据元素的优先级而非插入顺序进行出队操作。JDK1.8中的`PriorityQueue`使用堆实现,堆分为大根堆和小根堆。大根堆中每个节点的值都不小于其子节点的值,小根堆则相反。文章详细讲解了如何通过数组模拟实现堆,并提供了创建、插入、删除以及获取堆顶元素的具体步骤。此外,还介绍了堆排序及解决Top K问题的应用,并展示了Java中`PriorityQueue`的基本用法和注意事项。
23 5
【数据结构】优先级队列(堆)从实现到应用详解
|
1天前
|
搜索推荐 C++
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(一)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
1天前
|
搜索推荐 索引
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(二)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
25天前
|
设计模式 安全 Java
HashMap底层原理:数据结构+put()流程+2的n次方+死循环+数据覆盖问题
假如有T1、T2两个线程同时对某链表扩容,他们都标记头结点和第二个结点,此时T2阻塞,T1执行完扩容后链表结点顺序反过来,此时T2恢复运行再进行翻转就会产生环形链表,即B.next=A;采用2的指数进行扩容,是为了利用位运算,提高扩容运算的效率。JDK8中,HashMap采用尾插法,扩容时链表节点位置不会翻转,解决了扩容死循环问题,但是性能差了一点,因为要遍历链表再查到尾部。例如15(即2^4-1)的二进制为1111,31的二进制为11111,63的二进制为111111,127的二进制为1111111。
HashMap底层原理:数据结构+put()流程+2的n次方+死循环+数据覆盖问题
|
1天前
|
存储 算法 分布式数据库
【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力
【初阶数据结构】理解堆的特性与应用:深入探索完全二叉树的独特魅力
|
1天前
|
人工智能 搜索推荐 算法
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理(三)
【初阶数据结构】深度解析七大常见排序|掌握底层逻辑与原理
|
2月前
|
存储 算法
【数据结构】堆
【数据结构】堆
|
2月前
|
搜索推荐 算法 Go
深入探索堆:Go语言中的高效数据结构
深入探索堆:Go语言中的高效数据结构
|
2月前
|
存储 算法 Linux
【数据结构】树、二叉树与堆(长期维护)(1)
【数据结构】树、二叉树与堆(长期维护)(1)
|
2月前
|
算法
【数据结构】树、二叉树与堆(长期维护)(2)
【数据结构】树、二叉树与堆(长期维护)(2)
【数据结构】树、二叉树与堆(长期维护)(2)