目录
1. 没有重复项数字的全排列(中等)
1.1. 题目描述
1.2 解题思路
这道题目就是很典型的回溯类题目。
回溯其实也是暴力解法,但是又一些题目可以通过剪枝对算法进行优化,这道题目要找出所有的排列,其实还是
比较简单的。
算法的思路主要就是:选择与撤销
例如:1开头的有,[1,2,3],接着3撤销,2撤销,然后选择3,再选择2,就有了[1,3,2]。
整体用一个图来观看整个过程
1.3 代码实现
方法一:递归
permute:置换
backTrack:回溯
import java.util.*;
public class Solution {
// 存所有排列的集合
ArrayList<ArrayList<Integer>> res = new ArrayList<>();
public ArrayList<ArrayList<Integer>> permute(int[] num) {
// 存一种排列
LinkedList<Integer> list = new LinkedList<>();
// 递归进行
backTrack(num,list);
return res;
}
public void backTrack(int[] num, LinkedList<Integer> list){
// 当list中的长度等于数组的长度,则证明此时已经找到一种排列了
if(list.size() == num.length){
// add进返回结果集中
res.add(new ArrayList<>(list));
return;
}
// 遍历num数组
for(int i = 0; i < num.length; i++){
// 若当前位置中的数已经添加过了则跳过
if(list.contains(num[i]))
continue;
// 选择该数
list.add(num[i]);
// 继续寻找
backTrack(num,list);
// 撤销最后一个
list.removeLast();
}
}
}
方法二:非递归版
这种方法不使用递归,其实也是一个选择和撤销的过程,只是不使用递归来完成。
通过插入的方式,一次性找到所有的情况。
例如:第一次选择1,接着可以在1前面和后面插入2,则变为 1,2 和 2,1;接着可选择3,3插入到1,2中有三
种分别为 3,1,2;1,3,2;1,2,3;然后3插入2,1也有三种。
其实就是找到能插的位置,同一个数可以插在不同的位置,则构成了另外的排列。
public class Solution {
// 所有的排列结果集
ArrayList<ArrayList<Integer>> res = new ArrayList<>();
public ArrayList<ArrayList<Integer>> permute(int[] num) {
ArrayList<Integer> list = new ArrayList<>();
// 先对res中加入一个空的list,给第一次插入制造一个空间。
res.add(list);
// 整个循环的次数为num的元素个数
for(int i = 0; i < num.length; i++){
ArrayList<ArrayList<Integer>> tmp = new ArrayList<>();
// 遍历此时的排列结果
for(ArrayList<Integer> r:res){
// 根据集合的大小,使用for循环在可插入的位置进行插入
for(int j = 0; j < r.size()+1; j++){
// 在第j个位置插入
r.add(j,num[i]);
// 此时构成新的排列集合,可能是不完整的排列集合(例如:[1,2];[2,1]这类)
ArrayList<Integer> temp = new ArrayList<>(r);
// 放进去tmp临时集合中
tmp.add(temp);
// 将刚插入的数移除掉,为了将同样的这个插入不同的位置
r.remove(j);
}
}
// 最后赋给res进行返回
res = new ArrayList<>(tmp);
}
return res;
}
}
2. 有重复项数字的全排列(中等)
2.1. 题目描述
2.2. 解题思路
题目主要信息:
- 给定一组可能有重复数字的数组,输出该数组的全部排列
- 输出结果按照字典序升序排列
举一反三:
学习完本题的思路你可以解决如下题目:
知识点:递归与回溯
递归是一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层
转化为一个与原问题相似的规模较小的问题来求解。因此递归过程,最重要的就是查看能不能讲原本的问题分解
为更小的子问题,这是使用递归的关键。
如果是线型递归,子问题直接回到父问题不需要回溯,但是如果是树型递归,父问题有很多分支,我需要从子问
题回到父问题,进入另一个子问题。因此回溯是指在递归过程中,从某一分支的子问题回到父问题进入父问题的
另一子问题分支,因为有时候进入第一个子问题的时候修改过一些变量,因此回溯的时候会要求改回父问题时的
样子才能进入第二子问题分支。
思路:
这道题类似没有重复项数字的全排列,但是因为交换位置可能会出现相同数字交换的情况,出现的结果需要去
重,因此不便于使用交换位置的方法。
我们就使用临时数组去组装一个排列的情况:每当我们选取一个数组元素以后,就确定了其位置,相当于对数组
中剩下的元素进行全排列
添加在该元素后面,给剩余部分进行全排列就是一个子问题,因此可以使用递归。
- 终止条件: 临时数组中选取了n个元素,已经形成了一种排列情况了,可以将其加入输出数组中。
- 返回值: 每一层给上一层返回的就是本层级在临时数组中添加的元素,递归到末尾的时候就能添加全部元素。
- 本级任务: 每一级都需要选择一个不重复元素加入到临时数组末尾(遍历数组选择)。
回溯的思想也与没有重复项数字的全排列类似,对于数组[1,2,2,3],如果事先在临时数组中加入了1,后续子问题
只能是[2,2,3]的全排列接在1后面,对于2开头的分支达不到,因此也需要回溯:将临时数组刚刚加入的数字pop
掉,同时vis修改为没有加入,这样才能正常进入别的分支。
//标记为使用过
vis[i] = true;
//加入数组
temp.add(num[i]);
recursion(res, num, temp, vis);
//回溯
vis[i] = false;
temp.remove(temp.size() - 1);
具体做法:
- step 1:先对数组按照字典序排序,获取第一个排列情况。
- step 2:准备一个数组暂存递归过程中组装的排列情况。使用额外的vis数组用于记录哪些位置的数字被加入了。
- step 3:每次递归从头遍历数组,获取数字加入:首先根据vis数组,已经加入的元素不能再次加入了;同时,如果当前的元素num[i]与同一层的前一个元素num[i-1]相同且num[i-1]已经用,也不需要将其纳入。
- step 4:进入下一层递归前将vis数组当前位置标记为使用过。
- step 5:回溯的时候需要修改vis数组当前位置标记,同时去掉刚刚加入数组的元素,
- step 6:临时数组长度到达原数组长度就是一种排列情况。
图示:
2.3. 代码实现
递归+回溯(推荐使用)
结果列表
数组
临时列表
布尔数组
import java.util.*;
public class Solution {
public void recursion(ArrayList<ArrayList<Integer>> res, int[] num, ArrayList<Integer> temp, Boolean[] vis){
//临时数组满了加入输出
if(temp.size() == num.length){
res.add(new ArrayList<Integer>(temp));
return;
}
//遍历所有元素选取一个加入
for(int i = 0; i < num.length; i++){
//如果该元素已经被加入了则不需要再加入了
if(vis[i])
continue;
if(i > 0 && num[i - 1] == num[i] && !vis[i - 1])
//当前的元素num[i]与同一层的前一个元素num[i-1]相同且num[i-1]已经用过了
continue;
//标记为使用过
vis[i] = true;
//加入数组
temp.add(num[i]);
recursion(res, num, temp, vis);
//回溯
vis[i] = false;
temp.remove(temp.size() - 1);
}
}
public ArrayList<ArrayList<Integer>> permuteUnique(int[] num) {
//先按字典序排序
Arrays.sort(num);
Boolean[] vis = new Boolean[num.length];
Arrays.fill(vis, false);
ArrayList<ArrayList<Integer> > res = new ArrayList<ArrayList<Integer>>();
ArrayList<Integer> temp = new ArrayList<Integer>();
recursion(res, num, temp, vis);
return res;
}
}
3. 岛屿数量(中等)
3.1. 题目描述
3.2. 解题思路
题目主要信息:
- 给一个01矩阵,1代表是陆地,0代表海洋,如果两个1相邻,则这两个1属于同一个岛
- 只考虑上下左右为相邻
- 判断岛屿的个数
举一反三:
学习完本题的思路你可以解决如下题目:
3.3 代码实现
方法一:dfs(推荐使用)
知识点:深度优先搜索(dfs) 深度优先搜索一般用于树或者图的遍历,其他有分支的(如二维矩阵)也
适用。它的原理是从初始点开始,一直沿着同一个分支遍历,直到该分支结束,然后回溯到上一级继续
沿着一个分支走到底,如此往复,直到所有的节点都有被访问到。
思路:
矩阵中多处聚集着1,要想统计1聚集的堆数而不重复统计,那我们可以考虑每次找到一堆相邻的1,就将
其全部改成0,而将所有相邻的1改成0的步骤又可以使用深度优先搜索(dfs):当我们遇到矩阵的某个
元素为1时,首先将其置为了0,然后查看与它相邻的上下左右四个方向,如果这四个方向任意相邻元素
为1,则进入该元素,进入该元素之后我们发现又回到了刚刚的子问题,又是把这一片相邻区域的1全
部置为0,因此可以用递归实现。
//后续四个方向遍历
if(i - 1 >= 0 && grid[i - 1][j] == '1')
dfs(grid, i - 1, j);
if(i + 1 < n && grid[i + 1][j] == '1')
dfs(grid, i + 1,j);
if(j - 1 >= 0 && grid[i][j - 1] == '1')
dfs(grid, i, j - 1);
if(j + 1 < m && grid[i][j + 1] == '1')
dfs(grid, i, j + 1);
- 终止条件: 进入某个元素修改其值为0后,遍历四个方向发现周围都没有1,那就不用继续递归,返回即可,或者递归到矩阵边界也同样可以结束。
- 返回值: 每一级的子问题就是把修改后的矩阵返回,因为其是函数引用,也不用管。
- 本级任务: 对于每一级任务就是将该位置的元素置为0,然后查询与之相邻的四个方向,看看能不能进入子问题。
具体做法:
- step 1:优先判断空矩阵等情况。
- step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
- step 3:接着将该位置的1改为0,然后使用dfs判断四个方向是否为1,分别进入4个分支继续修改。
图示:
Java实现代码:
import java.util.*;
public class Solution {
//深度优先遍历与i,j相邻的所有1
public void dfs(char[][] grid, int i, int j) {
int n = grid.length;
int m = grid[0].length;
// 置为0
grid[i][j] = '0';
//后续四个方向遍历
if(i - 1 >= 0 && grid[i - 1][j] == '1')
dfs(grid, i - 1, j);
if(i + 1 < n && grid[i + 1][j] == '1')
dfs(grid, i + 1,j);
if(j - 1 >= 0 && grid[i][j - 1] == '1')
dfs(grid, i, j - 1);
if(j + 1 < m && grid[i][j + 1] == '1')
dfs(grid, i, j + 1);
}
public int solve (char[][] grid) {
int n = grid.length;
//空矩阵的情况
if (n == 0)
return 0;
int m = grid[0].length;
//记录岛屿数
int count = 0;
//遍历矩阵
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
//遍历到1的情况
if(grid[i][j] == '1'){
//计数
count++;
//将与这个1相邻的所有1置为0
dfs(grid, i, j);
}
}
}
return count;
}
}
方法二:bfs(扩展思路)
知识点:广度优先搜索(bfs)
广度优先搜索与深度优先搜索不同,它是将与某个节点直接相连的其它所有节点依次访问一次之后,再
往更深处,进入与其他节点直接相连的节点。bfs的时候我们常常会借助队列的先进先出,因为从某个节
点出发,我们将与它直接相连的节点都加入队列,它们优先进入,则会优先弹出,在它们弹出的时候再
将与它们直接相连的节点加入,由此就可以依次按层访问。
思路:
统计岛屿的方法可以和方法一同样遍历解决,为了去重我们还是要将所有相邻的1一起改成0,这时候同
样遍历连通的广度优先搜索(bfs)可以代替dfs。
具体做法:
- step 1:优先判断空矩阵等情况。
- step 2:从上到下从左到右遍历矩阵每一个位置的元素,如果该元素值为1,统计岛屿数量。
- step 3:使用bfs将遍历矩阵遇到的1以及相邻的1全部置为0:利用两个队列辅助(C++可以使用pair),每次队列进入第一个进入的1,然后遍历队列,依次探讨队首的四个方向,是否符合,如果符合则置为0,且位置坐标加入队列,继续遍历,直到队列为空。
图示:
Java实现代码:
import java.util.*;
public class Solution {
public int solve (char[][] grid) {
int n = grid.length;
//空矩阵的情况
if (n == 0)
return 0;
int m = grid[0].length;
//记录岛屿数
int count = 0;
//遍历矩阵
for(int i = 0; i < n; i++){
for(int j = 0; j < m; j++){
//遇到1要将这个1及与其相邻的1都置为0
if(grid[i][j] == '1'){
//岛屿数增加
count++;
grid[i][j] = '0';
//记录后续bfs的坐标
Queue<Integer> q1 = new LinkedList<Integer>();
Queue<Integer> q2 = new LinkedList<Integer>();
q1.offer(i);
q2.offer(j);
//bfs
while(!q1.isEmpty()){
int row = q1.poll();
int col = q2.poll();
//四个方向依次检查:不越界且为1
if(row - 1 >= 0 && grid[row - 1][col] == '1'){
q1.offer(row - 1);
q2.offer(col);
grid[row - 1][col] = '0';
}
if(row + 1 < n && grid[row + 1][col] == '1'){
q1.offer(row + 1);
q2.offer(col);
grid[row + 1][col] = '0';
}
if(col - 1 >= 0 && grid[row][col - 1] == '1'){
q1.offer(row);
q2.offer(col - 1);
grid[row][col - 1] = '0';
}
if(col + 1 < m && grid[row][col + 1] == '1'){
q1.offer(row);
q2.offer(col + 1);
grid[row][col + 1] = '0';
}
}
}
}
}
return count;
}
}
4. 字符串的排列(中等)
4.1. 题目描述
4.2. 题目分析
题目主要信息:
- 给定一个长度为n的字符串,求其中所有字符的全排列
- 字符串中可能有重复字符,打印顺序任意
- 字符串中只包含大小写字母
举一反三:
学习完本题的思路你可以解决如下题目:
4.3 解题思路
知识点:递归与回溯
递归是一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的
问题层层转化为一个与原问题相似的规模较小的问题来求解。因此递归过程,最重要的就是查看能不能
讲原本的问题分解为更小的子问题,这是使用递归的关键。
如果是线型递归,子问题直接回到父问题不需要回溯,但是如果是树型递归,父问题有很多分支,我需
要从子问题回到父问题,进入另一个子问题。因此回溯是指在递归过程中,从某一分支的子问题回到父
问题进入父问题的另一子问题分支,因为有时候进入第一个子问题的时候修改过一些变量,因此回溯的
时候会要求改回父问题时的样子才能进入第二子问题分支。
思路:
都是求元素的全排列,字符串与数组没有区别,一个是数字全排列,一个是字符全排列,因此大致思路
与有重复项数字的全排列类似,只是这道题输出顺序没有要求。但是为了便于去掉重复情况,我们还是
应该参照数组全排列,优先按照字典序排序,因为排序后重复的字符就会相邻,后续递归找起来也很方
便。
使用临时变量去组装一个排列的情况:每当我们选取一个字符以后,就确定了其位置,相当于对字符串
中剩下的元素进行全排列添加在该元素后面,给剩余部分进行全排列就是一个子问题,因此可以使用递
归。
- 终止条件: 临时字符串中选取了n个元素,已经形成了一种排列情况了,可以将其加入输出数组中。
- 返回值: 每一层给上一层返回的就是本层级在临时字符串中添加的元素,递归到末尾的时候就能添加全部元素。
- 本级任务: 每一级都需要选择一个元素加入到临时字符串末尾(遍历原字符串选择)。
递归过程也需要回溯,比如说对于字符串“abbc”,如果事先在临时字符串中加入了a,后续子问题只能
是"bbc"的全排列接在a后面,对于b开头的分支达不到,因此也需要回溯:将临时字符串刚刚加入的字符
去掉,同时vis修改为没有加入,这样才能正常进入别的分支。
具体做法:
- step 1:先对字符串按照字典序排序,获取第一个排列情况。
- step 2:准备一个空串暂存递归过程中组装的排列情况。使用额外的vis数组用于记录哪些位置的字符被加入了。
- step 3:每次递归从头遍历字符串,获取字符加入:首先根据vis数组,已经加入的元素不能再次加入了;同时,如果当前的元素str[i]与同一层的前一个元素str[i-1]相同且str[i-1]已经用,也不需要将其纳入。
- step 4:进入下一层递归前将vis数组当前位置标记为使用过。
- step 5:回溯的时候需要修改vis数组当前位置标记,同时去掉刚刚加入字符串的元素,
- step 6:临时字符串长度到达原串长度就是一种排列情况。
图示:
4.4 代码实现
方法:递归+回溯(推荐使用)
import java.util.*;
public class Solution {
public void recursion(ArrayList<String> res, char[] str, StringBuffer temp, boolean[] vis){
//临时字符串满了加入输出
if(temp.length() == str.length){
res.add(new String(temp));
return;
}
//遍历所有元素选取一个加入
for(int i = 0; i < str.length; i++){
//如果该元素已经被加入了则不需要再加入了
if(vis[i])
continue;
if(i > 0 && str[i - 1] == str[i] && !vis[i - 1])
//当前的元素str[i]与同一层的前一个元素str[i-1]相同且str[i-1]已经用过了
continue;
//标记为使用过
vis[i] = true;
//加入临时字符串
temp.append(str[i]);
recursion(res, str, temp, vis);
//回溯
vis[i] = false;
temp.deleteCharAt(temp.length() - 1);
}
}
public ArrayList<String> Permutation(String str) {
ArrayList<String> res = new ArrayList<String>();
if(str == null || str.length() == 0)
return res;
//转字符数组
char[] charStr = str.toCharArray();
// 按字典序排序
Arrays.sort(charStr);
boolean[] vis = new boolean[str.length()];
//标记每个位置的字符是否被使用过
Arrays.fill(vis, false);
StringBuffer temp = new StringBuffer();
//递归获取
recursion(res, charStr, temp, vis);
return res;
}
}
5. N皇后问题(较难)
5.1. 题目描述
5.2. 解题思路
题目主要信息:
- 在一个n∗n的棋盘上要摆放n个皇后,求摆的方案数,不同位置就是不同方案数
- 摆放要求:任何两个皇后不同行,不同列也不在同一条斜线上
举一反三:
学习完本题的思路你可以解决如下题目:
知识点:递归与回溯
递归是一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的
问题层层转化为一个与原问题相似的规模较小的问题来求解。因此递归过程,最重要的就是查看能不能
讲原本的问题分解为更小的子问题,这是使用递归的关键。
如果是线型递归,子问题直接回到父问题不需要回溯,但是如果是树型递归,父问题有很多分支,我需
要从子问题回到父问题,进入另一个子问题。因此回溯是指在递归过程中,从某一分支的子问题回到父
问题进入父问题的另一子问题分支,因为有时候进入第一个子问题的时候修改过一些变量,因此回溯的
时候会要求改回父问题时的样子才能进入第二子问题分支。
n个皇后,不同行不同列,那么肯定棋盘每行都会有一个皇后,每列都会有一个皇后。如果我们确定了第
一个皇后的行号与列号,则相当于接下来在n−1n-1n−1行中查找n−1n-1n−1个皇后,这就是一个子问
题,因此使用递归:
- 终止条件: 当最后一行都被选择了位置,说明n个皇后位置齐了,增加一种方案数返回。
- 返回值: 每一级要将选中的位置及方案数返回。
- 本级任务: 每一级其实就是在该行选择一列作为该行皇后的位置,遍历所有的列选择一个符合条件的位置加入数组,然后进入下一级。
具体做法:
- step 1:对于第一行,皇后可能出现在该行的任意一列,我们用一个数组pos记录皇后出现的位置,下标为行号,元素值为列号。
- step 2:如果皇后出现在第一列,那么第一行的皇后位置就确定了,接下来递归地在剩余的n−1行中找n−1个皇后的位置。
- step 3:每个子问题检查是否符合条件,我们可以对比所有已经记录的行,对其记录的列号查看与当前行列号的关系:即是否同行、同列或是同一对角线。
图示:
5.3. 代码实现
方法:递归(推荐使用)
import java.util.*;
public class Solution {
private int res;
//判断皇后是否符合条件
public boolean isValid(int[] pos, int row, int col){
//遍历所有已经记录的行
for(int i = 0; i < row; i++){
//不能同行同列同一斜线
if(row == i || col == pos[i] || Math.abs(row - i) == Math.abs(col - pos[i]))
return false;
}
return true;
}
//递归查找皇后种类
public void recursion(int n, int row, int[] pos){
//完成全部行都选择了位置
if(row == n){
res++;
return;
}
//遍历所有列
for(int i = 0; i < n; i++){
//检查该位置是否符合条件
if(isValid(pos, row, i)){
//加入位置
pos[row] = i;
//递归继续查找
recursion(n, row + 1, pos);
}
}
}
public int Nqueen (int n) {
res = 0;
//下标为行号,元素为列号,记录皇后位置
int[] pos = new int[n];
Arrays.fill(pos, 0);
//递归
recursion(n, 0, pos);
return res;
}
}
复杂度分析:
- 时间复杂度:O(n∗n!),isValid函数每次检查复杂度为O(n),递归过程相当于对长度为nnn的数组求全排列,复杂度为O(n!)
- 空间复杂度:O(n),辅助数组和栈空间最大为O(n)
6. 括号生成(中等)
6.1. 题目描述
6.2. 解题思路
题目主要信息:
- 求n对括号的全部合法组合,左右括号之间任意组合,只要合法就行
- 需要输出所有的结果
举一反三:
学习完本题的思路你可以解决如下题目:
知识点:递归与回溯
递归是一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的
问题层层转化为一个与原问题相似的规模较小的问题来求解。因此递归过程,最重要的就是查看能不能
讲原本的问题分解为更小的子问题,这是使用递归的关键。
如果是线型递归,子问题直接回到父问题不需要回溯,但是如果是树型递归,父问题有很多分支,我需
要从子问题回到父问题,进入另一个子问题。因此回溯是指在递归过程中,从某一分支的子问题回到父
问题进入父问题的另一子问题分支,因为有时候进入第一个子问题的时候修改过一些变量,因此回溯的
时候会要求改回父问题时的样子才能进入第二子问题分支。
思路:
相当于一共n个左括号和n个右括号,可以给我们使用,我们需要依次组装这些括号。每当我们使用一个
左括号之后,就剩下n−1个左括号和n个右括号给我们使用,结果拼在使用的左括号之后就行了,因此后
者就是一个子问题,可以使用递归:
- 终止条件: 左右括号都使用了n个,将结果加入数组。
- 返回值: 每一级向上一级返回后续组装后的字符串,即子问题中搭配出来的括号序列。
- 本级任务: 每一级就是保证左括号还有剩余的情况下,使用一次左括号进入子问题,或者右括号还有剩余且右括号使用次数少于左括号的情况下使用一次右括号进入子问题。
但是这样递归不能保证括号一定合法,我们需要保证左括号出现的次数比右括号多时我们再使用右括号
就一定能保证括号合法了,因此每次需要检查左括号和右括号的使用次数。
//使用一次左括号
if(left < n){
recursion(left + 1, right, temp + "(", res, n);
}
//使用右括号个数必须少于左括号
if(right < n && left > right){
recursion(left, right + 1, temp + ")", res, n);
}
具体做法:
- step 1:将空串与左右括号各自使