滑动窗口中位数(优先队列 + 延迟删除)


  

引言:

LeetCode中遇到的一道题,记录一下。
转自 求滑动窗口中的中位数

问题定义

  中位数是有序序列最中间的那个数。如果序列的长度是偶数,则没有最中间的数;此时中位数是最中间的两个数的平均数。给你一个数组 nums,有一个长度为 k 的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1 位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。(可以假设 k 始终有效,即:k 始终小于输入的非空数组的元素个数。)
示例

双优先队列 + 延迟删除

思路与算法:

我们首先思考一下完成本题需要做哪些事情:

  • 初始时,我们需要将数组 nums 中的前 k 个元素放入一个滑动窗口中,并且求出它们的中位数。
  • 随着滑动窗口会向右进行移动。每一次移动后,会将一个新的元素放入滑动窗口中,并且将一个旧的元素移除滑动窗口,最后在求出它们的中位数。

因此,我们需要设计一个「数据结构」,用来维护滑动窗口,并且需要提供如下的三个接口:

  • insert(num):将一个数 num 加入该数据结构中
  • erase(num):将一个数 num 移除该数据结构
  • getMedian():返回当前数据结构中所有数的中位数

  我们可以使用两个优先队列(堆)维护窗口内的所有元素,第一个优先队列 small 是一个大根对堆,它负责维护所有元素中较小的那一半;第二个优先队列 large 是一个小根堆,它负责维护所有元素中较大的那一半。具体地,如果当前需要维护的元素个数为 x,那么 samll 中维护了 ⌈x/2⌉ 个元素,large 中维护了 ⌊x/2⌋ 个元素,其中⌈y⌉和⌊y⌋分别表示将 y 向上取整和向下取整。也就是说:samll 中的元素个数要么与 large 中的元素个数相同,要么比 large 中的元素个数恰好多 1 个。这样设计的好处在于:当二者包含的元素个数相同时,它们各自的堆顶元素的平均值即为中位数;而当 small 包含的元素多了一个时,small 的堆顶元素即为中位数。这样 getMedian() 就设计完成了。
  而对于 insert(num) 而言,如果当前两个优先队列都为空,那么根据元素个数的要求,我们必须将这个元素加入 small(显然不会存在 samll 空而 large 非空的情况);如果 samll 非空,我们就可以将 num 与 samll 的堆顶元素top比较:

  • 如果 num ≤ top,我们就将其加入 samll 中;
  • 如果 num > top,我们就将其加入 large 中。

  在成功地加入元素 num 之后,两个优先队列的元素个数可能会变得不符合要求。由于我们每次只是加入了一个元素,那么不符合要求的情况只能是下面二者之一:① small比large的元素个数多了2个;② small比large的元素个数少了1个。对于第一种情况,我们将 small 的堆顶元素放入 large 中;而对于第二种情况,我们将 large 的堆顶元素放入 samll 中。这样就可以解决问题了,insert(num)也就设计完成了。

  然而对于 erase(num) 而言,设计起来就不是那么容易了,因为我们知道优先队列是不支持移除非堆顶元素这一操作的,因此我们可以考虑使用「延迟删除」这一技巧,即:

  当我们需要移除优先队列中的某个元素时,我们只将这个删除操作「记录」下来,而不去真的删除这个元素。当这个元素出现在 small 或者 large 的堆顶时,我们再去将其移除对应的优先队列。

  「延迟删除」使用到的辅助数据结构一般为哈希表 delayed,其中的每个键值对 <num, freq>表示元素 num 还需要被删除 freq 次。「优先队列 + 延迟删除」其实有非常多的设计方式,体现在「延迟删除」的时机选择上。在本解题中,我们使用一种比较容易编写代码的设计方式,即:

  我们保证在任意操作 insert(num),erase(num),getMedian()完成之后(或者说任意操作开始之前),small 和 large 的堆顶元素都是不需要被「延迟删除」的。这样设计的好处在于:我们无需更改 getMedian()的设计,只需要略加修改 insert(num) 即可。

  我们首先设计一个辅助函数 prune(heap),它的作用很简单,就是对 heap 这个优先队列(small或者large之一),不断地弹出其需要被删除的堆顶元素,并且减少 delayed 中对应项的值。在 prune(heap) 完成之后,我们就可以保证 heap 的堆顶元素是不需要被「延迟删除」的
  这样我们就可以在 prune(heap) 的基础上设计出另一个辅助函数 makeBalance(),它的作用即为调整 small 和 large 中元素个数,使得二者元素满足要求。由于有了 erase(num) 以及「延迟删除」,我们在将一个优先队列的堆顶元素放入另一个优先队列后,第一个优先队列的堆顶元素可能变成为需要被删除的。因此我们就可以用 makeBalance() 将 prune(heap) 封装起来,它的逻辑如下:

  • 如果 small 和 large 中的元素个数满足要求,则不进行任何操作;
  • 如果 small 比 lareg 的元素个数多了 2 个,那么我们就将 small 的堆顶元素取出来放入 lareg 中。此时,small 的堆顶元素可能是需要被删除的,因此我们要调用 prune(small);
  • 如果 small 比 large 的元素个数少了 1 个,那么我们就将 large 的堆顶元素取出来放入 small 中。此时,large 的堆顶元素可能是需要被删除的,因此我们要调用 prune(large)。

  此时,我们只需要在原先 insert(num) 的设计的最后加上一步 makeBalance()即可。然而对于 erase(num),我们还需要进行进一步的思考:

  • 如果 num 与 small 和 large 的堆顶元素都不相同,那么 num 是需要被「延迟删除」的,我们将其在哈希表中的值增加 1;
  • 否则,例如 num 与 small 的堆顶元素相同,那么该元素是可以被立即删除的。虽然我们并没有实现「立即删除」这个辅助函数,但只要我们将 num 在哈希表中的值增加 1,并且调用「延迟删除」的辅助函数 prune(small),那么就相当于实现了「立即删除」的功能。

  无论是「立即删除」还是「延迟删除」,其中一个优先队列的元素个数发生了变化(减少了1),因此我们还需要用 makeBalance() 调整元素的个数。
  此时,所有的接口都已经设计完了。由于 insert(num) 和 erase(num) 的最后一步都是 makeBalance(),而 makeBalance() 的最后一步是 prune(heap),因此我们就保证了任意操作完成之后,small 和 large 的堆顶元素都是不需要被「延迟删除」的。

