ARTICLE DETAIL

资讯详情

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

LeetCode轮转数组:数学推导、三种解法与Python原地修改陷阱

LeetCode轮转数组:数学推导、三种解法与Python原地修改陷阱 1. 题目精读与解题思路拆解1.1 题目到底在问什么“轮转数组”这道题在力扣Hot100里的编号是第15题不同批次可能略有差异但核心完全一致。题目描述非常简短给定一个整数数组nums将数组中的元素向右轮转k个位置。所谓轮转就是把数组末尾的k个元素搬到数组开头其余元素依次往后顺延。举个例子nums [1,2,3,4,5,6,7]k 3轮转后的结果就是[5,6,7,1,2,3,4]。我第一次看这道题时觉得这不就是切两刀再拼起来吗Python里用切片一行就能写完。但真正上手才发现题目有一个隐藏的高要求必须原地修改数组不能返回新数组更不能使用额外空间。这就把问题的难度从“写出来”提升到了“想清楚”。我当时刷题时的真实状态是这样的第一反应是nums[-k:] nums[:-k]切片拼接一运行发现结果不对因为nums nums[-k:] nums[:-k]这句话只是把变量名重新绑定到了新列表上原数组根本没变。后来踩过几次坑才把这道题彻底吃透。这篇文章就把我的完整思考过程、三类解法、以及调试的教训都整理出来希望帮刷这道题的朋友们少走几个弯路。1.2 轮转操作背后的数学本质在动手写代码前先把轮转的数学关系理清楚这对后面理解三次反转法至关重要。假设数组长度为n元素原来的下标是i向右轮转k位之后这个元素会移动到哪个位置直接看例子[1,2,3,4,5]k 2结果是[4,5,1,2,3]。下标0的元素1轮转后跑到下标2下标2的元素3轮转后跑到下标4下标3的元素4轮转后跑到下标0。得出的规律是新位置 (原位置 k) % n。为什么会有取模因为数组是环状的下标从n-1再加1就会回到0。这就像钟表上的时针拨动12点之后是1点而不是13点。取模运算% n就是把这个“绕圈”的逻辑精确地表达出来。反过来如果你想知道轮转后下标j的位置原来是谁公式是原位置 (j - k) % n或者(j n - k) % n。注意Python里负数取模的结果是正数所以(j - k) % n在Python里可以放心直接用。这三个公式是整个题目的灵魂。后面的三次反转法、额外数组法本质都是围绕这个映射关系在做文章。理解了它轮转数组就不是一道记忆题而是一道可以推导的数学题。2. 三种解法的完整实现与对比2.1 解法一三次反转法面试官最想看到的答案三次反转法的思路极其巧妙地利用了“反转”操作。它分三步走先反转整个数组。反转前k个元素。反转剩余n-k个元素。为什么这样做是对的用一个具体例子走一遍就清楚了。数组[1,2,3,4,5,6,7]k 3第一步反转整个数组得到[7,6,5,4,3,2,1]。注意观察此时数组被分成了两段前3个元素[7,6,5]对应原数组末尾的3个元素后4个元素[4,3,2,1]对应原数组开头的4个元素。也就是说一次整体反转已经把“末尾元素挪到开头”这个目标完成了只是两段内部的顺序是反的。第三步分别反转前k个元素和后n-k个元素。反转[7,6,5]得到[5,6,7]反转[4,3,2,1]得到[1,2,3,4]。拼接起来就是[5,6,7,1,2,3,4]恰好是正确答案。Python实现如下def rotate(nums, k): n len(nums) if n 0: return k % n if k 0: return # 先反转整个数组 nums.reverse() # 反转前k个 nums[:k] reversed(nums[:k]) # 反转剩余部分 nums[k:] reversed(nums[k:])这里有一个特别容易出错的细节nums[:k] reversed(nums[:k])能不能写成nums[:k].reverse()答案是不行。nums[:k]会创建一个新的切片列表对这个切片调用reverse()只会反转那个新列表原数组完全不受影响。切片赋值nums[:k] ...才是对原数组的原地修改方式因为Python的切片赋值会直接把右侧的可迭代对象展开填充到原列表的指定区间中。reversed()返回的是一个迭代器不用转成列表也可以直接放进切片赋值的右侧Python会自动迭代填入。但如果你想把反转结果保存成一个变量再赋值记得要套一层list()否则变量只是迭代器用一次就没了第二次遍历会发现是空的。另一个容易写错的版本是把第一步nums.reverse()和第2、3步都用类似方法处理逻辑一样但要注意边界反转前k个元素的范围下标是从0到k-1切片写作nums[:k]反转剩余部分的范围是从k到n-1切片写作nums[k:]。这两个区间的端点不能搞混尤其在写双指针版本的时候def rotate(nums, k): n len(nums) k % n if n 1 or k 0: return 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)这段代码里的区间都是闭区间[start, end]所以第3个翻转的起始下标是k终止下标是n-1中间不留缝隙也不重叠。双指针交换用Python的元组特性写起来非常简洁每次循环做一次交换直到两个指针相遇。2.2 解法二Python切片一行流工程开发的最爱如果你只是想在业务代码里完成数组轮转不追求面试表现Python切片绝对是最优雅的方式def rotate(nums, k): n len(nums) if n 0: return k % n nums[:] nums[-k:] nums[:-k]这一行代码做了几件事拆开来看nums[-k:]取出最后k个元素。nums[:-k]取出前面n-k个元素。把两个切片拼成一个新列表。nums[:] ...把拼接后的列表整体写回原数组。关键就在最后一步。如果写成nums nums[-k:] nums[:-k]那就错了因为这个操作只是让局部变量nums指向一个新的列表函数外部的原数组依旧保持不变调用方看到的还是轮转前的数据。刷题时函数里没有返回值调用完等于白做。用nums[:] 这种切片赋值Python会遍历右侧列表逐个覆盖原数组的每一项真正做到了原地修改。另外有个小坑当k 0时nums[-0:]等价于nums[0:]也就是整个数组而nums[:-0]等价于nums[:0]是空列表。拼接结果是整个数组本身所以功能上没问题。不过为了语义清晰和提前规避边界我还是习惯先做k % n和if k 0: return的防御性判断。切片做法的最大缺点是空间复杂度是O(n)因为拼接过程中创建了一个新列表。不过这并不意味着切片没有原地语义——你只是借用了一块临时空间来完成映射最终数据还是写回了原数组。对于不限制空间的场景这种写法可读性极高后来维护代码的人一眼就能看懂意图。2.3 解法三额外数组法最朴素的暴力美学既然题目说“原地修改”那额外数组法为什么还要拿出来讲因为它是所有解法中最符合直觉、最好验证正确性的方法也是理解轮转映射关系的起点。思路很简单创建一个和原数组等长的新数组遍历原数组把每个元素放到新数组的正确位置上最后再整体赋值回原数组。def rotate(nums, k): n len(nums) k % n new_nums [0] * n for i in range(n): new_nums[(i k) % n] nums[i] nums[:] new_nums这里new_nums[(i k) % n] nums[i]就是在实践前面讲的映射公式把下标i的元素搬到(ik) % n的位置。遍历完整个数组后new_nums里存放的就是轮转后的完整结果。这种解法其实暴露了一个思维转变很多人写这道题时会本能地想着“把元素一个个挪过去”但相邻元素的移动方向容易搞错。额外数组法绕开了这个问题直接计算出每个元素的最终落点相当于把复杂的“顺序移动”简化成了“位置映射”。虽然空间复杂度不达标但作为草稿纸上的推导方法很值得先写一遍。这里我再补充一种工作中很少用、但理解后能让你在同侪面前显得很专业的写法——环状替换法。它和额外数组法的思想一样是“位置映射”但把新数组省掉了直接在原地通过临时变量一步步替换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环状替换法的核心思想是从下标0开始把下标0的值交给下标(0k)%n再把那个位置原来的值交给下标(02k)%n一路传递下去形成一个环。当回到起点时一条环上的元素就全部归位了。然后起点向后挪一个位置继续处理下一条环。这个写法有一个容易翻车的点当n和k存在公约数时比如n6, k2一次循环只会覆盖下标0,2,4这三个位置剩下的1,3,5需要从start1开始再走一条环。所以外层必须有个count计数器来保证所有元素都被处理过不能只看“回到起点”就停。三种解法的时间空间复杂度对比如下解法时间复杂度空间复杂度是否原地适用场景额外数组法O(n)O(n)否入门理解、草稿推导环状替换法O(n)O(1)是进阶面试、极限优化三次反转法O(n)O(1)是面试标准答案Python切片法O(n)O(n)是借助临时列表工程快速实现3. 边界条件与Python语言特性的坑3.1 k 和 n 的关系取模操作的隐藏要求题目里k的范围是0 k 10^5但nums.length可能只有几个甚至一个元素。如果不做取模nums[-7:]这种切片语义会出错吗先说结论Python切片对越界索引非常宽容nums[-7:]不会报错会返回从开头到结尾的整个数组。看来似乎没毛病别急看看更隐蔽的情况。比如nums [1,2,3]k 5。正确轮转5位等价于轮转5 % 3 2位结果应该是[2,3,1]。但如果直接写nums[-5:] nums[:-5]nums[-5:]因为-5的绝对值超过数组长度Python会从下标0开始取结果[1,2,3]。nums[:-5]同理-5超出范围后等价于从0开始切结果[]。拼接出来的结果还是[1,2,3]看似“碰巧正确”但实际完全错误。更糟糕的是三次反转法如果不取模reverse(0, k-1)中的k-1会越界交换操作直接抛IndexError。所以无论用哪种解法第一步必须是k % n。这一步的本质是把“循环轮转”的数学问题归约到“单次轮转”上因为轮转n次数组会回到原样。这个动作就像是先把时钟拨了多少圈数去掉只留真正需要调整的分钟数。还有一个极端的边界是n 0。空数组不管怎么轮转都是空数组取模0 % 0会直接抛ZeroDivisionError所以需要单独拦截。n 1的情况倒是可以放心一个元素的数组怎么轮转都不变取模后k也变成0自然就安全返回了。3.2 为什么不能用nums nums[-k:] nums[:-k]这是我被问过无数次的问题也是这道题最容易踩的暗坑。Python里变量名和对象的关系可以用“标签”来理解。nums是这个列表对象的标签nums 新列表这个操作是把标签撕下来贴到新列表上原本的列表对象纹丝不动。而nums[:] 新列表的操作是把新列表的元素逐个复制到原列表的内存空间中标签没动但内容变了。刷题时力扣的判题系统会调用你的rotate函数然后检查传入的nums变量指向的那个列表对象的内容。如果函数里只是重新绑定了变量名函数结束后这个局部变量就销毁了原列表没有任何变化判题就失败。验证方法也简单写个小的测试脚本nums [1, 2, 3, 4, 5] original nums # 错误做法 nums nums[2:] nums[:2] print(original) # 输出 [1,2,3,4,5]原数组没变 # 正确做法 nums [1, 2, 3, 4, 5] original nums nums[:] nums[2:] nums[:2] print(original) # 输出 [4,5,1,2,3]原数组被改动了看到差别了吗第一种写法里original还是指向旧数组打印出来没有任何变化。第二种写法里original指向的列表内容已经被就地覆盖了。这道题我用一句话总结切片拼接是值传递切片赋值是引用内修改刷题必须用后者。3.3 负数取模对Python而言是福星很多从C或Java转过来的朋友会被Python的负数取模搞得一头雾水-1 % 5在Python里结果是4而不是-1。恰恰是这个“反直觉”的语义让环状替换法和映射公式在Python里写起来特别顺畅。回想额外数组法的代码new_nums[(i k) % n] nums[i]由于i k最多就是n-1 n-1肯定为正取模没什么说的。但如果你想写反向映射比如根据轮转后的下标j找出原下标需要计算(j - k) % n这时候j - k可能是负数。在Java里(-2) % 5的结果是-2你还需要手动 n再取模才能得到正下标。在Python里(-2) % 5直接就是3根本不用费劲。这就是为什么在Python里写轮转映射类题目代码往往比Java短一截。不过要注意这个语义只对取模运算成立不要把它套到数组索引上。Python的负索引nums[-1]表示倒数第一个元素nums[-k:]表示倒数k个元素这两者是Python的“负索引”语法和%的数学语义不是一回事但配合起来用往往效果很好。4. 常见报错、性能对比与面试进阶4.1 容易让人一夜白头的五个报错与坑我曾经把这道题在本地跑得飞快提交到力扣却连示例都过不了排查了很久才发现问题不在算法本身。把常见的报错和坑整理成一个速查表帮你提前避雷症状根本原因解决方案运行通过但结果完全没变用了nums ...而不是nums[:] ...所有赋值改成切片赋值IndexError: list assignment index out of range三次反转法没有对 k 取模在开头加k % nZeroDivisionError: integer division or modulo by zero数组为空时k % n除零先判断if n 0: return奇数组合时反转区间出问题双指针的end写成n-k而不是n-1确保三个反转区间恰好完整覆盖数组内存超限额外数组法在大数组上空间O(n)换三次反转法或环状替换法其中第三个反转区间的问题最隐蔽。有些朋友把三次反转误写成reverse(0, n-k-1)和reverse(n-k, n-1)这是两种思路的混搭。我习惯记住一个原则先整体反转再按k%n分成前k和后n-k两段分别局部反转三个区间必须严丝合缝。如果你想用“前n-k个和后k个分开处理”的另一套流程就要先处理后k个再处理前n-k个顺序别搞混。两种流程都正确但别交叉使用。4.2 用随机测试验证你的实现我写算法题有个习惯写完一个解法不急着提交先用随机测试跑一遍。这道题的验证逻辑特别简单对于随机生成的数组和随机k用Python标准库的方式计算期望结果再和你的函数结果比对。import random def brute_rotate(nums, k): 用Python标准库的切片操作作为基准答案 n len(nums) if n 0: return nums k % n return nums[-k:] nums[:-k] for _ in range(10000): n random.randint(0, 20) k random.randint(0, 100) nums [random.randint(-100, 100) for _ in range(n)] expected brute_rotate(nums.copy(), k) test_nums nums.copy() rotate(test_nums, k) # 你的实现 if test_nums ! expected: print(f出错: nums{nums}, k{k}, 结果{test_nums}, 期望{expected}) break else: print(全部用例通过)跑1万组随机用例基本能覆盖空数组、单元素、k大于n、k等于n等各种刁钻情况。这个习惯帮我抓出过不少“本地测试通过但边界崩了”的问题强烈推荐你养成。4.3 从这道题延伸出的面试考点轮转数组本身是道简单题但围绕它可以展开很多追问面试官经常会在这道题后面加码变体一如果要求向左轮转k位呢其实向右轮转k位等价于向左轮转n-k位套用同一套代码把k换成(n - k) % n即可。变体二如果在轮转后的数组上做二分查找呢这是力扣经典题“搜索旋转排序数组”关键思路是利用数组被分成两段递增序列的性质先判断目标在哪一段再二分。变体三如果输入不是数组而是链表要求轮转那就要用到链表找倒数第k个节点的技巧先快慢指针找到断开位置再接起来形成环最后在正确位置断开。变体四如果数组太大放不进内存怎么办这就涉及到外部排序和分段处理的思想属于海量数据处理的范畴了。每次遇到类似的题目我都会先回到底层的映射公式新位置 (原位置 k) % n从这个公式出发推导解法而不是死记硬背代码。公式在手不管题目怎么变都只是换了一层皮。5. 踩坑实录与优化心得5.1 一次内存超限的排查过程有一段时间我在一个内存限制很紧的在线平台上刷题提交轮转数组的Python切片解法时一直报内存超限。当时我挺困惑按说O(n)的空间不应该超啊后来仔细看了判题环境发现它统计的是运行过程中的峰值内存。nums[-k:] nums[:-k]这行代码先创建了nums[-k:]这个列表再创建了nums[:-k]这个列表最后拼接时又创建了一个完整的新列表峰值时总共占了接近2.5n的额外空间。如果你想控制内存就得放弃切片拼接改用三次反转法或者环状替换法把额外空间压缩到O(1)。自那以后我就记住了切片虽好但在内存受限的场景下不能无脑用。面试时也要主动提一句“切片法的时间复杂度是O(n)但会借助临时列表空间复杂度O(n)”展示你清楚每个操作的底层开销。5.2 关于reversed和reverse的精准区分新手最容易犯的一个错误是搞混reversed()和.reverse()。前者是Python内置函数返回一个反向迭代器并不修改原对象后者是列表对象的方法直接原地反转并返回None。在三次反转法里nums[:k] reversed(nums[:k])这里的reversed(nums[:k])是安全的因为nums[:k]已经是一份新列表reversed不会修改它迭代器被切片赋值消费掉以后就完成了任务。而如果你写nums[:k] nums[:k].reverse()问题就大了reverse()返回None切片的右侧变成None赋值时Python会尝试迭代None并抛出TypeError。还有种写法是nums[:k] nums[:k][::-1]nums[:k][::-1]会先复制一份切片再通过步长-1反转得到新列表然后赋值。这样写也对只是多加了一次列表复制空间上不如reversed省。工程上我倾向于用双指针的reverse函数完全零额外空间逻辑也清晰。5.3 力扣判定中“原地修改”的隐藏含义最后说一个很多教程没讲透的细节。力扣题目里写着“原地修改”但如果你用切片法本质上还是创建了新列表再写回那到底算不算“原地”从空间复杂度的角度看切片法创建了新列表额外空间是O(n)并不满足题目“使用O(1)额外空间”的进阶要求。但为什么它又能通过判题因为判题系统只检查最终nums指向的列表对象内容是否等于预期结果它不追踪你在过程中用过多少临时空间。除非个别题目标注了“空间复杂度O(1)”的强制要求否则切片法能通过只是不优雅。如果你要追求理论的严谨面试时优先展示三次反转法然后再补充说“如果允许额外空间Python还可以用切片一行实现”。这样既展示了你对算法复杂度的掌控也体现了语言的灵活性一举两得。以我反复调试这道题的经验来说真正卡住多数人的不是算法本身而是对Python可变对象赋值的理解不到位。把nums[:] 这个操作刻在脑子里以后刷到任何需要原地修改数组的题目——比如移动零、删除排序数组中的重复项——你都会比别人少踩一个巨大的坑。轮转数组这道题是我认为力扣Hot100里性价比极高的一题代码量不大但牵涉的考点横跨数学映射、数组操作、语言特性三个层面值得多花半小时彻底吃透。
返回列表