1. 项目概述:从一道华为OD机试真题说起

最近在技术社区和求职圈里,华为OD的机试题目热度一直居高不下,尤其是D卷的真题,常常成为大家讨论和模拟练习的重点。我手头这道“最长的顺子”就是其中之一,它要求用JavaScript实现,目标是在一组给定的扑克牌点数中,找出可以组成的最长连续序列(顺子)。这听起来像是个简单的数组处理问题,但实际做下来,你会发现它巧妙地融合了 数据处理、逻辑判断和边界条件处理 等多个基础且重要的编程技能点,非常考验解题者的基本功和思维严谨性。

这道题的价值不仅在于通过一次机试。对于正在学习JavaScript、准备技术面试或者想夯实算法基础的朋友来说,它都是一个绝佳的练手素材。通过拆解这道题,你能深入理解如何将现实问题(扑克牌规则)抽象为计算机可处理的模型,并编写出高效、健壮的代码。接下来,我就以一名前端开发者的视角,结合我多次参与类似技术笔试和面试官的经验,带你从头到尾、由浅入深地吃透这道题。我们会从理解题意开始,一步步拆解思路,最后给出清晰、可运行的JavaScript代码实现,并附上我调试过程中踩过的坑和总结的技巧。

2. 核心需求与规则解析

在动手写代码之前,彻底、准确地理解题目要求是成功的第一步。很多人在机试中失分,不是算法不会,而是一开始就误读了题目。“最长的顺子”这个描述需要结合扑克牌的特定规则来理解。

2.1 问题场景还原

题目通常会给出一个输入,比如一组扑克牌的点数。在扑克牌中,一副牌通常包含1-13点(对应A, 2, 3, ..., 10, J, Q, K),但在这个问题里,我们通常只关心数字点数,并且A有时可以作为1,有时可以作为14(即顺子A-2-3-4-5或10-J-Q-K-A)。然而,在大多数机试的简化版本中,为了降低复杂度, A通常只作为1点 来处理,并且顺子定义为至少3张点数连续递增的牌。例如,[3,4,5]是一个顺子,[8,9,10,11,12]是一个更长的顺子。

核心需求可以归纳为:给定一个可能包含重复数字的整数数组(代表抽到的牌的点数),我们需要从中找出一个最长的子序列,该子序列满足 序列中数字是连续递增的,且序列长度至少为3 。如果有多个最长顺子,通常需要返回其中一个(比如字典序最小的那个,具体看题目要求,常见是返回起始数字最小的那个)。

2.2 关键约束与边界条件

理解规则后,必须明确几个关键的约束和边界条件,这直接决定了我们算法的正确性:

  1. 输入数据范围与格式 :输入通常是一个字符串或数组。例如,可能是逗号分隔的字符串 “1,3,4,5,6,7,10,11,12” ,也可能直接是数组 [1,3,4,5,6,7,10,11,12] 。我们需要能正确解析。
  2. 重复点数的处理 :一副牌中同点数的牌有多张(如两张5),但在组成顺子时, 同一个点数在一副顺子中只能出现一次 。这意味着如果输入有重复数字,我们需要先进行去重,或者在我们的算法逻辑中能正确处理重复值,避免将 [5,5,6,7] 误判为顺子 [5,6,7] 时使用了两个5。
  3. 顺子的最小长度 :题目明确要求顺子长度至少为3。长度为2的连续数字对(如[7,8])不能算作顺子。
  4. “最长”的定义 :当存在多个长度相同的顺子时,需要确定返回哪一个。常见的约定是返回 起始数字最小的那个顺子 。例如,对于数组 [1,2,3,8,9,10] ,存在两个长度为3的顺子 [1,2,3] [8,9,10] ,那么应该返回 [1,2,3]
  5. A(1)的特殊性 :如前所述,需确认题目中A的处理方式。在经典的“最长连续序列”问题变体中,通常不涉及A作为14的情况,我们按1处理即可。如果题目特别说明,则需要额外逻辑。

注意 :机试题目描述务必逐字阅读。有时题目会允许“癞子牌”(万能牌)来填补间隔,但这道“最长的顺子”基础题通常不涉及,我们这里讨论的是最标准的版本。

3. 解题思路与算法设计

