上一篇文章中,算是初步了解了二叉树是一种怎样的数据结构,也算是有了一个初步的印象。接下来用自己的代码去实现一个二叉搜索树(以下全叫二叉树)类,对外提供常用的接口,比如insert、erase、size、find等等。就像盖房一样,如果说二叉树是一座建筑,那么其中的节点就是一块块砖。要实现二叉树这个类,就必须先实现节点类,假设我们起名为treeNode。在STL标准库中,像一般的数据结构都是模板类,在这里为了方便起见,假设二叉树这个类中保存的数据全是int型。
这个节点类中,需要包含如下的一些成员:节点值value,指向左子节点的指针left,指向右子节点的指针right,指向父节点的指针parent。用代码实现如下:
class treeNode { public: int value; treeNode *left; treeNode *right; treeNode *parent; treeNode() { value = 0; left = NULL; right = NULL; parent = NULL; } };
为了方便起见,我们将值初始化为0,指针全部初始化为NULL。
有了这个类以后,等于有了盖房子的基本原料。现在还需要实现二叉树类,目的就是按照一定的规则,将一个个节点添加进来,构成二叉树。与此同时还需要向外部用户提供友好的接口,而不必关心内部的细节。比如说,我现在想将值为12的一个节点插入二叉树,只需要调用insert(12)即可,而不是先生成一个节点的实例,赋值为12,再调整指针,插入二叉树,同时还要保证二叉树的结构特性。这些实现细节都需要用insert函数封装起来。
在二叉树这个类中,需要包含如下的成员,insert()、erase()、find()、size()成员函数,treeSize,根节点的指针root。当然还有一个构造函数。用代码实现如下:
class searchTree { public: searchTree(const int &value); int insert(const int &value); int erase(const int &value); treeNode* find(const int &value); int size() const; private: int treeSize; treeNode* root; };
在构造函数中,我们将二叉树的实例初始化为包含一个根节点,根节点值为value的树。
insert函数首先会生成一个节点实例,节点值为value,然后将这个节点插入到树中。插入成功返回0,插入失败返回非0,当树中包含有相同的节点,即已经存在值为value的节点,插入失败。
erase函数删除树中一个值为value的节点,删除成功返回0,删除失败返回非0,当树中不包含值为value的节点,删除失败。
find函数会在树中查找值为value的节点,返回该节点的指针。如果查找成功返回该节点的指针,查找失败返回NULL,如果树中不包含值为value的节点,查找失败。
size函数返回当前树中节点的数量。
私有成员变量treeSize保存当前树中节点的个数,指针root保存跟节点的地址。
接下来实现构造函数,构造函数中只需要生成一个根节点的实例,然后将根节点的值设为value,同时treeSize值为1,表示当前树中有一个节点。代码如下:
searchTree::searchTree(const int &value) { root = new treeNode(); root->value = value; treeSize = 1; }
接下来实现size函数,因为size函数最简单,直接返回当前树中节点的个数。代码如下:
int searchTree::size() { return treeSize; }
接下来实现find函数,find函数会在树中查找值为value的节点,返回这个节点的指针。实现的思路就是从根节点开始比较根节点值和value的大小关系,如果根节点的值大于value,就沿左子节点下降,否则就沿右子节点下降。遵循这种下降规则,会出现两种可能,第一种就是找到节点值和value相等的节点,返回节点指针。第二种就是下降到一个空节点,也就是NULL,此时返回NULL,代表查找失败。代码如下:
treeNode* searchTree::find(const int &value) { treeNode *curr; curr = root; //从根节点开始从上到下查找 while (curr!=NULL) //终止条件之一就是下降到空节点 { if ((curr->value) > value) //值小于当前节点的值,沿左子节点下降 { curr = curr->left; } else if ((curr->value)<value) //值大于当前节点的值,沿右子节点下降 { curr = curr->right; } else //值等于当前节点的值,查找结束,返回 { return curr; } } return NULL; //已经下降到空节点还是没找到该节点,代表该节点不在树中,查找失败,返回NULL }
接下来实现insert函数,insert函数会在当前的树中插入一个节点值为value的节点。函数的实现思想和find函数是类似的,也是从根节点开始比较根节点的值和value的大小关系,如果根节点值大于value,就沿左子节点下降,否则沿右子节点下降。这样下降最终会出现两种可能,第一种就是找到一个节点值和value大小相等,此时代表树中已经有值为value的节点,返回1,代表插入失败。第二种就是下降到一个空节点,也就是NULL,此时找到了正确的插入位置,新生成一个节点,赋值为value,插入到该位置。需要注意的问题就是在下降的过程中需要记录当前节点的父节点,这样当最终插入成功时,新生成的节点才知道自己的父节点是谁。还要记录最后一次下降是沿左子节点还是右子节点下降,这样最终插入的节点就知道自己是其父节点的左子节点还是右子节点。代码如下:
int searchTree::insert(const int &value) { int direct = -1; //从上到下下降时,需要记录上一次下降时沿左子节点还是沿右子节点下降 treeNode *curr,*par; //当前节点以及当前节点的父节点 curr = root; //从根节点开始从上到下查找 /*从上到下查找插入位置的过程需要保存当前节点的父节点,虽然每一个节点保存了自己的父节点, 但是插入的位置是一个空节点,也就是NULL,空节点是没有自己的父节点*/ par = root; while (curr!=NULL) //终止条件之一就是查找到一个空节点作为插入位置 { par = curr; //保存父节点 if ((curr->value) > value) //如果值小于当前节点的值,沿左子节点下降 { curr = curr->left; direct = 0; } else if ((curr->value)<value)//如果值大于当前节点的值,沿右子节点下降 { curr = curr->right; direct = 1; } else { return 1; //如果值等于当前节点的值,插入失败,代表树中已有该节点,返回1 } } curr = new treeNode(); //动态生成一个节点,赋值为value curr->value = value; if (direct!=-1) //如果值为-1,代表根节点为NULL,出现错误 { if (direct == 0) //表示做后一次下降是沿左子节点下降,那么新插入的节点就是父节点的左子节点 { par->left = curr; } else //表示最后一次下降是沿右子节点下降,那么新插入的节点就是父节点的右子节点 { par->right = curr; } curr->parent = par; //更新插入节点的父节点 treeSize++; //节点数量加一 return 0; } return 1; //根节点为NULL,出现错误,返回1 }
接下来实现erase函数,这个函数放在最后一个就是因为这是最难的一个函数。我们的第一反应是删除不是很简单吗,找到这个节点,释放掉这段内存不就好了吗。如果只是单纯删除一个节点,很简单。难的地方在于删除了这个节点之后,还要保持二叉树的结构特性。也就是说删除节点之后,剩下的结点还要组成一棵完整的树,其中还有一层内在的含义就是,树中的每个节点还要满足左子节点值小于父节点,右子节点值大于父节点这样的关系。
我们假设有下面这样一棵树:
假设我们删除8这个节点,如果按照上面的思想,只是单纯的释放掉8节点的内存,那么根节点12就没有了左子节点,左子节点指向了一段未分配的内存,这样在访问的时候就会出错。如果不能沿左子节点下降,那么剩下的6、11、10这三个节点都不能被访问。产生的还有一个问题就是节点6、11没有了自己的父节点,那么在遍历树的时候,是不能沿父节点返回的。显然这种删除的方法是不科学的。可是如果删除10节点,如果还按原先的思路,释放10节点的内存,将11节点左子节点指针指向NULL,好像又是合理的。
节点8和节点10的区别就在于8的左右子节点均不为空,而10节点的左右子节点均为空。这样我们就按左右子节点是否为空的原则,将节点分为四种情况:
1、左右子节点均为空。
2、左子节点为空,右子节点不为空。
3、右子节点为空,左子节点不为空。
4、左右子节点均不为空。
针对上述四种情况,我们要找出不同的删除规则,目的都是一样的。那就是找出一个替代节点,替代当前被删除的节点,这个替代节点的作用就是替代掉被删除的节点,依然能保证剩余的节点依然是一棵完整的二叉树。
第一种情况下,替代节点就是NULL。
第二种情况下,上图中13节点满足这种情况,此时找到的替代节点就是它的右子节点,16节点。
第三种情况下,上图中的11节点满足这种情况,此时找到的替代节点就是它的左子节点,10节点。
第四种情况下,上图中的8节点满足这种情况,此时找替代节点的方法就是先下降到该节点的右子节点,也就是11节点。,如果该节点没有左子节点,那么该节点就是替代节点,也就是图中的11节点。如果有左子节点,就再沿该节点的左子节点一直下降到末端。此时找到的替代节点就是图中的10节点。
替代节点并不是唯一的,只要找到的替代节点能保证剩余的节点依然是一棵完整的二叉树即可。寻找的过程中,要保持一个固定的规则不变,而且要尽量简单、快速。上述寻找的过程中我们的规则就是先将节点分类,再针对四种不同的情况,设置四种子规则。代码如下:
int searchTree::erase(const int &value) { treeNode *curr, *par, *replace, *replacePar; //分别为当前节点,当前节点的父节点,替代节点,替代节点的父节点 int direct = -1; curr = root; //从根节点开始查找待删除的点,查找过程和find函数一致 par = root; while (curr!=NULL) { if ((curr->value) > value) { par = curr; curr = curr->left; direct = 0; } else if ((curr->value)<value) { par = curr; curr = curr->right; direct = 1; } else //找到了待删除的点 { if (curr->left==NULL&curr->right==NULL) //对应于情况1,左右子节点均为NULL,也就是该节点为叶节点 { if (direct == 0) //根据最后一次下降方向,将父节点的左子节点或右子节点置为NULL { par->left = NULL; } else if (direct==1) { par->right = NULL; } delete curr; //释放内存,节点数量减一 treeSize--; return 0; } else if (curr->left==NULL&curr->right!=NULL) //对应于情况2,左子节点为空,右子节点不为空 { replace = curr->right; //替代节点就是当前节点的右子节点 /*根据最后一次下降方向,将父节点的左子节点或者右子节点置为替代节点,替代节点父节点为当前节点的父节点*/ if (direct == 0) { par->left = replace; replace->parent = par; } else if (direct==1) { par->right = replace; replace->parent = par; } else //direct=-1表示删除的根节点,将根节点设置为替代节点,替代节点的父节点置为NULL { root = replace; replace->parent = NULL; } delete curr; //释放当前节点的内存,节点数量减一 treeSize--; return 0; } else if (curr->left != NULL&curr->right == NULL) //对应于情况3,和情况2类似 { replace = curr->left; if (direct == 0) { par->left = replace; replace->parent = par; } else if (direct == 1) { par->right = replace; replace->parent = par; } else { root = replace; replace->parent = NULL; } delete curr; treeSize--; return 0; } else //左右节点均不为空 { replace = curr->right; //先将替代节点暂定为当前节点右子节点 if (replace->left==NULL) //如果右子节点的左子节点为NULL,那么右子节点就是替代节点 { replace->left = curr->left; //更新替代节点的左子节点为当前节点的左子节点 if (curr->left!=NULL) { /*如果当前节点的左子节点存在,更新左子节点的父节点。此时如果左子节点为NULL,NULL是没有父节点的*/ curr->left->parent = replace; } } else //右子节点左子节点不为NULL,此时就要沿着左子节点一直下降到末端 { replacePar = replace; //下降的过程保存替代节点的父节点 while (replace->left != NULL) //一直下降到末端 { replacePar = replace; replace = replace->left; } /*下降到末端时,替代节点会出现两种情况。第一种是替代节点的右子节点不为NULL, 那么就需要将其右子节点作为替代节点父节点的左子节点,也就是将替代节点的右子树, 重新链接在树上*/ replacePar->left = replace->right; //无论哪种情况,都将替代节点的右子节点作为替代节点父节点的左子节点 if (replace->right!=NULL) //如果右子节点不为NULL,要更新右子节点的父节点 { replace->right->parent = replacePar; } replace->left = curr->left; //更新替代节点的左子节点指针 curr->left->parent = replace; replace->right = curr->right; //更新替代节点的右子节点指针 curr->right->parent = replace; } if (direct == 0) //根据最后一次下降方向,再更新父节点的指针 { par->left = replace; replace->parent = par; } else if (direct == 1) { par->right = replace; replace->parent = par; } else { root = replace; replace->parent = NULL; } delete curr; //释放掉当前节点的内存,树中节点数量减一 treeSize--; return 0; } } } return 1; }
删除一个节点难就难在最后一种情况,根据下图说明一下如何删除一个左右子节点均不为空的情况
现在假设删除节点36和节点11。
删除节点35:首先找到节点35的右子节点,节点64,发现64的左子节点为NULL,那么64就是替代节点。此时删除掉35节点需要更新有关35节点的三个关系指针,就是和左子节点的关系指针,和右子节点的关系指针,和父节点的关系指针。首先将节点64的左子节点指针设置为35的左子节点,也就是31节点,将31节点的父节点指针设置为节点64。左子节点关系指针更新完毕。同理再更新父节点的关系指针。因为替代节点就是删除节点的右子节点,所以右子节点的关系指针不需要更新。
删除节点11,首先将替代节点暂定为删除节点的右子节点,发现右子节点的左子节点不为NULL,沿着左子节点一直下降到末端,也就是节点12,为替代节点。此时要同时更新左子节点,右子节点,父节点的关系指针(简单的理解就是将节点12填充到原来11的位置,更新节点12与周边的关系)。更新完以后,发现从节点15以下的全部节点脱离了原来的树,所以还需要更新节点15与节点19的关系。
这等于说删除节点11是按两个步骤进行的,第一步找到替代节点,并从树中删除,因为此时替代节点是沿左子节点一直下降到末端,那么其左子节点必为NULL,那么删除替代节点的操作等于情况1或者情况2。第二步再将替代节点填充到删除节点的位置,更新与周边的关系指针,释放删除节点的内存。
到此,我们为了测试方便,还需要拿到一棵树的根节点,如果按照中序遍历输出树中的全部节点,就会按照从小到大排序的顺序输出。拿到这个根节点有两种方法,第一种就是将类中的root节点设置为public,或者我们还需要在类中再添加一个方法rootNode,返回当前的根节点。考虑到封装,后者还是比较好。考虑到安全性,应该将返回的指针设置为常量,这里为了方便。
treeNode* searchTree::rootNode() { return root; }
为了测试,我们还需要一个按中序将树中节点输出的函数,为了代码简洁,采用递归的方式。代码如下:
void output(treeNode *node) { if (node!=NULL) { output(node->left); cout << node->value << "-"; output(node->right); } }
到此为止,准备工作全部结束,写一个简单的测试函数,测试一下我们自己实现的二叉树。代码如下:
int main() { treeNode *root,*curr; searchTree tree(100); for (size_t i = 0; i < 20; i++) { tree.insert(rand() % 200); } cout << tree.size() << endl; curr = tree.find(41); if (curr!=NULL) { cout << "find the node" << endl; } else { cout << "the node is not in the tree" << endl; } tree.erase(41); root = tree.rootNode(); output(root); }
首先声明一个树的实例,根节点为100,再随机生成20个0-199的数插入到树中。然后查找值为41的节点,最后将这个节点删除。最后打印树中全部节点,如果节点是按从小到大顺序打印,就代表这是一棵完整的二叉树。输出结果如下:
刚开始size为19的原因是随机可能生成重复点,这样插入就会失败。我们看到节点以及从小到大顺序打印。最后再上传一张在vs环境下,树的内存分布图:
我们可以清晰地看到根节点为100,左右子节点分别为64和134。节点64的左右子节点分别为27和67,也可以清晰地看到它们在内存中的分布情况。