前言:
在現在數據爲王的時代,數據的存儲量一般都是很大的,爲了在大量信息中找到某些值,就需要用到查找技術,爲了提高查找效率,需要對數據進行排序。排序和查找的數據處理量幾乎是整個數據處理量的80%,故排序和查找的有效性直接影響到基本算法的有效性。因而查找和排序是十分重要的處理技術。
往期基於線性表的排序方法--------》八大排序C語言實現版本
而基於線性表的查找方法一般分爲: 順序查找和二分查找。
接着便是學習基於樹結構的查找和排序方法。
基於樹結構的查找法是將待查表組織成特定樹結構的形式並在樹結構上實現查找的方法。主要包括二叉排序樹(也叫二叉搜索樹)、平衡二叉樹(AVL樹)、B樹等。
二叉搜索樹
二叉搜索樹概念
二叉搜索樹又稱二叉排序樹,它或者是一棵空樹,或者是具有以下性質的二叉樹:
- 若它的左子樹不爲空,則左子樹上所有節點的值都小於根節點的值
- 若它的右子樹不爲空,則右子樹上所有節點的值都大於根節點的值
- 它的左右子樹也分別爲二叉搜索樹
這是一個遞歸定義,注意只要節點之間具有可比性即可。
比如下面這棵樹,就是將數組int a [] = {5,3,4,1,7,8,2,6,0,9}按二叉搜索樹規則來組織的。
這樣的樹結構,可以讓我們的應對大數據的查找效率提高了質的飛躍。
比如在原始數組裏找一個數,要麼順序查找,時間複雜度是O(N),要麼先排序O(NlogN),在二分查找。顯然在面對大數據的情況下,效率不是很高。但是將其組織成二叉搜索樹形式,構造樹的時候就完成了排序,只需要二分即可O(logN),效率大大提高。
但是!!
對有n個結點的二叉搜索樹,若每個元素查找的概率相等,則二叉搜索樹平均查找長度是結點在二叉搜索樹的深度的函數。
但對於同一個關鍵碼集合,如果各關鍵碼插入的次序不同,可能得到不同結構的二叉搜索樹:
最優情況下,二叉搜索樹爲完全二叉樹,則可以使用二分查找:
最差情況下,二叉搜索樹退化爲單支樹,就又變回了順序查找了,反而白費一場。
所以普通二叉排序樹有退化成單支樹的情況,二叉搜索樹的性能也就失去了。那能否進行改進,不論按照什麼次序插入關鍵碼,都可以是二叉搜索樹的性能最佳?這便引申出來平衡二叉搜索樹(AVL),但是也要先把普通的二叉搜索樹原理及實現掌握纔可以觸碰它!
二叉搜索樹的實現
節點結構
template<class K>
struct BSTreeNode
{
BSTreeNode<K>* _left;
BSTreeNode<K>* _right;
K _key;
BSTreeNode(const K& key)
:_left(nullptr)
, _right(nullptr)
, _key(key)
{}
};
拷貝構造和析構實現
template<class K>
class BSTree
{
typedef BSTreeNode<K> Node;
public:
//構造函數不用寫,在聲明成員時已有缺省值
BSTree() = default;//此語句表示使用默認的構造,因爲下面實現了拷貝構造,編譯器檢測到了後會提示你實現構造函數
BSTree(const BSTree<K>& bst)//拷貝構造
:_root(nullptr)
{
_root=_copy(bst._root);
}
Node* _copy(Node* root)//遞歸拷貝
{
if (!root)
return nullptr;
Node* newroot = new Node(root->_key);
newroot->_left = _copy(root->_left);
newroot->_right = _copy(root->_right);
return newroot;
}
~BSTree() //析構函數必須要自己寫
{
_destroy(_root);
}
void _destroy(Node* root)//遞歸釋放空間
{
if (!root)
return;
_destroy(root->_left);
_destroy(root->_right);
delete root;
}
增(插入)操作
//搜索二叉樹的插入操作
bool Insert(const K& key)
{
if (_root == nullptr)//一開始沒有任何節點
{
_root = new Node(key);
return true;
}
//有節點時,需要利用二叉搜索樹可以二分查找的特點先去找可以插入的點的位置
Node* parent = nullptr;
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key>key)
{
parent = cur;
cur = cur->_left;
}
else //原本已存在
return true;
}
//最終會停在某個滿足的節點下,但是要考慮插入到左邊還是右邊
if (parent->_key < key)
{
parent->_right = new Node(key);
}
else
parent->_left = new Node(key);
return true;
}
//遞歸插入寫法
bool _insertR(Node*& cur, const K& key)//注意參數類型,這個引用就完成了parent的作用 ,即遞歸到下一層的時候就是和他的父親節點連接的
{
if (cur == nullptr)
{
cur = new Node(key);
return true;
}
if (cur->_key < key)
{
return _insertR(cur->_right, key);
}
else if (cur->_key > key)
return _insertR(cur->_left, key);
else
return false;
}
bool InsertR(const K& key) //由於遞歸函數參數的限制,必須寫一個內部用的子函數去遞歸
{
return _insertR(_root, key);
}
查找操作
由於是按照二叉搜索樹的規則來建的樹結構,所以可以直接使用二分去其左或右子樹中去找。
//查找操作
Node* Find(const K& key)
{
Node* cur = _root;
while (cur)
{
if (cur->_key < key)
{
cur = cur->_right;
}
else if (cur->_key>key)
{
cur = cur->_left;
}
else
return cur;
}
return nullptr;
}
//遞歸查找
Node* _findR(Node*& cur, const K& key)
{
if (cur == nullptr)
return cur;
if (cur->_key < key)
return _findR(cur->_right, key);
else if (cur->_key>key)
return _findR(cur->_left, key);
else
return cur;
}
Node* FindR(const K& key)
{
return _findR(_root, key);
}
刪除操作
對於樹中的節點無非就是四種情況:
a. 要刪除的結點無孩子結點
b. 要刪除的結點只有左孩子結點
c. 要刪除的結點只有右孩子結點
d. 要刪除的結點有左、右孩子結點
在對於要刪除的節點分析:要是沒找到,不用刪除操作;要是找到了,根據剩下三種情況具體分析。
對應三種情況的刪除點舉例
情況一:刪除1:沒有右孩子, 只有左孩子
刪除該結點且使被刪除節點的雙親結點指向被刪除節點的左孩子結點
情況二:刪除8:沒有左孩子, 只有右孩子
刪除該結點且使被刪除節點的雙親結點指向被刪除結點的右孩子結點
情況三:刪除5:左右孩子都存在
昔換法刪除,左樹的最大節點(最右節點)或者是右樹的最小節點(最左節點)用它的值填補到被刪除節點中,再來處理該結點的刪除問題(都是最左或者最右節點了,肯定只有一個孩子或者沒有孩子,所以問題就退化到前面的情況了)。
//二叉樹刪除操作
bool Erase(const K& key)
{
Node* parent = nullptr;
Node* cur = _root;
//先找到該節點,再根據具體情況,再不破壞二叉搜索樹的規則下,刪除該節點
while (cur)
{
if (cur->_key < key)
{
parent = cur;
cur = cur->_right;
}
else if (cur->_key> key)
{
parent = cur;
cur = cur->_left;
}
else //找到了,根據具體情況刪除
{
//1.沒有左節點 將此節點的右樹替換上來
//2.沒有右節點 將此節點的左樹替換上來
//3.左右節點都存在 替換法刪除 左樹的最大節點(最右節點) 或者是右樹的最小節點(最左節點)
//
if (cur->_left == nullptr) //1
{
if (parent == nullptr)//也要先判斷是不是根節點
{
_root=cur->_right;
}
else //不是根節點了 ,就替換到正確的位置去
{
if (parent->_left == cur)
parent->_left = cur->_right;
else
parent->_right=cur->_right;
}
delete cur;
}
else if (cur->_right == nullptr)//2
{
if (parent == nullptr)
_root = cur->_left;
else
{
if (parent->_left == cur)
parent->_left = cur->_left;
else
parent->_right = cur->_left;
}
delete cur;
}
else//3 左右都在 替換法刪除 左樹的最大節點(最右節點) 或者是右樹的最小節點(最左節點)
{
Node* minparent = cur;
Node* minNode = cur->_right; //這裏是去找右樹的最左節點
while (minNode->_left)
{
minparent = minNode;
minNode = minNode->_left;
}
swap(cur->_key, minNode->_key);//替換key值,再將此節點刪除
//但是此被刪除的這個最左節點可能還有右子節點,所以不能直接刪除,問題退化到上面的只有左節點 或 右節點的情況
minparent->_left=minNode->_right;
//否則的話,不用處理,可以直接刪除
delete minNode;
}
return true;
}
}//此樹爲空
return false;
}
//遞歸刪除
bool _eraseR(Node*& cur, const K& key)
{
if (cur == nullptr)//遞歸出口 ,沒找到或爲空樹
return false;
//還是先去找要刪除的節點
if (cur->_key < key)
return _eraseR(cur->_right, key);
else if (cur->_key> key)
return _eraseR(cur->_left, key);
else //找到後,還是根據三種情況來正確刪除 //遞歸函數的參數裏是節點指針的引用,所以不用再定義一個父節點來協助鏈接了
{
Node* del = cur;
if (cur->_left == nullptr)
{
cur = cur->_right;
delete del;
return true;
}
else if (cur->_right == nullptr)
{
cur = cur->_left;
delete del;
return true;
}
else
{
//選擇再其右子樹中找最左節點
Node* minNode = cur->_right;
while (minNode->_left)
{
minNode = minNode->_left;
}
cur->_key = minNode->_key;//注意是賦值,
return _eraseR(cur->_right, minNode->_key);//將問題轉換(退化)成上面只有左節點或右節點的情況
}
}
}
bool EraseR(const K& key)
{
return _eraseR(_root, key);
}
改?
由於本身爲樹結構,且又有排序的規則,故改操作十分不易於實現,且對於排序樹本身應用角度來說,改操作意義不大。
二叉搜索樹的應用
二叉搜索樹便是這些應用的”地基“。
- K模型(set容器):K模型即只有key作爲關鍵碼,結構中只需要存儲Key即可,關鍵碼即爲需要搜索到的值。
比如:給一個單詞word,判斷該單詞是否拼寫正確,具體方式如下:
以單詞集合中的每個單詞作爲key,構建一棵二叉搜索樹
在二叉搜索樹中檢索該單詞是否存在,存在則拼寫正確,不存在則拼寫錯誤。 - KV模型(map容器):每一個關鍵碼key,都有與之對應的值Value,即<Key, Value>的鍵值對。該種方式在現實生活中非常常見:比如英漢詞典就是英文與中文的對應關係,通過英文可以快速找到與其對應的中文,英文單詞與其對應的中文<word, chinese>就構成一種鍵值對;再比如統計單詞次數,統計成功後,給定單詞就可快速找到其出現的次數,單詞與其出現次數就是<word, count>就構成一種鍵值對。
注意:二叉搜索樹需要比較,鍵值對比較時只比較Key,查詢英文單詞時,只需給出英文單詞,就可快速找到與其對應的value。