樹:遍歷算法


樹的遍歷也一直都是重點,主要是在建造了一棵樹之后,如何將這棵樹輸出來確定創建的樹是否正確就成了問題。網上現在也有很多的方法來輸出樹,python也有專門的包來可視化,不過今天主要總結最基礎的遍歷算法。

樹的遍歷主要根據訪問根節點的時機來分為先序、中序、后序和層次遍歷。其中要掌握了十種算法,分別是先序遞歸和先序非遞歸(深度優先搜索)、中序遞歸和中序非遞歸、后序遞歸和倆種后序非遞歸(單棧和雙棧)、層次遍歷(廣度優先搜索),Morris遍歷以及改善版。下面八種對圖1的樹進行遍歷。


圖1. 實例樹

先序

首先訪問根節點、然后訪問左子樹,最后訪問右子樹。如果用遞歸實現這個思想非常簡單,代碼如下

// 前序遍歷,遞歸
private void printTree(TreeNode node) {
    if(node != null) {
    	System.out.print(node.node_value + ", ");
    	// 訪問左子樹
    	printTree(node.left_node);
    	// 訪問右子樹
        printTree(node.right_node);
    }
}

對於非遞歸的思想,先來看一下對於圖1中的先序遍歷的過程,就是先訪問根節點6,之后訪問6的左子樹,再訪問左子樹的根2,之后訪問根2的左子樹1,之后再訪問根2的右子樹的根4,再訪問根4的左子樹3,這時已經訪問完根6的左子樹,可以訪問根6的右子樹的根8。所以最后得到的順序是6,2,1,4,3,8。

這時的過程有點類似DFS(深度優先搜索),都是一條路走到底,因此使用DFS來實現。其中使用到了棧來存儲中間過程,主要注意的就是壓入棧的順序,是先將右節點壓進去,再將左節點壓進去,這時因為棧是先進后出。

// 深度遍歷,使用的是棧,前序遍歷得非遞歸實現
private void printTree(TreeNode node) {
    Stack<TreeNode> tree_stack = new Stack<TreeNode>();
    tree_stack.push(node);
    while(!tree_stack.empty()) {
    	TreeNode stack_element = tree_stack.pop();
	if(stack_element.right_node != null) tree_stack.push(stack_element.right_node);
	if(stack_element.left_node != null) tree_stack.push(stack_element.left_node);
	System.out.print(stack_element.node_value + ", ");
    }
    System.out.println();
}

中序

首先訪問左子樹,然后訪問根節點,最后訪問右子樹。下面先用遞歸來實現中序算法。

// 中序遍歷,遞歸
private void printTree(TreeNode node) {
    if(node != null) {
    	printTree(node.left_node);
    	System.out.print(node.node_value + ", ");
        printTree(node.right_node);
    }
}

先來看看中序在具體樹上是怎么遍歷的,先訪問根6的左子樹,再訪問左子樹2的左子樹1,訪問完1,就是訪問左子樹根2,再訪問左子樹2的右子樹,直接到2的右子樹的左子樹3,之后訪問2的右子樹的根4,這時根6的左子樹已經全部訪問完,可以訪問根6,最后訪問右子樹8,最后遍歷的順序是1,2,3,4,6,8。

還是用棧這個結構,先將根壓入棧中,然后不斷地將左子樹壓進去,直到葉節點,之后可以對棧中的每個節點訪問,訪問完一個節點之后,再看這個節點是否有右子樹,如果有,先壓入棧中,再判斷這個右子樹是否有左子樹,如果還有左子樹,那么就一直壓入棧中,如果沒有左子樹,那么繼續取棧中的元素,之后再判斷是否有右子樹。如此過程,一直進行下去。整個過程就是判斷這個節點是不是null,是的話,就拿棧中下一個節點,不是的話,就壓入棧中,之后判斷節點左子樹是不是null。

// 中序遍歷,非遞歸
// 思想就是用棧,先把當前節點的左子樹壓進去,之后拿出節點,再把右子樹壓進去
private void printTree(TreeNode node) {
    Stack<TreeNode> tree_stack = new Stack<TreeNode>();
    	
    while(node != null || !tree_stack.empty()) {
        // 判斷節點是否存在,主要是為了判斷左子樹是不是存在
    	if(node != null) {
    	    tree_stack.push(node);
    	    node = node.left_node;
    	}else {
    	    // 如果節點不存在,那么拿出棧中元素
    	    TreeNode current_node = tree_stack.pop();
    	    System.out.print(current_node.node_value + ", ");
    	    node = current_node.right_node;
    	}
    }	
}

后序

首先訪問左子樹,之后訪問右子樹,最后訪問根節點。用遞歸思想依舊很容易。

// 后序遍歷,遞歸
private void printTree(TreeNode node) {
    if(node != null) {
    	printTree(node.left_node);
        printTree(node.right_node);
        System.out.print(node.node_value + ", ");
    }
}

