滑动窗口—中值问题

题目一:295. 数据流的中位数

问题描述

中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。例如,[2,3,4] 的中位数是 3、[2,3] 的中位数是 (2 + 3) / 2 = 2.5。设计一个支持以下两种操作的数据结构:

  • void addNum(int num) - 从数据流中添加一个整数到数据结构中。
  • double findMedian() - 返回目前所有元素的中位数。

示例:

  • addNum(1)
  • addNum(2)
  • findMedian() -> 1.5
  • addNum(3) 
  • findMedian() -> 2

进阶:

  • 如果数据流中所有整数都在 0 到 100 范围内,你将如何优化你的算法?
  • 如果数据流中 99% 的整数都在 0 到 100 范围内,你将如何优化你的算法?

算法思路

由于奇数长度和偶数长度的中位数定义不同,判断「从数据流中读出的数的个数的奇偶性」很重要,这是因为长度的奇偶性决定了中位数的个数。当从数据流中读出的数的个数为奇数的时候,中位数只有 1 个;当从数据流中读出的数的个数为偶数的时候,中位数有 2 个。我们不妨分别称它们为「左中位数」和「右中位数」。

image.png

如果数据流中每读出 1 个数后都排一次序,「中位数」就位于这些数的「中间」。「中位数」把它们分为两个部分,一部分是「前有序数组」,另一部分是「后有序数组」。我们发现如下事实:

  • 当从数据流中读出的数的个数为奇数的时候,中位数是「前有序数组」中的最大值,如下左图所示;
  • 当从数据流中读出的数的个数为偶数的时候,左中位数是「前有序数组」中的最大值,右中位数是「后有序数组」中的最小值,如下右图所示。

image.png

由于我们只关心这两个 有序数组 中的最值,有一个数据结构可以帮助我们快速找到这个最值,这就是 优先队列 。具体来说:

  • 前有序数组由于只关注最大值,可以 动态地 放置在一个最大堆中;
  • 后有序数组由于只关注最小值,可以 动态地 放置在一个最小堆中。
  • 当从数据流中读出的数的个数为偶数的时候,我们想办法让两个堆中的元素个数相等,两个堆顶元素的平均值就是所求的中位数(如下左图);
  • 当从数据流中读出的数的个数为奇数的时候,我们想办法让最大堆的元素个数永远比最小堆的元素个数多 11 个,那么最大堆的堆顶元素就是所求的中位数(如下右图)。

image.png

为了得到所求的中位数,在任何时刻,两个堆应该始终保持的性质如下:

  • 最大堆的堆顶元素,小于或者等于最小堆的堆顶元素;
  • 最大堆的元素个数或者与最小堆的元素个数相等,或者多 11 。

具体可以进行如下操作:

  • 情况 1: 当两个堆的元素个数之和为偶数(例如一开始的时候),为了让最大堆中多 1 个元素,采用这样的流程:「最大堆 → 最小堆 → 最大堆」;
  • 情况 2: 当两个堆的元素个数之和为奇数,此时最小堆必须多 11 个元素,这样最大堆和最小堆的元素个数才相等,采用这样的流程:「最大堆 → 最小堆」 即可。

因此无论两个堆的元素个数之和是奇数或者是偶数,都得先「最大堆」 再「最小堆」 ,而当加入一个元素之后,元素个数为奇数的时候,再把最小堆的堆顶元素拿给最大堆就可以了。将元素放入优先队列以后,优先队列会自行调整(以对数时间复杂度),把最优值放入堆顶,是这道问题思路的关键

总结:

脑子里建立如下动态的过程:为了找到添加新数据以后,数据流的中位数,我们让新数据在最大堆和最小堆中都走了一遍。而为了让最大堆的元素多 1 个,我们让从最小堆中又拿出堆顶元素「送回」给最大堆;将元素放入优先队列以后,优先队列会以对数时间复杂度自行调整,把「最优值」调整到堆顶,这是使用优先队列解决这个问题的原因。如果不太熟悉优先队列的朋友们,请复习一下优先队列的相关知识,包括基本操作、理解上浮和下沉。这道题使用 Java 编码看起来思路更清晰一些,在 Python 中的堆只有最小堆,在构造最大堆的时候,要绕一个弯子,具体请看如下参考代码。

上述文字出自:https://leetcode-cn.com/problems/find-median-from-data-stream/solution/you-xian-dui-lie-python-dai-ma-java-dai-ma-by-liwe/

参考代码:

class MedianFinder {

    int count;
    PriorityQueue<Integer> maxHeap;
    PriorityQueue<Integer> minHeap;
    /** initialize your data structure here. */
    public MedianFinder() {
        count = 0;
        maxHeap = new PriorityQueue<>((x, y) -> y - x);
        minHeap = new PriorityQueue<>();
    }
    
    public void addNum(int num) {
        count += 1;
        maxHeap.offer(num);
        minHeap.offer(maxHeap.poll());
        if(count%2!=0)
            maxHeap.offer(minHeap.poll());
    }
    
    public double findMedian() {
        if(count%2!=0)
            return (double)maxHeap.peek();
        else
            return (double)(maxHeap.peek()+minHeap.peek())/2;
    }
}

/**
 * Your MedianFinder object will be instantiated and called as such:
 * MedianFinder obj = new MedianFinder();
 * obj.addNum(num);
 * double param_2 = obj.findMedian();
 */

复杂度分析:

