排列算法完全指南 - 从全排列到N皇后,一套模板搞定所有排列问题

上篇文章我们聊了回溯算法中的组合/子集问题,本文将聚焦于LeetCode上面的:46(全排列)、47(全排列II)、22(括号生成)、51(N皇后)、37(解数独),以及剑指offer - 38(去重排列),这几道非常经典的题目,带领大家彻底拿下排列算法。

排列 VS 组合

基本概念

很多人容易把 排列组合 混为一谈,但实际上它们有本质上的区别。它们的核心用一句话概括:

  • 组合(Combination):选出来就行,顺序不重要 [1,2] 和 [2,1] 是同一个。
  • 排列(Permutation):顺序很重要,[1,2] 和 [2,1] 是两个不同的结果。

通用模板

组合/回溯的通用模板

在本文开始之前,我们先回忆一下组合/回溯的通用模板:

javascript 复制代码
function backtrack(路径, 选择列表) {
    if (满足结束条件) {
        result.push([...路径]); // 存放结果
        return;
    }

    for (选择 in 选择列表) {
        // 1. 做选择(前序遍历)
        路径.push(选择);
        
        // 2. 进入下一层决策树(递归)
        backtrack(新的路径, 新的选择列表);
        
        // 3. 撤销选择(后序遍历)
        路径.pop();
    }
}

排列的通用模板

javascript 复制代码
const used = new Array(nums.length).fill(false);  // 关键1:used数组
const backtrack = (path) => {
    if (满足结束条件) {
        result.push([...path]);
        return;
    }
    
    // 关键2:每次都从0开始遍历
    for (let i = 0; i < nums.length; i++) {
        // 关键3:跳过已使用的元素
        if (used[i]) continue;
        
        // 做选择
        path.push(nums[i]);
        used[i] = true;
        
        // 递归
        backtrack(path);
        
        // 撤销选择
        path.pop();
        used[i] = false;
    }
};

排列 vs 组合的模板对比

对比维度 组合模板 排列模板
参数 backtrack(start, path) backtrack(path)
遍历起点 for (let i = start; i < n; i++) for (let i = 0; i < n; i++)
去重方式 用 start 控制不回头 用 used 数组标记
空间结构 不需要额外数组 需要 used 数组
结果数量 C(n, k) P(n, k) = n!

为什么排列要每次都从0开始?

  • 因为 [1,2] 和 [2,1] 是两个不同的结果
  • 第一个位置可以选任何元素,第二个位置也可以选任何元素(除了已选的)

为什么需要 used 数组?

防止同一个元素被重复选取(排列不允许重复使用同一个元素)

下面的所有题都是在这个模版的基础上增加剪枝条件!

排列问题的入门(LeetCode 46)

题目

给定一个没有重复数字的序列,返回其所有可能的全排列。

示例:nums = [1,2,3] 输出: [ [1,2,3], [1,3,2], [2,1,3], [2,3,1], [3,1,2], [3,2,1] ]

思路解析

排列问题的核心: 只要顺序不同,那就是不同的结果。比如第一个位置可以选1、2、3中的任意一个;选完第一个后,第二个位置只能在剩下的数中选。

图解过程

flowchart TD Start(("[]")) Start -->|"选1"| A1["[1]"] Start -->|"选2"| A2["[2]"] Start -->|"选3"| A3["[3]"] A1 -->|"选2"| B1["[1,2]"] A1 -->|"选3"| B2["[1,3]"] A2 -->|"选1"| C1["[2,1]"] A2 -->|"选3"| C2["[2,3]"] A3 -->|"选1"| D1["[3,1]"] A3 -->|"选2"| D2["[3,2]"] B1 -->|"选3"| E1["[1,2,3]"] B2 -->|"选2"| E2["[1,3,2]"] C1 -->|"选3"| E3["[2,1,3]"] C2 -->|"选1"| E4["[2,3,1]"] D1 -->|"选2"| E5["[3,1,2]"] D2 -->|"选1"| E6["[3,2,1]"]

代码实现

