题目描述

✅ 面试题 17.20. 连续中值

image-20260929010608396

题意分析

设计结构,支持不断加入整数并查询当前中位数。总数为奇数时取有序排列的中间一个数,偶数时取中间两个数的平均值;中位数查询针对已经有数据的状态。

解法:双堆维护有序的左右两半

核心思路

[!blue]

中位数只依赖有序排列的中央边界,不必每次重新排序全部数据。用 small 保存较小一半、large 保存较大一半,并维持两个条件:较小半区的所有值不大于较大半区的所有值;small 的元素数与 large 相同,或恰好多一个。这样中间位置一定就在堆顶边界上。

两个堆都按最小堆实现,但 small 保存原值的相反数,因此它的最小值还原符号后,正是较小半区的最大值;large 直接保存原值,堆顶就是较大半区的最小值。

插入时先把新值加入 small,再把其中最大的原值移到 large。这会保留两半的大小关系:若新值很大,它自己被移到右侧;否则移走的是左侧旧最大值,剩余左侧值既不大于它,也不大于原来右侧的值。因此不论新值落在哪个数值范围,移过一次边界后顺序都正确。

随后只需修复数量。原来两堆等大时,上述操作会让 large 多一个,便把它的最小值移回 small;原来 small 多一个时,操作后恰好等大,无需回移。回移的是右侧最小值,不会破坏两半的大小关系。因此每次插入后仍满足同样的不变量。

查询时,若两堆等大,中央两项是 small 堆顶恢复符号后的原值与 large 的堆顶,取其平均;否则 small 多出的那一项就是中位数。代码先把输入拓宽到 long 或 int64 再取负,并用宽类型求两个边界值的和,最后做浮点除法。

Go 的两个辅助函数同样维护最小堆:插入把新元素追加到尾部,只沿父链上浮;弹出用末尾元素补根,缩短数组后向较小孩子下沉。除了这条移动路径,其他父子关系原本都合法,因此不需要重新整理整个数组。只有一个元素时,缩短后没有孩子,直接结束下沉。

解题步骤

  1. 初始化两个空最小堆,small 中的值以相反数形式存放。
  2. 插入新值到 small,再把 small 对应的最大原值移入 large。
  3. 若 large 数量更多,将它的最小值移回 small。
  4. 查询时根据数量是否相等,返回一个边界值或两个边界值的平均数。

代码实现

class MedianFinder {
    private PriorityQueue<Long> small;
    private PriorityQueue<Long> large;

    public MedianFinder() {
        small = new PriorityQueue<>();
        large = new PriorityQueue<>();
    }

    public void addNum(int num) {
        // small 用相反数模拟最大堆,堆顶对应较小一半的最大值。
        small.offer(-(long) num);
        large.offer(-small.poll());

        if (large.size() > small.size()) {
            small.offer(-large.poll());
        }
    }

    public double findMedian() {
        if (small.size() == large.size()) {
            return ((-small.peek()) + large.peek()) / 2.0;
        }

        return -small.peek();
    }
}
type MedianFinder struct {
    small []int64
    large []int64
}

func Constructor() MedianFinder {
    return MedianFinder{}
}

func (this *MedianFinder) AddNum(num int) {
    // small 保存较小一半的相反数,因此它按最小堆维护。
    this.small = pushHeap(this.small, -int64(num))
    moved := -this.small[0]
    this.small = popHeap(this.small)
    this.large = pushHeap(this.large, moved)
    if len(this.large) > len(this.small) {
        moved = -this.large[0]
        this.large = popHeap(this.large)
        this.small = pushHeap(this.small, moved)
    }
}

func (this *MedianFinder) FindMedian() float64 {
    if len(this.small) == len(this.large) {
        return float64(-this.small[0]+this.large[0]) / 2.0
    }
    return float64(-this.small[0])
}

func pushHeap(heap []int64, value int64) []int64 {
    heap = append(heap, value)
    for child := len(heap) - 1; child > 0; {
        parent := (child - 1) / 2
        if heap[parent] <= heap[child] {
            break
        }
        heap[parent], heap[child] = heap[child], heap[parent]
        child = parent
    }
    return heap
}

func popHeap(heap []int64) []int64 {
    last := len(heap) - 1
    heap[0] = heap[last]
    heap = heap[:last]
    for parent := 0; ; {
        left := parent*2 + 1
        if left >= len(heap) {
            break
        }
        child := left
        right := left + 1
        if right < len(heap) && heap[right] < heap[left] {
            child = right
        }
        if heap[parent] <= heap[child] {
            break
        }
        heap[parent], heap[child] = heap[child], heap[parent]
        parent = child
    }
    return heap
}

复杂度分析

  • 时间复杂度:加入一个数均摊 $O(\log(n+1))$,只执行常数次堆操作;查询中位数为 $O(1)$。
  • 空间复杂度:$O(n)$,每个已经加入的数保存在其中一个堆中。

关键点总结

[!green]

  • 两半的数值顺序保证堆顶是分界值,数量差保证分界恰好位于中央。
  • small 保存相反数,用最小堆取得原值的最大值。
  • 先移动左侧最大值建立顺序,再按堆大小决定是否回移一次。

易错点总结

[!yellow]

  • 读取 small 的原值时必须恢复符号,尤其不能把其负数堆顶直接当作中位数。
  • 只有 large 严格更大才回移,等大时回移会破坏数量约定。
  • 求平均必须做浮点除法,取负和求和也应先在宽类型中完成。
  • Go 弹出后应按缩短后的长度判断孩子,并选较小孩子下沉,才能维持最小堆性质。

相似题目

题目 难度 关联与区别
480. 滑动窗口中位数 困难 同样用双堆维护中位数,滑动窗口还需删除过期值或延迟删除。
703. 数据流中的第 K 大元素 简单 同样只维护所需排名的边界,原题k固定,可只保留最大的k个元素。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/92742480
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!