下一个排列:从字典序到原地算法的完整推导

一、题目背景

「下一个排列」是一道非常经典的数组原地修改问题。

题目要求:给定一个整数数组 nums,将它重新排列成字典序中的下一个更大的排列。

如果不存在下一个更大的排列,则将数组重新排列成字典序最小的排列,也就是升序排列。

例如:

复制代码
nums = [1, 2, 3]

它的下一个排列是:

复制代码
[1, 3, 2]

再例如:

复制代码
nums = [3, 2, 1]

由于 [3, 2, 1] 已经是所有排列中字典序最大的排列,因此它的下一个排列需要回到最小排列:

复制代码
[1, 2, 3]

这道题的限制是:

复制代码
必须原地修改,只允许使用额外常数空间。

因此不能暴力生成所有排列,也不能额外开数组存储结果。


二、什么是字典序排列?

所谓字典序,可以理解为数字序列之间的大小比较规则。

例如对于数字 1, 2, 3,它的所有排列按照字典序从小到大排列如下:

复制代码
123
132
213
231
312
321

因此:

复制代码
123 的下一个排列是 132
132 的下一个排列是 213
321 没有更大的排列,因此回到 123

对于更长的序列,例如 1,2,3,4,5,6,它的排列顺序可以看成:

复制代码
123456
123465
123546
...
654321

题目的核心就是:

在这些有序排列中,找到当前排列的下一个排列。

换句话说,我们要找的是:

复制代码
刚好比当前排列大一点点的排列

而不是随便找一个更大的排列。


三、问题本质:如何让排列"刚好变大一点"

假设当前排列是:

复制代码
123456

如果我们想让它变大,可以交换后面的某些数字。

例如交换 56

复制代码
123456 -> 123465

显然,123465123456 大。

但是问题并不只是"变大",而是要变成"下一个更大的排列"。

也就是说,增加的幅度必须尽可能小。

因此我们需要满足两个原则。


原则一:尽可能在靠右的位置进行修改

数字越靠左,对整体大小的影响越大。

例如:

复制代码
123456

如果修改第一个数字,可能会变成:

复制代码
213456

这个变化幅度非常大。

但如果只修改最后两个数字:

复制代码
123456 -> 123465

变化幅度就小得多。

所以,要找到下一个排列,应当尽量从右侧低位开始调整。

这就是为什么算法需要:

复制代码
从后向前查找

原则二:用尽可能小的"大数"替换前面的"小数"

如果要让排列变大,就需要把后面的一个较大的数换到前面。

例如:

复制代码
123465

如果我们希望得到它的下一个排列,需要从后往前分析。

当前后缀中存在:

复制代码
4, 6, 5

我们可以用 56 去替换 4

如果用 6 替换 4

复制代码
123465 -> 123645

如果用 5 替换 4

复制代码
123465 -> 123564

显然:

复制代码
123564 < 123645

所以应该选择刚好比 4 大的那个数字,也就是 5

这说明,交换时不能随便找一个大数,而要找:

复制代码
右侧所有大于当前数字的元素中,最小的那个

原则三:交换后,后缀要变成最小排列

继续看:

复制代码
123465

我们已经知道要交换 45

交换后得到:

复制代码
123564

但是这还不是最终答案。

因为 5 后面的部分是:

复制代码
6, 4

它不是最小排列。

为了让整个结果尽可能小,需要把后缀调整成升序:

复制代码
6, 4 -> 4, 6

最终得到:

复制代码
123546

所以:

复制代码
123465 的下一个排列是 123546

这一步非常关键。

因为当我们已经让前面的某一位变大之后,后面的部分就应该尽可能小,才能保证整个排列是"刚好变大"的。


四、算法整体过程

标准的"下一个排列"算法可以分为三步。

假设数组为 nums,长度为 n

第一步:从后向前找到第一个升序位置

从右往左查找第一个满足:

复制代码
nums[i] < nums[i + 1]

的位置 i

这个位置就是需要被替换的"小数"。

为什么要找这个位置?

