日撸 Java 三百行(33 天: 广度优先搜索:BFS)

注意:这里是JAVA自学与了解的同步笔记与记录,如有问题欢迎指正说明

目录

一、关于广度搜索

二、借助队列实现广度遍历

三、BFS的代码实现

四、数据测试

 总结


一、关于广度搜索

        我在第22天的关于二叉树的转储中谈及过关于BFS的有关问题,但是当时并没有深入去了解这种遍历方案,今天我们来具体了解下这种针对图的遍历技巧。

正如我们当初在讨论树形遍历提到的问题,因为进一步来讲,一对多或者多堆多结构并没有非常严格意义上的前后之分,不是传统意义的一维结构,无法用“从前向后”或者“从后向前”这样的语言描述以指导基础的遍历或者访问。所以说我们往往会构造一种方案来指导,树形结构尚有前中后+层次遍历之分,那么图自然也具有自己的遍历技巧。

        于是广度遍历便诞生是参考一种辐射思想,自然界中存在一种自然点光源,他总是360°无死角地向周围辐射光波,因为光速不变,所以从时间\(t_0\)开始到一个极其短的时间\(t_1\)内,可以认为光同时向周围辐射的空间构成了一个球体,平面角度来看是一个一个等距离的标准环结构,构成了如同年轮结构。

        只要我们把中心结点向外的每次\(t_0\)到\(t_1\)间隔内至多塞下一个结点,然后节点之间至少存在一个统一向外连接的有向边,那么就可以存在以下图像。在外层环框架下,我们多堆多混乱的结点彼此划分为一层一层的。如果这个图中心是某个树的根节点,那么,辐射出去的结点之间只有唯一向外连边,那么就构成了树(白色结点):这就构成了树的层次结构。如果顶点之间彼此向外连接的情况下允许彼此互联(见蓝色结点),那么这种结构又不一定是树,广义上来讲,这就是图,而环分割的就是图广度遍历的层次。

         而我们在确立图的层次中后,在每层次中按照一种特定顺逆时针方向逐个访问就构成了一次当前层次遍历,其余层次若也按照这种方案就构成了一次广度遍历。

二、借助队列实现广度遍历

        在之前第22天的关于二叉树的转储中我们大概了解了树的层次遍历,当时我们也是通过队列实现的,但是对于图来说,采用队列时要额外注意一些细节问题。这些细节问题我放到下面这个案例中来具体阐述:

         1.见上图。假设先从a点开始遍历,首先我们需要a点率先入队

       2.见上图。之后我们进行出队得到队首元素,并且以这个刚出队的元素(橙色标记)为基础,分别访问其邻边,依次遍历得到“b”、“c”、"d"、“e”,并以此将其入队。基础元素a的任务因此完成,故对a进行访问。

         3.见上图。出队得到新一轮的基础元素b并且以之为基础元素访问邻边以加入本回合需要入队的元素,但是因为b元素未有邻边故直接结束当前操作。

         4.见上图。出队得到新一轮的基础元素c并且以之为基础元素访问邻边以加入本回合需要入队的元素,但是因为c元素未有邻边故直接结束当前操作。

         5.见上图。出队得到新一轮的基础元素d并且以之为基础元素访问邻边得到元素g,因此将元素d入队,本回合结束。

        6.见上图。出队得到新一轮的基础元素e并且以之为基础元素访问邻边得到元素g,计划将其入队,但是我们发现这里邻边已经存在与队列当中,因此不可能重复入队,因此跳过本回合。相比到这里大家应该能发现图与树的一个差别了,我们在树的层次遍历中,我们似乎不会去仔细分析元素是否出现之前就已经出现在队列的问题。出现这个情况的原因很简单:树形结构不存在环,而图可能存在环。环结构在遍历的时候会出现,往往对于这样的情况我们会采用标记的思想来避免访问的回环,这种方式是对于环结构常见的避免策略,我会在接下来的代码给出这个方法。

 

         7.见上图。继续操作,出队得到新一轮的基础元素g并且以之为标志访问邻边得到元素h与i,因此将其入队,本回合结束。

 

         8.继续操作不再赘述,分别出队得到基础元素h,但是h的邻边i已经入队,因此无法再进行入队操作;后续出队i,但是i已经无邻边无法再入队,进入下回合。下回合要出队时发现队列已空,算法结束。

        最后我将这个图的层次标记出来,让大家可以更加深刻体验到BFS基于的点的辐射思想:

        通过上述的描述不知道大家发现了规律没有,我将上述关键的入队出队分别用红色标记出来,细细观察可以发现,红色字体连接起来总是“出队入队出队入队...”的一个循环过程,因此这个算法的循环点非常容易发现,因此代码设计也不会非常难。

        但是额外需要强调一个内容,基于单独一个结点的遍历只能覆盖所有强连通的区域,有些图不一定能通过单独一个结点遍历图中全部结点,因为一个图可能有众多独立强连通的子区域相互构成(强连通分量≥1),在具体图上,可能是如下\(D_1\)与\(D_2\)两图所示。

         这两个图的强连通分量都是2,\(D_1\)也许不难发现,但是\(D_2\)你可能不容易发现。其实\(D_2\)中,若你从c、b、d出发遍历的话,并不存在任何一条可达a的边,因为a只存在一条出边。其实就我们刚刚用队列模拟的例子来看,连通分量也不是1而是8,若从i结点出发你甚至无法到达任何顶点。这是相比树的层次遍历来说,通用的BFS最特殊的地方,即BFS通常可能不是一次遍历就能完成的,往往需要多次遍历。我们解决这种问题的常用办法依旧是采用标记来避免重复访问。

