1. 问题背景与核心诉求解析
最近在整理一些高校计算机专业研究生复试的机试真题,发现北京邮电大学的一道关于“复数集合”的题目出镜率相当高,而且常常和“优先队列”这个数据结构绑定在一起出现。这道题本身不算复杂,但恰恰是这种“数据结构+特定规则”的组合,非常考验考生对基础数据结构的灵活运用能力,以及将实际问题抽象为计算模型的基本功。很多同学一看到“复数”、“集合”、“优先队列”这几个词堆在一起就有点发懵,其实拆解开来,每一步都有清晰的逻辑可循。
这道题的核心诉求是什么呢?简单来说,你需要维护一个动态的“复数集合”。这个集合不是普通的集合,它有一系列特殊的操作规则,其中最关键的规则是:当需要从集合中删除元素时,不是删除最先加入的,也不是删除最后加入的,而是删除“模最大的”那个复数。如果存在多个模相同的复数,则删除其中“先加入集合”的那一个。这个“按特定优先级出队”的需求,正是优先队列(Priority Queue)的典型应用场景。所以,题目的难点不在于复数的运算,而在于如何利用优先队列,或者更准确地说,如何设计优先队列中元素的“优先级比较规则”,来满足题目给出的、略显复杂的删除逻辑。
2. 优先队列的本质与定制化比较器
要解决这个问题,我们首先得吃透“优先队列”在这个场景下的玩法。在很多编程语言的标准库中,优先队列默认是一个“大顶堆”(Max Heap),即队首(top)元素永远是当前队列中“最大”的那个。这里的“大”和“小”,完全由我们定义的比较规则来决定。对于整数,默认就是数值大小;对于字符串,可能是字典序。但对于我们自定义的复数结构体,我们必须明确告诉优先队列:什么叫“大”。
根据题目要求,删除时的优先级顺序是:
- 模长较大的复数优先级更高(应该先出队)。
- 模长相同时,先加入集合的复数优先级更高(应该先出队)。
这里有一个非常关键的细节,也是容易踩坑的地方:优先队列的“优先级”定义,与我们直观理解的“谁该先出去”有时是相反的。我们需要仔细思考堆顶应该存放什么。
方案一:大顶堆,直接定义“优先级高”我们可以定义:对于两个复数,模长大的“优先级更高”;模长相同时,进入时间早的“优先级更高”。那么,在一个大顶堆中,优先级最高的元素就会位于堆顶。当执行删除操作时,我们直接弹出堆顶元素,这个元素正好就是“模最大(若模同则时间最早)”的那个,完美符合题目要求。这种思路最直观。
方案二:小顶堆,反转比较逻辑我们也可以定义:对于两个复数,模长大的“优先级更低”;模长相同时,进入时间早的“优先级更低”。那么,在一个小顶堆中,堆顶存放的就是“优先级最低”的元素,即“模最小(若模同则时间最晚)”的那个。这显然不符合我们的删除目标。因此,如果我们想用小顶堆,就需要在比较器里做反向处理,或者不直接使用堆顶元素。这种方案比较绕,一般不推荐。
所以,最清晰、最不容易出错的方案就是采用大顶堆(Max Heap),并自定义比较器,让“该被删除的元素”拥有最高的优先级,从而位于堆顶。
接下来是具体的比较逻辑实现。我们需要为每个复数元素绑定一个唯一的“入队时间戳”或“序号”。在C++中,我们可以使用std::priority_queue,并为其提供一个自定义的比较函数对象(仿函数)。这个比较函数应该实现“严格弱序”。假设我们有一个结构体Complex,包含实部real、虚部imag和入队序号id。
那么,在比较两个复数a和b时:
- 首先比较模的平方(避免开方运算带来的精度和效率问题):
a.modSq = a.real*a.real + a.imag*a.imag。 - 如果
a.modSq > b.modSq,那么a的模更大,a的优先级应该比b高。在实现大顶堆的比较函数时,我们定义a的优先级高于b当且仅当a应该排在b前面。对于std::priority_queue,它默认使用std::less,会构造一个大顶堆,其比较是“小于”比较。但自定义比较器时,我们返回true表示第一个参数应该排在第二个参数之前。为了构造大顶堆,我们希望值大的在前,所以当a.modSq > b.modSq时,比较函数应返回true。 - 如果
a.modSq < b.modSq,显然a优先级低,返回false。 - 如果
a.modSq == b.modSq,则比较入队序号id。id小的(先入队的)优先级应该更高。所以,当a.id < b.id时,返回true。
注意:这里有一个常见的混淆点。
std::priority_queue的模板参数中,第三个参数是Compare比较类。默认的std::less会生成大顶堆,这意味着队列内部使用“小于”比较来维护堆序:如果comp(a, b)返回true,则a被认为“小于”b,在堆中会排在b的后面?不对,这里需要纠正一个关键理解。对于默认的std::less,它生成的堆是大顶堆,即最大的元素在堆顶。其底层实现是:如果a“小于”b,那么a的优先级就低于b。所以,为了让我们“模大且id小”的元素成为最大的(即优先级最高),我们需要定义一种“小于”关系,使得“该被删除的元素”比别的元素都“大”。换句话说,在我们的自定义比较器Comp中,Comp(a, b)返回true应当表示a的优先级低于b。这样,优先级最低的元素会在堆底,优先级最高的(我们想删除的)在堆顶。所以,逻辑应该是:当a的模平方小于b的模平方,或者模平方相等但a.id大于b.id时,a的优先级低于b,比较函数返回true。这个逻辑需要仔细捋顺,否则很容易写出错误的比较器导致结果完全相反。我建议在写代码时,先写几个测试用例,比如 (模3,id1) 和 (模5,id2),想想谁应该先出队(模5的),那么在这个比较器里,模5的应该“大于”模3的,即Comp(模3, 模5)应该返回true(表示模3“小于”模5)。下面我们会在代码部分具体实现。
3. 输入输出处理与程序框架设计
明确了核心数据结构,我们来看整个程序的流程。题目通常是模拟一个交互系统,输入包含多条命令,直到遇到特定命令(如“Pop”在空集合时,或结束符)为止。命令有两种:
- Insert命令:格式如
Insert 1+i2或Insert 1-i2。表示向集合中插入一个实部为1,虚部为2(或-2)的复数。 - Pop命令:格式就是
Pop。表示从集合中删除并输出那个优先级最高的复数(模最大,同模则最早加入)。
输出对应如下:
- 执行
Insert后,输出当前集合中的复数个数。格式如SIZE = k,其中k为插入后集合大小。 - 执行
Pop后,如果集合为空,输出empty。如果不为空,则输出被删除的复数,格式如1+i2(注意虚部为正时输出+i,为负时输出-i,虚部为0或±1时需特殊处理格式),然后输出当前集合大小SIZE = k。
程序框架设计如下:
- 定义一个复数结构体
Complex,包含实部r、虚部i和入队序号idx。 - 定义一个优先队列
priority_queue<Complex, vector<Complex>, Comp>,其中Comp是我们自定义的比较仿函数。 - 初始化一个计数器
idCounter = 0,用于分配入队序号。 - 循环读入字符串命令
cmd。 - 判断
cmd的前几个字符是"Insert"还是"Pop"。 - 如果是
Insert:- 解析后面的字符串,提取实部和虚部。这里涉及字符串处理,要注意虚部符号和
i字符的识别。一个稳健的方法是使用sscanf或字符串查找、分割函数。例如,字符串可能是"1+i2","1-i2","i2"(实部为0),"1"(虚部为0),"-i2"(实部0,虚部-2)等。需要全面考虑。 - 根据解析出的实部
a和虚部b,以及当前idCounter,构造一个Complex对象。 idCounter自增。- 将该对象插入优先队列。
- 输出
SIZE = queue.size()。
- 解析后面的字符串,提取实部和虚部。这里涉及字符串处理,要注意虚部符号和
- 如果是
Pop:- 检查队列是否为空。若空,输出
empty。 - 若不为空,取出堆顶元素(
queue.top()),将其弹出(queue.pop())。 - 格式化输出该复数。这里要特别注意输出格式:实部直接输出;虚部输出时,如果虚部
b > 0,则输出+i再输出b;如果b < 0,则输出-i再输出-b(因为b本身是负数);如果b == 0,则什么都不输出(或者只输出实部,题目通常要求如果虚部为0则不显示虚部)。此外,如果虚部绝对值为1,通常只输出+i或-i,不输出数字1。例如,1+i1应输出为1+i,1-i1应输出为1-i。这是格式上的一个坑点。 - 输出当前集合大小
SIZE = queue.size()。
- 检查队列是否为空。若空,输出
4. 关键代码实现与避坑指南
理论清晰了,我们来看具体实现,这里以C++为例,因为机试环境通常支持C++ STL。
首先定义结构体和比较器。这里采用之前分析的正确逻辑:在我们的比较器Comp中,operator()返回true表示第一个参数a的优先级低于第二个参数b。这样,优先级最高的(模最大,同模则id最小)会位于大顶堆的堆顶。
#include <iostream> #include <queue> #include <string> #include <cstdio> #include <cmath> using namespace std; struct Complex { int r; // 实部 int i; // 虚部 int id; // 入队序号,用于区分同模元素 // 可以顺便缓存模的平方,避免重复计算,但此题数据量不大,也可在比较时计算 // long long modSq; // r*r + i*i }; // 自定义比较器 struct Comp { bool operator()(const Complex& a, const Complex& b) { long long modSq_a = 1LL * a.r * a.r + 1LL * a.i * a.i; long long modSq_b = 1LL * b.r * b.r + 1LL * b.i * b.i; // 优先级规则:模长大的优先,模相同则id小的优先 // 如果a的优先级低于b,则返回true if (modSq_a != modSq_b) { // a模小,则a优先级低,返回true return modSq_a < modSq_b; } else { // 模相同,a的id大(后入队),则a优先级低,返回true return a.id > b.id; } // 这个比较器用于构造大顶堆,堆顶将是优先级最高的元素(模最大,同模id最小) } }; // 使用优先队列 priority_queue<Complex, vector<Complex>, Comp> pq;接下来是命令解析函数,这是另一个容易出错的地方。我们需要处理多种输入格式。
// 解析Insert命令后的字符串,如 "1+i2", "1-i2", "i2", "-i2", "1", "-1" bool parseComplex(const string& s, int& real, int& imag) { real = 0; imag = 0; size_t pos_i = s.find('i'); if (pos_i == string::npos) { // 没有'i',说明只有实部,虚部为0 real = stoi(s); imag = 0; return true; } // 找到'i'的位置后,分情况讨论 if (pos_i == 0) { // 字符串以'i'开头,例如 "i2", "-i2" // 实部为0 real = 0; string imagPart = s.substr(pos_i + 1); // "i"后面的部分 if (imagPart.empty()) { // 只有"i",默认为1 imag = 1; } else { imag = stoi(imagPart); } // 检查'i'前面的符号 if (s[0] == '-') { // 实际上是 "-i2" imag = -imag; } } else { // 'i'不在开头,例如 "1+i2", "1-i2", "-1+i3" // 先提取实部部分:从开头到'i'之前最后一个非数字字符(通常是+或-)之后 // 更稳健的做法:找到'i'之前最后一个+或-号 size_t op_pos = s.find_last_of("+-", pos_i - 1); if (op_pos == string::npos) { // 没有找到+或-,说明实部后直接跟'i',例如 "1i2"? 这种格式不标准,但可能是"1i2"代表1+i2 // 题目通常格式规范,我们按规范处理。假设格式为 "a+ib" 或 "a-ib" // 如果找不到符号,我们假设实部是整个字符串直到'i'的前一个字符 string realPart = s.substr(0, pos_i); real = stoi(realPart); // 虚部是'i'之后的部分 string imagPart = s.substr(pos_i + 1); imag = imagPart.empty() ? 1 : stoi(imagPart); // 虚部符号?这里需要看实部和'i'之间是否有隐含的+。题目输入通常是明确带符号的。 // 这是一个漏洞,需要根据题目具体输入约定来调整。最安全的方法是使用sscanf。 } else { // 找到了分隔实部虚部的运算符位置op_pos string realPart = s.substr(0, op_pos); real = realPart.empty() ? 0 : stoi(realPart); // 处理像 "+i2" 这种情况,实部为0 string imagPart = s.substr(pos_i + 1); imag = imagPart.empty() ? 1 : stoi(imagPart); // 确定虚部符号 if (s[op_pos] == '-') { imag = -imag; } } } return true; }实际上,上面的解析函数为了覆盖所有情况变得有些复杂。在机试的紧张环境下,更推荐使用sscanf或stringstream进行格式化读取,前提是题目输入格式严格。如果题目明确说明格式为a+ib或a-ib(其中a和b都是整数,b>0),那么解析会简单很多。但为了鲁棒性,我们可以假设输入格式就是a+ib或a-ib,其中a和b都是整数,b可能带符号(但通常b是正整数,符号由前面的+或-决定)。我们可以这样解析:
// 更简洁的解析,假设输入格式严格为 [实部][+或-]i[虚部数字],如 "1+i2", "1-i2", "i2", "-i2", "0+i5" bool parseComplexSimple(const string& s, int& real, int& imag) { real = 0; imag = 0; // 尝试用sscanf匹配多种格式 // 格式1: a+ib 或 a-ib char plus_minus; if (sscanf(s.c_str(), "%d%ci%d", &real, &plus_minus, &imag) == 3) { if (plus_minus == '-') { imag = -imag; } return true; } // 格式2: +ib 或 -ib (实部为0省略) if (sscanf(s.c_str(), "%ci%d", &plus_minus, &imag) == 2 && (plus_minus == '+' || plus_minus == '-')) { real = 0; if (plus_minus == '-') { imag = -imag; } return true; } // 格式3: ib (虚部,实部为0,且虚部符号为正) if (sscanf(s.c_str(), "i%d", &imag) == 1) { real = 0; return true; } // 格式4: -ib if (sscanf(s.c_str(), "-i%d", &imag) == 1) { real = 0; imag = -imag; return true; } // 格式5: 只有实部 a if (sscanf(s.c_str(), "%d", &real) == 1) { imag = 0; return true; } // 如果都不匹配,可能是非法输入,根据题目要求处理 return false; }主程序逻辑如下:
int main() { int idCounter = 0; string cmd; while (cin >> cmd) { if (cmd == "Pop") { if (pq.empty()) { cout << "empty" << endl; } else { Complex c = pq.top(); pq.pop(); // 格式化输出复数 cout << c.r; if (c.i > 0) { if (c.i == 1) cout << "+i"; else cout << "+i" << c.i; } else if (c.i < 0) { if (c.i == -1) cout << "-i"; else cout << "-i" << -c.i; // 注意这里输出的是-i和虚部的绝对值 } // 如果虚部为0,则什么也不加 cout << endl; cout << "SIZE = " << pq.size() << endl; } } else if (cmd.substr(0, 6) == "Insert") { // 提取Insert后面的字符串,可能有空格,题目通常是一行一个命令 // 假设输入是 "Insert 1+i2" 整体作为一个字符串读入cmd // 我们需要分离出"Insert"和"1+i2" // 更常见的输入方式是:先读命令字符串cmd,如果是Insert,再读一个字符串表示复数 string complexStr; cin >> complexStr; // 读取复数字符串 int real, imag; if (parseComplexSimple(complexStr, real, imag)) { pq.push({real, imag, idCounter++}); cout << "SIZE = " << pq.size() << endl; } else { // 处理解析失败,但题目通常保证输入正确 } } else { // 其他命令或结束符,根据题目要求可能跳出循环 // 例如有的题目以一行"0"结束 break; } } return 0; }避坑指南与实操心得:
比较器逻辑是重中之重:这是本题的核心考点。一定要在纸上画两个复数,按照题目要求的删除顺序,确定谁应该先出队,然后推导出在比较函数中,什么情况下返回
true(表示第一个参数优先级更低)。写完后,用几个测试用例验证一下,比如插入 (1, i1, id1) 和 (2, i0, id2),模分别是 sqrt(2)≈1.41 和 2,显然(2,0)应该先出队。在你的优先队列里,堆顶应该是(2,0)。检查你的比较器:Comp((1,1), (2,0))应该返回true(因为(1,1)模小,优先级低)。多测试几组同模不同id的情况。输出格式必须严格匹配:机试是机器判题,格式错误就是零分。特别注意:
- 虚部为0时,只输出实部。例如
3+i0应该输出3,而不是3+i0或3+0i。 - 虚部为±1时,只输出
+i或-i,不输出1。例如1+i1输出1+i,1-i1输出1-i。 - 虚部为正时,有
+号;为负时,是-号。例如1+i2和1-i2。 - 实部为0时,也要正确输出。例如
0+i5输出i5?不,通常输出+i5或i5?根据题目示例来。常见的是i5。但我们的输出逻辑是:如果实部为0,我们输出0吗?还是直接以虚部开头?例如复数0+5i,通常数学上简写为5i。但在题目中,可能需要输出i5或5i?这必须看题目示例。我上面给出的代码输出的是0+i5或0-i5,这可能不符合要求。需要调整:当实部为0时,不输出“0”,直接输出虚部部分。例如,实部0,虚部5,输出+i5还是i5?如果虚部是-5,输出-i5。这里又是一个坑点。务必仔细阅读题目输出说明和样例!
- 虚部为0时,只输出实部。例如
字符串解析要健壮:机试的输入格式通常是规整的,但自己写解析函数时要考虑边界情况,比如
i后面没有数字(代表虚部为1),+i或-i单独出现,实部或虚部为负数等。使用sscanf可以简化很多,但要注意其返回值匹配的参数数量。数据类型与溢出:计算模的平方时,
r*r + i*i可能超出int范围。题目中实部虚部可能是绝对值不超过1000的整数,平方和可能达到2e6,仍在int范围内(约21亿以内)。但为了安全,使用long long存储平方和是更稳妥的做法,尤其是在比较器中。优先队列的底层容器:
priority_queue<Complex, vector<Complex>, Comp>这里第二个模板参数是底层容器,通常用vector。确保Complex结构体是可拷贝的。处理空集合的Pop:这是基本逻辑,别忘了。
5. 测试用例与调试技巧
写完代码后,必须用多个测试用例验证。自己设计测试用例覆盖以下场景:
- 基础功能:
- 输入:
Insert 1+i2,Insert 3-i4,Pop,Pop。 - 预期:插入后分别输出
SIZE = 1,SIZE = 2。第一次Pop应输出模大的3-i4(模5),然后输出SIZE = 1。第二次Pop输出1+i2(模≈2.236),SIZE = 0。
- 输入:
- 同模比较:
- 输入:
Insert 1+i2(id0, 模√5≈2.236),Insert 2+i1(id1, 模√5≈2.236),Pop。 - 预期:两个复数模相同,应该删除先插入的(id0),即
1+i2。
- 输入:
- 虚部为0或±1的格式:
- 输入:
Insert 1+i0,Insert 2-i1,Pop,Pop。 - 预期:第一次Pop输出
2-i(注意不是2-i1),第二次Pop输出1(注意不是1+i0)。
- 输入:
- 实部为0的格式:
- 输入:
Insert 0+i3,Insert 0-i4,Pop。 - 预期:输出
-i4(模4)还是0-i4?根据题目要求调整。通常可能输出-i4。
- 输入:
- 空集合Pop:
- 输入:
Pop。 - 预期:输出
empty。
- 输入:
- 复杂序列:
- 混合Insert和Pop,验证动态过程是否正确。
调试时,可以在关键位置打印中间变量,比如每次Insert后打印队列中所有元素的模和id(需要遍历队列,但优先队列不能直接遍历,可以临时拷贝出来打印),或者每次Pop前打印堆顶元素的信息,确保比较器工作符合预期。
6. 性能分析与扩展思考
对于机试题,数据规模通常不会太大,priority_queue的插入和删除操作都是 O(log N) 的复杂度,完全够用。这道题的重点在于正确实现逻辑,而非优化性能。
不过,我们可以做一些扩展思考:
- 如果要求实时查询集合中模最大的复数,但不删除,怎么做?这就是优先队列的
top()操作,O(1) 复杂度。 - 如果删除规则变为“模最小的”呢?只需要修改比较器,将模的比较方向反过来即可。或者更简单,使用小顶堆(
priority_queue<Complex, vector<Complex>, greater<Complex>>,但需要重载Complex的>运算符,其逻辑也是定义“优先级低”的顺序)。 - 如果复数实部虚部是浮点数怎么办?比较模大小时要注意浮点数的精度问题。通常使用平方和比较,避免开方。对于浮点数,直接比较平方和即可,但要注意溢出问题(浮点数有范围)。同模判断不能直接用
==,要使用fabs(a-b) < eps的形式。 - 除了优先队列,还有其他数据结构能实现吗?理论上,任何能维护有序集合的数据结构都可以,比如平衡二叉搜索树(
set或multiset),但需要自定义排序规则。插入和删除也是 O(log N),但代码可能更直观(因为set本身有序,最大元素可以直接用rbegin()获取)。不过,题目通常点名“优先队列”,考察的就是对这个特定数据结构的应用。
这道“复数集合”题,本质上是一个“最大堆(或带自定义优先级的最大堆)”的模拟应用题。它把数据结构理论和简单的数学计算、字符串处理结合在一起,非常经典。在准备复试机试时,这类题目值得反复练习,直到能快速、准确、一次性地写出无bug的代码。理解清楚比较器的定义,处理好输入输出的细节,你就拿下了这道题的关键分。