二叉树的非递归前序,中序,后续遍历实现

0 前言


    二叉树的前中后序遍历通过递归的方式非常容易实现,通过调整一下打印节点和遍历左右子树的顺序即可实现。而非递归方式实现二叉树则要比递归实现复杂一些。主要原因是,递归的写法屏蔽掉了二叉树的回溯过程。但是,通过非递归方式实现的二叉树遍历,就不得不“人工”地对遍历栈进行控制。本文会对二叉树的前中后的非递归遍历方式进行说明。

1 准备工作


public class TreeUtil {
	
	public static Node makeTree() {	//生成二叉树
		ArrayList<Node> nodes = new ArrayList<Node>();
		for (int i = 0; i < 5; i++) {
			nodes.add(new Node(i));
		}
		Node root = nodes.get(0);
		root.left = nodes.get(1);
		root.right = nodes.get(2);
		nodes.get(1).left = nodes.get(3);
		nodes.get(1).right = nodes.get(4);
		return root;
	}
}
public class Node {	//结点
	Node left;
	Node right;
	int value;

	public Node(int value) {
		this.value = value;
	}
}


2 前序遍历

    

    前序遍历应该是前中后非递归遍历中,最容易实现的遍历方式。主要原因是,在访问某个结点时,可以立即打印当前结点,从而不需要在遍历栈中保存这个结点的状态。


图1 前序遍历

    图2表示了这棵树的前序遍历结点访问顺序。初始化遍历的引用指向0号结点。第1个访问的结点是0号结点,这时可以直接打印出0号结点,然后开始对0号结点的左子树进行遍历。0号结点的左子树的根节点为1号结点,根据前序遍历的规则,那么下一个访问的结点应该是1号结点。对于0号结点而言,由于它的右子树还没有进行遍历,之后的回溯过程应该要回到2号结点继续进行遍历的。所以,当引用指向0号结点的同时,应该同时将2号结点进行进栈保存。同理,当下一个访问1号结点时,应该将4号结点进栈保存,而遍历的引用指向3号结点。当引用指向3号结点时,栈的状态如图3所示:


图2 前序遍历访问3号结点时的栈状态

    由于3号结点已经是叶子结点,此时需要进行回溯。回溯是通过对遍历栈进行出栈操作完成。所以,通过一步出栈操作,从栈中弹出4号结点。于是,将当前的引用指向4号结点。打印4号结点后,发现4号结点又是叶子结点,那么再进行出栈操作,从栈中弹出2号结点。同理,引用指向2号结点,并再次发现当前处于叶子结点,于是再次进行出栈操作。但是,此时的遍历栈已经为空,所以无法进行出栈操作,那么退出循环,结束遍历。此时刚好遍历完图2整棵树,打印顺序为0,1,3,4,2 。

现在,对上述过程进行一下总结:

    1.对于前序遍历,每当访问一个结点时,先打印结点。
    2.如果存在右子树,那么将右子树的根节点进行进栈保存,否则忽略。
    3.如果存在左子树,将遍历引用指向左子树根节点,否则出栈回溯。
    4.循环的退出条件是需要出栈操作时,栈为空,无法进行该操作。

public class PreOrderTraversal {
	private static Stack<Node> stack = new Stack<Node>();
	private static ArrayList<Integer> result = new ArrayList<Integer>();

	public static void main(String[] args) {
		Node root = TreeUtil.makeTree();
		preOrderTraversal(root);
		System.out.println(result);
	}

	private static void preOrderTraversal(Node root) {
		Node currentNode = root;
		while (true) {
			saveResultNode(currentNode);
			if (currentNode.right != null) {
				stack.push(currentNode.right);
			}
			if (currentNode.left != null) {
				currentNode = currentNode.left;
			} else {
				try {
					currentNode = stack.pop();
				} catch (EmptyStackException e) {
					currentNode = null;
				}
			}
			if (currentNode == null) {
				break;
			}
		}
	}

	private static void saveResultNode(Node node) { //保存需要打印的结点
		result.add(node.value);
	}
}


3 中序遍历


