ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

华为OD机试高频题:用map+list实现支持优先级队列的容器实战

华为OD机试高频题:用map+list实现支持优先级队列的容器实战 最近在带一波准备华为OD机试的朋友发现C卷100分的题目里“支持优先级队列 - map与list”出场率相当高。很多第一次刷到这道题的人第一反应都是“不就是堆吗”结果一上手就发现不对劲题目里压根没让你直接用优先队列反而是把“map”和“list”两个词明晃晃写在了标题里。这个信号很重要它基本决定了这道题的正解不是无脑堆而是用两层映射配合队列结构来模拟一个支持优先级调度的容器。这篇文章我会按机试里最常见的题目形态把题干还原出来讲清楚为什么用maplist能解决、懒删除的版本号怎么设计然后把Python、JS、C三种语言的完整写法都贴出来再给一套堆懒删除的保底方案做对比。无论你是刚开始刷OD、还是想冲刺满分这篇文章应该都能帮你把这道题吃透。文章较长建议边看边动手敲只看不写的话机试现场还是容易翻车。1. 把支持优先级队列还原成机试真题操作、约束与打分点1.1 真题样貌ADD和POP为主更新优先级是隐藏考点先说为什么我要“还原”而不是直接贴原题。华为OD机试的题目描述在不同批次里会有措辞差异但核心逻辑基本没变过我按最常见的形态整理如下系统需要维护一批任务每个任务有一个唯一的整数ID还有一个优先级P数值越大表示优先级越高。输入N条命令每条命令形如ADD id P把任务(id, P)加入系统如果这个ID已经存在则以最新一次ADD的优先级为准相当于更新该任务的优先级。POP取出当前优先级最高的任务输出它的ID并从系统中删除如果当前没有任务输出-1。数据规模N在10^5到10^6量级ID可能达到64位整数范围优先级P常见范围是1到10^5这个约束非常关键后面会专门讲。也就是说这道题看着像是“设计一个数据结构”实际上考的是“在频繁插入、删除、更新优先级的情况下怎么用基础容器拼出一个高效的优先级队列”。这里有个很容易被忽略的细节ADD id P如果遇到ID已存在题目要求是“更新优先级”。很多人在这一步直接用map[id] P结果旧优先级对应的桶里还留着这个ID的旧记录等到弹出时系统就混乱了。所以更新操作才是整道题真正想考你的地方。1.2 先看数据规模再定方案优先级取值范围决定了你能不能用“扫描”很多刷题习惯不好的同学上来就写代码不看数据范围这是机试大忌。这道题的数据规模直接影响解法选择而里面最敏感的一个参数就是优先级P的取值范围。我列个简单的判断表你考前可以照着这个思路快速定位情况推荐方案原因N大、P范围有限如1~10^5map分桶 list/deque组内排队 maxP扫描均摊O(1)代码量适中P范围极大如1~10^9桶 有序集合管理非空桶或直接堆懒删除避免扫描空桶时O(P)超时同优先级按ID升序弹出每个桶改用小根堆或全局堆懒删除堆的元组排序天然满足ID升序我在文章主解部分默认采用“同优先级按加入顺序FIFO弹出”的版本因为这是map与list组合最自然的使用场景如果原题要求按ID升序我也会在第2章里给出独立的改造方案不会让你在考场上一头雾水。1.3 100分题的套路核心不是堆是“两层映射”为什么这类题不推荐直接上堆因为堆擅长的是“找最值”和“删最值”一旦出现“指定ID更新优先级”堆就非常难受——你没法在堆里O(1)定位某个ID只能把新版本再塞进去让旧版本留在堆里变成“脏数据”。这引出两个解决问题的主线一条主线是“maplist分桶”用优先级 - 队列的映射来组织数据再用ID - 优先级/版本号的反查映射处理更新。另一条主线是“堆懒删除”承认堆里会有脏数据通过版本号在弹出时过滤。这两条主线都能过但机试标题里点出“map与list”说明出题人更希望你走第一条——这也符合这道题100分的位置不考高深算法考的是你对基础数据结构的组合能力。2. 主解map分桶 list组内排队懒删除处理优先级更新2.1 核心设计优先级桶、有效表、版本号各管什么先看一张结构草图系统里有多个优先级每个优先级对应一个队列。队列里存的是二元组(ID, 版本号)。同时系统里一张“有效表”记录每个ID当前真正有效的优先级和版本号。为什么要版本号因为我们要处理“更新优先级”这个操作。比如任务101一开始是优先级5后来更新成优先级8。最粗暴的做法是去优先级5的队列里把101删掉再塞进优先级8的队列。但问题来了如果桶内用list/deque删除中间元素是O(n)的操作数据一大就超时。所以正解是“不删旧只加新”把(id101, ver1)留在优先级5的桶里不动它让它变成脏数据把(id101, ver2)追加到优先级8的桶里有效表里更新101的最新状态为(prio8, ver2)。弹出时怎么区分干净数据和脏数据每次都检查有效表如果弹出的节点(id, ver)与有效表里记录的一致说明这个节点是当前有效任务正常输出并删除如果不一致说明是历史遗留的旧版本直接丢弃。这个机制就是懒删除它把“删除中间元素”的成本转移成了“稍后在弹出时多判断一次”整体均摊成本非常低。用生活中的例子来感受下你在A中介挂了一套房后来觉得价格不合适改挂了B中介。A中介那边不会立刻把房源信息撤下来但客户来问的时候A中介一查系统发现这套房已经改挂B了就说是无效房源。A中介的“系统查询”就是有效表A中介门店里那本登记簿就是list桶。Python代码贴出来注意我用的是普通dict配合setdefault没有用defaultdict这样在扫描空桶时不会因为访问buckets[p]而意外创建一堆空桶。import sys from collections import deque def main(): input sys.stdin.readline n int(input()) buckets {} # priority - deque[(id, ver)] valid {} # id - (priority, ver) max_prio 0 out [] for _ in range(n): parts input().split() if parts[0] ADD: id_ int(parts[1]) p int(parts[2]) old valid.get(id_) if old is not None and old[0] p: continue ver 1 if old is None else old[1] 1 valid[id_] (p, ver) buckets.setdefault(p, deque()).append((id_, ver)) if p max_prio: max_prio p else: # POP while max_prio 1: que buckets.get(max_prio) if que: break max_prio - 1 if max_prio 0: out.append(-1) continue que buckets[max_prio] ans None while que: id_, ver que.popleft() cur valid.get(id_) if cur is not None and cur (max_prio, ver): ans id_ del valid[id_] break out.append(str(ans) if ans is not None else -1) sys.stdout.write(\n.join(out)) if __name__ __main__: main()这段代码里有三个细节值得单独说if old is not None and old[0] p: continue如果ID已存在且优先级没变直接跳过。这个优化能省掉大量无意义的版本增长尤其当你连续ADD同一个ID时。while max_prio 1这里用buckets.get(max_prio)而不是buckets[max_prio]就是为了避免get的副作用。如果用defaultdict每次if not buckets[max_prio]都会在map里创建一个空的deque内存白白涨。cur (max_prio, ver)判断有效表与当前桶、当前版本号是否完全一致。这里比较的是元组Python写起来非常自然。2.2 同优先级排序规则的两个分支FIFO 与 ID升序我在开头提过不同批次的题目对“同优先级怎么排序”要求可能不一样。这里把两种情况都给了你考场上根据题目描述自己选。分支A同优先级按加入顺序FIFO这就是主解代码。桶内用deque先加入的先弹出。list/deque天然就是干这个的代码零改造。分支B同优先级按ID升序弹出桶内就不能用普通队列了需要每个桶一个小根堆堆里存(id, ver)。Python里可以直接用heapq操作每个桶的list。弹出时从堆顶取堆顶天然是ID最小且有效的那个。整体结构不变只是把“组内排队”从FIFO改成了按ID排序的优先级队列。这里给一个最小改造示意其他逻辑与主解完全相同import heapq # push阶段 heapq.heappush(buckets.setdefault(p, []), (id_, ver)) # pop阶段que是buckets[max_prio] id_, ver heapq.heappop(que)为什么两个分支要分清楚因为如果你默认FIFO结果考场上发现题目写的是“同优先级按ID从小到大”你很可能以为自己代码有bug折腾半小时才发现是排序规则理解错了。考前把两个分支都在脑海里过一遍遇到题目时直接套对应分支这比临时推演要稳得多。2.3 复杂度明细为什么均摊O(1)代价藏在哪个常数里做一个简单的摊还分析。每个ADD命令最多产生一个新节点(id, ver)放进某个桶每个节点之后最多被弹出一次弹出时如果是脏数据就丢弃、是干净数据就输出。所以把所有POP加起来的节点处理总量是O(N)。这是“懒删除”方案里复杂度最漂亮的地方你没有真正删除任何中间元素只是把所有删除动作推迟到桶头处理。但桶方案有一个无法回避的常数开销就是max_prio的递减扫描。假设最高优先级曾经到过10^5之后这些任务全被弹出了下一次POP时max_prio要从10^5一路降到下一个非空桶最坏情况可能扫描10^5次。这就是为什么我在开头反复强调“P取值范围”重要如果P在10^5以内N在10^5量级这个扫描的总跨度被P限制住整体复杂度是O(N P)完全能接受如果P给到10^9一次空扫就能让你超时这时就必须用“有序集合维护非空桶”或直接换堆。一句话总结桶list的均摊复杂度是O(1)但它的常数受“曾经出现过的最高优先级”影响所以读题时第一件事就是看P的范围。3. 多语言落地Python/JS/C的实现差异与隐蔽坑3.1 Python版本deque是亲儿子list别拿来当队列刚才的Python代码已经是完整版本这里补充一些你会踩到的实际坑。最典型的就是有人图省事用list.appendlist.pop(0)当队列组内结构结果POP一多就超时。pop(0)的时间复杂度是O(n)因为Python的list删除头部后要把所有后续元素往前挪。deque的popleft()是O(1)所以桶内队列必须用collections.deque。另一个坑是输入读取。机试里N可能到10^6如果你用input()逐行读Python会在IO上吃很多时间。正确姿势是sys.stdin.readline如果愿意甚至可以一次性sys.stdin.read().split()全部读进来再逐个处理那是最快的。上面代码里我用了readline已经能满足大部分机试环境。还有输出。不要POP一次就print一次这样会频繁操作标准输出。把所有结果放进out列表最后\n.join(out)一次性输出这个习惯能省下不少耗时也是面试官看代码时明显有好感的写法。3.2 JavaScript版本数组当队列必须用头指针别用shiftJS里没有内置的双端队列很多人贪方便用arr.push()入队、arr.shift()出队然后在大数据量下超时。shift()和Python的pop(0)一个毛病删除头部会把后面所有元素左移O(n)。解决方式是在每个桶里维护一个数组加一个head下标。入队就是arr.push()出队就是arr[head]数组本身不缩短哪怕head已经指向很远也没关系因为脏节点都靠“跳过”来处理。这样的出队均摊O(1)只是需要注意如果桶里的数组越积越长空间会稍大但每个节点最多被处理一次所以总空间还是O(N)。JS完整代码const readline require(readline); const rl readline.createInterface({ input: process.stdin }); const lines []; rl.on(line, line lines.push(line.trim())); rl.on(close, () { let idx 0; const n Number(lines[idx]); const buckets new Map(); // p - {arr: [], head: 0} const valid new Map(); // id - {prio, ver} let maxP 0; const out []; for (let i 0; i n; i) { const t lines[idx].split( ); if (t[0] ADD) { const id Number(t[1]); const p Number(t[2]); const old valid.get(id); if (old old.prio p) continue; const ver old ? old.ver 1 : 1; valid.set(id, { prio: p, ver }); if (!buckets.has(p)) buckets.set(p, { arr: [], head: 0 }); buckets.get(p).arr.push({ id, ver }); if (p maxP) maxP p; } else { while (maxP 1) { const que buckets.get(maxP); if (que que.head que.arr.length) break; maxP--; } if (maxP 1) { out.push(-1); continue; } const que buckets.get(maxP); let ans -1; while (que.head que.arr.length) { const node que.arr[que.head]; const cur valid.get(node.id); if (cur cur.prio maxP cur.ver node.ver) { ans node.id; valid.delete(node.id); break; } } out.push(String(ans)); } } console.log(out.join(\n)); });两个JS特有坑一是Map的键优先级和ID都用Number转了类型别把字符串和数字混着存否则valid.get(node.id)会因类型不一致返回undefined二是如果ID超过Number.MAX_SAFE_INTEGER2^53-1Number会精度丢失这时候要么改用BigInt要么庆幸机试数据没这么变态。说到底大部分机试用例的ID都在32位整数范围不必过度设计。3.3 C版本unordered_map桶越界问题与引用失效的注意点C实现里我推荐unordered_mapint, listNode。list自带push_back和pop_front完美替代Python的deque。完整代码如下#include bits/stdc.h using namespace std; using ll long long; struct Node { ll id; int ver; }; int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int n; cin n; unordered_mapint, listNode buckets; unordered_mapll, pairint, int valid; int maxP 0; vectorstring outs; while (n--) { string op; cin op; if (op ADD) { ll id; int p; cin id p; auto it valid.find(id); if (it ! valid.end() it-second.first p) { continue; } int ver (it valid.end()) ? 1 : it-second.second 1; valid[id] {p, ver}; buckets[p].push_back({id, ver}); if (p maxP) maxP p; } else { while (maxP 1) { auto bit buckets.find(maxP); if (bit ! buckets.end() !bit-second.empty()) break; maxP--; } if (maxP 1) { outs.push_back(-1); continue; } auto bit buckets.find(maxP); auto que bit-second; ll ans -1; while (!que.empty()) { Node cur que.front(); que.pop_front(); auto vit valid.find(cur.id); if (vit ! valid.end() vit-second.first maxP vit-second.second cur.ver) { ans cur.id; valid.erase(vit); break; } } outs.push_back(to_string(ans)); } } for (size_t i 0; i outs.size(); i) { cout outs[i] (i 1 outs.size() ? : \n); } return 0; }几个容易翻车的点buckets[p]会默认构造一个空list。你在ADD里用buckets[p].push_back()没问题但POP里扫描时如果也用buckets[maxP]那么即使这个优先级根本不存在运算符也会向map里插入一个空桶。所以扫描时必须用buckets.find(maxP)避免无意义的桶数量膨胀。auto que bit-second;拿到list引用后在循环里pop_front操作会改变list这没问题但如果你在后续代码里又调用了buckets[p]等可能触发map扩容的操作之前拿到的引用可能失效。好在我们的pop_front期间没有插入操作安全。结果统一放在outs里最后输出和Python一样避免频繁调用cout。要注意最后一行不要多出换行我用了三元表达式处理。3.4 C语言不写花活手写循环队列简易哈希的可行路线C语言没有现成的哈希表和list容器如果你在机试里选了C那这道题会比别的语言多花不少代码量。一个可行的简化路线是用一个结构体数组模拟节点池每个节点包含(id, ver, 优先级, 桶内前驱/后继下标)每个优先级对应一个双向链表用数组下标代替指针哈希表自己实现可以用开放寻址法存id - (有效优先级, 版本号, 节点下标)。这个方案写出来大概200行容易出错。我的建议是如果C不是你的绝对主力语言这道题还是优先用C或Python把人力省下来多检查边界。机试时间有限代码简洁本身就是一种优势。4. 换条路走堆懒删除的保底写法以及什么时候选它4.1 堆懒删除的完整思路与Python极简代码堆懒删除的思路其实我刚才一直在铺垫堆负责快速拿到当前最高优先级任务有效表负责判断堆顶是不是过期数据。当发生ADD id P更新时我们不去堆里删除旧数据而是直接把新节点(-P, id, ver)推进堆同时更新有效表。弹出时如果堆顶节点的版本号和有效表不一致就说明它是旧版本直接丢弃继续检查下一个。这个方案有一个额外福利堆里存元组(-P, id, ver)Python的元组比较会先比优先级再比ID所以“同优先级按ID升序”是天然满足的不需要你额外写比较逻辑。如果用桶方案这个需求反而要改造桶内结构。极简Python实现import sys import heapq def main(): input sys.stdin.readline n int(input()) valid {} heap [] out [] for _ in range(n): parts input().split() if parts[0] ADD: id_ int(parts[1]) p int(parts[2]) old valid.get(id_) if old is not None and old[0] p: continue ver 1 if old is None else old[1] 1 valid[id_] (p, ver) heapq.heappush(heap, (-p, id_, ver)) else: while heap: neg_p, id_, ver heap[0] if id_ in valid and valid[id_] (-neg_p, ver): break heapq.heappop(heap) if not heap: out.append(-1) else: neg_p, id_, ver heapq.heappop(heap) out.append(str(id_)) del valid[id_] sys.stdout.write(\n.join(out)) if __name__ __main__: main()注意我存的是(-p, id_, ver)因为Python的heapq是小顶堆。取堆顶时neg_p要还原成p -neg_p和有效表比较时也要转回去。4.2 “桶list”和“堆懒删除”的对比表很多读者会纠结机试里到底写哪个。我直接拉个表对比项map分桶 list/deque堆 懒删除ADD复杂度O(1)O(logN)POP复杂度均摊O(1)受P范围扫描影响O(logN)含脏节点弹跳同优先级按ID升序需要桶内改小根堆天然满足代码量中等结构清晰短逻辑集中空间占用O(N)准O(N)但脏节点可能让堆膨胀多倍核心风险maxP空扫超时堆脏数据过多时内存压力大实际机试中这道题更常见的要求是简洁和正确。如果时间紧、题面又没点明“map与list”我一般建议直接写堆懒删除因为它的正确性更容易一眼验证。但如果你想把100分拿得稳、又要体现数据结构设计能力桶方案更贴题也更容易在时间复杂度上碾过卡常用例。4.3 真实机试里为什么很多人死磕堆反而翻车堆懒删除看起来短但翻车点其实很隐蔽。最常见的是版本号忘加或者判断条件写错。比如有人只在有效表里记录最新优先级而没有版本号。这会导致什么结果任务101从优先级5更新到8后堆里有两个101旧节点(5)和新节点(8)。如果优先级5的旧节点先被误认为有效弹出系统就乱套了。还有人会在ADD时忘记checkold导致连续ADD同一个ID多次后有效表版本号归零或重复脏数据永远清不掉。其实解决办法很简单版本号自增不要复用。判断条件永远是“元组完全相等”不要只比ID不比版本号。如果你在考场上遇到POP一直输出-1、但明明还有任务的情况优先怀疑三件事一是valid被错误清空了二是堆顶判断里优先级符号没转回正数三是程序把continue写成了break导致有效节点被跳过。5. 验证与排错用两个临界样例把陷阱一次讲干净5.1 样例一同优先级、更新优先级、空队列的完整走查我设计一组8条命令的输入覆盖“同优先级”“更新优先级”“空队列”三个场景8 ADD 101 5 ADD 102 3 ADD 103 5 POP ADD 101 8 POP POP POP逐条走查命令操作后状态输出ADD 101 5桶5: [101v1]valid[101](5,1)-ADD 102 3桶3: [102v1]桶5: [101v1]-ADD 103 5桶5: [101v1, 103v1]桶3: [102v1]-POP桶5弹出101v1输出101101ADD 101 8valid[101]更新为(8,2)桶8: [101v2]桶5仍残留101v1-POPmaxP8桶8弹出101v2有效输出101删除valid[101]101POPmaxP从8降到5桶5队首101v1valid里已无101丢弃再弹103v1有效输出103103POPmaxP降到3桶3弹102v1输出102102最终输出101 101 103 102这个样例最能说明懒删除的价值第二次POP之后桶5里的101v1其实已经成了脏数据但我们没有去删除它而是等到第三次POP时顺手把它“过滤”掉。如果当时用“从桶中间删除”的思路这里就要遍历桶5数据一多必超时。5.2 样例二版本号是防止旧数据回魂的唯一防线再看一个更短的样例专门验证版本号机制4 ADD 101 5 ADD 101 8 POP POP第一次ADD桶5收到101v1。第二次ADDvalid更新为(8,2)桶8收到101v2。此时有效任务只有“优先级8的101”。第一次POPmaxP8桶8弹101v2与valid一致输出101。第二次POPmaxP从8降到5桶5队首是101v1。valid里已经没有101了所以这条数据直接被丢弃。随后桶5为空maxP降到3、2、1、0没有任务输出-1。预期输出101 -1如果你写代码时没加版本号只比较valid[id]的优先级就可能出现一种玄学bug101v1虽然过期了但它的优先级5和有效表里的优先级8不同被误判为“不同任务”而正常输出。其实它早就该被删除了。版本号的作用就是给任务上的每一次“更新”打一个唯一标记让旧版本彻底失去有效性。5.3 机试环境的几个低级但致命的输出问题代码逻辑对不代表一定能满分。机试环境里最容易出现这几个低级问题输出格式严格控制为每个结果一行不要在行首行尾加空格更不要输出调试信息。因为判题系统是整行比对多一个空格都算错。空队列的输出到底是-1还是null以题目为准。很多版本的题干写的是-1但我也见过写null的。开场读题时顺手标出来别写完代码再回去改。大数据量下建议用sys.stdout.write(\n.join(out))不要把print放进循环。同理C关掉sync_with_stdio(false)JS用最终join一次性输出。输入命令字符串不要做无谓的trim()之外的清理比如有人会把Windows的\r也trim掉这是必要的但不要把命令改成大小写不一致机试命令通常是全大写按原样比较即可。6. 从考题到工作消息队列的topic分组为什么长这样这道题解完以后我建议你停下来想一个问题为什么机试会选“maplist”作为考点因为它本质上就是消息队列里按优先级分组的一个缩影。在真实的消息队列中间件里经常能看到类似的结构不同优先级的消息进入不同的队列分组消费者永远先处理高优先级的分组某个分组空了就切换到下一个分组。这和我们用bucketsmaxP维护非空桶的思路如出一辙。每个桶里的消息按顺序排队和FIFO分支完全对应。而valid表 版本号在工程上也很常见数据库更新一行数据时旧版本不会立刻物理删除而是通过版本号控制可见性消息消费失败后通过offset回退重放。懒删除的本质是“用标记代替物理删除”这是一个在任何需要高并发写入的场景里都非常好用的优化思路。所以这道题不只是“会做一道题”它其实在帮你建立一套工程直觉什么时候该用桶、什么时候该用堆、什么时候该用版本号。面试官如果追问“为什么不用堆”你就可以从更新优先级、指定删除、同优先级排序这几个维度展开答出来会相当加分。最后说点实在的我在实际准备这道题时最大的教训是太迷信“通用最优解”。一开始我只会堆懒删除后来发现题目点名map与list才转向桶方案结果对P取值范围的理解又不够深第一次实现时用defaultdict导致空桶堆积内存差点爆掉。这道题如果只看题解不亲自动手很容易漏掉这些细节。给你一个考前实操建议把桶方案和堆方案都写一遍用我上面的两个样例自测然后自己再造几个随机用例。等你哪天能在不查资料的情况下十分钟内把Python桶方案默写出来这道题就算真正拿下了。别小看这100分C卷里它往往就是决定你是否能进入下一轮的临门一脚。
返回列表