回溯介绍及实战

一、介绍回溯

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

这个过程类似在一棵"决策树"中不断向下搜索,并在不符合条件时返回上层节点继续尝试其他分支。回溯算法通常用于求解组合、排列、子集、棋盘问题(如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> 的形式,这是题目要求的输出格式。每一行被转换成一个字符串,所有行组成一个解。

相关推荐
人道领域2 小时前
Day | 09 【苍穹外卖:订单售后业务】
java·数据库·后端
m0_662577972 小时前
模板编译期哈希计算
开发语言·c++·算法
m0_662577972 小时前
C++代码静态检测
开发语言·c++·算法
阿贵---2 小时前
编译器命令选项优化
开发语言·c++·算法
minji...2 小时前
Linux 进程间通信(一)进程间通信与匿名管道
linux·运维·服务器·数据结构·数据库·c++
add45a2 小时前
分布式计算C++库
开发语言·c++·算法
码农的小菜园2 小时前
Java线程池学习笔记
java·笔记·学习
2401_894241922 小时前
基于C++的数据库连接池
开发语言·c++·算法
阿贵---2 小时前
C++中的适配器模式
开发语言·c++·算法