ARTICLE DETAIL

资讯详情

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

三次旋转法:用O(1)空间原地解决数组旋转与工程性能优化

三次旋转法:用O(1)空间原地解决数组旋转与工程性能优化 当年我在处理一个消息队列的消费进度时写了一段把已读消息顶到数组末尾的逻辑随手用切片拼接两行代码搞定结果线上服务在千万级数据量下直接触发了好几次Full GC。后来我把这段逻辑换成三次旋转法用原地反转实现了O(1)的额外空间才意识到这个看似简单的算法技巧背后藏着的思考量比想象中大得多。三次旋转法也叫三次反转法是旋转数组问题的经典解法。所谓旋转数组就是把数组里的元素统一往左或往右移动k个位置超过边界的元素折返到另一端。比如数组[1,2,3,4,5,6,7]向右旋转3位得到[5,6,7,1,2,3,4]。这个操作在轮播图切换、循环队列扩容、数据流的滑动窗口、甚至某些加密算法的位移运算里都会频繁出现。这篇文章围绕三次旋转法展开覆盖了它的核心原理、边界处理、和暴力法/额外数组法的对比、以及它在矩阵旋转、链表位移、字符串判断等场景里的推广用法。不管是准备技术面试的人还是在工程里需要手写高效数组操作的开发者都能从中拿到可以直接用的结论和踩坑经验。1. 从最初的无脑实现到理解数组旋转的本质1.1 暴力位移直觉上最简单性能上最糟糕先说最直觉的方案把数组往右旋转k位每次向右移动一格需要把最后一个元素临时存起来然后把前面所有元素逐个往后搬最后把临时元素放到开头。重复k次就能得到最终结果。def rotate_violent(nums, k): n len(nums) if n 0 or k 0: return for _ in range(k): temp nums[-1] for i in range(n - 1, 0, -1): nums[i] nums[i - 1] nums[0] temp这个实现的时间复杂度是O(n*k)。数据量小的时候没什么感觉但n和k一旦上到十万、百万级别立刻变成灾难。我见过有人拿这个解法去处理日志回放100万条数据旋转50万次跑了整整两个多小时。另一个直觉方案是申请一块额外数组把旋转后的结果算好再复制回来def rotate_extra(nums, k): n len(nums) k % n result [0] * n for i in range(n): result[(i k) % n] nums[i] for i in range(n): nums[i] result[i]这个方案时间上能做到O(n)但空间复杂度是O(n)。在内存敏感的嵌入式环境或者并发密集的服务里一次性申请n个元素的额外空间代价相当可观。1.2 旋转的本质数组元素在位置上的一次循环移位我在琢磨这个问题的时候把旋转看成一种位置映射。向右移动k位等价于把原来下标为i的元素放到下标为(i k) % n的位置。反过来向左移动k位等价于把元素放到(i - k n) % n的位置。这里有个关键直觉向右旋转k位实际上就是把数组的后k个元素搬到最前面剩下的元素整体往后挪。基于这个观察三次旋转法的思路就浮出水面了——我根本不需要逐元素地搬只需要利用反转这种操作来交换两个子数组的位置。1.3 为什么反转操作能用来交换两个子段再看一眼这个规律。数组[1,2,3,4,5,6,7]向右旋转3位结果是[5,6,7][1,2,3,4]。我再怎么用反转操作才能把这段数组变魔术一样地换成后半段在前、前半段在后小时候玩翻牌的人都知道先把一整摞牌反过来再把其中一摞分出来反过来牌的顺序就会发生一些奇妙的交换。三次旋转法的核心就是利用两次反转来保住局部顺序利用一次整体反转来交换两段的前后位置。具体到数组上右边三段就是第一段[7,6,5,4,3,2,1]整体反转第二段[5,6,7,4,3,2,1]反转前k个元素即原数组的后k个第三段[5,6,7,1,2,3,4]反转剩下的元素三段操作用三次反转所以叫三次旋转法。这个操作不需要额外的数组空间时间是O(n)空间是O(1)在工程上非常实用。2. 深入三次旋转法的原理细节别只背步骤要懂为什么2.1 一次整体反转 两次局部反转的数学逻辑站在数学角度看这个解法其实是一件很优雅的事情。假设数组长度为n向右旋转k位。定义三个区域A 前n-k个元素原数组开头那些B 后k个元素原数组末尾那些目标是让组合从AB变成BA。反转操作有个基本性质对一个序列做两次完全相同的反转会恢复原样而两个序列放在一起如果整体反转每个序列内部的顺序都倒过来并且两个序列的前后位置也会互换。于是操作序列就变得非常清晰整体反转AB得到reverse(B)reverse(A)。反转前k个元素reverse(B)恢复出B。反转后n-k个元素reverse(A)恢复出A。整个数组最终变成BAPerfect。2.2 用例子完全走一遍消除步骤顺序的困惑上面的逻辑有一个容易混淆的点整体反转之后正好把原数组末尾的k个元素放到了最前面。所以我第一步整体反转第二步反转的是new数组的前k个元素不是原数组的前k个。拿[1,2,3,4,5,6,7]向右旋转3位走一遍初始[1,2,3,4,5,6,7]整体反转[7,6,5,4,3,2,1]反转前3个元素[5,6,7,4,3,2,1]反转后4个元素[5,6,7,1,2,3,4]结果和直接定义一致。如果向左旋转k位逻辑对称把数组前k个元素搬到末尾去那么定义A 前k个元素B 后n-k个元素目标从AB变成BA。整体反转AB后再分别反转前n-k个和后k个元素同样能得到结果。注意这里的反转顺序不同。2.3 多段交换的推广任意两个相邻子段都可以这样交换这个思路不局限于旋转。假设数组分成三段X Y Z我要是想把Y和Z换位置变成X Z Y可以用类似的局部反转再整体反转组合。最常见的推广是对于相邻的两个子段A和B通过三次反转把AB变成BA。这是三次旋转法真正的底层能力。def reverse_range(nums, left, right): while left right: nums[left], nums[right] nums[right], nums[left] left 1 right - 1 def rotate_right(nums, k): n len(nums) if n 0: return k % n if k 0: return reverse_range(nums, 0, n - 1) reverse_range(nums, 0, k - 1) reverse_range(nums, k, n - 1)2.4 用生活化方式理解扑克牌切牌表演我把这个算法讲给朋友听的时候用了这个类比。你有一摞牌想把它切成两摞并对调位置最简单的手法是把整摞牌底朝天翻转然后分别把两部分各自翻回来。这个操作在魔术里叫翻转切牌法。底层原理和数组反转一模一样。理解了这点即使长时间不写代码推导三次旋转法也只花30秒。3. 编码前必须处理好的四个细节否则容易被边界条件坑哭3.1 k大于数组长度时的取模策略这是新手最容易踩的坑。k经常不是链表长度n的友好数字比如数组长度是7k是10直接执行三次反转就会越界因为k - 1可能超出数组下标。处理方式是取模k % n。因为向右旋转n位等价于不旋转旋转k t*n位和旋转k位的效果完全相同。取模之后k总是落在[0, n-1]区间。3.2 k为0、数组为空或长度为1的边界k为0不需要任何操作。n为0或1此时k % n会出问题0作为分母非法必须先行判断直接返回。数组长度为1无论k多大旋转后结果都一样直接返回。这些边界条件一开始就要写好不要等着测试用例打脸。我平时习惯把边界检查放在函数最前面避免后续逻辑里出现非预期状态。3.3 左旋右旋的方向统一问题左旋和右旋是两个不同的操作向左旋转k位等价于向右旋转n - k位取模之后。很多人在这里绕来绕去我建议你的代码库只保留一个方向的实现然后根据不同方向调用时做一次转换右旋k位rotate_right(nums, k)左旋k位rotate_right(nums, n - k % n)这样只需要维护一套三次反转逻辑不容易写岔。需要注意k % n之后n - k恰好等于向左旋转的量如果k为0则n - k会等于n取模后变为0依然安全。3.4 原地反转函数的下标边界写得最频繁的bug就是reverse_range的边界。左闭右闭还是左闭右开必须统一。我用的是左闭右闭区间[left, right]传参数时传入首尾下标。换用切片或者迭代器的话你还需要额外确认切片的边界行为。function reverseRange(nums, left, right) { while (left right) { [nums[left], nums[right]] [nums[right], nums[left]]; left; right--; } } function rotateRight(nums, k) { const n nums.length; if (n 0) return; k % n; if (k 0) return; reverseRange(nums, 0, n - 1); reverseRange(nums, 0, k - 1); reverseRange(nums, k, n - 1); }3.5 基于额外数组的实现虽然简单但要警惕空间损耗如果你的语言自带方便的切片语法很多人可能会走捷径def rotate_slice(nums, k): k % len(nums) nums[:] nums[-k:] nums[:-k]这种方式在Python里简洁得让人心动但要知道nums[-k:] nums[:-k]创建了一个全新的列表然后再把新列表元素逐一拷贝回原列表。整个过程额外空间是O(n)不是O(1)。我在开头提到的Full GC就是这么来的。小数据量无所谓但在内存受限或者数组特别大的场景里三次反转法是更优的选择操作全部在原数组原地进行。4. 三次旋转法和其它旋转方案的对比What、When、Why4.1 暴力逐个移动时间复杂度O(n*k)空间复杂度O(1)优点思路简单三分钟写完缺点性能爆炸只适合n和k都极小的玩具场景4.2 额外数组法时间复杂度O(n)空间复杂度O(n)优点实现直观、不容易写错(i k) % n一步到位缺点额外空间开销会导致GC压力在嵌入式环境里可能内存不够。4.3 三次反转法时间复杂度O(n)空间复杂度O(1)优点原地操作、无额外内存、代码逻辑固定缺点边界条件多理解成本高但一旦封装好就非常可靠4.4 GCD分组跳跃法另一种原地方案这个方法也很经典把数组看成若干个独立的循环链每个元素跳跃到(i k) % n的位置最后每个环都完成旋转。一次遍历就能完成所有元素的移动但代码比三次反转法复杂得多需要计算环的数量gcd(n, k)。def rotate_gcd(nums, k): n len(nums) k % n if k 0: return count 0 start 0 while count n: current start prev nums[start] while True: next_idx (current k) % n temp nums[next_idx] nums[next_idx] prev prev temp current next_idx count 1 if start current: break start 1GCD法的时间也是O(n)空间O(1)但它对初学者很不友好追踪环的起点和位置映射很容易写错。从工程可维护性来看三次反转法的优势更明显。4.5 选型建议用表格总结一下方法时间空间代码复杂度适用场景暴力位移O(n*k)O(1)低几乎不适用额外数组O(n)O(n)低数据量小、内存充足三次反转O(n)O(1)中内存敏感、大数据量GCD法O(n)O(1)高追求极致的原地移动我在实际项目里的选择口径是面试时优先说出三次反转法的推导过程因为他证明了你懂得原地优化工程实现时三次反转法封装成工具函数后一线程序员都能看懂维护成本低。GCD法除非你特别享受算法的美感否则别在生产代码里自找麻烦。5. 三次旋转法的变体应用从数组跳到矩阵和字符串5.1 二维矩阵的原地旋转90度二维矩阵旋转90度可以拆解为转置逐行反转的组合和三次旋转法令人惊讶地相似。顺时针旋转90度 先转置再逐行反转逆时针旋转90度 先转置再逐列反转或者逐行反转后再转置转置和反转组合的写法本质上就是三维空间坐标变换的某种数组映射。拿一张3x3矩阵走一遍效果好得让人想起整摞扑克翻过来再局部翻回去的操作。5.2 链表的旋转和三次反转法的关系链表旋转把链表向右移动k个位置通常不用反转因为链表的指针操作比数组灵活得多。常见做法是先遍历求长度n然后找到新的头节点位置即第n - k % n个节点断开重新拼接。无论如何它的核心思想依然是把后k个节点移到最前面和数组旋转异曲同工。如果面试官非要问链表是否能用三次反转法可以如法炮制反转链表三次效果同样成立。只是链表的断开和连接操作更繁琐一般不需要这么做。5.3 判断两个字符串是否互为旋转字符串一道非常经典的面试题给定两个字符串s1和s2判断s2是否由s1旋转若干位得到。最优雅的解法根本不用模拟旋转而是利用一个关键性质如果s2是s1的旋转串那么s2一定出现在s1s1里。比如s1abcde旋转2位得到cdeab直接看s1 s1 abcdeabcde里面确实包含cdeab。def is_rotation(s1, s2): if len(s1) ! len(s2): return False return s2 in (s1 s1)这个方法只需要做一次子串查找时间复杂度O(n)而且完全绕开了手动模拟三次反转。它从另一个角度说明了旋转的数学本质循环移位只是字符串在自身副本上的线性匹配。5.4 用三次旋转法处理字符串左旋字符串左旋的题经常出现比如把abcXYZdef左旋3位得到XYZdefabc。如果语言里不允许用额外空间落地成三次反转也完全可以每次反转字符串的一个区间再把字符数组转回字符串。Java和C的reverse操作都支持区间反转实现起来很顺手。6. 工程实战中的调用姿势、性能观察和踩坑记录6.1 在Python里别被切片迷惑Python里有内置切片反转看起来特别优雅nums[:] nums[::-1] nums[:k] nums[:k][::-1] nums[k:] nums[k:][::-1]但注意nums[::-1]会生成一个新的list对象然后再整体赋值回去。这本质上用了O(n)的额外空间和之前说的Full GC隐患一脉相承。如果你真的需要原地反转用前面写的reverse_range配合双指针交换避免生成中间对象。6.2 在C里可以调用std::reverseC的std::reverse就是封装好的原地反转三次反转的写法非常干净class Solution { public: void rotate(vectorint nums, int k) { int n nums.size(); if (n 0) return; k % n; if (k 0) return; reverse(nums.begin(), nums.end()); reverse(nums.begin(), nums.begin() k); reverse(nums.begin() k, nums.end()); } };标准库的reverse对迭代器的左闭右开区间做了充分优化底层可能使用编译器内置的向量化指令实测比手写while循环更快。生产环境能用库就用库前提是你知道这个方法背后做了什么。6.3 大数据量下的性能观察我在一次性能测试里对比了50万条日志数据的三种旋转方式额外数组法耗时大约12毫秒内存多占4MB切片拼接法耗时大约15毫秒内存峰值多占8MB以上三次反转法耗时大约8毫秒内存几乎不涨三次反转法在cache友好性上也有优势因为它对数组的顺序访问和逆序访问都是连续内存块和现代CPU预取机制配合得不错。虽然理论上所有O(n)算法时间量级相同但实际常数项差异依然明显。6.4 我踩过的真实坑忘记对k取模有一回我处理一个定时任务天真的以为k永远小于n结果某次配置手动填了120数组长度才50。三次反转的第二步直接对数组越界程序崩在半夜的告警里。从此以后我写旋转函数第一行永远是if n 0 return; k % n; if k 0 return;当成肌肉记忆。6.5 测试用例设计自己在封装工具函数时至少要覆盖这些用例空数组只有一个元素的数组k等于0k等于nk大于nk是n的整数倍长度为偶数和奇数的数组左旋和右旋各验证一遍把这些用例写成一个参数化测试能覆盖90%以上的边界问题。6.6 一个额外的小技巧反转函数复用如果你的项目里有两个方向都需要可以统一为右旋然后映射。例如rotate_left(nums, k)直接调用rotate_right(nums, n - (k % n))。这样你只需要维护一个核心的反转逻辑团队协作时其他人也更容易review。注意如果k取模后等于0n - (k % n)可能等于n再取模一次就变成0了。所以内部实现里建议先取模再判断。7. 我自己在实际项目里的一些总结做了这么多年算法相关的开发我的感受是三次旋转法的价值不在于它多高深而在于它呈现了一种典型的算法思维转换——把看起来需要逐元素操作的问题转化为几个整体操作。这个思路后来我在处理数组分块交换、字符串的区间重排、甚至某些图形变换时反复用到。碰到数组旋转的需求先别急着写循环。想清楚k和n的关系想清楚方向然后直接套三次反转法的模板。模板不长但边界条件的处理决定成败。写完之后记得跑一遍边界测试尤其是k大于n的情况。如果说还有什么值得反复咀嚼的那就是反转后反转的组合能力。任何一个局部顺序被打乱的问题只要通过两次反转就能恢复原样任何两个子段的前后交换一次整体反转加两次局部反转就能完成。想明白了这一层三次旋转法就不只是一个解题套路而是一种可以自由组合的思维积木。
返回列表