树
之前我们一直在谈的是一对一的线性结构,可现实中,还有很多一对多的情况需要处理,这种一对多的数据结构就是 —— 树。
树
(Tree)是 n(n≥0)个结点的有限集。n=0 时称为空树。在任意一棵非空树中:(1) 有且仅有一个特定的称为根(Root)的结点;(2) 当 n>1 时,其余结点可分为 m(m>0)个互不相交的有限集 T1、T2、……、Tm,其中每一个集合本身又是一棵树,并且称为根的子树(SubTree)。
关于树的定义,需要注意的是:
- n>0 时根结点是唯一的,不可能存在多个根结点。
- m>0 时,子树的个数没有限制,但它们一定是互不相交的。
结点分类
树的结点包含一个数据元素及若干指向其子树的分支。 结点拥有的子树称为结点的度(Degree)。度为 0 的结点称为叶结点(Leaf)或终端结点;度不为 0 的结点称为非终端结点或分支结点。除根结点之外,分支结点也称为内部结点。树的度是树内各结点的度的最大值。
结点间关系
- 结点的子树的根称为该结点的孩子(Child),相应地,该结点称为孩子的双亲(Parent)。
- 同一个双亲的孩子之间互称兄弟(Sibling)。
- 结点的祖先是从根到该结点所经分支上的所有结点。
- 以某结点为根的子树中的任一结点都称为该结点的子孙。
树的其他相关概念
- 结点的层次(Level)从根开始定义起,根为第一层,根的孩子为第二层。
- 树中结点的最大层次称为树的深度(Depth)或高度。
- 如果将树中结点的各子树看成从左至右是有次序的,不能互换的,则称该树为有序树,否则称为无序树。
- 森林(Forest)是 m(m≥0)棵互不相交的树的集合。
对比线性表与树的结构
树的存储结构
🌲 双亲表示法
除了根结点外,其余每个结点,它不一定有孩子,但是一定有且仅有一个双亲。
我们可以以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点到链表中的位置。结点结构如下图所示:
其中:
data
是数据域,存储结点的数据信息。parent
是指针域,存储该结点的双亲在数组中的下标。
由于根结点是没有双亲的,所以我们约定根结点的位置域设置为 -1
。
🌰:树结构和对应树双亲表示如下图所示:
这样的存储结构,我们可以根据结点的 parent
指针很容易找到它的双亲结点,所用的时间复杂度为 O(1),直到 parent
为 -1
时,表示找到了树结点的根。
可如果要知道结点的孩子是什么,需要遍历整个结构才行。我们可以改进一下:
增加一个结点最左边孩子的域,叫它长子域,这样就很容易得到结点的孩子。如果没有孩子的结点,这个长子域就设置为 -1
。
我们也可以增加一个右兄弟域(
rightsib
)来体现兄弟关系。同样的,如果右兄弟不存在,则赋值为-1
。
🌲 孩子表示法
由于树中每个结点可能有多棵子树,可以考虑用多重链表,即 每个结点有多个指针域,其中每个指针指向一棵子树的根结点,这种方法叫做多重链表表示法。
🍃 方案一:
指针域的个数就等于树的度。结构如下图所示:
其中:
data
是数据域。child1
到childd
是指针域,用来指向该结点的孩子结点。
(对于上面的树来说,树的度是 3 ,所以指针域的个数是 3)
这种方法当树中各结点的度相差很大时,显示是浪费空间的,因为有很多结点的指针域都是空的。但如果树的各结点度相差很小时,空间就会被充分利用。
🍃 方案二:
每个结点指针域的个数等于该结点的度,专门与一个位置来存储结点指针域的个数,其结构如下图所示:
其中:
data
为数据域。degree
为度域,用来存储该结点的孩子结点的个数。child1
到childd
为指针域,指向该结点的各个孩子的结点。
这种方法对空间利用率很高,但由于各个结点的链表是不相同的结构,加上要维护结点的度的数值,在运算上会带来时间的损耗。
孩子表示法
,把每个结点的孩子结点排列起来,以单链表作存储结构,则 n 个结点有 n 个孩子链表,如果是叶子结点,则此单链表为空。然后 n 个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组中。
其中:
data
是数据域,存储某结点的数据信息。firstchild
是头指针域,存储该结点的孩子链表的头指针。child
是数据域,用来存储某个结点在表头数组中的下标。next
是指针域,用来存储指向某结点的下一个孩子结点的指针。
但是这样不易知道某个结点的双亲,可以把双亲表示法和孩子表示法综合一下:
称为双亲孩子表示法。
🌲 孩子兄弟表示法
任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。因此,我们设置两个指针,分别指向该结点的第一个孩子和此结点的右兄弟。结点结构如下图:
其中:
data
是数据域。firstchild
为指针域,存储该结点的第一个孩子结点的存储地址。rightsib
是指针域,存储该结点的右兄弟结点的存储地址。
实现的示意图:
这种表示法,如果想查找某个结点的某个孩子非常方便,只需要通过 firstchild
找到此结点的长子,然后再通过长子结点的 rightsib
找到它的二弟,接着一直找下去。
但如果想找某个结点但双亲,再加一个 parent
指针域来解决即可。
我们可以把上面的图变形成下面的样子,这样它把一棵复杂的树变成了一棵二叉树。
二叉树和二叉查找树
二叉树
(Binary Tree)是 n(n≥0)个结点的有限集合,该集合或者为空集(称为空二叉树),或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。
二叉树特点
特点:
- 每个结点最多有两棵子树(没有子树或有一棵子树都可)。
- 左子树和右子树是有顺序的,次序不能任意颠倒。
- 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树。
二叉树具有五种基本形态:
- 空二叉树
- 只有一个根结点
- 根结点只有左子树
- 根结点只有右子树
- 根结点既有左子树又有右子树
实现二叉查找树
BST
(Binary Search Tree)目的是为了提高查找的性能,其查找在平均和最坏的情况下都是 log n 级别,接近二分查找。
其 特点 是:每个节点的值大于其任意左侧子节点的值,小于其任意右节点的值。
// Node 对象既保存数据,也保存和其他节点的链接(left 和 right)
function Node(data, left, right) {
this.data = data;
this.left = left;
this.right = right;
this.show = show;
}
// show() 方法用来显示 保存在节点中的数据
function show() {
return this.data;
}
// 创建一个类,用来表示二叉查找树(BST)
function BST() {
this.root = null;
this.insert = insert;
this.inOrder = inOrder;
}
// insert() 方法,用来向树中加入新节点
function insert(data) {
var n = new Node(data, null, null);
if (this.root == null) {
this.root = n;
} else {
var current = this.root;
var parent;
while (true) {
parent = current;
if (data < current.data) {
current = current.left;
if (current == null) {
parent.left = n;
break;
}
} else {
current = current.right;
if (current == null) {
parent.right = n;
break;
}
}
}
}
}
查找正确插入点的算法:
(1) 设根节点为当前节点。
(2) 如果待插入节点保存的数据小于当前节点,则设新的当前节点为原节点的左节点;反之,执行第 4 步。
(3) 如果当前节点的左节点为 null,就将新的节点插入这个位置,退出循环;反之,继续执行下一次循环。
(4) 设新的当前节点为原节点的右节点。
(5) 如果当前节点的右节点为 null,就将新的节点插入这个位置,退出循环;反之,继续执行下一次循环。
遍历二叉查找树
二叉树的遍历
(traversing binary tree)是指从根结点出发,按照某种次序依次访问二叉树中所有结点,使得每个结点被访问一次且仅被访问一次。
有三种遍历 BST 的方式:中序、先序和后序。
- 中序遍历按照节点上的键值,以升序访问 BST 上的所有节点。
- 先序遍历先访问根节点,然后以同样方式访问左子树和右子树。
- 后序遍历先访问叶子节点,从左子树到右子树,再到根节点。
🌰 中序遍历
function inOrder(node) {
if (!(node == null)) {
inOrder(node.left);
console.log(node.show() + " ");
inOrder(node.right);
}
}
var nums = new BST();
nums.insert(23);
nums.insert(45);
nums.insert(16);
nums.insert(37);
nums.insert(3);
nums.insert(99);
nums.insert(22);
console.log("Inorder traversal: ");
inOrder(nums.root);
输出结果:
Inorder traversal:
3 16 22 23 37 45 99
下图展示了中序遍历 inOrder()
方法的访问路径:
在二叉树上进行查找
对 BST 通常有下列三种类型的查找:
(1) 查找给定值; (2) 查找最小值; (3) 查找最大值。
🌰 查找最小值
因为较小的值总是在左子节点上,在 BST 上查
找最小值,只需要遍历左子树,直到找到最后一个节点。
getMin()
方法查找 BST 上的最小值,该方法沿着 BST 的左子树挨个遍历,直到遍历到 BST 最左边的节点,该节点被定义为:
current.left = null;
这时,当前节点上保存的值就是最小值。
function getMin() {
var current = this.root;
while (!(current.left == null)) {
current = current.left;
}
return current.data;
}
var nums = new BST();
nums.insert(23);
nums.insert(45);
nums.insert(16);
nums.insert(37);
nums.insert(3);
nums.insert(99);
nums.insert(22);
var min = nums.getMin();
console.log("The minimum value of the BST is: " + min);
输出结果:
The minimum value of the BST is: 3
从二叉查找树上删除节点
- 从 BST 中删除节点的第一步是判断当前节点是否包含待删除的数据:
- 如果包含,则删除该节点;
- 如果不包含,则比较当前节点上的数据和待删除的数据:
- 如果待删除数据小于当前节点上的数据,则移至当前节点的左子节点继续比较;
- 如果删除数据大于当前节点上的数据,则移至当前节点的右子节点继续比较。
- 如果待删除节点是叶子节点(没有子节点的节点),那么只需要将从父节点指向它的链接指向
null
。 - 如果待删除节点只包含一个子节点,那么原本指向它的节点就得做些调整,使其指向它的子节点。
- 最后,如果待删除节点包含两个子节点,有两种方式:
- 要么查找待删除节点左子树上的最大值,
- 要么查找其右子树上的最小值。
下面的方法中我们选择后一种方式。
我们需要一个查找子树上最小值的方法,会用它找到的最小值创建一个临时节点。将临时节点上的值复制到待删除节点,然后再删除临时节点。
// remove() 方法只是简单地接受待删除数据
function remove(data) {
root = removeNode(this.root, data);
}
// 调用 removeNode() 才删除它
function removeNode(node, data) {
if (node == null) {
return null;
}
if (data == node.data) {
// 没有子节点的节点
if (node.left == null && node.right == null) {
return null;
}
// 没有左子节点的节点
if (node.left == null) {
return node.right;
}
// 没有右子节点的节点
if (node.right == null) {
return node.left;
}
// 有两个子节点的节点
var tempNode = getSmallest(node.right);
node.data = tempNode.data;
node.right = removeNode(node.right, tempNode.data);
return node;
} else if (data < node.data) {
node.left = removeNode(node.left, data);
return node;
} else {
node.right = removeNode(node.right, data);
return node;
}
}
🔗: