在了解深度优先搜索之前,我们先来了解一下图的处理算法的设计模式,这个可以帮助我们更好的理解下面的算法,也能为我们自己在思考图的算法时,提供一点有价值的指导。
图的处理算法的设计模式:
因为我们会讨论大量关于图的算法,所以设计的首要目标是将图的表示和实现分离开来。为此,我们会为每个任务创建一个相应的类,用例可以创建相应的对象来完成任务。类的构造函数一般会在预处理中构造各种数据结构,以有效地相应用例请求。典型的用例程序会构造一幅图,将图传递给实现了某个算法的类(作为构造函数的参数),然后调用用例的方法来获取图的各种性质。
深度优先搜索
探索迷宫而不迷路的一种古老办法(至少可以追溯到忒修斯和米诺陶的传说)叫做Tremaux搜索,要探索迷宫中的所有通道,我们需要:
- 选择一条没有标记过的通道,在你走过的路上铺一条绳子;
- 标记所有你第一次路过的路口和通道;
- 当来到一个标记过的路口时(用绳子)回退到上个路口;
- 当回退到的路口已没有可走的通道时继续回退。
绳子可以保证你总能找到一条出路,标记则保证你不会两次经过同一条通道或者同一个路口。如下图所示:
思考与之等价的问题——迷宫,是在思考图的搜索过程中一种有益的方法。用迷宫代替图,通道代替边,路口代替顶点,这么做可以帮助我们直观的认识问题。
我们常常通过系统地检查每一个顶点和每一条边来获取图的各种性质。
要知道是否完全探索了整个迷宫需要的证明更复杂,只有用图搜索才能更好地处理问题。Tremaux搜索很直接,但它与完全搜索一张图仍然稍有不同,我们可以看看图的搜素方法。
//深度优先搜索:
热热身:
搜索连通图的经典递归算法(遍历所有的顶点和边)和Tremaux搜索类似,但描述起来更为简单。要搜索一幅图,只需要一个递归方法来遍历所有顶点。在访问其中一个顶点时:
- 将它标记为已访问
- 递归地访问它的所有没有被标记过的邻居顶点。
Search API
我们可以先来定义一个Search API 。
这种方法称为深度优先搜索(DFS)。我们很容易就可以想到深度优先搜索标记与起点连通的所有顶点所需的时间和顶点的度数之和成正比。
如深度优先搜素算法所示,它使用一个boolean数组来记录和起点连通的所有顶点。递归方法会标记给定的顶点并调用自己来访问该顶点的相邻顶点列表中所有没被标记过的顶点。如果图是连通的,每个邻接链表中的元素都会被检查到。
单向通道
代码中方法的调用和返回机制对应迷宫中绳子的作用:当已经处理过依附于一个顶点的所有边时(搜索了路口连接的所有通道),我们就只能“返回“。
为了更好的与迷宫的Tremaux搜索对应起来,我们可以想象一座完全由单向通道构造的迷宫(每个方向都只有一个通道)。和我们在迷宫中会经过一条通道两次(方向不同)一样。
在Tremaux搜索中,要么是第一次访问一条边,要么是沿着它从一个被标记过的顶点退回。在无向图的深度优先搜索中,在碰到v-w时,要么进行递归调用(w没有被标记过),要么跳过这条边(w已经被标记过)。第二次从另一个方向w-v遇到这条边时,总是会忽略它,因为它的另一个端点v肯定已经被访问过了。
跟踪深度优先搜索
理解算法的最好方法之一是在一个简单的例子中跟踪它的行为。我们可以在深度优先算法中进行如此跟踪。
然而在跟踪它的轨迹时,首先要注意的是,算法遍历边和访问顶点的顺序与图的表示有关,而不只是与图的结构或是算法有关。因为深度优先搜索只会访问和起点连通的顶点,
如图展示了一幅小型连通图,在示例中,顶点2是顶点0之后第一个被访问的顶点,因为它正好是0的邻接表的第一个元素。
要注意的第二点是:如之前所述,深度优先搜索的每条边都会被访问两次,且在第二次时总会发现这个顶点已经被标记过。这意味着深度优先搜索的轨迹可能会比想想的长一倍!示例中仅含有8条边,但需要追踪算法在邻接表的16个元素上的操作。
深度优先搜索的详细轨迹
如图,显示的是示例中每个顶点被标记后算法使用的数据结构。
- 起点为顶点0查找开始于构造函数调用递归的dfs()来标记和访问顶点0。
- 因为顶点2是0的邻接表的第一个元素且没有被标记过,dfs()递归调用自己来标记并访问顶点2(效果是系统会将顶点0和0的邻接表的当前位置压入栈中)。
- 现在,顶点0是2的邻接表的第一个元素且已经被标记过了,因此dfs()跳过了它,接下来,顶点1是2的邻接表的第二个元素且没有被标记过,dfs()递归调用自己来标记并访问顶点1.
- 对于顶点1的访问和前面有所不同:因为它的邻接表中的所有顶点(0和2)都已经被标记过了,因此不需要在进行递归,因此dfs()递归调用自己来标记并访问顶点3.
- 顶点5是3是的邻接表的第一个元素且没有被标记,因此dfs()递归调用自己来标记并访问顶点5.
- 顶点5的邻接表中的所有顶点(3和0)都已经被标记过了,因此不需要在进行递归。
- 顶点4是3的邻接表的下一个元素且没有被标记过,因此dfs()递归调用自己来标记并访问顶点4.这是最后一个需要标记的顶点。
- 在顶点4被标记了之后,dfs()会检查它的邻接表,然后再检查3的邻接表,然后是2的邻接表,然后是0的,最后发现不需要再进行任何递归调用,因为所有的顶点都已经被标记过了。
这种简单的递归模式只是一个开始——深度优先搜索能够有效处理许多和图有关的任务。例如图的连通性问题、单点路径问题。
寻找路径
单点路径问题在图的处理领域中十分重要。我们将会使用下面这个API。
构造函数接受一个起点s作为参数,计算s到与s连通的每个顶点之间的路径。在为起点s创建了Paths对象后,用例可以调用pathTo()实例方法来遍历从s到任意和s连通的顶点的路径上的所有顶点。
实现
以下算法基于深度优先搜索实现了Paths,在此算法中添加了一个实例变量edgeTo[ ]整型数组来起到Tremaux搜索中绳子的作用。这个数组可以找到从每个与s连通的顶点回到s的路径。它会记录每个顶点到起点的路径,而不是记录当前顶点到起点的路径。为了做到这一点,在由边v-w第一次访问任意w时,将edgeTo[w]设为v来记住这条路径。换句话说,v-w是从s到w的路径上是最后一条已知的边。
public class DepthFirstPaths {
private boolean[] marked; //这个顶点是否已经调用过dfs()了?
private int[] edgeTo; //从起点到一个顶点的已知路径上的最后一个顶点
private final int s; //起点
public DepthFirstPaths(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) {
marked[v] = true;
for(int w : G.adj(v))
if(!marked[w]) {
edgeTo[w] = v;
dfs(G, w);
}
}
public 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;
}
}
这段Graph的用例使用了深度优先搜索,以找出图中从给定的起点s到它连通的所有顶点的路径。为了保存到达每个顶点的已知路径,这段代码使用了一个以顶点编号为索引的数组edgeTo[ ],edgeTo[w]=v表示v-w是第一次访问w时经过的边。edgeTo[ ]数组是一棵用父链接表示的以s为根且含有所有与s连通的顶点的树。如下图小示例: