[TOC]
树(Tree)是 个结点的有限集。 时称为空树。
在任意一棵非空树中:
(1)有且仅有一个根结点(Root);
(2)当 时,其余每个结点可分为 个互不相交的有限集 ,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
树(Tree)的结构图如下所示:
树的一些相关概念
结点的分类
树的结点包含一个数据元素和若干指向其子树的分支。
结点拥有的 子树数量 称为该结点的 度(Degree)。度为 0 的结点称为 叶(Leaf)结点 或 终端结点;度不为 0 的结点称为 分支结点(树枝结点) 或 非终端结点。
树的度是树内各结点的度的 最大值。如下图所示的树的度为 3(因为各结点度的最大值为结点 D,D 的度为 3)
结点的层数
结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层,依次类推。处于同一层的结点为兄弟结点。树的最大层次称为树的深度(Depth)或高度。如下图所示:
二叉树(Binary Tree)
二叉树(Binary Tree):是 个结点的有限集合,该集合通常由一个根结点和两棵互不相交的称为根结点的左子树和右子树组成 。
简而言之,二叉树 就是每个结点最多只有两颗子树。
满二叉树:在一棵二叉树中,如果 所有数支结点都存在左子树和右子树,并且 所有叶子结点都在同一层上,则称这样的二叉树为满二叉树。如下图所示:
完全二叉树:对二叉树按层依次进行编号,如果该二叉树的各结点的编号与同样深度的满二叉树完全相同,则称这棵二叉树为完全二叉树。如下面几个图所示:
二叉树的存储结构
二叉树的存储结构可以基于数组,也可以基于链表进行实现。
基于数组进行实现时,可以将数组索引映射到树的每个结点的层序编号。相当于直接把二叉树看成一个数组,但这样做的缺点就是对于二叉树中某些缺失的结点,还是会占据数组空间,浪费内存空间(极端情况下,比如左/右斜树,会造成大量空间浪费)。
基于链表进行实现二叉树时,按照树结点的特点,每个结点都包含一个数据和左子树,右子树,因此其结点的存储格式如下所示:
对应代码如下:
template
struct Node {
T data;
struct Node *pLeftChild, *pRightChild;
};
遍历二叉树
二叉树的遍历(traversing binary tree):是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。
树结点的遍历难度在于:不像线性表结构,每个元素都具备唯一的前驱/后继元素,因此其遍历是确切的。而对于树来说,树的结点之间不存在唯一的前驱和后继关系,访问一个结点后,下一个要访问的结点可能存在多种选择(比如,当前访问到二叉树的根结点,那下一步是要访问左子树还是右子树,此时,我们就面临着不同的选择)。
二叉树的遍历 通常可分为两种方式:
- 深度优先遍历(Depth First Search,简称 DFS):其过程简单来说,就是对每一个可能的分支路径遍历深入到不能深入为止,且每个结点只能被访问一次。DFS 根据根结点相对于左右子结点的访问先后顺序,又可细分为三种实现方式:前序遍历,中序遍历,后序遍历。
- 前序遍历(pre-order):若二叉树为空,则空操作返回。否则先访问根结点,然后再前序遍历访问左子树,最后再前序遍历访问右子树(总结:根结点->左子树->右子树)。如下图所示,遍历的顺序为:ABDGHCEIF。
- 中序遍历(in-order):若二叉树为空,则空操作返回。否则从根节点开始,中序遍历根结点的左子树,然后再访问根结点,最后中序遍历右子树(总结:左子树->根结点->右子树)。如下图所示,遍历的顺序为:GDHBAEICF。
- 后序遍历(post-order):若树为空,则空操作返回。否则先访问结点的左子树,然后再访问右子树,最后访问根结点(总结:左子树->右子树->根结点)。如下图所示,遍历的顺序为:GHDBIEFCA
- 广度优先遍历(Breadth First Search,简称 BFS):其遍历过程就是对每一层结点按顺序(比如从左往右)依次进行访问,访问完毕后进入下一层,重复上述过程。对于树来说,其广度优先遍历算法的实现方式为:层序遍历。
- 层序遍历(level-order):若树为空,则空操作返回。否则从树的第一层(即根结点)开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个进行访问。如下图所示,遍历的顺序为:ABCDEFGHI。
对于计算机来说,它只有循环,判断等方式处理数据,也就是说,计算机只会处理线性序列,而上述提到的四种树遍历方法,其实质就是:将树中的结点变成某种意义的线性序列,这为我们使用计算机来进行树遍历提供了可能。
下面给出上述四种树遍历方法的代码:
- 前序遍历:由于前序遍历是先访问根结点,然后依次访问左子树,最后访问右子树,右子树的访问顺序为最底层的结点先访问,这里对右子树的遍历存在一个结构上的逆反顺序(即先访问的是树结构的底下右子树,而上层右子树慢访问到),因此,如果想实现非递归的前序遍历,可以使用栈的形式,对按顺序访问到的根结点左子树依次取值后入栈,这样一轮操作后,当前结点的左子树就会被存储到栈中,且栈顶为当前结点的最底下的左子树。因此,此时就可以弹出栈顶元素,进而得到其右子树,对右子树重复上述一轮左子树入栈的操作,就可以循环得到所有的结点;如果此时右子树为空,则弹出栈顶元素,获取上一级结点,重复上述逻辑即可。
// 递归实现
template
void preOrderTraverse(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
std::cout << biTree->data << std::endl; // 获取根结点内容
preOrderTraverse(biTree->pLeftChild); // 前序遍历当前结点左子树
preOrderTraverse(biTree->pRightChild); // 前序遍历当前结点左子树
}
// 非递归实现
#include
template
void preOrderTraverseNonRecursive(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
std::stack*> trees;
BiTreeNode *node = biTree;
while (node || !trees.empty()) { // 如果结点有值,或者栈不为空
while (node != nullptr) {
std::cout << node->data << std::endl; // 获取当前结点值
trees.push(node); // 将当前根结点入栈
node = node->pLeftChild; // 遍历当前根结点左子树,并执行入栈操作
}
if (!trees.empty()) { // 左子树遍历完毕,且左子树非空
node = trees.top(); // 获取最底端的左子树
trees.pop(); // 获取完后,出栈舍弃当前左子树结点
node = node->pRightChild; // 转到最底右子树,对其进行一轮上述左子树遍历入栈操作
}
}
}
- 中序遍历:由于中序遍历是先访问左子树,再访问根结点,最后再访问右子树。因此,如果想实现非递归的中序遍历时,可以采用基于栈的底层结构。当访问一个结点时,先遍历其左子树,并依次入栈;这样一次循环后,栈顶元素就是当前结点的最底左子树,此时就可以取出栈顶元素,输出其值,由于结点可能存在右子树,因此,下一步就是获取其右子树,将其当成新根结点,重复上述过程,即可得到该右子树结点的全部左子树入栈;当某一个结点的右子树为空时,说明该结点遍历完毕,此时可以从栈顶获取其根结点,继续下一轮遍历。
// 递归实现
template void inOrderTraverse(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
inOrderTraverse(biTree->pLeftChild); // 先中序遍历左子树
std::cout << biTree->data << std::endl; // 获取当前根结点内容
inOrderTraverse(biTree->pRightChild); // 最后在中序遍历当前结点右子树
}
// 非递归实现
#include
template void inOrderTraverseNonRecursive(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
BiTreeNode *node = biTree;
std::stack*> trees;
while (node || !trees.empty()) { // 如果当前结点有值,或者栈不为空
while (node != nullptr) { // 如果当前根结点不为空,则遍历其左子树并压入栈中
trees.push(node);
node = node->pLeftChild;
}
if (!trees.empty()) { // 如果栈非空,说明有左子树
node = trees.top(); // 栈顶结点为最底层左子树
trees.pop();
std::cout << node->data << std::endl; // 输出最底层左子树内容
node = node->pRightChild; // 左子树可能存在右结点,对其右结点重复上述逻辑,得到其所有左子树入栈
}
}
}
- 后序遍历:后序遍历的顺序为:左子树->右子树->根结点。我们知道,树的遍历在结构上都是从根结点开始的,由于在后序遍历中,根结点处于最后遍历位置,因此其必须保证在访问根结点前,先要完成左右子树的遍历,这就对流程控制带来了复杂性。下面介绍两种方法实现二叉树的后序遍历。
- 增加标识位:由于右子树在根结点之前输出,而遍历顺序为根结点先于右子树。因此,如果采用栈的结构进行实现,获取到栈顶元素(根结点)后,还需遍历其右子树,导致该栈顶元素有可能被二次使用,造成死循环(即遍历该结点右子树完成后,右子树会回溯到其上一个结点,循环遍历右子树这个过程。假设当前结点右子树只有一个,那么遍历完后,就会再次回溯到当前根结点,则又会导致继续遍历当前结点的右子树····),因此,需要有一个标识表明当前结点之前是否已被访问。
// 递归实现
template void postOrderTraverse(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
postOrderTraverse(biTree->pLeftChild); // 先后序遍历当前结点左子树
postOrderTraverse(biTree->pRightChild); // 在后序遍历当前结点右子树
std::cout << biTree->data << std::endl; // 最后获取当前根结点内容
}
// 非递归实现方法1
template
struct BiTreeNode {
T data;
struct BiTreeNode *pLeftChild, *pRightChild;
bool hasVisited; // 添加一个标识
BiTreeNode(const T &data) :
data(data), pLeftChild(nullptr), pRightChild(nullptr),hasVisited(false) {}
};
template void postOrderTraverseNonRecursive(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
std::stack*> trees;
BiTreeNode *node = biTree;
while (node || !trees.empty()) {
while (node != nullptr) { // 找到当前结点的所有左子树,并入栈
node->hasVisited = false; // 标识:未被二次访问
trees.push(node);
node = node->pLeftChild;
}
if (!trees.empty()) {
node = trees.top(); // 获取当前结点最底左子树
if (node->hasVisited == false && node->pRightChild) { // 找到最底左子树的右子树
node->hasVisited = true; // 标识:设置当前最底左子树已被二次访问
node = node->pRightChild; // 遍历右子树,找到其所有左子树,并入栈
continue;
}
std::cout << node->data << std::endl; // 输出当前根结点的值
trees.pop();
node = nullptr; // 必须置空,防止重入
}
}
}
- 记录前一次访问的结点:对于二叉树的一个结点来说,它只具备四种状态:没有左右子树,只有左子树,只有右子树,左右子树都有。
当没有左右子树的时候,可以直接输出当前结点的值(此时需要记录当前访问的这个结点);
当只有左子树时,首先需要判断该左子树之前是否已被访问过,若被访问过,则不作处理;若未被访问过,则进行压栈操作;
当只有右子树时,首先需要判断该右子树之前是否已被访问过,若被访问过,则不作处理;若未被访问过,则进行压栈操作;
当同时存在左右子树时,需先判断左右子树是否都被访问过,是则忽略,否则入栈;如果两者均未被访问,则先将右子树入栈,再将左子树入栈,如此弹出顺序才符合后序遍历。
template void postOrderTraverseNonRecursive(BiTreeNode *biTree) {
std::stack*> trees;
BiTreeNode *cur = nullptr;
BiTreeNode *prev = nullptr;
trees.push(biTree); // 根结点入栈
while (!trees.empty()) {
cur = trees.top(); // 根结点出栈
if ((cur->pLeftChild == nullptr && cur->pRightChild == nullptr) // 如果根结点没有左右子树
|| ((prev != nullptr) && (prev == cur->pLeftChild || prev == cur->pRightChild))) { // 如果当前结点的左子树或右子树已被访问过
std::cout << cur->data << std::endl; // 输出当前根结点的值
trees.pop();
prev = cur; // 记录当前访问的结点
}
else {
if (cur->pRightChild != nullptr) { // 当前根结点存在右子树
trees.push(cur->pRightChild);
}
if (cur->pLeftChild != nullptr) { // 当前根结点存在左子树
trees.push(cur->pLeftChild);
}
}
}
}
- 层序遍历:由于层序遍历是先访问根结点,再从左往右依次访问根结点的左右子树,遵循先访问先使用规律,因此层序遍历采用基于队列的方式进行实现。第一次访问根结点时,将根结点入队,然后循环该队列,取出结点。在循环中,每次获取当前根结点后,输出当前结点内容,然后将其左右子树分别入队,最后弹出当前结点,直至队列已空,则层序遍历结束。
#include
template void levelOrderTraverse(BiTreeNode *biTree) {
if (biTree == nullptr) {
return;
}
std::queue*> trees; // 创建一个队列
trees.push(biTree); // 压入根结点
while (!trees.empty()) {
BiTreeNode *node = trees.front(); // 取出根结点
std::cout << node->data << std::endl; // 获取根结点数据
if (node->pLeftChild) {
trees.push(node->pLeftChild); // 左子树入队
}
if (node->pRightChild) {
trees.push(node->pRightChild); // 右子树入队
}
trees.pop(); // 弹出当前根结点
}
}
常用的树的结构
数据结构中有很多树的结构,比如我们上文提及的 二叉树,其余常见的还有 二叉查找树(二叉搜索树/二叉排序树),平衡二叉树(AVL树),红黑树,B树,B+树,B*树,字典树(tire树)···
二叉查找树:又称为二叉排序树(Binary Sort Tree)或 二叉搜索树。二叉查找树或者是一棵空树,或者是具备以下性质的二叉树:
若左子树不空,则 左子树上所有结点的值均小于它的根结点的值;
若右子树不空,则 右子树上所有结点的值均大于它的根结点的值;
左、右子树也分别为二叉排序树;
没有键值相等的节点。
二叉查找树 的一个重要特性就是:使用中序遍历就可得到一个排序序列。
二叉查找树的时间复杂度:二叉查找树采用中序遍历后,其实就是一个排序数组,因此其插入和查找的时间复杂度与二分查找一样,均为 ,但在极端情况下(比如左/右斜树),二叉查找树的时间复杂度可能为 ,通过使用平衡查找树可避免这个极端问题。
二叉查找树的高度决定了二叉查找树的查找效率。
平衡二叉树(Balanced Binary Tree):又称为 AVL树。平衡二叉树需满足以下特性:
平衡二叉树要么是一棵空树,要么其左右子树的高度之差的绝对值不超过 1;
其左右子树也都是平衡二叉树;
二叉树结点的平衡因子定义为该结点的左子树深度减去右子树深度,因此平衡二叉树的所有结点的平衡因子只可能是 -1,0,1;
红黑树:红黑树是一种 自平衡二叉查找树,其在平衡二叉树的基础上为每个结点都增加了一个颜色的属性,结点的颜色只能是红色或黑色,并且具备以下性质:
根结点只能是黑色;
结点是红色或黑色;
所有叶子都是黑色(叶子是NIL节点);
每个红色结点必须有两个黑色的子结点。(从每个叶子到根的所有路径上不能有两个连续的红色结点。);
从任一结点到其每个叶子的所有简单路径都包含相同数目的黑色结点,从而保证了其是一棵平衡二叉树;
B树(B-树,B-Tree,Balanced Tree):B树是一种多路自平衡查找树(不是二叉树),它在文件系统中很有用。一棵 m 阶B树,具有下列性质:
树中每个结点至多有 m 棵子树;
若根结点不是叶子结点,则至少有 2 棵子树;
除根结点之外的所有树枝结点都包含 k-1 个元素和 k 个孩子,其中 m/2 <= k <= m
每个叶子节点都包含 k-1 个元素,其中 m/2 <= k <= m
每个树枝结点中的信息结构为(A0,K1,A1,K2......Kn,An),其中 n 表示关键字个数,Ki 为关键字,Ai 为指针;
所有的叶子节点都出现在同一层次上,且不带任何信息,也是为了保持算法的一致性。
B树的搜索:从根结点开始,对结点内的关键字(有序)序列进行二分查找,如果命中则结束,否则进入查询关键字所属范围的儿子结点;重复,直到所对应的儿子指针为空,或已经是叶子结点;
B+树:是B-树的变体,也是一种多路搜索树。其定义基本与B-树同,除了:
非叶子结点的子树指针与关键字个数相同;
非叶子结点的子树指针P[i],指向关键字值属于[K[i], K[i+1])的子树(B-树是开区间);
为所有叶子结点增加一个链指针;
所有关键字都在叶子结点出现;
B+的搜索与B-树也基本相同,区别是B+树只有达到叶子结点才命中(B-树可以在非叶子结点命中),其性能也等价于在关键字全集做一次二分查找;
B*树:是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针,将结点的最低利用率从1/2提高到2/3。
字典树(tire树):又称单词查找树,是一种以树形结构保存大量字符串。以便于字符串的统计和查找,经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来节约存储空间,最大限度地减少无谓的字符串比较,查询效率比哈希表高。具有以下特点:
- 根节点为空;
- 除根节点外,每个节点包含一个字符;
- 从根节点到某一节点,路径上经过的字符连接起来,为该节点对应的字符串。
- 每个字符串在建立字典树的过程中都要加上一个区分的结束符,避免某个短字符串正好是某个长字符串的前缀而淹没。
参考
《大话数据结构》
【经典面试题二】二叉树的递归与非递归遍历(前序、中序、后序)
[Data Structure] 数据结构中各种树
数据结构中的各种树浅谈
B树、B-树、B+树、B*树之间的关系