图3 中序遍历
    相比于前序遍历,中序遍历要麻烦一些。 前序遍历的遍历顺序为 根结点->左子树->右子树 ,而对树的访问也是从根节点开始,因此前序遍历时对树的访问和遍历有一定的一致性,这样就可以每次访问一个结点,就立刻打印当前结点,而不需要在栈中保存这个结点的状态。所以,在前序遍历实现时,每次进栈保存当前访问结点的右孩子结点即可(前提是右孩子结点存在),而不需要多余的逻辑判断。
    但是中序遍历的遍历顺序为 左子树->根结点->右子树 ,也就是说第一次访问到某个结点时,不会立刻打印当前结点,需要左子树完成后再打印根节点,然后访问右子树。这样的话,在整个中序遍历的过程中,对于非叶子结点,每个结点需要被访问两次。比如,对于图3中1号结点,第一次被访问是在从0号结点访问左子树时,此时1号结点的左子树(即3号结点)还没有被遍历;第二被访问是1号左子树遍历并打印后,回溯时被访问到的,此时1号结点的左子树已经遍历完成。显然,在第一次访问1号结点时,应该保存1号结点的状态,等到第二次被访问时打印1号结点。这里和前序遍历不同点在于,前序遍历在第一次访问某个非叶子结点时,保存的是这个结点的右子树状态,而此时的中序遍历需要保存这个结点的状态。原因前面已经讲过, 前序遍历时对树的访问和遍历有一定的一致性,即第一次访问结点时就可以打印出当前结点状态,从而不需要保存当前结点状态,直接保存右子树的根节点即可。而中序遍历是当第二次访问这个结点时才会被打印出来,因此需要将结点保存到第二次被访问的时候。
    这里可能会有一点疑问:如果中序遍历访问某个结点时,保存的是当前结点而不是右孩子结点,那么当第二次访问并打印这个结点后,怎么进行右子树的遍历?其实,由于在前序遍历时,第一次访问非叶子结点就可以打印,因此规避掉了这个结点的左右子树是否遍历完成的问题。而中序遍历时,想要判断某个非叶子结点是被第一次访问还是第二次访问,就必须有一种办法可以判断当前结点左子树是否被遍历完成(后面的后序遍历不仅仅要判断左子树是否遍历完成,还需要判断右子树也同样遍历完成)。这里想到的方法是,当每次打印结点时,同时将这个结点保存在HashSet中(代码中使用的是HashSet,实际上用数组也可以,只是这里觉得java的HashSet已经有了现成的接口,更方便使用)。每一次访问结点的同时,也同时查看一下这个HashSet中是否包含了这个结点的左孩子结点。如果没有包含,则将访问引用指向左孩子,同时将当前结点进栈保存;否则打印当前结点,将访问引用指向右孩子(如果存在的话,否则继续出栈回溯)。
public class InOrderTraversal {
	private static Stack<Node> stack = new Stack<Node>();
	private static HashSet<Node> resultsMap = new HashSet<Node>();
	private static ArrayList<Integer> result = new ArrayList<Integer>();

	public static void main(String[] args) {
		Node root = TreeUtil.makeTree();
		inOrderTraversal(root);
		System.out.println(result);
	}

	private static void inOrderTraversal(Node root) {
		Node currentNode = root;
		while (true) {
			// 判断左子树是否被遍历。
			// 如果左子树为空,则认为左子树被遍历
			if(isLeftSubtreeTraversaled(currentNode)){
				saveResultNode(currentNode);
				if(currentNode.right!=null){
					currentNode=currentNode.right;
				}else{
					try {
						currentNode = stack.pop();
					} catch (EmptyStackException e) {
						currentNode = null;
					}
				}
			}else{
				stack.push(currentNode);
				currentNode=currentNode.left;
			}
			if (currentNode == null) {
				break;
			}
		}
	}

	/**
	 * 将需要打印的结点按顺序保存在result中,同时也记录在resultsMap中。resultsMap可以快速查询出某个结点是否已经被打印。
	 * 如果考虑存储空间,可以只用result这个Arraylist来保存结果,代价是查询结点是否被打印时必须要逐个比较整个Arraylist,速度会慢一些。
	 */
	private static void saveResultNode(Node node) {
		result.add(node.value);
		resultsMap.add(node);
	}
	
	private static boolean isLeftSubtreeTraversaled(Node node) {
		return node.left == null || resultsMap.contains(node.left);
	}
}

图4 中序遍历访问3号结点时的栈状态
    如图4所示,访问3号结点时,栈中存储的是1号和0号结点。由于3号结点时叶子结点,打印完成后记录3号结点已经被打印完毕。然后出栈操作,会从栈中取出1号结点,由于1号结点的左孩子3号结点已经被记录打印,即1号结点的左子树遍历完毕。同时,1号结点存在右孩子4号结点,那么将访问引用指向4号结点。当4号结点打印完成后,再次进行出栈操作,取出0号结点。同理,访问引用指向2号结点。最后2结点访问完毕后,由于栈为空,无法进行出栈操作,于是结束循环,结束遍历。
