☰
LeetCode Hot 100普通数组专题:5道高频题串讲与通用解法
2026/10/14 3:48:48 网站建设 项目流程

很多人一看到"普通数组"四个字,下意识会觉得这板块能有什么难的,数组不就是最基础的数据结构嘛。但真刷到 Hot 100 这一专题的朋友应该都有同感:这几道题没有树、没有图、没有链表,却一道一道能把人卡住,而且每一道都反复出现在算法面试的题单里。这里的"普通数组"特指 Hot 100 里单独划分的一个专题——最大子数组和、合并区间、轮转数组、除自身以外数组的乘积、缺失的第一个正数。难点全部藏在三件事里:数组自身的规律、边界条件的处理、额外空间的压缩。这篇文章就把这五道题横着串起来,逐个讲清思路、代码、复杂度,以及面试官最常问的变体,适合正在系统刷 Hot 100、或者想补一补数组专题的读者。

先给个结论:这个专题刷完,最大的收获不是背下五道题的答案,而是掌握四类通用能力——动态规划状态设计、排序预处理后的贪心判断、原地操作中的边界控制、用数组本身当哈希表。下面进入正题。

1. 先看清"普通数组"专题到底在考什么

1.1 五道题,四个能力维度

很多人觉得这个专题零散,每道题一个解法,没什么关联。其实把它们放一起复盘,能明显看出四个能力维度。

最大子数组和,练的是动态规划的状态设计。核心是把状态定义为"以当前元素结尾的最大连续子数组和",而不是"前 i 个元素的最大值"。为什么强调"以当前元素结尾"?因为连续子数组的要求是必须接得上,只有定义成结尾才能表达出"要么从当前元素新开一段、要么接到前一段后面"这两种合法情况。

合并区间,练的是排序预处理加贪心合并。给一堆区间,不排序就只能在互相比较上消耗大量时间。按左端点排完序以后,任何重叠关系都变成了"从前往后扫一遍就能合并",这就是用一次排序把复杂判断化简的典型。

轮转数组,练的是数学规律加原地操作。右移 k 位等价于先整体翻转、再分段翻转,同时还得记得对 k 取模。这题代码量很小,但边界极其容易写的差一位。

除自身以外数组的乘积,练的是把乘积拆成左右两侧信息。不能用除法,也不能用一个总乘积去整体除,那就把每个位置的答案拆成"左边所有数的乘积 × 右边所有数的乘积",用两趟遍历解决。

缺失的第一个正数,练的是把数组下标当成哈希表的思维。题目要求 O(1) 额外空间,那就不能另开一个 set 记录出现过的数字,只能原地交换,把每个正数放到它该放的位置上,再扫描哪里对不上。

1.2 推荐刷题顺序与理由

我建议按这个顺序刷:最大子数组和 → 轮转数组 → 合并区间 → 除自身以外数组的乘积 → 缺失的第一个正数。

前面两道相对容易,能帮你快速进入状态,建立起"数组题也能有巧解"的信心。中间合并区间引入排序预处理思路,乘积题开始体验双向遍历的优雅。最后缺失正数题把难度顶到最硬核,这时候你已经有前面四道题打底,理解起来顺畅很多。每道新题只引入一个新概念,不会被一上来就劝退。

提示:不建议按照 Hot 100 本身的题号顺序刷。题号顺序是历史生成的,不是学习路径。按思维模型分组刷,效率差距很大。

2. 五道高频题逐一拆解:思路、代码、为什么

2.1 最大子数组和——用最简单的方式入门动态规划

题目要求:给定整数数组 nums,找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。比如输入 [-2,1,-3,4,-1,2,1,-5,4],答案是 6,对应的子数组是 [4,-1,2,1]。

先看暴力做法。枚举所有起点和终点,计算每一段的和,更新最大值,复杂度 O(n²)。在数组长度到 10 万这个量级时直接超时,所以必须找冗余。暴力枚举里大量的求和其实是在做重复计算,滚动地保留"当前这段的和"可以砍掉一部分,但这仍然不够,因为枚举起点这件事本身才是最大的冗余。

