回溯介绍及实战

一、介绍回溯

回溯算法一本质上是一种深度优先搜索。它的核心思路是:从一个初始状态出发,逐步构建解,在每一步尝试所有可能的选择;如果当前路径满足条件,就继续深入;如果不满足或已经无法得到正确结果,就撤销上一步操作(回溯),再尝试其他选择。

这个过程类似在一棵"决策树"中不断向下搜索,并在不符合条件时返回上层节点继续尝试其他分支。回溯算法通常用于求解组合、排列、子集、棋盘问题(如N皇后)、路径搜索等问题。

其基本模板包括三个部分:路径(当前已经选择的结果)、选择列表(当前可以做的选择)以及结束条件(满足条件时收集结果)。实现时一般通过递归完成,每次递归前做选择,递归后撤销选择。

void backtrack(参数){

if(结束条件){

记录结果;

return;

}

for(选择 : 选择列表){

做选择;

backtrack(参数);

撤销选择; // 回溯

}

}

二、实战

216. 组合总和 III

这道题是一道中等难度的题,我们首先要搞清楚组合和排列,在组合中,数字的位置不同不产生影响。比如[2,1,6]与[6,2,1]在这题中是一样的,所以我们可以使用回溯。

我们每次递归大概是这个样子。代码如下:

复制代码
class Solution {
    List<List<Integer>> result=new ArrayList<>();
    List<Integer> path=new ArrayList<>();
    public List<List<Integer>> combinationSum3(int k, int n) {
        backtracking(n,k,0,1);
        return result;
    }
    public void backtracking(int targetSum,int k,int sum,int startIndex){
        if(sum>targetSum){
            return;
        }
        if(path.size()==k){
            if(targetSum==sum){
                result.add(new ArrayList<>(path));
            }
        }
        for(int i=startIndex;i<=9;i++){
            sum+=i;
            path.add(i);
            backtracking(targetSum,k,sum,i+1);
            sum-=i;
            path.remove(path.size()-1);
        }
    }
}

result 用于存放最终所有满足条件的结果集合,path 用于记录当前递归路)。在 backtracking 方法中,首先进行剪枝判断:如果当前路径的和 sum 已经大于目标值 targetSum,说明这条路径不可能再得到正确结果,直接返回。接着判断终止条件,当 path 中元素个数等于 k 时,如果此时路径和正好等于目标值,就将当前路径加入结果集中。然后进入 for 循环,从 startIndex 开始遍历到 9,依次尝试选择每一个数字。每次选择一个数字 i,就将其加入路径,并更新当前和 sum,然后递归进入下一层搜索,同时将起始位置设为 i+1,以保证不会重复使用同一个数字。递归结束后,通过 sum -= i 和移除路径最后一个元素来撤销本次选择,这一步就是回溯的核心操作,使程序能够继续尝试其他可能的组合。

90. 子集 II

这题也是一个中等难度的题,重点在去重,我们需要横向去重,也就是说,如果

复制代码
nums = [1,2,2],如图

但是如果是1,2,3,4,2这种又不需要去重,这是为什么呢?

在回溯算法中,去重的本质是避免"同一树层"出现重复选择,而不是禁止路径中出现相同元素。对于排序后的数组,如果两个相同元素出现在同一层(即作为兄弟节点),选择它们会产生重复结果,因此需要跳过;但如果是在不同层(即一个是另一个的子节点),则表示是不同位置的选择,是合法的组合,不需要去重。因此,像 [1,2,2] 这种同层重复需要去重,而像路径中连续选择两个 2(不同层)则是允许的。代码如下:

复制代码
class Solution {
    List<List<Integer>> result=new ArrayList<>();
    List<Integer> path=new ArrayList<>();

