遍历的意义
数据结构是存储数据的,在存储数据后,必然是要进行增删改查操作的,那么我们就需要至少一中方式对存放进数据结构进行遍历。 每一种数据结构都必须有遍历的方式。比如树的遍历。
很多算法的本质就是遍历。图论更是如此。 图通常不是用来存储数据,而是存储一种拓扑关系,为了获得这种关系,通常需要把整个图遍历一遍,同时还要在遍历时记录一些东西。相比较而言,在树(一种特殊的图)中我们在某些情况下不用全部进行遍历,比如二分搜索树中,我们每次选择是进入左子树,还是进入右子树。
图的深度优先遍历
我们先看一下二叉树的深度优先遍历:二叉树的前中后序遍历都是深度优先遍历。
同时我们可以回顾一下N叉树的前序遍历和中序遍历。
我们不用担心节点的重复遍历,因为树的节点之间没有交叉,没有环。
对于图来说,我们每遍历一个顶点,都需要记录一下哪个节点被遍历了,这样可保证没有重复
由于图的深度优先遍历和树的深度优先遍历有很多相似之处,那么我们首先看一下树的深度优先遍历的递归伪码(前序遍历):
preorderTraversal(root) ;
preorder(TreeNode node){
if(node !=null) {
list.add(node.val); // 遍历
preorder(node.left); // 访问所有子树
preorder(node.right) ;
}
}
// 或者使用下面这种“常见递归格式”
preorder(TreeNode node) {
if(node == null)
return ;
list.add(node.val) ; //程序不同,遍历的操作也相应不同
preorder(node.left) ;
preorder(node.right);
}
从上面的伪码可以看出,二叉树的遍历主要可以分为两个部分,一个是遍历部分,一个访问子树的部分。
其实图的深度优先遍历和树的深度优先遍历在某种程度上来说是一样的。 不同之处在于,(1)遍历这一步,通常图中没有单独的顶点类,而是用一个数子符号进行表示。(2)访问所有相邻的顶点这一步(树是访问所有孩子,本质十分相近)在邻接表存储下是在顶点v的相邻顶点集合中取出一个相邻的顶点。
下面我们看一下图的深度优先遍历的结构:
visited[0...v-1] = false;
dfs(start) ; // 从start 开始进行遍历
dfs(int v) { // v对应树中的node
// 下面两句对应树的遍历操作
visited[v] = true;
list.add(v) ;
// for循环对应树的访问孩子操作
for(int w :adj(v)){ // 访问相邻顶点
if(!visited[w] ) // 这一处是和树的本质不同,因为树没有环
dfs(w) ;
}
}
当然有些同学可能会说了,递归的书写逻辑一般不是都分为两步吗? 第一步是递归终止条件,第二步是具体的函数逻辑。 这里怎么没有呢?
我们知道图遍历的递归终止条件是:这个图一个相邻的顶点都没有,或者是它的所有相邻的顶点都被访问过了。其实这两个条件都是隐含在上面的for循环中的。 如果顶点v一个相邻的顶点都没有,那么for循环根本就进不去,本轮函数执行完毕。 如果v的所有相邻节点都已经被访问过了。那么if(!visited[w])
一直都是false , 内部的dfs函数依然不可能执行。
我们还可以用下面这种方式进行改写,下面的改写逻辑并不完全和上面的说明吻合。以下的逻辑是我第一次学习dfs逻辑时,用学习链表递归思路进行改写的。大家有兴趣可以参考。
// 递归终止条件为 顶点v已经被访问过。
// 递归函数体为: 如果当前顶点没有被访问过,那么我标记它为true。同时对这个顶点的所
// 相邻顶点进行一次访问
// 我这样做的道理是:如果一个顶点被访问过,下次再进入dfs ,递归终止条件的判断,它必
//然会在这里返回,不会进行二次访问。 如果没有被访问,先标记访问。 这样可以保证每一个顶点都只被访问一次。
dfs (int v) {
// 递归终止条件
if(visited[v] == true ) {
return;
}
// 递归函数的具体调用过程
visited[v] = true;
list.add(v) ;
for(int w : adj(v)){
dfs(w) ;
}
}
下面举一个例子具体模拟真实的dfs算法执行过程:
dfs(0)
----dfs(1)
--------dfs(3) // 发现1已经遍历过直接从2开始
------------dfs(2) // 2发现0 3 都已经标记过,直接从6开始
----------------dfs(6) // 6发现2已经标记过,直接从5开始
--------------------dfs(5) // 5发现3 6都已经标记过,本次递归结束 返回上一层顶点6
--------------------// 6没有顶点可以访问,返回上层顶点2
----------------//2的最后一个邻接点6已经访问过,返回上层3
------------//3的最后一个邻接点5已经访问过,返回上层1
--------dfs(4)
-----------//4发现1已经访问过 返回上层1
--------//1的最后一个顶点4已经访问过,返回上层0
----//0的最后一个顶点2已经访问过,由于已经是最顶层,此时整个递归过程结束
我们每dfs一个数字,这个数字就会立刻被标记。按照dfs的顺序,整个递归序列就是
[0,1,3,2,6,5,4]
实现图的深度优先遍历
package dfs;
// 深度优先遍历,这里包装成一个类
import java.util.ArrayList;
import java.util.List;
public class GraphDFS {
private Graph G ;
private boolean [] visited ;
private List<Integer> order = new ArrayList<>(); // 我们不希望外界直接修改
public GraphDFS(Graph G) { // 构造函数 我们在构造函数中已经调用dfs 对图进行遍历 并得到order数组
this.G = G ;
visited = new boolean[G.V()] ;
dfs(0) ;
}
private void dfs(int v){
visited[v] = true ;
order.add(v) ;
for(int w : G.adj(v)){
if(!visited[w])
dfs(w);
}
}
public Iterable<Integer> order() { // 返回给可遍历对象
return order;
}
public static void main(String[] args){
Graph g = new Graph("g.txt") ;
GraphDFS graphDFS = new GraphDFS(g);
System.out.println(graphDFS.order); //list有自己的totring方法
}
}
上面这段代码有一些局限性,它只针对一个连通图有效。也就是说,它只能遍历和0在一起的连通分量。产生这个问题的原因是在初始的构造函数dfs(0)
这里,只针对0所在的连通分量进行了遍历,如果有顶点不和0在同一个连通分量中,那么dfs函数就爱莫能助了。 因子改进的方式是,在初始调用时,也写成一个循环。
for(int v=0 ;v<V ;v++){
if(!visited[v])
dfs(v)
}
图的深度优先遍历的改进
package dfs;
// 深度优先遍历,这里包装成一个类
import java.util.ArrayList;
import java.util.List;
public class GraphDFS {
private Graph G;
private boolean[] visited;
private List<Integer> order = new ArrayList<>(); // 我们不希望外界直接修改
public GraphDFS(Graph G) { // 构造函数 我们在构造函数中已经调用dfs 对图进行遍历 并得到order数组
this.G = G;
visited = new boolean[G.V()];
for (int w = 0; w < G.V(); w++) { // 在这里进行修改
if (!visited[w])
dfs(w);
}
}
private void dfs(int v) {
visited[v] = true;
order.add(v);
for (int w : G.adj(v)) {
if (!visited[w])
dfs(w);
}
}
public Iterable<Integer> order() { // 返回给可遍历对象
return order;
}
public static void main(String[] args) {
Graph g = new Graph("g.txt");
GraphDFS graphDFS = new GraphDFS(g);
System.out.println(graphDFS.order); //list有自己的totring方法
}
}
回顾二叉树的遍历:
二叉树的前序遍历、中序遍历、后序遍历
//前序遍历
preorder(TreeNode node){
if(node !=null) {
list.add(node.val); // 遍历
preorder(node.left); // 访问所有子树
preorder(node.right) ;
}
}
// 中序遍历
Inorder(TreeNode node){
if(node !=null) {
Inorder(node.left); // 访问所有子树
list.add(node.val); // 遍历
Inorder(node.right) ;
}
}
// 后序遍历
Postorder(TreeNode node){
if(node !=null) {
Postorder(node.left); // 访问所有子树
Postorder(node.right) ;
list.add(node.val); // 遍历
}
}
所谓前序遍历 就是遍历发生在两次递归调用前面,中序遍历就是遍历在两次递归调用中间,后序遍历就是遍历在两次递归调用的后面。 同样的方法是不是可以放在图中呢?
看下面的代码:我们在递归调用之前把v加入了list 这个动作和 树的先序遍历十分的相似。其实可以说,这是图的 深度优先先序遍历。 相应的我们就知道了,代码还可以把list.add(v)
放在递归调用之后。 这就是图的 深度优先后序遍历
// 深度优先先序遍历
visitd[0..V-1] = false ;
for (int v = 0 ;v<V ; v++){
if( !visited[v] )
dfs(v) ;
}
dfs(int v) {
visited[v] = true;
list.add(v) ; // 我们在递归调用之前把v这个顶点加入了list
for(int w : adj(v)){
if(!visited[w])
dfs(w) ;
}
// list.add(v) 深度优先遍历后序遍历
}
深度优先遍历的复杂度
在最外层我们对整个顶点序列 进行一次for循环,这个时间复杂度为O(V) 其次我们对每一条边只进行了一次访问,这个时间复杂度是O(E) 因此总的时间复杂度为 O(E+ V)
图的深度优先遍历有什么作用
可以判断整个图是否连通,一次dfs 可以遍历一个连通分量。
可以判断两个点之间是否可达。如果一次深度优先遍历可以把两个点都标记上,那么两个 点之间就是可达的。
可以求两个点之间是否有一条路径。 在dfs中我们可用一个列表保存路径上的顶点。
可以检测图中是否有环。
二分图的检测
寻找图中的桥
寻找图中的割点
哈密顿通路
拓扑排序