从北邮机试题看优先队列实战:自定义比较器与复数模最大问题

📅 2026/8/24 9:40:19
从北邮机试题看优先队列实战:自定义比较器与复数模最大问题
1. 项目概述从一道复试机试题看优先队列的实战应用最近在整理一些知名高校的计算机专业复试机试题北京邮电大学的这道“复数集合”题目让我眼前一亮。它初看平平无奇就是维护一个集合支持插入复数、查询并删除模最大的复数。但题目要求用“优先队列”来实现这就把一道简单的模拟题变成了一个考察数据结构底层理解和灵活运用的绝佳案例。很多同学一看到“优先队列”可能下意识就想到priority_queue默认的大顶堆然后直接往里塞复数对象。但如果你真这么做了大概率会在比较规则上卡壳或者写出效率不高的代码。这道题的精髓恰恰在于如何根据“复数模最大”这一特定需求去定制优先队列的行为这比单纯调用API要深刻得多。这道题非常适合正在准备复试或希望夯实数据结构基础的朋友。通过它你不仅能复习复数的基本运算和优先队列的用法更能深入理解如何为自定义数据类型设计比较器Comparator这是学习C STL乃至其他语言中类似容器如Java的PriorityQueuePython的heapq时必须跨越的一道坎。我会从最基础的题意分析开始一步步拆解思路给出多种实现方案并分享其中容易踩坑的细节和调试技巧。无论你是机试新手还是想温故知新相信这篇内容都能给你带来实实在在的收获。2. 核心思路解析为什么优先队列是解题关键2.1 题意拆解与需求分析我们先来仔细读题。题目要求我们维护一个“复数集合”这个集合需要支持两种操作插入Insert向集合中加入一个复数格式如 abi或 a-bi。删除并输出Pop从集合中找出模绝对值最大的那个复数将其从集合中删除并输出该复数。如果集合为空时执行此操作需输出特定提示。这里的关键约束是“模最大”。复数的模计算公式为sqrt(a*a b*b)。我们需要一个数据结构能让我们在每次执行“弹出”操作时都能以尽可能高的效率理想是O(1)或O(log N)拿到当前集合中模最大的那个元素。为什么数组或链表直接存储不行如果用普通数组或链表每次查询最大模都需要遍历整个集合时间复杂度是O(N)当插入和删除操作频繁交替时这是机试题的典型场景整体效率会退化为O(N^2)无法通过大规模数据测试。2.2 优先队列的登场与定制化思考此时“优先队列”Priority Queue就该登场了。它是一种抽象数据类型其特性是每次从队列中取出的元素都是当前队列中“优先级最高”的元素。在C STL中std::priority_queue默认实现为一个大顶堆Max-Heap即优先级最高的元素是值最大的元素。一个常见的误解是直接把复数对象塞进默认的priority_queue。但priority_queue默认使用operator来比较元素对于自定义的复数结构体或类如果我们没有重载运算符编译器会报错。即使我们重载了默认的比较的是对象本身而不是我们关心的“模”。因此我们必须告诉优先队列“请按照复数的模来比较大小模大的优先级高”。这就需要用到比较器Comparator。我们可以通过两种方式实现在自定义的复数结构体中重载运算符但使其行为变为“模小的反而‘小于’模大的”因为priority_queue默认是最大堆它认为“最大”的元素在堆顶而“最大”是通过比较出来的“更大者”。这种方式有点绕容易出错。更清晰、更推荐的方式是为priority_queue显式指定一个自定义的比较类或Lambda表达式。这个比较器应该定义一种“小于”关系使得对于任意两个复数c1和c2如果c1的模小于c2的模那么comp(c1, c2)返回true这样c2模更大的就会被视为“更大”从而排在堆顶。注意这里有一个非常关键的思维转换点。priority_queue的第三个模板参数是“比较类”Compare它默认是std::less这个类会调用元素的运算符。当我们传入一个自定义比较器Comp时priority_queue内部会用它来构建堆。这个比较器应该实现一个严格的弱序。对于最大堆我们希望堆顶是“最大”元素那么比较器应该让“较小”的元素按我们定义的规则在排序中靠前即被判断为“小于”。简单记如果你希望堆顶是最大值你的比较器应该模拟std::less的行为即返回true当第一个参数“小于”第二个参数但这个“小于”是你根据模的大小自己定义的。3. 方案设计与实现细节3.1 数据结构定义与比较器设计首先我们需要一个结构体来表示复数。struct Complex { int real; // 实部 int imag; // 虚部 // 构造函数方便初始化 Complex(int r 0, int i 0) : real(r), imag(i) {} // 计算模的平方。为什么是平方因为比较模的大小等价于比较模的平方的大小可以避免耗时的开方运算。 long long norm2() const { return (long long)real * real (long long)imag * imag; } // 也可以计算模但比较时用平方更高效。 // double norm() const { return sqrt(norm2()); } };接下来是核心比较器。我们设计一个函数对象仿函数。// 方式一定义比较类仿函数 struct CompareByNorm { bool operator()(const Complex c1, const Complex c2) { // 注意我们希望模大的复数优先级高在最大堆的顶部 // 在priority_queue中如果此函数返回true则c1会被认为“优先级低于”c2从而c2更靠近堆顶。 // 所以当c1的模平方“小于”c2的模平方时我们返回true这样c2模更大的优先级更高。 return c1.norm2() c2.norm2(); // 更严谨的写法考虑模相等时按题目要求通常按输入顺序但优先队列不保证稳定排序不过对于同模长的复数任意顺序输出通常都可接受 // 如果题目要求模相同时按其他规则如实部、虚部可以在这里补充。 // if (c1.norm2() ! c2.norm2()) return c1.norm2() c2.norm2(); // else if (c1.real ! c2.real) return c1.real c2.real; // else return c1.imag c2.imag; } };有了结构体和比较器我们就可以声明优先队列了。// priority_queue元素类型, 底层容器类型默认vector, 比较器类型 priority_queueComplex, vectorComplex, CompareByNorm pq;3.2 输入处理与操作分发机试题的输入通常是标准输入。操作指令有两种以开头的插入和单独的-表示弹出。int main() { int n; while (cin n) { // 多组输入直到EOF priority_queueComplex, vectorComplex, CompareByNorm pq; for (int i 0; i n; i) { string op; cin op; if (op ) { // 输入格式: abi 或 a-bi string complexStr; cin complexStr; // 解析complexStr提取实部a和虚部b int a, b; char sign; // 虚部的符号 // 使用sscanf可以方便地解析这种格式字符串 // 注意虚部可能带符号格式如34i或3-4i sscanf(complexStr.c_str(), %d%c%di, a, sign, b); if (sign -) { b -b; // 如果符号是负号虚部取负 } pq.push(Complex(a, b)); cout SIZE pq.size() endl; } else if (op -) { if (pq.empty()) { cout empty endl; } else { Complex top pq.top(); pq.pop(); // 输出格式abi 或 a-bi (注意虚部为负时输出负号) cout top.real (top.imag 0 ? : ) top.imag i endl; cout SIZE pq.size() endl; } } } } return 0; }3.3 关键细节与避坑指南模的比较用平方而非开方这是非常重要的优化。sqrt函数是浮点数运算相对耗时且可能引入精度问题。比较a1*a1 b1*b1和a2*a2 b2*b2的整数结果完全等价于比较模的大小且快速准确。在比较器中我们正是使用了norm2()。整数溢出问题题目未明确给出实部虚部的范围但为了防止a*a或b*b在计算时超出int范围我们在norm2()函数中使用了long long类型进行运算和返回。这是一个良好的防御性编程习惯。输入格式解析使用sscanf是处理这种固定格式字符串的利器。%d%c%di分别匹配整数实部、字符虚部符号、整数虚部绝对值和结尾的字符i。注意处理虚部符号为-的情况。输出格式输出复数时当虚部为正或0时中间需要加号当虚部为负数时其自身带有-号中间就不需要再加号了。我们使用条件运算符(top.imag 0 ? : )来优雅地处理。优先队列的“最大堆”与比较器务必反复理解2.2节中的思维转换。可以这样验证假设有两个复数c1模小c2模大。我们的CompareByNorm(c1, c2)返回true因为c1.norm2() c2.norm2()。对于priority_queue返回true意味着在堆的排序中c1应该在c2之前不对恰恰相反。在STL的堆算法中用于排序的比较器comp满足如果comp(a, b)为true则a将排在b之前。对于最大堆的priority_queue它通过std::less默认来建堆std::less(a,b)为true意味着ab那么b更大的会被推到堆顶。当我们传入自定义的CompareByNorm时它取代了std::less。CompareByNorm(c1,c2)为true意味着“c1的模小于c2的模”此时priority_queue会认为c1“小于”c2因此会把c2模更大的放在堆顶。这正是我们想要的。如果觉得绕记住结论想要最大堆你的比较器应该在第一个参数“小于”第二个参数时返回true。4. 扩展探讨优先队列的其他玩法与常见陷阱4.1 使用Lambda表达式简化代码C11及以上如果你觉得单独定义一个比较类有点繁琐可以在声明优先队列时直接使用Lambda表达式这样代码更紧凑。// 注意Lambda表达式需要作为构造函数的参数传入并且需要decltype推导类型 auto cmp [](const Complex left, const Complex right) { return left.norm2() right.norm2(); // 同样的逻辑 }; // 声明优先队列需要将decltype(cmp)作为模板参数并将cmp作为构造函数参数 priority_queueComplex, vectorComplex, decltype(cmp) pq(cmp);这种方式在临时使用或比较逻辑简单时非常方便。但要注意decltype(cmp)获取的是Lambda的类型它是一个独特的、未命名的类型。4.2 如果题目要求弹出“模最小”的复数这其实就是求一个“最小堆”。有两种修改方式修改比较器逻辑让比较器在第一个参数模大于第二个参数模时返回true。这样模小的就会被认为“小于”模大的从而排在堆顶。struct CompareByNorm_MinHeap { bool operator()(const Complex c1, const Complex c2) { return c1.norm2() c2.norm2(); // 注意这里变成了大于号 } };更简单的方法直接使用std::greater作为比较器但前提是你要重载复数结构体的运算符使其基于模的比较。或者你可以结合std::greater和一个已经定义了operator基于模的结构体。不过为了清晰我仍然推荐自定义比较器。4.3 关于“pair”与优先队列的常见问题网络热词中提到了“c优先队列pair”。std::pair是STL中一个非常实用的模板类常用于将两个值捆绑在一起。当我们需要根据pair的某一个元素如first或second来排序时也需要特别注意。默认情况下priority_queuepairint, int会使用pair默认的运算符即先比较first如果相等再比较second。如果我们想根据pair的second成员构建最大堆就需要自定义比较器。// 假设我们有一个pairint, int想根据second的值构建最大堆 struct ComparePairBySecond { bool operator()(const pairint, int p1, const pairint, int p2) { // 希望second大的优先级高所以当p1.second p2.second时返回true return p1.second p2.second; } }; priority_queuepairint, int, vectorpairint, int, ComparePairBySecond pq;4.4 性能考量与替代方案对于本题优先队列的插入和删除操作时间复杂度都是O(log N)N为集合大小非常高效。这是最合适的解法。有没有其他数据结构理论上一个始终保持有序的集合如std::multiset配合自定义比较器也能在O(log N)内完成插入并且获取最大元素是O(1)。但是multiset的删除操作需要迭代器而“弹出最大元素”需要我们首先找到它rbegin()然后删除删除操作的平均复杂度也是O(log N)。两者复杂度相同但priority_queue的常数更小内存布局更紧凑基于数组的堆通常性能更好。set类容器基于红黑树节点是分散分配的缓存不友好。因此在这种只需要访问最大/最小元素的场景下优先队列是首选。5. 调试技巧与常见错误排查在实现这道题时以下几个点是常见的错误来源比较器逻辑写反这是最最常见的错误。表现为弹出的元素不是模最大的而是模最小的。快速检查方法插入几个模长相等的复数看弹出顺序是否符合预期或题目要求。如果题目对同模长复数无特殊要求任意顺序均可。如果逻辑反了把比较器中的改成试试或者反过来理解你的设计意图。输入解析错误特别是虚部为负数时的解析。调试方法在解析完a, sign, b后立即打印出来看看。例如输入 3-4i你应该看到a3, sign-, b4然后你将其处理为b-4。如果输出不对检查sscanf的格式字符串是否正确匹配了所有字符包括末尾的i。整数溢出如果实部虚部很大比如接近10^5平方后可能超过int范围约2e9。排查方法在norm2()函数中坚持使用long long。如果题目极端连long long都可能溢出则需要使用__int128如果编译器支持或手动进行高精度比较比较a1*a1和a2*a2的大小可以先比较绝对值等。输出格式错误机试系统通常是严格对比输出字符串的。多一个空格、少一个加号、在虚部为0或1时格式不对如输出30i还是3题目通常会明确本题要求输出abi格式即使b0或1都可能导致错误。应对策略仔细阅读题目输出说明并严格按照样例输出进行比对。容器未清空在处理多组测试数据时如果优先队列pq定义在循环外部一定要在每组数据开始前用while(!pq.empty()) pq.pop();清空或者更简单地将pq的声明放在while(cin n)循环内部如我给出的示例代码这样每组数据都会是一个全新的队列。这道“复数集合”题就像一把精巧的钥匙打开了理解和使用优先队列的大门。它告诉我们掌握一个数据结构不仅仅是记住它的API更要理解其内部逻辑如堆并学会如何让它适配各种自定义的排序规则。在解决更复杂的问题时比如Dijkstra算法中的优先队列优化、哈夫曼编码、求滑动窗口的中位数这正好对应了网络热词“优先队列怎样求中位数”等这种定制化能力至关重要。下次当你遇到需要动态获取极值的问题时不妨先想想能不能用优先队列又该如何定义它的“优先级”