时间复杂度:O(logN),优先队列的出队入队操作都是对数级别的,数据在两个堆中间来回操作是常数级别的,综上时间复杂度是 O(logN) 级别的;
空间复杂度:O(N),使用了三个辅助空间,其中两个堆的空间复杂度是O( 2N),一个表示数据流元素个数的计数器 count,占用空间 O(1),综上空间复杂度为 O(N)。

题目二:480. 滑动窗口中位数

问题描述

给你一个数组 nums,有一个长度为 k 的窗口从最左端滑动到最右端。窗口中有 k 个数,每次窗口向右移动 1 位。你的任务是找出每次窗口移动后得到的新窗口中元素的中位数,并输出由它们组成的数组。

示例:

给出 nums = [1,3,-1,-3,5,3,6,7],以及 k = 3,返回该滑动窗口的中位数数组 [1,-1,-1,3,5,6]。

窗口位置                      中位数

---------------               -----

[1  3  -1] -3  5  3  6  7       1

 1 [3  -1  -3] 5  3  6  7      -1

 1  3 [-1  -3  5] 3  6  7      -1

 1  3  -1 [-3  5  3] 6  7       3

 1  3  -1  -3 [5  3  6] 7       5

 1  3  -1  -3  5 [3  6  7]      6

算法思路

本题考查动态维护数组的中位数。我们思考中位数的性质:如果一个数是中位数,那么在这个数组中,大于中位数的数目和小于中位数的数目,要么相等,要么就相差一。因此,我们采用对顶堆的做法,控制所有小于等于的数字放到一个堆中,控制所有比中位数大的数字放到另一个堆中,并且保证两个堆的数目相差小于等于1。这样就可以保证每一次查询中位数的时候,答案一定出于两个堆的堆顶元素之一。因此选定数据结构:优先队列。因为优先队列采用的是堆结构,正好符合我们的需求。我们将所有小于等于中位数的元素放到大顶堆,将所有大于中位数的元素放到小顶堆。

1)初始化方法如下:

  • 将前K个元素全部插入到大顶堆中。从大顶堆中弹出K/2个元素到小顶堆中。
  • 这样,当K为奇数,则大顶堆元素比小顶堆元素多1;当K为偶数,两个堆元素相等。

2)取中位数的操作:

  • 我们的插入操作可以保证每次优先插入到大顶堆中,因此大顶堆中的元素个数大于等于小顶堆的元素个数。
  • 当K为奇数时候,中位数是元素数量较多的大顶堆 堆顶元素。
  • 当K为偶数时候,中位数是大顶堆和小顶堆的堆顶元素平均值。

3)窗口滑动过程中的操作:

  • 假定在上一次滑动之后,已经有大顶堆和小顶堆元素数目相差小于等于1.
  • 设置当前的滑动时,balance = 0。balance表示大顶堆元素数目减去小顶堆元素个数。
  • 删除窗口左侧的元素:由于堆无法直接删除掉某个指定元素,先欠下这个账,等某次元素出现在堆顶的时候,再删除他。mp记录这个元素欠账的个数。mp[left]++;虽然没有真的在堆数据结构中删除窗口最左侧的元素,但是在我们的心中已经删掉他了。堆两侧的平衡性发生了变化。如果left<=大顶堆.top(),就说明删掉的元素在大顶堆中,我们让balance--;否则,就说明删掉的元素在小顶堆中,让balance++;
  • 添加进来窗口右侧的元素。如果right<=大顶堆.top(),就应该让这个元素放到samll堆里面,balance++;否则放到小顶堆里,balance--。
  • 经过上面的操作,balance要么为0,要么为2,要么为-2。我们需要经过调整使得balance为0。
  • 如果balance为0,在这次窗口滑动之前已经是平衡的,这次调整也没有让两堆的数目变化,所以不用调整两边的堆。
  • 如果balance为2,就说明small堆的元素比big堆的元素多了两个。从small堆减少一个,big堆里增加一个,就可以让两边相等。big.push(small.top());small.pop();
  • 如果balance为-2,就说明big堆的元素比small堆的元素多了两个。从big堆减少一个,small堆里增加一个,就可以让两边相等。small.push(big.top());big.pop();
  • 调整完了,现在该欠债还钱了。不能让那些早该删除的元素涉及到中位数的运算。
  • 分别检查两边的堆顶元素,如果堆顶元素欠着债,则弹出堆顶元素,直到堆顶元素没有欠债为止。有朋友问了:堆顶下面也有欠债的怎么办呢?我们之前说过,取中位数的时候只与堆顶元素有关,至于那些堆顶下面欠着债的,欠着就欠着吧,等他们到堆顶的时候再弹出去就好了。
  • 最后,添加中位数即可。

上段文字出自:https://leetcode-cn.com/problems/sliding-window-median/solution/feng-xian-dui-chong-shuang-dui-dui-ding-hq1dt/

参考代码:

class Solution {
    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;
    // small 和 large 当前包含的元素个数,需要扣除被「延迟删除」的元素
    private int smallSize, largeSize;

    public DualHeap(int k) {
        this.small = new PriorityQueue<Integer>(new Comparator<Integer>() {
            public int compare(Integer num1, Integer num2) {
                return num2.compareTo(num1);
            }
        });
        this.large = new PriorityQueue<Integer>(new Comparator<Integer>() {
            public int compare(Integer num1, Integer num2) {
                return num1.compareTo(num2);
            }
        });
        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;
            // small 堆顶元素被移除,需要进行 prune
            prune(small);
        } else if (smallSize < largeSize) {
            // large 比 small 元素多 1 个
            small.offer(large.poll());
            ++smallSize;
            --largeSize;
            // large 堆顶元素被移除,需要进行 prune
            prune(large);
        }
    }
}
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值