重溫資料結構:二叉排序樹的查詢、插入、刪除
讀完本文你將瞭解到:
我們知道,二分查詢可以縮短查詢的時間,但是有個要求就是 查詢的資料必須是有序的。每次查詢、操作時都要維護一個有序的資料集,於是有了二叉排序樹這個概念。
上篇文章 我們介紹了 二叉樹 的概念,二叉樹有左右子樹之分,想必在區分左右子樹時有一定的規則。
現在我們來介紹二叉樹的一種特殊形式 — 二叉排序樹,瞭解它的區分策略及常用操作。
什麼是二叉排序樹 Binary Sort Tree, BST
二叉排序樹,又稱二叉查詢樹、二叉搜尋樹。
二叉排序樹是具有下列性質的二叉樹:
- 若左子樹不空,則左子樹上所有結點的值均小於它的根結點的值;
- 若右子樹不空,則右子樹上所有結點的值均大於或等於它的根結點的值;
- 左、右子樹也分別為二叉排序樹。
也就是說,二叉排序樹中,左子樹都比節點小,右子樹都比節點大,遞迴定義。
根據二叉排序樹這個特點我們可以知道,二叉排序樹的中序遍歷一定是從小到大的,比如上圖,中序遍歷結果是:
1 3 4 6 7 8 10 13 14
二叉排序樹的關鍵操作
1.查詢
根據二叉排序樹的定義,我們可以知道在查詢某個元素時:
- 先比較它與根節點,相等就返回;或者根節點為空,說明樹為空,也返回;
- 如果它比根節點小,就從根的左子樹裡進行遞迴查詢;
- 如果它比根節點大,就從根的右子樹裡進行遞迴查詢。
可以看到,這就是一個 二分查詢。
程式碼實現:
public class BinarySearchTree { private BinaryTreeNode mRoot; //根節點 public BinarySearchTree(BinaryTreeNode root) { mRoot = root; } /** * 在整個樹中查詢某個資料 * * @param data * @return */ public BinaryTreeNode search(int data) { return search(mRoot, data); } /** * 在指定二叉排序樹中查詢資料 * * @param node * @param data * @return */ public BinaryTreeNode search(BinaryTreeNode node, int data) { if (node == null || node.getData() == data) { //節點為空或者相等,直接返回該節點 return node; } if (data < node.getData()) { //比節點小,就從左子樹裡遞迴查詢 return search(node.getLeftChild(), data); } else { //否則從右子樹 return search(node.getRightChild(), data); } } }
可以看到,在二叉排序樹中查詢是十分簡單的,但是這依賴於每次插入、刪除元素時對整個 排序樹 結構的維護。
2.插入
二叉樹中的插入,主要分兩步:查詢、插入:
- 先查詢有沒有整個元素,有的話就不用插入了,直接返回;
- 沒有就插入到之前查到(對比)好的合適的位置。
插入時除了設定資料,還需要跟父節點繫結,讓父節點意識到有你這個孩子:比父節點小的就是左孩子,大的就是右孩子。
程式碼實現:
/** * 插入到整個樹中 * * @param data */ public void insert(int data) { if (mRoot == null) { //如果當前是空樹,新建一個 mRoot = new BinaryTreeNode(); mRoot.setData(data); return; } searchAndInsert(null, mRoot, data); //根節點的父親為 null } /** * 兩步走:查詢、插入 * * @param parent 要繫結的父節點 * @param node 當前比較節點 * @param data 資料 */ private BinaryTreeNode searchAndInsert(BinaryTreeNode parent, BinaryTreeNode node, int data) { if (node == null) { //當前比較節點為 空,說明之前沒有這個資料,直接新建、插入 node = new BinaryTreeNode(); node.setData(data); if (parent != null) { //父節點不為空,繫結關係 if (data < parent.getData()) { parent.setLeftChild(node); } else { parent.setRightChild(node); } } return node; } //對比的節點不為空 if (node.getData() == data) { //已經有了,不用插入了 return node; } else if (data < node.getData()) { //比節點小,從左子樹裡查詢、插入 return searchAndInsert(node, node.getLeftChild(), data); } else { return searchAndInsert(node, node.getRightChild(), data); } }
3.刪除 *
插入操作和查詢比較類似,而刪除則相對複雜一點,需要根據刪除節點的情況分類來對待:
- 如果要刪除的節點正好是葉子節點,直接刪除就 Ok 了;
- 如果要刪除的節點還有子節點,就需要建立父節點和子節點的關係:
- 如果只有左孩子或者右孩子,直接把這個孩子上移放到要刪除的位置就好了;
- 如果有兩個孩子,就需要選一個合適的孩子節點作為新的根節點,該節點稱為 繼承節點。
新節點要求要比所有左子樹大,比所有右子樹小,怎麼選擇呢?
**要比所有左子樹的值大、右子樹小,就從右子樹裡找最小的好了;
同樣也可以從左子樹裡找最大的。**
兩種選擇方法都可以,本文選用右子樹裡最小的節點,也就是右子樹中最左邊的節點。
程式碼實現:
/**
* 在整個樹中 查詢指定資料節點的父親節點
*
* @param data
* @return
*/
public BinaryTreeNode searchParent(int data) {
return searchParent(null, mRoot, data);
}
/**
* 在指定節點下 查詢指定資料節點的父親節點
*
* @param parent 當前比較節點的父節點
* @param node 當前比較的節點
* @param data 查詢的資料
* @return
*/
public BinaryTreeNode searchParent(BinaryTreeNode parent, BinaryTreeNode node, int data) {
if (node == null) { //比較的節點為空返回空
return null;
}
if (node.getData() == data) { //找到了目標節點,返回父節點
return parent;
} else if (data < node.getData()) { //資料比當前節點小,左子樹中遞迴查詢
return searchParent(node, node.getLeftChild(), data);
} else {
return searchParent(node, node.getRightChild(), data);
}
}
/**
* 刪除指定資料的節點
*
* @param data
*/
public void delete(int data) {
if (mRoot == null || mRoot.getData() == data) { //根節點為空或者要刪除的就是根節點,直接刪掉
mRoot = null;
return;
}
//在刪除之前需要找到它的父親
BinaryTreeNode parent = searchParent(data);
if (parent == null) { //如果父節點為空,說明這個樹是空樹,沒法刪
return;
}
//接下來該找要刪除的節點了
BinaryTreeNode deleteNode = search(parent, data);
if (deleteNode == null) { //樹中找不到要刪除的節點
return;
}
//刪除節點有 4 種情況
//1.左右子樹都為空,說明是葉子節點,直接刪除
if (deleteNode.getLeftChild() == null && deleteNode.getRightChild() == null) {
//刪除節點
deleteNode = null;
//重置父節點的孩子狀態,告訴他你以後沒有這個兒子了
if (parent.getLeftChild() != null && parent.getLeftChild().getData() == data) {
parent.setLeftChild(null);
} else {
parent.setRightChild(null);
}
return;
} else if (deleteNode.getLeftChild() != null && deleteNode.getRightChild() == null) {
//2.要刪除的節點只有左子樹,左子樹要繼承位置
if (parent.getLeftChild() != null && parent.getLeftChild().getData() == data) {
parent.setLeftChild(deleteNode.getLeftChild());
} else {
parent.setRightChild(deleteNode.getLeftChild());
}
deleteNode = null;
return;
} else if (deleteNode.getRightChild() != null && deleteNode.getRightChild() == null) {
//3.要刪除的節點只有右子樹,右子樹要繼承位置
if (parent.getLeftChild() != null && parent.getLeftChild().getData() == data) {
parent.setLeftChild(deleteNode.getRightChild());
} else {
parent.setRightChild(deleteNode.getRightChild());
}
deleteNode = null;
} else {
//4.要刪除的節點兒女雙全,既有左子樹又有右子樹,需要選一個合適的節點繼承,這裡使用右子樹中最左節點
BinaryTreeNode copyOfDeleteNode = deleteNode; //要刪除節點的副本,指向繼承節點的父節點
BinaryTreeNode heresNode = deleteNode.getRightChild(); //要繼承位置的節點,初始為要刪除節點的右子樹的樹根
//右子樹沒有左孩子了,他就是最小的,直接上位
if (heresNode.getLeftChild() == null) {
//上位後,兄弟變成了孩子
heresNode.setLeftChild(deleteNode.getLeftChild());
} else {
//右子樹有左孩子,迴圈找到最左的,即最小的
while (heresNode.getLeftChild() != null) {
copyOfDeleteNode = heresNode; //copyOfDeleteNode 指向繼承節點的父節點
heresNode = heresNode.getLeftChild();
}
//找到了繼承節點,繼承節點的右子樹(如果有的話)要上移一位
copyOfDeleteNode.setLeftChild(heresNode.getRightChild());
//繼承節點先繼承家業,把自己的左右孩子變成要刪除節點的孩子
heresNode.setLeftChild(deleteNode.getLeftChild());
heresNode.setRightChild(deleteNode.getRightChild());
}
//最後就是確認位置,讓要刪除節點的父節點認識新兒子
if (parent.getLeftChild() != null && parent.getLeftChild().getData() == data) {
parent.setLeftChild(heresNode);
} else {
parent.setRightChild(heresNode);
}
}
}
執行程式碼測試
可以看到,二叉排序樹的查詢、新增較簡單,刪除邏輯比較多,我們以下圖為例:
測試程式碼:
@Test
public void delete() throws Exception {
//亂序插入到二叉排序樹中
BinarySearchTree binarySearchTree = new BinarySearchTree(null);
binarySearchTree.insert(8);
binarySearchTree.insert(3);
binarySearchTree.insert(1);
binarySearchTree.insert(6);
binarySearchTree.insert(4);
binarySearchTree.insert(7);
binarySearchTree.insert(10);
binarySearchTree.insert(13);
binarySearchTree.insert(14);
//中序遍歷
binarySearchTree.iterateMediumOrder(binarySearchTree.getRoot());
System.out.println("");
//查詢某個資料
System.out.println(binarySearchTree.search(10).getData());
//刪除某個資料對應的元素
binarySearchTree.delete(6);
//中序遍歷刪除後的二叉排序樹
binarySearchTree.iterateMediumOrder(binarySearchTree.getRoot());
}
執行結果:
一道面試題
輸入一棵二元查詢樹,將該二元查詢樹轉換成一個排序的雙向連結串列。要求不能建立任何新的結點,只調整指標的指向。 比如將二元查詢樹:
10 / \ 6 14 / \ / \ 4 8 12 16
轉換成雙向連結串列後為:4=6=8=10=12=14=16
解析:
這題據說是微軟的面試題,乍看起來貌似很麻煩,又是二叉排序樹又是雙向連結串列的,其實考察的都是很基礎的東西,明眼人一看就發現只要將這棵樹中序遍歷後就是將二叉樹節點排序(不然它為啥叫二叉排序樹呢…),那麼我們只要將這棵樹中序遍歷,遍歷到一個節點就將該節點的左指標指向上一個遍歷的節點,並將上一個遍歷的節點的右指標指向現在正在遍歷的節點,那麼當我們遍歷完整棵樹後,我們的雙向連結串列也改好啦!這樣既不用新增多餘節點,也不用新增多餘的指標變數。你可以寫下程式碼試試。
總結
二叉排序樹的效能取決於二叉樹的層數:
- 最好的情況是 O(logn),存在於完全二叉排序樹情況下,其訪問效能近似於折半查詢;
- 最差時候會是 O(n),比如插入的元素是有序的,生成的二叉排序樹就是一個連結串列,這種情況下,需要遍歷全部元素才行(見下圖 b)。
Thanks
《輕鬆學演算法》