参考代码(java版)

public class LeetCodeTest {
    @Test
    public void  test() {
        int[] nums = {1, 3, -1, -3, 5, 3, 6, 7};
        int k = 3;
        double[] ans = medianSlidingWindow(nums, k);
        System.out.println(Arrays.toString(ans));
    }

    public double[] medianSlidingWindow(int[] nums, int k) {
        DualHeap dh = new DualHeap(k);
        for (int i = 0; i < k; ++i) {
            dh.insert(nums[i]);
        }
        double[] ans = new double[nums.length - k + 1];
        ans[0] = dh.getMedian();

        for (int i = k; i < nums.length; ++i) {
            dh.insert(nums[i]);
            dh.erase(nums[i - k]);
            ans[i - k + 1] = dh.getMedian();
        }
        return ans;
    }
}

class DualHeap {
    // 大根堆,维护窗口中较小的一半元素(堆顶元素为最大值)
    private PriorityQueue<Integer> small;
    // 小根堆,维护窗口中较大的一半元素(堆顶元素为最小值)
    private PriorityQueue<Integer> large;
    // 哈希表,记录「延迟删除」的元素:key为元素,value为需要删除的次数
    private Map<Integer, Integer> delayed;
    // 窗口大小
    private int k;
    // samll 和 large 当前包含的有效元素个数,需要扣除被「延迟删除」的元素
    private int smallSize, largeSize;