因为如果从右往左看,后面一直是降序,说明后面的部分已经是最大排列,无法仅靠调整后缀变得更大。

例如:

复制代码
12385764

从右往左看:

复制代码
6 > 4
7 > 6
5 < 7

因此第一个满足升序关系的位置是:

复制代码
nums[i] = 5

此时可以把数组分成两部分:

复制代码
1238 | 5764

其中 5 是我们要修改的位置。


第二步:从后向前找到第一个大于 numsi 的元素

找到 nums[i] 之后,需要在它右边找到一个刚好比它大的数。

由于右侧后缀是降序排列,因此从右往左扫描,遇到的第一个大于 nums[i] 的元素,就是最小的大数。

对于:

复制代码
12385764

我们已经找到:

复制代码
nums[i] = 5

右侧部分为:

复制代码
7, 6, 4

从右往左查找第一个大于 5 的数:

复制代码
6

因此交换 56

复制代码
12385764 -> 12386754

第三步:反转后缀,使其变成升序

交换后,前面的部分已经变大。

此时为了让整个排列尽可能小,需要把 i 后面的部分调整成升序。

当前结果是:

复制代码
12386754

6 后面的部分是:

复制代码
7, 5, 4

这是降序的。

将其反转:

复制代码
7, 5, 4 -> 4, 5, 7

最终得到:

复制代码
12386457

因此:

复制代码
12385764 的下一个排列是 12386457

五、为什么后缀一定是降序?

这是这道题最核心的逻辑之一。

我们从右往左寻找第一个满足:

复制代码
nums[i] < nums[i + 1]

的位置。

在找到这个位置之前,说明右侧所有相邻元素都不满足升序关系,也就是:

复制代码
nums[k] >= nums[k + 1]

所以右侧后缀一定是非递增的,也就是降序结构。

例如:

复制代码
12385764

当我们找到 5 < 7 时,5 后面的部分是:

复制代码
7, 6, 4

它是降序的。

这就带来两个好处:

第一,从右往左找第一个大于 nums[i] 的元素,就能找到最小的大数。

第二,交换完成后,只需要反转后缀,就能让后缀变成升序,而不需要额外排序。

这也是该算法能做到 O(n) 时间复杂度和 O(1) 空间复杂度的关键。


六、完整 C++ 代码

复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
    void nextPermutation(vector<int>& nums) {
        int n = nums.size();

        // 从右往左找到第一个 nums[i] < nums[i + 1] 的位置
        int i = n - 2;
        while (i >= 0 && nums[i] >= nums[i + 1]) {
            i--;
        }

        // 如果 i >= 0,说明当前排列不是最大排列
        if (i >= 0) {
            int j = n - 1;

            // 从右往左找到第一个大于 nums[i] 的元素
            while (j >= 0 && nums[j] <= nums[i]) {
                j--;
            }

            // 交换"小数"和"大数"
            swap(nums[i], nums[j]);
        }

        // 反转 i + 1 到末尾的后缀,使其变成升序
        reverse(nums.begin() + i + 1, nums.end());
    }
};

七、代码详细解析

1. 查找第一个升序位置

复制代码
int i = n - 2;
while (i >= 0 && nums[i] >= nums[i + 1]) {
    i--;
}

这里从倒数第二个元素开始向前扫描。

原因是我们需要比较:

复制代码
nums[i] 和 nums[i + 1]

如果一直满足:

复制代码
nums[i] >= nums[i + 1]

说明当前部分是降序的。

一旦找到:

复制代码
nums[i] < nums[i + 1]

就说明 nums[i] 可以被右侧某个更大的元素替换,从而让整个排列变大。


2. 查找交换对象

复制代码
if (i >= 0) {
    int j = n - 1;

    while (j >= 0 && nums[j] <= nums[i]) {
        j--;
    }

    swap(nums[i], nums[j]);
}

如果 i >= 0,说明当前排列还不是最大排列。

接下来需要在 i 的右侧找到一个比 nums[i] 大的元素。