先來看看后序具體的過程,先訪問根6的左子樹,再訪問根6左子樹的左子樹1,訪問完1之后,再看左子樹2的右子樹4,然后訪問左子樹2的右子樹4的左子樹3,訪問完3之后,可以訪問根4,然后就可以訪問左子樹的根2,這時根6的左子樹全部訪問完成,再訪問根6的右子樹8,最后訪問根6,最后的訪問順序就是1,3,4,2,6,8。

后序非遞歸思想是當中最難的,這時因為根節點最后才能訪問,如果我們根據中序非遞歸的寫法來看后序非遞歸,那么最起碼還得有一個標識,標記節點的右子樹是否被訪問了,左子樹就不用標記,因為棧中拿出來的就是左子樹。如果訪問過了,那么可以拿出來,如果沒有,那么將右子樹壓進入。這時右子樹的訪問在根節點之前,那么只有看前一個訪問過的節點是不是當前根節點的右節點就行。

// 后序遍歷,非遞歸
// 使用一個棧,外加一個標志,這個標志就是說明右子樹是否被訪問了。
private void printTree(TreeNode node) {
    Stack<TreeNode> tree_stack = new Stack<TreeNode>();
    // 標記右子樹是否被訪問
    TreeNode previsited_node = null;
    while(node != null || !tree_stack.empty()) {
        if(node != null) {
    	    tree_stack.push(node);
    	    node = node.left_node;
    	}else {
    	    TreeNode right_node = tree_stack.peek().right_node;
    	    // 查看是否有右子樹或者右子樹是否被訪問
    	    if(right_node == null || right_node == previsited_node) {
    		TreeNode current_node = tree_stack.pop();
    		System.out.print(current_node.node_value + ", ");
    		previsited_node = current_node;
    		node = null;
    	    }else {
    		// 處理右子樹
    		node = right_node;
    	    }
    	}
    }
}

但是如果使用倆個棧的話,這個非遞歸算法的思想就可以很簡單了,如果我們先將右子樹壓進去,再將左子樹壓進去,那么這個棧中就是后序遍歷順序。不過這需要倆個棧,一個棧保存結果,一個棧拿出壓進去的節點,看這個節點是不是有左子樹,有的話,壓入倆個棧中。

// 后序遍歷,非遞歸
// 使用雙棧遍歷,思想其實和中序遞歸差不多。
// 一個棧是保存全部數據的,一個棧是把數據拿出來,找到它的左子樹的,找到之后全部放入輸出棧中
private void printTree(TreeNode node) {
    Stack<TreeNode> tree_stack = new Stack<TreeNode>();
    Stack<TreeNode> output_stack = new Stack<TreeNode>();
    	
    while(node != null || !tree_stack.empty()) {
    	if(node != null) {
    	    // 如果節點存在,那么壓入倆個棧中
    	    tree_stack.push(node);
    	    output_stack.push(node);
    	    node = node.right_node;
    	}else {
    	    TreeNode current_node = tree_stack.pop();
    	    node = current_node.left_node;
    	}
    }
    // 輸出棧中的元素就是后序
    while(!output_stack.empty()) {
    	TreeNode temp_node = output_stack.pop();
    	System.out.print(temp_node.node_value + ", ");
    }
}

BFS(廣度優先搜索)層次遍歷

層次遍歷就是一層一層的輸出節點,按照從左往右的順序進行輸出,圖1樹中輸出的順序就是6,2,81,4,3,這種類似於廣度優先搜索算法,那么需要的是隊列結構,這里就是先進先出,就是先將根節點進隊,之后從隊中拿出節點,看這個節點是否有左節點和右節點,有的話,就進隊,然后不斷地對隊列中的樹進行判斷進隊就行,直到隊列中沒有節點。

// 廣度層次遍歷,使用的是隊列
private void printTree(TreeNode node) {
    Queue<TreeNode> tree_queue = new LinkedList<TreeNode>(); 
    tree_queue.offer(node);
    // 查看隊列中是否還有節點
    while(!tree_queue.isEmpty()) {
	// 拿出隊列中的節點
	TreeNode queue_element = tree_queue.poll();
	if(queue_element.left_node != null) tree_queue.offer(queue_element.left_node);
	if(queue_element.right_node != null) tree_queue.offer(queue_element.right_node);
	System.out.print(queue_element.node_value + ", ");
    }
    System.out.println();
}

Morris遍歷(線索二叉樹)

上面的遍歷一般都是使用遞歸、棧或者隊列實現的,空間復雜度和時間復雜度都是\(O(n)\),因為遍歷的話,你肯定需要訪問每個元素依次,那么時間復雜度最少就是\(O(n)\),能優化的也只是空間復雜度。之前都是使用數據結構存儲還未訪問的節點,如果我們不存儲呢,每次訪問一個節點時候,如果有左子樹,直接找出直接前驅,這個前驅肯定是個葉子節點,沒有右子樹,將這個前驅節點的右節點與當前節點鏈接,形成線索二叉樹那樣的鏈接,之后去掉當前節點的左鏈接,那么是不是直接將樹結構變成單鏈表結構,並且還是中序遍歷,如果遍歷的是BST樹,那么遍歷出來的是從小到大的順序的。


