【數據結構】——線索二叉樹

一、什麼是線索二叉樹

概括來講,線索二叉樹就是將二叉樹中空的指針域利用了起來,用來保存遍歷過程中前驅結點和後繼結點的信息。其中這樣的信息叫做線索。

二叉樹的遍歷其實就是一個將非線性結構(樹,一對多的關係)轉化成一個線性結構(線性表,一對一的關係)的過程。得到的線性序列中除第一個結點和最後一個結點外其它結點都只有一個直接前驅和一個直接後繼(簡稱前驅後繼)。

有了這些前驅後繼信息的好處是:在查找某個結點時就可以類似鏈表那樣很方便的從表頭遍歷到表尾,並且空間複雜度只有O(1)Ο(1)。而前面二叉樹的遍歷中,無論遞歸還是非遞歸,都要用到棧,空間複雜度跟二叉樹的具體形態有關。

二、線索二叉樹的存儲方式

使用鏈式存儲,其中:

(1)首先最容易想到的辦法就是直接在每個結點中單獨增加兩個指針用來指向前驅後繼,但這樣增加了結點的空間,降低了存儲密度。

(2)因爲n個結點必定存在n+1個空指針域,可以利用這些空指針域來存放結點的前驅或後繼信息。具體方法是:

  • 對於一個結點,如果它的左子結點非空,那麼結點左指針指向左子結點不變,否則左指針指向它的前驅結點;同理如果右子結點非空,結點右指針指向右子樹不變,否則指向它的後繼結點。

  • 因爲指針域指向的可能是子結點,也可能是前驅或後繼結點,所以還需要在結點中爲左右兩個指針增加兩個標誌位,分別爲LTag,RTag。並表示:

