今天我们来研究一下深度优先搜索(depth first search,DFS)和广度优先搜索(bread first search,BFS)两种算法。DFS是使用递归法解决问题的一个很好例子,它会使用到栈这种后进先出处理顺序的数据结构;BFS是使用队列进行顺序迭代的很好例子,通过使用队列先进先出的处理顺序进行层层推进。
DFS和BFS可进行图的遍历,区别是顶点的处理顺序。
图的许多性质都与路径有关,比如判断图中两定点是否连通,如果连通,那么给出一条连通路径;又比如给出两点的最短路径。与图有关的典型游戏有走迷宫,打车软件的路径规划(即地图应用,其实就是加权有向图的最短路径问题)等等,而DFS和BFS是两点连通性、最短路径这类问题的解法。
1.无向图
回到正题,在我们学习这两个算法前,我们需要先明确图的表示法。图分为无向图和有向图,我们以无向图为例。
无向图的API
我们先来定义一下图的API。
图是由一些定点和边组成的图形,常见的API有:
用例
我们以常见的图的处理代码为例来简单了解无向图API的使用。
- 计算顶点的度数
度数用来表示图中某个顶点边数。
public static int degree(Graph G, int v) {
int degree = 0;
for (int w : G.adj(v)) degree++;
return degree;
}
- 计算所有顶点的平均度数
public static double avgDegree(Graph G) {
return 2.0 * G.E() / G.V();
}
- 计算自还的个数
public static int numberOfSelfLoops(Graph G) {
int count = 0;
for (int v = 0; v < G.V(); v++)
for (int w : G.adj(v))
if (v == w) count++;
return count / 2;
}
无向图的表示方式
常见的表示法有:邻接矩阵、邻接表。最长使用的是邻接表,如下图所示:
Graph领接表表示数据结构如下所示:
public class Graph {
private final int V;
private int E;
private Bag<Integer>[] adj;
public Graph(int V) {
this.V = V;
this.E = 0;
adj = (Bag<Integer>[]) new Bag[V];
for (int v = 0; v < V; v++) {
adj[v] = new Bag<Integer>();
}
}
public int V() {
return V;
}
public int E() {
return E;
}
public void addEdge(int v, int w) {
E++;
adj[v].add(w);
adj[w].add(v);
}
public Iterable<Integer> adj(int v) {
return adj[v];
}
public int degree(int v) {
return adj[v].size();
}
}
注:Bag是一种集合类数据类型,我们在《算法基础》一文中介绍过。
2.图处理算法的设计模式
深度优先搜索和广度优先搜索属于2种图处理算法,下面我们来定义一下这两种搜索算法的API,也是一般图处理算法的API,其他图处理算法可以以此为参考。
2.1.深度优先搜索(DFS)
深度优先搜索来自于走迷宫游戏,如果我们把迷宫代替图、通道代替边、路口代替顶点,走迷宫游戏其实可以规约为给出图中两个顶点(s,v)的一条连同路径。
想象一下我们小时候是怎么玩走迷宫游戏的,一般步骤如下所示:
- 选择一条没有标记过的通道,试着推进,在你走过的路上划线用来标记你走过的路口和通道;
- 当你来到标记过的路过时回退到上一个路口;
- 当回退的路口已没有可走的通道时继续回退。
重复以上步骤,直到走出迷宫为止。
我们小时候是不是就是这样玩走迷宫游戏的,这种搜索方法在算法界叫Tremaux搜索。这种思路就是深度优先搜索,如下图所示。
下面我们用算法实现来思想上面的思路。
深度优先搜索实现:
public class DepthFirstSearch {
private boolean[] marked;
private int count;
public DepthFirstSearch(Graph G, int s) {
marked = new boolean[G.V()];
dfs(G, s);
}
private void dfs(Graph G, int v) {
mardked[v] = true;
count++;
for (int w : G.adj(v)) {
if (!marked[w]) dfs(G, w);
}
}
public boolean marked(int w) {
return marked[w];
}
public int count() {
return count;
}
}
可以看到,dfs方法处理过程就是一个顶点标记和顶点统计过程,使用递归算法不断深入探索未被标记的顶点,标记过则函数返回,探索上一个顶点的另一个方向。
深度优先搜索算法标记与起点连通的所有顶点所需时间和顶点的度数之和成正比。
深度优先搜索可以解决两顶点是否连通的问题,只需要稍稍改变一下,就能找到这样的路径。
API定义如下:
实现如下:
public class DepthFirstPaths {
private boolean[] marked;
private int[] edgeTo;// 从起点到一个顶点的已知路径上的最后一个顶点
private final int s;
public DepthFirstPath(Graph G, int s) {
marked = new boolean[G.V()];
edgeTo = new int[G.V()];
this.s = s;
dfs(G, s);
}
private void dfs(Graph G, int v) {
mardked[v] = true;
count++;
for (int w : G.adj(v)) {
marked[v] = true;
count++;
for (int w : G.adj(v))
{
if (!marked[w]) {
edgeTo[w] = v;
dfs(G, w);
}
}
}
}
public boolean hasPathTo(int w) {
return marked[w];
}
public Iterable<Integer> pathTo(int v) {
if (!hasPathTo(v)) return null;
Stack<Integer> path = new Stack<Integer>();
for (int x = v; x != s; x = edgeTo[x])
path.push(x);
path.push(s);
return path;
}
}
可以看出对深度优先搜索简单改造后就能得到到顶点v的可达路径。
使用深度优先搜索得到给定起点到任意标记顶点v的路径所需时间与路径长度成正比。
以下是DFS搜索的轨迹。
DFS通过递归隐式栈不断深入探索。
2.2.广度优先搜索(BFS)
深度优先搜索解决不了最短路径问题,问题描述如下:
给定一幅图和一个起点s,问是否存在一条从起点s到目的顶点v是否存在一条路径?如果有,找出其中最短的路径。
解决这个问题的经典方法叫广度优先搜索(BFS)。
深度优先搜索就像一个人在走迷宫,而广度优先搜索则好像一组人朝着各个方向走迷宫。广度优先算法巧妙利用队列先进先出特点,从起点朝着各个方向同时推进。
BFS数据结构
广度优先队列需要不像深度优先搜索那样通过递归使用隐式栈进行深入探索,它显式地使用队列。广度优先结果将结果也是写入edgeTo[],即也是一颗用父链接表示的根结点为s的树,如下图所示:
BFS数据结构由3部分组成:
- s,即起点。
- mark[]数组,用于标记已经访问过的路口,即顶点。
- edgeTo[]数组,用于记录最短路径,即保存每个节点的在以s为为根结点树中的父节点。
BFS算法实现与轨迹跟踪
public class BreadthFirstPaths {
private boolean[] marked;
private int[] edgeTo;
private final int s;
public BreadthFirstPaths(Graph G, int s) {
marked = new boolean[G.V()];
edgeTo = new int[G.V()];
this.s = s;
bfs(G, s);
}
private void bfs(Graph G, int s) {
Queue<Integer> queue = new Queue<Integer>();
marked[s] = true;
queue.enqueue(s);
while (!queue.isEmpty()) {
int v = queue.dequeue();
for (int w : G.adj(v)) {
if (!marked[w]) {
edgeTo[w] = v;
marked[w] = true;
queue.enqueue(w);
}
}
}
}
private boolean hasPathTo(int v) {
return marked[v];
}
public Iterable<Integer> pathTo(int v) {
if (!hasPathTo(v)) return null;
Stack<Integer> path = new Stack<Integer>();
for (int x = v; x != s; x = edgeTo[x])
path.push(x);
path.push(s);
return path;
}
}
注:可以看出bfs通过迭代法巧妙使用队列不断顶点标记和路径跟踪的过程,不同之处是标记的顺序。
广度优先搜索所需的时间在最坏情况下和 V+E 成正比。
以下是BFS搜索的轨迹
广度优先算法巧妙利用队列从起点朝着各个方向层层推进。
3.典型应用
本节我们来如何利用dfs和bfs特征解决一些有趣的问题。典型应用基本都是根据DFS和BFS模板算法变换而来,关键是理解两种算法的精髓:
- DFS是沿着一个点通过递归法不断深入探索的过程。
- BFS是使用一个队列去维护遍历顺序,通过迭代法从一点朝着各个方向碾压式层层推进。
3.1.岛屿数量
给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。
岛屿总是被水包围,并且每座岛屿只能由水平方向或竖直方向上相邻的陆地连接形成。
此外,你可以假设该网格的四条边均被水包围。
示例 1:
输入:
11110
11010
11000
00000
输出: 1
示例 2:
输入:
11000
11000
00100
00011
输出: 3
解释: 每座岛屿只能由水平和/或竖直方向上相邻的陆地连接而成。
分析:网格可以认为是一个无向图,这个问题可规约为图定点遍历问题,由于我们只需要统计岛屿个数,而与路径无关,因此可以采用DFS或BFS进行求解。
实现1:DFS实现
采用DFS, dfs处理过程就是扫描出相邻顶点为1的顶点标记为0。外部大循环dfs的调用次数就是岛屿个数。递归不断深度。
public int numIslands(char[][] grid) {
if (grid.length == 0) return 0;
int rowNum = grid.length;
int colNum = grid[0].length;
int count = 0;
for (int r = 0; r < rowNum; r++)
for (int c = 0; c < colNum; c++)
if (grid[r][c] == '1') {
count++;
dfs(grid, r, c);
}
return count;
}
private void dfs(char[][] grid, int rowIndex, int colIndex) {
int rowNum = grid.length;
int colNum = grid[0].length;
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rowNum || colIndex >= colNum || grid[rowIndex][colIndex] == '0') return;
grid[rowIndex][colIndex] = '0'; // 值为1则标记为0
dfs(grid, rowIndex - 1, colIndex);
dfs(grid, rowIndex + 1, colIndex);
dfs(grid, rowIndex, colIndex - 1);
dfs(grid, rowIndex, colIndex + 1);
}
实现2:BFS实现
即通过Queue进行迭代法实现。
public int numIslands(char[][] grid) {
if (grid.length == 0) return 0;
int rowNum = grid.length;
int colNum = grid[0].length;
int count = 0;
Queue<Integer> queue = new Queue<>();
for (int r = 0; r < rowNum; r++)
for (int c = 0; c < colNum; c++)
if (grid[r][c] == '1') {
grid[r][c] = '0';
count++;
bfs(grid, r, c, queue);
}
return count;
}
private void bfs(char[][] grid, int rowIndex, int colIndex, Queue<Integer> queue) {
int rowNum = grid.length;
int colNum = grid[0].length;
queue.enqueue(rowIndex * colNum + );
while (!queue.isEmpty()) {
int id = queue.dequeue();
int row = id / colNum;
int col = id % colNum;
if (row - 1 >=0 && grid[row - 1][col] == '1') {
queue.add((row - 1) * colNum + col);
grid[row - 1][col] = '0';
}
if (row + 1 < rowNum && grid[row + 1][col] == '1') {
queue.add((row + 1) * colNum + col);
grid[row + 1][col] = '0';
}
if (col - 1 >=0 && grid[row][col - 1] == '1') {
queue.add((row) * colNum + col - 1);
grid[row][col - 1] = '0';
}
if (col + 1 < colNum && grid[row][col + 1] == '1') {
queue.add((row) * colNum + col + 1);
grid[row][col + 1] = '0';
}
}
}
3.2.二叉树的最小深度
给定一个二叉树,找出其最小深度。
最小深度是从根节点到最近叶子节点的最短路径上的节点数量。
说明: 叶子节点是指没有子节点的节点。
示例:
给定二叉树 [3,9,20,null,null,15,7],
3
/
9 20
/
15 7
返回它的最小深度 2.
分析:树的深度即叶子节点所在树的层级,bfs迭代法不断刷新深度变量。
代码:
public int minDepth(TreeNode root) {
if (root == null) return 0;
return bfs(root);
}
public int bfs(TreeNode root) {
Queue<Pair<TreeNode, Integer>> nodeDepthQueue = new LinkedList<>();
nodeDepthQueue.add(new Pair(root, 1));
int depth = 1;
while (!nodeDepthQueue.isEmpty()) {
Pair<TreeNode, Integer> nodeDepth = nodeDepthQueue.remove();
TreeNode node = nodeDepth.getKey();
depth = nodeDepth.getValue();
if (node.left == null && node.right == null) break;
else if (node.left == null) nodeDepthQueue.add(new Pair(node.right, depth + 1));
else if (node.right == null) nodeDepthQueue.add(new Pair(node.left, depth + 1));
else {
nodeDepthQueue.add(new Pair(node.right, depth + 1));
nodeDepthQueue.add(new Pair(node.left, depth + 1));
}
}
return depth;
}
3.3.完全平方数
给定正整数 n,找到若干个完全平方数(比如 1, 4, 9, 16, ...)使得它们的和等于 n。你需要让组成和的完全平方数的个数最少。
示例 1:
输入: n = 12
输出: 3
解释: 12 = 4 + 4 + 4.
示例 2:
输入: n = 13
输出: 2
解释: 13 = 4 + 9.
分析:
数的加减可以理解为数的组合,本题等价于求解给定正整数的完全平方数组合中个数最少的组合。如下图所示:
树的根结点是输入正整数n,父节点到子节点的边由小于n的所有平方数组成,通过层层不断分解,当节点的值等于完全平方组组合元素之一时,则说明n可以被完全平方数分解,且最早找到这个平方数结点所在的level就是可用分解的最小组合数。
实现代码:
public int numSquares(int n) {
if (n < 1) return -1;
List<Integer> squareNums = computeSquareNums(n);
return bfs(n, squareNums);
}
// 计算小于等于n的完全平方数
private List<Integer> computeSquareNums(int n) {
List<Integer> list = new ArrayList<>();
for (int i = 1; i * i <= n; i++)
list.add(i * i);
return list;
}
// bfs按层遍历
private int bfs(int n, List<Integer> squareNums) {
Set<Integer> set = new HashSet<>();
set.add(n);
int level = 0;
while (!set.isEmpty()) { // 逐层遍历
level++;
Set<Integer> tmpSet = new HashSet<>();
for (Integer remainder : set) {
for (Integer squareNum : squareNums) {
if (remainder.equals(squareNum)) return level;
else if (remainder < squareNum) break;
else tmpSet.add(remainder - squareNum);
}
}
set = tmpSet;
}
return level;
}
总结
深度优先搜索和广度优先搜索分别是使用递归法不断深度和使用迭代法层层推进遍历数据结构元素的2个经典算法,常用于解决连通、最短路径、树的最小深度等问题,应用范围广泛。希望读者朋友活学活用。
The end.
转载请注明来源,否则严禁转载。