三、BFS的代码实现

        通过上面的分析,为了避免漏掉非强连通域的结点,我们需要事先构造面向全体结点的遍历操作:

	/**
	 *********************
	 * Breadth first traversal for all nodes.
	 *********************
	 */
	public String BreadthFirstTraversalForAllNodes() {
		int tempNumNodes = connectivityMatrix.getRows();
		boolean[] tempVisitedArray = new boolean[tempNumNodes];
		String resultString = "";

		for (int i = 0; i < tempNumNodes; i++) {
			if (tempVisitedArray[i] == false) {
				resultString += breadthFirstTraversal(i, tempVisitedArray);
			} // Of if
		} // Of for i
		return resultString;
	}// Of BreadthFirstTraversalForAllNodes

         今日BFS方法封装于昨日实现的Graph类中,因此,这里的connectivityMatrix其实是本类的属性,而这里的getRows方法是自定义矩阵类的方法,详见这篇博客。这里的tempVisitedArray提供了一种标记数组,可以存储1-0的二值情况,从而通过对应下标索引得到二值情况反映的使用的下标索引代表的结点是否被访问。我们将用这个避免路径复现

        这个的for循环即对每个结点的访问,若这个图是个强连通图,那么这个for循环内的if语句只会执行一次。因此,这个for中的if执行次数等于此图的强连通分量。

        之后,我们设计进入breadthFirstTraversal函数的一些内容,这部分是BFS的核心,以下是一些初始化操作:

	CircleObjectQueue tempQueue = new CircleObjectQueue();
	String resultString = "";
	int tempNumNodes = connectivityMatrix.getRows();

	// Initialize the queue.
	// Visit before enqueue.
	tempVisitedArray[paraStartIndex] = true;
	resultString += paraStartIndex;
	tempQueue.enqueue(new Integer(paraStartIndex));

        代码中关于通用性队列tempQueue声明的代码实现细节可参考我的这篇博客。下一步,当前结点已经被访问(tempVisitedArray设为true),这一步是写BFS的初学者容易漏掉的一步操作;纳入遍历结点到结果数组,预示被访问;基础结点入队。

		// Now visit the rest of the graph.
		int tempIndex;
		Integer tempInteger = (Integer) tempQueue.dequeue();
		while (tempInteger != null) {
			tempIndex = tempInteger.intValue();

			// Enqueue all its unvisited neighbors.
			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 before enqueue.
				tempVisitedArray[i] = true;
				resultString += i;
				tempQueue.enqueue(new Integer(i));
			} // Of for i

			// Take out one from the head.
			tempInteger = (Integer) tempQueue.dequeue();
		} // Of while

		return resultString;

        如同刚刚进行的模拟,这里简单总结来说,就是重复的出队与入队的循环。以tempInteger作为工作数据,我们始终通过tempInteger获取队首元素,若其为null反映了队列已空,代码结束。否则的话其就代表了队首元素,即基础元素,是我们接下来需要入队结点的父级(入度的源点)。

        所以我们接下里就用for循环分别对基础结点的邻边进行收集,若其尚未被访问过(tempVisitedArray为False)那么就将其入队。具体实现邻边收集这个步骤要视你的存储图的数据结构而定。倘若使用邻接矩阵,循环的就是所有的顶点集:假设取到顶点\(v_i\),我们需要额外通过邻接矩阵的数据来判断\(v_{tempIndex}\)与\(v_i\)是否可达。若采用邻接表,这部分就可优化。

        入队具有一系列伴随操作,这里主要1、标记被访问过;2、输入到结果打印;3、入队。

        当所有操作完成后,通过一个出队以获取下次循环的基本结点来结束当前循环。

        需要最后强调的就是,BFS的核心代码在实现时各有各的方案,写法不唯一。比如说我们可以用队列是否为空最为外围while条件,以及在出队的时候做数据访问等等。

