数据结构拾遗 - 堆(原理与实现)

由于在做减面算法时需要用到优先队列,忘记优先队列的实现了,因此补充一下有关的知识点。

完全二叉树

理解堆的实现前需要先了解完全二叉树。完全二叉树满足以下性质:

  • 层级顺序完全填满:除了最后一层外其他层完全填满,也就是说除了最后一层,前面的层不存在空节点。

  • 最后一层尽可能靠左排列:最后一层从左到右紧密排列,不存在连续的空节点。

当一颗完全二叉树增加一个节点时,有可能发生两种情况:

  • 如果父节点已经有了一个叶子结点,那么该节点挂到另一半成为叶子节点,没有节点性质发生改变

  • 如果所有父节点的叶子节点均已满,那么会选择一个叶子节点变成父节点,新增节点成为叶子节点

核心观察:由于满足以上性质,完全二叉树中叶子节点始终比非叶子结点多 0 个或 1 个

因此可以直接用数组进行存储整棵树,并通过元素下标运算实现访问父节点、子节点的操作。

完全二叉树的元素索引

当新增一个节点 C,将一个 P 节点从叶子节点变成父节点时,P 点会是最新且是最后一个父节点,如果 P 点的索引为 i i i,那么从 0 ∼ i 0 \sim i 0i 均为父节点,第 i + 1 i + 1 i+1 及以后得节点均为叶子节点,由于叶子节点数量始终比非叶子结点数量多 0 或 1 个,因此此时叶子节点数量同样为 i + 1 i+1 i+1 个(数量 = 索引+1),因此 C 点的索引为 ( i + 1 ) × 2 − 1 = 2 i + 1 (i+1) \times2 -1=2i+1 (i+1)×21=2i+1,且这里 C 点为左节点(因为只有新增左节点才会产生节点性质的变化)。

所以我们可以访问直接采用元素索引的方式子节点,父节点的访问方式可以反算出来:

  • 左子节点索引: 2 i + 1 2i+1 2i+1

  • 右子节点索引: 2 i + 2 2i+2 2i+2

  • 父节点索引: ⌊ ( i − 1 ) / 2 ⌋ \left\lfloor(i-1)/2\right\rfloor (i1)/2(向下取整)

同样根据核心观察,可以知道如果总节点数为 n n n,那么父节点总数为 ⌊ n / 2 ⌋ \left\lfloor{n/2}\right\rfloor n/2,因此:

  • 最后一个非叶子节点索引: ⌊ n / 2 ⌋ − 1 \left\lfloor{n/2}\right\rfloor-1 n/21

  • 叶子结点索引范围: ⌊ n / 2 ⌋ ∼ n − 1 \left\lfloor{n/2}\right\rfloor\sim n-1 n/2n1

另外完全二叉树还有一个高度属性,这个比较好理解,就不过多赘述

  • 高度: ⌊ l o g 2 ( n ) + 1 ⌋ \left\lfloor{log_2(n)+1}\right\rfloor log2(n)+1

堆和队列

在理解堆之前,首先需要理解队列是什么,通常而言队列是由于处理器资源限制,需要将等待处理的任务进行排队,依次处理任务。像食堂排队打饭就是由于打饭阿姨数量少于吃饭的人,因此需要吃饭的人在后面排队。队列是一种缓解资源紧张的设计思想,并不是一种特定的数据形式(比如数组、列表)。一般社会规则遵循先来后到,这种队列就是先入先出队列,除此之外还可以设计先入后出队列(也就是栈,后入队的元素优先出队),以及基于特定优先级判定规则决定的队列(定义为堆,优先级更高的元素优先出队)。每种队列拥有不同的适用场合,但不管在什么场合下,队列要解决的问题是不变的:

  • 存储一定数量的元素,因此队列拥有一个属性 size

  • 支持有新的元素进来排队,因此队列有一个方法 Push

  • 获取下一个待处理的元素,因此队列有一个方法 Pop