LTag={0結點左指針指向左孩子1結點左指針指向前驅結點 LTag= \begin{cases} 0 & \text {結點左指針指向左孩子} \\ 1 & \text{結點左指針指向前驅結點} \end{cases}
RTag={0結點右指針指向右孩子1結點右指針指向後驅結點 RTag= \begin{cases} 0 & \text {結點右指針指向右孩子} \\ 1 & \text{結點右指針指向後驅結點} \end{cases}

關於n個結點存在n+1個空鏈域:可以這樣理解,n個結點一定有2n個指針域,又除根結點外所有結點都被一個指針指向,共佔用了n-1個指針域,所以還剩n+1個空指針域。

三、二叉樹線索化及遍歷

首先創建一棵二叉樹

public static TreeNode buildTree(char[] arr, int index) {
	TreeNode root = null;
	if (index < arr.length) {
		if (arr[index] == '#') {
			return null;
		}
		root = new TreeNode(arr[index]);
		root.lchild = buildTree(arr, 2 * index + 1);
		root.rchild = buildTree(arr, 2 * index + 2);
	}
	return root;
}

其中結點的結構爲

class TreeNode {
	char data;
	TreeNode lchild;
	TreeNode rchild;
	int LTag;
	int RTag;

	public TreeNode(char data) {
		super();
		this.data = data;
		this.LTag = 0;
		this.RTag = 0;
	}
}

(一)前序線索化及遍歷

前序線索化

前序線索化就是在前序遍歷的過程中動態的爲每個結點添加前驅或後繼的信息。

//首先創建一個結點用來保存訪問結點的前驅結點
static TreeNode preNode = null;

public static void preThreading(TreeNode T) {
	if (T != null) {
		// 訪問結點的左指針爲空則指向前驅結點,修改標誌位
		if (T.lchild == null) {
			T.lchild = preNode;
			T.LTag = 1;
		}
		// 前驅結點存在且右指針爲空則右指針指向後繼結點也就是訪問結點,修改標誌位
		if (preNode != null && preNode.rchild == null) {
			preNode.rchild = T;
			preNode.RTag = 1;
		}
		// 修改前驅結點
		preNode = T;
		
		// 遞歸處理左子樹
		if (T.LTag == 0)
			preThreading(T.lchild);
		// 遞歸處理右子樹
		if (T.RTag == 0)
			preThreading(T.rchild);
	}
}

下面是這棵二叉樹前序線索化後的結果(虛線表示線索),可以發現前序線索化後可以根據後繼線索從前往後遍歷,但不能根據前驅線索從後往前遍歷。總結就是前序線索化找前驅困難。
在這裏插入圖片描述
要注意前序線索化過程存在死循環,進入遞歸時必須要有 if 條件限制。以上面二叉樹爲例,如果沒有限制,假設當前訪問結點是D,和前驅結點B建立線索也就是左指針指向B,然後遞歸進入左子樹回到了B,B又遞歸進入左子樹回到D,陷入死循環。

前序線索化的遍歷(按後繼線索)(ABDEC)

按後繼線索是指遍歷過程中用到的線索是指向後繼結點的線索,下面同理。

public static void preThreadTraverse(TreeNode T) {
	while (T != null) {
		// 從根結點開始往左一直遍歷到最左子結點
		while (T.LTag == 0) {
			System.out.print(T.data);
			T = T.lchild;
		}
		System.out.print(T.data);

		// 這裏的T.rchild可能是線索,可能是右子結點
		T = T.rchild;
	}
}

(二)中序線索化及遍歷

中序線索化

中序線索化即在中序遍歷的過程中動態的爲每個結點添加前驅或後繼的信息。

static TreeNode preNode = null;

public static void inThreading(TreeNode T) {
	if (T != null) {
		// 遞歸處理左子樹
		inThreading(T.lchild);

		// 訪問結點的左指針爲空則指向前驅結點,修改標誌位
		if (T.lchild == null) {
			T.lchild = preNode;
			T.LTag = 1;
		}
		// 前驅結點存在且右指針爲空則右指針指向後繼結點也就是訪問結點,修改標誌位
		if (preNode != null && preNode.rchild == null) {
			preNode.rchild = T;
			preNode.RTag = 1;
		}
		// 修改前驅結點
		preNode = T;

		// 遞歸處理右子樹
		inThreading(T.rchild);
	}
}

還是上面那棵二叉樹中序線索化後的結果,可以發現中序線索化後既可以按照後繼線索從前往後遍歷也可以按照前驅線索從後往前遍歷。
在這裏插入圖片描述
中序線索化的遍歷(按後繼線索)(DBEAC)

public static void inThreadTraverse(TreeNode T) {
	while (T != null) {
		// 找到開始遍歷的結點也就是最左子結點並輸出
		while (T.LTag == 0) {
			T = T.lchild;
		}
		System.out.print(T.data);

		/*
		 * 如果存在右線索不斷的沿右線索遍歷後繼結點並輸出。 
		 * 其實個人覺得下面while也可以替換成if,因爲中序左根右,線索一定是左子結點指向父結點,
		 * 那麼接下來一定是遍歷這個父結點的右子樹,即不存在兩條連續的後繼線索。 如果有不同想法歡迎指出。
		 */
		while (T.rchild != null && T.RTag == 1) {
			T = T.rchild;
			System.out.print(T.data);
		}
		// 不存在右線索時轉向這個結點的右子樹
		T = T.rchild;
	}
}

(三)後序線索化及遍歷

後序線索化

後序線索化即在後序遍歷的過程中動態的爲每個結點添加前驅或後繼的信息。

static TreeNode preNode = null;

public static void postThreading(TreeNode T) {
	if (T != null) {
		// 遞歸處理左子樹
		postThreading(T.lchild);
		// 遞歸處理右子樹
		postThreading(T.rchild);

		// 訪問結點的左指針爲空則指向前驅結點,修改標誌位
		if (T.lchild == null) {
			T.lchild = preNode;
			T.LTag = 1;
		}
		// 前驅結點存在且右指針爲空則右指針指向後繼結點也就是訪問結點,修改標誌位
		if (preNode != null && preNode.rchild == null) {
			preNode.rchild = T;
			preNode.RTag = 1;
		}
		// 修改前驅結點
		preNode = T;
	}
}

後序線索化後的結果,可以發現後序線索化後可以按照前驅線索從後往前遍歷,但不能根據後繼線索從前往後遍歷。總結就是後序線索化找後繼困難。
在這裏插入圖片描述
後序線索化的遍歷(按後繼線索)(DEBCA)

因爲後序遍歷左右根的次序,所以後序遍歷建立線索時必然存在兩種情況:
(1)一個結點與父結點建立線索時因爲該結點的右子結點存在右指針域非空導致後繼線索建立失敗。
(2)從根結點的左子樹往右子樹建立線索時因爲根結點的左子節點右指針域非空導致後繼線索建立失敗。

比如上面這棵樹按後繼線索遍歷時,先找到開始遍歷的結點D,然後依次遍歷E,B,但結點B的右指針保存的是右子結點而不是線索,無法遍歷到結點C。對應上述的情況(2)。

再來回顧一下前序中序的線索化:因爲前序遍歷根左右中序遍歷左根右的順序,最後訪問的結點一定是左右葉子結點,兩個指針域爲空線索可以建立。否則在原二叉樹中一定存在一條路徑可以間接找到後繼結點,也就不存在後序線索化中的既後繼線索建立失敗又無法通過原二叉樹中的指向關係找到後繼結點。

無論出現上面兩種情況中的哪種情況,都可以通過在原二叉樹結點中添加一個指向父結點的指針來解決。比如上面這棵樹遍歷到結點B時,可以通過先回到父結點A再尋找到結點C。

對應的結點結構

class TreeNode {
	char data;
	TreeNode lchild;
	TreeNode rchild;
	TreeNode parent;	// 添加父結點的指針
	int LTag;
	int RTag;

	public TreeNode(char data) {
		super();
		this.data = data;
		this.LTag = 0;
		this.RTag = 0;
	}
}

帶父指針的二叉樹初始化

public static TreeNode buildTree(char[] arr, int index) {
	TreeNode root = null;
	if (index < arr.length) {
		if (arr[index] == '#') {
			return null;
		}
		root = new TreeNode(arr[index]);
		root.lchild = buildTree(arr, 2 * index + 1);
		root.rchild = buildTree(arr, 2 * index + 2);
		
		// 記錄父結點
		if (root.lchild != null)
			root.lchild.parent = root;
		if (root.rchild != null)
			root.rchild.parent = root;
	}
	return root;
}

線索化過程不變。

遍歷

public static void postThreadTraverse(TreeNode T) {
	TreeNode root = T;
	TreeNode preNode = null;	
	// 找到後序遍歷開始的結點
	while (T.LTag == 0) {
		T = T.lchild;
	}

	while (true) {
		// 右指針是線索
		if (T.RTag == 1) {
			System.out.print(T.data);
			preNode = T;
			T = T.rchild;		
		} else {
			// 上個訪問的結點是右子結點
			if (T.rchild == preNode) {
				System.out.print(T.data);
				if (T == root) {
					return;
				}
				preNode = T;
				T = T.parent;
			} else {
				// 上個訪問的結點是左子結點轉到右子樹
				T = T.rchild;
				while (T.LTag == 0) {
					T = T.lchild;
				}	
			}
		}
	}
}

四、帶頭結點的中序線索化

因爲中序線索化可以根據前驅線索或後繼線索雙向遍歷的特點,所以可以通過添加一個頭結點形成類似雙向循環鏈表結構,既可以從頭結點從前往後遍歷,也可以從頭結點從後往前遍歷。

具體步驟爲:
(1)新建一個頭結點,頭結點左指針指向根節點,左標記LTag=0不變,右標記Rtag初始修改爲1表示用來存放線索。preNode初始指向頭結點。
(2)調用中序線索化的算法,調用完畢後preNode會停在最後個結點的位置。
(3)最後將頭結點右指針指向最後一個結點preNode,最後一個結點右指針指向頭結點。

// 注意head是一個data爲空的結點而不是null,且LTag默認爲0
public static void inThreadingWithHead(TreeNode head, TreeNode T) {
	if (T != null) {
		head.lchild = T;
		head.RTag = 1;
		preNode = head;

		inThreading(T);

		preNode.rchild = head;
		preNode.RTag = 1;
		head.rchild = preNode;
	}
}

線索化後的結果:
在這裏插入圖片描述
遍歷

public static void inThreadWithHeadTraverse(TreeNode head) {
	TreeNode T = head.lchild;
	// 此處循環結束條件不再是T!=null
	while (T != head) {
		while (T.LTag == 0) {
			T = T.lchild;
		}
		System.out.print(T.data);

		// 此處循環條件要加上T.rchild!=head限制,如果不限制會陷入頭結點與最後個結點之間的死循環
		while (T.RTag == 1 && T.rchild != head) {
			T = T.rchild;
			System.out.print(T.data);
		}
		T = T.rchild;
	}
}

五、總結

(1)無論有沒有線索化,遍歷的過程中都只訪問了nn個結點,時間複雜度都是O(n)Ο(n)。線索化的空間複雜度除第一次遍歷建立線索外,之後遍歷都是O(1)Ο(1)
(2)前序線索化找前驅困難,後序線索化找後繼困難。
(3)因爲中序線索化可以雙向遍歷的原因,所以可以在中序線索化中添加一個頭結點構成雙向循環鏈表。
(4)點擊查看後序線索化動態演示過程

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章