数据结构与算法 -- 树
树是一种非线性表结构。直观看下树的结构
我们看下树的几个重要的概念:高度、深度、层,它们是这样定义的
树的结构多种多样,但我们最常用的还是二叉树。二叉树,顾名思义,就是每个节点最多有两个"叉",也就是两个子节点,分别是左子节点和右子节点。
这个图里,有两个比较特殊的二叉树,分别是编号 2 和编号 3 。
编号2这种 叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点 的二叉树叫 满二叉树;
编号3这种 叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层, 其它层的节点个数都要达到最大 的二叉树叫 完全二叉树;
满二叉树很好理解,也很好识别,但完全二叉树就不容易分清了,如下
二叉树有哪几种存储方式?
二叉树有两种存储方法:基于指针或引用的二叉链式存储法,基于数组的顺序存储法
链式存储法:每个节点都有三个字段,数据和指向左右子节点的指针,只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式比较常用,大部分二叉树
代码都是通过这种结构来实现的
顺序存储法:我们把根节点存储在下标 i=1 的位置,其左子节点存储在 2*i 的位置,其右子节点存储在 2*i+1 的位置,依此类推。通过这种方式,只要知道了根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),就可以通过下标计算,把整棵树都串起来。
什么样的二叉树适合用数组来存储?
完全二叉树使用基于数组的顺序存储法仅会浪费一个下标为 0 的存储位置,但非完全二叉树使用基于数组的顺序存储法就会浪费比较多的数组存储空间。
所以,完全二叉树用数组存储是最节省内存的一种方式,因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也就是完全二叉树会单独拎出来的原因,也是完全二叉树要求最后一层的子节点都靠左的原因
二叉树的遍历
二叉树经典的遍历方法有三种:前序遍历、中序遍历、后续遍历。其中,前、中、后序,表示的是节点和它的左右子树节点遍历打印的先后顺序
前序遍历:对于数中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树
中序遍历:对于数中的任意节点来说,先打印它的左子树,然后再打印这个节点,最后打印它的右子树
后序遍历:对于数中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点
二叉查找树
二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的,不过,它不仅仅支持快速查找,还支持快速插入、删除一个数据。
那什么样的二叉树才能叫做二叉查找树呢?
二叉查找树要求:在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。以下是二叉查找树的几个图例
1、二叉查找树的查找操作
首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就直接返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。
public class BinarySearchTree { private Node root; public Node find(int data) { Node p = root; while(p != null) { if(data < p.data) { p = p.left; }else if(data > p.data) { p = p.right; }else { return p; } } return null; } public static class Node{ private int data; private Node left; private Node right; public Node(int data) { this.data = data; } } }
2、二叉查找树的插入操作
二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所有我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置,如果右子树不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置,如果左子树不为空,就再递归遍历左子树,查找插入位置。
public class BinarySearchTree { private Node root; public void insert(int data) { if(root == null) { root = new Node(data); return; } Node p = root; while(p != null) { if(data > p.data) { if(p.right == null) { p.right = new Node(data); return; } p = p.right; }else {//data < p.data if(p.left == null) { p.left = new Node(data); return; } p = p.left; } } } public static class Node{ private int data; private Node left; private Node right; public Node(int data) { this.data = data; } } }
3、二叉查找树的删除操作
二叉查找树的删除操作比较复杂,针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。第一种情况是,要删除的节点没有子节点,我们只需要直接将父节点中指向要删除节点的指针置为null。第二种情况是,要删除的节点只有一个子节点(只有左子节点或右子节点),我们只需要更新父节点中指向要删除节点的指针,让它指向要删除节点的子节点就可以了。第三种情况是,要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上,然后再删除掉这个最小节点,因为最小节点肯定没有左子节点,所以,我们可以应用上面两条规则来删除这个最小节点。
public class BinarySearchTree { private Node root; public void delete(int data) { Node p = root;//p指向要删除的节点,初始化指向根节点 Node pp = null;//pp记录的是p的父节点 while(p != null && p.data != data) { pp = p; if(data > p.data) { p = p.right; }else { p = p.left; } } if(p == null) { return;//没有找到 } //要删除的节点有两个子节点 if(p.left != null && p.right != null) {//查找右子树中最小节点 Node minP = p.right; Node minPP = p;//minPP表示minP的父节点 while(minP.left != null) { minPP = minP; minP = minP.left; } p.data = minP.data;//将minP的数据替换到p中 p = minP;//下面就变成了删除minP了 pp = minPP; } //删除节点是叶子节点或者仅有一个子节点 Node child;//p的子节点 if(p.left != null) { child = p.left; }else if(p.right != null) { child = p.right; }else { child = null; } if(pp == null) { root = child;//删除的是根节点 }else if(pp.left == p) { pp.left = child; }else { pp.right = child; } } public static class Node{ private int data; private Node left; private Node right; public Node(int data) { this.data = data; } } }
堆
堆是一种特殊的树,那什么样的树才是堆?只要满足以下两个条件,一个树就是堆
- 堆是一个完全二叉树
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫做"大顶堆",对于每个节点的值都小于等于子树中每个节点值的堆,我们叫做"小顶堆"。
如何实现一个堆?
要实现一个堆,我们先要知道,堆都支持哪些操作以及如何存储一个堆。
1、往堆中插入一个元素
往堆中插入一个元素后,我们需要继续满足堆的两个特性。
我们首先把新插入的元素放到堆的最后,然后进行调整,让其重新满足堆的特性,这个过程叫做堆化。堆化实际上有两种,从上往下和从下往上。
堆化非常简单,就是顺着节点所在的路径,向上或者向下对比,然后交换。
我这里画了一张堆化的过程分解图【以大顶堆为例】。我们可以让新插入的节点与父节点对比大小,如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。
public class Heap { private int[] a;//数组,从下标1开始存储数据 private int n;//堆可以存储的最大数据个数 private int count;//堆中已经存储的数据个数 public Heap(int capacity) { a = new int[capacity + 1]; n = capacity; count = 0; } public void insert(int data) { if(count >= n) {//堆满了 return; } count++; a[count] = data; int i = count; while(i/2 > 0 && a[i] > a[i/2]) {//自下往上堆化 swap(a, i, i/2);//swap()函数作用:交换下标为i和i/2的两个元素 i = i/2; } } }
2、删除堆顶元素
删除堆顶元素的方法是,把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。
public class Heap { private int[] a;//数组,从下标1开始存储数据 private int n;//堆可以存储的最大数据个数 private int count;//堆中已经存储的数据个数 public Heap(int capacity) { a = new int[capacity + 1]; n = capacity; count = 0; } public void removeMax() { if(count == 0) {//堆中没有数据 return; } a[1] = a[count]; count--; } private void heapify(int[] a, int n, int i) {//自上往下堆化 while(true) { int maxPos = i; if(i*2 <= n && a[i] < a[i*2]) { maxPos = i*2; } if(i*2+1 <= n && a[maxPos] < a[i*2+1]) { maxPos = i*2+1; } if(maxPos == i) { break; } swap(a, i, maxPos); i = maxPos; } } }
如何基于堆实现排序?
我们把借助于堆这种数据结构实现的排序算法,叫做堆排序。堆排序的过程大致分为两个步骤,建堆和排序。
1、建堆
我们首先将数组原地建成一个堆。所谓"原地",就是不借助于另一个数组,就在原数组上操作。
public class Heap { public void buildHeap(int[] a, int n) { for(int i=n/2; i>=1; i--) { heapify(a, n, i); } } private void heapify(int[] a, int n, int i) {//自上往下堆化 while(true) { int maxPos = i; if(i*2 <= n && a[i] < a[i*2]) { maxPos = i*2; } if(i*2+1 <= n && a[maxPos] < a[i*2+1]) { maxPos = i*2+1; } if(maxPos == i) { break; } swap(a, i, maxPos); i = maxPos; } } }
2、排序
//n表示数据的个数,数组a中的数据从下标1到n的位置 public void sort(int[] n, int n) { buildHeap(a, n); int k = n; while(k > 1) { swap(a, 1, k); k--; heapify(a, k, 1); } }