DFS 深度优先搜索
深度优先遍历
搜索!= 递归
搜索可以使用循环的方式来做。
搜索是一种算法思想没具体的实现使用递归或者迭代都可以
什么是回溯
回溯搜索是深度优先搜索(DFS)的一种
关于到底什么是回溯,百度百科说的。
这么一看其实就是找出所有可能的情况,但是比起普通的暴力搜索来看,有点就是会适可而止,当发现一旦继续进行下去不满足要求的时候,就退回到上一步重新枚举。
电话号码的组合
思路一: 使用队列的做法
class Solution {
public List<String> res = new LinkedList<>();
public List<String> letterCombinations(String digits) {
List<String> list = new LinkedList<>();
if(digits == null || digits.length() == 0) return list;
HashMap<Character, String> map = new HashMap<>();
//初始化map结构
map.put('2', "abc");
map.put('3', "def");
map.put('4', "ghi");
map.put('5', "jkl");
map.put('6', "mno");
map.put('7', "pqrs");
map.put('8', "tuv");
map.put('9', "wxyz");
Queue<String> queue = new LinkedList<>();
for(Character num : digits.toCharArray()){
//获取到每一个数字对应的字母组合
int size = queue.size();
//刚开始的时候队列是空的 那么就放入队列
if(size == 0){
for(Character ch :map.get(num).toCharArray()){
queue.add(ch.toString());
}
}else {
//如果不是空的 就要把之前的已有个数记录下来 然后在后面拼接以后进行一个添加
//依次拿出队列的元素进行拼接
for(int i = 0 ;i< size;i++){
String head = queue.poll();
for(Character ch :map.get(num).toCharArray()){
queue.add(head+ch);
}
}
}
}
//然后把队列里面的元素放回到 list里面
list.addAll(queue);
return list;
}
}
思路二:使用回溯的做法
一般出现用来“求解所有组”合,会考虑到使用回溯的做法。
回溯算法用于寻找所有的可行解,如果发现一个解不可行,则会舍弃不可行的解。在这道题中,由于每个数字对应的每个字母都可能进入字母组合,因此不存在不可行的解,直接穷举所有的解即可。
需要一个哈希表预先对数字进行存储。然后枚举字符串里面的每一个数字(一层for),通过哈希表的映射关系找到对应的字符,然后枚举每一个字符(两层for),之后枚举上一个状态的每一个字符,依次尾插字符(三层for)。
class Solution {
public List<String> res = new LinkedList<>();
public List<String> letterCombinations(String digits) {
List<String> list = new LinkedList<>();
HashMap<Character,String> map = new HashMap<>();
//初始化map结构
map.put('2',"abc");
map.put('3',"def");
map.put('4',"ghi");
map.put('5',"jkl");
map.put('6',"mno");
map.put('7',"pqrs");
map.put('8',"tuv");
map.put('9',"wxyz");
//判定特殊例子
if(digits.length() == 0 ) return list;
list .add("");
//枚举每一个数字
for(Character num : digits.toCharArray()){
//枚举每一数字对应的字符
List<String> now = new LinkedList<>();
for(Character ch :map.get(num).toCharArray() ){
//枚举之前的字符串 然后依次添加进去
// 遍历上一个状态的list 然后 对list的每一个字符串进行添加
for(String s : list){
now.add(s+ch);
}
list = now;
}
return list;
}
}
单词搜索
这个题感觉搜索和动态规划都可以,当数据范围比较小可以用搜索,数据范围比较大的时候,可以用动态规划。
通过这个题:首先要做到感受回溯的思想,同时给掌握遍历设计技巧( 比如四个方向的坐标设计和遍历过的字符的设计,以及最后的回溯)
class Solution {
//枚举四个方向的技巧 向量 上下左右
int[] dx = {-1,1,0,0};
int[] dy = {0,0,-1,1};
int level = 0;
int column = 0;
public boolean exist(char[][] board, String word) {
if(board == null || board[0].length == 0) return false;
if(word == null) return false;
level = board.length;
column = board[0].length;
//递归一个个的字母搜索
char[] original = word.toCharArray();
//枚举起点
for(int i = 0; i< level ;i++){
for(int j = 0 ;j< column;j++){
if(dfs(board,i,j,word,0)) return true;
}
}
//表示的是所以的起点都没有枚举到 那么返回false
return false;
}
/**
*
* @param board
* @param i 当前枚举的坐标
* @param j
* @param word 目标单词
* @param m 目标单词的第几位
* @return
*/
private boolean dfs(char[][] board, int i, int j, String word, int m) {
//如果首个单词都不相等 那么就返回false
if(board[i][j] != word.charAt(m)) return false;
//这个条件表示 字符相等且已经走到了最后一个
if(m == word.length()-1) return true;
//然后继续dfs 这个单词的上下左右 但是不是四个方向都可以 得排除哪个从哪里来的位置
//对应的位置的坐标才不可以枚举
//对已经使用过的位置进行一个标记
board[i][j] = '.';
//然后枚举上下左右的四个方向
for (int count = 0; count < 4; count++) {
int a = i + dx[count];
int b = j + dy[count];
// 其实可以不写 board [a] [b] != '.' 因为 肯定字符里面不会有这个字母 所以一定不会匹配到
if(a >= 0 && a < level && b >= 0 && b < column && board[a][b] != '.') {
if (dfs(board,a,b,word,m+1) ) return true;
}
}
//然后需要回溯也就是恢复初始状态
board[i][j] = word.charAt(m);
return false;
}
}
恢复现场 要理解这里的状态是什么:
如果这里的状态是整个棋盘,那么就是要恢复现场。
如果这里的状态是一个棋盘上面的一个格子的话,那么不需要恢复现场。
全排列
思路一:枚举每一个位置的数字
class Solution {
int size = 0;
//定义一个boolean 数组来表示当前的元素是否遍历过
boolean[] arr ;
//用来存放最终的结果list
List<List<Integer>> res = new LinkedList<>();
List<Integer> now = new LinkedList<>();
public List<List<Integer>> permute(int[] nums) {
//枚举每一个数字作为全排列的qidia
size = nums.length;
arr = new boolean[size];
//起点就是枚举第一个位置放哪一个元素
dfs(nums,0);
return res;
}
private void dfs(int[] nums, int i) {
//表示的是当前的方案
if(i == size) {
res.add(new LinkedList<>(now));
return;
}
//i 号位置可以存放哪些元素进行枚举
for(int j = 0 ;j < size;j++){
if(!arr[j]) {
//当前的元素已经遍历过了
arr[j] = true;
now.add(nums[j]);
dfs(nums, i + 1);
//恢复现场 回溯已经加入now的结点的标记 同时在now中删除这个结点
//回溯的过程是和上面添加的过程是相对称的
now.remove(now.size()-1);
arr[j] = false;
}
}
}
}
里面的一个坑真的很重要
这个题解可能暂时只能看懂一部分,但是如果刷完了这十道题,应该可以有不一样的收获吧!
思路二:枚举每一个数字的可能位置
全排列II
如何保证全排类不会出现重复呢
人为规定相同数字的相对顺序 不变 保证相同数字的稳定性是这个意思吗
难度晋升的有点快,本人有一点吃不消了。
子集
思路一:递归 DFS 递归和回溯到底有什么不一样
常规的想法就是不断地递归,对第一个数字选择还是不选择,然后第二个数字选择还是不选择,依次递归下去,知道所有的数字都遍历过一遍,就可以返回了
其实就是这个图进行dfs回溯的方式。其实也就是一种中序遍历的方式。
class Solution {
List<List<Integer>> res = new LinkedList<>();
List<Integer> now = new LinkedList<>();
int high = 0;
int n = 0;
//用递归 也就是DFS来实现
//依次递归的是每一个元素
public List<List<Integer>> subsets(int[] nums) {
n = nums.length;
dfs(nums,0);
return res;
}
private void dfs(int[] nums, int i) {
if(i == n) {
res.add(new LinkedList<>(now));
return;
}
//如果不选择这个元素 就继续进行 直接往下进行递归
//这句话和下面的代码块可以交换顺序 没有什么本质的区别
dfs(nums,i+1);
//如果选择这个元素 将元素进行添加
now.add(nums[i]);
dfs(nums,i+1);
//回溯到根节点
now.remove(now.size()-1);
}
}
思路二:位运算 for循环的操作,可以是一种迭代。这个思路真的很牛逼!!!
== 枚举集合的时候 可以使用二进制的形式来枚举==
集合的每个元素,都有可以选或不选,用二进制和位运算,可以很好的表示。
为什么这么说呢,上图!集合中如果有三个元素,那么需要三个二进制来表示每一位数字选择还是不选择。
class Solution {
List<List<Integer>> res = new LinkedList<>();
List<Integer> now = new LinkedList<>();
int high = 0;
public List<List<Integer>> subsets(int[] nums) {
int n = nums.length;
high = (int) Math.pow(2,n)-1;
//使用for循环的方式来做
//用来循环的是不同的方案组合数字
for (int i = 0; i<=high;i++){
//当前的数字
int number = i;
//判断第j位数字是否是1
for(int j = 0;j < n;j++){
number = i;
int bit = (number >> j)& 1;
if(bit == 1){
now.add(nums[j]);
}
}
res.add(new LinkedList<>(now));
now.clear();
}
return res;
}
}
子集II
这个题有一点点难理解
递归的树形状也不太好画,但是可以大致分析一下
思路: 枚举每一个数字的出现的次数
虽然画不出递归的树,但是可以想一下
- 循环第一次,第一次加入到res里面的时候,整个now为null,然后进行第一个3的加入。
- 之后循环第二次,整个now为{3} 然后加入第二个3
- 之后进入循环第三次,整个now为{3,3} 然后加入第三个3
- 之后进入循环第四次now为{3,3,3} 然后加入第四个三
- 退出循环 这个时候虽然now {3,3,3,3} 但是没有继续递归,这个结果不会加入res中,所以不会产生影响
class Solution {
List<List<Integer>> res = new LinkedList<List<Integer>>();
List<Integer> now = new LinkedList<>();
HashMap<Integer,Integer> map ;
int n = 0;
//用来获得所有的key
static Set<Integer> keySet ;
// 利用 start + 1 可以不断获得下一个key
static int[] key ;
public List<List<Integer>> subsetsWithDup(int[] nums) {
//先对数组进行排序将相同的数字放在一起
Arrays.sort(nums);
map = new HashMap<>();
//统计每个出现出现的次数 然后枚举数字
for (int n : nums) {
map.put(n, map.getOrDefault(n,0) + 1);
}
//然后枚举map里面的每一个key
//是set类型的
keySet= map.keySet();
n =map.keySet().size();
//或者是用一个数组存放起来 便于使用index检索
key = new int[n];
int i =0;
for(int num : keySet){
key[i++] = num;
}
//或者是用一个数组存放起来 便于使用index检索
dfs(nums,0);
return res;
}
/**
*
* @param nums
* @param start 对第几个map里面key进行枚举
*/
private void dfs(int[] nums, int start) {
if(start == n){
//所有的数字都枚举完了
res.add(new LinkedList<>(now));
return;
}
//枚举每一个数字对应的选择次数 这个好像写不了
// 说明为什么这里要写等号
// 以{1,2,2,3,3,3} 为例子
// 如果循环的次数是 对应的出现次数 递归到第三次add(3)的时候
//循环结束 然后恢复现场 那么第三次的加入进去的3 是没有加入到res里面的 直接跳出循环执行后面的remove了
//这也就解释了为什么循环的次数要比对应出现的次数多1
for(int i = 0;i <= map.get(key[start]);i++){
dfs(nums,start+1);
now.add(key[start]);
}
//要恢复现场,删除已经添加的元素
for(int j = 0;j <= map.get(key[start]);j++) {
now.remove(now.size() - 1);
}
}
}
上面的变量用的有点多,很麻烦,麻烦的原因主要是想让start+1 就可以跳到下一个数字,但是由于数组是排序过的,其实让start+对应元素出现的次数就可以跳到下一个数字
代码改进,不想定义那么多的变量了。
class Solution {
List<List<Integer>> res = new LinkedList<List<Integer>>();
List<Integer> now = new LinkedList<>();
HashMap<Integer,Integer> map = new HashMap<>();
int n = 0;
Set<Integer> keySet ;
int[] key ;
public List<List<Integer>> subsetsWithDup(int[] nums) {
//先对数组进行排序将相同的数字放在一起
Arrays.sort(nums);
n = nums.length;
for (int num : nums){
map.put(num,map.getOrDefault(num,0)+1);
}
dfs(nums,0);
return res;
}
/**
*
* @param nums
* @param start 对第几个map里面key进行枚举
*/
private void dfs(int[] nums, int start) {
if(start == n){
//所有的数字都枚举完了
res.add(new LinkedList<>(now));
return;
}
//枚举每一个数字对应的选择次数 这个好像写不了
int count = map.get(nums[start]);
for(int i = 0;i <= count;i++){
dfs(nums,start+map.get(nums[start]));
now.add(nums[start]);
}
//要恢复现场,删除已经添加的元素
for(int j = 0;j <= count;j++) {
now.remove(now.size() - 1);
}
}
}
组合之和III
这个题的相似题目非常多,所有把列出所有相似题目的链接放这里,有兴趣的可以接着刷题,题是永远不会刷完的。
思路一:二进制组合枚举 利用位来枚举1 - 9是否选择
缺陷:这个写法的剪枝不好,没有在发现不满足条件以后及时回退,只是做到了不断地迭代。
class Solution {
List<List<Integer>> res = new LinkedList<>();
List<Integer> now = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
//需要对now里面的数组统计大小的
int temp = k;
int sum = 0;
int number = (int) (Math.pow(2,9)-1);
for (int i = 0; i <= number; i++) {
//统计每一个数字是否是1
int j = 0;
//循环9次进行判断
for (j = 0; j < 9; j++) {
//说明和数字的个数不满足 需要剪枝
if ((i >> j & 1) == 1) {
sum += j+1;
now.add(j+1);
k--;
// //说明综合超过了需要剪枝 或者是数字的个数已经够了
// if(sum >= n || k == 0) break;
}
}
//最后出来有可能小于或者等于所以再次判断
//但是这其实是没有达到剪枝的效果 还是df完了以后才进行的剪枝操作
if ( j == 9 && sum == n && k == 0) {
res.add(new LinkedList<>(now));
}
//恢复现场
now.clear();
//这里一定要把k 和sum 恢复到最原始的状态
k = temp;
sum = 0;
}
return res;
}
}
思路二:组合枚举 本质上这个题要做的就是在1-9的9个数字里面选择k个数字,使得和为n,那么枚举的思想不变,但是有了剪枝的条件。如果在枚举的过程过发现当前枚举到的数字已经比n还要大,那么没有必要继续枚举,直接剪枝。同时如果k个数字已经够了但是总和不满足也要剪枝。
class Solution {
List<List<Integer>> res = new LinkedList<>();
List<Integer> now = new LinkedList<>();
public List<List<Integer>> combinationSum3(int k, int n) {
dfs(k,1,n);
return res;
}
/**
* 在9个数字中选择k个数字 使得和为n
* @param k 可以选择的个数
* @param start 开始枚举的第几个数字
* @param n 总和
* @return
*/
private void dfs(int k, int start, int n) {
if (k == 0 && n == 0 ) {
//说明组合的个数和总和满足要求
res.add(new LinkedList<>(now));
return;
}
//不满足条件 不加入最终结果 直接返回
if(k == 0) {
return;
}
if(k > 0){
//这样写的只能是依次枚举每一个数字 而无法做到跳着枚举
for (int i = start; i <= 9; i++) {
//这里剪枝 如果当前枚举到的数字 已经比n还要大那么就剪枝 不需要继续往下枚举了
if(i > n) break;
now.add(i);
//觉得这里应该做剪枝的处理
dfs(k-1,i+1,n-i);
//恢复原始状态
now.remove(now.size()-1);
}
}
}
}
可以进一步扩展练习 组合lc77、组合II、组合iv这些题目
N皇后
皇后彼此不能相互攻击,也就是说:任何两个皇后都不能处于同一条横行、纵行或斜线上。
N皇后II
思路: 参照全排列的做法,其实还是枚举,并且是一种有顺序的枚举。
题中主要可以得出来的要求有
- 每一行只能出现一个皇后
- 每一列只能出现一个皇后
- 每一个正向对角线只能出现一个皇后
- 每一个斜向对角线只能出现一个皇后
所以需要布尔数组来辅助进行判断当前枚举到的位置是否可以进行放皇后判定。
关于对角线如何判定是否可以放只要是对角线的性质需要作为解题的前缀。
class Solution {
//每一列只能有一个皇后 列的状态数字 col【n]
// 斜线存在两种 每条对角线上只能有一个皇后 正向对角线d[2n]
//反向对角线 ud[2n]
//关于斜对角线的性质 同一条正向的对角线的和是固定的
//同一条反向斜对角线的的差值是固定的 但注意可能出现负数 所以都加上一个n
boolean[] column ;
boolean[] d;
boolean[] ud;
int res = 0;
int level ;
public int totalNQueens(int n) {
column = new boolean[n];
d = new boolean[2*n];
ud = new boolean[2*n];
level = n;
dfs(0);
return res;
}
/**
* 枚举每一行
* @param i
*/
private void dfs(int i) {
if(i == level){
//说明所有的行都枚举过了
res++;
return;
}
for(int j = 0;j < level;j++){
//枚举每一列 正向斜对角线 反向写对角线
if(!column[j] && !d[i+j] && !ud[i-j+level]) {
column[j] = true;
d[i+j] = true;
ud[i-j+level] = true;
dfs(i+1);
column[j] = false;
d[i+j] = false;
ud[i-j+level] = false;
}
}
}
}
解数独
思路: 和皇后问题的原理差不多,但是区别是需要返回值 返回值在这里起到了剪枝的作用
public class solveSudoku {
//搜索的顺序问题
//从前往后枚举每一个空格 哪一个空格该填哪一个数字
//每一行哪个数字是否以及出现过了
// row[9][9] 第一个维度表示的是第几行 第二个维度表示的是哪一个数字是否以及填过了
// colunmn [9][9] 哪一列哪一个数字是否以及填过了
// cell[3][3][9] 哪一行那一列 哪一个数字有没有被填过
//当所有的空格都填满了以后就结束了
//这和皇后问题 是一类精确覆盖问题
//数据结构 dancing links 十字链表
boolean[][] row = new boolean[9][9];
boolean[][] column = new boolean[9][9];
boolean[][][] cell = new boolean[3][3][9];
public void solveSudoku(char[][] board) {
if(board == null || board[0].length ==0){
return;
}
for(int i = 0;i < 9;i++) {
for(int j = 0; j < 9;j++) {
if (board[i][j] != '.') {
//把初始化的数据都先写好
row[i][board[i][j] - '1'] = true;
column[j][board[i][j] - '1'] = true;
cell[i / 3][j / 3][board[i][j] - '1'] = true;
}
}
}
dfs(0,0,board);
}
//这里面为什么要有返回值 每一个地方的return 为什么都那么重要呢?
private boolean dfs(int i, int j,char[][] board) {
if(i == 9) return true;
if(j == 9){
return dfs(i+1,0, board);
}
//首先先判断当前位置是否可以填
if (board[i][j] == '.') {
//枚举这个位置1-9
for(int m = 0;m < 9;m++){
if(!row[i][m] && !column[j][m] && !cell[i/3][j/3][m] ){
board[i][j] = (char) ('1'+ m);
row[i][m] = true;
column[j][m] = true;
cell[i/3][j/3][m] = true;
if(dfs(i,j+1,board)) return true;
board[i][j] = '.';
//这里必须得到一个返回值才能继续往下走
row[i][m] = false;
column[j][m] = false;
cell[i/3][j/3][m] = false;
}
}
}else {
return dfs(i, j + 1, board);
}
return false;
}
}
刷了这么多的题,来总结一下。其实回溯好像并不难想,关键是要知道一个套路。
回溯是什么意思,回到什么之前的什么状态,这个是关键。有的题是一步步的回溯
有的就是最后才回溯。
火柴拼正方形
前方高能,做好心理准备!
如果说前面的剪枝可能想一想就可以想到,那么下面这个就是剪枝中的教科书,主要是开开眼。
y总说这个题是经典的剪枝的题,所以有很多的剪枝的条件是经过很多人研究过的,所以想不到剪枝怎么去做是很正常的。毕竟有很多人都把这个剪翻来覆去的研究,但是虽然想不到这个剪枝该怎么去做,但是别人剪枝的思路,和原因还是值得去借鉴的。
剪枝:
- 从大到小枚举所有的边
枚举大边的时候,有限枚举大的值,那么对应的分支少,会有更多的搜索空间剪枝。 - 每条边内木棒的长度规定从大到小
- 如果当前木棒拼接失败,则跳过去接下来所有长度相同的木棒
- 如果当前木棒拼接失败,并且是当前边的第一个,则直接剪掉当前分支。
- 如果当前木棒拼接失败,并且是当前边的最后一个,则直接剪掉当前分支。
发现和解数独类似,都是需要将获得到的值return出去,每一步的return都不可以忽略掉。
以下代码的剪枝做了数组排序和从大到小来枚举的剪枝,后面三个的剪枝确实是不太容易想的到,但是前两个的剪枝从lc的判题来看时间性能还算不错了。
class Solution {
boolean[] now ;
//用来记录当前的边是否被枚举过
public boolean makesquare(int[] matchsticks) {
Arrays.sort(matchsticks);
int sum = 0;
for(int n : matchsticks){
sum += n;
}
now = new boolean[matchsticks.length];
if(sum == 0 || sum % 4 !=0) return false;
return dfs(matchsticks,0,0,sum/4);
}
/**
*
* @param matchsticks
* @param side 当前拼接到了第几条边
* @param cur 当前边的长度
* @param length 这条边应该的长度
* @return
*/
private boolean dfs(int[] matchsticks, int side , int cur , int length) {
//只有拼接好了以后 才可以接着往后枚举下一条边
if(cur == length) {
//return dfs(matchsticks,side+1,0,length);
side++;
cur = 0;
//这两种写法是一样的,只不过下面的写法会有更少的递归调用栈的常见
}
if(side == 4 ) {
//说明所有的边都拼接完了
return true;
}
//依次枚举 从大边到小边枚举
for(int i = matchsticks.length-1;i >= 0;i--){
if(!now[i]){ //当前的边没有被用过
//如果当前边的长度大于边长度的要求限制 那么直接剪枝
if(matchsticks[i] + cur > length) return false;
now[i] = true;
//如果当前的结果是true 那么就可以返回了
if(dfs(matchsticks,side,cur+matchsticks[i],length)) return true;
//如果不是需要恢复现场
now[i] = false;
}
}
return false;
}
}
五个剪枝全部使用,最后时间性能和空间性能优化到比较不错的地方。
这个五个剪枝的条件判定那里出了一点点问题,现在还没有完全理解。
class Solution {
boolean[] now ;
//用来记录当前的边是否被枚举过
public boolean makesquare(int[] matchsticks) {
Arrays.sort(matchsticks);
int sum = 0;
for(int n : matchsticks){
sum += n;
}
now = new boolean[matchsticks.length];
if(sum == 0 || sum % 4 !=0) return false;
return dfs(matchsticks,0,0,sum/4);
}
/**
*
* @param matchsticks
* @param side 当前拼接到了第几条边
* @param cur 当前边的长度
* @param length 这条边应该的长度
* @return
*/
private boolean dfs(int[] matchsticks, int side , int cur , int length) {
//只有拼接好了以后 才可以接着往后枚举下一条边
if(cur == length) {
// return dfs(matchsticks,side+1,0,length);
side++;
cur = 0;
}
if(side == 4 ) {
//说明所有的边都拼接完了
return true;
}
//依次枚举 从大边到小边枚举
for(int i = matchsticks.length-1;i >= 0;i--){
if(!now[i] && matchsticks[i] + cur <= length ){ //当前的边没有被用过
//如果当前边的长度大于边长度的要求限制 那么直接剪枝
// if(matchsticks[i] + cur > length) return false;
now[i] = true;
//如果当前的结果是true 那么就可以返回了
if(dfs(matchsticks,side,cur+matchsticks[i],length)) return true;
//如果不是需要恢复现场
now[i] = false;
//同时表示的是当前的木棒拼接失败 第一个和最后一个 也要剪枝
if(cur == 0) return false;
if(cur + matchsticks[i] == length) return false;
//如果当前木棒拼接失败,那么跳过和他长度相同的木棒 第5给剪枝
while (i - 1 >= 0 && matchsticks[i] == matchsticks[i-1]) i--;
}
}
return false;
}
}
枚举的时候 查看是否需要考虑顺序问题 是否可以重复枚举