十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

华为机考经典编程题解析:约瑟夫环、字符串去重与错误记录

华为机考经典编程题解析:约瑟夫环、字符串去重与错误记录 在牛客网上搜“华为编程题”跳到前排的依然有2016年研发工程师岗位那套题。N8输出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个数为例数组是{01234567}删除顺序是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) { queueint 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) 0f(i) (f(i-1) K) % i其中K是报数间隔这道题每隔两个删一个等价于K3。这个递推式的含义是当有i个人时最后幸存者的下标等于有i-1个人时的幸存者下标加上偏移量K然后对i取模。为什么是这样我来拆解一下。当第一轮删除结束后从被删除元素的下一个位置开始重新编号。假设当前有i个人第一轮被删除的下标是(K-1)%i那么原来的下标K就成了新一轮的0号位K1成了1号位以此类推。如果我们在规模为i-1的问题中知道了幸存者的相对位置f(i-1)那么把它映射回原始下标就需要加上K这个偏移量。因为映射是按模i循环的所以最后还要对i取模。手推一遍N8的情况会更清楚f(1) 0f(2) (03)%2 1f(3) (13)%3 1f(4) (13)%4 0f(5) (03)%5 3f(6) (33)%6 0f(7) (03)%7 3f(8) (33)%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; }这里有一个隐藏细节递推时是从i2开始循环到in对应的初始值f(1)0。如果你在考试时忘了为什么可以临时拿小数据验证一下比如N3时删除顺序是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个也就是K3如果是“每隔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; vectorErrRecord 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函数里写完整输入输出的习惯即使题目看起来只需要补充一个函数。因为实际的在线编码环境经常要求你自己处理输入平时不练考试时会非常被动。我个人在实际陪跑过程中发现能一遍通过这三道题的人机考成绩普遍不会差。原因不一定是他们算法学得多深而是代码的稳定性足够高。笔试这种东西稳定大于一切。最后再分享一个小技巧。这三道题做完之后试着把它们的解题思想迁移到新题里。约瑟夫环的递推思想可以用在一切“按周期删除并循环”的问题上字符集合的标记数组可以迁移到“判断字符串是否包含重复字符”错误记录的状态维护就是典型的“滑动窗口去重计数”。把一道题吃透到能迁移比做过十道题但忘了九道要强得多。
返回列表