六. 搜索算法
1. 深度优先搜索
例题 695 求最大岛屿的面积。
- 即求最大的连通区域的大小;
- 主函数遍历矩阵,判断当前元素是否需要递归判断连通岛屿的面积;
- 辅助函数用于递归计算岛屿面积;
- 注意遍历过的地方需要将 1 置为 0 ,防止重复计算。
//辅
public int searchGrid(int[][] grid, int i, int j) {
if(i >= grid.length || i < 0 || j >= grid[0].length || j < 0) {
return 0;
}
if (grid[i][j] == 1) {
//这里置成0,防止下次重新遍历到
grid[i][j] = 0;
return 1 + searchGrid(grid, i+1, j) + searchGrid(grid, i, j+1) + searchGrid(grid, i-1, j) + searchGrid(grid, i, j-1);
}
return 0;
}
//主
public int maxAreaOfIsland(int[][] grid) {
int row = grid.length;
int col = grid[0].length;
int area = 0;
int maxArea = 0;
for (int i = 0; i < row; i++) {
for (int j = 0; j < col; j++) {
if (grid[i][j] == 1) {
area = searchGrid(grid, i, j);
maxArea = maxArea < area ? area : maxArea;
}
}
}
return maxArea;
}
例题547。计算省份的数量
- 即给出一个是否直接相连的二维矩阵,找出其中有多少个岛屿;
- 深度优先搜索,主函数遍历每一个城市,判断该城市是否被访问过;
- 辅函数以当前城市为基准,遍历所有城市判断是否有直接相连的城市,找到直接相连的城市,再以该城市为基准进行递归;
- 需要维护一个当前城市是否被访问过的一维数组,防止重复访问。
/**
这个递归函数从进入到退出刚好访问一个省会
*/
public void search(int[][] isConnected, boolean[] isVisited, int i) {
if (i < 0 || i >= isConnected.length) {
return;
}
//以当前城市为基准,重新遍历所有城市看是否能访问
for (int j = 0; j < isConnected.length; j++) {
if (isConnected[i][j] == 1 && !isVisited[j]) {
isVisited[j] = true;
search(isConnected, isVisited, j);
}
}
}
public int findCircleNum(int[][] isConnected) {
/**
深度优先搜索,从一个城市开始,遍历所有与之直接相连的城市,遍历过的做个标记防止重复遍历
*/
int sum = 0;
boolean[] isVisited = new boolean[isConnected.length];
for (int i = 0; i < isConnected.length; i++) {
if (!isVisited[i]) {
sum += 1;
isVisited[i] = true;
search(isConnected, isVisited, i);
}
}
return sum;
}
例题417。太平洋大西洋水流问题,找出那些水流既可以流动到“太平洋”,又能流动到“大西洋”的陆地单元的坐标。
- 按照题目的意思顺流判断是否能到达太平洋和大西洋,需要考虑的条件很复杂;
- 可以考虑倒流,从四边往内倒流,只要能访问即表示可流到;
- 这种方式的思路是只要当前值可连通,如果四周的值更大,那肯定更满足要求;
- 上边和左边一定能流到太平洋,下边和右边一定能流到大西洋。
public static void search(int[][] heights, int i, int j, boolean[][] canReach) {
if (i < 0 || j >= heights[0].length || canReach[i][j]) {
return;
}
//可访问到即可达,这也是倒流的好处
canReach[i][j] = true;
if (i >= 1 && heights[i][j] <= heights[i-1][j]) {
search(heights,i-1, j, canReach);
}
if (i < heights.length-1 && heights[i][j] <= heights[i+1][j]) {
search(heights,i+1, j, canReach);
}
if (j >= 1 && heights[i][j] <= heights[i][j-1]) {
search(heights,i, j-1, canReach);
}
if (j < heights[0].length-1 && heights[i][j] <= heights[i][j+1]) {
search(heights,i, j+1, canReach);
}
}
public List<List<Integer>> pacificAtlantic(int[][] heights) {
/**
按照题目的意思顺流判断是否能到达太平洋和大西洋,需要考虑的条件很复杂;
可以考虑倒流,从四边往内倒流,只要能访问即表示可流到。
上边和左边表示能流到太平洋,下边和右边表示能流到大西洋
*/
List<List<Integer>> retList = new ArrayList<>();
int m = heights.length;
int n = heights[0].length;
boolean[][] canReachP = new boolean[m][n];
boolean[][] canReachA = new boolean[m][n];
for (int i = 0; i < m; i++) {
search(heights, i, 0, canReachP);
search(heights, i, n-1, canReachA);
}
for (int j = 0; j < n; j++) {
search(heights, 0, j, canReachP);
search(heights, m-1, j, canReachA);
}
for (int i = 0; i < m; i ++) {
for (int j = 0; j < n; j++) {
if(canReachP[i][j] && canReachA[i][j]) {
retList.add(Arrays.asList(i, j));
}
}
}
return retList;
}
2. 回溯法
例题46. 全排列
- 回溯法在递归前修改状态,在回溯时将状态还原;
- 求全排列时,一个数组的全排列等于选定一个数,加剩余数的全排列;
- 如 [1, 2, 3] 的全排列等于选定 1 + [2, 3] 的全排列,及选定 2 + [1, 3] 的全排列,及选定 3 + [1, 2] 的全排列;而 [2, 3] 的全排列等于选定 2 加 [3] 的全排列,及选定 3 加 [2] 的全排列,以此类推。
public void swap(List<Integer> nums, int i, int j) {
int tmp = nums.get(i);
nums.set(i, nums.get(j));
nums.set(j, tmp);
}
/**
从start开始的nums的全排列
*/
public void sort(List<Integer> nums, List<List<Integer>> retList, int start) {
if (start == nums.size() - 1) {
retList.add(new ArrayList<Integer>(nums));
}
for (int i = start; i < nums.size(); i++) {
// 对于当前位置 start,可以与后续每一个位置交换,得到一种排列
swap(nums, i, start);
// 第一位选定后(i),继续处理后一个位置
sort(nums, retList, start + 1);
//恢复原样
swap(nums, i, start);
}
}
public List<List<Integer>> permute(int[] nums) {
List<Integer> newNums = new ArrayList<>();
for (int i = 0; i < nums.length; i++) {
newNums.add(nums[i]);
}
List<List<Integer>> retList = new ArrayList<>();
sort(newNums, retList, 0);
return retList;
}
还可以不采用原数组交换的方式,而是用一个新的列表来存储当前的全排列数组,这样更好理解,但是需要多占用空间
public static void sort(int[] nums, List<Integer> tmpList, boolean[] isVisited, List<List<Integer>> retList) {
if (tmpList.size() == nums.length) {
retList.add(new ArrayList<Integer>(tmpList));
return;
}
//注意这里从0开始
for (int i = 0; i < nums.length; i++) {
//被使用的不能再用
if (isVisited[i]) {
continue;
}
isVisited[i] = true;
tmpList.add(nums[i]);
sort(nums, tmpList, isVisited, retList);
//回溯恢复
isVisited[i] = false;
tmpList.remove(Integer.valueOf(nums[i]));
}
}
public static List<List<Integer>> permute(int[] nums) {
boolean[] isVisited = new boolean[nums.length];
List<List<Integer>> retList = new ArrayList<>();
//利用一个tmpList存储当前的全排列数组
List<Integer> tmpList = new ArrayList<>();
sort(nums, tmpList, isVisited, retList);
return retList;
}
如果全排列的数组中含重复数字,则得想办法进行剪枝:
public void sort(int[] nums, List<Integer> tmpList, boolean[] isVisited, List<List<Integer>> retList) {
if (tmpList.size() == nums.length) {
retList.add(new ArrayList<Integer>(tmpList));
return;
}
//注意这里从0开始
for (int i = 0; i < nums.length; i++) {
//被使用的不能再用
if (isVisited[i]) {
continue;
}
//含重复数字只需添加这一步剪枝
//!isVisited[i-1]是因如果前一个相同数字没被使用,那么这个数字就会在后面再次被选中造成重复
if (i > 0 && nums[i] == nums[i-1] && !isVisited[i-1]) {
continue;
}
isVisited[i] = true;
tmpList.add(nums[i]);
sort(nums, tmpList, isVisited, retList);
//回溯恢复
isVisited[i] = false;
//删除最后一个
tmpList.remove(tmpList.size() - 1);
}
}
public List<List<Integer>> permuteUnique(int[] nums) {
boolean[] isVisited = new boolean[nums.length];
List<List<Integer>> retList = new ArrayList<>();
//利用一个tmpList存储当前的全排列数组
List<Integer> tmpList = new ArrayList<>();
//含重复数字需要排序将重复的数字排列在一起
Arrays.sort(nums);
sort(nums, tmpList, isVisited, retList);
return retList;
}
例题77. 组合
给定两个整数 n 和 k,返回范围 [1, n] 中所有可能的 k 个数的组合。
- 排列回溯的是交换的位置,而组合回溯的是否把当前的数字加入结果中;
- 排列递归时传入的 start 是 start+1,而组合传入的是 i+1,因为组合时永远是在后一位寻找数字。
/**
组合与排列类似,只是在回溯的时候是将添加的元素恢复
*/
public void myComb(List<Integer> tmpList, List<List<Integer>> retList, int start, int n, int k) {
if (k == 0) {
retList.add(new ArrayList(tmpList));
return;
}
//注意这里i <= n-k+1, 因为超过n-k+1的话,tmpList总长度永远小于k
for (int i = start; i <= n-k+1; i ++) {
tmpList.add(i);
//这里的start是i+1,而不是排列中的start+1
myComb(tmpList, retList, i+1, n, k-1);
tmpList.remove(tmpList.size()-1);
}
}
public List<List<Integer>> combine(int n, int k) {
List<List<Integer>> retList = new ArrayList<>();
myComb(new ArrayList<>(), retList, 1, n, k);
return retList;
}
例题40. 含重复数字的组合题。给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用 一次 。解集不能包含重复的组合。
- 组合和全排列一样,如果含有重复数字,第一步要做的就是给数组排序,使得相同元素在一起;
- 第二步是剪枝,i>0 && candidates[i] == candidates[i-1] && !isUsed[i-1]。
class Solution {
public List<List<Integer>> combinationSum2(int[] candidates, int target) {
Arrays.sort(candidates);
boolean[] isUsed = new boolean[candidates.length];
List<List<Integer>> retList = new ArrayList<>();
List<Integer> list = new ArrayList<>();
dfs(candidates, target, list, retList, isUsed, 0);
return retList;
}
public void dfs(int[] candidates, int target, List<Integer> list, List<List<Integer>> retList, boolean[] isUsed, int start) {
if (target == 0) {
retList.add(new ArrayList<>(list));
return;
}
if (target < 0) {
return;
}
for (int i = start; i < candidates.length; i++) {
if (isUsed[i]) {
continue;
}
//和全排列一样的剪枝方法
if (i>0 && candidates[i] == candidates[i-1] && !isUsed[i-1]) {
continue;
}
list.add(candidates[i]);
isUsed[i] = true;
dfs(candidates, target - candidates[i], list, retList, isUsed, i+1);
isUsed[i] = false;
list.remove(list.size() - 1);
}
}
}
排列组合小结:
- 排列题:如果不含重复数字,可以通过对原数组进行位置交换得到全排列,注意此时要传入 start,dfs 时是 start+1;也可以另外开辟空间存储单个排列,并建立 isUsed 判断当前元素是否使用过,此时则无需传入 start,for 循环中 i = 0。如果含重复数字,则一定要使用方法二另外开辟空间,并且需要对数组排序和剪枝。
- 组合题:组合题必须另外开辟空间存储单个组合,并且必须传入 start,for 循环中 i = start,因为组合时不能往回获取元素,dfs 时是 i + 1;如果数组中的元素可重复选取,那 dfs 时是 i。由于一定要传入start,则 isUsed 无需建立。但如果是含重复数字的组合题,则需要建立 isUsed 用于剪枝。
例题79. 单词搜索。在矩阵中搜索指定的单词,存在返回 true,否则返回 false。
- 单词搜索回溯的是当前字母是否被使用;
public boolean search(char[][] board, char[] arr, int k, int n, int i, int j, boolean[][] isUsed) {
//搜索到所有字母才算成功
if (k == n) {
return true;
}
if (i >= board.length || j >= board[0].length || i < 0 || j < 0) {
return false;
}
if (arr[k] == board[i][j] && !isUsed[i][j]) {
isUsed[i][j] = true;
//选择了一个字母后,再在其周围搜索
if(search(board, arr, k+1, n, i+1, j, isUsed)
|| search(board, arr, k+1, n, i-1, j, isUsed)
|| search(board, arr, k+1, n, i, j+1, isUsed)
|| search(board, arr, k+1, n, i, j-1, isUsed)) {
return true;
} else {
//如果后续不满足要求,当前字母回退重新搜索
isUsed[i][j] = false;
return false;
}
}
return false;
}
public boolean exist(char[][] board, String word) {
char[] arr = word.toCharArray();
// 存放字母是否被使用
boolean[][] isUsed = new boolean[board.length][board[0].length];
for (int i = 0; i < board.length; i++) {
for (int j = 0; j < board[0].length; j++) {
isUsed = new boolean[board.length][board[0].length];
if (search(board, arr, 0, word.length(), i, j, isUsed)) {
return true;
}
}
}
return false;
}
例题 51. N 皇后。将 n 个皇后放置在 n×n 的棋盘上,并且每一行、列、左斜、右斜最多只有一个皇后。
- N 皇后问题有隐藏条件,即每一行都只能放置一个皇后,因此我们遍历时只需一行一行进行;
- 回溯时回溯的是当前行里,该列是否放置皇后;
- 需要建立方法判断当前是否能放置皇后。
/**
判断当前是否能放置皇后
*/
public boolean canPlace( int row, int col, int n, char[][] board) {
// 检查列是否有皇后冲突
for (int i = 0; i < row; i++) {
if (board[i][col] == 'Q') {
return false;
}
}
// 检查右上方是否有皇后冲突
for (int i = row - 1, j = col + 1; i >=0 && j < n; i--, j++) {
if (board[i][j] == 'Q') {
return false;
}
}
// 检查左上方是否有皇后冲突
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (board[i][j] == 'Q') {
return false;
}
}
return true;
}
public void search(int k, int n, List<List<String>> retList, char[][] board, int i) {
if (k == n) {
List<String> tmpList = new ArrayList<>();
for (char[] c : board) {
String tmp = String.valueOf(c);
tmpList.add(tmp);
}
retList.add(tmpList);
return;
}
if (i < 0 || i >= n) {
return;
}
//针对第i行的每一列
for (int p = 0; p < n; p++) {
if (canPlace(i, p, n, board)) {
//放置
board[i][p] = 'Q';
search(k+1, n, retList, board, i+1);
//撤销放置
board[i][p] = '.';
}
}
}
public List<List<String>> solveNQueens(int n) {
//初始化board数组
char[][] board = new char[n][n];
for (char[] c : board) {
Arrays.fill(c, '.');
}
List<List<String>> retList = new ArrayList<>();
//由于每行只能放一个,因此只需要对j进行遍历
search(0, n, retList, board, 0);
return retList;
}
3. 广度优先算法
广度优先不同与深度优先搜索,它是一层层进行遍历的,因此需要用先入先出的队列进行遍历。由于是按层次进行遍历,广度优先搜索时按照“广”的方向进行遍历的,也常常用来处理最短路径等问题。最短路径就是当前层所在的层数。
例题 934. 最短的桥。给定一个二维 0-1 矩阵,其中 1 表示陆地,0 表示海洋,每个位置与上下左右相连。已知矩阵中有且只有两个岛屿,求最少要填海造陆多少个位置才可以将两个岛屿相连。
- 实际上是求两个岛屿间的最短距离;
- 先深度优先寻找到其中一个岛屿的所有坐标,再对该岛屿的每个坐标进行广度优先一层一层遍历,每遍历完一层,距离加一,直到找到第二个岛屿;
- 遍历过的位置要进行记录,防止重复遍历;记得需要明确层与层间的边界,才能正确增加距离。
/**
递归寻找第一个岛屿坐标
*/
public void dfs(int i, int j, int[][] grid, Queue<int[]> retList, boolean[][] isVisited) {
if (i < 0 || j < 0 || i >= grid.length || j>= grid[0].length) {
return;
}
if (grid[i][j] == 1) {
grid[i][j] = 2;
isVisited[i][j] = true;
retList.add(new int[]{i, j});
dfs(i+1, j, grid, retList, isVisited);
dfs(i, j+1, grid, retList, isVisited);
dfs(i-1, j, grid, retList, isVisited);
dfs(i, j-1, grid, retList, isVisited);
}
}
public int shortestBridge(int[][] grid) {
/**
先通过深度优先找到其中一个岛屿的所有坐标,并将值改成2,
再通过广度优先一层一层寻找其与另一个岛屿的距离
*/
Queue<int[]> landList = new LinkedList<>();
boolean[][] isVisited = new boolean[grid.length][grid[0].length];
//寻找第一个岛屿坐标
boolean flag = true;
for (int i = 0; i < grid.length && flag; i++) {
for (int j = 0; j < grid[0].length && flag; j++) {
if (grid[i][j] == 1) {
dfs(i, j, grid, landList, isVisited);
flag = false;//用于退出循环
}
}
}
//开始广度优先搜索
int dis = 0;
int[][] direc = new int[][]{{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
while (!landList.isEmpty()) {
//记录当前层的长度
int size = landList.size();
for (int k = 0; k < size; k ++) {
int[] land = landList.poll();
for (int i = 0; i < 4; i++) {
int p = land[0] + direc[i][0];
int q = land[1] + direc[i][1];
if (p >= 0 && p < grid.length && q >= 0 && q < grid[0].length && !isVisited[p][q]) {
isVisited[p][q] = true;
//找到另一个岛屿直接返回
if (grid[p][q] == 1) {
return dis;
}
landList.add(new int[]{p, q});
}
}
}
//搜索完当前层,到下一层长度需加一
dis ++;
}
return Integer.MAX_VALUE;
}
例题126.单词接龙 。按字典 wordList 完成从单词 beginWord 到单词 endWord 转化,一个表示此过程的 转换序列 是形式上像 beginWord -> s1 -> s2 -> … -> sk 这样的单词序列。
- 广度优先建图获取可达关系,深度优先+回溯寻找所有可能的解;
- 把每个字符串当作一个节点,如果某个字符串可以由另一个字符串通过变换一个字符得到,则这两个字符串可连接;通过广度优先为每个字符串确定所在的层级,用一个map存储当前字符串所在的层级,一个map存储当前字符串可达哪些下层字符串(注意同层或上层的字符串不能被加入到可达的下层字符串中)。
- 将可达关系送入到深度优先算法中,递归+回溯寻找所有可能的解。
/**
寻找当前字符串的可达字符串
*/
public static Set<String> getWords(String beginWord, List<String> dict, Map<String, Integer> map) {
Set<String> retSet = new HashSet<>();
//当前将字符串的每一位进行替换,看是否在字典中
for (int i = 0; i < beginWord.length(); i++) {
char[] word = beginWord.toCharArray();
for (char c = 'a'; c <= 'z'; c++) {
word[i] = c;
String tmp = String.valueOf(word);
if (dict.contains(tmp)) {
//避免平级字符串被加入到可达字符串中
if (map.get(tmp) == null || map.get(tmp) > map.get(beginWord)) {
retSet.add(tmp);
map.put(tmp, map.get(beginWord) + 1);
}
}
}
}
return retSet;
}
/**
dfs回溯寻找所有可能的解
*/
public static void dfs(Map<String, Set<String>> wordMap, String beginWord, String endWord, List<String> tmpList, List<List<String>> retList) {
if (endWord.equals(beginWord)) {
retList.add(new ArrayList(tmpList));
return;
}
Set<String> wordSet = wordMap.get(beginWord);
for (String word : wordSet) {
tmpList.add(word);
dfs(wordMap, word, endWord, tmpList, retList);
tmpList.remove(word);
}
}
public static List<List<String>> findLadders(String beginWord, String endWord, List<String> wordList) {
if (!wordList.contains(endWord)) {
return new ArrayList<>();
}
Queue<String> queue = new LinkedList<>();
queue.add(beginWord);
//存储当前字符串所在的层级
Map<String, Integer> map = new HashMap<>();
//存储从一个字符串可以到后续哪些字符串
Map<String, Set<String>> wordMap = new HashMap<>();
int dis = 0;
while(!queue.isEmpty()) {
int size = queue.size();
for (int i = 0 ; i < size; i++) {
String word = queue.poll();
//存储当前字符串所在层数
map.putIfAbsent(word, dis);
Set<String> words = getWords(word, wordList, map);
wordMap.put(word, words);
//当一个字符串有多个父级时,避免该字符串被重复加入队列
queue.addAll(words.stream().filter(w -> !queue.contains(w)).collect(Collectors.toSet()));
}
dis ++;
}
List<List<String>> retList = new ArrayList<>();
List<String> tmpList = new ArrayList<>();
tmpList.add(beginWord);
if (map.get(endWord) != null) {
dfs(wordMap, beginWord, endWord, tmpList, retList);
}
return retList;
}