明确了规则,接下来就是设计解题思路。这道题本质上是在一个整数集合中寻找最长的连续数字段。有几种常见的思路,我将分析它们的优劣,并选择最适合机试场景的一种。

3.1 思路一:排序后遍历(推荐)

这是最直观、也最易于实现和理解的方法,特别适合在时间有限的机试中采用。

  1. 数据预处理 :首先,将输入的数组进行排序(升序)。排序后,连续的数字就会彼此相邻。
  2. 去重 :在排序前后进行去重操作,确保每个点数在后续判断中只被考虑一次。可以使用 Set 数据结构一步完成去重和转数组。
  3. 单次遍历寻找连续段 :遍历去重排序后的数组。使用一个临时数组 currentStraight 来记录当前正在考察的连续序列,用 longestStraight 记录目前找到的最长序列。
    • 如果当前数字 sorted[i] 恰好比 currentStraight 最后一个元素大1,说明连续,将其加入 currentStraight
    • 否则,说明连续中断。此时,检查 currentStraight 的长度是否大于等于3且是否比 longestStraight 更长(或长度相等但起始点更小)。如果是,则更新 longestStraight 。然后,清空 currentStraight 并以当前数字作为新序列的开始。
  4. 遍历结束后的处理 :循环结束后,别忘了再检查一次 currentStraight ,因为最后一个连续段在循环内可能还没来得及和 longestStraight 比较。

时间复杂度分析 :排序操作是主导,时间复杂度为 O(n log n),其中n是去重前的数组长度。去重和遍历都是 O(n)。在机试常见的输入规模下(比如n<=10000),这个性能是完全可接受的。

空间复杂度分析 :主要消耗在存储去重后的数组以及几个临时数组上,是 O(n)。

3.2 思路二:基于哈希集合的查找

另一种更高效(理论上O(n))的思路是利用 Set

  1. 构建集合 :将所有数字放入一个 Set 中,天然去重。
  2. 寻找序列起点 :遍历 Set 中的每个数字 num 。如果 Set 不包含 num-1 ,那么 num 有可能是一个连续序列的起点。如果包含 num-1 ,那么 num 肯定不是起点,跳过它。
  3. 扩展序列 :对于每一个可能的起点 num ,我们不断地检查 num+1 , num+2 , num+3 ... 是否存在于 Set 中,并计数,直到某个数字不存在为止。这样就得到了一个以 num 为起点的连续序列长度。
  4. 更新最长序列 :在扩展过程中,记录下产生最长长度的那个起点和长度。

这个算法的妙处在于,通过判断 num-1 是否存在,确保了每个连续序列只被遍历一次(从它的最小数字开始),从而将时间复杂度降到 O(n)。但是,在需要输出具体的顺子数组(而不仅仅是长度)时,代码逻辑会稍微复杂一点,需要记录序列的起止点。

3.3 思路选择与理由

对于华为OD机试这种场景,我 强烈推荐使用思路一(排序后遍历) 。原因如下:

  • 实现简单 :逻辑直白,不易出错。在紧张的考试环境下,简单可靠的代码比理论上更优但复杂的代码更有价值。
  • 易于调试 :排序后的数组状态一目了然,中间步骤很容易通过 console.log 来验证。
  • 满足输出要求 :在遍历过程中,我们可以轻松地维护和更新整个顺子数组,直接满足题目输出完整序列的要求。
  • 性能足够 :题目给定的数据范围通常不会大到让 O(n log n) 和 O(n) 产生决定性差距。清晰正确的 O(n log n) 解法远胜于可能有边界错误的 O(n) 解法。

因此,我们后续的代码实现将基于 思路一 展开。

4. JavaScript代码实现与逐行解读

理论说得再多,不如一行代码。下面,我将给出基于排序遍历思路的完整JavaScript实现,并附上详细的注释,解释每一关键步骤的意图和注意事项。

/**
 * 寻找最长的顺子
 * @param {number[]} cards - 扑克牌点数数组,可能包含重复
 * @return {number[]} - 最长的顺子数组,如果不存在长度>=3的顺子,返回空数组[]
 */