动态规划的思路是这样:定义一个变量 cur,表示"以当前位置结尾的连续子数组的最大和"。遍历到 nums[i] 时,cur 只有两种来源:

  • 把 nums[i] 接到前一段的后面,即 cur_old + nums[i];
  • 从 nums[i] 重新开始一段,即 nums[i] 本身。

于是状态转移只有一行:cur = max(cur + x, x)。再用一个 ans 变量记录历史上出现过的最大值即可。

class Solution: def maxSubArray(self, nums: list[int]) -> int: ans = nums[0] cur = 0 for x in nums: cur = max(cur + x, x) ans = max(ans, cur) return ans

为什么 cur 的初始值是 0?因为空子数组贡献为 0,遍历第一个元素时,max(0 + x, x) 等于 x,不会出错。但 ans 的初始值必须是 nums[0],不能是 0。想想全负数数组 [-1,-2,-3],最大子数组和是 -1,如果 ans 初始为 0,最后会错误地返回 0。这是这道题最经典的翻车点。

再往深想一层,题目为什么经典?因为它的状态设计思想可以平移。环形数组最大子数组和,核心就是把答案分成两种情况:要么是普通数组上的最大子段,要么跨越了首尾(等价于总和减去普通数组上的最小子段)。买卖股票的最佳时机,把价格数组转成差分数组,最大利润就是差分数组上的最大子段和。你理解了这一道题,就等于理解了整整一族题目。

2.2 合并区间——排序之后只遍历一遍就够了

题目要求:给定若干个区间 intervals[i] = [start, end],合并所有重叠或相邻的区间,返回互不重叠的区间数组。比如输入 [[1,3],[2,6],[8,10],[15,18]],输出 [[1,6],[8,10],[15,18]]。

这道题最典型的错误是:不排序,直接暴力两两合并。你会发现自己要反复判断哪些区间已经被合并过,还要处理被合并区间把其他区间再次牵进来的连锁反应,代码越写越脏。正确做法是先排序,把问题降维。

按区间左端点升序排序之后,有一个关键性质:遍历过程中,当前区间的左端点一定不小于结果列表最后一个区间的左端点。这意味着合并时只需要看一个条件——当前区间的 start 是否小于等于结果列表最后一个区间的 end。若是,则重叠或相邻,更新右端点为两者较大值;若否,则完全没有交集,直接加入结果列表。

class Solution: def merge(self, intervals: list[list[int]]) -> list[list[int]]: intervals.sort(key=lambda x: x[0]) res = [] for interval in intervals: if not res or interval[0] > res[-1][1]: res.append(interval[:]) else: res[-1][1] = max(res[-1][1], interval[1]) return res

这里有几个细节要刻意记住。

第一,排序必须指定 key=lambda x: x[0]。虽然默认排序也会先按第一列排,但显式写出来,代码意图一目了然,避免别人质疑你靠 Python 默认行为。

第二,重叠判定用的是interval[0] > res[-1][1]作为"不重叠"条件,反过来说,重叠的条件是interval[0] <= res[-1][1]。也就是说,如果输入是 [[1,2],[2,3]],答案是 [[1,3]],因为 2 和 2 相等的时候也算相邻可合并。这个"等于号是否包含"的边界如果不确认,面试时很容易被追问。

第三,res.append(interval[:])这里做了切片复制。直接 append(interval) 也能过,但后续万一有步骤修改原数组,这里引用同一个对象就会产生诡异 bug。习惯性复制一份,成本几乎为零,收益是避免一类坑。

时间复杂度是 O(n log n),瓶颈在排序。空间复杂度 O(log n) 或 O(n),取决于排序实现,一般可以接受。变体题目里,插入区间就是把这个思路反过来用:先把新区间插入到合适的位置,然后再走一遍合并逻辑。区间交集问题则是把双指针和这里的分段判断结合。一道题会了,这一族题都能顺势拿下。

2.3 轮转数组——三次翻转背后的取模规律

题目要求:给定数组 nums,将数组中的元素向右轮转 k 个位置。比如 [1,2,3,4,5,6,7],k=3,输出 [5,6,7,1,2,3,4]。

这道题有三种做法,复杂度递进得非常典型。

第一种是暴力移动:每次把最后一个元素移到开头,重复 k 次。O(k*n),在 k 和 n 都很大时完全没有可行性。

第二种是额外数组:开一个新数组,对每个 i,把 nums[i] 放到新数组的 (i + k) % n 位置上,最后复制回来。O(n) 时间,O(n) 空间,思路最直白。

第三种就是面试官最想看到的原地解法:先把整个数组翻转,再把前 k 个元素翻转,最后把剩下 n-k 个元素翻转。总时间 O(n),额外空间 O(1)。

为什么三次翻转是正确的?它跟右移的规律强相关。右移 k 位以后,数组末尾的 k 个元素会跑到最前面。我们分两步看:整体翻转后,原来在后面的 k 个元素来到数组前部,但它们内部顺序是反的;这个时候再翻转前 k 个元素,就把它们恢复成正确的相对顺序;同理,翻转剩下的 n-k 个元素,恢复后半段顺序。举个例子更直观,[1,2,3,4,5,6,7] 整体翻转变成 [7,6,5,4,3,2,1],前三个翻转成 [5,6,7],后四个翻转成 [1,2,3,4],拼起来正好是 [5,6,7,1,2,3,4]。

实现的时候,两个边界问题最容易翻车。

k 可能大于 n,比如 n=7,k=10,右移 10 位和右移 3 位等价,所以第一件事就是 k %= n。还有 k=0 或者 n=1 这种用例,走了翻转逻辑也不会出错,但提前判断能省去不必要的操作。

翻转函数的区间定义要统一。我习惯用左闭右闭区间,也就是 reverse(nums, l, r),循环里用 while l < r,交换两端后 l += 1、r -= 1。这样调用三次翻转时前两段的边界分别是 (0, n-1)、(0, k-1)、(k, n-1),不容易乱。

class Solution: def rotate(self, nums: list[int], k: int) -> None: n = len(nums) k %= n self.reverse(nums, 0, n - 1) self.reverse(nums, 0, k - 1) self.reverse(nums, k, n - 1) def reverse(self, nums: list[int], l: int, r: int) -> None: while l < r: nums[l], nums[r] = nums[r], nums[l] l += 1 r -= 1

Python 里还有一种一行写法nums[:] = nums[-k:] + nums[:-k],可读性极好,但它新建了列表,不符合原地要求,面试时最好别拿它当主答案。可以提一嘴作为工程上的替代方案,但核心还是给出手写的 reverse。

这题在生活中也能找到对应场景,比如字符串的左旋转、反转英文句子里的单词顺序,思路都是"局部翻转加整体翻转"。把这个操作练熟了,以后遇到任何翻转类问题都不会慌。

注意:轮转数组的翻转公式跟左旋、右旋有关系。右移 k 位是"整体翻转 + 翻转[0,k) + 翻转[k,n)";如果题目改成左移 k 位,只需要把第二段和第三段的边界对调,或者把 k 换成 n-k。面试现场推导一遍最稳妥。

2.4 除自身以外数组的乘积——左右夹击,一趟扫完

题目要求:返回一个数组 answer,answer[i] 等于 nums 中除 nums[i] 之外所有元素的乘积。题目明确禁止使用除法,并要求 O(n) 时间、O(1) 额外空间(返回数组不计入)。

最容易想到的思路是先用一个变量算出所有元素的乘积,再遍历一次,用总乘积除以当前数。但这样做有两个问题:第一,题目明确不允许用除法,属于硬性要求;第二,如果数组中存在 0,除法直接没法处理,哪怕特判也只能在一个 0 的情况下勉强工作,两个 0 就彻底崩溃。所以必须换思路。

