华为OD机试“数大雁”题解:状态机与贪心策略实战

📅 2026/7/14 10:21:23
华为OD机试“数大雁”题解:状态机与贪心策略实战
1. 项目概述从“数大雁”看华为OD机试的实战逻辑最近在帮几个准备冲刺华为OD机试的朋友做模拟训练他们普遍反映一个痛点题库里的题目看答案好像都懂但自己一上手尤其是面对那些题干描述有点“绕”的题目就完全不知道从何切入。正好最近在整理E卷的复用真题时碰到了这道“数大雁”我觉得它是个非常典型的例子完美诠释了华为OD机试喜欢考察的那种“逻辑建模”和“边界处理”能力。这题乍一看像是在玩一个字符串匹配的找茬游戏但内核其实是对状态机思想和贪心策略的一次精巧应用。今天我就以这道题为例用C、Java、Python三种语言把解题的完整思路、代码实现以及那些容易踩坑的细节掰开揉碎了讲清楚。无论你是正在备战OD还是单纯对算法感兴趣相信这篇深度解析都能让你有所收获。简单来说“数大雁”这个题目的核心是给你一个由字符 ‘q’, ‘u’, ‘a’, ‘c’, ‘k’ 组成的字符串。这些字符模拟了大雁的叫声一个完整的雁群叫声序列必须是按顺序、完整地出现 “quack” 这五个字符并且字符可以被不同的“雁群”共享使用。题目要求你计算出最少有多少只大雁即最少有多少个完整的“quack”序列在同时鸣叫。如果给定的字符串无法被解释为若干只大雁的合法叫声比如字符顺序不对或者最后有字符未被匹配则返回 -1。这听起来是不是有点像“括号匹配”的升级版但它的匹配规则更复杂因为一个字符可能被多个“栈”所期待。2. 核心思路拆解为什么状态机与贪心是正解面对这个问题最直接的暴力想法可能是去搜索所有可能的“quack”子序列划分但字符串长度可能很大这种指数级复杂度显然不可行。我们需要一个高效且一次遍历就能解决的方案。这里的关键洞察在于一只大雁的叫声“quack”是一个严格有序的状态序列。我们可以把每只“虚拟的大雁”看作一个状态机它从等待 ‘q’ 开始依次经历 ‘u’ - ‘a’ - ‘c’ - ‘k’当收到 ‘k’ 后这只大雁完成了一次鸣叫其状态可以重置等待下一个 ‘q’ 开始新一轮鸣叫。但题目允许字符共享这意味着一个新来的 ‘q’既可以唤醒一只已经完成上一轮鸣叫、正在空闲的大雁也可以为一只新的大雁开启它的第一次鸣叫。那么如何求出最少的雁群数量呢这里就用到贪心策略当遇到一个字符时我们优先将它分配给一只已经处于对应前驱状态的大雁而不是轻易开启新的大雁。为什么要这样因为开启新的大雁会增加“同时鸣叫”的雁群数量我们的目标是最小化这个数量。只有当没有处于合适前驱状态的大雁时我们才不得不招募一只新的大雁。具体来说状态定义我们用五个状态来标识一只大雁的进度0:等待q, 1:已有q等待u, 2:已有qu等待a, 3:已有qua等待c, 4:已有quac等待k。状态转移遍历字符串的每个字符ch。如果ch ‘q’我们需要一只状态为 0空闲或完成的大雁。如果有将其状态置为1如果没有则必须创建一只新的大雁状态置为1。此时当前活跃大雁数即状态非0的大雁数可能增加。如果ch ‘u’我们需要一只状态为 1 的大雁将其状态更新为 2。如果没有状态1的大雁说明序列非法。ch ‘a’需要状态 2 - 状态 3。ch ‘c’需要状态 3 - 状态 4。ch ‘k’需要状态 4 - 状态 0。注意当一只大雁收到 ‘k’ 后它的状态回归0相当于它完成了本次鸣叫可以开始接收下一个 ‘q’。此时当前活跃大雁数减少。结果计算在整个遍历过程中我们需要维护一个峰值即某一时刻同时处于鸣叫状态状态1-4的大雁的最大数量这个峰值就是所需的最少雁群数。为什么不是最终状态因为大雁完成叫后状态0就空闲了不影响同时鸣叫的数量。遍历结束后必须检查是否所有大雁都回到了空闲状态即所有状态计数归零或只有状态0的计数不为零如果有大雁“叫到一半”状态1-4还有计数说明字符串不完整返回-1。这个思路的本质是维护了一个针对五个状态的计数器数组count[5]count[i]表示当前处于状态i的大雁有多少只。遍历过程就是驱动这些计数器根据规则流转。注意这里最容易混淆的点在于“最少大雁数”的定义。它指的是在整个鸣叫过程中同时处于活跃状态的大雁的最大数量而不是你总共创建过的大雁数量。因为大雁叫完是可以复用的。例如字符串 “quackquack”最优解是只用一只大雁叫完第一轮“quack”后状态归零接着叫第二轮“quack”同时活跃数始终为1。而字符串 “qquuack”则需要两只大雁交错进行才能解释。3. 代码实现与逐行解析理解了核心状态机与贪心思想后我们来看代码实现。我会分别用C、Java和Python给出清晰版本并附上详细注释。3.1 C 实现详解C版本注重效率和清晰的逻辑表达。#include iostream #include string #include vector #include algorithm using namespace std; int countGeese(string sounds) { // 状态映射0-q, 1-u, 2-a, 3-c, 4-k // count数组记录处于每个状态的大雁数量 vectorint count(5, 0); // 记录遍历过程中同时鸣叫大雁数的最大值即最少所需大雁数 int maxGeese 0; // 当前活跃的大雁数状态1-4的大雁总数 int activeGeese 0; // 预定义字符到目标状态的映射关系 // 当遇到字符char时它期望的前一个状态是prev[char]并将该状态的大雁转移到nextState[char] unordered_mapchar, int prevState {{q, 4}, {u, 0}, {a, 1}, {c, 2}, {k, 3}}; unordered_mapchar, int nextState {{q, 0}, {u, 1}, {a, 2}, {c, 3}, {k, 4}}; for (char ch : sounds) { int needState prevState[ch]; // 当前字符需要哪个状态的大雁 int gotoState nextState[ch]; // 匹配后大雁将转移到哪个状态 if (needState 4) { // 特殊情况字符 q 需要状态4即刚叫完k的空闲大雁或新建 // 优先使用空闲大雁 if (count[4] 0) { count[4]--; // 一只空闲大雁被唤醒 count[gotoState]; // 进入状态0不对注意gotoState对于q是0 // 实际上q的gotoState是0但我们的状态0代表等待q这有点绕。 // 更清晰的逻辑是 // q 期望前驱状态是4完成态匹配后进入状态0但我们的状态0是等待q这不对。 // 我们需要调整一下映射让状态0代表“已有q”状态1代表“已有qu”...状态4代表“已有quack”。 // 所以重新定义状态0:已有q, 1:已有qu, 2:已有qua, 3:已有quac, 4:已有quack(完成)。 // 那么字符映射为q需要状态4或新建-状态0u需要状态0-状态1a需要状态1-状态2c需要状态2-状态3k需要状态3-状态4。 } else { // 没有空闲大雁必须创建一只新的大雁初始状态为0已有q count[gotoState]; // gotoState0 activeGeese; // 新大雁加入活跃数1 } } else { // 处理 u, a, c, k if (count[needState] 0) { // 没有处于所需前驱状态的大雁序列非法 return -1; } // 消耗一只处于needState的大雁 count[needState]--; count[gotoState]; // 状态转移时的活跃数变化 if (gotoState 4) { // 一只大雁完成了鸣叫到达状态4它变为空闲活跃数应减少 // 但注意我们立刻将状态4视为完成可以认为count[4]的雁是空闲的。 // 因此在字符k处理中当大雁转移到状态4时实际上活跃数应该减少。 // 更优的设计不单独维护activeGeese变量而是每次遍历后计算状态1-4的总数作为当前活跃数。 } } // 计算当前活跃大雁数状态1,2,3,4之和并更新最大值 int currentActive count[1] count[2] count[3] count[4]; maxGeese max(maxGeese, currentActive); } // 遍历结束后检查是否所有大雁都完成了鸣叫即只有状态0和状态4的雁不我们的状态4是完成态 // 实际上合法的结束状态应该是所有大雁都处于状态4已完成或状态0从未开始。 // 根据我们的状态定义0:已有q, 4:已有quack合法的结束应该是所有大雁都在状态4已完成一次完整鸣叫。 // 但字符串可能结束于一次鸣叫的中间吗不可以那是不完整的。 // 所以遍历结束后除了状态4其他状态0,1,2,3的计数都必须为0。 for (int i 0; i 4; i) { if (count[i] 0) { return -1; } } // 同时状态4的雁数量应该大于0吗不一定如果字符串为空结果是0。但题目通常假设非空。 // 我们只需要其他状态为0即可状态4的雁是已经完成鸣叫的是合法的。 return maxGeese; } int main() { string sounds; cin sounds; int result countGeese(sounds); cout result endl; return 0; }上面的C代码在状态定义上做了调整但注释里揭示了其中的纠结。这是因为最初的状态设计0:等q, 1:有q等u, …在处理’q’需要前驱状态4空闲时与数组索引的直观性产生了冲突。让我们重构一个更清晰、更通用的版本这也是面试中更推荐写法int minNumberOfGeese(string sounds) { // 清晰状态定义state[i] 表示当前处于阶段 i 的大雁数量 // 阶段: 0: 刚收到 q (已有q) // 1: 刚收到 u (已有qu) // 2: 刚收到 a (已有qua) // 3: 刚收到 c (已有quac) // 4: 刚收到 k (已有quack即完成) vectorint state(5, 0); int maxActive 0; // 字符到其所需前一个阶段的映射 unordered_mapchar, int prev {{q, -1}, {u, 0}, {a, 1}, {c, 2}, {k, 3}}; // 字符到其自身阶段的映射 unordered_mapchar, int cur {{q, 0}, {u, 1}, {a, 2}, {c, 3}, {k, 4}}; for (char ch : sounds) { int need prev[ch]; int go cur[ch]; if (ch q) { // q 需要前驱阶段是 -1即没有要求它总是可以开始 // 但优先使用已经完成阶段4的大雁 if (state[4] 0) { // 复用一只已完成的大雁 state[4]--; state[go]; } else { // 没有可复用的大雁创建一只新的 state[go]; } } else { // u, a, c, k 需要特定的前驱阶段 if (state[need] 0) { // 没有处于所需前驱阶段的大雁序列非法 return -1; } // 消耗一只处于need阶段的大雁将其转移到go阶段 state[need]--; state[go]; } // 计算当前活跃大雁数阶段0,1,2,3的大雁阶段4是已完成不算活跃 int currentActive state[0] state[1] state[2] state[3]; maxActive max(maxActive, currentActive); } // 遍历结束后所有大雁必须都处于完成状态阶段4 // 即阶段0,1,2,3都必须为0 for (int i 0; i 4; i) { if (state[i] 0) { return -1; } } return maxActive; }这个版本就清晰多了。核心是state数组记录各个“进度”的大雁数量。‘q’字符特殊它可以从阶段4已完成的大雁中复用也可以新建。其他字符则严格需要前一个阶段有大雁存在。每次操作后计算处于“未完成”状态0~3的大雁总数其最大值即为答案。3.2 Java 实现详解Java版本在逻辑上与C完全一致但利用了HashMap和更简洁的数组操作。import java.util.HashMap; import java.util.Scanner; public class Main { public static void main(String[] args) { Scanner sc new Scanner(System.in); String sounds sc.next(); System.out.println(minGeese(sounds)); sc.close(); } public static int minGeese(String sounds) { // state[0]: 已有 q 的大雁数 // state[1]: 已有 qu 的大雁数 // state[2]: 已有 qua 的大雁数 // state[3]: 已有 quac 的大雁数 // state[4]: 已有 quack (已完成) 的大雁数 int[] state new int[5]; int maxActive 0; // 建立字符到其所需前驱状态和当前状态的映射 HashMapCharacter, Integer prevMap new HashMap(); HashMapCharacter, Integer curMap new HashMap(); prevMap.put(q, -1); // q 不需要特定前驱状态 prevMap.put(u, 0); prevMap.put(a, 1); prevMap.put(c, 2); prevMap.put(k, 3); curMap.put(q, 0); curMap.put(u, 1); curMap.put(a, 2); curMap.put(c, 3); curMap.put(k, 4); for (int i 0; i sounds.length(); i) { char ch sounds.charAt(i); int needState prevMap.get(ch); int goState curMap.get(ch); if (ch q) { // 优先复用已完成的大雁state[4] if (state[4] 0) { state[4]--; state[goState]; } else { // 没有可复用的创建新的大雁 state[goState]; } } else { // 对于 u, a, c, k必须存在处于needState的大雁 if (state[needState] 0) { return -1; } state[needState]--; state[goState]; } // 计算当前未完成活跃的大雁数状态0,1,2,3 int currentActive state[0] state[1] state[2] state[3]; if (currentActive maxActive) { maxActive currentActive; } } // 结束后所有大雁必须都已完成处于状态4 // 即状态0,1,2,3必须全部为0 for (int i 0; i 4; i) { if (state[i] ! 0) { return -1; } } return maxActive; } }Java实现中使用HashMap使得字符到状态索引的映射非常清晰。注意在遍历过程中实时更新maxActive以及最后对非法序列的检查这些是容易遗漏的边界条件。3.3 Python 实现详解Python版本以其代码简洁著称我们可以用字典和列表更优雅地实现。def min_number_of_geese(sounds: str) - int: # 状态数组state[0]~state[4] 分别代表已有 q, qu, qua, quac, quack 的大雁数量 state [0] * 5 max_active 0 # 字符映射关系 # prev: 当前字符需要哪个前驱状态 # cur: 当前字符使大雁进入哪个状态 prev {q: -1, u: 0, a: 1, c: 2, k: 3} cur {q: 0, u: 1, a: 2, c: 3, k: 4} for ch in sounds: need_state prev[ch] go_state cur[ch] if ch q: # 遇到 q优先使用已完成的大雁state[4] if state[4] 0: state[4] - 1 state[go_state] 1 else: # 没有已完成的大雁创建一只新的 state[go_state] 1 else: # 遇到 u, a, c, k必须存在处于 need_state 的大雁 if state[need_state] 0: return -1 state[need_state] - 1 state[go_state] 1 # 计算当前活跃未完成的大雁数状态0~3 current_active sum(state[:4]) max_active max(max_active, current_active) # 遍历结束后所有大雁必须都已完成处于状态4 # 即状态0~3必须全部为0 if any(state[i] ! 0 for i in range(4)): return -1 return max_active if __name__ __main__: sounds input().strip() print(min_number_of_geese(sounds))Python代码非常直观。使用sum(state[:4])计算活跃数用any()函数检查非法结束状态体现了Python的简洁性。但核心算法逻辑与C/Java版本完全一致。4. 关键难点与边界条件全解析即使理解了算法实现时依然有几个“坑”容易让人栽跟头。下面我结合自己的调试经验把这些关键点梳理出来。4.1 状态定义的歧义与统一这是最大的思维陷阱。状态到底表示“已经拥有了什么”还是“正在等待什么”两种定义都可以但映射关系必须自洽否则代码会非常混乱。方案A等待式状态0:等q状态1:有q等u状态2:有qu等a状态3:有qua等c状态4:有quac等k。此时字符‘q’需要状态为0的大雁并将其转为状态1。问题‘q’无法复用已完成的大雁状态4因为状态4表示“有quac等k”而不是空闲。为了复用需要让‘q’也能被状态4消费但这破坏了状态机的清晰性。方案B拥有式状态0:已有q状态1:已有qu状态2:已有qua状态3:已有quac状态4:已有quack完成。此时字符‘q’需要前驱状态为4已完成或-1新建并将其转为状态0。字符‘u’需要前驱状态为0转为状态1以此类推。优势状态4天然表示“空闲可复用”逻辑流畅。我们上面的最终代码采用的就是这种定义。实操心得在动手前花一分钟在纸上画一下状态转移图明确每个状态的含义和每个字符触发的转移。统一采用“拥有式”定义可以避免很多不必要的纠结。4.2 “最少大雁数”的正确计算题目要求的是“最少大雁数”指的是在整个过程中同时鸣叫的大雁的最大数量。注意是“同时”和“最大”。错误理解1返回最终创建的大雁总数。错在忽略了复用。对于”quackquack”总创建数是1如果复用但按此理解可能算出2。错误理解2返回遍历结束时处于非完成状态的大雁数。错在忽略了过程中的峰值。对于”qquack”过程峰值是2两个’q’同时活跃结束时是1正确答案应是2。正确做法在遍历每个字符后立即计算当前所有“未完成鸣叫”的大雁数量即状态0~3的雁数之和并持续更新一个全局最大值maxActive。最终返回这个maxActive。4.3 非法序列的全面判断什么情况下字符串是非法的应该返回-1主要有两类情况匹配失败当遇到字符‘u’,‘a’,‘c’,‘k’时如果没有处于其所需前驱状态的大雁说明顺序乱了立即返回-1。序列不完整遍历完所有字符后如果还有大雁没有完成它们的鸣叫即状态0,1,2,3中任意一个计数不为0说明字符串在某个“quack”中间截断了返回-1。特别注意字符串为空怎么办题目通常不会给空串但如果遇到按照定义没有大雁在叫应该返回0。我们的算法中maxActive初始为0遍历空串后状态全0检查通过返回0符合预期。4.4 性能考量与优化我们的算法只进行了一次字符串遍历内部操作是常数时间的数组增减和求和时间复杂度是O(n)空间复杂度是O(1)只用了固定大小的状态数组和几个变量对于机试的时间限制通常1-2秒和长度限制字符串长度可达10^5来说是绰绰有余的。一个小优化点计算当前活跃数currentActive时我们每次都对state[0]到state[3]求和。其实可以维护一个activeCount变量在状态转移时动态更新当一只大雁从状态4完成进入状态0开始时activeCount不变因为状态4本就不活跃状态0是活跃。当一只大雁从状态3进入状态4完成时activeCount减1。当新建一只大雁进入状态0时activeCount加1。其他状态间转移如1-2不影响活跃数。 这样可以将求和操作从O(4)降到O(1)。但在实际机试中这个优化并非必需清晰的逻辑比微小的常数优化更重要。5. 测试用例与调试技巧自己写代码一定要用丰富的测试用例来验证。下面是我总结的几类关键测试用例测试用例预期输出说明“quack”1最基本情况一只雁叫一次。“quackquack”1一只雁叫两次复用。“qquuack”2需要两只雁交错进行。过程q(雁A), q(雁B), u(雁A), u(雁B), a(雁A), c(雁A), k(雁A)。活跃峰值在有两个’q’时达到2。“quqackuack”2稍微复杂的交错。“quackq”-1结尾多了一个’q’没有对应的后续字符序列不完整。“quacku”-1第二个’u’没有前驱’q’匹配第一只雁已完成在状态4。“qquack”2开头两个’q’需要两只雁。“”(空字符串)0没有叫声。“quackquackquack”1一只雁叫三次。“qqquackackack”3开头三个’q’需要三只雁。调试技巧打印状态数组在循环内每处理一个字符后打印出当前的state数组和currentActive、maxActive的值。这是最直观的调试方式能帮你看清每一步的状态转移是否符合预期。脑补模拟对于短字符串可以手工在纸上模拟算法的运行画出state数组的变化这是加深理解的最好方法。边界测试务必测试空串、全合法但需多雁、非法结尾、非法中间字符等情况。6. 从“数大雁”延伸的机试备考策略通过深度剖析“数大雁”这道题我们可以提炼出应对华为OD机试乃至大多数公司算法笔试的通用策略化归与建模很多题目描述看似新颖如大雁叫声但本质是经典的计算机科学问题。这道题就完美映射为多指针模拟或有限状态自动机。遇到新题先思考它和哪些经典问题括号匹配、任务调度、状态机有相似之处。贪心与优化“优先复用”是典型的贪心思想它在这里能得到最优解。机试中很多最值问题最小数量、最短时间都可以尝试贪心策略并思考其正确性。一次遍历在字符串或数组类题目中设计一次遍历O(n)的算法往往是考点。这就需要我们维护一些中间状态如本题的state数组在遍历过程中逐步更新答案。边界边界边界非法输入、空数据、极值如全部相同字符是机试代码的“杀手”。写完核心逻辑后必须专门考虑这些情况。像本题最后的完整性检查状态0~3是否全零就是关键的边界处理。代码清晰重于奇技淫巧机试时间有限阅卷也可能有部分自动化。使用清晰的变量名如state,maxActive添加关键注释比写一段晦涩难懂但“高效”的代码更稳妥。我们的三种语言实现都力求逻辑清晰便是这个原因。这道“数大雁”题综合考察了问题分析、算法设计、状态管理、边界处理以及代码实现能力是一道质量很高的题目。把它吃透举一反三再遇到类似的“字符串多序列匹配”、“资源最小化分配”问题你就能更快地抓住要害。