一.前言
我们在上一篇里简单了解了什么是树,以及树的一种特殊结构——二叉树。而我们对二叉树息息相关的堆进行了简单的介绍。我们知道了堆是借助二叉树中完全二叉树来实现的。它实现了二叉树的顺序存储。但对于普通的二叉树来说,顺序存储会造成空间浪费。所以对于普通的二叉树来说,链式结构存储更有利。现在我们一起了解一下二叉树的链式存储。
二.二叉树的链式存储
二叉树的链式存储是借助两个指针分别执行其左右孩子来实现的(图a)。因为二叉树每一个节点的度都小于等于2,所以我们可以采取左孩子右孩子的方式(二叉链表)来表示二叉树。之前左孩子右兄弟的表示方式更适合普通的树。除了这种方式,我们还有另一种链式表示方式:三叉链表,那就是多加一个指向父亲的指针(图b)。但是相比第二种来说,第一种我们更为常用。
//二叉链表
typedef int BinaryTreeDataType;
typedef struct BinaryTreeNode
{BinaryTreeDataType val;struct BinaryTreeNode* _left;//指向左孩子struct BinaryTreeNode* _right;//指向右孩子
}BTNode;//三叉链表
typedef struct BinaryTreeNode
{BinaryTreeDataType val;struct BinaryTreeNode* _left;//指向左孩子struct BinaryTreeNode* _right;//指向右孩子struct BinaryTreeNode* _parent;//指向父亲
}BTNode;
三.二叉树的遍历
二叉树的遍历有四种方式,分别为前序遍历、中序遍历、后序遍历以及层序遍历。
-
前序遍历(Preorder Traversal):先访问根节点,然后按照先左后右的顺序遍历左子树和右子树,即根-左子树-右子树的顺序。
-
中序遍历(Inorder Traversal):先按照先左后右的顺序遍历左子树,然后访问根节点,最后再遍历右子树,即左子树-根-右子树的顺序。
-
后序遍历(Postorder Traversal):先按照先左后右的顺序遍历左子树和右子树,然后再访问根节点,即左子树-右子树-根的顺序。
-
层序遍历(Level Order Traversal),从上到下逐层遍历树的节点。
3.1前序遍历
前序遍历也叫前根遍历,二叉树的前序遍历的访问规则是:遇到根节点后,先访问根节点,然后访问根节点的左子树,在访问根节点的右子树。而每一次遇到左子树或者右子树时,就将其再分为根、左子树、右子树。直到遍历完该树。如下图所示,它的前序遍历结果是什么?
根据上面给出的方法,我们先访问根节点1,然后访问其左子树,然后访问左子树的根2,在访问2的左子树,然后访问左子树的根3,然后访问3的左子树为空,然后右子树为空,在访问2的右子树为空,在访问1的右子树……。
所以根据分析最后的结果应该为:1、2、3、N、N、N、4、5、N、N、6、N、N。在实际的访问时我们是不必打印空的。但是这里为了理解清楚我们将空也打印上了。
我们理解了前序遍历之后,怎样转化成代码呢?我们仔细思考上面的分析过程,我们再前序遍历的过程中运用到了递归,所以我们再完成代码的时候也要利用递归的思想。
//前序遍历
void PreorderTraversal(BTNode* root)
{if (root == NULL){printf("N ");}//先访问根节点printf("%d ", root->val);//访问其左子树PreorderTraversal(root->_left);//访问其右子树PreorderTraversal(root->_left);
}
我们还没有了解二叉树的创建,所以我们手搓一个和上图一样的二叉树以此来调试我们的前序遍历代码。
//树节点的创建
BTNode* BuyNode(int x)
{BTNode* node = (BTNode*)malloc(sizeof(BTNode));if (node == NULL){perror("malloc");return;}node->val = x;node->_left = NULL;node->_right = NULL;return node;
}//二叉树的创建
BTNode* CreateBinaryTree()
{BTNode* node1 = BuyNode(1);BTNode* node2 = BuyNode(2);BTNode* node3 = BuyNode(3);BTNode* node4 = BuyNode(4);BTNode* node5 = BuyNode(5);BTNode* node6 = BuyNode(6);node1->_left = node2;node1->_right = node4;node2->_left = node3;node4->_left = node5;node4->_right = node6;return node1;
}
我们运行我们的前序遍历代码,结果与我们推导出来的结果是一样的。
3.2中序遍历
中序遍历也叫中根遍历,其遍历二叉树的规则是:遇到根时先不访问根,先访问其左子树,在访问根,最后访问其右子树。遇到子树之后,又将其分为左子树、根、右子树。直到将该树的所有节点遍历完。 我们继续对上面的树进行分析:
我们遇到根1之后,先不访问它,先访问它的左子树,遇到左子树的根2之后,先访问它的左子树3,然后先访问3的左子树空,再访问3,在访问3的右子树空。在访问2,在访问2的右子树空。在访问1,在访问1的右子树4,访问4的左子树5,先访问5的左子树空,在访问5,在访问5的右子树空,在访问4,在访问4的右子树6,先访问6的左子树空,在访问6,最后访问6的右子树空。
根据上面的分析,中序遍历的结果为:N、3、N、2、N、1、N、5、N、4、N、6、N。
我们发现,该方法也使用了递归的思想,而且代码应该也和前序遍历差不多:
//中序遍历
void InorderTraversal(BTNode* root)
{if (root == NULL){printf("N ");return;}//访问其左子树InorderTraversal(root->_left);//先访问根节点printf("%d ", root->val);//访问其右子树InorderTraversal(root->_right);
}
我们运行代码,该结果与我们的分析是一致的。
3.3后序遍历
后序遍历也叫后根遍历,其遍历二叉树的顺序是遇到根之后先不访问,先访问其左子树,再访问其右子树,最后访问其根。其分析的思路和前序中序是一样的,我们简单看一下:
我们遇到根1之后,先访问其左子树2,遇到2之后,我们先访问2的左子树3,遇到3之后我们先访问3的左子树空,在访问3的右子树空,再访问3。访问完之后回到2,访问2的右子树空,再访问1的右子树……。
根据上面的分析,后序遍历的结果为:N、N、3、N、2、N、N、5、N、N、6、4、1.
我们由上面前序中序得出,其代码只是调用指令的顺序改变了而已,后序也一样。
//后序遍历
void PostorderTraversal(BTNode* root)
{if (root == NULL){printf("N ");return;}//访问其左子树PostorderTraversal(root->_left);//访问其右子树PostorderTraversal(root->_right);//先访问根节点printf("%d ", root->val);
}
结果与分析相同。
四.二叉树的节点个数
我们之前实现堆是借助数组实现的,而数组想要直到元素的个数是非常简单的。但是我们现在对于普通的二叉树来说,要用链式结构存储,那么现在该怎么统计二叉树的节点个数呢?
二叉树是递归而来的,我们刚才实现的遍历方法也借助了递归的思想。所以我们在实现该方法时也可以借助递归的思想。
要求一个树的节点个数是不是可以看作其左子树的节点个数+右子树的节点个数+自己就是该树的节点个数。
这就是一个递归的思想,将一个树的节点个数问题,转化成它的左右子树的节点+自己。然后左右子树又可以看成根,继续分为左右子树的节点数+自己。一直这样分化下去。直到分到叶子节点(没有左右儿子)。
//二叉树节点个数
int GetBinaryTreeSize(BTNode* root)
{if (root == NULL){return 0;}return GetBinaryTreeSize(root->_left) + GetBinaryTreeSize(root->_right) + 1;
}
五.二叉树叶子结点的个数
叶子节点是度为0的节点,也就是没有左右儿子的节点。我们实现该方法时依旧可以借助递归的思想。二叉树叶子结点的个数 = 左子树叶子结点的个数+右子树叶子结点的个数。而左右子树又可以分为左右子树,我们一直这样分解下去,直到遇到某一个节点的左右子树都为NULL,就说明该节点就是叶子节点,我们就返回1,如果遇到NULL节点,我们直接返回0.
//二叉树叶子结点的个数
int GetBinaryTreeLeafSize(BTNode* root)
{if (root == NULL){return 0;}if (root->_left == NULL && root->_right == NULL){return 1;}return GetBinaryTreeLeafSize(root->_left) + GetBinaryTreeLeafSize(root->_right);
}
为了便于理解该代码,我们借助代码画出递归展开图来清晰思路。
六.二叉树第k层节点的个数
我们用k来表示我们当前所在的层数,假设我们要求第2层的节点个数,k=2时,我们在第一层,当k=1时,我们就来到了待求层。当k不等于1时,我们将根分为左右子树分别去求第k层的节点的个数。当k=1时,root就指向这待求层的一个节点,所以k = 1就代表有一个节点,返回1即可。
// 二叉树第k层结点个数
int GetBinaryTreeLevelKSize(BTNode* root, int k)
{if (root == NULL){return 0;}if (k == 1){return 1;}return GetBinaryTreeLevelKSize(root->_left, k - 1) + GetBinaryTreeLevelKSize(root->_right, k - 1);
}
七.二叉树查找值为x的节点
要在二叉树种查找某个值为x的节点,我们只需要遍历二叉树的节点,找到与x值有相等值的第一个节点就行,然后返回该节点的地址。如果节点与x不相等,那就先比较该节点的左孩子,再比较该节点的右孩子。
//二叉树查找值为x的节点
BTNode* BinaryTreeFind(BTNode* root, BinaryTreeDataType x)
{if (root == NULL){return NULL;}//先跟根比较,相等直接返回根节点,不相等在于根的左右子树比较if (root->val == x){return root;}//如果根的左子树的根与x相等,就直接返回,不必在比较右子树了。BTNode* ret1 = BinaryTreeFind(root->_left, x);if (ret1){return ret1;}BTNode* ret2 = BinaryTreeFind(root->_right, x);if (ret2){return ret2;}//走到这里说明,左右子树都不和x相等,所以返回NULLreturn NULL;
}
我们发现,该方法和前序遍历有点类似,都是先访问根,左子树、右子树。
我们查找2这个节点是可以查到的。
当我们查找二叉树中没有的数据时,就会返回NULL。
八.二叉树的深度
二叉树的深度也就是二叉树的高度,其高度是由最高的那一个子树决定的。那么怎么求该树的高度的呢?
二叉树的深度可以表示为左右子树中高的+1。如下图,该树的深度就是右子树的高度3+1 = 4.
当我们遍历到叶子节点是就说明已经到底了,此时的高度就是1。
//二叉树的高度
int GetBinaryTreeDepth(BTNode* root)
{//如果根为空,说明该树的高度为0if (root == NULL){return 0;}if (root->_left == NULL && root->_right == NULL){return 1;}//树的高度等于左右子树中高的+1int height1 = GetBinaryTreeDepth(root->_left);int height2 = GetBinaryTreeDepth(root->_right);return height1 > height2 ? height1 + 1 : height2 + 1;
}
九.二叉树的创建
我们在创建二叉树的时候,通常会给出一个二叉树的前序表达形式,我们要根据该前序序列将二叉树构建出来。假如有一段序列是“abc##de#g##f###”,其中#表示空格,在二叉树中表示空树,跟我们上面打印的前序序列中的N是一样的。
已知的前序序列是储存在一个数组中的,我们需要访问该数组的数据作为二叉树结点的值。如果我们访问的不是空,那就创建节点,给该节点赋值,然后先创建该节点的左孩子,在创建该节点的右孩子。每个节点的创建过程是相同的。这里之所以要传pi指针,是为了形参的改变可以影响实参。如果传int的话,再该函数+1了,可以到了递归的函数中i的值还是最初的值。
因为该序列是前序,所以我们创建的方式也与前序代码的逻辑相似。
//二叉树的创建
BTNode* CreateBinaryTree(char* arr,int *pi)
{//遇到"#"就是空格,直接返回NULLif (arr[*pi] == '#'){(*pi)++;return NULL;}BTNode* node = (BTNode*)malloc(sizeof(BTNode));if (node == NULL){perror("malloc");exit(-1);}node->val = arr[(*pi)++];node->_left = CreateBinaryTree(arr, pi);node->_right = CreateBinaryTree(arr, pi);return node;
}
十.二叉树的销毁
二叉树的销毁非常简单,我们只需要一个一个节点的销毁即可。那我们采用哪种销毁方式呢?前序?中序?还是后序呢?答案是后序。如果采用前两种的销毁方式,都会导致找不到有些节点。所以我们在这里采取后序递归的方式来销毁二叉树。
//二叉树的销毁
void BinaryTreeDestroy(BTNode* root)
{if (root == NULL){return;}BinaryTreeDestroy(root->_left);//先销毁左子树BinaryTreeDestroy(root->_right);//再销毁右子树free(root);//最后消除根root = NULL;
}
十一.二叉树的层序遍历
二叉树的层序遍历就是根据上下层关系,从左到右依次遍历的方法。
比如上图:该树的层序遍历的结果就是1、2、4、3、5、6。 那我们要如何实现层序遍历呢?这里我们需要借助队列来实现。
当前已经创建好了一个空队列q,我们先将该树的根插入到队列中。
然后我们取出队头的数据,从队列中删除它,打印该节点数据后,再将将它的左右孩子插入到队列中,插入时要先判断其是否为空,空则不插入。
然后我们重复刚才的操作,先将队头数据取出,然后从队列中删除它,打印该节点数据,再将其左右孩子插入到队列中。 重复该操作,直到队列为空。
//二叉树的层序遍历
void LevelOrderTraversal(BTNode* root)
{if (root == NULL){return;}//先创建队列Queue q = { 0 };QueueInit(&q);//先将根节点插入到队列中QueuePush(&q, root);while (!QueueEmpty(&q)){//取出队头数据BTNode* front = GetQueueFront(&q);QueuePop(&q);printf("%d ", front->val);if (front->_left != NULL){QueuePush(&q, front->_left);}if (front->_right != NULL){QueuePush(&q, front->_right);}}QueueDestroy(&q);
}
与我们的结论相同。
十二.判断是不是完全二叉树
判断一个数是不是完全二叉树之前,我们先要了解什么是完全二叉树?完全二叉树就是只有最后一层不满,最后一层节点从左到右连续。
形如上图的二叉树就是完全二叉树,注意下图的就是完全二叉树,因为它最后一层的节点从左到右并不连续。
注意:满二叉树是特殊的完全二叉树
我们发现,对于完全二叉树来说,空节点的位置一定是连续的,它只会出现在最后一层。且第一个空节点出现,后面就全是空节点。而对于非完全二叉树来说,其空节点的后面还有可能有非空节点。
知道了这个特点,我们就可以借助刚才的层序遍历的逻辑来判断是不是完全二叉树。判断的方法是:先将根节点插入到队列中,然后取出该节点,从队列中删除,然后将该节点的儿子节点也插入到队列中。与层序遍历不同是,在插入的过程中将空节点也插入进去。
我们是在边取边插,等到我们取到第一个空结点的时候,此时就不再插入了,判断队列中此时是否都是空节点。如果是的话,那该树就是完全二叉树,否则就不是。
//判断一个树是不是完全二叉树
bool BinaryTreeComplete(BTNode* root)
{if (root == NULL){return false;}//先创建队列Queue q = { 0 };QueueInit(&q);//先将根节点插入到队列中QueuePush(&q, root);while (!QueueEmpty(&q)){//取出队头数据BTNode* front = GetQueueFront(&q);QueuePop(&q);//判断取到没有第一个空节点if (front == NULL){//取到第一个空节点之后,判断队列中是不是全都是空while (!QueueEmpty(&q)){if (GetQueueFront(&q)){return false;}QueuePop(&q);}//到了这里说明队列里全是空节点break;}QueuePush(&q, front->_left);QueuePush(&q, front->_right);}QueueDestroy(&q);return true;
}
完!