由于右侧后缀是降序排列,所以从右往左找到的第一个大于 nums[i] 的元素,就是最小的大数。

这一步保证了新排列只会比原排列大一点点。


3. 反转后缀

复制代码
reverse(nums.begin() + i + 1, nums.end());

交换之后,前缀已经变大。

为了让结果尽可能小,后缀必须变成升序。

由于后缀原本是降序,所以只需要执行一次反转即可。

如果当前数组本身就是降序,例如:

复制代码
[3, 2, 1]

那么第一步结束后:

复制代码
i = -1

此时执行:

复制代码
reverse(nums.begin(), nums.end());

数组就会变成:

复制代码
[1, 2, 3]

刚好符合题目要求。


八、示例推演:12385764

下面用一个完整例子来走一遍算法流程。

初始数组:

复制代码
1 2 3 8 5 7 6 4

第一步:从右往左找第一个升序位置

从右往左观察:

复制代码
6 > 4
7 > 6
5 < 7

因此:

复制代码
nums[i] = 5

当前结构为:

复制代码
1 2 3 8 [5] 7 6 4

其中 5 是需要被替换的小数。


第二步:从右往左找第一个大于 5 的数

从右往左扫描 5 右侧部分:

复制代码
7 6 4

第一个大于 5 的元素是:

复制代码
6

交换 56

复制代码
1 2 3 8 6 7 5 4

第三步:反转后缀

交换后,6 后面的部分是:

复制代码
7 5 4

将其反转为升序:

复制代码
4 5 7

最终结果:

复制代码
1 2 3 8 6 4 5 7

也就是:

复制代码
12385764 -> 12386457

九、边界情况分析

1. 当前排列已经是最大排列

例如:

复制代码
nums = [3, 2, 1]

整个数组从左到右是降序排列。

从右往左无法找到:

复制代码
nums[i] < nums[i + 1]

说明当前排列已经是字典序最大排列。

此时直接反转整个数组:

复制代码
[3, 2, 1] -> [1, 2, 3]

2. 数组中存在重复元素

例如:

复制代码
nums = [1, 1, 5]

从右往左找到:

复制代码
1 < 5

交换后得到:

复制代码
[1, 5, 1]

本算法对重复元素同样适用。

因为寻找交换对象时使用的是严格大于关系:

复制代码
nums[j] > nums[i]

不会把相等元素误认为可以产生更大排列的元素。


3. 数组长度为 1

例如:

复制代码
nums = [1]

只有一个元素时,它的排列只有一种,下一个排列仍然是它本身。

代码中:

复制代码
int i = n - 2;

此时 i = -1,最后执行反转操作,结果不变。


十、正确性证明

可以从三个角度证明该算法的正确性。

1. 修改位置必须尽可能靠右

为了让新排列比原排列大,但又尽量接近原排列,应尽量保持高位不变。

从右往左找到的第一个 nums[i] < nums[i + 1],就是最靠右的、可以通过交换变大的位置。

如果不修改这个位置,那么右侧后缀已经是降序,也就是后缀的最大排列,无法再变大。

因此,i 是必须修改的最靠右位置。


2. 交换对象必须是最小的大数

当确定要修改 nums[i] 后,新数字必须比 nums[i] 大。

如果选择一个过大的数字,虽然也能让排列变大,但结果不是"下一个排列"。

由于右侧后缀是降序,从右往左扫描到的第一个大于 nums[i] 的元素,就是所有可选元素中最小的那个大数。

这保证了交换后,前缀增量最小。


3. 后缀必须调整为升序

交换完成后,前缀已经比原排列更大。

为了让整个结果在所有更大排列中最小,后缀必须取最小字典序。

对于一组数字来说,升序排列就是字典序最小的排列。

而右侧后缀本身具有降序结构,因此直接反转即可得到升序。


十一、复杂度分析

时间复杂度

算法主要包括三次线性操作:

复制代码
1. 从右往左查找升序位置:O(n)
2. 从右往左查找交换对象:O(n)
3. 反转后缀:O(n)

