JavaScript算法实战:从华为OD真题“最长顺子”解析数组处理与序列查找

JavaScript算法实战:从华为OD真题“最长顺子”解析数组处理与序列查找
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,3,4,5,6,7,10,11,12”也可能直接是数组[1,3,4,5,6,7,10,11,12]。我们需要能正确解析。重复点数的处理一副牌中同点数的牌有多张如两张5但在组成顺子时同一个点数在一副顺子中只能出现一次。这意味着如果输入有重复数字我们需要先进行去重或者在我们的算法逻辑中能正确处理重复值避免将[5,5,6,7]误判为顺子[5,6,7]时使用了两个5。顺子的最小长度题目明确要求顺子长度至少为3。长度为2的连续数字对如[7,8]不能算作顺子。“最长”的定义当存在多个长度相同的顺子时需要确定返回哪一个。常见的约定是返回起始数字最小的那个顺子。例如对于数组[1,2,3,8,9,10]存在两个长度为3的顺子[1,2,3]和[8,9,10]那么应该返回[1,2,3]。A1的特殊性如前所述需确认题目中A的处理方式。在经典的“最长连续序列”问题变体中通常不涉及A作为14的情况我们按1处理即可。如果题目特别说明则需要额外逻辑。注意机试题目描述务必逐字阅读。有时题目会允许“癞子牌”万能牌来填补间隔但这道“最长的顺子”基础题通常不涉及我们这里讨论的是最标准的版本。3. 解题思路与算法设计明确了规则接下来就是设计解题思路。这道题本质上是在一个整数集合中寻找最长的连续数字段。有几种常见的思路我将分析它们的优劣并选择最适合机试场景的一种。3.1 思路一排序后遍历推荐这是最直观、也最易于实现和理解的方法特别适合在时间有限的机试中采用。数据预处理首先将输入的数组进行排序升序。排序后连续的数字就会彼此相邻。去重在排序前后进行去重操作确保每个点数在后续判断中只被考虑一次。可以使用Set数据结构一步完成去重和转数组。单次遍历寻找连续段遍历去重排序后的数组。使用一个临时数组currentStraight来记录当前正在考察的连续序列用longestStraight记录目前找到的最长序列。如果当前数字sorted[i]恰好比currentStraight最后一个元素大1说明连续将其加入currentStraight。否则说明连续中断。此时检查currentStraight的长度是否大于等于3且是否比longestStraight更长或长度相等但起始点更小。如果是则更新longestStraight。然后清空currentStraight并以当前数字作为新序列的开始。遍历结束后的处理循环结束后别忘了再检查一次currentStraight因为最后一个连续段在循环内可能还没来得及和longestStraight比较。时间复杂度分析排序操作是主导时间复杂度为 O(n log n)其中n是去重前的数组长度。去重和遍历都是 O(n)。在机试常见的输入规模下比如n10000这个性能是完全可接受的。空间复杂度分析主要消耗在存储去重后的数组以及几个临时数组上是 O(n)。3.2 思路二基于哈希集合的查找另一种更高效理论上O(n)的思路是利用Set。构建集合将所有数字放入一个Set中天然去重。寻找序列起点遍历Set中的每个数字num。如果Set中不包含num-1那么num有可能是一个连续序列的起点。如果包含num-1那么num肯定不是起点跳过它。扩展序列对于每一个可能的起点num我们不断地检查num1,num2,num3... 是否存在于Set中并计数直到某个数字不存在为止。这样就得到了一个以num为起点的连续序列长度。更新最长序列在扩展过程中记录下产生最长长度的那个起点和长度。这个算法的妙处在于通过判断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 代码关键点解读去重与排序的简洁写法[...new Set(cards)].sort((a,b) a-b)这一行代码是ES6的经典用法。new Set(cards)创建一个集合自动去重...展开运算符将集合转回数组最后sort进行数字排序必须提供比较函数否则会按字符串排序。updateLongestIfNeeded辅助函数将更新最长顺子的逻辑抽离出来使主循环更清晰。这个函数处理了长度比较和同长时起始点比较的规则。注意我们通过修改longest数组引用的内容来返回结果避免了全局变量。循环结束后的处理这是一个非常容易遗漏的边界情况。当数组遍历完后最后一个currentStraight可能是一个有效的顺子必须在循环外再次调用updateLongestIfNeeded进行检查。数组的替换技巧在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,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]。使用console.log进行关键状态跟踪在循环开始、每次更新currentStraight和longestStraight时打印它们的值。这能帮你直观看到算法的执行过程快速定位逻辑错误。for (let i 1; i uniqueSortedCards.length; i) { // ... 判断逻辑 ... console.log(i${i}, currentCard${currentCard}, currentStraight[${currentStraight}], longest[${longestStraight}]); // ... 更新逻辑 ... }模块化与函数拆分就像我把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 应对变种题目真实的机试或面试中题目可能会变化。了解核心思想后你可以应对如下变种返回长度而非数组更简单在算法中只维护maxLength和currentLength即可。允许“癞子牌”万能牌例如给定一个数组和一张万能牌可以当作任何点数求最长顺子。这时连续的条件不再是严格相差1而是相差1或2用万能牌填补。解题思路会变得更复杂可能需要使用滑动窗口或动态规划。顺子必须由5张或更多牌组成只需修改最小长度判断条件将3改为5。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的考试环境分享几点实战心得。仔细阅读题目描述和输入输出格式华为OD的题目通常会详细说明输入格式如一行字符串空格分隔、输出格式。你的代码必须严格按照要求读取输入可能是readline()或fs.readFileSync和输出通常是console.log。上面我们的函数只实现了核心逻辑在考试中你需要将其嵌入到输入输出处理框架中。使用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(); });注意代码风格和命名虽然不占主要分数但清晰、有意义的变量名和函数名如findLongestStraight,currentStraight能让你的代码更易读也方便自己检查。先写思路注释如果时间允许在代码关键部分写上简短注释解释你的算法步骤。这能在你思路卡顿时帮你理清逻辑也能向阅卷人展示你的思考过程。预留时间测试完成编码后务必用题目给的示例和你自己设计的边界用例进行测试。在本地或提供的调试环境中运行确保输出完全正确。这道“最长的顺子”题就像一把钥匙打开的是你系统化处理数据、严谨实现逻辑的能力大门。把它吃透举一反三你在面对数组处理、序列查找这类问题时会更加游刃有余。