function findLongestStraight(cards) {
    // 1. 参数校验与预处理
    if (!Array.isArray(cards) || cards.length < 3) {
        return [];
    }

    // 2. 去重并排序
    // 使用Set去重,再转为数组并排序。这是性能与简洁性的平衡。
    const uniqueSortedCards = [...new Set(cards)].sort((a, b) => a - b);
    // 如果去重后都不足3张牌,直接返回空数组
    if (uniqueSortedCards.length < 3) {
        return [];
    }

    // 3. 初始化变量
    let longestStraight = []; // 记录当前找到的最长顺子
    let currentStraight = [uniqueSortedCards[0]]; // 从第一个数字开始当前顺子

    // 4. 遍历寻找最长连续序列
    for (let i = 1; i < uniqueSortedCards.length; i++) {
        const currentCard = uniqueSortedCards[i];
        const lastCardInCurrent = currentStraight[currentStraight.length - 1];

        // 判断是否连续:当前牌的点数恰好比当前顺子最后一张牌大1
        if (currentCard === lastCardInCurrent + 1) {
            // 连续,加入当前顺子
            currentStraight.push(currentCard);
        } else {
            // 不连续,当前顺子结束。检查是否需要更新最长顺子
            updateLongestIfNeeded(currentStraight, longestStraight);
            // 以当前牌作为新顺子的开始
            currentStraight = [currentCard];
        }
    }

    // 5. 循环结束后,处理最后一个顺子
    updateLongestIfNeeded(currentStraight, longestStraight);

    // 6. 返回结果
    return longestStraight;
}

/**
 * 辅助函数:比较并更新最长顺子
 * @param {number[]} current - 当前顺子
 * @param {number[]} longest - 当前记录的最长顺子(会被修改)
 */
function updateLongestIfNeeded(current, longest) {
    // 只有长度大于等于3的顺子才参与比较
    if (current.length >= 3) {
        if (current.length > longest.length) {
            // 当前顺子更长,直接替换
            longest.length = 0; // 清空原数组
            longest.push(...current);
        } else if (current.length === longest.length && current.length > 0) {
            // 长度相等,比较起始点,取更小的(题目常见要求)
            if (current[0] < longest[0]) {
                longest.length = 0;
                longest.push(...current);
            }
        }
    }
}

// ============= 测试用例 =============
console.log("测试1 - 标准情况:");
const test1 = [1, 3, 4, 5, 6, 7, 10, 11, 12];
console.log(`输入: [${test1}]`);
console.log(`输出: [${findLongestStraight(test1)}]`); // 期望: [3,4,5,6,7] (长度5)

console.log("\n测试2 - 有重复数字:");
const test2 = [5, 2, 3, 3, 4, 6, 7, 8, 8];
console.log(`输入: [${test2}]`);
console.log(`输出: [${findLongestStraight(test2)}]`); // 期望: [2,3,4,5,6,7,8] (去重后连续)

console.log("\n测试3 - 多个等长顺子,取起始点小的:");
const test3 = [1, 2, 3, 8, 9, 10];
console.log(`输入: [${test3}]`);
console.log(`输出: [${findLongestStraight(test3)}]`); // 期望: [1,2,3]

console.log("\n测试4 - 无长度>=3的顺子:");
const test4 = [1, 5, 9];
console.log(`输入: [${test4}]`);
console.log(`输出: [${findLongestStraight(test4)}]`); // 期望: []

console.log("\n测试5 - 输入就是最长顺子:");
const test5 = [10, 11, 12, 13];
console.log(`输入: [${test5}]`);
console.log(`输出: [${findLongestStraight(test5)}]`); // 期望: [10,11,12,13]

console.log("\n测试6 - 边界输入:");
const test6 = [];
console.log(`输入: [${test6}]`);
console.log(`输出: [${findLongestStraight(test6)}]`); // 期望: []

4.1 代码关键点解读

  1. 去重与排序的简洁写法 [...new Set(cards)].sort((a,b) => a-b) 这一行代码是ES6的经典用法。 new Set(cards) 创建一个集合自动去重, ... 展开运算符将集合转回数组,最后 sort 进行数字排序(必须提供比较函数,否则会按字符串排序)。
  2. updateLongestIfNeeded 辅助函数 :将更新最长顺子的逻辑抽离出来,使主循环更清晰。这个函数处理了 长度比较 同长时起始点比较 的规则。注意我们通过修改 longest 数组引用的内容来返回结果,避免了全局变量。
  3. 循环结束后的处理 :这是一个非常容易遗漏的边界情况。当数组遍历完后,最后一个 currentStraight 可能是一个有效的顺子,必须在循环外再次调用 updateLongestIfNeeded 进行检查。
  4. 数组的替换技巧 :在 updateLongestIfNeeded 中,我们使用 longest.length = 0; 清空原数组,再用 longest.push(...current); 填充新内容。这比直接赋值 ( longest = current.slice() ) 更好,因为它保持了外部对 longestStraight 数组的引用有效。这在某些调用场景下很重要。