数学上任何位置的答案都可以拆成两部分:左边所有数的乘积乘以右边所有数的乘积。这就把问题变成了两次扫描:

第一趟,从左到右,answer[i] 记录 i 左边所有数的乘积。第二趟,从右到左,用一个滚动变量 right 记录 i 右边所有数的乘积,累乘到 answer[i] 上。

class Solution: def productExceptSelf(self, nums: list[int]) -> list[int]: n = len(nums) ans = [1] * n # 从左到右:算左边的乘积 for i in range(1, n): ans[i] = ans[i - 1] * nums[i - 1] # 从右到左:乘上右边的乘积 right = 1 for i in range(n - 1, -1, -1): ans[i] *= right right *= nums[i] return ans

这里有个顺序问题特别容易错。第二趟循环里,必须先ans[i] *= right,再right *= nums[i]。你想象一下,right 是从当前元素右边累积过来的,所以当用到 ans[i] 的时候,right 已经包含了 nums[i+1] 到末尾的全部乘积,当前 nums[i] 本身必须留给左边的位置去乘,不能先进 right。

为什么它只用 O(1) 额外空间?因为 answer 数组本身被用来存第一趟的临时结果,第二趟再就地修改。这是"输出数组不计算额外空间"的经典用法。很多题目里,如果结果允许用数组承载,就可以用它做中间存储,省掉一个独立的辅助数组。

这种"左右夹击"的思路,在另一道知名难题上也能复用——接雨水。接雨水要算每个位置能接多少水,同样需要找左边最大值和右边最大值,然后取两者较小的那个减去当前高度。虽然细节不同,但"两边信息各自累积,再组合"的骨架是完全一致的。所以这道题值得好好消化,它背后是一个思路族。

还有一个小细节:数组里出现 0 时,这套解法完全不受影响。因为有 0 的位置只会让它左右的乘积变成 0,而"左右分离"的方式天然规避了除零问题,也不需要写特判分支。

2.5 缺失的第一个正数——把数组本身当哈希表

题目要求:给定一个未排序的整数数组 nums,找出其中没有出现的最小的正整数,要求 O(n) 时间、O(1) 额外空间。比如输入 [3,4,-1,1],答案是 2;输入 [1,2,0],答案是 3。

看到 O(1) 额外空间,第一反应是"不能用哈希表"。但注意,数组本身就是一块可写的存储空间,只要我们不把它当数组用,而是把它当哈希表用,问题就破了。

先想清楚一个性质:长度为 n 的数组,缺失的第一个正整数只可能落在 [1, n+1] 这个区间里。如果 1 到 n 全部出现,答案才是 n+1;只要有任何一个在 1 到 n 之间的数缺失,答案就会落在 [1, n] 里。换句话说,所有负数、0、大于 n 的数,对我们找答案都没有任何价值,可以直接忽略。

基于这个观察,最干净的做法是原地置换。遍历数组,对每个位置 i,如果 nums[i] 是一个有效值 x,即 1 <= x <= n,而且 x 还没出现在它该在的位置 nums[x-1] 上,就把 x 和 nums[x-1] 交换。整个过程持续到当前位置的数要么无效、要么已经归位,才继续往后走。

class Solution: def firstMissingPositive(self, nums: list[int]) -> int: n = len(nums) for i in range(n): while 1 <= nums[i] <= n and nums[nums[i] - 1] != nums[i]: target = nums[i] - 1 nums[i], nums[target] = nums[target], nums[i] for i in range(n): if nums[i] != i + 1: return i + 1 return n + 1

这个 while 循环里最怕的是死循环。如果数组中两个位置存储了相同的值,比如 [2, 2],交换永远不改变数组状态,条件永远成立,程序就卡住了。所以 while 条件里必须加上nums[nums[i] - 1] != nums[i],只有目标位置上的值和当前值不同才交换。这一步防的是"已经有同样元素归位"的情况,务必理解。

