11、递归与回溯:全排列问题

全排列问题,是理解回溯算法最好的入门题。我个人觉得,它把递归的「深度优先」和回溯的「状态撤销」这两个核心概念,展现得淋漓尽致。

说白了,就是给你几个不同的数字,比如 [1,2,3],让你列出所有可能的排列方式。3 个数字有 6 种排列,4 个数字有 24 种,n 个数字就是 n! 种。嗯,这个增长很快,但递归处理起来却很优雅。

问题定义

给定一个不含重复数字的数组 nums,返回其所有可能的全排列。你可以按任意顺序返回答案。

示例:

输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

递归思路:选数填空

我是这么理解全排列的:想象有 n 个空位,我们要依次把数字填进去。

  • 第一个空位,可以从 n 个数字中任选一个
  • 第二个空位,从剩下的 n-1 个数字中选一个
  • ……
  • 最后一个空位,只剩一个数字,没得选

你想想看,这不就是一棵多叉树吗?每一层对应一个空位,每个节点代表「当前已选数字的序列」。叶子节点就是完整的排列。

核心思想:递归函数负责「在当前空位,尝试所有还没用过的数字」。每选一个,就递归到下一层。当所有空位填满,就得到一个排列。

回溯的「撤销」操作

这里有个关键点:当你递归返回时,必须把刚才选的那个数字「还回去」。为什么?

举个例子。你选了 1 放在第一个位置,然后递归处理剩下的 [2,3]。等递归返回,你得到了所有以 1 开头的排列。这时候,如果你不把 1 从「已选列表」中移除,下一个循环尝试把 2 放在第一个位置时,1 还在已选列表里,你就没法选 1 了——这显然不对。

所以,回溯的「撤销」操作,本质上就是:在递归调用之后,把状态恢复成调用之前的样子

我的习惯:写回溯代码时,先写「选择」和「递归」,再写「撤销」。这三行代码通常紧挨着,形成一个固定模式。你写多了就会发现,回溯题的代码结构都差不多。

代码实现

来看 C 语言实现。我建议你重点关注 backtrack 函数中的那三行核心代码。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 交换两个整数
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

// 回溯函数
// nums: 原始数组
// numsSize: 数组长度
// start: 当前要确定的位置
// result: 存储所有排列
// returnSize: 当前已找到的排列数量
void backtrack(int* nums, int numsSize, int start, int** result, int* returnSize) {
    // 递归终止条件:所有位置都已确定
    if (start == numsSize) {
        // 保存当前排列
        result[*returnSize] = (int*)malloc(numsSize * sizeof(int));
        memcpy(result[*returnSize], nums, numsSize * sizeof(int));
        (*returnSize)++;
        return;
    }

    // 尝试将每个未使用的数字放到 start 位置
    for (int i = start; i < numsSize; i++) {
        // 选择:将 nums[i] 放到 start 位置
        swap(&nums[start], &nums[i]);

        // 递归:处理下一个位置
        backtrack(nums, numsSize, start + 1, result, returnSize);

        // 撤销:恢复原状
        swap(&nums[start], &nums[i]);
    }
}

int** permute(int* nums, int numsSize, int* returnSize, int** returnColumnSizes) {
    // 计算排列总数:n!
    int total = 1;
    for (int i = 1; i <= numsSize; i++) {
        total *= i;
    }

    // 分配结果数组
    int** result = (int**)malloc(total * sizeof(int*));
    *returnSize = 0;

    // 开始回溯
    backtrack(nums, numsSize, 0, result, returnSize);

    // 设置每行的列数
    *returnColumnSizes = (int*)malloc(*returnSize * sizeof(int));
    for (int i = 0; i < *returnSize; i++) {
        (*returnColumnSizes)[i] = numsSize;
    }

    return result;
}

核心逻辑流程图

下面这张图展示了以 [1,2,3] 为例,回溯算法是如何一步步生成所有排列的。注意看「选择」和「撤销」的交替过程。

开始 选 1 选 2 选 3 选 2 选 3 选 1 选 3 选 1 选 2 1,2,3 1,3,2 2,1,3 2,3,1 3,1,2 3,2,1 ← 撤销:恢复原状 回溯算法本质:深度优先遍历 + 状态撤销 每层选择一个数字,递归到底后撤销选择,尝试下一个 选择 排列结果

代码执行过程详解

我们拿 [1,2,3] 走一遍,看看递归和回溯是怎么配合的。

递归深度 当前数组状态 操作 说明
0 [1,2,3] start=0, i=0, 交换(0,0) 选 1 到位置 0
1 [1,2,3] start=1, i=1, 交换(1,1) 选 2 到位置 1
2 [1,2,3] start=2, i=2, 交换(2,2) 选 3 到位置 2,得到 [1,2,3]
2 [1,2,3] 撤销交换(2,2) 恢复原状
1 [1,2,3] i=2, 交换(1,2) 选 3 到位置 1
2 [1,3,2] start=2, i=2, 交换(2,2) 选 2 到位置 2,得到 [1,3,2]
2 [1,3,2] 撤销交换(2,2) 恢复原状
1 [1,3,2] 撤销交换(1,2) 恢复成 [1,2,3]
0 [1,2,3] i=1, 交换(0,1) 选 2 到位置 0
…… …… …… 继续类似过程

我曾经踩过的坑:刚开始写回溯时,我经常忘记在递归调用后面写撤销操作。结果就是,第一次递归返回后,数组状态已经变了,后面的循环全乱套了。调试了整整一下午才找到原因。所以我的建议是:写回溯代码时,先把「选择」「递归」「撤销」这三行写在一起,形成一个不可分割的单元。

时间复杂度分析

全排列的时间复杂度是 O(n!),这个没法优化,因为 n! 个排列必须全部生成。空间复杂度方面,递归调用栈的深度是 n,所以是 O(n)。

不过在实际工程中,n 通常不会太大。n=10 时,10! = 3628800,已经接近千万级别了。如果 n 再大,就要考虑剪枝或者用迭代法了。

回溯算法的通用模板

从全排列问题中,我们可以提炼出回溯算法的通用模板。你以后遇到任何回溯题,都可以套这个框架。

void backtrack(参数列表) {
    if (满足终止条件) {
        保存结果;
        return;
    }

    for (选择 in 所有可选列表) {
        做选择;
        backtrack(更新后的参数);
        撤销选择;
    }
}

我的经验:这个模板看似简单,但真正用好它,关键在于两点:一是搞清楚「可选列表」是什么,二是想明白「撤销」要撤销什么。全排列中,可选列表是「当前未使用的数字」,撤销是「交换回去」。不同题目,这两点的实现方式不同,但思路是一样的。

小结

全排列问题,是理解回溯算法的绝佳入口。它把递归的深度优先遍历和回溯的状态撤销,展现得清清楚楚。

你想想看,整个算法就像一个人在迷宫里探索:每到一个岔路口,选一条路走到底;走不通或者走完了,就退回到上一个路口,换一条路继续走。这个「退回」的动作,就是回溯中的「撤销」。

掌握了全排列,你就掌握了回溯算法的核心思想。后面遇到八皇后、组合总和、子集等问题,你会发现,它们本质上都是同一个套路——只是「选择」和「撤销」的具体形式不同罢了。

公众号:蓝海资料掘金营,微信 deep3321