    public DualHeap(int k) {
        // 初始化 大根堆
        this.small = new PriorityQueue<Integer>(new Comparator<Integer>(){
            @Override
            public int compare(Integer o1, Integer o2) {
                return o2.compareTo(o1);
            }
        });

        // 初始化 小根堆
        this.large = new PriorityQueue<Integer>(Integer::compare);

        this.delayed = new HashMap<Integer, Integer>();
        this.k = k;
        this.smallSize = 0;
        this.largeSize = 0;
    }

    public double getMedian() {
        return (k & 1) == 1 ? small.peek() : ((double) small.peek() + large.peek()) / 2;
    }

    public void insert(int num) {
        if (small.isEmpty() || num <= small.peek()) {
            small.offer(num);
            ++smallSize;
        } else {
            large.offer(num);
            ++largeSize;
        }
        makeBalance();
    }

    public void erase(int num) {
        delayed.put(num, delayed.getOrDefault(num, 0) + 1);
        if (num <= small.peek()) {
            --smallSize;
            if (num == small.peek()) {
                prune(small);
            }
        } else {
            --largeSize;
            if (num == large.peek()) {
                prune(large);
            }
        }
        makeBalance();
    }

    // 不断地弹出 heap 的堆顶元素,并且更新哈希表
    private void prune(PriorityQueue<Integer> heap) {
        while (!heap.isEmpty()) {
            int num = heap.peek();
            if (delayed.containsKey(num)) {
                delayed.put(num, delayed.get(num) - 1);
                if (delayed.get(num) == 0) {
                    delayed.remove(num);
                }
                heap.poll();
            } else {
                break;
            }
        }
    }

    // 调整 small 和 large 中的元素个数,使得二者的元素个数满足要求
    private void makeBalance() {
        if (smallSize > largeSize + 1) {
            //small 比 large 元素多 2 个
            large.offer(small.poll());
            --smallSize;
            ++largeSize;
            prune(small);//small堆顶元素被移除,需要进行 prune,保证堆顶新元素已不再是需要被「延迟删除」的元素
        } else if (smallSize < largeSize) {
            //large 比 small 元素多 1 个
            small.offer(large.poll());
            --largeSize;
            ++smallSize;
            prune(large);//large堆顶元素被移除,需要进行 prune,保证堆顶新元素已不再是需要被「延迟删除」的元素
        }
    }
}

复杂度分析

  由于「延迟删除」的存在,small 比 large 在最坏情况下可能包含所有的 n 个元素,即没有一个元素被真正删除了。因此优先队列的大小是 O(n) 而不是 O(k) 的,其中 n 是数组 nums 的长度。

  • 时间复杂度:O(nlog(n))。insert(num) 和 erase(num) 的单次时间复杂度为 O(log(n)),getMedian() 的单次时间复杂度为 O(1)。因此总时间复杂度为 O(nlog(n))。
  • 空间复杂度:O(n)。即为 small,large 和 delayed 需要使用的空间。

文章作者: YangChongZhi
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 YangChongZhi !
评论
 上一篇
滑动窗口的最大值 滑动窗口的最大值
   引言: LeetCode中遇到的一道题,记录一下。转自 求滑动窗口的最大值 问题定义  给定一个数组 nums 和滑动窗口的大小 k,要求找出所有滑动窗口中的最大值。(可以假设 k 总是有效的,在输入数组不为空的情况下,1 ≤ k
2021-02-04
下一篇 
约瑟夫环问题解释 约瑟夫环问题解释
   引言: 约瑟夫问题(有时也称为约瑟夫斯置换,是一个计算机科学和数学中的问题。在计算机编程的算法中,类似问题又称为约瑟夫环。又称“丢手绢问题”.) 约瑟夫问题 N个人围成一圈,第一个人从1开始报数,报M的将被杀掉,下一个人接着从1开始
2021-02-01
  目录