时间复杂度的分析也很有意思。表面上看,for 循环里嵌套了 while,每一步都可能交换,但每次交换都会把一个有效值放到它正确的位置上,而一个位置一旦归位,不会再被换走。因此整个过程的交换次数最多为 n,总时间复杂度是 O(n)。

这道题还有一个等价实现路线:先扫描一遍,把所有无效数(小于等于 0 或大于 n)改为 n+1,然后遍历,对每个有效值 x,把 nums[x-1] 标记为负数,最后找第一个不为负数的下标。我个人的建议是优先掌握置换法,因为它只需要一个直观的"放到该在的位置"思想,面试时在纸上画两个例子就能讲清楚;标记法代码略简单,但是"取反再取反"的细节容易把自己绕晕。

提示:这道题的变体在 Hot 100 里不在少数。找数组中重复的数、找缺失的数、找只出现一次的数,本质上都是一类利用"下标与数值关系"的题目。把置换法搞透以后,看到这类题你会比一般人有天然的敏锐度。

3. 串起来看:数组题的通用套路

3.1 先写暴力,再从冗余里找优化点

每一道题我都建议先在心里把暴力解法走一遍,不是因为它能通过,而是暴力的代码暴露了重复计算的位置。最大子数组和的暴力里,大量重复求和提示你可以用滚动状态消除;乘积题里每个位置反复计算别人的乘积,提示你可以把左右信息分开算;轮转数组的暴力一次移一格,提示你可以用翻转把移动批量完成。优化不是灵感突现,而是从暴力代码的冗余中顺藤摸瓜找到的。

3.2 边界三件套:空数组、单元素、同值

数组题有一个百试百灵的检查流程,每次写完代码,先跑几个用例:空数组或长度为 1 的数组、所有元素相同、所有元素为负数、包含 0 或重复值。最大子数组和在全负数数组上会暴露 ans 初值问题;合并区间在空数组上会暴露 res 判空问题;轮转数组在 k 为 0 时会暴露取模后处理问题;缺失正数在重复值数组上会暴露死循环问题。这一套用例跑完,90% 的边界 bug 都能提前挡住。

3.3 "原地标记"的两条路线

需要 O(1) 额外空间时,"在原数组上做标记"是最高频的策略,但做法分两派。第一派是置换:把每个元素放回它应该出现的位置,典型代表就是缺失的第一个正数。第二派是打标:用符号或特殊值标记某个位置"已经出现过",比如把 nums[x-1] 改成负数。前者的优点是信息保留完整,缺点是交换过程中要小心重复值;后者的优点是写起来简单,缺点是需要先把无效数据清理干净,否则标记会被干扰。遇到题目时,根据有效值范围选择其中一种即可。

3.4 复杂度与核心技巧速查表

题目最优时间复杂度额外空间核心技巧
最大子数组和O(n)O(1)动态规划 + 滚动变量
合并区间O(n log n)O(1)(不计排序栈)排序预处理 + 贪心合并
轮转数组O(n)O(1)取模 + 三次翻转
除自身以外数组的乘积O(n)O(1)前缀乘积 × 后缀乘积
缺失的第一个正数O(n)O(1)原地交换,下标当作哈希表

这张表在做复盘时特别有用。刷完五道题,闭上眼睛回忆表格里每一行:题目说的是什么,最优思路是什么,最容易错在哪。能完整复述出来,才算真正掌握。

4. 实操中的翻车现场与排查心得

4.1 每个题最常见的 Bug

最大子数组和的典型错误是 ans 初始化为 0。全负数数组是它的照妖镜,一定要把 ans 初始化为 nums[0],或者写成cur = nums[0]; ans = nums[0]再从头遍历。

