ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LeetCode 1929 数组串联:从三种解法到边界陷阱全解析

LeetCode 1929 数组串联:从三种解法到边界陷阱全解析 说实话第一次在LeetCode上刷到 1929. 数组串联Concatenation of Array 这道题时我愣了好几秒——确认自己没选错题。题目要求简单得反直觉给一个数组 nums长度为 n让你构造一个长度为 2n 的新数组 ans其中 ans[i] nums[i] 且 ans[i n] nums[i]。说白了就是把数组复制一份接在自己屁股后面。放在任何一个真实项目里这大概是一行代码的事放在刷题里它却很有资格成为很多人的第一道题。这道题适合两类人一是刚接触 LeetCode、想从零建立刷题节奏的新手二是想借热身题重新审视自己读题、选型、编码、验边界这套基本功的老手。我这次就以它为 Q1把从审题到最后提交的完整过程记录下来包括三种解法的对比、复杂度分析、容易被忽略的边界坑以及一个让我印象深刻的手写循环陷阱。1. 题目到底在考什么别被简单骗了1.1 先把题面翻译成人话LeetCode 1929 的原题描述不长大意是给定一个长度为 n 的整数数组 nums你需要返回一个新的数组 ans长度为 2n并且要满足两个条件ans[i] nums[i]其中 0 i nans[i n] nums[i]其中 0 i n也就是说左半段是原数组右半段还是原数组。数组串联这个名字起得很直白就是 abc abc 变成 abcabc 的效果。官方给了两个示例nums [1, 2, 1] 时输出 [1, 2, 1, 1, 2, 1]nums [1, 3, 2, 1] 时输出 [1, 3, 2, 1, 1, 3, 2, 1]。你甚至可以把原数组想象成一张纸上的图案题目要你复印一遍贴在原图案的右边拼成一张两倍长的图下标前半截照搬原图后半截还是照搬原图。这种题在中文社区一般叫数组串联英文原题叫 Concatenation意思完全相同。很多人觉得它不够算法但它其实把一个数组题最底层的动作——索引映射完整地考了一遍。1.2 热身题的隐藏考点为什么官方会把这道题放在入门位置因为它把数组题最基础的三件事全部覆盖到了。第一函数签名。LeetCode 要求你实现一个方法而不是打印结果很多第一次刷题的人会在这里懵掉不知道该返回什么也不知道参数是怎么传进来的。如果你平时只写过脚本没有写过带返回值的方法这一步本身就值得专门练习。第二索引映射。你要能写出 ans[i] nums[i] 和 ans[i n] nums[i] 这两条赋值关系。看起来简单但这里藏着一个思维转变你不是在修改原数组而是在填充一个新数组并且新数组的两个区间都来源于同一个旧数组。这个一对多的来源映射比想象中更容易写错。第三边界。数组长度为 1 时怎么处理长度为 0 时又怎么处理这些问题在真实工程项目里一样会出现。所以这道题虽然是热身题但把读题、选型、编码、验边界这四步流程完整地走了一遍。四步流程说起来很虚在这道题里却非常具体读题是弄清楚 2n 和 n 的关系选型是决定用循环还是用内置拼接编码是写对下标验边界是至少测一遍空数组和单元素数组。1.3 简单题为什么值得认真对待我见过很多刷题新手一上来就直奔两数之和三数之和结果被哈希表和双指针劝退。我的建议恰恰相反先把热身题老老实实刷透。热身题的价值不在难度而在节奏。它让你在没有任何心理压力的情况下把刷题的标准动作练成肌肉记忆。等你哪天做到周赛的第二题、第三题你依然会先执行同一套流程先读题再想暴力再想优化再验边界。这套流程的雏形就是在这道简单题里打下的。2. 三种解法逐个拆解从最笨到最巧2.1 解法一先申请长度 2n 的数组再逐位填充这是最老实的写法也是我认为每个人都需要先在编辑器里敲一遍的写法。思路很简单既然输出数组长度是 2n那就先 new 一个长度 2n 的容器然后遍历 0 到 2n-1 这 2n 个位置用 if 判断当前位置落在前一半还是后一半分别从 nums 里取值。class Solution: def getConcatenation(self, nums: List[int]) - List[int]: n len(nums) ans [0] * (2 * n) for i in range(2 * n): if i n: ans[i] nums[i] else: ans[i] nums[i - n] return ans为什么说它最符合人类直觉因为你是拿着题目条件一行一行照搬到代码里的条件说 ans[i] nums[i]你就写一个 if条件说 ans[i n] nums[i]你就写一个 else。它没有任何技巧也不容易错。对于刚开始刷题的人来说能被一眼看懂本身就是巨大优势因为调试成本低。我第一次写这题时甚至没有用 if而是写了两段循环第一段复制前 n 个第二段复制后 n 个效果一样只是代码更长一点。你要是追求可读性完全可以拆成两个并排的 for 循环。2.2 解法二取模运算把两次拷贝压成一次这是我在看题解时学到的一个小技巧直接用一个循环遍历 0 到 2n-1每个位置从 nums[i % n] 里取值。因为当 i 从 0 跑到 n-1 时i % n 就是 i 本身当 i 从 n 跑到 2n-1 时i % n 会重新从 0 递增到 n-1正好把原数组再取一遍。class Solution: def getConcatenation(self, nums: List[int]) - List[int]: n len(nums) ans [0] * (2 * n) for i in range(2 * n): ans[i] nums[i % n] return ans取模在这里扮演的角色就像钟表上的刻度小时数超过 12 自动绕回 1你要做的只是从 0 到 23 一直走让表盘自己决定指针停在哪。这个技巧看起来很小但它是一种非常重要的思维在循环里用一个数学运算替代 if 分支把两种情况统一成一种规律。后面你会大量遇到这种思想比如环形数组、循环队列、约瑟夫环问题全部绕不开取模。所以这道题虽然只要求拼接但你应该趁这个机会把取模的手感练出来。2.3 解法三用语言内置能力说人话前面两种解法是在教计算机怎么一步步干活但现实工程里没人会这么写。Python 里一句 nums nums 或者 nums * 2 就够了C 里可以用 insert 把 nums 追加到自身末尾JavaScript 里是 nums.concat(nums)。这不是偷懒而是选择合适的抽象层级——当语言已经提供了语义完全一致、性能经过优化的能力时直接用就是最正确的选择。class Solution: def getConcatenation(self, nums: List[int]) - List[int]: return nums numsclass Solution { public: vectorint getConcatenation(vectorint nums) { vectorint ans nums; ans.insert(ans.end(), nums.begin(), nums.end()); return ans; } };不过我自己有一个使用原则刷题提交的时候我会选最不可能写错的写法通常是内置拼接复盘的时候我一定会把解法一和解法二都在草稿纸上走一遍。原因很简单内置函数帮我们省掉了实现细节但也可能藏掉边界问题。你先亲手实现一次再回到抽象层你才会知道 nums * 2 到底帮你做了什么。2.4 三种做法放在一起对比解法核心思路时间复杂度额外空间代码量适合场景解法一if 判断两次取值O(n)O(1)较多理解底层逻辑解法二i % n 自动复刻O(n)O(1)中等训练取模思维解法三内置拼接O(n)O(1)最少工程最高效别因为代码简单就看轻这道题三种写法恰好代表了三种思考层次照搬条件、抽象规律、利用工具。刷题水平的提升本质上就是在这三个层次之间反复穿梭的能力。你在做更难的题时也是在不停重复这三个动作先暴力实现再寻找规律再调用合适的语言特性。3. 复杂度分析比AC更值钱的习惯3.1 时间复杂度的两个层次三个解法的时间复杂度都是 O(n)因为输出数组本身就有 2n 个元素你至少要把这 2n 个位置都填上所以任何解法都不可能低于 O(n)。注意这里有个新手常犯的错误看到自己用了两层循环就慌怀疑是 O(n^2)。这道题里即使你写成两个并排的 for 循环每个循环跑 n 次加起来也是 2n 次操作不是 n^2 次操作。大 O 表示法关心的是随着输入规模增长操作次数怎么增长线性增长就是 O(n)有没有系数并不影响它的量级判断。举个例子会更清楚n 1000 时2n 2000n 10000 时2n 20000。输入规模扩大 10 倍操作次数也扩大 10 倍这就是典型的线性关系。而如果是 O(n^2)输入扩大 10 倍时操作次数会扩大 100 倍那才是需要警惕的复杂度爆炸。3.2 空间复杂度里返回空间和额外空间的区别很多题解在分析空间时会写 O(n)指的是最终答案数组占用的空间但面试官更常追问的是额外空间 O(1)。这两种说法的区别在于返回数组是题目要求你构造的不算算法额外开销只有在处理过程中额外申请的临时容器才算额外空间。在数组串联这道题里三种解法的额外空间都是 O(1)因为你除了构造返回数组外没有使用任何随 n 增长的辅助结构也没有递归调用栈。这个区分会直接影响你在原地算法类题目里的思路。比如后面要练的轮转数组就要求你尽可能用 O(1) 额外空间完成旋转这时候返回空间和额外空间的边界感就非常重要了。如果你现在还分不清这两个概念建议在这道题上就先把它想透。3.3 三种解法在真实机器上的差距虽然都是 O(n)但常数差异和实现方式决定了真实耗时。手动 for 循环逐位拷贝在 Python 里要走解释器循环每执行一次循环体都要处理索引解析、字节码分发性能天然吃亏而 nums nums 本质上是 CPython 的 C 层列表拼接底层是连续内存的批量拷贝速度往往快一个量级。在 n 等于几百时这个差距无感但到 n 10^6 甚至更大时内置拼接的优势会变得非常明显。这给我们一个实际策略能用内置批量操作就不要在 Python 里写手写循环。算法题里牺牲一点理论上的手动实现换取实际性能往往是值得的。当然如果你正在准备面试要能立刻说出来 nums * 2 的底层行为等价于申请新列表 循环复制这样面试官追问时你就不会露怯。3.4 一个值得养成的复盘习惯AC 之后别急着做下一题多问自己三个问题能不能把两个循环合并成一个能不能去掉 if 分支能不能用语言内置能力再写一版这三个问题对应的恰好是 2.1、2.2、2.3 三种解法。哪怕题目再简单把这个过完再想一遍的过程做足积累下来的思考习惯才是刷题最大的红利。4. 最容易翻车的边界情况提交前请自查4.1 空数组与单元素数组题目约束里 nums.length 通常是 1 n 1000但不少刷题平台会在隐藏测试里塞边界输入。空数组的场景nums [] 时n 0任何解法返回 [] 都是合理的单元素场景nums [5]应该返回 [5, 5]。这两个用例虽然简单却能够瞬间验证你的代码是否存在越界风险。因为只要你哪一步写成 nums[i n] 而循环边界没控制好第一个报错就会出现在这里。我建议每个数组题都先在本机跑一遍这两个用例成本几秒钟收益是避免重复提交。尤其是解法一里给 ans 预分配空间用的是 [0] * (2 * n)如果 n 0得到的就是空列表循环不会执行逻辑上完全安全如果有人在预分配时写死了长度空数组场景就会直接翻车。4.2 一个会死循环的经典陷阱原地拼接有些同学会想既然是把数组拼到自己后面能不能不申请新数组直接原地扩展在 Python 里最容易踩的坑是这种写法nums [1, 2, 3] for x in nums: nums.append(x)这段代码看起来没问题实际运行会死循环。for 循环的迭代器按索引走到 list 末尾为止但 append 每轮都在给列表增加新元素列表永远没有尽头迭代器也就永远走不到头。就算有人告诉你 nums.extend(nums) 在某些解释器实现里是安全的我也不建议依赖这个行为——它依赖实现细节换一个解释器版本可能就翻车。正确的做法仍然是显式构造一个新列表并返回既符合题目语义也零歧义。注意循环遍历一个列表的同时往同一个列表里追加元素是 Python 里的经典死循环写代码时永远要避开这个模式。4.3 大输入下的内存与性能当 n 很大时nums * 2 会一次性分配长度为 2n 的新列表这完全正常真正要避免的是在循环里反复拼接列表。比如下面这种写法ans [] for i in range(n): ans nums[i:i 1]如果 n 很大这种写法的总成本会退化到 O(n^2)因为每次 都可能触发一次新的内存分配和拷贝。遇到需要拼接的场景先想清楚需要的最终长度一次分配到位或者直接用 nums nums / nums * 2让底层替你完成批量拷贝。这个经验在做字符串和数组的复杂题时特别重要很多性能问题的根源都不是算法选错而是容器操作写得太过零碎。5. 从数组串联看后续刷题地图这题不是终点5.1 立刻可以练的同类题轮转数组LeetCode 189 轮转数组是一个非常合适的下一题。它把 nums 循环右移 k 位最简单的解法之一就是利用取模新数组里的位置 i 应该放原始数组中(n - k % n i) % n位置上的元素。这和数组串联的取模思想完全同源但加了一个偏移量需要你理解环形结构。你可以给自己设一道要求先把 O(n) 额外空间的版本写出来再挑战额外空间 O(1) 的三步翻转法。后者是面试高频考点而它的第一步恰恰是你在这道热身题里反复练习的数组区间操作。5.2 热身题在刷题路线里的位置很多人的第一个刷题清单是 LeetCode 热门 100 题这本清单里的题目更难但你会发现其中相当一部分数组题最终都会落到怎么高效遍历数组怎么用索引映射代替复杂状态这些基本功上。数组串联作为热身题的定位就是帮你把这些基本功焊死在潜意识里。我不建议在热身题上停留太久但也不建议直接跳过。就像跑步前的拉伸花两分钟做一下后面几公里会更顺。周赛里最典型的场景就是第一题往往简单但如果你连第一题的手速和准确率都没有后面的中等题只会更慌。周赛 430 之类的实战环境很多时候拼的就是谁能更快地把热身题干净利落地解决给后面的题目留出足够思考时间。5.3 难度跨度从搬运数据到搜索答案你可以拿一道经典题做对照——LeetCode 875 爱吃香蕉的狒狒。它同样只给一个数组但要求你找出一个最小速度 k使得狒狒能在 h 小时内吃完所有香蕉。这题的难度比数组串联高了一个大台阶因为你要二分答案、写验证函数、处理上取整。两相对比你能明显看到刷题进阶的本质从把数据搬到新位置这种线性操作上升到在答案空间里搜索最优解这种非线性思维。数组串联练的是手二分答案练的是脑但如果没有前者打底的索引感和复杂度感直接跳去啃后者很容易卡在实现细节上。5.4 关于题解的正确打开方式遇到不会的题我的习惯是先卡十五分钟再看题解里的第一个暴力解法把暴力读懂写通再翻优化部分。看题解时不要只复制最优解代码重点看作者是怎么从暴力推导到最优的——他的哪一步思路是我没想到的这一步往往就是你下一次刷题前的复习重点。这道数组串联题虽然没有太多可推导的空间但先暴力、再优化、再抽象的阅读路径会在之后的每一道题里反复出现。6. 我刷这道题留下的几条私人体会6.1 把边界用例做成固定清单我后来把空数组、单元素数组、全相同元素数组、逆序数组这四类用例固定放在本地的一个测试脚本里刷任何数组题都会先跑一遍。不要高估自己的记忆力也不要高估 LeetCode 自动判题能帮你兜住多少边界。你提交前自己发现的问题永远比提交后让平台告诉你更省时间。6.2 每个解法都要亲手走一遍我刷题日记的格式很简单题目我的第一版解法题解里的最优解法二者差距复杂度对比一句话心得。数组串联这题我写了三版分别对应 if 分支、取模、内置拼接然后自己画了张两行的小表时间复杂度都是 O(n)额外空间都是 O(1)但实现层次完全不同。这个动作很轻但它把这题我会变成这题我懂。6.3 别怕简单题也别停留在简单题最后想说简单题最大的风险不是学不到东西而是让人产生我已经会了的错觉。如果你能把数组串联的三种解法、复杂度判断、边界检查在一分钟内全部讲清楚那它对你来说就是合格的热身如果你讲不清楚不妨回去再看一遍。刷题这条路很长从热身题到热门 100 题再到周赛、经典题合集每一层都需要下面那层足够扎实。这道 Q1 教会我的不是怎么拼接数组而是刷题的正确动作到底是什么。
返回列表