在问 · 算法题解

480. 滑动窗口中位数

困难滑动窗口有序数组 + 二分插入删除力扣原题 ↗

题目描述

输入整数数组 nums 和窗口长度 k。一个长度为 k 的窗口从数组最左侧开始,每次向右挪动一格,直到窗口右端到达数组末尾。

对每个窗口求其中 k 个数的中位数:把这些数排好序,k 为奇数时取正中间那个;k 为偶数时取中间两个数的平均值,例如 [2,3] 的中位数是 2.5。

按窗口从左到右的顺序,返回所有中位数组成的数组。与标准答案相差在 10^-5 以内即视为正确。

示例 1
输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[1.0,-1.0,-1.0,3.0,5.0,6.0]

解释:六个窗口依次为 [1,3,-1]、[3,-1,-3]、[-1,-3,5]、[-3,5,3]、[5,3,6]、[3,6,7],中位数分别是 1、-1、-1、3、5、6。

示例 2
输入:nums = [1,2,3,4,2,3,1,4,2], k = 3
输出:[2.0,3.0,3.0,3.0,2.0,3.0,2.0]

解释:七个窗口排序后的中间值依次为 2、3、3、3、2、3、2。

数据范围

  • k 一定合法,即不超过非空数组 nums 的长度。
  • 与真实值误差不超过 10^-5 的答案都算正确。

解题思路:有序数组 + 二分插入删除

维护一个始终有序的窗口数组 window。中位数直接读 window[k//2](偶数时再加上 window[k//2-1] 取平均)。窗口右移时,用二分查找找到离开的数 nums[i-k] 并删除,再用二分查找把新数 nums[i] 插入合适位置。这样不必每次重新排序。

为什么正确

每轮开始时,window 是 nums[i-k .. i-1] 这 k 个数的升序排列。

复杂度分析

时间 O(n·k):二分查找 O(log k),但删除和插入要挪动元素 O(k);空间 O(k)。内存搬移很快,实际可以通过。

易错点

k 为偶数时两个数相加可能超出 32 位整数范围(如 2147483647 + 2147483647),C++/Java 要先转成 long long 或 double 再相加。删除时要删除一个等于 nums[i-k] 的元素,而不是把所有等值元素都删掉。

参考代码

Python、Java、C++、Go、JavaScript、TypeScript 六种语言,逻辑一致。

class Solution:
    def medianSlidingWindow(self, nums: List[int], k: int) -> List[float]:
        window = sorted(nums[:k])
        ans = []
        for i in range(k, len(nums) + 1):
            if k % 2:
                ans.append(float(window[k // 2]))
            else:
                ans.append((window[k // 2 - 1] + window[k // 2]) / 2)
            if i == len(nums):
                break
            window.pop(bisect_left(window, nums[i - k]))
            insort(window, nums[i])
        return ans

载入编辑器,自己跑一遍 →

其他解法

有序数组写法直观,但每次插入删除要移动 O(k) 个元素;双堆加延迟删除把每次操作降到 O(log n),是“对顶堆”求动态中位数的经典模板。

解法 2:双优先队列 + 延迟删除 时间 O(n log n),空间 O(n)

用大根堆 small 保存窗口中较小的一半,小根堆 large 保存较大的一半,并让 small 的有效元素比 large 多 0 或 1 个,中位数取自两个堆顶。堆不支持删除任意元素,所以离开窗口的数只记入哈希表 delayed,并调整对应堆的有效计数。每当它出现在堆顶时再真正弹出。每次插入或删除后都重新平衡两个堆。

每次操作是对数级,窗口很大时远快于有序数组的 O(k) 移位。延迟删除要同时维护有效计数和堆顶,细节多,容易写错。

class Solution:
    def medianSlidingWindow(self, nums: List[int], k: int) -> List[float]:
        small, large = [], []   # small:较小一半(大根堆,存相反数);large:较大一半(小根堆)
        delayed = collections.Counter()  # 待删除的数及次数(延迟删除)
        size = [0, 0]           # 两个堆中有效元素的个数

        def prune(h, sign):
            # 弹出堆顶所有已标记删除的元素,保证堆顶有效
            while h and delayed[sign * h[0]]:
                delayed[sign * h[0]] -= 1
                heapq.heappop(h)

        def balance():
            # 保持 small 比 large 多 0 或 1 个有效元素
            if size[0] > size[1] + 1:
                heapq.heappush(large, -heapq.heappop(small))
                size[0] -= 1
                size[1] += 1
                prune(small, -1)
            elif size[0] < size[1]:
                heapq.heappush(small, -heapq.heappop(large))
                size[0] += 1
                size[1] -= 1
                prune(large, 1)

        def insert(x):
            if not small or x <= -small[0]:
                heapq.heappush(small, -x)
                size[0] += 1
            else:
                heapq.heappush(large, x)
                size[1] += 1
            balance()

        def erase(x):
            delayed[x] += 1
            if x <= -small[0]:
                size[0] -= 1
                if x == -small[0]:
                    prune(small, -1)
            else:
                size[1] -= 1
                if x == large[0]:
                    prune(large, 1)
            balance()

        def median():
            return float(-small[0]) if k % 2 else (-small[0] + large[0]) / 2

        for x in nums[:k]:
            insert(x)
        ans = [median()]
        for i in range(k, len(nums)):
            insert(nums[i])
            erase(nums[i - k])
            ans.append(median())
        return ans

滑动窗口 专题的其他题目

查看 滑动窗口 全部题目 →