前言
之前几篇,主要讲了排序问题,同时也涉及到了一点数据结构,比如堆。这里我将介绍一下一个重要的数据结构树。
其实之前的堆其实已经说过,它属于完全二叉树。而维基百科中讲到,单纯的树有以下特点:
- 每个节点有零个或多个子节点;
- 没有父节点的节点称为根节点(树叶);
- 每一个非根节点有且只有一个父节点;
- 除了根节点外,每个子节点可以分为多个不相交的子树;
这里我们为什么要使用二叉搜索树呢?因为它在查找、删除、查找时都有较好的性能,各种数据结构搜索方式对比:
二分查找法
在说二分搜索树之前,我们先来看看与之思想几乎一样的二分查找法。
原理
在使用二分查找法之前,我们必须先对数组进行排序,这里可以看到算法其实是相辅相成的,很多时候一些算法的子过程也用到了其他的算法。
排序完成之后,我们取中间的那个数v与目标数进行比较,左边的部分就是小于v的,右边的就是大于v的,如果查找失败,那缩小范围继续取中间的值进行比较,直到查找结束。
代码实现
这里我们就使用了循环的方法进行查找,特别需要注意的是mid的溢出问题。L,R可以保证是在int范围内,但是他们相加的结果并不一定在int范围内,这就会导致溢出。这里就使用了一个很巧妙的方法避免了溢出问题,先进行减法,再加上偏移量。L+(R-L)/2在范围内,结果上是和(L+R)/2一致的,但是前者有效地避免了溢出。
//找到target,返回相应的索引index
//如果没有找到target,返回-1
template<typename T>
int binarySearch(T arr[], int n, T target) {
//在arr[L...R]之中查找
int L = 0, R = n-1;
while(L <= R) {
//可能存在溢出bug
// int mid = (L+R)/2;
int mid = L + (R-L)/2;
if(arr[mid] == target)
return mid;
if(target < arr[mid])
//在arr[L...mid-1]之中查找target
R = mid - 1;
else
//在arr[mid+1...R]之中查找
L = mid + 1;
}
return -1;
}
当然我们还可以用递归的方法
//递归方法实现
template<typename T>
int __binarySearch(T arr[], int L, int R, T target) {
if(L > R)
return -1;
//在arr[L...R]之中查找
int mid = L + (R-L)/2;
if(arr[mid] == target)
return mid;
else if(arr[mid] > target)
return __binarySearch(arr, L, mid-1, target);
else
return __binarySearch(arr, mid+1, R, target);
}
template<typename T>
int binarySearch2(T arr[], int n, T target) {
int result = __binarySearch(arr, 0, n-1, target);
return result;
}
当然我们这里的查找可以看出是查找一个没有重复值的数组,如果有重复值,我们会去搜索它的floor和ceil,由于时间原因我暂时还没有实现,有兴趣大家可以去尝试实现一下。
二叉搜索树
原理
二叉搜索树是树的其中一种,与最大二叉堆不同,它不一定是完全二叉树,它的特点如下:
- 若任意节点的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若任意节点的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 任意节点的左、右子树也分别为二叉查找树;
- 没有键值相等的节点。
如图所示:
代码实现
BST类
我们的所有方法应该都包含在一个BST类中,其中Key和Value是键值,他们可以是整数也可以是字符串等。
template <typename Key, typename Value>
class BST{
//内容稍后补充
};
节点定义
首先,我们需要定义树的节点,它应该有键值两个属性,同时还需要指向左右子树的指针。第一个构造函数就是普通节点的构造函数,第二个构造函数是复制一个节点,后面会有使用,这里先写出来,当然实际开发的过程中并不可能一步到位。。
struct Node{
Key key;
Value value;
Node *left;
Node *right;
Node(Key key, Value value) {
this->key = key;
this->value = value;
this->left = this->right = NULL;
}
//复制一个
Node(Node* node) {
this->key = node->key;
this->value = node->value;
this->left = node->left;
this->right = node->right;
}
};
插入
根据定义来看,其实二叉搜索树本身就很适合使用递归来进行操作,因为它的任一子树本身都是二叉搜索树。那么我们的插入形成一棵二叉搜索树的过程也可以使用递归。
我们先从根节点插入,比较过后,小的话转到左子树,大的话转到右子树,不断递归完成操作。如图所示:
代码如下:
public:
void insert(Key key, Value value) {
root = insert(root, key, value);
}
private:
//向以node为根的二叉搜索树中插入节点
//返回插入节点之后二叉搜索树的根
Node* insert(Node *node, Key key, Value value) {
//递归到底条件
if(node == NULL) {
count++;
return new Node(key, value);
}
if(key == node->key)
//key相等执行修改操作
node->value = value;
else if(key < node->key)
node->left = insert(node->left, key, value);
else
node->right = insert(node->right, key, value);
return node;
}
搜索
搜索同样是使用递归,但是返回值我们需要进行一定思考。如果我们返回一个Node那么等于是将我们的结构暴露了出来,同时在使用这个函数时,也有诸多不变,一个比较优秀的函数应该将自身的实现结构隐藏,返回一个易处理的数据类型。这里我们返回了一个Value型的指针,因为指针可以为空,如果不返回指针的话,我们需要对搜索失败的情况进行特殊处理。
这里也是使用了二叉搜索树的性质:左节点一定小于根节点,右节点一定大于根节点。如图:
public:
/**
返回值不适用Node,对外隐藏数据结构
返回Value可能不存在
*/
Value* search(Key key){
return search(root, key);
}
private:
//搜索以node为根的二叉搜索树中key对应的value
Value* search(Node* node, Key key){
if(node == NULL) {
return NULL;
}
if(key == node->key)
return &(node->value);
else if(key < node->key)
return search(node->left, key);
else
return search(node->right, key);
}
三种遍历
听说过二叉树,自然也就会听说过前序遍历、中序遍历和后序遍历了。
前序遍历的顺序:根->左->右;
中序遍历的顺序:左->根->右;
后序遍历的顺序:左->右->根。
这是一张中序遍历的图,中间的蓝点就是进行输出的时候,我们按照箭头的方向前进,遇到蓝色的点就输出,最后就完成了中序遍历。前序就是左边的点,后序就是右边的点了。这是一个快速判断输出结果的方式,但是我们实现的时候还是需要紧紧抓住定义。
代码实现如下:
public:
//前序遍历
void preOrder(){
preOrder(root);
}
//中序遍历
void inOrder(){
inOrder(root);
}
//后序遍历
void postOrder(){
postOrder(root);
}
private:
//对以node为根的二叉搜索树进行前序遍历
void preOrder(Node* node){
if(node != NULL){
cout<<node->key<<endl;
preOrder(node->left);
preOrder(node->right);
}
}
//对以node为根的二叉搜索树进行中序遍历
void inOrder(Node* node){
if(node != NULL){
inOrder(node->left);
cout<<node->key<<endl;
inOrder(node->right);
}
}
//对以node为根的二叉搜索树进行后序遍历
void postOrder(Node* node){
if(node != NULL){
postOrder(node->left);
postOrder(node->right);
cout<<node->key<<endl;
}
}
其实我们可以看到,代码真的没几行,只要紧紧抓住定义即可。
层序遍历
想必大家听说过了三种遍历,也一定听说过深度优先和广度优先吧。刚刚那三个其实就是深度优先的实现,首先考虑向下搜索。这里我们介绍一个广度优先的遍历方式——层序遍历。
层序遍历,顾名思义,就是一层一层地遍历下去。我们实现层序遍历的方式需要一种数据结构,它就是队列。
我们首先放入根节点,然后队首出队,这是放入队首左右节点,这时左节点成为队首,不断循环完成操作。
代码如下:
//层序遍历
void levelOrder(){
queue<Node*> q;
q.push(root);
while(!q.empty()){
//取出队首
Node* node = q.front();
q.pop();
cout<<node->key<<endl;
if(node->left)
q.push(node->left);
if(node->right)
q.push(node->right);
}
}
删除
删除是二分搜索树的难点,因为你在删除时,还必须保持二叉搜索树的数据结构。
查找最大值与最小值
根据二叉搜索树的定义,我们可以很快就能发现,最大值是最右边的节点,最小值就是最左边的节点。因此,我们只要不断递归即可实现。
代码实现:
public:
//寻找最小的键值
Key minimum(){
assert(count != 0);
Node* minNode = minimum(root);
return minNode->key;
}
//寻找最大的键值
Key maximum(){
assert(count != 0);
Node* maxNode = maximum(root);
return maxNode->key;
}
private:
//对以node为根的二叉搜索树查找最小值节点
Node* minimum(Node* node){
if(node->left == NULL)
return node;
return minimum(node->left);
}
//对以node为根的二叉搜索树查找最大值节点
Node* maximum(Node* node){
if(node->right == NULL)
return node;
return maximum(node->right);
}
删除最大最小值
删除最大最小值我们首先要找到最大最小值,找到之后,我们就可以进行删除操作了。
首先是删除最小值,最小值节点根据定义是不可能存在左节点的,因为没有更小的值了。如果说没有右节点,那最简单,直接删除就可以;如果有右节点,只需要将右节点替换原来的节点即可,因为右节点本身满足二叉搜索树,而它又肯定比自己父节点的父节点要小,所以替换原来的父节点后,整个树依然满足二叉搜索树性质。
这里我当时也迷了一会,但是重新看insert函数的时候,就可以知道为什么是这样了。
同理,删除最大值时,也是考虑两种情况:一种是没有左子树,直接删除;另一种有左子树,那么将左子树上移即可。
代码实现:
public:
//从二叉树中删除最小值所在的节点
void removeMin(){
if(root)
root = removeMin(root);
}
//从二叉树中删除最大值所在的节点
void removeMax(){
if(root)
root = removeMax(root);
}
private:
//删除以node为根的二分搜索树的最小节点
//返回新的二分搜索树的根
Node* removeMin(Node* node){
if(node->left == NULL){
//右节点不存在也没关系
Node* rightNode = node->right;
delete node;
count--;
return rightNode;
}
node->left = removeMin(node->left);
return node;
}
//删除以node为根的二分搜索树的最大节点
//返回新的二分搜索树的根
Node* removeMax(Node* node){
if(node->right == NULL){
//左节点不存在也没关系
Node* leftNode = node->right;
delete node;
count--;
return leftNode;
}
node->right = removeMax(node->right);
return node;
}
删除任意节点
我们删除最大最小值的时候,只需要移动节点即可,但是当我们需要删除任意节点时,情况就复杂了很多,我们必须找到一个节点,能替换删除的节点同时保持二叉搜索树的性质。
同样我们采用递归的方法。首先进行比较,先找到位置所在,左子树是比根小的值,右子树是比根大的值。接着,找到之后,有三种情况:
- 删除没有左子树的值
- 删除没有右子树的值
- 删除拥有左右子树的值
其中,前两种删除方法和之前的很类似,直接移动左右子树即可。第三种的话,我们可以查找它的后继节点。
可以看到如果我们要删除58,那么它的右子树的最小值替换之后,整个二叉搜索树依然能保持定义。因为右子树的任意一个值必定大于左子树的任意一个值,而59又满足比60小的定义,因此可以进行一次移动。
代码如下:
public:
//从二叉树中删除键值为key的节点
void remove(Key key){
remove(root, key);
}
private:
//删除掉node为根二分搜索树中key的节点
//返回删除节点后二分搜索树的根
Node* remove(Node* node, Key key){
//寻找
//不存在
if(node == NULL)
return NULL;
if(key < node->key){
node->left = remove(node->left, key);
return node;
}
else if(key > node->key){
node->right = remove(node->right, key);
return node;
}
else{ //key == node->key
if(node->left == NULL){
Node *rightNode = node->right;
delete node;
count--;
return rightNode;
}
if(node->right == NULL){
Node *leftNode = node->left;
delete node;
count--;
return leftNode;
}
//左右都不为空
//复制一份防止指向空,得到后继节点
Node *successor = new Node(minimum(node->right));
count++;
successor->right = removeMin(node->right);
successor->left = node->left;
delete node;
count--;
return successor;
}
}
第二种方式是找前驱节点
//if node->left && node->right != NULL
Node* processor = New Node(maximum(node->left));
count++;
processor->left = removeMax(node->left);
processor->right = node->right;
delete node;
count--;
return processor;
后记
当然我们的二叉搜索树也并非是完美的,一是尚无法对重复的元素进行操作,二是当数组有序时,会退化成链表。
对于第一种情况,我们可以给Node添加新的属性count进行统计;第二种情况,有一种经典的实现叫做红黑树,希望之后能同大家分享。
各项定义部分引用自维基百科
图片引用百度图片
代码实现参照liuyubobobo慕课网教程