漫漫前端路之数据结构与算法基础14——堆篇

206 阅读9分钟

什么是堆

满足这两点,就是一个堆

  • 堆是一个完全二叉树
  • 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值:最大堆/最小堆image.png

堆的插入与删除

完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。 这里,堆中的数据是从数组下标为 1 的位置开始存储。那如果从 0 开始存储,左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 (i−1)/2。 image.png 调整堆,让其重新满足堆的特性叫堆化,堆化实际上有两种,从下往上和从上往下,两种方法的区别就是看下一个堆化的节点是谁,如果下一个是i/2,那就是从下往上,如果下一个是2i or 2i+1的话,就是从上往下。

插入

image.png 因为插入会破坏堆的特性,所以需要进行堆化。下图是采用从下往上的堆化。 image.png 我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。

  function insert( data) {
    if (count >= n) return; // 堆满了
    ++count;//count为当前储存的数据总量
    a[count] = data;
    let i = count;
    while (i/2 > 0 && a[i] > a[i/2]) { // 自下往上堆化
      swap(a, i, i/2); // swap()函数作用:交换下标为i和i/2的两个元素
      i = i/2;
    }
  }

删除栈顶元素

我们把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。 image.png

function removeMax() {
  if (count == 0) return -1; // 堆中没有数据
  a[1] = a[count];
  --count;
  heapify(a, count, 1);
}

function heapify(a, n, i) { // 自上往下堆化
  while (true) {
    let maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

时间复杂度

一个包含 n 个节点的完全二叉树,树的高度不会超过 log2(n)。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。

堆排序

堆排序的过程大致分解成两个大的步骤,建堆和排序

建堆

  1. 借助在堆中插入一个元素的思路。尽管数组中包含 n 个数据,但是可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,调用前面讲的插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样就将包含 n 个数据的数组,组织成了堆。【时间复杂度为nlog(n)】,从前往后处理数据,从下往上对化
  2. 从后往前处理数组,并且每个数据都是从上往下堆化。【时间复杂度为(n/2)log(n)】
function buildHeap(a, n) {
  for (let i = n/2; i >= 1; --i) {
    heapify(a, n, i);
  }
}

function heapify( a,  n,  i) {
  while (true) {
    let maxPos = i;
    if (i*2 <= n && a[i] < a[i*2]) maxPos = i*2;
    if (i*2+1 <= n && a[maxPos] < a[i*2+1]) maxPos = i*2+1;
    if (maxPos == i) break;
    swap(a, i, maxPos);
    i = maxPos;
  }
}

我们对下标从 n/2开始到 1 的数据进行堆化,因为下标是 (n/2)+1 到 n 的节点是叶子节点【若其不是叶子节点,那么其子节点n+2会超出n,即超出了完全二叉树的数据大小】,我们不需要堆化。

时间复杂度推导

每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度 k 成正比。 image.png image.png 其中,把公式左右都乘以 2,就得到另一个公式 S2。我们将 S2 错位对齐,并且用 S2 减去 S1,可以得到 S。 image.png 因为 h=log2(n),代入公式 S,就能得到 S=O(n),所以,建堆的时间复杂度就是 O(n)。

排序

数组中的第一个元素就是堆顶,也就是最大的元素。把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。当堆顶元素移除之后,把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。堆化完成之后,再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。 image.png

// n表示数据的个数,数组a中的数据从下标1到n的位置。
function sort(a, int n) {
  buildHeap(a, n);
  let k = n;
  while (k > 1) {
    swap(a, 1, k);
    --k;
    heapify(a, k, 1);
  }
}

堆排序算法性质

  • 原地算法:只需要极个别的临时空间;
  • 时间复杂度:建堆过程时间复杂度为O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。
  • 不稳定:因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。

堆排序 vs 快速排序

  1. 堆排序数据访问的方式没有快速排序友好。 对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。 image.png

  2. 对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。 快速排序数据交换的次数不会比逆序度多,但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。

快速排序只能应对静态数据;如果数据集合一直在变动,堆更适合

应用

优先级队列

在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。

合并有序小文件

现有100个小文件,每个文件含有有序的字符串,现需将100个小文件合并成一个有序的大文件。

  1. 从100个文件中,各取第一个字符串放到数组,比较大小后,把最小的字符串放入大文件,并在数组中删除,若这个最小字符串属于文件1,那再从文件1取出下一个字符串,补充到数组,依次类推。
  • 这种需要每次都需要遍历整个数组,不高效。
  1. 先各取第一个字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环往复。
  • 删除与插入的时间复杂度都为O(logn),比原来的方式更有效。

高效定时器

定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。 image.png

  1. 定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。
  • 较为低效:每1秒就扫描,任务的约定执行时间离当前时间可能还有很久,扫描任务白费并且每次都扫描整个任务列表,扫描耗时;
  1. 我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。那么每次定时器扫描的间隔时间就是当前时间与栈顶任务时间的间隔。

利用堆求 Top K

针对静态数据集合:数据集合事先确定,不会再变

先用数组前k个数据维护一个最小堆,遍历剩下的元素,若元素大于栈顶元素,将栈顶元素删除,换成该元素,若小等于,跳过遍历下一个元素。

  • 比如数组 [4,5,3,7,1,8],要取前3大元素 首先维护一个小顶堆,放入前三个数据,为 [3,4,5]; 接着遍历数组到元素7,比堆顶元素3大,将3移除,将7放入堆中,小顶堆变为 [4,5,7]; 接着遍历数组到元素1,比堆顶元素4小,不处理,接着遍历; 接着遍历数组到元素8,比堆顶元素4大,将4移除,将8放入堆中,小顶堆变为 [5,7,8]; 遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。
/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
var findKthLargest = function(nums, k) {
   buildMinHeap(nums, k);
   for(let i = k; i < nums.length; i++){
       if(nums[i]>=nums[0]){
           nums[0] = nums[i];
           heapify(nums,k,0);
       }
   }
   return nums[0];
   function buildMinHeap(nums,k){
       let mid = Math.floor(k/2);
       for(let i = mid; i >= 0; i--){
           heapify(nums, k, i)//从上往下配的是从后往前处理数据
       }
    }
    function heapify (nums, k, i){
        let left = 2*i+1, right = 2*i+2, min = i;
        if(left < k && nums[left] < nums[i]) min = left;
        if(right < k && nums[right] < nums[min]) min = right;
        if(min !== i){
           [nums[min],nums[i]] = [nums[i], nums[min]];
           heapify(nums, k, min)
        }
    }
};

针对动态数据集合:数据集合事先并不确定,有数据动态地加入到集合

类似静态,可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,都可以立刻返回给他。

利用堆求中位数

image.png

针对静态数据集合

先排序,第n/2个数据就是中位数。每次询问中位数的时候,直接返回这个固定的值就好了。只需要一次的排序成本,边际成本较小。

针对动态数据集合

中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。

需要维护两个堆,一个大顶堆,一个小顶堆。如果有 n 个数据,从小到大排序,那前n/2(n为偶数) or n/2+1(n为奇数)个数据存储在大顶堆中,后n/2个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是要找的中位数。

如果新加入的数据小于等于大顶堆的堆顶元素,就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆,这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况,这时,可以从一个堆中不停地将堆顶元素移动到另一个堆。 image.png

  • 插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)
let MinHeap = function () {
    let heap = [,];
    this.getSize = () => heap.length - 1;
    this.getHead = () => { return heap.length > 1 ? heap[1]: null}
    this.insert = (num) => {
        heap.push(num);
        let i = heap.length - 1;
        while(Math.floor(i/2) > 0 & heap[i] < heap[Math.floor(i/2) ]){
            [heap[i],heap[Math.floor(i/2)]]=[heap[Math.floor(i/2)],heap[i]]
            i = Math.floor(i/2);
        }
    }  
    this.removeHead  = () =>{
        if(heap.length > 1){
            if(heap.length === 2) return heap.pop();
            let num = heap[1];
            heap[1] = heap.pop();
            heapify(1);
            return num;
        }
        return null
    }
    let heapify = (i)=>{
        let left = 2*i, right = 2*i+1, min = i;
        if(left < heap.length && heap[left] < heap[min]) min = left;
        if(right < heap.length && heap[right] < heap[min]) min = right;
        if(i!==min){
            [heap[i],heap[min]]=[heap[min], heap[i]];
            heapify(min);
        }
    }
}
let MaxHeap = function () {
    let heap = [,];
    this.getSize = () => heap.length - 1;
    this.getHead = () => { return heap.length > 1 ? heap[1]: null}
    this.insert = (num) => {
        heap.push(num);
        let i = heap.length - 1;
        while(Math.floor(i/2) > 0 & heap[i] > heap[Math.floor(i/2) ]){
            [heap[i],heap[Math.floor(i/2)]]=[heap[Math.floor(i/2)],heap[i]]
            i = Math.floor(i/2);
        }
    }  
    this.removeHead  = () =>{
        if(heap.length > 1){
            if(heap.length === 2) return heap.pop();
            let num = heap[1];
            heap[1] = heap.pop();
            heapify(1);
            return num;
        }
        return null
    }
    let heapify = (i)=>{
        let left = 2*i, right = 2*i+1, max = i;
        if(left < heap.length && heap[left] > heap[max]) max = left;
        if(right < heap.length && heap[right]>heap[max]) max = right;
        if(i!==max){
            [heap[i],heap[max]]=[heap[max], heap[i]];
            heapify(max);
        }
    }
}
var MedianFinder = function() {
    this.maxHeap = new MaxHeap()
    this.minHeap = new MinHeap()
};


/** 
 * @param {number} num
 * @return {void}
 */
MedianFinder.prototype.addNum = function(num) {
    if(!this.maxHeap.getSize() || num < this.maxHeap.getHead()){
        this.maxHeap.insert(num)
    }else{
        this.minHeap.insert(num)
    }
    if(this.maxHeap.getSize() - this.minHeap.getSize() > 1){
        this.minHeap.insert(this.maxHeap.removeHead())
    }
    if(this.minHeap.getSize() > this.maxHeap.getSize()){
        this.maxHeap.insert(this.minHeap.removeHead())
    }
};

/**
 * @return {number}
 */
MedianFinder.prototype.findMedian = function() {
    // 为偶数
    if(this.maxHeap.getSize() && this.maxHeap.getSize() === this.minHeap.getSize()) {
        return (this.maxHeap.getHead() + this.minHeap.getHead())/2
    }
    // n 为奇数
    return this.maxHeap.getHead()
};

利用堆求接口的 99% 响应时间;

99% 响应时间:如果有 100 个接口访问请求,把这 100 个接口的响应时间按照从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。

维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是 n,大顶堆中保存 n99% 个数据,小顶堆中保存 n1% 个数据。大顶堆堆顶的数据就是要找的 99% 响应时间。

每次插入一个数据的时候,要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。

但是,为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。

思考

  • 有一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词呢?
  1. 创建 10 个空文件 00,01,02,……,09。遍历这 10 亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。【如果不分区的话,直接对10亿关键词建散列表,不重复可能有1亿条,每条50字节,那1亿的存储可能需要5G,因为散列表为避免冲突,不会将装载因子设的过大。】
  2. 在每个文件中,顺序扫描这1亿个搜索关键词。当扫描到某个关键词时,去散列表中查询。如果存在,就将对应的次数加一;如果不存在,就将它插入到散列表,并记录次数为 1。以此类推,等遍历完这 10 亿个搜索关键词之后,散列表中就存储了不重复的搜索关键词以及出现的次数。
  3. 建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中。

资料来源

time.geekbang.org/column/arti… time.geekbang.org/column/arti…