因此总时间复杂度为:

复制代码
O(n)

空间复杂度

算法只使用了少量指针变量,没有使用额外数组。

因此空间复杂度为:

复制代码
O(1)

完全满足题目"原地修改,只允许使用常数额外空间"的要求。


十二、常见错误

错误一:从左往右找交换位置

如果从左往右寻找可交换位置,可能会过早修改高位,导致结果过大。

例如:

复制代码
123456

如果错误地修改前面的 12,得到的结果就不是紧邻的下一个排列。

正确做法是:

复制代码
从右往左找第一个可以增大的位置

错误二:交换后没有反转后缀

例如:

复制代码
123465

交换 45 后得到:

复制代码
123564

但这不是最终答案。

因为后缀还可以变得更小:

复制代码
64 -> 46

所以正确答案是:

复制代码
123546

错误三:寻找交换元素时没有选择最小的大数

如果当前数组是:

复制代码
123465

对于数字 4,右侧比它大的数有:

复制代码
6, 5

应该选择 5,而不是 6

如果选择 6,得到的排列会变得过大,不符合"下一个排列"的要求。


十三、模板总结

这道题可以总结成一个固定模板:

复制代码
1. 从右往左找第一个 nums[i] < nums[i + 1] 的位置;
2. 如果找到了 i,则从右往左找第一个 nums[j] > nums[i] 的位置;
3. 交换 nums[i] 和 nums[j];
4. 反转 i + 1 到末尾的后缀。

对应代码模板:

复制代码
void nextPermutation(vector<int>& nums) {
    int n = nums.size();
    int i = n - 2;

    while (i >= 0 && nums[i] >= nums[i + 1]) {
        i--;
    }

    if (i >= 0) {
        int j = n - 1;
        while (j >= 0 && nums[j] <= nums[i]) {
            j--;
        }
        swap(nums[i], nums[j]);
    }

    reverse(nums.begin() + i + 1, nums.end());
}

十四、总结

「下一个排列」这道题的难点不在代码,而在于对字典序的理解。

它的核心思想可以概括为:

复制代码
从右往左找到第一个可以变大的位置,
用右侧刚好比它大的元素替换它,
再把后缀调整为最小排列。

也就是:

复制代码
低位优先修改,增幅尽量小,后缀重新最小化。

这道题很好地体现了贪心思想:

每一步都选择当前最小的有效改变,最终得到全局意义上的下一个排列。

掌握这道题之后,对全排列生成、字典序排序、组合搜索等问题都会有更深入的理解。

相关推荐
搬砖魁首11 分钟前
基础能力系列 - 多线程2 - 条件变量
c++·rust·条件变量·原子类型·线程同步互斥
youngerwang17 分钟前
【从搬运工到协处理器:网卡芯片架构、算法、验证与边缘演进深度剖析】
网络·算法·架构·芯片
chase_my_dream19 分钟前
C++ + SLAM 高频面试问题整理
开发语言·c++·面试
想要成为糕糕手28 分钟前
前端必修课:JavaScript 数组与数据结构底层逻辑全解析
javascript·数据结构·面试
牛油果子哥q41 分钟前
【C++ STL string 】C++ STL string 终极精讲:底层原理、内存机制、全套API、深浅拷贝、易错坑点与工程实战规范
数据库·c++
KaMeidebaby41 分钟前
卡梅德生物技术快报|纯化重组蛋白实操详解
人工智能·python·tcp/ip·算法·机器学习
caimouse43 分钟前
Reactos 第 5 章 进程与线程 — 5.8 Windows 的 APC 机制
c语言·windows
PHP隔壁老王邻居1 小时前
windows菜单搜索栏无法显示历史记录或者无法使用修复方法
windows
手写码匠2 小时前
从零实现 Prompt 工程引擎:结构化提示、自动优化与多轮自省体系
人工智能·深度学习·算法·aigc
道一232 小时前
Windows系统查看端口占用进程的3种实用方法
windows·笔记