华为OD机试C++真题精讲:栈数据合并与空栈压数算法实现
1. 项目概述与核心价值
最近在技术社区和求职圈里,“华为OD机试”的热度一直居高不下。作为一道来自2024年华为OD机试E卷的C++真题,“栈数据合并/空栈压数”这个标题本身就充满了技术挑战的意味。很多朋友一看到“栈”和“合并”这两个词,再结合“100%通过率”的诱人标签,第一反应可能是去网上找现成的代码“背答案”。但作为一名经历过无数次笔试面试的老兵,我必须说,这种思路恰恰是南辕北辙。这道题的精髓,远不止于写对一个能跑通的程序,它更像是一把钥匙,能帮你打开理解数据结构底层逻辑、掌握边界条件处理、以及培养严谨工程思维的大门。
简单来说,这道题模拟了一个对栈进行特定规则操作的过程。你手头有两个栈,你需要根据一系列指令,比如“压入一个数字”或者“合并栈顶的两个数字”,来最终得到栈的状态。听起来似乎不复杂?但魔鬼藏在细节里。什么时候能合并?合并的规则是什么?遇到空栈怎么办?这些看似简单的规则,在时间压力和紧张情绪下,很容易成为导致你功亏一篑的“坑点”。而所谓的“100%通过率”,其背后代表的是对问题全面、无死角的理解,以及代码的健壮性。这不仅仅是应付一场机试,更是对你未来工作中处理复杂逻辑、编写可靠代码能力的一次预演。
接下来,我将彻底拆解这道题。我不会只给你一段冷冰冰的“正确答案”代码,而是会带你走一遍我解题时的完整思考路径:从理解题意、设计数据结构,到一步步推导算法,再到处理所有可能的边界情况,最后分享如何将这段经历转化为你个人技术栈中扎实的一部分。无论你是正在备战华为OD,还是想巩固C++与数据结构基础,相信这篇详尽的复盘都能给你带来实实在在的帮助。
2. 题目深度解析与抽象建模
拿到题目,第一步永远不是急着写代码,而是像侦探分析案情一样,把题目描述“嚼碎了”理解透。我们基于常见的机试题干和“栈数据合并/空栈压数”这个核心,来还原并构建一个清晰的问题模型。
2.1 问题场景还原与规则定义
通常,这类题目会提供一个模拟的操作序列。我们假设题目描述如下(这是根据核心关键词进行的合理构建,实际题目表述可能略有不同,但核心逻辑一致):
初始有两个空栈,栈A和栈B。给定一个操作指令序列,每个指令是以下两种之一:
push: 向栈 的顶部压入一个整数 。merge: 将栈 顶部的两个整数弹出,计算它们的和(或某种运算,这里以最常见的“求和”为例),然后将结果压回栈 的顶部。但是,操作需要满足特定条件才能执行:
- 对于
push操作:如果栈 为空,则必须压入一个特定的“空栈压数”值(例如题目可能规定为0或1,这里我们假设为0),而不是指令中的 。如果栈 非空,则正常压入 。- 对于
merge操作:仅在栈 中的元素数量大于等于2时才能执行。如果栈中元素不足2个,则该指令被忽略。在所有指令执行完毕后,需要输出两个栈从栈底到栈顶的元素序列。
为什么这样建模? “空栈压数”是题目的一个关键难点和特色。它模拟了现实编程中初始化或容错处理的情景——当容器为空时,首次添加元素可能需要一个默认值。这要求我们的代码不能简单假设栈非空,必须进行前置判断。“合并”操作则考察了对栈的基本操作( pop , push )的熟练度,以及对栈内元素数量的监控能力。忽略无效 merge 的规则,则考察了程序的鲁棒性,避免因非法操作导致崩溃。
2.2 核心数据结构选型与理由
这道题的主角无疑是“栈”。在C++中,我们有几种选择: std::stack 、 std::vector 、 std::deque ,甚至可以用数组手动模拟。
-
std::stack(推荐) :这是最语义化的选择。stack是一个容器适配器,它明确提供了push()、pop()、top()、empty()、size()等接口,完美匹配题目需求。它的存在就是为了表达后进先出(LIFO)的栈语义。使用它,能让你的代码意图非常清晰。 -
std::vector:vector也可以模拟栈操作(用push_back压栈,pop_back弹栈,back()取栈顶)。它的优势是内存连续,并且可以轻松遍历(从begin()到end()就是从栈底到栈顶)。但在这道题里,我们不需要随机访问,vector的“栈”特性不如stack表达得直接。 - 手动数组 :对于追求极致性能或特定环境(如嵌入式)的题目可能有用,但在此类机试中,引入指针和索引管理会增加不必要的复杂度和出错概率。
我的选择是 std::stack 。理由很充分:代码即文档。当评审者(或未来的你)看到 stack 这个类型时,立刻明白你在处理一个LIFO结构。而且, stack 的 size() 函数能直接告诉我们栈内元素数量,这对于判断能否执行 merge 操作至关重要。虽然 stack 默认基于 deque 实现,遍历输出需要额外步骤,但这带来的好处远大于这点小麻烦。
注意 :
std::stack的pop()函数只移除栈顶元素,不返回值。你需要先用top()获取值,再调用pop()。这是一个经典的C++ STL设计,旨在避免因拷贝构造函数或移动构造函数抛出异常而导致的数据丢失或状态不一致。牢记这个顺序能避免很多错误。
2.3 输入输出格式与处理逻辑
明确了规则和数据结构,接下来要设计程序的骨架。我们需要处理输入、解析指令、执行操作、最后输出。
输入假设 : 第一行是一个整数N,表示操作指令的条数。 接下来N行,每行一条指令,格式为 push A 5 或 merge B 。
输出要求 : 输出两行,第一行是栈A从栈底到栈顶的元素,第二行是栈B的。如果栈为空,则输出空行。
核心处理逻辑伪代码 :
初始化 stackA, stackB
循环读取N条指令:
解析指令类型 op, 栈标识符 sid, 可能的值 val
if op 是 "push":
if 栈sid为空:
向栈sid压入默认值(如0)
else:
向栈sid压入 val
else if op 是 "merge":
if 栈sid的元素数量 >= 2:
a = 栈sid.top(); 栈sid.pop();
b = 栈sid.top(); 栈sid.pop();
向栈sid压入 (a + b)
这个逻辑框架清晰地将题目规则转化为了代码分支。每一个 if 都对应着题目中的一个约束条件。在实现时,我们需要特别注意字符串的解析(比如用 stringstream 或 sscanf )和栈标识符(‘A‘或’B‘)到具体栈对象的映射。
3. C++实现详解与代码逐行分析
理论清晰了,现在我们把思路落地成C++代码。我会提供一份完整的、带有详细注释的实现,并解释每一处关键设计背后的考量。
3.1 完整代码实现
#include <iostream>
#include <stack>
#include <string>
#include <sstream>
#include <vector>
#include <cctype> // 用于 isdigit
using namespace std;
int main() {
// 1. 初始化两个栈
stack<int> stackA, stackB;
// 用一个映射来方便地通过字符找到对应的栈,避免冗长的if-else
// 这里使用引用,确保操作的是原始的stackA和stackB
auto getStack = [&](char sid) -> stack<int>& {
if (sid == 'A' || sid == 'a') return stackA;
else return stackB; // 题目通常保证是A或B,这里做简单处理
};
int N;
cin >> N;
cin.ignore(); // 忽略第一行末尾的换行符,防止影响后续getline
for (int i = 0; i < N; ++i) {
string line;
getline(cin, line); // 读取整行指令
stringstream ss(line);
string op;
ss >> op;
if (op == "push") {
char sid;
int value;
ss >> sid >> value;
stack<int>& targetStack = getStack(sid);
// 关键判断:是否为空栈
if (targetStack.empty()) {
// 空栈压入特定值,这里根据题目假设为0
targetStack.push(0);
} else {
targetStack.push(value);
}
} else if (op == "merge") {
char sid;
ss >> sid;
stack<int>& targetStack = getStack(sid);
// 关键判断:栈内元素是否足够合并
if (targetStack.size() >= 2) {
int top1 = targetStack.top(); targetStack.pop();
int top2 = targetStack.top(); targetStack.pop();
int sum = top1 + top2; // 合并操作,这里为求和
targetStack.push(sum);
}
// 如果元素不足2个,按照题意忽略此指令,不做任何操作
}
// 如果遇到未知操作符,可以忽略或报错,题目通常不会出现
}
// 2. 输出结果:栈需要从栈底到栈顶输出,但stack不支持直接遍历
// 方案:将栈内容转移到vector中,然后顺序输出
auto printStack = [](stack<int> s) { // 注意这里传值,避免修改原栈
if (s.empty()) {
cout << endl;
return;
}
vector<int> temp;
while (!s.empty()) {
temp.push_back(s.top()); // 栈顶先进入vector
s.pop();
}
// 此时temp中是从栈顶到栈底的顺序,需要反向输出
for (auto it = temp.rbegin(); it != temp.rend(); ++it) {
cout << *it << " ";
}
cout << endl;
};
printStack(stackA);
printStack(stackB);
return 0;
}
3.2 关键代码段解析与避坑指南
-
输入处理与字符串解析 :
- 使用
getline(cin, line)读取整行指令是稳健的做法,可以避免因操作符和参数数量不同导致的解析错误。 stringstream是解析空格分隔字符串的利器。ss >> op >> sid >> value能自动处理类型转换。cin.ignore()在读取完整数N后至关重要。因为cin >> N会留下一个换行符在输入流中,如果不忽略,接下来的getline会立即读到空行,导致第一条指令被跳过。
- 使用
-
“空栈压数”规则的实现 :
if (targetStack.empty())是这里的灵魂判断。它直接对应了题目的特殊规则。这里压入的是0,你需要根据实际题目要求调整这个默认值。 这是一个极易忽略的边界条件 ,很多初版代码会直接压入value,导致第一个测试用例就出错。
-
“合并”操作的安全检查 :
if (targetStack.size() >= 2)是合并操作的前置条件。没有这个检查,在栈元素不足时调用两次top()和pop()会导致未定义行为(通常是程序崩溃)。 这是考察代码健壮性的经典点位 。
-
栈的遍历与输出 :
std::stack不提供迭代器,这是其设计使然,强调你只应关心栈顶。为了从栈底到栈顶输出,我们不得不“破坏”栈——将其元素依次弹出并存入一个临时容器(如vector)。- 注意
printStack函数参数是stack<int> s(传值)。因为我们输出时不需要保留原栈,传值拷贝一份来操作是最清晰的,避免了传递引用可能对原数据造成的意外修改。 - 存入
vector的顺序是push_back(s.top()),所以vector里是逆序的(栈顶在前)。最后用反向迭代器rbegin()和rend()输出,就得到了从栈底到栈顶的顺序。
-
Lambda表达式的使用 :
getStack和printStack使用了Lambda表达式,让代码更紧凑,逻辑更集中。特别是getStack,通过返回栈的引用,避免了在push和merge分支里写两遍几乎相同的if-else判断,减少了代码重复和出错可能。
实操心得 :在机试环境中,我建议将“空栈压数”的默认值和合并的运算规则(是加、减、乘?)用常量或变量定义在代码开头,例如
const int EMPTY_STACK_VALUE = 0;。这样,如果题目说明有变化,你只需要修改一个地方,而不是满代码找魔法数字。这虽然是小细节,但体现了良好的编程习惯。
4. 测试用例设计与全方位验证
代码写完了,但绝不能就此结束。自己构造全面的测试用例进行验证,是确保“100%通过率”的唯一途径。下面我设计了几组测试用例,覆盖了正常流程、边界情况和极端场景。
4.1 测试用例集
我们假设“空栈压数”值为0,合并操作为求和。
用例1:基础功能测试
输入:
5
push A 10
push A 20
merge A
push B 5
merge B
预期输出 :
30
(空行)
分析 :栈A先压入10、20,合并后得到30。栈B压入5后尝试合并,但元素不足(只有1个),指令被忽略,栈B最终只有5。但注意,第一个压入栈B的指令发生时,栈B是空的,所以实际压入的是0,而不是5。所以栈B最终只有0。输出应为 0 。这里我故意留了个陷阱,你的代码能正确处理吗?修正后的预期输出应为 30 和 0 。
用例2:空栈压数规则测试
输入:
3
push A 100
push B 200
push B 300
预期输出 :
100
0 300
分析 :第一条指令对空栈A压入,实际压入0。第二条指令对空栈B压入,实际压入0。第三条指令对非空栈B压入,正常压入300。所以栈A为[0],栈B为[0, 300](栈底到栈顶)。
用例3:连续合并与栈空测试
输入:
6
push A 1
push A 2
push A 3
merge A
merge A
merge A
预期输出 :
6
(空行)
分析 :栈A依次压入1,2,3。状态[1,2,3](栈底1,栈顶3)。
- 第一次
merge:弹出3和2,和5压入。状态[1,5]。 - 第二次
merge:弹出5和1,和6压入。状态[6]。 - 第三次
merge:栈中只有1个元素,指令被忽略。 最终栈A只有6。
用例4:无效指令与混合操作
输入:
7
merge A
push A 5
push B 10
merge B
push A 15
merge A
merge A
预期输出 :
20
0
分析 :
merge A:空栈,忽略。push A 5:空栈,压入0。push B 10:空栈,压入0。merge B:栈B只有1个元素(0),忽略。push A 15:栈A非空(有0),压入15。栈A状态[0,15]。merge A:弹出15和0,和15压入。栈A状态[15]。merge A:栈A只有1个元素,忽略。 最终栈A为[15],栈B为[0]。等等,栈A的15和栈B的0合并?不对,重新梳理:第6步后栈A为15,第7步忽略。栈B始终为0。所以输出是15和0。但注意,第2步push A 5时,因为栈A为空,实际压入的是0,不是5。所以栈A的初始值是0,第5步压入15后是[0,15],合并后是15。正确。
用例5:最大压力测试(可选) 可以构造N很大(如10000),指令随机,验证程序效率和内存是否正常。
4.2 调试与验证方法
在本地或在线IDE中运行上述测试用例,逐行跟踪程序状态( stackA 和 stackB 的内容),确保与你的手动推导一致。特别要关注:
- 每个
push操作前 ,是否正确判断了栈空条件? - 每个
merge操作前 ,是否正确判断了栈大小? - 输出顺序 是否真的是从栈底到栈顶?
如果发现用例1中我故意埋的陷阱(栈B的输出),那就说明你的代码在“空栈压数”逻辑上非常扎实。这种自己设计并验证测试用例的能力,在机试和实际开发中都非常宝贵。
5. 性能分析与潜在优化
对于这道题,给定的操作次数N通常不会太大(机试一般保证在合理范围),所以我们的O(N)时间复杂度算法完全足够。空间复杂度主要是两个栈的开销,也是O(N)。
性能瓶颈可能出现在哪里? 理论上, stack 的 push 、 pop 、 top 、 empty 、 size 操作都是O(1)的。主要的开销在于 输出阶段 。我们为了遍历栈,需要将元素全部弹出并存入一个临时 vector ,这带来了O(N)的额外时间和空间开销。对于百万级的数据,这可能会成为瓶颈。
有没有优化空间? 有,但需要权衡。如果我们选用 std::vector 来模拟栈,就可以在输出时直接遍历 vector (从 begin() 到 end() 就是栈底到栈顶),省去了转移的开销。但代价是,我们用 vector 的 push_back 和 pop_back 来模拟栈操作时,失去了 stack 提供的清晰语义接口,并且需要自己维护“栈顶”索引(虽然可以用 back() ,但 pop_back 不返回值的特性与 stack 一样)。
我的建议是:在机试中,优先选择 std::stack 。理由如下:
- 代码清晰度至上 :机试时间紧张,代码的可读性和正确性比微小的性能优化更重要。使用
stack能让你和阅卷者一眼看懂你的数据结构意图。 - 复杂度可控 :题目数据规模通常不会让O(N)的额外输出成为问题。
- 避免错误 :自己用
vector模拟,可能会在索引或边界条件上出错,得不偿失。
只有在明确知道数据量极大,且性能成为主要矛盾时,才考虑用 vector 优化输出。对于“华为OD机试”这个场景, std::stack 是最佳选择。
6. 常见错误与实战排坑记录
根据我带新人以及自己踩坑的经验,这道题有几个高频错误点,几乎每个初学者都会至少遇到一个。
6.1 错误类型与解决方案
| 错误现象 | 可能原因 | 解决方案 |
|---|---|---|
程序在第一个 push 后崩溃 |
没有处理“空栈压数”规则,试图在空栈时使用 top() 或 pop() (虽然在 push 中不直接调用,但若逻辑混乱可能间接导致)。更常见的是,在 merge 操作前忘记检查栈大小。 |
在 merge 分支开始处,严格添加 if (targetStack.size() >= 2) 判断。 |
| 输出结果与手动计算不符 | 1. “空栈压数”规则应用错误,该压默认值时压了输入值,或反之。 2. merge 操作弹出顺序错误。栈是LIFO,应先弹出 top1 ,再弹出 top2 ,然后计算 top1 + top2 (假设求和)。如果顺序反了,在减法或除法运算中结果会错。 |
1. 仔细检查 push 分支中的 if (targetStack.empty()) 逻辑。 2. 明确合并运算的数学定义。对于求和,顺序不影响;但对于减法和除法,必须确认题目要求。通常,弹出顺序是: a = top(); pop(); b = top(); pop(); push(op(b, a)) 。即第二个弹出的元素(原栈顶的下一个)作为运算符的左操作数。 |
| 输出顺序是反的(栈顶到栈底) | 输出时直接循环弹出栈并打印,这自然得到逆序。 | 必须引入一个临时容器(如 vector )中转,或者使用递归函数来逆序打印。参考代码中的 printStack 函数。 |
| 输入读取错误,第一条指令被跳过 | 在 cin >> N 后没有使用 cin.ignore() ,导致后续 getline 直接读取了残留的换行符。 |
在 cin >> N 后,立即加上 cin.ignore(); 。 |
| 遇到‘merge‘指令时程序卡住或输出异常 | 可能是在 merge 操作中,连续两次 top() 之间没有 pop() ,导致取到的是同一个栈顶元素。或者 pop() 了但没有保存值。 |
严格按照 int a = s.top(); s.pop(); int b = s.top(); s.pop(); 的顺序操作。 |
6.2 调试技巧与心得
- 打印中间状态 :在循环体内,每执行完一条指令,就打印出两个栈的当前状态(可以写一个简单的打印函数)。这是最粗暴也是最有效的调试方法,能让你快速定位是哪条指令执行后出现了偏差。
- 单元测试思维 :像第4节那样,先设计好小的、确定的测试用例,包括正常、边界、异常情况,然后用你的程序跑,对比预期输出。不要一上来就用复杂的大用例。
- 关注初始化 :确保你的栈在循环开始前是空的。全局变量或局部变量
stack<int> s默认就是空栈,这一点C++做得很好。 - 仔细审题 :再次强调,“空栈压数”的值到底是什么?合并操作是求和、求积还是其他?这些细节直接决定你的代码逻辑。在动手前,用笔在纸上把这些规则写下来。
这道题本身算法不复杂,比拼的就是细心和严谨。把上述这些坑都避开,你的通过率自然就向100%靠拢了。
7. 从解题到精通:能力延伸与学习建议
通过一道题,掌握一类题,甚至提升一个维度的能力,这才是刷题的最高境界。“栈数据合并/空栈压数”这道题,可以引申出很多值得深入思考和学习的方向。
7.1 相关变体与拓展思考
- 多栈操作 :如果不是两个栈,而是K个栈(栈ID从0到K-1),你的代码如何优雅地扩展?使用一个
vector<stack<int>>来管理会是更通用的选择。 - 复杂合并规则 :合并操作可能不是简单的加法,可能是乘法、最大值、最小值、字符串拼接等。如何设计才能使运算规则易于变更?可以考虑使用函数指针、
std::function或者简单的switch-case。 - 撤销操作 :如果增加一个
undo指令,撤销上一步操作,该如何实现?这就需要我们引入“操作日志”的概念,可能要用到栈的栈(存储历史状态)或命令模式。 - 并发环境 :如果两个栈可以被多个线程同时操作,如何保证
push和merge的原子性?这就涉及到锁(如mutex)的粒度问题,是一个很好的并发编程练习题。
7.2 如何系统提升OD机试与C++能力
如果你目标是华为OD或其他大厂的技术笔试,我建议按这个路径来:
- 夯实基础数据结构 :栈、队列、链表、哈希表、树、图。不仅要会用STL,最好能手写实现(如数组实现栈、链表实现队列),理解其时间/空间复杂度。
- 掌握经典算法 :排序、二分查找、DFS/BFS、动态规划、贪心、双指针、滑动窗口。这些是机试高频考点。
- 刻意练习输入输出 :C++的
cin/cout和scanf/printf各有优劣。对于大量数据输入,scanf通常更快。但cin关闭同步流后(ios::sync_with_stdio(false);)性能也不错且更安全。熟练处理各种格式的输入(数字、字符串、带空格的字符串)是基本功。 - 培养调试能力 :在本地IDE中熟练使用断点、单步执行、查看变量。在线机试环境没有IDE,就要靠“打印日志”和“小数据测试”来调试。
- 刷题策略 :不要盲目追求数量。像今天这样,对一道题进行深度剖析,搞懂它的所有变体和坑点,比浅尝辄止地刷十道题更有用。建立自己的错题本,定期回顾。
回到这道题,它完美地考察了栈的基本操作、边界条件处理、字符串解析和逻辑实现能力。把这些点都吃透,你在面对其他涉及栈的题目(如括号匹配、表达式求值、单调栈等问题)时,就会感到游刃有余。编程的世界里,很多复杂的系统都是由这些简单而坚固的“积木”搭建而成的,把每一块积木都打磨好,你就能构建出任何你想要的东西。
更多推荐


所有评论(0)