算法核心框架-回溯算法
🚮

算法核心框架-回溯算法

AI custom autofill
Algorithm Backtracking: A powerful technique for problem-solving using depth-first search.
Tags
CS
经典算法
回溯算法
DFS
Published
March 5, 2024

一 回溯算法

DFS:Deep First Search

1. 基础框架

💡
回溯算法和 DFS 算法的细微差别是:回溯算法是在遍历「树枝」,DFS 算法是在遍历「节点」
抽象地说,解决一个回溯问题,实际上就是遍历一棵决策树的过程,树的每个叶子节点存放着一个合法答案。你把整棵树遍历一遍,把叶子节点上的答案都收集起来,就能得到所有的合法答案
站在回溯树的一个节点上,你只需要思考 3 个问题:
1、路径:也就是已经做出的选择。
2、选择列表:也就是你当前可以做的选择。
3、结束条件:也就是到达决策树底层,无法再做选择的条件。
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」
result = [] def backtrack(路径, 选择列表): if 满足结束条件: result.add(路径) return for 选择 in 选择列表: 做选择 backtrack(路径, 选择列表) 撤销选择

2. 例题

全排列

class Solution { List<List<Integer>> res = new LinkedList<>(); public List<List<Integer>> permute(int[] nums) { boolean[] used = new boolean[nums.length]; backtrack(nums, new LinkedList<>(), used); return res; } // 路径:记录在 track 中 // 选择列表:nums 中不存在于 track 的那些元素(used[i] 为 false) // 结束条件:nums 中的元素全都在 track 中出现 public void backtrack(int[] nums, LinkedList<Integer> track, boolean[] used) { if (track.size() == nums.length) { // 满足结束条件:添加结果退出 res.add(new LinkedList<>(track)); return; } for (int i = 0; i < nums.length; i++) { if (used[i]) { // 已经被使用 continue; } // 做选择 used[i] = true; track.add(nums[i]); // 进入下一层决策树 backtrack(nums, track, used); // 撤销选择 used[i] = false; track.removeLast(); } } }

n皇后问题

class Solution { List<List<String>> res = new ArrayList<>(); public List<List<String>> solveNQueens(int n) { // 初始化 空棋盘 List<String> board = new ArrayList<>(); for (int i = 0; i < n; i++) { StringBuilder sb = new StringBuilder(); for (int j = 0; j < n; j++) { sb.append("."); } board.add(sb.toString()); } backtrack(board, 0); return res; } // 路径:board 中小于 row 的那些行都已经成功放置了皇后 // 选择列表:第 row 行的所有列都是放置皇后的选择 // 结束条件:row 超过 board 的最后一行 private void backtrack(List<String> board, int row) { // 结束条件:row 超过 board 的最后一行 if (row >= board.size()) { // 满足结束条件:添加结果退出 res.add(new ArrayList<String>(board)); return; } // n为棋盘有多少列 int n = board.get(row).length(); for (int col = 0; col < n; col++) { // 排除不合法选择 if (!isValid(board, row, col)) { continue; } // 做选择 StringBuilder sb = new StringBuilder(board.get(row)); sb.setCharAt(col, 'Q'); board.set(row, sb.toString()); // 进入下一行决策 backtrack(board, row + 1); // 撤销选择 sb.setCharAt(col, '.'); board.set(row, sb.toString()); } } /* 是否可以在 board[row][col] 放置皇后? */ private boolean isValid(List<String> board, int row, int col) { int n = board.get(row).length(); // 判断是否能放 // 检查列,所有row的第col有没有Q for (int i = 0; i < row; i++) { if (board.get(i).charAt(col) == 'Q') { return false; } } // 检查左上角直线 for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) { if (board.get(i).charAt(j) == 'Q') { return false; } } // 检查右上角之间 for (int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) { if (board.get(i).charAt(j) == 'Q') { return false; } } return true; } }

3. 最后总结

回溯算法就是个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作,算法框架如下:
def backtrack(...): for 选择 in 选择列表: 做选择 backtrack(...) 撤销选择
写 backtrack 函数时,需要维护走过的「路径」和当前可以做的「选择列表」,当触发「结束条件」时,将「路径」记入结果集
其实想想看,回溯算法和动态规划是不是有点像呢?我们在动态规划系列文章中多次强调,动态规划的三个需要明确的点就是「状态」「选择」和「base case」,是不是就对应着走过的「路径」,当前的「选择列表」和「结束条件」?
动态规划和回溯算法底层都把问题抽象成了树的结构,但这两种算法在思路上是完全不同的。在 东哥带你刷二叉树(纲领篇) 你将看到动态规划和回溯算法更深层次的区别和联系。
 
 

二 回朔算法秒杀所有排列、组合、子集问题

无论是排列、组合还是子集问题,简单说无非就是让你从序列 nums 中以给定规则取若干元素,主要有以下几种变体:
形式一、元素无重不可 复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次,这也是最基本的形式
以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该只有 [7]
形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次
以组合为例,如果输入 nums = [2,5,2,1,2],和为 7 的组合应该有两种 [2,2,2,1] 和 [5,2]
形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次
以组合为例,如果输入 nums = [2,3,6,7],和为 7 的组合应该有两种 [2,2,3] 和 [7]
当然,也可以说有第四种形式,即元素可重可复选。但既然元素可复选,那又何必存在重复元素呢?元素去重之后就等同于形式三,所以这种情况不用考虑。
上面用组合问题举的例子,但排列、组合、子集问题都可以有这三种基本形式,所以共有 9 种变化。
情况
排列
组合
子集
元素无重不可 复选
46全排列
77组合
78子集
元素可重不可复选
47全排列2
40组合总和2
90子集2
元素无重可复选
39组合总和
除此之外,题目也可以再添加各种限制条件,比如让你求和为 target 且元素个数为 k 的组合,那这么一来又可以衍生出一堆变体,怪不得面试笔试中经常考到排列组合这种基本题型。
但无论形式怎么变化,其本质就是穷举所有解,而这些解呈现树形结构,所以合理使用回溯算法框架,稍改代码框架即可把这些问题一网打尽
notion image
notion image

最后总结

来回顾一下排列/组合/子集问题的三种形式在代码上的区别。
由于子集问题和组合问题本质上是一样的,无非就是 base case 有一些区别,所以把这两个问题放在一起看。
形式一、元素无重不可复选,即 nums 中的元素都是唯一的,每个元素最多只能被使用一次backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); used[i] = false; } }
形式二、元素可重不可复选,即 nums 中的元素可以存在重复,每个元素最多只能被使用一次,其关键在于排序和剪枝backtrack 核心代码如下:
Arrays.sort(nums); /* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 剪枝逻辑,跳过值相同的相邻树枝 if (i > start && nums[i] == nums[i - 1]) { continue; } // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i + 1); // 撤销选择 track.removeLast(); } } Arrays.sort(nums); /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 剪枝逻辑 if (used[i]) { continue; } // 剪枝逻辑,固定相同的元素在排列中的相对位置 if (i > 0 && nums[i] == nums[i - 1] && !used[i - 1]) { continue; } // 做选择 used[i] = true; track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); used[i] = false; } }
形式三、元素无重可复选,即 nums 中的元素都是唯一的,每个元素可以被使用若干次,只要删掉去重逻辑即可,backtrack 核心代码如下:
/* 组合/子集问题回溯算法框架 */ void backtrack(int[] nums, int start) { // 回溯算法标准框架 for (int i = start; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); // 注意参数 backtrack(nums, i); // 撤销选择 track.removeLast(); } } /* 排列问题回溯算法框架 */ void backtrack(int[] nums) { for (int i = 0; i < nums.length; i++) { // 做选择 track.addLast(nums[i]); backtrack(nums); // 撤销选择 track.removeLast(); } }