因为这里我的写法与老师的文章有一定出入,老师的代码是只针对强连通区域的,我稍微做了面向全体结点的改进,因此特此贴出完全代码:

    /**
	 *********************
	 * Breadth first traversal.
	 * 
	 * @param paraStartIndex   The start index.
	 * @param tempVisitedArray The visit tag.
	 * @return The sequence of the visit.
	 *********************
	 */
	public String breadthFirstTraversal(int paraStartIndex, boolean[] tempVisitedArray) {
		CircleObjectQueue tempQueue = new CircleObjectQueue();
		String resultString = "";
		int tempNumNodes = connectivityMatrix.getRows();

		// Initialize the queue.
		// Visit before enqueue.
		tempVisitedArray[paraStartIndex] = true;
		resultString += paraStartIndex;
		tempQueue.enqueue(new Integer(paraStartIndex));

		// Now visit the rest of the graph.
		int tempIndex;
		Integer tempInteger = (Integer) tempQueue.dequeue();
		while (tempInteger != null) {
			tempIndex = tempInteger.intValue();

			// Enqueue all its unvisited neighbors.
			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 before enqueue.
				tempVisitedArray[i] = true;
				resultString += i;
				tempQueue.enqueue(new Integer(i));
			} // Of for i

			// Take out one from the head.
			tempInteger = (Integer) tempQueue.dequeue();
		} // Of while

		return resultString;
	}// Of breadthFirstTraversal

	/**
	 *********************
	 * Breadth first traversal for all nodes.
	 *********************
	 */
	public String BreadthFirstTraversalForAllNodes() {
		int tempNumNodes = connectivityMatrix.getRows();
		boolean[] tempVisitedArray = new boolean[tempNumNodes];
		String resultString = "";

		for (int i = 0; i < tempNumNodes; i++) {
			if (tempVisitedArray[i] == false) {
				resultString += breadthFirstTraversal(i, tempVisitedArray);
			} // Of if
		} // Of for i
		return resultString;
	}// Of BreadthFirstTraversalForAllNodes

四、数据测试

        单元测试代码如下:

	/**
	 *********************
	 * Unit test for breadthFirstTraversal.
	 *********************
	 */
	public static void breadthFirstTraversalTest() {
		// Test an undirected graph.
		int[][] tempMatrix = { { 0, 1, 1, 0 }, { 1, 0, 0, 1 }, { 1, 0, 0, 1 }, { 0, 1, 1, 0 } };
		Graph tempGraph = new Graph(tempMatrix);
		System.out.println(tempGraph);

		String tempSequence = "";
		try {
			tempSequence = tempGraph.BreadthFirstTraversalForAllNodes();
		} catch (Exception ee) {
			System.out.println(ee);
		} // Of try.

		System.out.println("The breadth first order of visit: " + tempSequence);
	}// Of breadthFirstTraversalTest

        测试模拟图如下:

        得到结果如下(符合预期):

 总结

        BFS是图的一个非常重要的算法,图的的不同遍历可以在不同层次和思维上解决不同类型的问题,因此图的DFS与BFS共同构成了图论的两个最主要的工具。对于那些大纵深的图但是已知需要访问的顶点距离直接距离不远的图,合理采用BFS可以在一定程度上降低执行的复杂度,避免陷入访问过深导致系统栈满。

        BFS同时还是很多算法的跳板,例如我们可以用BFS实现无权的单源最短路径问题,是解决单元最短路径的一种关键思路,因此后来的Dijkstra单源最短路径算法和Prim最小生成树算法都采用了和BFS类似的思想。同时其巧妙利用队列的想法被Richard Bellman与Ford发现从而提出了解决负权最短路径问题的Bellman-Ford算法,Bellman-Ford算法虽然本身复杂度较高但是却也成了后续许多高级最短路径算法譬如SPFA这些算法的基础。因此可以发现,BFS对于最短路径问题的解决体系的贡献是巨大的,其简单的思路却成为后续众多强大算法的奠基石。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值