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’,既可以唤醒一只已经完成上一轮鸣叫、正在空闲的大雁,也可以为一只新的大雁开启它的第一次鸣叫。

那么,如何求出最少的雁群数量呢?这里就用到 贪心策略 :当遇到一个字符时,我们优先将它分配给一只已经处于对应前驱状态的大雁,而不是轻易开启新的大雁。为什么要这样?因为开启新的大雁会增加“同时鸣叫”的雁群数量,我们的目标是最小化这个数量。只有当没有处于合适前驱状态的大雁时,我们才不得不招募一只新的大雁。

具体来说:

  1. 状态定义 :我们用五个状态来标识一只大雁的进度:0:等待q, 1:已有q等待u, 2:已有qu等待a, 3:已有qua等待c, 4:已有quac等待k。
  2. 状态转移 :遍历字符串的每个字符 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’。此时,当前活跃大雁数减少。
  3. 结果计算 :在整个遍历过程中,我们需要维护一个峰值,即 某一时刻同时处于鸣叫状态(状态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数组记录处于每个状态的大雁数量
    vector<int> count(5, 0);
    // 记录遍历过程中,同时鸣叫大雁数的最大值(即最少所需大雁数)
    int maxGeese = 0;
    // 当前活跃的大雁数(状态1-4的大雁总数)
    int activeGeese = 0;

    // 预定义字符到目标状态的映射关系
    // 当遇到字符char时,它期望的前一个状态是prev[char],并将该状态的大雁转移到nextState[char]
    unordered_map<char, int> prevState = {{'q', 4}, {'u', 0}, {'a', 1}, {'c', 2}, {'k', 3}};
    unordered_map<char, 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或新建->状态0;'u'需要状态0->状态1;'a'需要状态1->状态2;'c'需要状态2->状态3;'k'需要状态3->状态4。
            } else {
                // 没有空闲大雁,必须创建一只新的大雁,初始状态为0(已有q)
                count[gotoState]++; // gotoState=0
                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,即完成)
    vector<int> state(5, 0);
    int maxActive = 0;

    // 字符到其所需前一个阶段的映射
    unordered_map<char, int> prev = {{'q', -1}, {'u', 0}, {'a', 1}, {'c', 2}, {'k', 3}};
    // 字符到其自身阶段的映射
    unordered_map<char, 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;

        // 建立字符到其所需前驱状态和当前状态的映射
        HashMap<Character, Integer> prevMap = new HashMap<>();
        HashMap<Character, 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?主要有两类情况:

  1. 匹配失败 :当遇到字符 ‘u’ , ‘a’ , ‘c’ , ‘k’ 时,如果没有处于其所需前驱状态的大雁,说明顺序乱了,立即返回-1。
  2. 序列不完整 :遍历完所有字符后,如果还有大雁没有完成它们的鸣叫(即状态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’,需要三只雁。

调试技巧

  1. 打印状态数组 :在循环内每处理一个字符后,打印出当前的 state 数组和 currentActive maxActive 的值。这是最直观的调试方式,能帮你看清每一步的状态转移是否符合预期。
  2. 脑补模拟 :对于短字符串,可以手工在纸上模拟算法的运行,画出 state 数组的变化,这是加深理解的最好方法。
  3. 边界测试 :务必测试空串、全合法但需多雁、非法结尾、非法中间字符等情况。

6. 从“数大雁”延伸的机试备考策略

通过深度剖析“数大雁”这道题,我们可以提炼出应对华为OD机试(乃至大多数公司算法笔试)的通用策略:

  1. 化归与建模 :很多题目描述看似新颖(如大雁叫声),但本质是经典的计算机科学问题。这道题就完美映射为 多指针模拟 有限状态自动机 。遇到新题,先思考它和哪些经典问题(括号匹配、任务调度、状态机)有相似之处。
  2. 贪心与优化 :“优先复用”是典型的贪心思想,它在这里能得到最优解。机试中很多最值问题(最小数量、最短时间)都可以尝试贪心策略,并思考其正确性。
  3. 一次遍历 :在字符串或数组类题目中,设计一次遍历(O(n))的算法往往是考点。这就需要我们维护一些中间状态(如本题的 state 数组),在遍历过程中逐步更新答案。
  4. 边界!边界!边界! :非法输入、空数据、极值(如全部相同字符)是机试代码的“杀手”。写完核心逻辑后,必须专门考虑这些情况。像本题最后的完整性检查(状态0~3是否全零)就是关键的边界处理。
  5. 代码清晰重于奇技淫巧 :机试时间有限,阅卷也可能有部分自动化。使用清晰的变量名(如 state , maxActive ),添加关键注释,比写一段晦涩难懂但“高效”的代码更稳妥。我们的三种语言实现都力求逻辑清晰,便是这个原因。

这道“数大雁”题,综合考察了问题分析、算法设计、状态管理、边界处理以及代码实现能力,是一道质量很高的题目。把它吃透,举一反三,再遇到类似的“字符串多序列匹配”、“资源最小化分配”问题,你就能更快地抓住要害。

Logo

码道开发者社区,聚焦华为云码道 CodeArts 代码智能体,沉淀 Agent、Skill、鸿蒙开发实战内容,供开发者查阅资料、交流技术、分享工程实践

更多推荐