hot100 下一个排列(31)

📅 2026/7/30 10:00:10
hot100 下一个排列(31)
本题采用双指针逆序扫描、局部元素交换与后缀区间翻转算法解决字典序下一个排列的计算问题。其核心本质是利用数组后缀的单调性拓扑特征寻找“最靠右”的较小元素与“最靠右”的较大元素进行交换并通过翻转后缀使其恢复升序从而实现字典序增量的最小化。当前提供的源码实现了在时间复杂度 O(N) 和额外空间复杂度 O(1)完全原地修改条件下的全局最优解答精准完成了排列字典序的平滑递进。一、 问题本质与字典序数学拓扑模型1.1 字典序Lexicographical Order的物理语义对于长度为 N 的整数数组nums其所有可能的排列在数学上可以按照字典序建立全序关系。字典序的比较规则与高位优先的十进制数值比较完全一致对于两个长度同为 N 的排列 A 和排列 B从高位索引 0向低位索引 N - 1逐位比对。若存在第一个不相等的索引 k且A[k] B[k]则称排列 A 的字典序小于排列 B。例如对于由数字{1, 2, 3}构成的全排列其按字典序升序排列的完整序列为[1, 2, 3] [1, 3, 2] [2, 1, 3] [2, 3, 1] [3, 1, 2] [3, 2, 1]题目要求的“下一个排列”即寻找在全序集合中紧跟在当前排列后面的那一个排列。1.2 “最小变动”与“最小增量”的贪心求解原则为了让新排列尽可能紧凑地排在当前排列的后一位即增量最小必须遵循以下三个核心贪心原则尽可能靠右改变高位数值变更越靠左的位数引发的字典序增量越巨大。因此必须从右向左从低位到高位寻找第一个可以增大的位置。用仅比原数值稍大一点的元素替换在右侧后缀中选择比被替换位置数值“大但大得最少”的元素进行交换以控制该位置的变动增量。使交换后的后缀恢复最小值完成高位替换后右侧后缀必须重置为字典序最小的排列即严格升序排列确保后续低位不会引入多余的增量。二、 算法设计与三步贪心推导为了在 O(N) 时间和 O(1) 空间内实现上述原则算法将过程拆解为三个精准的物理步骤2.1 步骤 1逆序寻找第一个降序节点pivot从数组末尾索引N - 2开始向前逆序扫描寻找第一个满足nums[pivot] nums[pivot 1]的位置pivot。扫描方向: ------------------ (由右向左) 数组形态: [ ... , nums[pivot], nums[pivot1], ... , nums[N-1] ] 满足条件: nums[pivot] nums[pivot1] 后半特征: [nums[pivot1] ... nums[N-1]] 必为严格非递增单调递减区间数学含义[pivot 1, N - 1]区间内的元素已经是单调递减的代表该后缀子排列已经达到了其字典序的最大可能值无法再通过重排该后缀来变得更大。物理推论要想获得更大的排列必须改变nums[pivot]处的数值。如果扫描到最左端pivot 0依然没有找到这样的节点说明整个数组已经是严格非递增排列如[3, 2, 1]达到了全局最大字典序。此时直接跳转至步骤 3 翻转整个数组即可。2.2 步骤 2逆序寻找第一个大于nums[pivot]的节点next当pivot 0时再次从数组末尾索引N - 1开始向前逆序扫描[pivot 1, N - 1]区间寻找第一个满足nums[next] nums[pivot]的节点next。为何逆序扫描能找到“刚好大于”的最小值因为[pivot 1, N - 1]区间是单调递减的从右向左扫描遇到的第一个大于nums[pivot]的元素必然是该区间内大于nums[pivot]的所有元素中的最小值。执行交换调用swap(nums, pivot, next)。交换后nums[pivot]的数值增幅被控制到了极限最小值且交换后的[pivot 1, N - 1]区间依然保持单调递减性。2.3 步骤 3反转后缀区间[pivot 1, N - 1]交换完成后[pivot 1, N - 1]区间目前依然处于降序状态字典序极大。为了让整体排列紧随其后必须将该后缀重置为升序状态字典序极小。由于该后缀已知是单调递减的将其恢复升序无需使用复杂的排序算法O(N log N)直接使用双指针进行原地反转O(N)即可完成。三、 算法演进脉络与多重解法对比解法名称时间复杂度空间复杂度核心原理物理瓶颈 / 缺陷回溯全排列检索 (Brute Force)O(N! * N)O(N)生成全排列排序后定位当前排列的后继时间复杂度呈阶乘级爆破完全无法应对 N 15 的输入单向扫描 额外排序 (Sorting Suffix)O(N log N)O(1)寻找pivot与next交换后对后缀调用Arrays.sort额外引入了排序开销未利用后缀已严格递减的拓扑特征双指针逆序扫描与翻转 (当前解法)O(N)O(1)利用后缀递减拓扑逆序寻找pivot与next交换后原地翻转后缀达到理论时空复杂度下界逻辑严密无任何冗余计算四、 算法执行状态机步进推演与图解4.1 复杂示例全量状态演进追踪输入数组nums [1, 2, 3, 8, 5, 7, 6, 4](长度 N 8)阶段一逆序扫描定位pivot比对nums[6](6) 与nums[7](4):6 4继续。比对nums[5](7) 与nums[6](6):7 6继续。比对nums[4](5) 与nums[5](7):5 7锁定pivot 4对应数值为5。此时后缀区间为索引[5, 7]数值为[7, 6, 4]严格递减。阶段二逆序扫描定位next并交换从N - 1 7开始对比nums[next]与nums[pivot](5)nums[7]为 44 5继续。nums[6]为 66 5锁定next 6对应数值为6。执行swap(nums, 4, 6)。数组状态更新为[1, 2, 3, 8, 6, 7, 5, 4]。注意此时后缀区间[5, 7]内的数值为[7, 5, 4]依然保持严格递减阶段三反转后缀区间[pivot 1, N - 1]需要反转的区间为索引[5, 7]。反转[7, 5, 4]变为[4, 5, 7]。最终数组状态[1, 2, 3, 8, 6, 4, 5, 7]。步骤追踪状态表步骤指针变量状态当前数组状态动作说明初始-[1, 2, 3, 8, 5, 7, 6, 4]初始输入步骤 1pivot 4(数值 5)[1, 2, 3, 8, 5, 7, 6, 4]从右向左寻找首个升序对(5, 7)步骤 2next 6(数值 6)[1, 2, 3, 8, 5, 7, 6, 4]在后缀[7, 6, 4]中逆序寻找首个 5 的数步骤 3swap(4, 6)[1, 2, 3, 8, 6, 7, 5, 4]交换后后缀[7, 5, 4]维持递减步骤 4reverse(5, 7)[1, 2, 3, 8, 6, 4, 5, 7]将后缀翻转为递增[4, 5, 7]获得最终解4.2 边界示例全降序数组反转输入数组nums [3, 2, 1](长度 N 3)步骤指针变量状态当前数组状态动作说明步骤 1pivot -1[3, 2, 1]逆序扫描未发现nums[pivot] nums[pivot 1]循环结束步骤 2if (pivot 0)[3, 2, 1]条件不成立跳过寻找next与交换步骤步骤 3reverse(0, 2)[1, 2, 3]全局反转整个数组重置为最小字典序五、 Java 源码实现与逐行注释class Solution { /** * 寻找数组的下一个字典序排列要求原地修改只使用 O(1) 额外空间 * * param nums 待修改的整数数组 */ public void nextPermutation(int[] nums) { // 1. 初始化 pivot 指针指向倒数第二个元素 int pivot nums.length - 2; // 2. 从右向左逆序扫描寻找第一个满足 nums[pivot] nums[pivot 1] 的位置 // 注意循环条件为 nums[pivot] nums[pivot 1]涵盖相等元素以保证稳定性 while (pivot 0 nums[pivot] nums[pivot 1]) { pivot--; } // 3. 如果找到了有效的 pivot说明当前数组不是完全单调递减的 if (pivot 0) { // 从数组最右端开始向前寻找第一个严格大于 nums[pivot] 的元素 int next nums.length - 1; while (nums[next] nums[pivot]) { next--; } // 交换 pivot 与 next 位置的数值 swap(nums, next, pivot); } // 4. 将 pivot 之后的后缀子数组进行原地反转 // 若 pivot 0则相当于反转整个数组 [0, nums.length - 1] reverse(nums, pivot 1, nums.length - 1); } /** * 辅助函数原地交换数组中两个索引位置的元素 */ private void swap(int[] nums, int i, int j) { int temp nums[i]; nums[i] nums[j]; nums[j] temp; } /** * 辅助函数双指针原地反转指定闭区间 [i, j] 内的元素 */ private void reverse(int[] nums, int i, int j) { while (i j) { swap(nums, i, j); i; j--; } } }六、 复杂度分析与硬件底层优化视角6.1 时间复杂度O(N)寻找pivot最多向前遍历 N - 1 次时间复杂度为 O(N)。寻找next最多向前遍历 N - 1 次时间复杂度为 O(N)。交换swap常数次基本赋值操作时间复杂度为 O(1)。反转reverse双指针相向移动最多交换 N / 2 次时间复杂度为 O(N)。总体时间复杂度最坏与最好情况下各步骤均为线性扫描或局部反转无嵌套循环总时间复杂度严格收敛于O(N)。6.2 空间复杂度O(1)算法全过程仅申请了pivot、next、temp、i、j等基础局部整型变量。数组的修改完全在原内存物理块内完成无任何新数组或递归栈空间的申请额外空间复杂度为O(1)。6.3 硬件级 CPU 缓存与分支预测优化分析CPU L1 Data Cache 连续预取 FRIENDLY算法中的扫描与反转逻辑均为严格的连续内存顺序访问向前或向后连续步进。CPU 的硬件预取单元Hardware Prefetcher可以 100% 准确预测内存访问轨迹将数组数据批量装载至 CPU Cache Line通常为 64 字节缓存命中率接近 100%。分支预测Branch Prediction优化while (pivot 0 nums[pivot] nums[pivot 1])循环中由于后缀数组具备高度的局部单调性分支判断结果在绝大多数迭代中保持一致极大地减少了 CPU 流水线停顿Pipeline Stall。七、 工业级应用与拓展变体7.1 C STLstd::next_permutation的设计哲学在 C 标准模板库中std::next_permutation采用了完全相同的双指针算法体系其源码架构原型如下templateclass BidiIt bool next_permutation(BidiIt first, BidiIt last) { if (first last) return false; BidiIt i last; if (first --i) return false; while (true) { BidiIt i1, i2; i1 i; if (*--i *i1) { i2 last; while (!(*i *--i2)); std::iter_swap(i, i2); std::reverse(i1, last); return true; } if (i first) { std::reverse(first, last); return false; } } }返回值设计若成功生成更大的下一个排列返回true若已经达到字典序最大值并重置回最小值则返回false。这一设计允许开发者使用do-while循环轻松遍历全排列。7.2 经典拓展题型LeetCode 556. 下一个更大元素 III将一个 32 位整数转换为字符数组直接应用下一个排列算法最后将结果转换回整数并检查 32 位有符号整数溢出。LeetCode 47. 全排列 II (含重复元素)可先将数组升序排序然后配合循环调用“下一个排列”逻辑能够无重复地按字典序生成包含重复元素的所有全排列。八、 边界避坑矩阵边界场景潜在 Bug 点本源码的防御机制数组长度小于等于 1索引越界异常pivot nums.length - 2初始值为 -1无法进入第一个while跳过if (pivot 0)直接执行reverse(nums, 0, 0)无操作直接退出包含重复元素如 [1, 5, 1]死循环或非严格递增错误寻找pivot时使用nums[pivot] nums[pivot 1]跳过相等元素寻找next时使用nums[next] nums[pivot]确保找到严格大于nums[pivot]的元素全局最大排列如 [3, 2, 1]空指针或边界溢出pivot递减至 -1 退出循环if (pivot 0)分支自动跳过直接调用reverse(nums, 0, 2)将整个数组翻转为[1, 2, 3]