ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

对顶堆详解:数据流中位数与滑动窗口中位数的实现与避坑指南

对顶堆详解:数据流中位数与滑动窗口中位数的实现与避坑指南 面试季一到“数据流的中位数”“滑动窗口中位数”这两道题就稳稳占据热搜榜单。说实话对于准备过的人来说这题不算难因为它几乎有一个固定的思维模型——对顶堆。但正因为有固定的套路它才更容易在细节上拉开差距堆顶清理不干净、平衡条件写反、窗口删除后忘补偿每一处都是翻车点。这篇文章我就从对顶堆的底层原理讲起把这两道经典题从思路到实现一次说透顺手整理我踩过的坑和调试经验。1. 为什么面试官总爱考这道题以及对顶堆到底在解决什么问题1.1 高频背后的考察点不仅仅是背模板先回答一个很多人心里都有的问题这题真值得反复考吗答案是值得因为一道“数据流中位数”能一次性考察好几层能力你对堆的熟悉程度、你对流式数据场景的理解、你写边界条件的细心程度还有你能否把一个看起来需要排序的问题变成两个堆的局部操作。面试官不是想看你会不会背代码而是想看你在“数据不断到达、查询随时发生”的约束下能不能自动放弃排序这个暴力方案转向更聪明的增量计算。滑动窗口中位数更是围绕“窗口”这个模型做文章。数据流中位数里只有插入没有删除滑动窗口则在插入的同时还要删除过期元素。这时候原来的“查询前堆顶一次取数”思路就失效了因为堆不支持高效删除任意元素。很多候选人能写出数据流版本却在加上窗口后卡住问题恰恰出在对“惰性删除”没有建立起直观认识。所以这两道题放在一起考本质是“插入模型”到“插入删除模型”的升级理解了这一点你就知道面试官的下一题可能往哪个方向走了。从实际工作角度讲这种需要“在动态数据中快速取分位数”的需求在很多系统里都真实存在监控系统统计请求延迟的中位数、数据分析平台计算滑动窗口内的分位值、推荐系统实时维护分数分布。堆这种结构在内存占用和时间复杂度上的平衡非常实用面试考察的是你能否把课本数据结构落地成能跑的业务代码。1.2 对顶堆的直观理解用两个堆维护一个有序切口对顶堆这个名字听起来挺唬人其实就是两个堆一个大根堆一个小根堆。大根堆放在左边管着数据中较小的一半小根堆放在右边管着数据中较大的一半。两个堆的堆顶相对而望中间形成一条“分界线”。只要保证两边数量均衡那么分界线附近的两个堆顶值就是整个集合的中位数所在。我特别喜欢用一个比喻想象一个班级的成绩单你不需要知道所有人的具体排名只需要知道“上半区最高分”和“下半区最低分”。只要这两个值确定了中位数就有了。大根堆的堆顶就是左半边的最大值小根堆的堆顶就是右半边的最小值。当两个半区数量相同时中位数取这两个值的平均值当左边比右边多一个时中位数就直接是左堆顶。这个模型最大的好处是什么呢查询中位数的复杂度是O(1)插入一个新元素只需要O(log n)的堆操作远优于每次插入后排序的O(n log n)或有序数组插入的O(n)。流式数据场景下你并不知道下一个数字是什么但每一次查询都必须快速返回对顶堆完美契合这种节奏。我见过有同学用sort直接硬刚这题数据量小勉强能跑一旦流式输入上万条性能直接崩盘。对顶堆就是那个“又好又快”的答案。2. 数据流中的中位数从插入到查询的完整实现2.1 约定先定好左堆右堆的数量规则动手写代码之前必须先定死一个数量约定不然后面所有判断都会乱套。我采用的约定是大根堆左堆的数量要么等于小根堆右堆要么比右堆多一个。为什么要让大根堆多一个因为这样当总数量为奇数时中大根堆的堆顶就是中位数不需要做平均计算取数更直接。// 简化示意 priority_queueint leftHeap; // 大根堆存较小一半 priority_queueint, vectorint, greaterint rightHeap; // 小根堆存较大一半你需要一直在头脑里绷紧一根弦leftHeap.size() 和 rightHeap.size() 之间的差永远不能超过1。一旦超过就要从多的那边把堆顶挪到少的那边。这个平衡操作是整个对顶堆代码的核心节奏面试时可以一边写一边说出你维护的“不变量”这一点非常加分。需要注意不同教程对左右堆的设定可能相反。有些文章用“左边小根堆、右边大根堆”除了堆顶取值位置不同思路完全一样。关键是自己在代码里统一好定义面试写题时我建议选择自己最顺手的一套不要临时切换思路。一旦切错了方向插入时把元素放反了堆后面整个数据结构就全乱了。2.2 插入操作分配加平衡两招解决插入逻辑我写过很多版本最不易出错的其实是“先按规则分配再统一平衡”两段式。具体的做法是如果当前leftHeap为空或者新数字小于等于leftHeap的堆顶说明它属于左半区插入leftHeap。否则新数字放rightHeap。插入后检查两边数量差不满足约定就调整。这个版本好在哪里它把“插到哪边”和“保持平衡”两个问题拆开每一步的职责都很清晰。我见过另一种写法是“先放入leftHeap再把leftHeap堆顶挤到rightHeap”本质是一样的只是节奏不同。无论用哪种面试中都要解释清楚为什么需要这个分配条件因为leftHeap堆顶代表左半区最大元素新元素如果比它还大那就不可能留在左半区只能去右半区。平衡调整的代码也非常机械void addNum(int num) { if (leftHeap.empty() || num leftHeap.top()) { leftHeap.push(num); } else { rightHeap.push(num); } // 平衡左边最多比右边多1 if (leftHeap.size() rightHeap.size() 1) { rightHeap.push(leftHeap.top()); leftHeap.pop(); } // 平衡右边不能比左边多 if (rightHeap.size() leftHeap.size()) { leftHeap.push(rightHeap.top()); rightHeap.pop(); } }这里有两个最容易写错的地方。一是比较新数字和堆顶大小时必须处理等于的情况。等于时归左边还是右边我习惯归左边这样能维持左堆略大的约定。二是两个平衡if的顺序和条件不能写反否则可能出现“左边比右边多2右边被强制挤回左边”的循环拉扯。2.3 查询中位数堆顶一取便知查询逻辑依赖我们之前定好的数量约定所以先保证插入函数是对的查询其实是一行代码的事double findMedian() { if (leftHeap.size() rightHeap.size()) { return leftHeap.top(); } return (leftHeap.top() rightHeap.top()) / 2.0; }有人可能会问两边数量相等时直接取两个堆顶的平均值没问题但万一集合为空呢这个在实际题目里通常有约束“至少有一个元素”不过在工程实现中调用方自己得保证非空或者在查询前判空。这个边界虽然不复杂但面试时主动提一句“如果后续支持删除堆空的情况也要考虑”会让面试官觉得你考虑得周全。还有一个细节是除以2.0而非2如果写(a b) / 2整数除法会把 1.5 截断成 1本地测试用例很可能发现不了因为样例往往给的是偶数个数相加刚好能整除。这是写C最容易踩的隐形坑建议写上2.0或者转成double。2.4 模板代码与复杂度分析把上面的零碎拼在一起数据流中位数的完整代码就是一个类class MedianFinder { public: priority_queueint leftHeap; priority_queueint, vectorint, greaterint rightHeap; void addNum(int num) { if (leftHeap.empty() || num leftHeap.top()) { leftHeap.push(num); } else { rightHeap.push(num); } if (leftHeap.size() rightHeap.size() 1) { rightHeap.push(leftHeap.top()); leftHeap.pop(); } if (rightHeap.size() leftHeap.size()) { leftHeap.push(rightHeap.top()); rightHeap.pop(); } } double findMedian() { if (leftHeap.size() rightHeap.size()) return leftHeap.top(); return (leftHeap.top() rightHeap.top()) / 2.0; } };复杂度上单次插入最多涉及2次堆的push/pop每次操作O(log n)查询O(1)。空间复杂度O(n)存储全部数据。相比维护有序数组插入O(n)、二分搜索树平均O(log n)最坏O(n)、分段统计实现复杂度高堆的写法代码量最少时间稳定面试现场不容易翻车工程上内存控制也很容易。3. 滑动窗口中位数当删除来了问题就变了3.1 窗口移动的本质删除左侧过期元素滑动窗口的中位数问题可以理解成数据流版本加了一个约束窗口大小固定随着新元素入窗最老的元素必须出窗。也就是说除了插入你还需要支持“删除任意一个已知元素”。普通堆没有提供删除指定元素的操作如果你硬是扫描整个堆去删复杂度就变成O(n)完全失去优势。面试遇到这种情况脑子里要立马蹦出一个更通用的词惰性删除lazy deletion。我们不急着从堆里物理剔除元素而是用一张哈希表记录“待删除”的元素及其数量。在每次取堆顶时检查堆顶是不是一个“已经被标记删除”的元素如果是就把它弹出并减少计数直到堆顶是真正有效的元素为止。这个方法之所以叫“惰性”因为它是把删除操作的时间延后到“不得不清理堆顶”时才做。真正取结果的时候堆顶必须是干净的否则结果就错了。但在不取结果的间隙里脏数据可以暂时堆在堆里反正不影响别的操作。这种“记账式删除”在很多优先队列相关的题目里都是标配比如计算TopK变体、任务调度器都会用到同一个思路。3.2 延迟删除的关键计数表顶部清理函数延迟删除最核心的就是给每个元素记一笔“待删除账”。我为滑动窗口题写了一个通用的清理函数顺便提炼成模板unordered_mapint, int delayed; // 元素值 - 待删除次数 void cleanHeap(priority_queueint heap, bool isMaxHeap) { while (!heap.empty() delayed[heap.top()] 0) { delayed[heap.top()]--; heap.pop(); } }调用 cleanHeap 前必须要区分“清理哪个堆”。如果是大根堆堆顶是最大值如果是小根堆堆顶是最小值。滑动窗口每移动一次右侧加入一个新元素会进入其中一个堆左侧移除的旧元素也会对应某个堆。所以每次移动窗口后两个堆的顶都有可能存在“脏数据”必须分别清理。这里有一个新手经常漏的点移除旧元素时你并不能直接判断它在哪个堆所以我给窗口下标做一个“当前窗口内元素值到延迟次数的映射”而不是“元素值到所在堆的映射”。因为同一个值可能在两个堆里都出现你只关心总次数等到清理堆顶时哪个堆碰到这个值谁就来消耗这个计数。这种设计从逻辑上更干净实现也更安全。3.3 删除之后必须先平衡再清脏滑动窗口的插入操作和数据流版本一样先分配再平衡。但“删除元素”这一步很容易让人忽略平衡的修正。想想看窗口左端移出一个元素这个元素贡献给了某个堆现在某个堆的总有效数量可能已经跟另一个堆不均衡了。此时如果你不做处理中位数取值要么取错堆顶要么堆是非法的。解决分三步走。第一步把待删除元素记入延迟表delayed[outNum]。第二步执行分配和平衡把新元素按规则放入某个堆再检查左右堆数量差通过堆顶挤入的方式恢复平衡。这里需要特别小心平衡操作要基于实际有效元素数量而不是堆的原始大小所以平衡前应该先清理掉涉及堆顶的脏数据但更稳妥的办法是先分配新元素、再清理两个堆的堆顶、再比较两个堆的实际有效大小做平衡。顺序不能乱。我在反复调试后总结的稳妥顺序是加入新元素按大根堆堆顶比较规则放入 leftHeap 或 rightHeap。删除旧元素记录到 delayed并递增次数。清理 leftHeap 与 rightHeap 顶部。如果 leftHeap.size() rightHeap.size() 1挤一个左堆顶到右堆如果 rightHeap.size() leftHeap.size()挤一个右堆顶到左堆。再清理两个堆顶一次确保最终取中位数时堆顶有效。清理和平衡的顺序为什么重要因为平衡操作会移动真实存在的堆顶元素如果堆顶本身是脏数据移动的其实是一个无效元素会把两个堆都搞乱。先清脏再做平衡拿到的是各自堆里最大/最小的有效元素这时移动它才是正确的。3.4 完整实现滑动窗口中位数代码解析结合上面所有要点给出Adapative版的完整实现以LeetCode 480滑动窗口中位数为例class Solution { public: priority_queueint leftHeap; priority_queueint, vectorint, greaterint rightHeap; unordered_mapint, int delayed; void cleanLeft() { while (!leftHeap.empty() delayed[leftHeap.top()] 0) { delayed[leftHeap.top()]--; leftHeap.pop(); } } void cleanRight() { while (!rightHeap.empty() delayed[rightHeap.top()] 0) { delayed[rightHeap.top()]--; rightHeap.pop(); } } void addNum(int num) { if (leftHeap.empty() || num leftHeap.top()) { leftHeap.push(num); } else { rightHeap.push(num); } } void removeNum(int num) { delayed[num]; } void balance() { // 先清脏顶 cleanLeft(); cleanRight(); // 再平衡这里都基于清理后的堆大小 if (leftHeap.size() rightHeap.size() 1) { rightHeap.push(leftHeap.top()); leftHeap.pop(); } else if (rightHeap.size() leftHeap.size()) { leftHeap.push(rightHeap.top()); rightHeap.pop(); } // 平衡可能把脏元素推给对方再清一次 cleanLeft(); cleanRight(); } double getMedian() { cleanLeft(); cleanRight(); if (leftHeap.size() rightHeap.size()) return leftHeap.top(); return (leftHeap.top() rightHeap.top()) / 2.0; } vectordouble medianSlidingWindow(vectorint nums, int k) { int n nums.size(); vectordouble ans; // 先放入前 k 个 for (int i 0; i k; i) addNum(nums[i]); balance(); ans.push_back(getMedian()); for (int i k; i n; i) { addNum(nums[i]); removeNum(nums[i - k]); balance(); ans.push_back(getMedian()); } return ans; } };代码里的 balance() 依旧维持“左边至少等于右边且最多比右边多一”的不变量。窗口初始化时前k个数字逐个加入没有得到平衡维护所以在第一次取中位数前必须调用一次 balance()这是一个极易遗漏的初始化步骤。另外两个值得注意的细节delayed的计数可能因同一个数值多次出现而累加清理时用 0判断弹出后才递减不会出现负数。因为堆里可能存在重复数字delayed计数的作用范围是全局的不区分堆这就保证了即使两个堆里都有同一个值删除时也只减少一次计数不会错删两个。4. 常见问题、排查和面试加分点4.1 堆顶脏数据不清理会怎样这是发生在身边的真实案例曾经有个学弟在滑动窗口中位数题上死磕两天最后的bug就是“只在取答案时清理堆顶但平衡操作移动了脏数据”。他的场景是右堆比左堆大了需要把右堆顶挤到左堆可那个右堆顶恰好是一个被标记删除的元素。结果右堆确实变小了但左堆却多了一个无效元素后续的计数和堆顶判断全部错位。要彻底避免这个问题正确思路是“清理必须发生在任何读取堆顶之前”。无论是balance里的挤堆操作、getMedian里的取值操作第一步都应是清理对应堆。我把这个原则概括为“任何需要依赖堆顶有效性的操作之前必须先清脏”。写代码时把这个念成口诀能少掉80%的lazy deletion bug。排查时还有一个技巧是打印堆里的有效元素数量。有效数量 堆的size减去delayed中对应元素被该堆合法占据的次数。这个值不要单独维护直接用“清理后堆的size”来作为平衡判断依据最可靠。4.2 延迟删除的计数边界重复元素与缺失元素用哈希表做延迟删除最怕的就是“删除一个并不存在于堆里的元素”我举个例子窗口里有两个值为5的元素堆里也可能有两个5。删除第一个5时delayed[5]。假如下一次又删除一个5delayed[5]变2。清理堆顶时如果连续两次遇到5会消耗掉两个计数把两个5都弹掉。但如果堆里实际只有两个5都弹掉之后总数就少了可能引发其他堆的平衡错误。怎么处理我的经验是时刻记住delayed记录的是需求删除次数而不是“哪个堆里的哪个元素”。因此即使在错误的时间点记录了删除比如删除的元素并不在当前窗口最多也只是延迟下次取堆顶时才清理它不会把一个有效元素永久删除因为只有当某个堆顶值等于这个键时才消耗一次计数。所以统计计数不会导致元素凭空消失这是“延迟删除”设计天然的安全特性。核心要防的是“清理了但忘记重新平衡”而不是纠结于计数边界。另外题目中有个隐蔽的坑移除窗口最左元素时如果最左元素跟堆顶值一样但堆顶已经被前面延迟删除过那么这次的删除计数可能根本不会被消耗它会一直留在哈希表里。解决办法不是每个移动步骤都清空延迟表而是接受这种“偶尔堆顶清理会多花一点时间”的设计均摊下来复杂度仍然是O(n log n)不影响整体性能。4.3 面试现场怎么一步步演算而不东拉西扯面试做题跟刷题完全不同你需要在15分钟内让面试官看到清晰的思路链路。我的建议是现场先画一张对顶堆的示意图左边大根堆、右边小根堆中间写“中位数”三个字。然后明确说出两条不变量左堆的大小 右堆大小或左堆比右堆大1。左堆所有元素 右堆所有元素两堆互不交叉。这样面试官马上能判断你已经建立正确的模型后面的代码就只是把这个模型翻译成pq操作而已。进入滑动窗口版本时主动说一句“堆不支持任意删除我会用哈希表做惰性删除只清理堆顶”这样即使代码出现小失误大方向上的技术选型也能保住印象分。演算的时候我会用小规模数据手动模拟一遍。比如窗口[1,3,-1,-3]k3加上-3前的状态逐步走到平衡。这样的过程不仅帮助自己理清顺序也让面试官看到代码背后的操作序列。千万不要一上来就贴代码面试官更想看到你“构造模型-验证边界-写代码”的过程。4.4 变体和扩展从中位数到TopK、分位数、前缀中位数这题一旦掌握一大批类似问题会被顺手带出。常见变体有求动态数组的TopK大/小元素就是维护一个小根堆存目前最大的K个或者大根堆存最小的K个本质上就是对顶堆只取一个半区。求滑动窗口中的分位数比如p50、p90、p99可以把对顶堆扩展成“多个堆切分区间”或者用有序统计树。对顶堆天然只擅长中位数但“两个堆维护分界”的思维能迁移。前缀中位数在线处理每个前缀的中位数输出到数组相当于数据流中位数的序列版直接套用第一部分的类就可以。带删除的TopK同样用延迟删除清堆顶配合堆顶平衡策略和滑动窗口中位数几乎同构。面试时如果你能主动说出“这个问题本质上是对顶堆惰性删除”会让面试官觉得你真正理解题目之间的关联。我自己在准备这题时就把这几个变体各刷了一遍后续遇到类似的优先队列场景尤其是任务调度和K路合并思路都变得很顺。最后再分享一个实用经验本地调试时别用大样例先用小数组打印每个阶段的两个堆顶和堆大小。Visualize stack里的左右堆状态能立刻发现是插入时放错了堆还是删除时平衡顺序错了。这题写出bug最容易的地方不在算法框架而在“清理-平衡-再清理”的执行顺序。把这条调好了面试基本稳拿。这个知识点本身不大但它背后隐藏的“用两个不同优先级结构维护一条分界线”的思想在很多系统设计的场景里会反复出现。掌握好了性价比极高。
返回列表