问题简介
题目描述
整数数组的一个 排列 就是将其所有成员以序列或线性顺序排列。
- 例如,
arr = [1,2,3],以下这些都可以视作arr的排列:[1,2,3]、[1,3,2]、[3,1,2]、[2,3,1]。
下一个排列 是指字典序中下一个更大的排列。更正式地,如果数组的所有排列根据其字典顺序从小到大排列在一个容器中,那么数组的 下一个排列 就是在这个有序容器中排在它后面的那个排列。如果不存在下一个更大的排列,那么必须将数组重排成字典序最小的排列(即升序排列)。
你必须设计一个算法,使用常数级额外空间来解决此问题。
示例说明
✅ 示例 1:
输入:nums = [1,2,3]
输出:[1,3,2]
✅ 示例 2:
输入:nums = [3,2,1]
输出:[1,2,3]
✅ 示例 3:
输入:nums = [1,1,5]
输出:[1,5,1]
✅ 示例 4:
输入:nums = [1,3,2]
输出:[2,1,3]
解题思路
💡 核心思想: 找到字典序中刚好比当前排列大的下一个排列。
步骤详解(✅ 标准解法)
-
从右向左找第一个「下降」的位置
i- 即找到最大的索引
i,使得nums[i] < nums[i + 1] - 如果找不到(整个数组非递增),说明当前是最大排列,直接反转整个数组即可
- 即找到最大的索引
-
从右向左找第一个大于
nums[i]的元素nums[j]- 因为
i右侧是递减序列,所以从右往左第一个大于nums[i]的就是最小的可交换元素
- 因为
-
交换
nums[i]和nums[j] -
反转
i+1到末尾的子数组- 使右侧变为升序(字典序最小)
其他解法对比
| 解法 | 思路 | 时间复杂度 | 空间复杂度 | 是否满足题目要求 |
|---|---|---|---|---|
| ❌ 暴力生成全排列 | 生成所有排列后找下一个 | O(n!) | O(n!) | 否 |
| ✅ 上述标准解法 | 贪心 + 双指针 | O(n) | O(1) | ✅ 是 |
| ⚠️ 二分优化查找 | 在步骤2中用二分查找 | O(n) | O(1) | ✅ 是(但常数更大) |
💡 实际上由于步骤2的子数组是严格递减的,线性扫描已经是最优,二分并无优势。
代码实现
=== "Java"
java
class Solution {
public void nextPermutation(int[] nums) {
int n = nums.length;
// 步骤1: 从右向左找第一个下降位置 i
int i = n - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
i--;
}
// 如果找到了下降位置
if (i >= 0) {
// 步骤2: 从右向左找第一个大于 nums[i] 的位置 j
int j = n - 1;
while (j >= 0 && nums[j] <= nums[i]) {
j--;
}
// 步骤3: 交换 nums[i] 和 nums[j]
swap(nums, i, j);
}
// 步骤4: 反转 i+1 到末尾
reverse(nums, i + 1, n - 1);
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
private void reverse(int[] nums, int start, int end) {
while (start < end) {
swap(nums, start, end);
start++;
end--;
}
}
}
=== "Go"
go
func nextPermutation(nums []int) {
n := len(nums)
// 步骤1: 从右向左找第一个下降位置 i
i := n - 2
for i >= 0 && nums[i] >= nums[i+1] {
i--
}
// 如果找到了下降位置
if i >= 0 {
// 步骤2: 从右向左找第一个大于 nums[i] 的位置 j
j := n - 1
for j >= 0 && nums[j] <= nums[i] {
j--
}
// 步骤3: 交换 nums[i] 和 nums[j]
nums[i], nums[j] = nums[j], nums[i]
}
// 步骤4: 反转 i+1 到末尾
reverse(nums, i+1, n-1)
}
func reverse(nums []int, start, end int) {
for start < end {
nums[start], nums[end] = nums[end], nums[start]
start++
end--
}
}
示例演示
📌 以 [1,3,2] 为例:
| 步骤 | 操作 | 数组状态 |
|---|---|---|
| 初始 | - | [1,3,2] |
| 1️⃣ | 找下降位置:i=0 (1<3) |
[1,3,2] |
| 2️⃣ | 找大于 nums[0]=1 的最右元素:j=2 (2>1) |
[1,3,2] |
| 3️⃣ | 交换 nums[0] 和 nums[2] |
[2,3,1] |
| 4️⃣ | 反转 i+1=1 到末尾 |
[2,1,3] |
✅ 最终结果:[2,1,3]
答案有效性证明
💡 为什么这个算法正确?
- 必要性:要得到字典序下一个排列,必须修改尽可能靠右的位置(保持前缀不变)
- 充分性 :
- 步骤1确保了
i是最右的可增大位置 - 步骤2确保了替换值是最小的可能增大值
- 步骤4确保了后缀是最小字典序(升序)
- 步骤1确保了
❌ 反例验证 :对于最大排列 [3,2,1]
- 步骤1找不到
i(i=-1) - 直接执行步骤4:反转整个数组 →
[1,2,3]✅
复杂度分析
| 指标 | 分析 |
|---|---|
| 时间复杂度 | O(n) - 最多三次线性扫描 |
| 空间复杂度 | O(1) - 只使用常数额外空间 |
| 原地修改 | ✅ 是 |
📌 关键点:虽然有多个循环,但每个元素最多被访问常数次,因此整体仍是线性时间。
问题总结
✅ 核心要点回顾:
- 字典序下一个排列的本质是找到最右的可增大位置
- 贪心策略 :在可增大位置选择最小的增大值
- 后缀处理 :增大后必须让后缀变为最小字典序
💡 面试技巧:
- 先理解字典序的定义
- 从简单例子入手(如
[1,2,3,4]的各种排列) - 强调算法的原地性 和线性时间特性
📌 易错点提醒:
- 边界条件:
i = -1的情况(最大排列) - 比较操作符:步骤1用
>=,步骤2用<= - 反转范围:从
i+1开始,不是从i开始
github地址: https://github.com/swf2020/LeetCode-Hot100-Solutions