注意:这里是JAVA自学与了解的同步笔记与记录,如有问题欢迎指正说明
目录
前言
因为昨日修改论文耽误了部分时间,故昨天没能继续完成34日的内容,今天继续。
一、关于深度优先搜索
深度优先搜索(Depth First Search:DFS)属于图的遍历算法的一种,其过程简要来说是对每一个可能的分支路径深入到不能再深入为止。DFS是图论当使用非常频繁的算法,同时也是所有暴力算法中最常见的一种策略,学会写DFS是一个从入门到基本的必修课。已经了解DFS的朋友若只想看迭代实现的过程可直接看第三部分。
DFS主要通过优先先访问到分支结点便进入遍历,由此使得遍历可以不断向下深入,所以相比BFS广度,DFS有更加深的纵深。此外,光知道向下遍历肯定是不行的。DFS还会通过回溯去处理一些结点没能用完分支,往往回溯操作是最深的那个结点无法再深入时发生。而回溯过程中一旦遇到有空闲分支没访问的就进入这个分支,因此这个回溯操作并非一股脑回溯到初始结点,而是在回溯中还能兼顾一定的搜索功能。
以这个图\(D_1\)为例,我们从a点开始遍历。为了方便表示,我们以蓝色结点表示资源尚未用完(资源是否用完必须要在回溯后发现无可用结点时来确定),还可以回溯。而橙色结点表示已经回溯完毕,无可用资源和邻边。
(见下图)a结点具有c与e两个分支,但是依据DFS的标准,在我们发现他的一个分支结点后便立即访问,因此哪怕a的另一个分支e尚未被访问。按照这个逻辑,我们在接下来的深度访问中还会漏掉d的第二个分支f。访问顺序为[a]、[c]、[b]、[d]。
(见下图)当访问进行到b的时候,他的唯一分支c已经被访问过,因此进入回溯,而回溯的第一个结点就是d,我们将会对d遗漏第二个分支f进行访问。访问顺序为a、c、b、d、[f]。
(见下图) 然后到f结点的时候发现f结点并没有可用的邻边,因此需要再度回溯到d;d这个时候已经完成了向b和向f的分支探寻了,因此现在d也无可用的分支了,所以要继续回溯到c结点;c结点的唯一分支d已经在第一次深度探索时被使用过了,因此c也无可用邻边,故再度回溯到a结点。
(见下图)终于,在a结点分析时发现了一个空余的分支结点e,于是确定了接下来从a向e方向遍历。访问顺序为a、c、b、d、f、[e]、[d]。
(见下图)在d结点的访问完毕之后,发现d的唯一邻边指向b结点,而b结点已经被访问,故回溯到e结点;e的唯一邻边指向的d结点已经被访问过了,因此回溯到a结点;a结点的两个相连结点c月e都已经被访问过了,因此在回溯...好吧,已经没有可以回溯的点了,所以DFS遍历结束,遍历顺序为:a、c、b、d、f、e、d。
总结DFS的过程,可用写为以下三步。
- 对当前结点进行访问。
- 若当前结点存在一个相邻结点未被访问就进入这个结点(回到第一步),否则执行下一步
- 回溯到上一个结点,进入第二步。若无节点可回溯,程序结束。
若用程序流程图可用简单表示为:
二、关于深度优先搜索的实现逻辑
因为DFS有回溯的需求,因此这个过程有非常明显的结点暂存的痕迹。我们需要用一个数据结构来暂存我们进入过但是尚未访问完全的结点,自然而然地,我们第一可以想到利用栈来完成。但是实际上,在现实生活中DFS的实现过程更多是用递归方式来完成,因为这样的方式比较快而且简单。而且,刚刚的操作只要你认真思考一下你就会非常熟悉,因为这种“ 先访问当前元素然后向下搜索邻边,待到无法继续搜索后回溯 ”其实就是树的前序遍历思想。
这会是偶然么?其实并不,因为DFS的每次搜索操作确实可以总结为一次重复的过程,作为将全局的大问题划分为小问题的自顶向下的过程。而每次的搜索一旦划分为一个递归函数后,DFS要求的先访问结点再查看邻边的流程注定导致了函数体中访问结点的操作要优于进入下一次递归的操作,而这个顺序完全符合前序遍历的“ 根 - 左子树递归 - 右子树递归 ”的思想。因此,可以认为,若对树进行DFS其实就是在进行前序遍历,只不过邻边固定最多只有两个,而且邻边不会被之前的遍历访问(因为树无环)
本篇我们的重点在于利用栈辅助的迭代,虽然说递归过程可能会简单些,但是灵活地学习栈替代的迭代写法可以加强我们对于迭代的思维。同时也是与昨日采用的队列进行照应,图遍历的DFS与BFS与线性结构的两架马车——栈与队列的照应本身就是一件很有意思的事情。我在写栈实现二叉树迭代中序遍历那篇内容里面已经比较深入地提到过递归到迭代的思维转换过程,同时在前序的迭代转换中提到了先访问再递归的思路转换到迭代,这里就不过多赘述了。更多的内容我们通过代码一步步讲解。
三、迭代实现深度优先搜索的代码逻辑与实现
首先需要说明的,类似于昨日的BFS代码,这里的代码也是基于普遍的连通子集的,是对全图的遍历,而绝非单独的强连通域而论,因此还是采用两个函数分离。一下关于昨日重复的内容便不再冗余说明。
/**
*********************
* Depth first traversal for all nodes.
*********************
*/
public String depthFirstTraversalForAllNodes() {
int tempNumNodes = connectivityMatrix.getRows();
boolean[] tempVisitedArray = new boolean[tempNumNodes];
String resultString = "";
for (int i = 0; i < tempNumNodes; i++) {
if (tempVisitedArray[i] == false) {
resultString += depthFirstTraversal(i, tempVisitedArray);
} // Of if
} // Of for i
return resultString;
}// Of depthFirstTraversalForAllNodes
首先是面向全体结点的DFS遍历,设计好统一的标记数组,若未被访问则进入,对以i结点未开始的DFS遍历。
重点是下面的核心代码部分。我们代码的实现是利用一个栈来描述当前遍历的情况,栈顶元素时刻表示着当前正在讨论的结点,也就是说,我们一切关于“ 这个结点是否有可用的相邻边 ”的问题的这个结点都是栈顶1号结点(见下图)。而2号结点始终表示着当前的结点的上一次访问,简单来说就是当前结点的回溯。一旦当前1号结点找不到邻边后我们就要立马出栈1号结点而使用2号结点作为回溯,参与下次选邻边的讨论。
那么这么,栈的什么状态可以表示当前程序结束了呢?并不是栈空,而是栈内元素只有一个且这个元素找不到任何可用邻边时。首先,这个点没有邻边可以找了,其自身也经历过访问了,所以已经没有用处了;其二,这个结点已经是栈内最后一个结点了,已经没有下一个可用的“ 回溯结点 ”了,翻译一下就是:这个模拟程序已经无法有效的回溯了,故DFS应结束。
下面是一些基本的栈初始化和矩阵宽度获知,访问数组的标记(这步操作对于初学者比较容易漏,一定要注意)此外,我们定义,对于数据的访问在入栈时就可以进行,因此这里先对第一个数据进行访问。这里栈内只有一个结点,但是这个结点的邻边还未讨论,因此其不算是“ 无用 ”的结点,因此虽然栈深度只有1但是并不会结束程序。
tempIndex是我们的工作标记,用于代表栈顶元素序号,而tempNext是用于描述邻边信息的,若我们讨论邻边过程中发现了全新的结点,那么tempNext就时这个全新元素的序号,否则为-1(将在接下来初始化)
ObjectStack tempStack = new ObjectStack();
String resultString = "";
int tempNumNodes = connectivityMatrix.getRows();
//Initialize the stack.
//Visit before push.
tempVisitedArray[paraStartIndex] = true;
resultString += paraStartIndex;
tempStack.push(new Integer(paraStartIndex));
System.out.println("Push " + paraStartIndex);
System.out.println("Visited " + resultString);
//Now visit the rest of the graph.
int tempIndex = paraStartIndex;
int tempNext;
Integer tempInteger;
下面是这个代码的核心循环,若暂时敲不定怎么写循环结束可以先写个while(1)永循环,或者说循环结束条件也是某个中间条件的一部分,那么其实可以把这个循环出口放在中间部分而整体继续使用永循环。
tempNext = -1表示当前尚未发现可用的邻边,这个变量本身即是一个标记也是一个邻边信息载体。后续在for循环中,以tempIndex工作标记为中心结点判断其邻边,若已经访问过(tempVisitedArray已经被标记)则跳过,若此结点不可达(通过邻接矩阵查询得到)那么也跳过。最终确定邻边后进行相应的标记和遍历,并且更新tempNext。这里最需要注意的就是,我们的for循环一旦找到了结点便直接退出for循环,这个正是DFS的纵深遍历的特点,找到结点便不犹豫而直接遍历。
tempNext作为标记与信息的载体参与到最后的判定:作为标记的功能,在无可用邻边时,其触发回溯操作——即出栈操作;作为信息传递功能,可用记录下一个可用的邻边结点,在最后交付给工作标记tempIndex,从而用作下一次遍历的中心结点来找邻边。
出栈的内涵在刚刚上述的介绍中已经有所提及,这里需要注意这么几点:
- 触发出栈操作时已经说明当前栈顶元素已经没有可用出边了,当前结点已经“无用”了。
- 栈内只有一个元素说明无法回溯了,这时程序应当结束。
- 出栈后要读取下一次栈顶元素作为回溯元素,这个元素不能出栈,因为这个元素可能还有邻边可以访问,它还有用。
while (true) {
// Find an unvisited neighbor.
tempNext = -1;
for (int i = 0; i < tempNumNodes; i++) {
if (tempVisitedArray[i]) {
continue; // Already visited.
} // Of if
if (connectivityMatrix.getData()[tempIndex][i] == 0) {
continue; // Not directly connected.
} // Of if
// Visit this one.
tempVisitedArray[i] = true;
resultString += i;
tempStack.push(new Integer(i));
System.out.println("Push " + i);
tempNext = i;
// One is enough.
break;
} // Of for i
if (tempNext == -1) {
if ((Integer) tempStack.size() == 1) {
break;
} // Of if
int tempPopElement = (Integer) tempStack.pop(); // The current node has no adjacent edges
System.out.println("Pop " + tempPopElement);
tempIndex = (Integer) tempStack.top(); // Go back to the previous node(Don't pop it)
} else {
tempIndex = tempNext;
} // Of if
} // Of while
最后我还是贴出全部代码,因为这部分和老师描述的有些许出入。我的代码主要是针对全图遍历而不是单独强连通域遍历。
/**
*********************
* Depth first traversal.
*
* @param paraStartIndex The start index.
* @return The sequence of the visit.
*********************
*/
public String depthFirstTraversal(int paraStartIndex, boolean[] tempVisitedArray) {
ObjectStack tempStack = new ObjectStack();
String resultString = "";
int tempNumNodes = connectivityMatrix.getRows();
// Initialize the stack.
// Visit before push.
tempVisitedArray[paraStartIndex] = true;
resultString += paraStartIndex;
tempStack.push(new Integer(paraStartIndex));
System.out.println("Push " + paraStartIndex);
System.out.println("Visited " + resultString);
// Now visit the rest of the graph.
int tempIndex = paraStartIndex;
int tempNext;
Integer tempInteger;
while (true) {
// Find an unvisited neighbor.
tempNext = -1;
for (int i = 0; i < tempNumNodes; i++) {
if (tempVisitedArray[i]) {
continue; // Already visited.
} // Of if
if (connectivityMatrix.getData()[tempIndex][i] == 0) {
continue; // Not directly connected.
} // Of if
// Visit this one.
tempVisitedArray[i] = true;
resultString += i;
tempStack.push(new Integer(i));
System.out.println("Push " + i);
tempNext = i;
// One is enough.
break;
} // Of for i
if (tempNext == -1) {
if ((Integer) tempStack.size() == 1) {
break;
} // Of if
int tempPopElement = (Integer) tempStack.pop(); // The current node has no adjacent edges
System.out.println("Pop " + tempPopElement);
tempIndex = (Integer) tempStack.top(); // Go back to the previous node(Don't pop it)
} else {
tempIndex = tempNext;
} // Of if
} // Of while
return resultString;
}// Of depthFirstTraversal
/**
*********************
* Depth first traversal for all nodes.
*********************
*/
public String depthFirstTraversalForAllNodes() {
int tempNumNodes = connectivityMatrix.getRows();
boolean[] tempVisitedArray = new boolean[tempNumNodes];
String resultString = "";
for (int i = 0; i < tempNumNodes; i++) {
if (tempVisitedArray[i] == false) {
resultString += depthFirstTraversal(i, tempVisitedArray);
} // Of if
} // Of for i
return resultString;
}// Of depthFirstTraversalForAllNodes
四、数据测试
今天沿用昨日的测试案例:
/**
*********************
* Unit test for depthFirstTraversal.
*********************
*/
public static void depthFirstTraversalTest() {
// Test an undirected graph.
int[][] tempMatrix = { { 0, 1, 1, 0 }, { 1, 0, 0, 1 }, { 1, 0, 0, 0 }, { 0, 1, 0, 0 } };
Graph tempGraph = new Graph(tempMatrix);
System.out.println(tempGraph);
String tempSequence = "";
try {
tempSequence = tempGraph.depthFirstTraversalForAllNodes();
} catch (Exception ee) {
System.out.println(ee);
} // Of try.
System.out.println("The depth first order of visit: " + tempSequence);
}// Of depthFirstTraversalTest
测试结果如下图:
总结
虽然今天我们讲的是迭代的DFS,但是我还是想谈谈递归DFS的重要性。
我第一次学习DFS的时候是在大一第一次接触算法的时候,那时接触到的关于图的知识非常少,甚至连递归的内涵都完全没有形成一个成熟的理念。当时更多的是对于DFS认知只是任务它是一种暴力枚举的一种递归算法,但困惑为什么叫做深度优先搜索。后来随着后续知识体系的逐渐建构,我才逐渐认识到其“深度”的含义是针对遍历树的特征而言的,是与BFS对立的一种概念。
当然也许正是因为这个知识的空窗期,我到现在也更多认为DFS并不单单是针对图的遍历而使用的工具,而更像是一种强大的搜索工具,只是附带来说可以用于图的遍历。
深搜的思想可以应用于任何你可以想到的场所,动态规划可以用DFS的记忆化搜索来完成,线性结构也可以用DFS完成暴力枚举(比如求子集等操作),二叉树、树、森林也可以DFS。DFS是暴力搜索的代名词;是任何搜索型算法的重要工具;是实现递归的最常见套路;是串联复杂数据结构最普遍的思路。
如果你决心学好一些关键的算法和递归实现,灵活掌握DFS是必经之路。而这个过程中,想必你自身也能感觉到你自身递归实现的飞跃。