从今天起,我打算正式把 LeetCode 刷题这件事变成每日固定节目,用一篇篇刷题日记把思路和坑一起记下来。Q1 我选的是数组串联这道题,LeetCode 第 1929 题,难度 Easy,原题名 Concatenation of Array。它真的非常简单,但越是简单的题,越适合把数组的底层操作、空间分配、代码风格这些基本功一次讲透。这篇文章我会把题干、解法、复杂度计算、我踩过的坑,以及后续刷题安排的思考全部写出来,给同样从零开始的朋友一条可以直接参考的路径。
1. 题目到底在考什么:看穿数组串联的核心要求
1.1 题干逐句拆解:为什么是 i 和 i + n
原题描述大概是这样:给你一个长度为 n 的整数数组 nums,需要构造并返回一个长度为 2n 的新数组 ans,满足两个条件:
- ans[i] == nums[i]
- ans[i + n] == nums[i]
我第一次看到 i + n 的时候愣了几秒,但把它翻译成人话就非常简单:把原数组完整复制一份,拼在自己后面。数组下标从 0 开始,所以前 n 个位置直接照搬 nums[0] 到 nums[n-1],后 n 个位置再照搬一遍。
拿示例 nums = [1, 2, 1] 来说,期望输出就是 [1, 2, 1, 1, 2, 1]。你可以想象成一条队伍被复制成了两列,前一列还是原来的顺序,后一列完全相同。
这个定义有意思的地方在于,它不是在简单说“把两个数组拼起来”,而是明确告诉你:从原数组的下标 i,可以映射到新数组的两个位置 i 和 i + n。这种一对多的索引映射,在初学者眼里没什么感觉,但后面做到矩阵旋转、滑动窗口、循环数组时就会发现,很多题的核心就是“从一个下标映射到另一个下标”。数组串联就是最朴素的映射练习,所以它适合当 Q1。
1.2 输入输出和约束条件:决定你代码怎么写的关键
原题的约束我记得非常宽松:n >= 1,nums[i] 在 0 到 1000 之间。它保证了数组非空,所以不需要处理空数组这种边界。
但有一个约束很容易被忽略:题目要求返回一个新数组,而不是修改原数组。这意味着你不能试图在 nums 身上“原地增长一倍长度”,然后把返回值丢出去。就算某些语言能原地扩展,你也要清楚返回的到底是新对象还是旧引用。
另一个值得思考的约束是元素值范围。因为元素可能包含 0,所以如果你初始化了一个长度为 2n 的数组,默认值都是 0,却只填充了前半段,后半段也会是 0。某些测试用例里原数组的对应位置恰好也是 0,这种 bug 反而不容易被肉眼发现。
我在准备这道题时,会先手写几个输入输出示例:
| 输入 nums | 期望输出 ans |
|---|---|
| [1] | [1, 1] |
| [1, 2] | [1, 2, 1, 2] |
| [1, 2, 1] | [1, 2, 1, 1, 2, 1] |
| [0, 5] | [0, 5, 0, 5] |
这一步看起来浪费时间,但对建立“先理解题意再动手”的习惯非常有用。你会在示例里直接看出后半段和前半段完全一样,而不是把题理解成某种复杂的翻转或复制。
1.3 为什么越简单的题越要认真拆
很多人刷题喜欢直接打开题解,看到 Python 一句return nums + nums就觉得自己会了。但刷题日记的意义在于:你需要知道自己为什么这么做,而不是单纯背答案。
数组在内存里是一段连续空间,长度在创建时就已经固定。Java 的 int[]、C++ 的 vector、Go 的 slice 都遵循这个基本逻辑。所谓“串联”,在底层无非就是一次内存分配和 n 次赋值。如果你能通过这道简单题把“连续空间 + 下标访问”这个模型刻进脑子里,后面遇到链表、哈希表、树这些结构时,你会更清楚它们之间的差异和使用场景。
2. 解法思路与选型:从循环复制到一句话返回
2.1 最直观的写法:新建数组加一次循环
我第一个 AC 的版本非常简单:
class Solution: def getConcatenation(self, nums: List[int]) -> List[int]: n = len(nums) ans = [0] * (2 * n) for i in range(n): ans[i] = nums[i] ans[i + n] = nums[i] return ans这段代码的核心只有一个循环,但它在一次循环里做了两件事:把 nums[i] 给到 ans[i],再把同一个值给到 ans[i + n]。这正好对应题目里的两个等式。
很多初学者会写成两个独立的 for 循环,第一段填充前半段,第二段填充后半段。功能完全正确,但我更推荐单循环的写法,因为逻辑上更贴近题目的“同一个原下标映射到两个新下标”这个本质。以后遇到这类映射题,优先想“一次循环做两次赋值”,而不是机械地写两遍遍历。
在 Java 里是同样思路:
class Solution { public int[] getConcatenation(int[] nums) { int n = nums.length; int[] ans = new int[2 * n]; for (int i = 0; i < n; i++) { ans[i] = nums[i]; ans[i + n] = nums[i]; } return ans; } }注意 Java 的新数组默认会先用 0 填满,所以我们不需要清空动作,直接赋值即可。C++ 和 Go 也类似,vector 和 make 出来的 slice 都会做零值初始化。
2.2 语言内置能力:能不能直接调用现成 API
这道题最简单优雅的写法往往不是循环,而是语言内置的拼接手段:
- Python:
return nums + nums或者return nums * 2 - Java:用
System.arraycopy,把 nums 复制到 ans 前半段和后半段 - C++:
ans.insert(ans.end(), nums.begin(), nums.end())执行两次 - Go:
append(ans, nums...)执行两次
以 Java 为例:
class Solution { public int[] getConcatenation(int[] nums) { int n = nums.length; int[] ans = new int[2 * n]; System.arraycopy(nums, 0, ans, 0, n); System.arraycopy(nums, 0, ans, n, n); return ans; } }这种写法很漂亮,性能也不差,因为 System.arraycopy 是 native 方法,底层通常是内存块复制,比一层层 for 循环的常数更小。但我依然建议第一次做题时先写循环版本,原因有两个:
第一,面试时你可能会被追问内置方法的实现思路。如果你能答出“它本质上就是申请一段空间,然后把原数组的元素逐个或批量拷贝过去”,面试官会加分;如果你只会说“函数就是干这个的”,那印象分会打折。
第二,如果题目变形,比如只要求把数组前 k 个元素拼到后面,或者要求交错排列,return nums + nums这种写法就立刻失效。你脑子里如果没有循环和索引的概念,遇到变体题会很被动。
2.3 选型背后的经验:先保正确,再谈简洁
我见过很多初学者特别喜欢追求一行解,觉得代码越短越厉害。在这道题上,一行解确实足够优雅,但刷题这件事最终目的不是写出最短代码,而是建立解决问题的思维模型。
如果给解法排个学习优先级,我的顺序是:循环版本 > 内置 API 版本 > 一行结论版本。在线上项目里,我可能更倾向选最短最清晰的写法,因为代码是给人读的;但在刷题日记里,我要求自己先能把每一步拆开讲明白。
还有一个工程视角:周赛或面试中,你每写一行代码都要对自己负责。一句nums + nums看起来没什么问题,但如果你对 Python 的列表拼接机制理解不透,就很容易在“列表里存的是对象引用”这类坑里栽跟头。分数数组是整数没问题,可换成一个自定义对象列表,乘号复制出来的可能是一堆共享引用,而不是独立副本。简单题反而能帮我们提前看到这类隐患。
3. 代码实现与复杂度分析:四种语言上手记录
3.1 主流语言代码对照
我实际把这道题在几种常用语言里都写了一遍,统一采用最直观的循环版本,方便对比差异。
C++ 版本:
class Solution { public: vector<int> getConcatenation(vector<int>& nums) { int n = nums.size(); vector<int> ans(2 * n); for (int i = 0; i < n; ++i) { ans[i] = nums[i]; ans[i + n] = nums[i]; } return ans; } };Go 版本:
func getConcatenation(nums []int) []int { n := len(nums) ans := make([]int, 2*n) for i := 0; i < n; i++ { ans[i] = nums[i] ans[i+n] = nums[i] } return ans }这几份代码在逻辑上完全等价。不同语言之间的差异主要体现在初始化方式:
- C++ 的
vector<int> ans(2 * n)会构造一个长度为 2n 且所有元素为 0 的 vector。 - Go 的
make([]int, 2*n)同样会做零值初始化。 - Java 的
new int[2 * n]默认填充 0。
所以不存在“旧数组残留内容”的问题,你只需要关注要写的位置是否正确。
3.2 时间复杂度和空间复杂度到底怎么算
这个算法的时间复杂度是 O(n),因为只需要遍历长度为 n 的原数组一次,每次循环做常量次操作。
空间上,我们创建了一个长度 2n 的新数组,所以额外空间是 O(n)。按照大 O 表示法,O(2n) 会写成 O(n),因为常数倍不影响增长趋势。
这里有一个值得细究的细节:刷题里说的空间复杂度,通常指的是“除了题目要求返回的结果之外,额外使用的空间”。这道题返回的 ans 本身就是输出的一部分,所以严格来说,额外空间只是循环里的几个临时变量,也就是 O(1)。但在面试中,你说“总空间是 O(n)”也不会被扣分,反而体现了你考虑得比较全面。
如果你用 Python 的return nums + nums,底层会创建一个新的长度为 2n 的列表,并把原列表的元素逐个复制进去,时间复杂度和手动循环完全相同,都是 O(n)。空间也一样,会额外占用 O(2n) 的堆空间。所以从算法层面看,内置 API 不是“更高效”,只是“更简洁”。
3.3 我的提交记录和真实感受
我第一次提交后返回的是 Accepted,运行耗时和内存数值在这个约束下已经不重要,因为 n 最大才 1000。但我依然认真记录了结果:Java 版本大概 1ms,Python 版本大概 40ms,这更多反映的是语言和评测环境的差异,不代表哪个写法更优。
真正花费时间的是后面的复现练习:我在本地把几种语言都跑了一遍,又故意写了几个错误版本,观察越界异常和错误结果。个人体会是,简单题最容易让人觉得“没必要”,但恰恰是这些基础操作,决定你后面能不能稳定写出不越界的代码。一道题花 30 分钟 AC,再用 1 小时做变体和复盘,收益远大于草草刷完十道重复题。
4. 实操中踩过的坑与排查技巧
4.1 高频错误:越界、引用、惯性思维
刷题写代码,有些错是新手必踩的,数组串联虽然简单,但恰好能集中展示这些问题。
第一个常见错误是忘记给新数组分配空间,直接在原数组上操作。比如你在 Java 里写下nums[i + n] = nums[i],可是 nums 的长度只有 n,访问 n 及以后的下标立刻抛 ArrayIndexOutOfBoundsException。这提醒你:原数组和新数组是两个独立空间,不能混用。
第二个错误是把赋值理解为引用复制。尤其是在 Python 里,如果你写ans = nums,再对 ans 做操作,nums 也会跟着变,因为两个名字指向同一个列表对象。正确做法是ans = [0] * (2 * n),手动创建新空间。这个知识点在这道题里不一定犯,但后面刷到矩阵复制、链表复制时一定会遇到。
第三个错误是忽略默认值带来的假阳性。假设 nums = [0, 1],正确期望是 [0, 1, 0, 1]。如果你初始化 ans 为长度 4 的数组,默认全是 0,然后只填充前半段 [0, 1],忘记填充后半段,得到的结果是 [0, 1, 0, 0]。因为第三个位置恰好应该是 0,所以肉眼很难看出问题,一提交就 WA。这个例子很有价值,它说明“数组默认值为 0”这件事有时反而会掩盖缺失赋值的问题。
4.2 排查技巧:小样本手工推演和打印下标
我最常用的排查方法,是把输入缩到最小,然后手工推演每个索引。比如 nums = [1, 2, 1],n = 3:
| i | 写入位置 | 写入值 | 当前 ans |
|---|---|---|---|
| 0 | ans[0], ans[3] | 1 | [1, 0, 0, 1, 0, 0] |
| 1 | ans[1], ans[4] | 2 | [1, 2, 0, 1, 2, 0] |
| 2 | ans[2], ans[5] | 1 | [1, 2, 1, 1, 2, 1] |
只要把这么一张表写出来,代码的逻辑对不对、下标是否会越界,基本一眼就能看出来。如果算法复杂一些,我会在循环里加一行临时输出:
print(i, i + n, ans)这样能直接看到每次写入的位置和当前数组状态。养成这种“跟着程序走一遍”的习惯后,你会发现大部分 bug 并不是算法思路错,而是下标边界和变量状态没跟上。
4.3 从踩坑里总结出的通用刷题习惯
刷了几道题之后,我给自己定了几条规矩,希望以后每题都遵守:
- 先写清楚输入输出和边界条件,再写核心逻辑。
- 在循环里用局部变量保存长度,比如
int n = nums.length,避免每轮都重复取长度。 - 每次访问数组下标前,先想清楚它的上下界。
- 提交之前,至少用一个小样本在纸上跑一遍。
还有一个小习惯:在代码里把关键不变式写成注释。比如:
for i in range(n): # 不变式:0 <= i < n,且 i + n < 2n,两个下标都合法 ans[i] = nums[i] ans[i + n] = nums[i]这个注释看起来多余,但它能逼你自己把边界条件想明白。等做到二分查找、双指针这类更复杂的题时,这种“先写证明,再写代码”的习惯会非常救命。
5. 从一道简单题延伸出去:适合新手的后续路线
5.1 数组类题目的共性:索引映射、遍历顺序、原地与拷贝
数组串联的核心是索引映射,你可以顺着这个主题去找同类题,会发现很多热门题都在考同一个底层能力。
比如 LeetCode 热门 100 题里的两数之和,它需要把数组下标和哈希表联系起来;轮转数组那类题,需要你理解(i + k) % n这个下标映射;删除有序数组中的重复项,则考验你维护快慢两个下标的能力。
这些题表面难度不同,但底层都在要求你对数组下标足够敏感。数组串联里的i和i + n是一对多的映射,轮转数组里的(i + k) % n是环状映射。你把简单的映射关系吃透了,再面对复杂映射时就不会慌。
5.2 后续练习节奏:不要急着直接上难题
我给自己设计的路线是先稳住 Easy 题:数组串联、只出现一次的数字、移动零、杨辉三角,先把输入输出、遍历、原地修改这些基本功打牢。然后进入双指针和二分查找专题,再慢慢接触中等题。
这里想特别提一下二分查找中那道爱吃香蕉的狒狒(LeetCode 875,Koko Eating Bananas)。它看起来和数组串联完全不沾边,但你在写二分模板时,依然要精确定位mid,依然要小心访问piles[mid]时是否越界,依然要用循环去累计某种结果。这些能力的起点,都是能把简单数组遍历中的边界讲清楚的人。
周赛也是一样的道理,比如周赛 430 的第一题,通常就是这类能直接拆成数组操作的基础题。不要因为简单就跳过周赛,周赛的意义在于训练你在有限时间内快速完成“读题 -> 提取映射 -> 写出循环”的整套流程。
5.3 如何把简单题价值榨干:一题多解和反向思考
每道 Easy 题,我都建议至少写出两种解法,再想一个扩展问题。以数组串联为例:
第一种是我们常用的新数组循环法。第二种可以尝试语言 API 法,感受一下简洁和底层逻辑之间的取舍。第三种可以想一个变体:如果传入的不是数组,而是链表,你会怎么实现两个链表的拼接?这时候你需要考虑尾节点指针、空链表、节点复制还是共享节点等问题,一下子就把题目宽度打开了。
还可以反向思考:如果要求你写一个工具函数concat(int[] a, int[] b),把两个不同长度的数组按顺序拼起来,代码应该怎么写?答案是类似的,但边界判断会更多。这种小小的变体练习,能让你从“背题解”变成“造题解”,价值完全不同。
我个人在实际操作中体会最深的是,简单题最容易给人一种“我已经会了”的错觉。数组串联我五分钟就写出了 AC 代码,但要把下标关系、新旧数组、浅拷贝深拷贝的差别、空间复杂度口径讲清楚,却花了快一个小时。我建议你也把刷题日记坚持写下来,别怕在简单题上花时间。那些被一道 Easy 题逼出来的细节,往往会在下一道 Medium 题里拉你一把。