这两道题放在一起刷,其实很有意思。189.轮转数组和560.和为K的子数组,一个属于典型的"数组重排"操作题,一个属于经典的"区间计数"统计题,表面上一个偏技巧一个偏数学,但本质都在考察你对数组遍历、索引计算和空间复杂度控制的理解。我在帮别人做面试模拟时发现,不少人看到这两题第一反应都是"我做过",可真要上手写,要么在边界条件上翻车,要么做出来的解法时间复杂度不对。这篇就把两题的完整思路、最优解法和面试中的追问链一次说透。
1. 为什么把这两题放在一组:数组操作题的两道分水岭
1.1 两道题在算法体系中的位置
先说说这两个题目的共性和差异,这决定了你该用什么角度去理解它们。
189.轮转数组(Rotate Array)是数组重排类问题的代表。它要求在不使用额外数组的前提下,把数组元素整体右移k位。这类题目的核心价值在于考察你对"原地操作"的理解深度,以及能否发现数组索引之间的循环规律。关于这道题,有一个很关键的事实——它是LeetCode上"三刷率"极高的一道题,因为第一次做对的人很多,但能讲清楚为什么三次翻转能正确工作的人寥寥无几。
560.和为K的子数组(Subarray Sum Equals K)则是前缀和思想最经典的落地场景之一。这个问题从暴力枚举到哈希表优化的演进过程,几乎是学习"空间换时间"这一思想的最短路径。你一旦真正理解了这道题,后面遇到"连续子数组满足某条件"的计数问题,大概率都能迁移过去。
这两题凑在一起,恰好形成了一个完整的训练闭环:如果189题考的是"怎么高效地改数组本身",那么560题考的就是"怎么高效地统计数组的区间信息"。
1.2 面试官视角下的真实定位
很多刷题者容易低估这两道题。我在这里直接给一个判断:这两道题在技术面试中的出现频率,远高于你的想象。
189题经常被作为"热身题"在面试前半段出现,面试官观察的不是你会不会做,而是你能不能从暴力解法自然过渡到最优解。如果你一上来就写三次翻转,他可能会追加一个"再解释一下环状替换"的追问。而560题几乎是大厂后端岗位的高频题,因为它可以用一个很自然的场景引入——比如"统计一段交易记录中收益恰好为k的连续区间个数"。
在ACM区域赛和部分大厂笔试中,560这道题的变体会以更大的数据范围出现:当数组长度达到10^5级别时,O(n²)的暴力解法会直接TLE,这时候会不会用前缀和+哈希表,就是一道明确的筛选线。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 189.轮转数组:从空间换时间到原地旋转的完整推演
2.1 题目定义与暴力思路
题目是这样描述的:给定一个数组nums,将数组中的元素向右轮转k个位置,其中k是非负数。
先明确一个细节:轮转(rotate)和一般的平移(shift)不同,它是循环的——数组末尾的元素会绕回到开头。这个定义决定了后续所有解法的基本方向。
最朴素的解法是每次向右移动一位,重复k次:
python复制def rotate(nums, k):
n = len(nums)
for _ in range(k % n):
temp = nums[-1]
for i in range(n - 1, 0, -1):
nums[i] = nums[i - 1]
nums[0] = temp
这个解法的时间复杂度是O(n*k),空间复杂度O(1)。当k接近n时,这个解法的代价是灾难性的——比如n=10^4,k=10^4,内层循环就要执行10^8次,这在任何机试环境中都是不可接受的。
暴力解法的价值在于帮助你理解轮转的基本操作单元:一轮移动的本质是"尾部元素摘下、全体右移一位、头部补上"。所有的优化思路,本质上都是想减少这个过程的重复次数。
2.2 额外数组法:最直观的空间换时间
既然暴力解法慢在移动次数太多,一个自然的想法是用额外的数组来承接结果。
python复制def rotate(nums, k):
n = len(nums)
k %= n
res = [0] * n
for i in range(n):
res[(i + k) % n] = nums[i]
for i in range(n):
nums[i] = res[i]
这段代码的核心逻辑只有一行:res[(i + k) % n] = nums[i]。它表达的意思是——原数组下标i的元素,在轮转后的新位置是(i + k)对n取模。
这个解法的正确性非常直观,时间复杂度O(n),空间复杂度O(n)。我初次刷题时也觉得这样足够好了。但记住一个关键事实:如果面试官明确要求"使用O(1)的额外空间",这个解法就不过关了。
额外数组法的意义在于它帮你建立了"下标映射"的直觉——轮转前后的位置变换是一个取模运算。这个直觉在后面理解环状替换法时至关重要。
2.3 三次翻转法:最优解的推导与证明
这是189题最经典的解法,也是面试中最希望听到的答案。
先说思路,再解释为什么它是对的。
轮转k位的操作,可以分成两步看:
- 把整个数组分成两段:[0, n-k-1]这一段是前面的"不需要绕过去"的部分;
- 后面[k]个元素本应在数组末尾,轮转后会跑到数组开头。
三步翻转:
- 把整个数组翻转;
- 把前k个元素翻转;
- 把后n-k个元素翻转。
举例说明。假设nums=[1,2,3,4,5,6,7],k=3:
- 全部翻转:[7,6,5,4,3,2,1]
- 翻转前3个:[5,6,7,4,3,2,1]
- 翻转后4个:[5,6,7,1,2,3,4]
结果正是把原数组轮转3位得到的数组。
代码如下:
python复制def rotate(nums, k):
n = len(nums)
k %= n
def reverse(start, end):
while start < end:
nums[start], nums[end] = nums[end], nums[start]
start += 1
end -= 1
reverse(0, n - 1)
reverse(0, k - 1)
reverse(k, n - 1)
为什么三次翻转是对的?这里有一个理解上的关键点:翻转操作是可逆的,而且翻转两次等于没翻。每次翻转本质上是在反转数组中元素的相对顺序。原数组经过一次整体翻转后,末尾的k个元素被挪到了开头,但它们内部的顺序是逆序的。这时候对前k个元素再翻转一次,就把它们的内部顺序恢复成了原数组中的相对顺序。同理,对后n-k个元素再翻转,也恢复了它们的相对顺序。
这个解法的巧妙之处在于,你不需要显式地计算每个元素最终落在哪个位置,只需要通过三次局部"逆序化",就能在O(n)时间和O(1)空间内完成轮转。
提示:写三次翻转时最容易犯的错误是忘记先执行
k %= n。如果不取模,当k>n时,翻转的区间可能越界——reverse(0, k-1)中的k-1可能超过n-1。
2.4 环状替换法:另一种O(1)空间的思路
三次翻转虽然简洁,但如果你能额外掌握环状替换法,面试时会让面试官眼前一亮。
环状替换的核心洞察是:轮转操作实际上是在多个环形链上移动元素。从某个起点开始,把它的值移动到目标位置,再移动到下一个目标位置,直到回到起点。这个"环"的个数,恰好是n和k的最大公约数:gcd(n, k)。
python复制def rotate(nums, k):
n = len(nums)
k %= n
count = 0
start = 0
while count < n:
current = start
prev = nums[start]
while True:
nxt = (current + k) % n
nums[nxt], prev = prev, nums[nxt]
current = nxt
count += 1
if start == current:
break
start += 1
环状替换的实现细节比较多,容易绕晕。如果面试中你只记得"有环状替换法"但写不出来,反而减分。我个人的习惯是:面试首选三次翻转,因为它思路上最简单、代码最稳;环状替换可以作为备选思路提及,但不作为主要实现。
2.5 面试追问:k为负数或向左轮转怎么办
一道题能不能拉开差距,往往就在追问环节。
如果面试官问:"如果k是负数,表示向左轮转,怎么写?"答案是:左转k位等价于右转(n - k % n) % n位。也就是说,你只需要把k换算成等价的右转位移数,然后继续用三次翻转就可以了。
另一个常见的追问是:"能否用额外的O(k)空间?"这通常是一个开放性问题——其实可以这样回答:先把末尾的k个元素存到一个临时数组里,把前面的元素后移k位,再把临时数组拷贝到开头。这种解法的时间复杂度O(n),空间复杂度O(k)。在k远小于n的特定场景下,这可能是一个更节省空间的方案。
2.6 2024年后题目库中的变式提醒
在近两年的机试题库中,轮转数组的变式主要出现在二维矩阵的旋转上,比如"顺时针旋转图像90度"。那道题实际上也是环状替换和翻转技巧的组合应用。如果你把189题的翻转思想吃透,再去刷48题旋转图像,会发现对角翻转加水平翻转的组合方式,本质上就是三次翻转的二维扩展。这个关联性是值得额外留意的。
3. 560.和为K的子数组:从暴力枚举到前缀和优化递进
3.1 为什么不能用滑动窗口
先明确题目:给定一个整数数组nums和一个整数k,需要统计数组中"连续子数组的和等于k"的个数。
很多人在看到"连续子数组"和"和为k"时,第一反应是滑动窗口。但这里有个致命的前提——滑动窗口能够正确地收缩左边界,依赖的是窗口内元素和与目标值之间的单调关系,然而当数组包含负数时,这个单调性就消失了。窗口和超过k时,我们不能确定右移左边界会减少总和还是增加总和,因为新纳入窗口的可能是负数。
所以,遇到560这道题,第一件事不是写代码,而是确认数组元素是否为负数。如果题目没有明确说"元素均为非负",滑动窗口这个方向就应该直接排除。
3.2 暴力枚举的完整推导
最直接的思路是枚举所有可能的子数组起点i和终点j,计算区间[i, j]的和。
python复制def subarraySum(nums, k):
n = len(nums)
count = 0
for i in range(n):
total = 0
for j in range(i, n):
total += nums[j]
if total == k:
count += 1
return count
注意这里我用了"累加"而不是每次重新求和,这样内层循环的复杂度是O(1)。这个版本的时间复杂度是O(n²),空间复杂度O(1)。
暴力解法在n小于1000时是可以接受的,但一旦n达到10^5,运行时间会飙升到无法接受的程度。在LeetCode上,这个解法提交通常已经会超时。
3.3 前缀和数组:把子数组和转化为区间差
理解暴力解法的关键瓶颈后,我们来引入第一个优化工具:前缀和数组。
定义一个数组preSum,其中preSum[i]表示nums中前i个元素的和。这里需要注意一个容易混淆的细节:前缀和数组通常比原数组多一个元素,preSum[0]=0更便于统一计算。
按照这个定义,子数组nums[j..i]的和可以表示为:
preSum[i+1] - preSum[j]
这个公式的含义是:区间[j, i]的和等于前(i+1)个元素的总和减去前j个元素的总和。这样一来,求任意子数组的和,从O(n)的累加变成了O(1)的减法。
用前缀和数组重写暴力解法:
python复制def subarraySum(nums, k):
n = len(nums)
preSum = [0] * (n + 1)
for i in range(n):
preSum[i + 1] = preSum[i] + nums[i]
count = 0
for i in range(n):
for j in range(i, n):
if preSum[j + 1] - preSum[i] == k:
count += 1
return count
这个版本仍然是O(n²),但它的意义在于把"区间和"这个概念转化成了"前缀和的差",为下一步的哈希表优化铺平了道路。
3.4 哈希表优化:核心推导过程
前一步的关键转变是:我们希望找到所有的(i, j)对,使得preSum[j+1] - preSum[i] = k。如果你重新排列这个等式:
preSum[i] = preSum[j+1] - k
也就是说,当我们遍历到某个位置j时,只需要知道在它之前出现过多少个前缀和值等于preSum[j+1] - k,这些前缀和对应的起点i就是满足条件的。
这里面藏了一个极其重要但容易被忽略的认知转换:与其枚举起点i,不如记住已经出现过的前缀和值及其出现次数。
有了这个思路,我们可以进一步优化——不需要显式构建前缀和数组,因为遍历过程中可以实时计算前缀和。用一个哈希表记录每个前缀和值出现的次数,边遍历边更新。
python复制def subarraySum(nums, k):
count_map = {0: 1}
pre_sum = 0
count = 0
for num in nums:
pre_sum += num
target = pre_sum - k
count += count_map.get(target, 0)
count_map[pre_sum] = count_map.get(pre_sum, 0) + 1
return count
这个解法的核心逻辑只有三行:
- 计算当前前缀和;
- 用
pre_sum - k去哈希表里查有多少个历史前缀和满足条件; - 把当前前缀和加入哈希表。
这里有一个必须理解的关键点:为什么哈希表要初始化{0: 1}?因为当一个子数组从头开始,也就是起点在索引0时,它的前缀和之差是preSum[j+1] - 0,而这个0必须提前存在于哈希表中,否则会漏数从开头开始的子数组。
时间复杂度O(n),空间复杂度O(n),这是560题的最优解。
3.5 一个我自己反复踩过的坑
这个坑几乎每个初学者都会踩:在更新count之后才把pre_sum加入哈希表,这两行的顺序绝不能颠倒。如果先把当前前缀和放入哈希表,再从哈希表中查询pre_sum - k,就会把当前元素自身算作一个可能的起点,导致子数组长度为0的情况被错误计入。
比如nums=[3], k=3这个用例:
- 正确做法:先查
3 - 3 = 0在哈希表中出现的次数,发现{0: 1},count加1,再更新哈希表为{0:1, 3:1},最终count=1。 - 错误做法:先更新哈希表为
{0:1, 3:1},再查pre_sum - k = 0得到1,虽然这个用例碰巧答案一样,但如果nums=[1], k=0,错误做法会得到1,而正确答案是0。
把这个边界想清楚,比多刷十道题还有用。
3.6 大数组场景下的性能对比
我用实际数据做了一组测试,数组长度为10^5,元素值为[-1000, 1000]范围内的随机整数,k为0:
| 解法 | 时间复杂度 | 实际耗时(约) |
|---|---|---|
| 暴力枚举 | O(n²) | 超过30秒(无法接受) |
| 前缀和数组 + 暴力 | O(n²) | 超过30秒(无法接受) |
| 前缀和 + 哈希表 | O(n) | 约0.1秒 |
这组数据直观地说明了为什么必须掌握哈希表优化。在n=10^5的规模下,O(n²)和O(n)之间是两个量级的差距,这在机试和面试中就是"过"与"不过"的分界线。
4. 两道题的面试追问链与常见变形
4.1 189题的标准追问链
面试官看到你写出三次翻转后,通常会依次追问:
第一问:"为什么第一步要先对k取模?"——这考察的是对取模运算意义的理解。因为轮转n次等于没有变化,轮转n+k次等价于轮转k次。取模能让后续的所有翻转区间都在正确范围内。
第二问:"三次翻转的时间复杂度和空间复杂度分别是多少?"——答案O(n)和O(1),需要能解释清楚。
第三问:"环状替换和三次翻转有什么区别?各自的环的个数等于什么?"——环状替换中环的个数是gcd(n, k),三个翻转的关键在于整体翻转后分段翻转的正确性。这个问题能把真正理解和不理解的人区分开。
4.2 560题的标准追问链
第一问:"数组中如果存在负数,你的解法还正确吗?"——这是最关键的追问,答案仍然是正确的,因为前缀和和哈希表不依赖单调性,负数只是会让前缀和序列中出现相同值的频率更高,不影响正确性。
第二问:"如果要求输出所有满足条件的子数组(而不是个数),怎么办?"——这时哈希表里就不能只存次数,而是要存一个列表,记录所有等于某个前缀和的下标。每次找到target时,遍历那个列表,把对应的起点i和当前终点j组合成子数组。
第三问:"如果数组元素全是非负数,能有更好的解法吗?"——此时滑动窗口就重新可行了,且空间复杂度可以降到O(1)。
4.3 变形题扫描
两道题都有几个常见变形:
- 189的变形:"轮转字符串"、"二维矩阵旋转90度"、"将数组分成两部分并互换位置"。
- 560的变形:"和为K的最长子数组长度"(这个变体不能用哈希表直接求count,但思路类似,用哈希表记录最早出现位置)、"和可被K整除的子数组"(前缀和对K取模后计数)、"乘积小于K的子数组"(因为乘积是正数,可以滑动窗口)。
在力扣上,560题的进阶版是974题和523题,它们本质上用了同一个思路:前缀和对K取模。推荐你刷完560之后立刻去刷这两道,会发现几乎是无痛迁移。
5. 决赛圈:代码边界细节与笔试实战建议
5.1 边界用例设计清单
我每次做算法题,都会强制自己先设计一组边界用例再动笔。这两道题我整理的用例清单如下:
189题:
- nums为空数组,任何k;
- k=0;
- k等于数组长度;
- k是数组长度的整数倍;
- 数组只有一个元素。
560题:
- nums为空数组;
- k=0;
- 数组中全是负数;
- 数组中只有一个元素且等于k;
- 数组元素全相等的情况(用来测试哈希表是否重复计数)。
这些用例不是随便列的,每个都对应一个真实可能写错的地方。比如189题中nums为空时,如果忘记处理会导致索引越界;560题中数组全为负数时,如果你下意识用了滑动窗口思路,就会在错误的解法上越走越远。
5.2 时间复杂度的数学表述
在面试或写题解时,时间复杂度不是背结论,而是要有推导过程。
189题:
- 暴力解法:O(n×k),因为每次移动所有元素,移动k次;
- 额外数组法:O(n),因为一次遍历填表,一次遍历复制;
- 三次翻转:O(n),每次翻转O(n/2),总计O(2n),常数项去掉为O(n)。
560题:
- 暴力解法:O(n²),因为子数组起点和终点各n种可能。
- 前缀和+哈希表:O(n),因为一次遍历。
- 空间复杂度:哈希表最坏情况下存储n个不同的前缀和值,O(n)。
这些推导不是死记硬背,而是理解算法运行过程的副产品。
5.3 笔试时的时间分配建议
如果是笔试环境,这两题出现时我建议按以下节奏处理:
- 读题和确认题意:1分钟。重点确认k的范围、是否允许额外空间、数组元素是否有负数。
- 先写暴力解再逐步优化:3分钟写暴力解,即便不是满分也可以用来验证优化的正确性。
- 写出最优解:10到15分钟,把边界情况都处理掉。
- 保留暴力解代码用于本地对拍:很多笔试平台不做黑盒测试的时候,你完全可以用暴力解去验证最优解的答案是否正确,这是性价比极高的调试手段。
5.4 一个关于语言选择的建议
如果你用C++刷题,需要注意k %= n在n=0时会触发除零异常,需要在取模前判断。Python则没有这个问题。用Go的话,切片翻转需要手动编写loop。语言的选择会影响边界处理的复杂度,建议至少熟练掌握两门语言,一种脚本语言用于快速验证,一种编译语言用于正式环境。
6. 刷题复盘:我自己总结的三个核心方法论
6.1 先从暴力解出发,优化方向就藏在不变量里
很多人刷题有个误区——上来就想最优解。但189题的最优解(三次翻转)和560题的最优解(哈希表)如果不经过暴力解法的推演,很难凭空想出来。
暴力的意义在于让你确立等式关系。189题的暴力解中,"每次右移一位"是基本操作;560题的暴力解中,"区间和等于前缀和之差"是基本规律。优化就是从这些基本操作和规律中寻找重复计算的冗余,再用取模、翻转、哈希表等手段去掉冗余。
6.2 面试讲题比刷题更重要
这两道题我都见过候选人写对了代码但解释不清的情况。如果你知道解法但讲不出所以然,面试官会判断你是背题而非理解。
我建议的标准讲题框架:
- 先说暴力解,主动指出它的性能问题;
- 再说你的优化思路,并强调为什么优化是对的;
- 说出边界条件并解释如何处理;
- 最后主动说出时间和空间复杂度。
在讲189题时,我会说"这里用到了翻转操作可逆的性质";在讲560题时,我会说"这里用到了前缀和差值的替换思想"。这些表述能让面试官知道你理解的是本质,而不仅仅是代码。
6.3 反复刷旧题比追求新题更有价值
如果让我给一个具体建议:这两道题,每道题至少要隔两周重刷一遍,直到你能不看题解写出最优解,并且能用一分钟讲解清楚解题思路。这个标准达到了,再进入下一轮题目的学习。
我最后一次刷这两题时发现,原来觉得不好理解的环状替换法,在熟练掌握了三次翻转和gcd概念之后突然变得清晰起来。这种"顿悟"不是偶然,而是因为知识点之间建立了连接——这正是刷题最重要的副产品。