5. 常见陷阱与调试心得

即使思路清晰,在实现时也容易踩坑。下面是我在实现和测试过程中总结的几个关键陷阱及解决方法。

5.1 陷阱一:忽略去重或去重时机不当

  • 问题 :直接在原数组上排序并遍历,如果输入包含重复数字 [3,4,5,5,6] ,算法可能会错误处理。例如,在判断连续时,第二个5会破坏 [3,4,5] 这个顺子的连续性判断,导致找不到 [3,4,5,6]
  • 解决 必须在排序前或排序后立即进行去重 。使用 Set 是最佳实践。确保后续操作都在唯一数字集合上进行。

5.2 陷阱二:排序函数使用错误

  • 问题 [1, 10, 11, 12, 2, 3].sort() 的结果是 [1, 10, 11, 12, 2, 3] ,因为默认的 sort() 会将元素转为字符串,按UTF-16编码排序。
  • 解决 对数字数组排序,必须提供比较函数 sort((a, b) => a - b)

5.3 陷阱三:顺子长度判断条件遗漏

  • 问题 :只记录了连续的数字,但忘记在更新最长顺子时检查 currentStraight.length >= 3 这个条件。导致可能将 [7,8] 这样的双张误判为顺子并返回。
  • 解决 :在 updateLongestIfNeeded 函数内部, 首要判断就是当前顺子长度是否达到3 。只有达标了,才参与“最长”的竞选。

5.4 陷阱四:多个最长顺子的选择逻辑

  • 问题 :题目可能未明确说明,当有多个最长顺子时该返回哪一个。如果不处理,代码可能返回最后找到的那个,这不一定符合预期(通常期望返回起始点最小的)。
  • 解决 :在 updateLongestIfNeeded 中增加逻辑。当 current.length == longest.length 时,比较两者的起始元素 current[0] longest[0] ,保留较小的那个。这符合常见的“字典序”或“自然序”要求。

5.5 调试技巧实录

在机试或平时练习时,如何快速验证代码?

  1. 设计全面的测试用例 :不要只测理想情况。你的测试集应该包括:
    • 标准用例 :如 [1,3,4,5,6,7,10,11,12]
    • 含重复数字用例 [5,2,3,3,4,6,7,8,8]
    • 多顺子等长用例 [1,2,3,8,9,10]
    • 无顺子用例 [1,5,9]
    • 边界用例 :空数组 [] ,不足3个元素的数组 [1,2] ,全部连续 [5,6,7,8]
    • 数字跨度大用例 [100, 1, 2, 3, 200]
  2. 使用 console.log 进行关键状态跟踪 :在循环开始、每次更新 currentStraight longestStraight 时,打印它们的值。这能帮你直观看到算法的执行过程,快速定位逻辑错误。
    for (let i = 1; i < uniqueSortedCards.length; i++) {
        // ... 判断逻辑 ...
        console.log(`i=${i}, currentCard=${currentCard}, currentStraight=[${currentStraight}], longest=[${longestStraight}]`);
        // ... 更新逻辑 ...
    }
    
  3. 模块化与函数拆分 :就像我把 updateLongestIfNeeded 抽成函数一样,将独立的功能模块化,能让代码更易读、易调试。在机试中,清晰的代码结构也能给阅卷人留下好印象。

6. 性能优化与进阶思考

虽然我们选择的排序方案对于机试已经足够,但了解更优的方案和可能的变种题目,能体现你的技术深度。

6.1 哈希集合方案的实现

作为对比,这里给出基于思路二(哈希集合)的实现。这种方案在数据量极大时(例如上百万)优势明显。