圖2. 樹結構變單鏈表

為了能夠形象的展示遍歷過程,將上面圖的過程進行詳細的表述:

  1. 節點6設置為當前節點;
  2. 查看節點6是否有左子樹,發現有,那么找出左子樹中的最右側節點4,也就是當前節點的直接前驅,將最右側節點4的右節點指向當前節點6;
  3. 將當前節點設置為節點6的左節點2,然后刪除節點2和節點6的鏈接;
  4. 查看節點2是否有左子樹,發現有,那么找出左子樹中的最右側節點1,將節點1的右節點指向當前節點2;
  5. 將當前節點設置為節點2的左節點1,然后刪除節點2和節點1的鏈接;
  6. 查看節點1是否有左子樹,發現當前節點1沒有左子樹,那么訪問當前節點1,之后進入節點1的右鏈接中,到了節點2,節點2設置成當前節點;
  7. 查看當前節點2是否有左子樹,發現沒有,訪問當前節點2,進入節點2的右子樹中,到了節點4,將之設置成當前節點;
  8. 查看當前節點4是否有左子樹,發現有,那么找出左子樹中的最右側節點3,將之右節點指向當前節點4;
  9. 將當前節點設置成節點4的左節點3,然后刪除節點4和節點3的鏈接;
  10. 查看當前節點3是否有左子樹,發現沒有,訪問當前節點3,之后進入節點3的右鏈接中,到了節點4,節點4設置成當前節點;
  11. 查看當前節點4是否有左子樹,發現沒有,訪問當前節點4,之后進入節點4的右鏈接中,到了節點6,節點6設置成當前節點;
  12. 查看當前節點6是否有左子樹,發現沒有,訪問當前節點6,之后進入節點6的右鏈接中,到了節點8,節點8設置成當前節點;
  13. 查看當前節點8是否有左子樹,發現沒有,訪問當前節點8,之后進入節點6的右鏈接中,發現是null,那么退出循環;
// 使用線索二叉樹的Morris遍歷,但是會改變樹的結構,直接變成單項升序鏈表形式
private void printTree(TreeNode node) {
    TreeNode current_node = node;
    while(current_node != null) {
        // 左孩子不為空的話,就找到左子樹的最右節點指向自己
    	if(current_node.left_node != null) {
    	    TreeNode leftNode = current_node.left_node;
    	    while(leftNode.right_node != null) {
    		leftNode = leftNode.right_node;
    	    }
    	    leftNode.right_node = current_node;
    	    leftNode = current_node.left_node;
    	    current_node.left_node = null;
    	    current_node = leftNode;
    	}else {
    	    System.out.print(current_node.node_value + ", ");
    	    current_node = current_node.right_node;
    	}
    }
}

上面最大的問題就是強行改變了樹的結構,將之變成了單鏈表結構,這在實際應用中,往往是不可取的,但是在上面的程序中,是必須的,因為刪除左孩子,是為了防止無限循環,因為判定條件都是節點是否有左孩子。我們加上一個條件來查看是否是第二次訪問該節點。當查看當前節點是有左子樹的時候,還要看直接前驅右鏈接是不是當前節點,如果是當前節點,那么刪除直接前驅的右鏈接,並且將當前節點強制移動到右子樹中。

// 上面那個會使得二叉樹強行改變,不可取,因此有了這個。
private void printTree(TreeNode node) {
    TreeNode current_node = node;
    while(current_node != null) {
    	// 左孩子不為空的話,就找到左子樹的最右節點指向自己
    	if(current_node.left_node == null) {
    	    System.out.print(current_node.node_value + ", ");
    	    current_node = current_node.right_node;	
    	}else {
    	    TreeNode leftNode = current_node.left_node;
    	    while(leftNode.right_node != null && leftNode.right_node != current_node) {
    		leftNode = leftNode.right_node;
    	    }
    			
    	    if(leftNode.right_node == null) {   				
    		leftNode.right_node = current_node;
        	current_node = current_node.left_node;
    	    }else {
    		System.out.print(current_node.node_value + ", ");
    		leftNode.right_node = null;
    		current_node = current_node.right_node;
    	    }
    	}
    }
}

總結

其實這章我想等等再寫,奈何在寫BST樹要輸出,只好先把遍歷這章全部寫完,代碼也和我得github中BST樹的實現放在一起了。樹遍歷的算法很基礎,但是也很重要,以前在本科時,都用C寫過,奈何那時沒有寫博客的習慣,已經全部丟失,只好重新再寫一邊,就當復習。其中的非遞歸遍歷算法都用到了棧這個數據結構,主要是因為遍歷的特性,不過完全自己寫的話,還真的很難寫。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM