[路飞]javaScript中堆的实现方式-以数据流中的第 K 大元素为例

278 阅读7分钟

记录 1 道算法题

数据流中的第 k 大元素

leetcode-cn.com/problems/kt…


希望可以讲清楚 “堆” 以及 “堆是怎么进行优化计算”

  1. 先说比较直接的方法。

可以维护一个前 k 大元素的数组,每次添加的时候都进行一次冒泡,把新添加的元素放到合适的地方,然后把最小的给弹出数组。

    function KthLargest(k, nums) {
        this.k = k
        this.data = []
        for (let i = 0; i < nums.length; i++) {
          this.add(nums[i])
        }
    }
    
    KthLargest.prototype.add = function (val) {
        const data = this.data
        // 如果还没够 k 个就直接推进去,然后排序
        if (data.length < this.k) {
          data.push(val)
        } else if (val > data[data.length - 1]) {
            // 如果够了就把最小的给替换掉,然后进行冒泡
          data[data.length - 1] = val
        }
        let a = data.length - 1
        // 从最后开始往前冒泡,每次推都会排序所以只要停下来就算完成
        // 整个 data 是降序的
        while (data[a] > data[a - 1]) {
          const temp = data[a]
          data[a] = data[a - 1]
          data[a - 1] = temp
          a--
        }

        return data[data.length - 1]
    }

虽然已经优化成只需要排序 k 个,但执行时间成绩依然不好。

像这种第几大元素的可以用堆来解决。第 k 大就用最小堆,第 k 小就用最大堆。最小堆和最大堆也只是排序时是升序还是降序而已。上面其实已经有堆的意思了。

  1. 然后第二个优化的点是引入 搜索二叉树 的扁平数组版。首先先讲解一下这个数组版的实现。
    [a, b, c, d, e, f, g, h, i ,j, k,  l,  m]
     0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12
     
   上面是节点以及给他们标上号,转成二叉树是,每一个标记都可以自己做父节点,或者做别人的子节点。
                  a
             b          c
          d     e    f     g
       h   i  j  k  l  m  
       
                   0
             1           2
          3     4     5     6
       7   8  9  10 11 12

这个扁平数组其实是跟层序遍历一样,一层层的推入数组中形成的。二叉树的节点的下标规律是,等差数列吧。

             i
   2n*i+1       2n*i+2
   
   列举一下的话  父 左 右  父下标 左下标 右下标
   a b c 0 1 2
   b d e 1 3 4
   c f g 2 5 6
   d h i 3 7 8
   e j k 4 9 10
   f l m 5 11 12

当知道 父节点下标的时候 是满足上面的规律的。 但是知道左右子节点反推父节点的时候就不能用上面的规律了。 我们可以用 i >> 1, 取中间的下标的方法。和 Math.floor(i/2) 一样。但是需要注意的是算出来的结果是偏移了1位的,解决方法是(i - 1) >> 1 。下标再减1。

    a b c d e f
    0 1 2 3 4 5
    
    从子节点推算父节点时
    正常来说 应该 a b c 一组。
    但是 i >> 1 计算出来的是 b c d 一组 1, 2 >> 1 , 3 >> 1。
    
    所以先 -1 的话就对的上了。
    a    b          c
    0, (2-1) >> 1, (3-1) >> 1

之所以引入二叉树是为了优化计算,我们只需要知道第 k 大元素是什么就可以了,并不需要关系前面的 k - 1 个元素是怎么排序的。哪怕是 9, 10, 7, 5,也不影响第 4 大元素是 5。

二叉树之中顶上的根节点就是第 k 大元素,然后因为升序的关系,他的子节点都会比父节点大。表现在数组看是升序。

但是可能会有疑惑,二叉树怎么比较左节点和右节点,怎么进行冒泡。到底是怎么优化的。

接下来会结合代码和例子进行解释二叉树为什么能用。

我们首先初始化一个堆,然后把数据加入到堆里面。也是用上了add方法。

    function KthLargest(k, nums) {
        this.k = k
        this.heap = new Heap()
        for (let i = 0; i < nums.length; i++) {
          this.add(nums[i])
        }
    }
    
    KthLargest.prototype.add = function (val) {
        const { heap } = this
        heap.push(val) /* 升序排完,推入的时候会进行冒泡 */
        if (heap.size() > this.k) {
          heap.pop() /* 这里面把最后一个即最大数 覆盖掉第一个最小的数,然后冒泡 */
        }
        return heap.data[0] // 第一个就是第 k 大的数。
    }

接下来是关键的堆的构造函数

    // 先做些基础建设
    class Heap {
        constructor() {
            // 升序数组
            this.data = []
            this.compare = (a, b) => a - b
        }
        
        size() {
            return this.data.length
        }
        
        swap(n1, n2) {
            const { data } = this
            const temp = data[n1]
            data[n1] = data[n2]
            data[n2] = temp
        }
        // 推入堆中
        push(val) {}
        // 加入新的数 替换掉 第 k 大以外的
        pop() {}
        // 从 index 进行向上冒泡,一般是数组结尾
        bubblingUp(index) {}
        // 从 index 进行下沉, 一般是数组开头
        bubblingDown(index) {}
    }

向上冒泡的时候,普通版是一个个进行比较的冒泡,当引入二叉树的时候,就是和父节点进行比较,而不是一一比较,这时就会减少至少一半的比较。

只跟父节点进行比较会不会遗漏呢?答案是不会的。这颗树的特点是右节点的值会比左节点的值大,左右子节点都比父节点大。

冒泡时因为升序的关系,只要比父节点小,冒泡上去了,就一定比另外一个子节点小。

