日撸 Java 三百行(25 天: 栈模拟树的中序遍历)

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

目录

前言

一、迭代实现遍历的可行性

二、前序遍历的迭代实现思想

三、前序遍历的迭代思想的代码复述

四、数据模拟

 总结


前言

        这可能是你看见的把栈模拟树的中序遍历讲得最复杂的博客了

一、迭代实现遍历的可行性

        我在写栈那一篇的时候提到过,大多时候栈是可以代替部分递归运算的,因为我们的递归在本质上就是计算机内部存储空间中的栈在进行辅助完成的,所谓递归之思想不外乎:每一次递归调用都会把函数的局部变量、参数值和返回地址等压入调用栈中,然后递归返回的时候,从栈顶弹出上一次递归的各项参数,所以这就是递归为什么可以返回上一层位置的原因。而这种特性与我们在讲树的基本操作中提到的树遍历的" 搁置当前选择 "的思想是一致的,例如我们在前序遍历时,我们会搁置对于当前根结点的访问而优先去访问左子树,等到左子树访问完毕之后,再来访问根结点,乃至于右子树。

二、前序遍历的迭代实现思想

        其实要讲清楚树的遍历的递归思想要比具体思想他要麻烦呢,因为这个算法的思路远不如递归实现那么简单,递归中自然存在的搁置不操作思想在迭代中却要用栈做复杂的考虑,要完成这些代码,我们需要对前序遍历进行解构。因为递归设计的思想是分解问题为若干个操作相同的子操作的过程,但是迭代却不是这种思路,迭代需要将问题化解若干步类型可能不同,操作复杂级也可能不同的操作,我们设法找到这些操作的循环结点并将问题构成一个可操作性循环。

        今天我尽力把这部分用语言数清楚吧!

日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第1张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第2张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第3张图片

        我们以上图的这个二叉树作为参考,如果我们按照中序遍历的递归思路的话,最开始,我们总是不断从递归函数新的递归函数中,因为我们前序遍历的代码主体中的第一行代码就是进入左孩子的函数入口,所以代码总是优先进入左子树,每次进入左子树后便进入了一个新的递归函数中,我们记录这是一个截然不同的调入点1,然后到左子树之后若此左子树内还有左子树那么继续向左子树方向迭代,从上图来看就是橙色标记的这个顺序。

        但是要清楚一点!我们这里展示的顺序并非是实际访问结点的顺序,而是递归的顺序,若用栈模拟,则就是入栈进行模拟的顺序,这个大家必须留个心眼。中序遍历的递归思想中,对于结点的访问是留在左子树遍历完了后——递归函数完成操作后进行回溯时;中序遍历的迭代思想中,在栈的辅助下,这句话就应该翻译成,对于结点的访问留在出栈时。日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第4张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第5张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第6张图片

         我们继续回到最开始从根节点开始进入迭代的这条路,当我们不断进入(入栈)到了D结点,我们本能地继续准备入D结点的左孩子,发现他没有左孩子了,那么这次进入(入栈)就失败,当前结点的操作自然结束。放在我们的递归代码中来看,就是第一个if条件执行失败,按照顺序,我们应该直接执行当前节点的访问操作(就是图中的打印语句);那么放到迭代算法中,当前函数节点所在的位置(图中名为inOrderVisit的当前函数在执行时在内存中的入口地址)刚好是我们栈顶的位置(我们直接构造的栈结构的栈顶的元素实际在物理空间中存放的位置),因为这个结点是最近一次入栈的,这有个Tips,你可以无脑认为栈顶结点,或者说最近一次入栈结点元素就相当于我们递归编程中的当前函数,因为这个思想是与计算机编译器内部控制调用的实际思路一致的。

        细细理解了这段绕口的解释后便不难理解下面的操作了:现在要访问当前递归函数内的value,等价翻译这句话,就是把栈顶结点D出栈,并且取出这个结点的值就好了。