    public List<List<Integer>> subsetsWithDup(int[] nums) {
        Arrays.sort(nums); 
        boolean[] used=new boolean[nums.length];
        backtracking(nums,0,used);
        return result;
    }
    public void backtracking(int[] nums,int startIndex,boolean[] used){
        result.add(new ArrayList<>(path));
        for(int i=startIndex;i<nums.length;i++){
            if(i>0&&nums[i]==nums[i-1]&&used[i-1]==false){
                continue;
            }
            path.add(nums[i]);
            used[i]=true;
            backtracking(nums,i+1,used);
            path.remove(path.size()-1);
            used[i]=false;
        }
    }
}

整体思路是基于回溯算法,通过深度优先搜索枚举所有可能的子集组合,同时利用去重策略避免生成重复结果。程序中使用 result 保存最终结果集合,path 用来记录当前构造的子集。在 backtracking 方法中,每进入一层递归,都会先将当前路径加入结果集,这是因为子集问题中,每一个状态(节点)本身就是一个合法子集。随后通过 for 循环,从 startIndex 开始遍历数组,依次尝试选择每一个元素。为了避免重复子集,代码中使用了关键的去重逻辑:当当前元素与前一个元素相同(nums[i] == nums[i-1]),并且前一个元素在当前树层没有被使用(used[i-1] == false)时,说明这是同一层的重复选择,需要跳过,从而实现"树层去重"。如果可以选择当前元素,就将其加入 path,并标记为已使用,然后递归进入下一层搜索,起始位置更新为 i+1,保证每个元素只被使用一次。递归结束后,通过移除路径最后一个元素并重置 used[i] 来完成回溯操作,使程序可以继续探索其他分支。整个过程本质是在一棵决策树上进行搜索,并通过排序加条件判断有效避免重复结果。

47. 全排列 II

这题代码与上一题相似,不同的是这一题是排列,那么如[1,2]和[2,1]在这里是完全不同的,所以在for循环中,我们不再从startIndex开始,而是从0开始,同时还是要进行横向去重。代码如下:

复制代码
class Solution {
    List<List<Integer>> result=new ArrayList<>();
    List<Integer> path=new ArrayList<>();

    public List<List<Integer>> permuteUnique(int[] nums) {
        Arrays.sort(nums);
        boolean[] used=new boolean[nums.length];
        backtracking(nums,used);
        return result;
    }

    public void backtracking(int[] nums,boolean[] used){
        if(path.size()==nums.length){
            result.add(new ArrayList<>(path));
            return;
        }
        for(int i=0;i<nums.length;i++){
            if(i>0&&nums[i]==nums[i-1]&&used[i-1]==false){
                continue;
            }
            if(used[i]==true){
                continue;
            }
            used[i]=true;
            path.add(nums[i]);
            backtracking(nums,used);
            used[i]=false;
            path.remove(path.size()-1);
        }
    }
}

首先在 permuteUnique 方法中对数组进行排序,这是去重的关键前提,因为只有相同元素相邻,后续的剪枝条件才成立。然后定义一个 used 布尔数组,用来标记每个位置的元素在当前路径中是否已经被使用过,避免同一个元素被重复选取。回溯函数中,当路径 path 的长度等于数组长度时,说明已经形成一个完整排列,就将其加入结果集中。

在递归过程中,通过遍历每个位置的元素进行选择,如果当前元素已经被使用(used[i] == true),就直接跳过;而真正实现"去重"的关键在于这一句:当 i > 0 且当前元素 nums[i] 等于前一个元素 nums[i-1],并且前一个元素在当前递归路径中没有被使用(used[i-1] == false)时,说明这两个相同元素处于同一层递归中,如果此时再选择当前元素,就会产生重复排列,因此需要用 continue 跳过。这个条件本质是在控制"同一层不能重复选相同元素",而不是限制不同层的选择。

51. N 皇后

这道题的难点在于如何去搜这个二维的棋盘,如果我们采用暴力的解法,那么就要有n个for循环,这样太多了,所以我们可以使用回溯算法。

代码如下:

复制代码
class Solution {

    List<List<String>> result=new ArrayList<>();