对中序遍历进行一下总结:
    1.对于中序遍历,第一次访问非叶子结点时,需要将当前结点进栈保存。
    2.第二次访问时非叶子结点时,打印结点。继续出栈回溯还是将访问引用指向右孩子取决于右孩子是否存在。
    3.每次打印结点,需要同时将打印结点保存,用于记录当前结点是否被遍历。
    4.循环的退出条件是需要出栈操作时,栈为空,无法进行该操作。


4 后序遍历


    后序遍历在实现时,不仅要考虑左子树是否遍历完成,还需要考虑右子树是否遍历完成。关于判断左右子树是否遍历完成,同样可以采用中序遍历中的做法,即打印结点的同时,也将该结点保存起来供父结点查看遍历状态。如果理解了中序遍历的非递归方式,其实后序遍历只需要在循环中,判断完左子树完成后,再判断右子树是否同样被遍历。对于栈的保存,同样保存访问时的根结点即可。
public class PostOrderTraversal {

	private static Stack<Node> stack = new Stack<Node>();
	private static HashSet<Node> resultsMap = new HashSet<Node>();
	private static ArrayList<Integer> result = new ArrayList<Integer>();

	public static void main(String[] args) {
		Node root = TreeUtil.makeTree();
		postOrderTraversal(root);
		System.out.println(result);
	}

	private static void postOrderTraversal(Node root) {
		if (root == null) {
			throw new IllegalArgumentException("root should not be null");
		}
		Node currentNode = root;
		while (true) {
			// 判断左子树是否被遍历。
			// 如果左子树为空,则认为左子树被遍历
			if (isLeftSubtreeTraversaled(currentNode)) {
				// 同理于左子树
				if (isRightSubtreeTraversaled(currentNode)) {
					saveResultNode(currentNode);
					try {
						currentNode = stack.pop();
					} catch (EmptyStackException e) {
						currentNode = null;
					}
				} else {
					stack.push(currentNode);
					currentNode = currentNode.right;
				}
			} else {
				stack.push(currentNode);
				currentNode = currentNode.left;
			}

			if (currentNode == null) {
				break;
			}
		}
	}

	private static void saveResultNode(Node node) {
		result.add(node.value);
		resultsMap.add(node);
	}

	private static boolean isLeftSubtreeTraversaled(Node node) {
		return node.left == null || resultsMap.contains(node.left);
	}

	private static boolean isRightSubtreeTraversaled(Node node) {
		return node.right == null || resultsMap.contains(node.right);
	}
}
图4 后序遍历

图5 后序遍历从3号结点访问开始的栈状态
    图5为后序遍历,从访问3号结点开始到遍历结束的栈状态。刚开始访问3号结点时,处于S1状态。由于是叶子结点,打印完3号结点后需要进行回溯。于是对栈进行出栈操作,弹出1号结点,将访问引用指向1号结点,进入S2状态。S2状态中,由于3号结点已经被打印,所以左子树遍历完毕。但是右孩子4号结点没有被打印,因此1号结点的右子树没有被遍历。将1号结点再次入栈,引用指向4号结点,从而进入S3状态。同S1状态一样,打印完4号结点后,出栈,引用指向1号结点,进入S4状态。此时,3号结点和4号结点均被打印,说明左右子树遍历完成。打印1号节点后,再进行出栈操作,引用指向0号结点,进入S5状态。同S2状态类似,0号结点的左子树遍历完成,但是右子树没有被遍历。将0号结点入栈,引用指向2号结点,进入S6状态。同S3状态,打印完2号结点后,出栈,引用指向0号结点,进入S7状态。S7状态中,左右子树都遍历完毕,打印0号结点。由于栈已经为空,无法进行出栈操作,退出循环,结束遍历。
对后序遍历进行一下总结:
    1.对于非叶子结点,只有当左右子树的根节点均打印完成后,才可以打印当前结点。
    2.每次打印结点,需要同时将打印结点保存,用于记录当前结点是否被遍历。
    3.循环的退出条件是需要出栈操作时,栈为空,无法进行该操作。

5 总结


    其实,非递归的二叉树遍历实现,关键要理解访问结点时的栈状态。只要明白,在遍历时如何进栈出栈,也就基本可以写出整个实现过程。本文的思路并非是唯一的思路,只是平日里的一些思考。不妥之处望斧正。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值