堆通常也称为优先队列,与先入先出、先入后出的优先队列不同,优先队列根据元素本身的优先级进行排列,可以根据我们给元素定义的优先级实现快速的入队、出队操作。

堆的实现

在完全二叉树的基础上,如果满足任意父节点的优先级都始终大于等于其子节点的优先级,这颗完全二叉树就是堆。

核心观察:在堆中,上层节点的值优先级始终大于下一层的任意节点,而相同层的节点值之间优先级不固定。且在新增、删除元素之后,这个性质也不发生变化。

为了实现始终保持这个性质,算法人员想出了一个方法,通过 ShiftUp 操作子节点,通过 ShitfDown操作父节点,让每个父节点-子节点的节点对都满足上述性质,这样就能在队列新增、删除元素后进行快速调整。

ShiftDown 核心思想在于递归(虽然实现上是循环,但思想是递归)地处理父节点,先拿出子节点中优先级最高的节点,将其和父节点的优先级进行比较,如果父节点优先级更低,则说明父节点该让位于子节点,交换两者。在成为子节点后,再递归地判断它是否需要让位于新的子节点,直到它满足优先级高于它的所有子节点。

 private void ShiftDown(int index)
    {
        while (true)
        {
            if (!IsIndexValid(index)) return;

            var value = _array[index];
            var leftChildIndex = index * 2 + 1;
            var rightChildIndex = leftChildIndex + 1;
            if (!IsIndexValid(leftChildIndex) && !IsIndexValid(rightChildIndex)) return;

            var childIndex = leftChildIndex;
            var childValue = _array[leftChildIndex];
            if (IsIndexValid(rightChildIndex))
            {
                var rightChildValue = _array[rightChildIndex];
                if (rightChildValue > childValue)
                {
                    childIndex = rightChildIndex;
                    childValue = rightChildValue;
                }
            }

            if (childValue <= value) return;

            _array[index] = childValue;
            _array[childIndex] = value;
            index = childIndex;
        }
    }

ShiftUp 用于处理子节点,并递归向上,让整棵树保持堆的性质。

    private void ShiftUp(int index)
    {
        while (true)
        {
            if (!IsIndexValid(index)) return;

            var value = _array[index];
            var parentIndex = (index - 1) / 2;
            if (!IsIndexValid(parentIndex)) return;

            var parentValue = _array[parentIndex];
            if (parentValue >= value) return;

            _array[index] = parentValue;
            _array[parentIndex] = value;
            index = parentIndex;
        }
    }

在有了这两个操作之后,就可以在构建堆、新增元素、删除元素时,方便地放置元素到合理的位置。

  • 构造

当用数组构造堆时,思路是对所有父节点,从后往前做一遍 ShiftDown操作,便可以让整棵树保持堆的性质。

    public MyHeap(int[] array)
    {
        size = array.Length;
        for (var i = 0; i < array.Length; i++)
            _array[i] = array[i];
        for (var i = size/2 - 1; i >= 0; i--)
            ShiftDown(i);
    }
  • Push

在往队列中增加元素时,将新增元素直接放置在最后一个叶子节点上,并对其做 ShiftUp,即可将该元素插入到合适的位置

    public void Push(int value)
    {
        _array[size] = value;
        size++;
        ShiftUp(size - 1);
    }
  • Pop

在从队列中获取优先级最高的元素时,此时第一个父节点便是优先级最高的节点,因此可以直接返回父节点的值。在移出父节点后,将最后一个叶子节点放到第一个父节点位置,并对其做 ShiftDown即可重新让树保持堆的性质。

    public int Pop()
    {
        var value = _array[0];
        _array[0] = _array[size - 1];
        size--;
        ShiftDown(0);
        return value;
    }

总结

以上就是关于堆的理解,要实现堆,首先需要了解堆是用来做什么的(队列的概念)。然后在实现堆的过程中为什么可以采用数组来存储堆中的元素(完全二叉树的概念),以及如何让完全二叉树实现堆(ShiftDownShiftUp)。最后通过堆来实现元素的入队(Push)、出队(Pop),以此实现优先级队列。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值