前言
今日要讲的是递归与回溯,很多人可能还不懂递归与回溯的区别,那么今天我就来讲解一下,他们到底有什么区别。且如何克服大家对递归的恐惧。
递归
相信递归调用大家都很熟悉了,其实递归就是在一个方法中,再次调用这个方法,像是一个盒子中在套一个盒子,而且盒子的装的东西除了参数其它的逻辑都是一样的。
那么何时使用递归:
那就是出现重复子问题的时候,这时我们就可以采用递归的方式。
如何才能写好一个递归:
1.先找好一个相同子问题,这对应着函数头的设计
2.找到每个子问题要做什么如何解决问题,这对应着函数体
3.想清楚函数的截止条件,这对应着函数的出口
那么下面我们可以用一道题来理解我们的递归的使用:
两两交换链表中的节点
那么首先我们先根据我们的三步来思考
1.先找好一个相同子问题,这对应着函数头的设计
我们思考我们子问题就是,给我们一个节点,交换该节点和下一个节点的指向
那么我们就可以清楚的知道我们的函数头就是要一个链表的节点即可
2.找到每个子问题要做什么如何解决问题,这对应着函数体
如何解决呢,我们就先将head的下个节点next和下下一个节nnext(作为下一次递归的head)点给拿出来用变量存储着。
然后开始更改他们的指向,然后继续将nnext递归下去即可
3.想清楚函数的截止条件,这对应着函数的出口
递归截止的条件又是什么?应为我们要交换吗,那如果节点已经到结尾或者为null是不是就无法交换了,所以这就是递归的截止条件。
回溯
回溯其实就是递归向上返回的过程中,我们要清除掉下一层递归对该层递归产生的影响
可能有人就问了又要递归又要清楚痕迹这是不是矛盾了,其实这并不矛盾,首先通常递归产生的结果我们已经用全局变量存储起来,而消除痕迹这是因为我们下面的操作就是需要原本的数据,比如我们有个循环,循环里的就是递归函数的调用,那么一次循环结束就要消除痕迹,以便下一次循环时数据还是原本的样子没有改变。又或是继续返回上一层时,上一层需要的也是没有被改变的数据所以我们才需要回溯。
回溯题的基本解题步骤:
1.画出决策树
2.创建全局变量
3.写出递归函数
4.剪枝
那么接下来我将给出大部分的题来让大家理解回溯,练习回溯题:
子集(数组元素不同)
子集也可以说是递归的经典问题了,首先我们按照高中知识都知道子集就是从arr这个大的集合中,选择或不选一个元素然后就可以组合成不同的子集总共·有2^n个子集。
其中我上面其实已经点出了主要关键了,那就是选与不选,也就是说我们只需要对每个元素分别进行选与不选的抉择就能得到2^n个子集。
那么具体代码如何实现呢,那么接下来我就一一讲解。
如下:
首先我们定义两个全局变量,其中一个是要返回的答案ret,另一个list则是用来存储我们递归过程中得到的各个子集
然后开始定义递归函数,这里我们首先要知道我们当前层次是决定选哪个数组,我们这里用i来表示,不选我们则直接进入到我们的函数,选这个数则先将当前元素加入到我们的list中,注意下面就设计到我们的回溯了,当我们选择完当前元素要记得回溯到原样,即把选的元素从list中去掉,为什么要回溯呢,因为如果不回溯就会影响上一层的选择,比如你当前返回的递归是第一个dfs的调用,而接下来是要继续进行递归选择了这个元素的,所以必须要回溯,当我们遍历完arr数组时即代表我们的选择结束,已经没有元素可以在供我们选择了,所以我们直接加入ret。
class Solution {
List<Integer> list=new ArrayList();
List<List<Integer>> ret=new ArrayList<>();
public List<List<Integer>> subsets(int[] nums) {
dfs(nums,0);
return ret;
}
public void dfs(int[] nums,int i ){
if(i==nums.length){
ret.add(new ArrayList(list));
return;
}
dfs(nums,i+1);
list.add(nums[i]);
dfs(nums,i+1);
list.remove(list.size()-1);
}
}
全排列(元素互不相同)
我们这里要求的是排列而且还是长度和原数组的长度,即全排列,那么我们只需要在每一层选择我们的袁术组中没被选择的元素即可,那么如何实现呢,那么我i们只需要一个vis数组(boolean类型),对于哪个元素已经选择我们直接将其下标赋值为true。即可,然后在我们选择完后就需要回溯了,应为我们这里是一个循环,每次循环都进行递归,所以必须回溯。
class Solution {
List<List<Integer>> ret=new ArrayList<>();
public List<List<Integer>> permute(int[] nums) {
List<Integer> list=new ArrayList<>();
dfs(nums,new boolean[nums.length],list);
return ret;
}
public void dfs(int[] nums,boolean[] vis, List<Integer> list){
int n=nums.length;
if(list.size()==n){
ret.add(new ArrayList(list));
return;
}
for(int i=0;i<n;i++){
if(!vis[i]){
list.add(nums[i]);
vis[i]=true;
//不复制不能在这返回还是得回溯,不然影响i+1
dfs(nums,vis,list);
list.remove(list.size()-1);
vis[i]=false;
}
}
}
}
全排列二(元素有相同)
这一题与上一题不同的是,这一题是有重复元素的,而答案要求不能有重复的全排列。这与上一题不同的就是我们需要对相同的全排列去重。
首先我们要知道重复的原因是什么,不就是其实在这个两个位置中,放着两个相同的元素。因为这两个元素对应的下标不同,所以会出现两次这样的情况,那么我们如何杜绝呢,那么我们就只需要让后面的元素在挑选时直接跳过,即这个位置不在允许在次选择这个元素了,那么如何找到当前元素是重复元素呢,这是排序就起到了大作用了,我们排序后,相同元素是贴近的,所以当我们在当前位置挑选放哪个元素时,发现前面的元素和当前元素相同,且还上一个元素没被选过这就说明这个元素不能在这个位置,如果在放置,就会造成重复。
class Solution {
List<List<Integer>> ret;
List<Integer> list;
boolean[] vis;
public List<List<Integer>> permuteUnique(int[] nums) {
ret=new ArrayList<>();
list=new ArrayList<>();
int n=nums.length;
vis=new boolean[n];
Arrays.sort(nums);
dfs(nums,0);
return ret;
}
private void dfs(int[] nums,int pos){
if(pos==nums.length){
ret.add(new ArrayList(list));
return;
}
for(int i=0;i<nums.length;i++){
if(vis[i]||i>0&&nums[i]==nums[i-1]&&!vis[i-1]) continue;
list.add(nums[i]);
vis[i]=true;
dfs(nums,pos+1);
vis[i]=false;
list.remove(list.size()-1);
}
}
}
单词搜索
这里就是简单的dfs,上下左右,开始寻找下一个单词字母,如果这个位置选了字母但是下面继续递归返回的结果是false,那么我们就回溯,寻找其它三个方位是否存在要找的单词。
class Solution {
private static final int[] dx = {0, 0, 1, -1};
private static final int[] dy = {1, -1, 0, 0};
public boolean exist(char[][] board, String word) {
if (board == null || board.length == 0 || word == null || word.length() == 0) {
return false;
}
int m = board.length;
int n = board[0].length;
char[] wordArr = word.toCharArray(); // 转为字符数组,访问更快
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (board[i][j] == wordArr[0]) {
if (dfs(board, wordArr, i, j, 0)) {
return true;
}
}
}
}
return false;
}
private boolean dfs(char[][] board, char[] word, int i, int j, int index) {
if (index == word.length - 1) {
return true; // 全部字符匹配完成
}
char originalChar = board[i][j];
board[i][j] = '#'; // 标记为已访问
for (int k = 0; k < 4; k++) {
int x = i + dx[k];
int y = j + dy[k];
if (x >= 0 && x < board.length && y >= 0 && y < board[0].length
&& board[x][y] == word[index + 1]) {
if (dfs(board, word, x, y, index + 1)) {
return true;
}
}
}
board[i][j] = originalChar; // 回溯,恢复字符
return false;
}
}
解数独
这一题与我们之前的递归回溯相当不同,前几题我们都是对于一个一维的数组进行选择,但这里是二维的,并且还要求在3x3的宫格数的要求,所以我们这一题当中的boolean数组比较多。且因为我们要确定改行该列是否能填9个数字中的一个,那么我们的函数头就必须带上col来确定列,不能像一维一样只用一个pos变量。
其中比较妙的是/3这个操作我们可以直接用行和列直接得到这个数位于哪个3x3的宫格,并且我们的记录数组也是使用boxUsed = new boolean[3][3][10],这么定义的。
其它就没有什么难的了,回溯的函数中只要确定该行和该列是否能填,且继续往下填,是否能完成该9x9数独的填充,不能则回溯。
class Solution {
boolean[][] colUsed; // 记录每列数字是否使用
boolean[][] rowUsed; // 记录每行数字是否使用
boolean[][][] boxUsed; // 记录每个3×3宫格数字是否使用
public void solveSudoku(char[][] board) {
colUsed = new boolean[9][10]; // 数字1~9,索引1~9
rowUsed = new boolean[9][10];
boxUsed = new boolean[3][3][10];
// 初始化已存在的数字
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (board[i][j] != '.') {
int num = board[i][j] - '0';
rowUsed[i][num] = true;
colUsed[j][num] = true;
boxUsed[i / 3][j / 3][num] = true;
}
}
}
backtrack(board, 0, 0); // 从(0,0)开始填
}
private boolean backtrack(char[][] board, int row, int col) {
if (row == 9) {
return true; // 填完所有行,成功
}
if (col == 9) {
return backtrack(board, row + 1, 0); // 换到下一行
}
if (board[row][col] != '.') {
return backtrack(board, row, col + 1); // 已有数字,跳过
}
// 尝试填入1~9
for (int num = 1; num <= 9; num++) {
if (!rowUsed[row][num] && !colUsed[col][num] && !boxUsed[row / 3][col / 3][num]) {
board[row][col] = (char) (num + '0');
rowUsed[row][num] = true;
colUsed[col][num] = true;
boxUsed[row / 3][col / 3][num] = true;
if (backtrack(board, row, col + 1)) {
return true; // 递归成功,直接返回
}
// 回溯,撤销选择
board[row][col] = '.';
rowUsed[row][num] = false;
colUsed[col][num] = false;
boxUsed[row / 3][col / 3][num] = false;
}
}
return false; // 当前格子无法填入任何数字,回溯
}
}
N皇后
N皇后也是一个比较经典的问题。
那么其实N皇后棘手的问题是对角的问题,那么我们除了用一个vis确定该列是否放置了皇后,还需要用两个boolean来记录两个左右对角线是否也放置了皇后,解决了这个问题我们就可以像解决之前的问题一样解决这个问题了,那么我们怎么用两个一维数组来记录对角呢。
那么我们其实我们仔细观察就发现,我们一个对角线的横纵坐标之间的加减存在某种关系,比如我们的左下方向的对角线 他们的横纵坐标相加(i+x)是定值,总共2*(n-1)+1条左下方向对角线,其中每个对角线的定值都是不一样的,所以我们就可以利用这个性质就可以记录哪条对角线里存在皇后了。有对角线同理i-x是定值,需要注意的是当i-x是负数时转换为正数加上n(区分于i-x是正数的情况所以要加n,即-1要放在1+n里,而不是1,因为1已经有其它对角线占用了)
然后面就是简单的循环递归尝试是否能正确放置皇后,不能就回溯。
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
class Solution {
List<List<String>> ret;
List<String> list;
boolean[] vis;
boolean[] dig1;
boolean[] dig2;
public List<List<String>> solveNQueens(int n) {
ret=new ArrayList<>();
list=new ArrayList<>();
vis=new boolean[n];
dig1=new boolean[2*n];
dig2=new boolean[2*n];
backtrack(n,0);
return ret;
}
private void backtrack(int n,int x){
if(list.size()==n){
ret.add(new ArrayList(list));
return;
}
for(int i=0;i<n;i++){
int d1=i+x;
int d2=i-x;
if(d2<0) d2=-d2+n;
if(!vis[i]&&!dig1[d1]&&!dig2[d2]){
char[] s=new char[n];
Arrays.fill(s,'.');
s[i]='Q';
vis[i]=true;
dig1[d1]=true;
dig2[d2]=true;
list.add(String.valueOf(s));
backtrack(n,x+1);
list.remove(list.size()-1);
vis[i]=false;
dig1[d1]=false;
dig2[d2]=false;
}
}
}
}