合并区间有三个高频坑。第一是忘记排序,直接遍历,导致合并结果受输入顺序影响;第二是排序时按右端点排,合并逻辑就会失效;第三是把重叠判定写成interval[0] < res[-1][1],遗漏掉相邻区间合并。第三种错非常隐蔽,因为绝大多数测试用例数据不会同时出现恰好相邻的区间,只有真面试的时候才可能被问到。

轮转数组最常见的是忘写 k %= n。输入 k = 10、n = 7 时,后面所有翻转边界全是错的。其次就是 reverse 边界写差一位,比如把前 k 个写成 (0, k) 而不是 (0, k-1),这种错误单看代码几乎看不出来,跑一次用例就会发现。

乘积题最容易在第二趟循环的顺序上出错。如果先把right *= nums[i]再乘到 ans[i] 上,那每个位置都凭空多乘了一次自己,整个答案就错了。我的记忆方式是:right 代表着"当前元素右边的乘积",所以用它的那一刻,不能先让它吞掉当前元素。

缺失正数题的死循环问题前面已经强调过。再多说一个细节:交换的写法。有些人写nums[i], nums[nums[i] - 1] = nums[nums[i] - 1], nums[i],这一行在 Python 里其实既可能正确也可能错误,因为右边的取值在赋值前完成,但可读性很差,容易让人误解。先target = nums[i] - 1再交换,清晰可靠。

4.2 一套可复用的调试流程

我调试数组题的经验是三步走。第一步,打印中间状态。把循环里关键变量(cur、res[-1]、right、交换后的数组)print 出来,几乎立刻能看出逻辑断在哪。第二步,构造极简用例。数组题不要先去跑大随机数据,先跑长度为 1 或 2 的用例,手动画一遍过程。第三步,对照元素个数检查边界。凡是涉及下标差一的,就把 n=3 的小数组完整展开,标出每个 i 的访问范围,往往一遍就能揪出问题。

5. 面试怎么讲、追问怎么接

5.1 讲题的正确节奏

面试遇到数组题,不要上来就写最优解。我的习惯是先花半分钟给一个暴力解,简单说明复杂度,再指出暴力解里冗余在哪,然后引出优化。比如乘积题,先说我想到把总乘积算出来再除,但题目禁止除法,而且存在 0 的情况这个方案不成立,所以改为左右两次扫描。这样讲,既展示了你的思考过程,也给了面试官和你讨论的抓手,比直接甩出最优解要好得多。很多面试官其实更在意你有没有能力把"为什么不能这么做"讲清楚。

轮转数组这类题尤其适合展示现场推导能力。把三次翻转对着一个小数组推一遍,再写代码,整个过程不超过三分钟,但给面试官的印象是完全不同的。

5.2 高频变体清单

每个题我整理了一个追问清单,刷完以后自己练一练:

  • 最大子数组和 → 允许删掉一个元素后的最大子数组和、环形最大子数组和、最大子矩阵(二维)。
  • 合并区间 → 插入区间、区间列表的交集、会议室问题(判断区间是否重叠)。
  • 轮转数组 → 字符串左旋转、反转单词顺序。
  • 除自身以外数组的乘积 → 左右夹击思路复用到接雨水。
  • 缺失的第一个正数 → 找重复数、找缺失的重复元素族。

把这些变体都过一遍,等于用五道题撬动了附近十几道题,性价比非常高。

我个人刷了几轮这个专题后的体会是:数组题最忌讳"背模板"。五道题看起来解法各自独立,但他们反复训练的是同一套底层感觉——在约束条件里找规律,在边界处保持敏感。你不需要记录每一道题的答案,只需要记住每次题目限制"不能用 X"或"必须 O(1) 空间"时,那都是出题人在暗示你换一个思路,而这套思路恰恰是工程中处理大规模数据时真正用得上的东西。最后再分享一个小方法:把这五道题每三天重写一遍,连续写三次,每次不参考任何资料,直到能在十分钟内一次通过全部边界用例。这个方法帮我打下了数组题的底子,也是我现在推荐给身边人的最高效复现方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询