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]。 - A(1)的特殊性 :如前所述,需确认题目中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)。在机试常见的输入规模下(比如n<=10000),这个性能是完全可接受的。
空间复杂度分析 :主要消耗在存储去重后的数组以及几个临时数组上,是 O(n)。
3.2 思路二:基于哈希集合的查找
另一种更高效(理论上O(n))的思路是利用 Set 。
- 构建集合 :将所有数字放入一个
Set中,天然去重。 - 寻找序列起点 :遍历
Set中的每个数字num。如果Set中 不包含num-1,那么num有可能是一个连续序列的起点。如果包含num-1,那么num肯定不是起点,跳过它。 - 扩展序列 :对于每一个可能的起点
num,我们不断地检查num+1,num+2,num+3... 是否存在于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)能让你的代码更易读,也方便自己检查。 - 先写思路注释 :如果时间允许,在代码关键部分写上简短注释,解释你的算法步骤。这能在你思路卡顿时帮你理清逻辑,也能向阅卷人展示你的思考过程。
- 预留时间测试 :完成编码后,务必用题目给的示例和你自己设计的边界用例进行测试。在本地或提供的调试环境中运行,确保输出完全正确。
这道“最长的顺子”题,就像一把钥匙,打开的是你系统化处理数据、严谨实现逻辑的能力大门。把它吃透,举一反三,你在面对数组处理、序列查找这类问题时,会更加游刃有余。
更多推荐
所有评论(0)