日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第7张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第8张图片

        然后当前函数的操作好像还没完,按照顺序,继续模拟下一步:我们要访问当前函数的右子树。那么一对一翻译,就是要迭代进入右子树。刚好我们刚从栈内取出的结点还保留在局部变量中,那么照样地,我们把右子树加入到栈中去就好了。但是我这里的D结点似乎已经没有右子树可进入了,于是当前节点自然结束,进入下一轮了。下一轮与当前已经是完全不同的结点,记录这是一个截然不同的调入点2。

        但如果其有右孩子,那么就进入这个右孩子(右孩子入栈),这部分操作转换为下一个右孩子的操作,右孩子自己也是个独立的结点,所以记录这是一个截然不同的调入点3。当然!不要忘记,我们进入新的结点后不是访问这个结点,而是“进入”罢了,翻译为具体的迭代操作是入栈,我们刚刚说过,对结点的访问恒定是在出栈时进行的,也就是递归概念中的回溯时。日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第9张图片日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第10张图片

        我们再试着走几步操作吧,当出栈D后访问了结点value后,发现了D结点没有右孩子后,当前节点自然结束,回溯到B结点,因为上回合是自然结束的,所以本回合是回溯的回合,所以没必要进入左子树,直接进行访问操作。然后看B点是否有右孩子,若有,则进入右孩子(右孩子入栈)。然后现在的情况与最开始D结点刚入栈是一样的,想想我们原来是怎么操作的?我直接给大家放出来,并把D改成E就对了:

当我们不断进入(入栈)到了E结点,我们本能地继续准备入E结点的左孩子,发现他没有左孩子了,那么这次进入(入栈)就失败,当前结点的操作自然结束。放在我们的递归代码中来看,就是第一个if条件执行失败,按照顺序,我们应该直接执行当前节点的访问操作(就是图中的打印语句);

         好了,我们的模拟到此为止,再往后面说多了容易糊涂,我们下面直接通过上面的信息说下代码的过程。

三、前序遍历的迭代思想的代码复述

        如果你看了我上面的描述后你会发现我留了许多红色字体,这些并非平白无故,而是我做的标记。迭代的代码需要依靠循环来驱动,但是要找到如此多复杂麻烦规则下的循环点并不是一件容易事。所以我尽可能在上面讲述步骤的时候把操作会出现循环地方都说了句“记录这是一个截然不同的调入点”。而在当前结点可能会出现null访问的时候都说了“自然结束”,这个其实是一个关键的循环的情况。为了方便大家理解,我把这个遍历过程总结为几个部分:

  1.  得到了当前的核心结点(核心结点的含义其实可以等价理解为递归方法中的当前函数)
  2.  核心结点若有左子树:【左结点入栈,进入左子树,并回到第一步】;若无则进入第三步
  3.  出栈取得结点,将其作为核心结点,并访问其值
  4.  若核心结点有右子树,【右结点入栈,进入右子树,并回到第一步】;若无则进入第三步

         解释:1. 进入X子树的含义就是替换当前核心结点为当前核心结点的X孩子;2.步骤中的所有“ 并回到第一步 ”,对应我上问反复暗示的“记录这是一个截然不同的调入点”。对于我所有暗示的“ 若无则进入第三步 ”,对应我反复暗示的“自然结束”。在了解这个之后大家若时间足够的话,可以试着再看下第二部分的思路。

        然后代码如下:

	/**
	 * 
	 *********************
	 * In-order visit with stack.
	 *********************
	 */
	public void inOrderVisitWithStack() {
		ObjectStack tempStack = new ObjectStack();
		BinaryCharTree tempNode = this;
		while (!tempStack.isEmpty() || tempNode != null) {
			if (tempNode != null) {
				tempStack.push(tempNode);
				tempNode = tempNode.leftChild;
			} else {
				tempNode = (BinaryCharTree) tempStack.pop();
				System.out.print("" + tempNode.value + " ");
				tempNode = tempNode.rightChild;
			} // Of if
		} // Of while
	}// Of inOrderVisitWithStack

         在代码实现过程中又进行了些必要的统一化过程,因为你可以非常明显发现,上面的四个步骤中都有统一的回到第一步回到第三步的描述,我们可以归纳这些操作。于是在代码中,无论有没有左右孩子,统一地、一律地执行进入操作(让当前核心结点tempNode直接转变为对应的子结点),然后把这个结点是否有效放到了下一回合去辩论。四个步骤中,左右孩子存在的话最终都会回到第一步,于是,我们可以认为在tempNode非空时都进入触发进入第一步所属的操作,这就是图中if语句部分的来由(四步骤中第一步骤+第二步骤前半部分是紧密联系在一起的);相对的,左右孩子任何一个不存在的话最终都会回到第三步,因此这就是else语句的来由(四步骤中第三步骤+第四步骤前半部分是紧密联系在一起的)。

        至于最后的while操作只有在所有问题捋清楚后再去设计才容易,不然一开始就去考虑怎么设计while就显得太难了。这里只需要想几个特殊案例就好:

  1. 可能出现tempNode空而栈不空的情况,这种时候是正常的无儿子情况,代码需要正常循环;
  2. 可能出现tempNode不空而栈空情况,这种情况太好举例了,程序刚开始执行就是这个情况;
  3. 都不满就不说了,肯定是安全的;
  4. 最后是都空了的情况,栈在不是初期的时候空只能意味着所有结点都访问完了,因为出栈就立马会访问这个结点,如果都出栈了表现出来的就是栈空。而tempNode空也证明了这个时候并非初期,一定是在中途。

        因此while设计如代码所示。这种不先设计循环条件而放在最后设计是一种非常高效的程序设计技巧,这种时候往往在初期不是特别了解程序细节内容,而在后期才明白时是最有用的。