以 [9, 10, 7, 5] 为例。使用上面分析的父节点下标和子节点下标的关系。向上冒泡是根据子节点下标找父节点下标。下沉是根据父节点下标找子节点下标。

    bubblingUp(index) {
        const { data } = this
        // 只有两个以上才需要冒泡
        while(index > 0) {
            const parent = (index - 1) >> 1
            // 和 sort 规则一样 -1 进行交换
            if (this.compare(data[index], data[parent]) < 0) {
                this.swap(index, parent)
                // 一直冒泡
                index = parent
            } else {
                // 因为是升序,所以一旦停下来可以提前结束。
                break
            }
        }
    }

当数组里有一个的时候 [9], 推入 10。会计算 子节点下标: 1父节点下标: (1-1) >> 1 = 0, 比较 data[0] 和 data[1]。不用冒泡。

[9, 10], 推入 7。计算 子节点下标:2父节点下标: (2-1) >> 1 = 0, 比较 data[2] 和 data[0]。进行交换。新子节点下标 0 ,结束冒泡

[7, 10, 9], 推入 5。计算 子节点下标:3父节点坐标:(3-1) >> 1 = 1,比较 data[3] 和 data[1]。进行交换。 新子节点下标 1 ,继续冒泡

[7, 5, 9, 10], 这时候 子节点下标:1, 继续对 5 进行冒泡。父节点下标:0

最后得到的二叉树是

            5
         7     9
      10
      
      [5, 7, 9, 10]

然后下沉时每一层父节点都会和左右节点比较。这样比较确认每一个父子关系中,右节点都比左节点大。然后确保下沉时都会把左右节点里面最小的替换上去父节点

    bubblingDown(index) {
        const { data } = this
        while(true) {
            const left = index * 2 + 1
            const right = index * 2 + 2
            let parent = index
            // 比较左节点和父节点, 先比较的左节点,所以最后右节点会比左节点大
            if (this.compare(data[left], data[parent]) < 0) {
                parent = left
            }
            
            // 当左节点比父节点小时,parent = left,进行左右节点的比较。
            // 如果右节点比左节点小,则 parent = right,最后确认是 父节点和右节点交换。
            // 如果右节点比左节点大,则父节点和左节点交换。
            // 如果上面左节点的分支没有进去,左节点比父节点大,
            //    则比较右节点和父节点谁大,要不要交换。
            if (this.compare(data[right], data[parent]) < 0) {
                parent = right
            }
            
            if (index !== parent) {
                // 当比较了之后需要交换
                this.swap(index, parent)
                index = parent
            } else {
                // 不需要交换的时候就结束循环,不然就是死循环了,因为 index 不会改变
                break
            }
        }
    }

push 和 pop 就很简单了,只是引用冒泡和下沉的方法就行。

    push(val) {
        this.data.push(val)
        // 刚推进去的数的下标
        this.bubblingUp(this.size() - 1)
    }
    
    pop() {
        if (this.size() === 0) return
        const { data } = this
        // 第 k 大的数
        const discard = data[0]
        // 最大的数
        const newMember = data[pop]
        // 如果数组里面只有一个数,弹完就没了, discard 和 newMember 是同一个就等于没弹,所以要区别判断。
        if (this.size() > 0) {
            data[0] = newMember
            this.bubblingDown(0)
        }
        
        return discard //  体贴的返回了被踢出去的曾经第 k 大元素
    }

以上就是JavaScript模拟堆的优化代码,希望讲清楚了。

下面是这道题的完整代码。

    function KthLargest(k, nums) {
        this.k = k
        this.heap = new Heap()
        for (let i = 0; i < nums.length; i++) {
          this.add(nums[i])
        }
      }

      KthLargest.prototype.add = function (val) {
        const { heap } = this
        heap.push(val) /* 升序排完 */
        if (heap.size() > this.k) {
          heap.pop() /* 把最后一个即最大数 覆盖掉第一个最小的数,然后排序 */
        }

        return heap.data[0]
      }

      class Heap {
        constructor() {
          this.data = []
          this.compare = (a, b) => a - b
        }

        size() {
          return this.data.length
        }

        swap(n1, n2) {
          const { data } = this
          const temp = data[n1]
          data[n1] = data[n2]
          data[n2] = temp
        }

        push(val) {
          this.data.push(val)
          this.bubblingUp(this.size() - 1)
        }

        pop() {
          if (this.size() === 0) return null
          const { data } = this
          const discard = data[0]
          const newMember = data.pop()
          if (this.size() > 0) {
            data[0] = newMember
            this.bubblingDown(0)
          }

          return discard /* 被踢掉的曾经堆内最小节点 */
        }

        bubblingUp(index) {
          /* 冒泡,把最小的冒到第一个,是按二叉树的父子关系,完全升序 */
          while (index > 0) {
            /* 一层层上去最后会聚焦到 0 */
            /* 下标再减 1 这样就对的上二叉树的父子节点 */
            const parent = (index - 1) >> 1
            const { data } = this
            if (this.compare(data[index], data[parent]) < 0) {
              this.swap(parent, index)
              index = parent
            } else {
              break
            }
          }
        }

        bubblingDown(index) {
          /* 把最大的置换下去,按照二叉树的分布,放到叶子节点的下标,不是完全升序 */
          const { data } = this
          const last = this.size() - 1
          while (true) {
            /* 用二叉树而且不用保证升序的原因是只要第k大,并不关心顺序,二叉树的根节点就是第k大的数 */
            const left = index * 2 + 1
            const right = index * 2 + 2
            let parent = index
            if (left <= last && this.compare(data[left], data[parent]) < 0) {
              parent = left
            }
            /* 在左右子节点中更小的那个,给挤上去 */
            if (right <= last && this.compare(data[right], data[parent]) < 0) {
              parent = right
            }
            if (index !== parent) {
              this.swap(index, parent)
              index = parent
            } else {
              break
            }
          }
        }
      }