function findLongestStraightHash(cards) {
    if (!Array.isArray(cards) || cards.length < 3) return [];

    const numSet = new Set(cards);
    let bestStart = 0;
    let bestLength = 0;

    for (const num of numSet) {
        // 只有当num是一个连续序列的起点时(即num-1不在集合中),我们才进行扩展
        if (!numSet.has(num - 1)) {
            let currentNum = num;
            let currentLength = 1;

            // 向后扩展序列
            while (numSet.has(currentNum + 1)) {
                currentNum++;
                currentLength++;
            }

            // 更新最佳记录
            if (currentLength >= 3) {
                if (currentLength > bestLength || (currentLength === bestLength && num < bestStart)) {
                    bestLength = currentLength;
                    bestStart = num;
                }
            }
        }
    }

    // 根据记录的最佳起点和长度生成结果数组
    if (bestLength >= 3) {
        const result = [];
        for (let i = 0; i < bestLength; i++) {
            result.push(bestStart + i);
        }
        return result;
    }
    return [];
}

对比与选择

  • 时间复杂度 :哈希法为 O(n),优于排序法的 O(n log n)。
  • 空间复杂度 :两者都是 O(n),哈希法需要额外的 Set
  • 可读性与实现难度 :排序法更简单直观,哈希法需要理解“起点”判断的巧妙之处。
  • 机试建议 优先使用排序法 。除非题目明确强调数据规模极大(通常机试不会),或者你对该解法有十足把握。正确性永远是第一位的。

6.2 应对变种题目

真实的机试或面试中,题目可能会变化。了解核心思想后,你可以应对如下变种:

  1. 返回长度而非数组 :更简单,在算法中只维护 maxLength currentLength 即可。
  2. 允许“癞子牌”(万能牌) :例如,给定一个数组和一张万能牌(可以当作任何点数),求最长顺子。这时,连续的条件不再是严格相差1,而是相差1或2(用万能牌填补)。解题思路会变得更复杂,可能需要使用滑动窗口或动态规划。
  3. 顺子必须由5张或更多牌组成 :只需修改最小长度判断条件,将 >=3 改为 >=5
  4. A可以作为14 :需要在预处理时特殊处理1和14的关系。一种方法是将1视为14加入集合,但注意顺子不能同时包含1和14(即A不能既当1又当14)。更稳妥的方法是分别计算以1为起点(1,2,3...)和以10为起点(10, J(11), Q(12), K(13), A(14))的顺子,取最长。

7. 在华为OD机试中的实战建议

最后,结合华为OD的考试环境,分享几点实战心得。

  1. 仔细阅读题目描述和输入输出格式 :华为OD的题目通常会详细说明输入格式(如一行字符串,空格分隔)、输出格式。你的代码必须严格按照要求读取输入(可能是 readline() fs.readFileSync )和输出(通常是 console.log )。上面我们的函数只实现了核心逻辑,在考试中你需要将其嵌入到输入输出处理框架中。
  2. 使用Node.js环境 :华为OD的JavaScript环境通常是Node.js。确保你熟悉基本的Node.js文件操作和标准输入输出。
    // 一个常见的Node.js机试代码框架示例
    const readline = require('readline');
    const rl = readline.createInterface({
        input: process.stdin,
        output: process.stdout
    });
    
    rl.on('line', (input) => {
        // 1. 解析输入,例如将字符串"1,3,4,5"转为数组[1,3,4,5]
        const cards = input.split(',').map(Number);
        // 2. 调用核心函数
        const result = findLongestStraight(cards);
        // 3. 格式化输出,例如输出"3,4,5,6,7"
        console.log(result.join(','));
        rl.close();
    });
    
  3. 注意代码风格和命名 :虽然不占主要分数,但清晰、有意义的变量名和函数名(如 findLongestStraight , currentStraight )能让你的代码更易读,也方便自己检查。
  4. 先写思路注释 :如果时间允许,在代码关键部分写上简短注释,解释你的算法步骤。这能在你思路卡顿时帮你理清逻辑,也能向阅卷人展示你的思考过程。
  5. 预留时间测试 :完成编码后,务必用题目给的示例和你自己设计的边界用例进行测试。在本地或提供的调试环境中运行,确保输出完全正确。

这道“最长的顺子”题,就像一把钥匙,打开的是你系统化处理数据、严谨实现逻辑的能力大门。把它吃透,举一反三,你在面对数组处理、序列查找这类问题时,会更加游刃有余。

Logo

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

更多推荐