    public List<List<String>> solveNQueens(int n) {
        char[][] chessboard=new char[n][n];
        for(int i=0;i<n;i++){
            Arrays.fill(chessboard[i],'.');
        }
        backtracking(chessboard,n,0);
        return result;
    }

    public void backtracking(char[][] chessboard,int n,int row){
        if(row==n){
            result.add(arrayToList(chessboard));
            return;
        }

        for(int i=0;i<n;i++){
            if(isValid(row,i,chessboard,n)){
                chessboard[row][i]='Q';
                backtracking(chessboard,n,row+1);
                chessboard[row][i]='.';
            }
        }
    }

    public boolean isValid(int row,int col,char[][] chessboard,int n){
        for(int i=0;i<row;i++){
            if(chessboard[i][col]=='Q')
            return false;
        }
        for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
             if(chessboard[i][j]=='Q')
            return false;
        }
        for(int i=row-1,j=col+1;i>=0&&j<n;i--,j++){
             if(chessboard[i][j]=='Q')
            return false;
        }
        return true;
    }

    public List<String> arrayToList(char[][] chessboard){
        List<String> list=new ArrayList<>();
        for(char[] row:chessboard){
            list.add(new String(row));
        }
        return list;
    }
}

首先在 solveNQueens 方法中初始化一个 n × n 的棋盘,用字符 '.' 表示空位,然后从第 0 行开始调用回溯函数 backtracking。回溯函数的含义是:在当前 row 行,尝试在每一列放置一个皇后。如果当前行已经等于 n,说明前面每一行都成功放置了皇后,此时构成一个完整解,就把当前棋盘转换成字符串列表加入结果集中。

在回溯过程中,通过一个 for 循环遍历当前行的每一列位置,对于每个位置 (row, col),都会调用 isValid 方法判断是否可以放皇后。这个判断包括三部分:第一,检查当前列上方是否已有皇后;第二,检查左上对角线是否有皇后;第三,检查右上对角线是否有皇后。因为回溯是按行从上往下进行的,所以只需要检查"上方"的情况即可,不需要检查下方。

如果某个位置合法,就在该位置放置 'Q',然后递归处理下一行;当递归返回时,再将该位置恢复为 '.',这一步叫"回溯",目的是尝试其他可能的放法。通过这种"尝试---递归---撤销"的过程,可以枚举出所有可能的解。

最后,arrayToList 方法的作用是把当前棋盘从二维字符数组转换为 List<String> 的形式,这是题目要求的输出格式。每一行被转换成一个字符串,所有行组成一个解。

相关推荐
We་ct11 分钟前
LeetCode 5. 最长回文子串:DP + 中心扩展
前端·javascript·算法·leetcode·typescript
JAVA面经实录9173 小时前
Java企业级工程化·终极完整版背诵手册(无遗漏、全覆盖、面试+落地通用)
java·开发语言·面试
王老师青少年编程4 小时前
csp信奥赛C++高频考点专项训练之贪心算法 --【哈夫曼贪心】:合并果子
c++·算法·贪心·csp·信奥赛·哈夫曼贪心·合并果子
叼烟扛炮5 小时前
C++第二讲:类和对象(上)
数据结构·c++·算法·类和对象·struct·实例化
天疆说5 小时前
【哈密顿力学】深入解读航天器交会最优控制中的Hamilton函数
人工智能·算法·机器学习
许彰午5 小时前
CacheSQL(二):主从复制——OpLog 环形缓冲区与故障自动恢复
java·数据库·缓存
wuweijianlove6 小时前
关于算法设计中的代价函数优化与约束求解的技术7
算法
leoufung6 小时前
LeetCode 149: Max Points on a Line - 解题思路详解
算法·leetcode·职场和发展
样例过了就是过了6 小时前
LeetCode热题100 最长公共子序列
c++·算法·leetcode·动态规划
HXDGCL6 小时前
矩形环形导轨:自动化循环线的核心运动单元解析
运维·算法·自动化