有许多逻辑关系并不是简单的线性关系,在实际场景中,常常存在着一对多,甚至是多对多的情况。其中树和图就是典型的非线性数据结构,我们首先讲一讲树的知识。
什么是树呢?
在现实生活中有很多体现树的逻辑的例子。例如企业里的职级关系,就是一个“树”。
除人与人之间的关系之外,许多抽象的东西也可以成为一个“树”,如一本书的目录。
在数据结构中,树的定义如下。
树(tree)是n(n≥0)个节点的有限集。当n=0时,称为空树。在任意一个非空树中,有如下特点。
- 有且仅有一个特定的称为根的节点。
- 当n>1时,其余节点可分为m(m>0)个互不相交的有限集,每一个集合本身又是一个树,并称为根的子树。
下面这张图,就是一个标准的树结构。
在上图中,节点1是根节点(root);节点5、6、7、8是树的末端,没有“孩子”,被称为叶子节点(leaf)。图中的虚线部分,是根节点1的其中一个子树。同时,树的结构从根节点到叶子节点,分为不同的层级。从一个节点的角度来看,它的上下级和同级节点关系如下。
在上图中,节点4的上一级节点,是节点4的父节点(parent);从节点4衍生出来的节点,是节点4的孩子节点(child);和节点4同级,由同一个父节点衍生出来的节点,是节点4的兄弟节点(sibling)。树的最大层级数,被称为树的高度或深度。显然,上图这个树的高度是4。
什么是二叉树:
二叉树(binary tree)是树的一种特殊形式。二叉,顾名思义,这种树的每个节点最多有2个孩子节点。注意,这里是最多有2个,也可能只有1个,或者没有孩子节点。二叉树的结构如图所示。
二叉树节点的两个孩子节点,一个被称为左孩子(left child),一个被称为右孩子(right child)。这两个孩子节点的顺序是固定的,就像人的左手就是左手,右手就是右手,不能够颠倒或混淆。
此外,二叉树还有两种特殊形式,一个叫作满二叉树,另一个叫作完全二叉树。
什么是满二叉树呢?
一个二叉树的所有非叶子节点都存在左右孩子,并且所有叶子节点都在同一层级上,那么这个树就是满二叉树。
简单点说,满二叉树的每一个分支都是满的。
什么又是完全二叉树呢?完全二叉树的定义很有意思。
对一个有n个节点的二叉树,按层级顺序编号,则所有节点的编号为从1到n。如果这个树所有节点和同样深度的满二叉树的编号为从1到n的节点位置相同,则这个二叉树为完全二叉树。看看下图就很容易理解了。
在上图中,二叉树编号从1到12的12个节点,和前面满二叉树编号从1到12的节点位置完全对应。因此这个树是完全二叉树。完全二叉树的条件没有满二叉树那么苛刻:满二叉树要求所有分支都是满的;而完全二叉树只需保证最后一个节点之前的节点都齐全即可
数据结构可以划分为物理结构和逻辑结构。二叉树属于逻辑结构,它可以通过多种物理结构来表达。
二叉树可以用哪些物理存储结构来表达呢?
- 链式存储结构。
- 数组。
首先来看一看链式存储结构。
链式存储是二叉树最直观的存储方式。链表是一对一的存储方式,每一个链表节点拥有data变量和一个指向下一节点的next指针。而二叉树稍微复杂一些,一个节点最多可以指向左右两个孩子节点,所以二叉树的每一个节点包含3部分。
- 存储数据的data变量
- 指向左孩子的left指针
- 指向右孩子的right指针
再来看看用数组是如何存储的。
使用数组存储时,会按照层级顺序把二叉树的节点放到数组中对应的位置上。如果某一个节点的左孩子或右孩子空缺,则数组的相应位置也空出来。
为什么这样设计呢?因为这样可以更方便地在数组中定位二叉树的孩子节点和父节点。
假设一个父节点的下标是parent,那么它的左孩子节点下标就是2×parent +1;右孩子节点下标就是2×parent + 2。
反过来,假设一个左孩子节点的下标是leftChild,那么它的父节点下标就是(leftChild-1)/ 2。
假如节点4在数组中的下标是3,节点4是节点2的左孩子,节点2的下标可以直接通过计算得出。节点2的下标 = (3-1)/2 = 1
显然,对于一个稀疏的二叉树来说,用数组表示法是非常浪费空间的。
什么样的二叉树最适合用数组表示呢?下文中将会介绍二叉堆,一种特殊的完全二叉树,就是用数组来存储的。
二叉树的应用:
二叉树包含许多特殊的形式,每一种形式都有自己的作用,但是其最主要的应用还在于进行查找操作和维持相对顺序这两个方面。
二叉树的树形结构使它很适合扮演索引的角色。这里我们介绍一种特殊的二叉树:二叉查找树(binary search tree)。光看名字就可以知道,这种二叉树的主要作用就是进行查找操作。二叉查找树在二叉树的基础上增加了以下几个条件。
- 如果左子树不为空,则左子树上所有节点的值均小于根节点的值
- 如果右子树不为空,则右子树上所有节点的值均大于根节点的值
- 左、右子树也都是二叉查找树
下图就是一个标准的二叉查找树。
二叉查找树的这些条件有什么用呢?当然是为了查找方便。怎么查找?相信有经验的开发人员这里是不需要解释的。
对于一个节点分布相对均衡的二叉查找树来说,如果节点总数是n,那么搜索节点的时间复杂度就是O(logn),和树的深度是一样的。这种依靠比较大小来逐步查找的方式,和二分查找算法非常相似。
这一点仍然要从二叉查找树说起。二叉查找树要求左子树小于父节点,右子树大于父节点,正是这样保证了二叉树的有序性。因此二叉查找树还有另一个名字——二叉排序树(binary sort tree)。新插入的节点,同样要遵循二叉排序树的原则。例如插入新元素5,由于5<6,5>3,5>4,所以5最终会插入到节点4的右孩子位置。
在某种程度下,二叉查找树慢慢的插入元素,导致成了下面这种结构:
不只是外观看起来变得怪异了,查询节点的时间复杂度也退化成了O(n)。
怎么解决这个问题呢?这就涉及二叉树的自平衡了。二叉树自平衡的方式有多种,如红黑树、AVL树、树堆等。
除二叉查找树以外,二叉堆也维持着相对的顺序。不过二叉堆的条件要宽松一些,只要求父节点比它的左右孩子都大。
二叉树的遍历:
当我们介绍数组、链表时,为什么没有着重研究他们的遍历过程呢?在计算机程序中,遍历本身是一个线性操作。所以遍历同样具有线性结构的数组或链表,是一件轻而易举的事情。
二叉树的遍历又有什么特殊之处?反观二叉树,是典型的非线性数据结构,遍历时需要把非线性关联的节点转化成一个线性的序列,以不同的方式来遍历,遍历出的序列顺序也不同。
那么,二叉树都有哪些遍历方式呢?从节点之间位置关系的角度来看,二叉树的遍历分为4种。
- 前序遍历。
- 中序遍历。
- 后序遍历。
- 层序遍历。
从更宏观的角度来看,二叉树的遍历归结为两大类。
- 深度优先遍历(前序遍历、中序遍历、后序遍历)。
- 广度优先遍历(层序遍历)。
深度优先遍历:
深度优先和广度优先这两个概念不止局限于二叉树,它们更是一种抽象的算法思想,决定了访问某些复杂数据结构的顺序。在访问树、图,或其他一些复杂数据结构时,这两个概念常常被使用到。
所谓深度优先,顾名思义,就是偏向于纵深,“一头扎到底”的访问方式。可
能这种说法有些抽象,下面就通过二叉树的前序遍历、中序遍历、后序遍历,来看一看深度优先是怎么回事吧。针对下面这个二叉树的遍历结果是怎么样的?
- 前序遍历:二叉树的前序遍历,输出顺序是根节点、左子树、右子树。结果为 1 2 4 5 3 6
- 中序遍历:二叉树的中序遍历,输出顺序是左子树、根节点、右子树。结果为 4 2 5 1 3 6
- 后序遍历:二叉树的后序遍历,输出顺序是左子树、右子树、根节点。结果为 4 5 2 6 3 1
对于二叉树相关遍历的代码如下:
public class TreeNode {
int data;
TreeNode leftChild;
TreeNode rightChild;
TreeNode(int data) {
this.data = data;
}
/**
* 构建二叉树
*
* @param inputList 输入序列
*/
public static TreeNode createBinaryTree(LinkedList<Integer> inputList) {
TreeNode node = null;
if (inputList == null || inputList.isEmpty()) {
return null;
}
Integer data = inputList.removeFirst();
if (data != null) {
node = new TreeNode(data);
node.leftChild = createBinaryTree(inputList);
node.rightChild = createBinaryTree(inputList);
}
return node;
}
/**
* 二叉树前序遍历
*
* @param node 二叉树节点
*/
public static void preOrderTraveral(TreeNode node) {
if (node == null) {
return;
}
System.out.println(node.data);
preOrderTraveral(node.leftChild);
preOrderTraveral(node.rightChild);
}
/**
* 二叉树中序遍历
*
* @param node 二叉树节点
*/
public static void inOrderTraveral(TreeNode node) {
if (node == null) {
return;
}
inOrderTraveral(node.leftChild);
System.out.println(node.data);
inOrderTraveral(node.rightChild);
}
/**
* 二叉树后序遍历
*
* @param node 二叉树节点
*/
public static void postOrderTraveral(TreeNode node) {
if (node == null) {
return;
}
postOrderTraveral(node.leftChild);
postOrderTraveral(node.rightChild);
System.out.println(node.data);
}
public static void main(String[] args) {
LinkedList<Integer> inputList = new LinkedList<Integer>(
Arrays.asList(new Integer[]{3, 2, 9, null, null, 10, null, null, 8, null, 4}));
TreeNode treeNode = createBinaryTree(inputList);
System.out.println(" 前序遍历:");
preOrderTraveral(treeNode);
System.out.println(" 中序遍历:");
inOrderTraveral(treeNode);
System.out.println(" 后序遍历:");
postOrderTraveral(treeNode);
}
}
二叉树用递归方式来实现前序、中序、后序遍历,是最为自然的方式,因此代码也非常简单。这3种遍历方式的区别,仅仅是输出的执行位置不同:前序遍历的输出在前,中序遍历的输出在中间,后序遍历的输出在最后。代码中值得注意的一点是二叉树的构建。二叉树的构建方法有很多,这里把一个线性的链表转化成非线性的二叉树,链表节点的顺序恰恰是二叉树前序遍历的顺序。链表中的空值,代表二叉树节点的左孩子或右孩子为空的情况。在代码的main函数中,通过{3,2,9,null,null,10,null,null,8,null,4}这样一个线性序列,构建成的二叉树如下。
绝大多数可以用递归解决的问题,其实都可以用另一种数据结构来解决,这种数据结构就是栈。因为递归和栈都有回溯的特性。
二叉树非递归前序遍历的代码如下:
/**
* 二叉树非递归前序遍历
*
* @param root 二叉树根节点
*/
public static void preOrderTraveralWithStack(TreeNode root) {
Stack<TreeNode> stack = new Stack<TreeNode>();
TreeNode treeNode = root;
while (treeNode != null || !stack.isEmpty()) {
//迭代访问节点的左孩子,并入栈
while (treeNode != null) {
System.out.println(treeNode.data);
stack.push(treeNode);
treeNode = treeNode.leftChild;
}
//如果节点没有左孩子,则弹出栈顶节点,访问节点右孩子
if (!stack.isEmpty()) {
treeNode = stack.pop();
treeNode = treeNode.rightChild;
}
}
}
至于二叉树的中序、后序遍历的非递归实现,思路和前序遍历差不太多,都是利用栈来进行回溯。
广度优先遍历:
如果说深度优先遍历是在一个方向上“一头扎到底”,那么广度优先遍历则恰恰相反:先在各个方向上各走出1步,再在各个方向上走出第2步、第3步……一直到各个方向全部走完。听起来有些抽象,下面让我们通过二叉树的层序遍历,来看一看广度优先是怎么回事。层序遍历,顾名思义,就是二叉树按照从根节点到叶子节点的层次关系,一层一层横向遍历各个节点。
上图就是一个二叉树的层序遍历,每个节点左侧的序号代表该节点的输出顺序。
可是,二叉树同一层次的节点之间是没有直接关联的,如何实现这种层序遍历呢?这里同样需要借助一个数据结构来辅助工作,这个数据结构就是队列。详细遍历步骤如下。
- 根节点1进入队列。
- 节点1出队,输出节点1,并得到节点1的左孩子节点2、右孩子节点3。让节点2和节点3入队。
- 节点2出队,输出节点2,并得到节点2的左孩子节点4、右孩子节点5。让节点4和节点5入队。
- 节点3出队,输出节点3,并得到节点3的右孩子节点6。让节点6入队。
- 节点4出队,输出节点4,由于节点4没有孩子节点,所以没有新节点入队。
- 节点5出队,输出节点5,由于节点5同样没有孩子节点,所以没有新节点入队。
- 节点6出队,输出节点6,节点6没有孩子节点,没有新节点入队。到此为止,所有的节点都遍历输出完毕。
代码如下:
/**
* 二叉树层序遍历
*
* @param root 二叉树根节点
*/
public static void levelOrderTraversal(TreeNode root) {
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while (!queue.isEmpty()) {
TreeNode node = queue.poll();
System.out.println(node.data);
if (node.leftChild != null) {
queue.offer(node.leftChild);
}
if (node.rightChild != null) {
queue.offer(node.rightChild);
}
}
}
递归实现:
/**
* 二叉树层序遍历递归实现
*
* @param root 二叉树根节点
*/
public static void levelOrderTraversalDigui(TreeNode root, int level) {
if (root == null) {
return;
}
if (level == 0) {
System.out.println(root.data);
}
if (root.leftChild !=null) {
System.out.println(root.leftChild.data);
}
if (root.rightChild !=null) {
System.out.println(root.rightChild.data);
}
levelOrderTraversalDigui(root.leftChild,level+1);
levelOrderTraversalDigui(root.rightChild,level+1);
}
什么是二叉堆:
二叉堆本质上是一种完全二叉树,它分为两个类型。
- 最大堆。
- 最小堆。
什么是最大堆呢?最大堆的任何一个父节点的值,都大于或等于它左、右孩子节点的值。
什么是最小堆呢?最小堆的任何一个父节点的值,都小于或等于它左、右孩子节点的值。
二叉堆的根节点叫作堆顶。最大堆和最小堆的特点决定了:最大堆的堆顶是整个堆中的最大元素;最小堆的堆顶是整个堆中的最小元素。
二叉堆的自我调整:
我们如何构建一个堆呢?这就需要依靠二叉堆的自我调整了。对于二叉堆,有如下几种操作。
- 插入节点。
- 删除节点。
- 构建二叉堆。
这几种操作都基于堆的自我调整。所谓堆的自我调整,就是把一个不符合堆性质的完全二叉树,调整成一个堆。下面让我们以最小堆为例,看一看二叉堆是如何进行自我调整的。
插入节点:
1.插入节点当二叉堆插入节点时,插入位置是完全二叉树的最后一个位置。例如插入一个新节点,值是 0。
2.这时,新节点的父节点5比0大,显然不符合最小堆的性质。于是让新节点“上浮”,和父节点交换位置。
3.继续用节点0和父节点3做比较,因为0小于3,则让新节点继续“上浮”。
继续比较,最终新节点0“上浮”到了堆顶位置。
删除节点:
二叉堆删除节点的过程和插入节点的过程正好相反,所删除的是处于堆顶的节点。例如删除最小堆的堆顶节点1。
这时,为了继续维持完全二叉树的结构,我们把堆的最后一个节点10临时补到原本堆顶的位置。
接下来,让暂处堆顶位置的节点10和它的左、右孩子进行比较,如果左、右孩子节点中最小的一个(显然是节点2)比节点10小,那么让节点10“下沉”。
继续让节点10和它的左、右孩子做比较,左、右孩子中最小的是节点7,由于10大于7,让节点10继续“下沉”。
这样一来,二叉堆重新得到了调整。
构建二叉堆:
构建二叉堆,也就是把一个无序的完全二叉树调整为二叉堆,本质就是让所有非叶子节点依次“下沉”。下面举一个无序完全二叉树的例子,如下图所示。
- 首先,从最后一个非叶子节点开始,也就是从节点10开始。如果节点10大于它左、右孩子节点中最小的一个,则节点10“下沉”。
- 接下来轮到节点3,如果节点3大于它左、右孩子节点中最小的一个,则节点3“下沉”。
- 然后轮到节点1,如果节点1大于它左、右孩子节点中最小的一个,则节点1“下沉”。事实上节点1小于它的左、右孩子,所以不用改变。
- 接下来轮到节点7,如果节点7大于它左、右孩子节点中最小的一个,则节点7“下沉”。
- 节点7继续比较,继续“下沉”。
- 经过上述几轮比较和“下沉”操作,最终每一节点都小于它的左、右孩子节点,一个无序的完全二叉树就被构建成了一个最小堆。
关于堆的插入和删除操作,时间复杂度是O(logn)。构建堆的时间复杂度是O(n)。
二叉堆的代码实现:
需要明确一点:二叉堆虽然是一个完全二叉树,但它的存储方式并不是链式存储,而是顺序存储。换句话说,二叉堆的所有节点都存储在数组中。
在数组中,在没有左、右指针的情况下,如何定位一个父节点的左孩子和右孩子呢?
像上图那样,可以依靠数组下标来计算。假设父节点的下标是parent,那么它的左孩子下标就是 2×parent+1;右孩子下标就是2×parent+2。
例如上面的例子中,节点6包含9和10两个孩子节点,节点6在数组中的下标是3,节点9在数组中的下标是7,节点10在数组中的下标是8。那么,7 = 3×2+1,8 = 3×2+2,刚好符合规律。
(array.length-2)/2 就能计算出最后一个非叶子节点
public class BinaryHeap {
/**
* “上浮”调整
*
* @param array 待调整的堆
*/
public static void upAdjust(int[] array) {
int childIndex = array.length - 1;
int parentIndex = (childIndex - 1) / 2;
// temp 保存插入的叶子节点值,用于最后的赋值
int temp = array[childIndex];
while (childIndex > 0 && temp < array[parentIndex]) {
//无须真正交换,单向赋值即可
array[childIndex] = array[parentIndex];
childIndex = parentIndex;
parentIndex = (parentIndex - 1) / 2;
}
array[childIndex] = temp;
}
/**
* “下沉”调整
*
* @param array 待调整的堆
* @param parentIndex 要“下沉”的父节点
* @param length 堆的有效大小
*/
public static void downAdjust(int[] array, int parentIndex,
int length) {
// temp 保存父节点值,用于最后的赋值
int temp = array[parentIndex];
int childIndex = 2 * parentIndex + 1;
while (childIndex < length) {
// 如果有右孩子,且右孩子小于左孩子的值,则定位到右孩子
if (childIndex + 1 < length && array[childIndex + 1] <
array[childIndex]) {
childIndex++;
}
// 如果父节点小于任何一个孩子的值,则直接跳出
if (temp <= array[childIndex])
break;
//无须真正交换,单向赋值即可
array[parentIndex] = array[childIndex];
parentIndex = childIndex;
childIndex = 2 * childIndex + 1;
}
array[parentIndex] = temp;
}
/**
* 构建堆
*
* @param array 待调整的堆
*/
public static void buildHeap(int[] array) {
// 从最后一个非叶子节点开始,依次做“下沉”调整
for (int i = (array.length - 2) / 2; i >= 0; i--) {
downAdjust(array, i, array.length);
}
}
public static void main(String[] args) {
int[] array = new int[]{1, 3, 2, 6, 5, 7, 8, 9, 10, 0};
upAdjust(array);
System.out.println(Arrays.toString(array));
array = new int[]{7, 1, 3, 10, 5, 2, 8, 9, 6};
buildHeap(array);
System.out.println(Arrays.toString(array));
}
}
代码中有一个优化的点,就是在父节点和孩子节点做连续交换时,并不一定要真的交换,只需要先把交换一方的值存入temp变量,做单向覆盖,循环结束后,再把temp的值存入交换后的最终位置即可。
什么是优先队列:
队列的特点是先进先出(FIFO)。入队列,将新元素置于队尾。出队列,队头元素最先被移出。
那么,优先队列又是什么样子呢?优先队列不再遵循先入先出的原则,而是分为两种情况。
- 最大优先队列,无论入队顺序如何,都是当前最大的元素优先出队
- 最小优先队列,无论入队顺序如何,都是当前最小的元素优先出队
例如有一个最大优先队列,其中的最大元素是8,那么虽然8并不是队头元素,但出队时仍然让元素8首先出队。
因此,可以用最大堆来实现最大优先队列,这样的话,每一次入队操作就是堆的插入操作,每一次出队操作就是删除堆顶节点。
二叉堆节点“上浮”和“下沉”的时间复杂度都是O(log2(n)),所以优先队列入队和出队的时间复杂度也是O(log2(n))
public class PriorityQueue {
private int[] array;
private int size;
public PriorityQueue() {
//队列初始长度为32
array = new int[32];
}
/**
* 入队
*
* @param key 入队元素
*/
public void enQueue(int key) {
//队列长度超出范围,扩容
if (size >= array.length) {
resize();
}
array[size++] = key;
upAdjust();
}
/**
* 出队
*/
public int deQueue() throws Exception {
if (size <= 0) {
throw new Exception("the queue is empty !");
}
//获取堆顶元素
int head = array[0];
//让最后一个元素移动到堆顶
array[0] = array[--size];
downAdjust();
return head;
}
/**
* “上浮”调整
*/
private void upAdjust() {
int childIndex = size - 1;
int parentIndex = (childIndex - 1) / 2;
// temp 保存插入的叶子节点值,用于最后的赋值
int temp = array[childIndex];
while (childIndex > 0 && temp > array[parentIndex]) {
//无须真正交换,单向赋值即可
array[childIndex] = array[parentIndex];
childIndex = parentIndex;
parentIndex = parentIndex / 2;
}
array[childIndex] = temp;
}
/**
* “下沉”调整
*/
private void downAdjust() {
// temp 保存父节点的值,用于最后的赋值
int parentIndex = 0;
int temp = array[parentIndex];
int childIndex = 1;
while (childIndex < size) {
// 如果有右孩子,且右孩子大于左孩子的值,则定位到右孩子
if (childIndex + 1 < size && array[childIndex + 1] >
array[childIndex]) {
childIndex++;
}
// 如果父节点大于任何一个孩子的值,直接跳出
if (temp >= array[childIndex])
break;
//无须真正交换,单向赋值即可
array[parentIndex] = array[childIndex];
parentIndex = childIndex;
childIndex = 2 * childIndex + 1;
}
array[parentIndex] = temp;
}
/**
* 队列扩容
*/
private void resize() {
//队列容量翻倍
int newSize = this.size * 2;
this.array = Arrays.copyOf(this.array, newSize);
}
public static void main(String[] args) throws Exception {
PriorityQueue priorityQueue = new PriorityQueue();
priorityQueue.enQueue(3);
priorityQueue.enQueue(5);
priorityQueue.enQueue(10);
priorityQueue.enQueue(2);
priorityQueue.enQueue(7);
System.out.println(" 出队元素:" + priorityQueue.deQueue());
System.out.println(" 出队元素:" + priorityQueue.deQueue());
}
}