四、数据模拟

    // 4.4 updata
	char[] tempCharArray = { 'A', 'B', 'C', 'D', 'E', 'F' };
	int[] tempIndicesArray = { 0, 1, 2, 4, 5, 12 };
	BinaryCharTree tempTree2 = new BinaryCharTree(tempCharArray, tempIndicesArray);

	System.out.println("\r\nPreorder visit:");
	tempTree2.preOrderVisit();
	System.out.println("\r\nIn-order visit:");
	tempTree2.inOrderVisit();
	System.out.println("\r\nPost-order visit:");
	tempTree2.postOrderVisit();
	
	// 4.5 updata
	System.out.println("\r\nIn-order visit with stack:");
	tempTree2.inOrderVisitWithStack();

日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第11张图片

        为方便,今天的数据就照搬昨日的吧,运行结果如下(通过与递归写法对照,证明了其正确)

日撸 Java 三百行(25 天: 栈模拟树的中序遍历)_第12张图片

 总结

       递归转迭代的内容涉及两种思维转换,如果说我在递归实现那篇博客着重说了怎么将常规迭代思维设计成递归,那么今天属于是逆过程的。今天文章的第二部分我用了很多话着重去说明了基于递归思路去翻译迭代思维,我是第一次这样描述,多有不准确欢迎指正。若想了解为什么代码要这么写的话可以看下第二部分,但若只想了解在怎么写的话,可以直接看第三部分,这里我直接总结的第二部的四个步骤,可能理解能更快。其实我对于用栈模拟递归的操作,不是特别熟悉,描述部分可能有些地方显得冗杂,欢迎指正。

        树遍历的递归转迭代这部分代码简单,但是要讲清楚这个方法的内涵的话......是真的很不容易,像这种思维难但代码简单,而且是一看就懂一写就懵逼的代码,我往往在相关比赛或者考试之前我都是直接记模板的......当然彻底搞懂也倒不是什么比登天还难的事,但是可能是因为这个方法真的不算实用,所以我感觉我一直就在用用递归就好的思想在搪塞自己:

        因为,虽然栈模拟迭代的程序执行速度会略快于递归遍历,但是就程序的易用性与方便性来说,这个略快的速度显得性价比还不算太高。而且,对于体量本身就大且复杂的递归案例的话,再用栈来进行模拟只会难上加难,所以我们在了解数据结构的相关内容时,最多只提供了这些像树遍历这些简单递归操作的栈迭代模拟,并不会去推广其他复杂递归的迭代模拟,若真的模拟,那都是换思路的模拟(比如有些记忆化搜索的递归可以用迭代的动态规划完成,但是这样基本上都换思路,与栈模拟递归没关系了)。

        但是我们仍要试着学习这种操作,一方面,这个操作能加深我们对于栈的递归功能的掌握,另一方面,写这种代码能非常锻炼一个人的迭代思维的整合能力。所以这样的问题可能会被公司大厂的面试官用于面试的一些测试中,去查看一个程序员整合程序过程的功底。或者说一些学习的考试题目什么的。

       
                

你可能感兴趣的