在牛客网上搜“华为编程题”,跳到前排的依然有2016年研发工程师岗位那套题。N=8输出6的删数、abcqweracb去重、还有那个绕人的错误记录——这三道题我前前后后看了不下五遍,每次给备战校招或OD机试的朋友讲题都会拎出来当引子。这套题难吗?说实话,放在今天也就中等偏下的水平。但它有个很有意思的特点:三道题恰好覆盖了笔试里最常翻车的三种能力——抽象建模、边界意识、状态维护。这篇文章不打算罗列一堆答案了事,而是把每道题的完整推导过程、代码实现、以及网上很少被讲清楚的选型理由和易错细节都拆开说透。无论你是刚开始刷题准备机考,还是已经有半年经验想回头补基础,这篇都值得认真看。
1. 这三道题为什么过了快十年还在被反复翻出来
1.1 华为机考关键词背后的持续性热度
把近两年的技术热搜词拉出来看一眼,华为OD机试、华为机考、OD上机考试、华为机考ASIC、华为OD面试手撕代码,这些词几乎常年挂在榜单上。华为相关岗位的招聘流程里,机考是绕不开的第一道关卡。而机考的出题风格,某种程度上就是从2016年前后那批校招编程题延续下来的。
很多备考同学会有一个误区,觉得“2016年的题太老了,现在肯定不考了”。但你去牛客网上看题目的浏览量和收藏量,这套题的数据一直居高不下。原因有两个:第一,它是最接近真实机考风格的公开题目,难度、区分度、表述习惯都带有明显的华为特色;第二,题目考察的不是某个冷门算法,而是通用的编码基本功,这类能力永远不会过时。
我跟不少参加过华为机考的读者聊过,他们的反馈几乎一致:机考真正拉开差距的,往往不是最后那道压轴难题,而是前面两道看似简单的题。有人因为输入处理不对直接0分,有人因为边界条件没考虑全被扣掉大量用例,有人因为用了复杂的数据结构反而把自己绕晕。这些情况,在2016年这套题里全都能找到对应。
1.2 一套题覆盖的三种关键能力
先给这套题做一个整体画像。三道题分别对应三种不同的能力维度:
| 题目标题 | 核心考点 | 对应能力 | 常见翻车方式 |
|---|---|---|---|
| 删数 | 约瑟夫环 | 抽象建模与数学推导 | 只会模拟,数据一大就超时 |
| 字符集合 | 哈希去重 | 代码基本功与细节处理 | 顺序错乱、字符范围考虑不全 |
| 简单错误记录 | 字符串处理+状态维护 | 工程思维与边界设计 | 路径解析错误、8条窗口维护混乱 |
这三道题的难度曲线是平的,没有一道是典型的“压轴难题”,但它们组合在一起,刚好模拟了真实研发工作中最常遇到的编码场景:你要么在写一个需要数学建模的算法,要么在处理带脏数据的字符串,要么在维护一个有容量限制的记录系统。
把这个能力矩阵记在心里,再去做题,你就不会只是“感觉这题会做”,而是能清楚地知道自己每一个选择背后的理由。这也是我带人刷题时最强调的一点:笔试不是背题,是能力的映射测试。
2. 删数:约瑟夫环的模拟与递推,考场上的选型题
2.1 原题与样例:每隔两个删一个,删到最后一个
原题描述如下:有一个数组a[N]顺序存放0到N-1,要求每隔两个数删掉一个数,到末尾时循环至开头继续进行,求最后一个被删掉的数的原始下标位置。以8个数为例,数组是{0,1,2,3,4,5,6,7},删除顺序是0->1->2(删除2),然后3->4->5(删除5),然后6->7->0(删除0),如此循环直到所有数都被删除。
输入是N,输出是最后一个被删掉的数的原始下标。样例输入8,样例输出6。
要注意,这里问的是“最后一个被删掉的数”,不是“最后幸存者”。但仔细想想,当删除过程持续到只剩一个数时,这个唯一的数也会被删掉,所以它既是最后的幸存者,也是最后一个被删除的对象。这道题就是在问约瑟夫环问题中最后幸存者的下标。
2.2 模拟解法:队列轮转最直观
最容易想到的解法就是完全按照题目描述去模拟。用一个队列,把所有数放进去,然后循环:弹出队头元素,计数器加1,如果计数器是3的倍数就删掉这个元素,否则把它放回队尾。重复这个过程直到队列为空,最后弹出的元素就是答案。
#include <iostream> #include <queue> using namespace std; int main() { int n; while (cin >> n) { queue<int> q; for (int i = 0; i < n; i++) q.push(i); int cnt = 0, last = -1; while (!q.empty()) { int cur = q.front(); q.pop(); cnt++; if (cnt % 3 == 0) { last = cur; } else { q.push(cur); } } cout << last << endl; } return 0; }这个写法为什么用队列?因为题目要求“到末尾时循环至开头”,这和队列“队头出、队尾进”的特性天然吻合。每次报数不到3的元素重新入队,相当于模拟了循环到开头的过程,不需要手动维护下标。
我见过有同学用链表来模拟,也能跑通,但代码长度和出错概率都会上升。对于这种循环删除的场景,队列是最短路径。唯一要小心的是最后那个变量last,它记录的是最近一次被删除的元素,当队列为空时,last就是最后一个被删掉的数。
2.3 数学解法:递推公式到底在推什么
模拟解法易懂,但效率是O(N*K)级别的,K等于3。当N很大的时候,比如N是10的7次方,队列方案就撑不住了。这时候需要用数学递推。
约瑟夫环问题的标准递推式是:
- f(1) = 0
- f(i) = (f(i-1) + K) % i
其中K是报数间隔,这道题每隔两个删一个,等价于K=3。这个递推式的含义是:当有i个人时,最后幸存者的下标等于有i-1个人时的幸存者下标,加上偏移量K,然后对i取模。
为什么是这样?我来拆解一下。当第一轮删除结束后,从被删除元素的下一个位置开始重新编号。假设当前有i个人,第一轮被删除的下标是(K-1)%i,那么原来的下标K就成了新一轮的0号位,K+1成了1号位,以此类推。如果我们在规模为i-1的问题中知道了幸存者的相对位置f(i-1),那么把它映射回原始下标,就需要加上K这个偏移量。因为映射是按模i循环的,所以最后还要对i取模。
手推一遍N=8的情况会更清楚:
- f(1) = 0
- f(2) = (0+3)%2 = 1
- f(3) = (1+3)%3 = 1
- f(4) = (1+3)%4 = 0
- f(5) = (0+3)%5 = 3
- f(6) = (3+3)%6 = 0
- f(7) = (0+3)%7 = 3
- f(8) = (3+3)%8 = 6
最后结果6,和样例一致。代码非常短:
#include <iostream> using namespace std; int main() { int n; while (cin >> n) { int f = 0; for (int i = 2; i <= n; i++) { f = (f + 3) % i; } cout << f << endl; } return 0; }这里有一个隐藏细节:递推时是从i=2开始循环到i=n,对应的初始值f(1)=0。如果你在考试时忘了为什么,可以临时拿小数据验证一下,比如N=3时,删除顺序是0,1,2,第一个被删的是2,然后0被删,最后被删的是1,递推结果f(3)=1,对得上。
2.4 选型建议:数据规模决定写法
这题最值得学习的地方不是两种解法本身,而是如何根据数据规模选型。
如果题目没有给出N的范围,稳妥的做法是看时间限制。经典的华为机考时间限制是1秒左右,模拟解法在N超过10的5次方时就开始吃力,到10的7次方级别基本必挂。而递推解法是O(N),N到10的8次方也能在1秒内跑完(C++)。
我的建议是:笔试中一旦看到“循环删除”“报数出圈”“每隔几个删一个”这类描述,优先想约瑟夫环递推。模拟解法可以作为验证递推结果的辅助手段,用在小数据上测试,不要依赖它去跑大数据用例。
另外提醒一个容易绕晕的点:“每隔两个数删掉一个”是删除第3个,也就是K=3;如果是“每隔3个数删掉一个”,K就是4。把题目描述翻译成K的时候多读一遍,这个错误一旦发生,调试起来非常浪费时间。
3. 字符集合:去重题里最不起眼的三个坑
3.1 原题与样例:去重且保持原顺序
原题描述:输入一个字符串,求出该字符串包含的字符集合,按字母输入顺序输出,重复出现的字符不再输出。输入字符串最大长度为100,且只包含字母,不可能为空串,区分大小写。每组数据一行输出,按字符串原有的字符顺序输出字符集合。
样例输入:abcqweracb,样例输出:abcqwer。
这题看起来简单,不过是在遍历过程中判断字符是否出现过,没出现过就输出。但我在帮人review代码时,发现翻车的比例比想象中高得多。下面把高频问题拆开讲。
3.2 标记数组:比想当然的集合更靠谱
先给基础实现:
#include <iostream> #include <string> #include <cstring> using namespace std; int main() { string s; while (cin >> s) { bool mark[128] = {false}; string res; for (char c : s) { if (!mark[c]) { mark[c] = true; res += c; } } cout << res << endl; } return 0; }核心就是一个长度为128的bool数组。把字符的ASCII码当作下标,出现过就置true。为什么用128而不是26?因为题目说“只包含字母,区分大小写”,那么大小写字母加起来是52个,ASCII码表里大小写字母并不连续,中间还夹着一些其他字符。如果只开52的数组,需要手动做下标映射,反而容易出错。直接用128覆盖整个ASCII可见字符范围,一劳永逸。
还有同学用unordered_set来做去重,也能得到正确答案。但从性能角度讲,布尔数组的访问是O(1)且常数极小,也不会涉及哈希函数的计算开销和潜在的冲突处理,在笔试场景下更推荐。
3.3 三个容易失分的细节
第一个坑:数组越界。如果把mark开成bool mark[26],然后直接mark[c-'a'],遇到大写字母就数组越界了。题目明确说区分大小写,A和a是两个不同字符。有些同学平时刷题习惯了只处理小写字母,换了个题型就踩进去。
第二个坑:输出顺序。题目要求“按字母输入顺序输出”,不是“按字典序输出”。我见过有人先建集合再排序,输出了排好序的字符集合,用例全挂。要保持原顺序,正确做法是遍历原字符串,第一次出现的字符才追加到结果里。
第三个坑:多组输入。题目没有明确说会有多少组数据,但华为机考的输入输出习惯是可能包含多组。如果不写while(cin >> s)循环,只处理一次就return,那么遇到多组输入时,后续数据全部没处理,得分直接砍半。这个点在很多简单的字符串题里都存在,养成“看到输入流就想到多组”的条件反射,能避免大量失分。
3.4 变体提醒:字符集不再是纯字母时
有的变体题目会把“只包含字母”改成“包含大小写字母和数字”,甚至不限制字符范围。这时候最安全的做法是把标记数组开到256,或者直接开一个unordered_set 。另外,如果字符串里可能包含空格,cin >> s就废了,得用getline来读整行。变体题不会太难,但往往就是在这种小地方埋雷。
再补充一个实际操作建议:写完代码后,自己构造两个边界用例验证一下。第一个是全部字符都重复,比如“aaaaa”,期望输出“a”;第二个是全部字符都不重复,比如“abc”,期望输出“abc”。这两个用例跑通,这道题基本就稳了。
4. 简单错误记录:文件路径、16字符截断和8条上限的连环套
4.1 原题与规则拆解:八条上限、循环覆盖、截断16字符
这道题是华为2016研发工程师编程题里信息量最大的一道。原题描述:
开发一个简单错误记录功能小模块,能够记录出错的代码所在的文件名称和行号。处理规则:
- 记录最多8条错误记录,对相同的错误记录(即文件名称和行号完全匹配)只记录一条,错误计数增加;
- 超过8条时,只记录最后8条;
- 对文件名称进行处理:输入包含文件名和行号,文件名可能带路径,只保留文件名部分;如果文件名长度大于16,只保留最后16个字符;
- 输入文件名为路径形式,如 a/b/c.txt,需要只保留 c.txt。
每组数据一行输入,输出所有有效记录,格式为:文件名+空格+行号+空格+出现次数。
这道题的关键不是算法,而是能不能在复杂规则下把状态维护清楚。很多人挂在两件事上:路径解析错误,以及8条窗口的覆盖逻辑混乱。
4.2 路径解析的坑:/和\同时存在的处理
题目示例里用的是正斜杠a/b/c.txt,但实际测试数据里很可能出现Windows风格的反斜杠路径,比如D:\code\main.cpp。你需要把路径末尾的文件名提取出来。
很多人的第一反应是写一个循环从后往前找‘/’,找到就截取。这没问题,但如果路径里同时包含两种分隔符呢?稳妥做法是用C++的find_last_of,一次性匹配两个字符:
string getFileName(const string& path) { size_t pos = path.find_last_of("/\\"); string file = (pos == string::npos) ? path : path.substr(pos + 1); return file; }注意find_last_of的参数是"/\",在C++字符串里需要转义。这里的含义是:在字符串中从后往前找,无论是正斜杠还是反斜杠,都视为路径分隔符。这样写可以覆盖绝大多数输入形式,比单独处理某一种分隔符可靠得多。
提取文件名后再做长度判断,超过16个字符就取最后16个字符:
if (file.size() > 16) { file = file.substr(file.size() - 16); }这里有个细节:截断是在提取文件名之后进行的,不是对完整路径截断。如果拿完整路径去截最后16位,结果会包含目录前缀,直接错。
4.3 先截断还是先判重:顺序错了结果就错了
这是最隐蔽的一个规则点。相同错误记录的定义是“文件名称和行号完全匹配”。这里的文件名称,指的是经过路径提取和截断处理之后的文件名,还是原始路径?答案是前者。
我举个例子。假设两条记录:
- /home/test/longfilename12345error.cpp 行号100
- /opt/test/longfilename12345error.cpp 行号100
这两个路径不同,但如果文件名超过16字符,截断后可能都变成一样的长文件名。如果按照处理后的文件名去判重,这两条会被认为是同一条错误,计数值合并;如果按原始路径判重,会被当成两条不同记录。题目规则说“对文件名称进行处理”之后再记录,所以正确的顺序必须是:先提取文件名,再截断16字符,然后用处理后的字符串去判重。
代码写法:
bool isSameRecord(const ErrRecord& a, const ErrRecord& b) { return a.file == b.file && a.line == b.line; }其中a.file和b.file已经是截断后的文件名。千万别拿原始path去比较。
4.4 8条窗口的维护:简单结构反而更稳
另一个高频翻车点是8条上限的维护。规则说的是“超过8条时,只记录最后8条”,这不是简单把数组开大然后截断,而是要在插入过程中动态淘汰最早的记录。
有人上来就用map维护<文件名+行号, 计数>,然后发现超过8条时无法知道哪条是最早的,因为map按键排序,和插入顺序无关。要记录插入顺序,还需要额外维护一个链表或队列。结构一复杂,代码就乱。
最稳妥的方案是用vector存记录,每次插入前先遍历一遍查重。为什么敢这么干?因为上限是8条,线性查找最多比较8次,常数极小,完全不用担心性能。这属于典型的“数据规模确定就选最简单结构”的思路。
具体逻辑:
- 遍历vector,查找是否有文件名和行号都匹配的记录;
- 有则对应记录的计数加1;
- 没有则判断vector.size()是否等于8,等于8就先删除头部元素(最早的记录);
- 把新记录push_back到队尾。
这个方案只用vector一个结构,代码容易写对,也容易审查。
4.5 完整实现与复盘
#include <iostream> #include <string> #include <vector> using namespace std; struct ErrRecord { string file; int line; int cnt; }; string getFileName(const string& path) { size_t pos = path.find_last_of("/\\"); string file = (pos == string::npos) ? path : path.substr(pos + 1); if (file.size() > 16) { file = file.substr(file.size() - 16); } return file; } int main() { string path; int line; vector<ErrRecord> records; while (cin >> path >> line) { string file = getFileName(path); bool found = false; for (auto& rec : records) { if (rec.file == file && rec.line == line) { rec.cnt++; found = true; break; } } if (found) continue; if (records.size() == 8) { records.erase(records.begin()); } records.push_back({file, line, 1}); } for (const auto& rec : records) { cout << rec.file << " " << rec.line << " " << rec.cnt << endl; } return 0; }复盘一下关键点。getFileName同时处理正反斜杠,截断16字符紧跟其后。判重时使用处理后的文件名。8条上限通过erase(begin)加push_back维护,插入顺序就是输出顺序。
易错点总结成一张表:
| 易错点 | 错误做法 | 正确做法 |
|---|---|---|
| 路径分隔符 | 只找/ | 同时找/和\\ |
| 截断对象 | 对完整路径截断 | 先提取文件名再截断 |
| 判重依据 | 用原始路径比较 | 用截断后的文件名比较 |
| 超出8条 | 丢弃新记录 | 删除最旧记录再插入新记录 |
| 计数 | 每次直接覆盖 | 相同记录计数累加 |
5. 从2016校招到OD机试:这代机考变了什么,又没变什么
5.1 机考形式的演变:从三道题到OD上机考试
2016年的校招机考,基本就是这套三道题的结构,难度偏基础,重点考察编码基本功。到2025年,华为相关岗位的机考形式已经变成了更规范的在线考试系统,OD机试通常包含多道题,可能涉及数组、字符串、栈、队列、二分、动态规划、贪心算法等更广的范围,模式也从“补充函数”到“完整ACM模式”都有。
形式变了,但底层的考察逻辑没有变。我看了大量机考反馈后得出的结论是:现在的机考更看重——能不能在有限时间内写出有足够健壮性的代码,能不能在题目里包含复杂输入输出格式的情况下不出bug,能不能在数据量大到不能用暴力解法时找到正确优化方向。这三个能力,恰好就是2016年这套题在考察的东西。
5.2 不变的考察内核
如果只能用一个词概括这套题的考察内核,我会选“细节”。三道题没有一道需要背模板,但每一道都需要你认真处理边界。
删数考的是数据规模感知,不知道N的范围时敢不敢用模拟,知道范围后能不能想到递推;字符集合考的是字符编码的基本认知,数组要开多大,顺序能不能保持;错误记录考的是需求拆解,规则那么多,先做哪一步后做哪一步,window怎么维护。
这些能力不是临时背题能补的,而是要平时就有意识训练。我个人的经验是,每道简单题都至少给自己提三个问题:数据范围最大是多少?有没有多组输入?如果输入格式和题目描述有出入怎么办?把这套“灵魂三问”练成习惯,机考上会少丢很多冤枉分。
5.3 用这三道题做一次高质量刻意练习
如果你现在正准备华为OD机试或校招机考,我给你一套具体的练习方案。
第一轮:限时60分钟,把三道题独立写完。不参考任何题解,过程中记录自己卡壳的地方。绝大多数人会卡在第3题的错误记录上,这是正常的,说明你对规则拆解还不够熟练。
第二轮:不看代码,只看题目描述,给自己讲一遍每道题的解题思路。尤其是约瑟夫环的递推式,能不能把“为什么是(f(i-1)+K)%i”讲给一个完全不懂的人听。讲得清楚,才是真懂。
第三轮:改进代码。要求不再依赖模拟解法,递推解法闭眼能写;错误记录这个实现用list代替vector试试看,体会一下迭代器维护和索引维护的区别;字符集合题目试着处理带空格输入的变体版本。
三轮下来的收获,比盲目刷20道新题更实在。
另一个建议是:机考练习时,一定要养成从main函数里写完整输入输出的习惯,即使题目看起来只需要补充一个函数。因为实际的在线编码环境经常要求你自己处理输入,平时不练,考试时会非常被动。
我个人在实际陪跑过程中发现,能一遍通过这三道题的人,机考成绩普遍不会差。原因不一定是他们算法学得多深,而是代码的稳定性足够高。笔试这种东西,稳定大于一切。
最后再分享一个小技巧。这三道题做完之后,试着把它们的解题思想迁移到新题里。约瑟夫环的递推思想可以用在一切“按周期删除并循环”的问题上;字符集合的标记数组可以迁移到“判断字符串是否包含重复字符”;错误记录的状态维护就是典型的“滑动窗口+去重计数”。把一道题吃透到能迁移,比做过十道题但忘了九道要强得多。