javascript 复制代码
var permute = function(nums) {
    const result = [];
    const used = new Array(nums.length).fill(false);
    
    const backtrack = (path) => {
        // 结束条件:找到一个完整排列
        if (path.length === nums.length) {
            result.push([...path]);
            return;
        }
        
        // 每次都从0开始,尝试所有未使用的元素
        for (let i = 0; i < nums.length; i++) {
            if (used[i]) continue;  // 已使用的跳过
            
            // 做选择
            path.push(nums[i]);
            used[i] = true;
            
            // 递归
            backtrack(path);
            
            // 撤销选择
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

全排列 II(LeetCode 47)------ 有重复元素的全排列

题目

给定一个可能包含重复数字的序列,返回所有不重复的全排列。

示例:nums = [1,1,2] 输出: [[1,1,2], [1,2,1], [2,1,1]]

注意:不能有重复的排列。

思路解析

这题和上诉 46 题解析思路基本一致,唯一一点需要注意:当数组有重复元素时,需要去重,例如:两个 1 互换位置,但它们值相同,需要去重:

  • 排序:让相同的元素挨在一起。
  • 剪枝:在排列模板的基础上,增加同层去重判断。

代码实现

javascript 复制代码
var permuteUnique = function(nums) {
    const result = [];
    const used = new Array(nums.length).fill(false);
    nums.sort((a, b) => a - b); // 排序是去重的前提
    
    const backtrack = (path) => {
        if (path.length === nums.length) {
            result.push([...path]);
            return;
        }
        
        for (let i = 0; i < nums.length; i++) {
            // 基础剪枝:已使用过的跳过
            if (used[i]) continue;
            
            // 去重剪枝:同一层相同元素跳过
            if (i > 0 && nums[i] === nums[i - 1] && !used[i - 1]) continue;
            
            path.push(nums[i]);
            used[i] = true;
            backtrack(path);
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

字符串的排列(剑指Offer 38)

题目

输入一个字符串,打印出该字符串中字符的所有排列。可以以任意顺序返回这个字符串数组,但不能有重复元素。

示例:s = "abc" 输出:["abc","acb","bac","bca","cab","cba"]

思路解析

这题和上述 LeetCode 47 题完全一样,只是输入从数组变成了字符串。

代码实现

javascript 复制代码
var permutation = function(s) {
    const result = [];
    const arr = s.split('');
    const used = new Array(arr.length).fill(false);
    arr.sort(); // 排序去重
    
    const backtrack = (path) => {
        if (path.length === arr.length) {
            result.push(path.join(''));
            return;
        }
        
        for (let i = 0; i < arr.length; i++) {
            if (used[i]) continue;
            // 去重:同一层相同字符跳过
            if (i > 0 && arr[i] === arr[i - 1] && !used[i - 1]) continue;
            
            path.push(arr[i]);
            used[i] = true;
            backtrack(path);
            path.pop();
            used[i] = false;
        }
    };
    
    backtrack([]);
    return result;
};

括号生成(LeetCode 22)------ 特殊的排列问题

题目

数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且有效的括号组合。

示例:n = 3 输出: [ "((()))", "(()())", "(())()", "()(())", "()()()" ]

思路解析

这题虽然看起来并不是传统的排列问题,但它本质上也是一个排列问题:

  • 选择列表:( 和 ) 两个字符。
  • 约束条件:左括号数量不超过 n,右括号数量不超过左括号数量。
  • 不需要 used 数组,因为括号可以重复使用。

排列模板的变体

javascript 复制代码
const backtrack = (path, left, right) => {
    // 结束条件
    if (left === n && right === n) { // TODO }
    
    // 选择1:选左括号
    if (left < n) {
        backtrack(path + '(', left + 1, right);
    }
    
    // 选择2:选右括号
    if (right < left) {
        backtrack(path + ')', left, right + 1);
    }
};

代码实现

javascript 复制代码
var generateParenthesis = function(n) {
    const result = [];
    
    const backtrack = (path, left, right) => {
        // 结束条件:左右括号都用完了
        if (left === n && right === n) {
            result.push(path);
            return;
        }
        
        // 剪枝1:左括号数量不能超过 n
        if (left < n) {
            backtrack(path + '(', left + 1, right);
        }
        
        // 剪枝2:右括号数量不能超过左括号数量
        if (right < left) {
            backtrack(path + ')', left, right + 1);
        }
    };
    
    backtrack('', 0, 0);
    return result;
};

N皇后(LeetCode 51)------ 二维棋盘上的排列问题

题目

n 皇后问题研究的是如何将 n 个皇后放置在 n × n 的棋盘上,并且使皇后彼此之间不能相互攻击(即任意两个皇后都不能处于同一行、同一列或同一斜线上)。给你一个整数 n,返回所有不同的 n 皇后问题的解决方案。

示例:n = 4 输出: [ [".Q..", // 解法 1 "...Q", "Q...", "..Q."],

"..Q.", // 解法 2 "Q...", "...Q", ".Q.."\]

思路解析

N皇后问题可以看作一个特殊的排列问题:

  • 每行只能放一个皇后,所以我们可以用 row 来表示当前处理到第几行
  • 每列也只能放一个皇后,所以我们需要记录哪些列已经被占用
  • 还需要考虑两个斜线方向

排列模板的变体

javascript 复制代码
const backtrack = (row) => {
    if (row === n) {
        // 找到一个有效解
    }
    
    for (let col = 0; col < n; col++) {
        if (isValid(row, col)) {
            board[row] = col;  // 相当于做选择
            backtrack(row + 1); // 递归下一行
            board[row] = -1;    // 撤销选择
        }
    }
};

代码实现

javascript 复制代码
var solveNQueens = function(n) {
    const result = [];
    // board 是一维数组,索引表示行,值表示该行皇后所在的列
    const board = new Array(n).fill(-1);
    
    // 检查在 (row, col) 放置皇后是否合法
    const isValid = (row, col) => {
        for (let i = 0; i < row; i++) {
            // 检查列冲突
            if (board[i] === col) return false;
            // 检查对角线冲突:行差 === 列差
            if (Math.abs(row - i) === Math.abs(col - board[i])) return false;
        }
        return true;
    };
    
    // 将 board 转换成题目要求的字符串数组格式
    const formatBoard = () => {
        return board.map(col => {
            const row = new Array(n).fill('.');
            row[col] = 'Q';
            return row.join('');
        });
    };
    
    const backtrack = (row) => {
        // 结束条件:所有行都放置了皇后
        if (row === n) {
            result.push(formatBoard());
            return;
        }
        
        // 在当前行尝试每一列
        for (let col = 0; col < n; col++) {
            if (!isValid(row, col)) continue; // 剪枝
            
            // 做选择:在 (row, col) 放置皇后
            board[row] = col;
            // 递归到下一行
            backtrack(row + 1);
            // 撤销选择
            board[row] = -1;
        }
    };
    
    backtrack(0);
    return result;
};

N皇后 vs 全排列:

维度 全排列 N皇后
选择列表 所有未使用的数字 所有列
递归参数 path row
约束条件 不能重复选 列、对角线不冲突
结束条件 path.length === n row === n

解数独(LeetCode 37)------ 排列问题的终极形态

题目

编写一个程序,通过填充空格来解决数独问题。数独的解法需遵循如下规则:

  1. 数字 1-9 在每一行只能出现一次
  2. 数字 1-9 在每一列只能出现一次
  3. 数字 1-9 在每一个 3x3 宫格内只能出现一次

思路解析

解数独是排列问题的终极形态,它结合了:

  • 行的排列约束:每行数字不能重复。
  • 列的排列约束:每列数字不能重复。
  • 宫的排列约束:每个 3x3 宫格内数字不能重复。

排列模板的变体

javascript 复制代码
const backtrack = () => {
    for (let i = 0; i < 9; i++) {
        for (let j = 0; j < 9; j++) {
            if (board[i][j] === '.') {
                for (let num = 1; num <= 9; num++) {
                    if (isValid(i, j, num)) {
                        board[i][j] = num;
                        if (backtrack()) return true;
                        board[i][j] = '.';
                    }
                }
                return false;
            }
        }
    }
    return true;
};

代码实现

javascript 复制代码
var solveSudoku = function(board) {
    const isValid = (row, col, num) => {
        const numStr = num.toString();
        
        // 检查行
        for (let i = 0; i < 9; i++) {
            if (board[row][i] === numStr) return false;
        }
        
        // 检查列
        for (let i = 0; i < 9; i++) {
            if (board[i][col] === numStr) return false;
        }
        
        // 检查 3x3 宫格
        const boxRow = Math.floor(row / 3) * 3;
        const boxCol = Math.floor(col / 3) * 3;
        for (let i = 0; i < 3; i++) {
            for (let j = 0; j < 3; j++) {
                if (board[boxRow + i][boxCol + j] === numStr) return false;
            }
        }
        
        return true;
    };
    
    const backtrack = () => {
        for (let i = 0; i < 9; i++) {
            for (let j = 0; j < 9; j++) {
                // 找到空白格
                if (board[i][j] === '.') {
                    // 尝试填入 1-9
                    for (let num = 1; num <= 9; num++) {
                        if (isValid(i, j, num)) {
                            // 做选择
                            board[i][j] = num.toString();
                            // 递归
                            if (backtrack()) return true;
                            // 撤销选择
                            board[i][j] = '.';
                        }
                    }
                    return false; // 1-9 都不行,说明之前的选择有问题
                }
            }
        }
        return true; // 所有格子都填满了
    };
    
    backtrack();
    return board;
};

排列问题的核心套路

做完这几道题,我们会发现它们其实都是排列思想的变形。

各题模板对比

题目 递归参数 选择列表 剪枝条件 特殊点
46.全排列 path 所有未使用元素 used[i] 无重复,基础模板
47.全排列II path 所有未使用元素 used[i] + 同层去重 需要排序 + 去重
剑指38.字符串排列 path 所有未使用字符 used[i] + 同层去重 和47一样
22.括号生成 path, left, right 左括号/右括号 left < n 和 right < left 不固定长度,约束特殊
51.N皇后 row 所有列 列冲突 + 对角线冲突 每行一个皇后,用一维数组记录
37.解数独 无(全局遍历) 1-9 行+列+宫格约束 最复杂的排列问题

解题要点

  • 排列用 used:排列问题都要用 used 数组记录哪些元素已用。
  • 去重先排序:有重复元素时,排序 + 同层去重。
  • 约束是剪枝:不合法的情况提前 continue 或 return。
  • 递归深度是条件:路径长度等于元素个数时,收获结果。

结语

希望这篇文章能帮大家彻底搞懂排列算法。下次遇到"所有排列"、"全排列"、"N皇后"这类问题,别忘了拿出这个万能模板试一试!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

相关推荐
Cobyte2 小时前
微信 ClawBot 接入本地 AI Agent 的实现原理
前端·agent·ai编程
@大迁世界2 小时前
15.React 中的 Fragment 是什么?它出现的动机是什么?
前端·javascript·react.js·前端框架·ecmascript
吃一根烤肠2 小时前
使用ChatGPT Copilot加速Python调试:实战指南
前端·ui·html
ai生成式引擎优化技术2 小时前
拓世网络技术开发工作室的ts概率递推ai工程应用技术GEOChatGPT,不同用户账号信息,网站引用效果
算法
CylMK2 小时前
题解:UVA1218 完美的服务 Perfect Service
数据结构·c++·算法·深度优先·图论
重生之我是Java开发战士2 小时前
【广度优先搜索】BFS解决拓扑排序:课程表I,课程表II,火星词典
算法·leetcode·广度优先
Embrace9242 小时前
钉钉工作台内嵌应用=》调用钉钉对话框
前端·javascript·钉钉
蜡台2 小时前
Vue2 elementui2 中 el-switch 实现先判断改变状态
前端·vue.js·elementui·el-switch
whuhewei2 小时前
CSS动画倍速播放
前端·css