为什么需要树?
顺序存储:
优点:通过下标方式访问元素,速度快。对于有序数组,还可使用二分查找提高检索速度。
缺点:如果要检索具体某个值,或者插入删除值(按一定顺序)会整体移动,效率较低
链式存储:
优点:在一定程度上对数组存储方式有优化(比如:插入一个数值节点,只需要将插入节点,链接到链表中即可, 删除效率也很好)。
缺点:在进行检索时,效率仍然较低,比如(检索某个值,需要从头节点开始遍历)
树:
能提高数据存储,读取的效率, 比如利用 二叉排序树(Binary Sort Tree),既可以保证数据的检索速度,同时也可以保证数据的插入,删除,修改的速度。
二叉树:每个节点最多只能有两个子节点的一种形式称为二叉树。左右子节点不同。
满二叉树:二叉树的所有叶子节点都在最后一层,并且结点总数= 2^n -1 , n 为层数
创建二叉树:
思路:
- 定义节点类
- 定义树
代码实现:
class Node {
int value;
Node leftNode;
Node rightNode;
public Node(int value) {
this.value = value;
}
}
public class Tree {
private Node root;
public void setRoot(Node root) {
this.root = root;
}
测试:
Tree tree = new Tree();
Node root = new Node(0);
tree.setRoot(root);
Node left = new Node(1);
Node right = new Node(2);
root.setLeftNode(left);
root.setRightNode(right);
left.setLeftNode(new Node(3));
left.setRightNode(new Node(4));
right.setLeftNode(new Node(5));
right.setRightNode(new Node(6));
遍历二叉树:
- 前序遍历
- 中序遍历
- 后序遍历
图示说明:
思路:
不难发现,不管是哪种遍历,对于每一个节点来说,要做的事情都是一样的。
这就符合递归的思想。
代码实现:
public void frontShow() { //如果根节点为空,这里少了判空的方式,可在调用处进行判断
System.out.println(this.value);
if (this.leftNode != null) {
leftNode.frontShow();
}
if (this.rightNode != null) {
rightNode.frontShow();
}
}
public void midShow() {
if (this.leftNode != null) {
leftNode.midShow();
}
System.out.println(this.value);
if (this.rightNode != null) {
rightNode.midShow();
}
}
public void lastShow() {
if (this.leftNode != null) {
leftNode.lastShow();
}
if (this.rightNode != null) {
rightNode.lastShow();
}
System.out.println(this.value);
}
注意树的方法应该由节点调用
查找二叉树的节点
需要用到遍历对比每一个节点,所以也有三种查找方式
代码实现:
public Node frontSearch(int i) { //前序查找
if (this.value == i) {
return this;
} else {
Node temp = null;
if (this.leftNode != null) {
temp = leftNode.frontSearch(i);
}
if (temp != null) {
return temp;
} else {
if (this.rightNode != null) {
temp = rightNode.frontSearch(i);
}
}
return temp;
}
}
删除二叉树的节点
- 如果删除的节点是叶子节点,则删除该节点
- 如果删除的节点是非叶子节点,则删除该子树
思路:
代码实现:
public void del(int i) {//这个方法默认不是删除根节点(已经进行判断后才进入此方法)
Node parent = this;
if (parent.leftNode != null && parent.leftNode.value == i) {
parent.leftNode = null;
return;
}
if (parent.rightNode != null && parent.rightNode.value == i) {
parent.rightNode = null;
return;
}
parent = leftNode;
if (parent != null) {
parent.del(i);
}
parent = rightNode;
if (parent != null) {
parent.del(i);
}
}
顺序存储的二叉树
上述定义节点类的存储方式是链式存储
下面将用数组实现(完全)二叉树。
为什么是完全二叉树呢?
- 因为数组的连续性,使得不满足完全二叉树条件的二叉树,逻辑结构与存储结构无法对应。
- 完全二叉树:如果该二叉树的所有叶子节点都在最后一层或者倒数第二层,而且最后一层的叶子节点在左边连续,倒数第二层的叶子节点在右边连续,我们称为完全二叉树。(即从上往下、从左往右依次数,只要不连续(有空缺)就不是完全二叉树)
数组模拟完全二叉树的特性:图示
遍历:只给出前序遍历,思路与链式存储的一致。
public class ArarryTree {
private int[] data;
public ArarryTree(int[] data) {
this.data = data;
}
public void frontShow() {
frontShow(0);
}
public void frontShow(int index) {
if (index > data.length || data.length == 0 || data == null) {
return;
}
System.out.println(data[index]);
if (2 * index + 1 < data.length) {
frontShow(2 * index + 1);
}
if (2 * index + 2 < data.length) {
frontShow(2 * index + 2);
}
}
}
堆排序
堆是具有以下性质的完全二叉树:
- 每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆(注意 : 没有要求结点的左孩子的值和右孩子的值的大小关系 )
- 每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆
堆排序是利用堆这种数据结构而设计的一种选择排序算法,它的最坏,最好,平均时间复 杂度均为 O(nlogn),它也是不稳定排序。
堆排序的基本思想是:(数组实现) 一般升序采用大顶堆,降序采用小顶堆
- 将待排序序列构造成一个大顶堆
- 此时,整个序列的最大值就是堆顶元素。
- 将其与末尾元素进行交换,此时末尾就为最大值。(将最大元素沉到数组最末端)
- 然后将前面剩余 n-1 个元素重新构造成一个大顶堆。
如此反复执行,便能得到一个有序序列了。(升序)
代码实现:
//构造大顶堆
//参数:待排序列、数组长度、最后一个非叶子节点下标(难点)
public static void maxHeap(int[] arr, int size, int index) {
int left = 2 * index + 1; //左子节点
int right = 2 * index + 2; //右子节点
int max = index;
//找出父子节点的最大值
if (left < size && arr[max] < arr[left]) {
max = left;
}
if (right < size && arr[max] < arr[right]) {
max = right;
}
//是否需要交换
if (max != index) {
int temp = arr[index];
arr[index] = arr[max];
arr[max] = temp;
maxHeap(arr, size, max); //难点:因为交换过后,有可能破环了原来的平衡,所以递归将它的子树调整为大顶堆。
}
}
//堆排序
public static void HeapSort(int[] arr) {
//第一次构造大顶堆时需要 从最后一个非叶子节点开始 往前
int start = (arr.length - 2) / 2; //(arr.length - 1) / 2
//下标出错,但能得到正确答案,原因是:多做了一步无用功。
for (int i = start; i >= 0; i--) { //把所有非叶子节点都跑一遍
maxHeap(arr, arr.length, i);
}
//到这里得到的是第一次构造的大顶堆,剩余的构造大顶堆都在下面循环中完成
//将无序序列构造成大顶堆,和交换后形成的无序序列有差别,因此可以简化剩余的排序
for (int i = arr.length - 1; i > 0; i--) {
int temp = arr[0];
arr[0] = arr[i];
arr[i] = temp;
maxHeap(arr,i,0); //这里传入0:是利用上面子树递归构造大顶堆的功能
}
}
//测试
public static void main(String[] args) {
int[] arr = new int[]{9, 6, 8, 7, 0, 1, 10, 4, 2};
HeapSort(arr);
System.out.println(Arrays.toString(arr));
}
堆排序的重难点:
1、第一趟的排序,剩下趟的排序不同。主要是利用的子树构造大顶堆的便利性
2、第一趟的最后非叶子节点的下标即使出错也能得到正确答案,因为循环的原因:第一步无用,但下一步就是正确的下标,所以只是多了一次循环。
线索二叉树
引出:
- 二叉树的节点有左右指针域,但通常不能充分利用(没有指向左右子节点时都为空),造成浪费。
- 二叉树不能实现随机访问,不管要找哪个元素都要从头到尾遍历。
概述:
线索二叉树就是将没有左右子节点的节点,令其左子节点指向它的前一个节点(按遍历的顺序),右子节点指向它的后一个节点(同上),使其查找或遍历更加高效。
思路:(中序线索化)
- 虽然减少了空的指针域,但引起了歧义:到底是指向遍历时的前一个节点还是左子节点?
- 解决:节点类中定义一个 标识符 默认是0时,代表左右子节点;1时代表前后节点。
- 按中序遍历的顺序:先找到最左叶子节点,如果当前节点的左子节点为空,则让他指向前驱节点(上个节点);由于遍历过程中会丢失上一个节点,所有需要记录上一个节点(pre);同时处理上个节点的右子节点,空则指向后继节点(当前节点)
- 重复上述过程,处理所有的节点。
图示:
代码实现:
private Node pre; //定义当前节点的上一个节点,与遍历顺序有关
//将二叉树 中序线索化
void threadNodes(Node node) {
if (node == null) { //递归结束的条件
return;
}
threadNodes(node.leftNode); //中序线索化先处理左子树
//然后处理当前节点
//每次分别处理当前节点的左边域 和 上个节点的右边域(重点)这样其实所有节点的左右都能被处理到
if (node.leftNode == null) {
node.leftNode = pre;
node.leftType = 1;
}
if (pre != null && pre.rightNode == null) { //当前节点为遍历的第一个元素时,前驱结点为空,会报空指针异常
pre.rightNode = node;
pre.rightType = 1;
}
pre = node; //放在代码的这个位置有讲究!!刚好处理完当前节点,将pre指向当前节点,node指向下一个节点
threadNodes(node.rightNode); //最后处理右子树
}
遍历中序线索化二叉树
思路:
- 先找到遍历时的首个元素
- 输出首个元素
- 依次输出它的后继元素
- 如果是右子节点则指向右子节点
- 重复3、4,直到右子节点为空循环结束
代码实现:
public void threadIterate(){
Node node = root;
while (node != null){ //到最后节点的右子节点为空,循环结束
while (node.leftType ==0){
node = node.leftNode;
}
//node.leftType ==1 首个元素特征
System.out.println(node.value);
while (node.rightType ==1){ //持续输出后继节点
node = node.rightNode;
System.out.println(node.value);
}
//node.rightType == 0 表示右子节点
node = node.rightNode;
}
}
赫夫曼树(最优二叉树)
概述:
叶子节点带权路径长度 = 权值 * 到此节点所经过的节点数;
树的带权路径长度(WPL) = 所有叶子节点的带权路径长度之和;
WPL 最小时 称作最优二叉树,也就是我们的赫夫曼树;
图示:
由上图可知:权值越大的节点 离根节点越近的二叉树 才是最优二叉树
构造赫夫曼树:
思路:
- 将所有叶子节点按权值升序排序
- 取最小权值的两个节点,创建一棵新二叉树
- 它们的权值之和作为新树的根节点权值,它们分别作为左右子树;
- 将新二叉树的根节点放入剩下的节点集合中
- 重复上述过程,直到只剩一棵树,就得到赫夫曼树。
图示:
代码实现:
public static Node createHaffmanTree(int[] arr){
//使用数组中的所有元素创建若干二叉树(只有一个根节点)
ArrayList<Node> nodes = new ArrayList<>();
for (int val : arr) {
nodes.add(new Node(val));
}
while (nodes.size()>1){
//排序
Collections.sort(nodes);
//取出权值最小的两棵二叉树
Node left = nodes.get(0);
Node right = nodes.get(1);
//创建一颗新的二叉树(根节点的权值为上面两棵二叉树的权值之和)
Node parent = new Node(left.value + right.value);
parent.leftNode = left;
parent.rightNode = right;
//移除取出的两棵二叉树
nodes.remove(left);
nodes.remove(right);
//加入新创建的二叉树到集合中
nodes.add(parent);
}
return nodes.get(0);
}
赫夫曼编码
引出:
- 定长编码:每个字符都用八位(一个字节表示)
- 非定长编码:统计每个字符出现的次数,出现次数多的字符用位数少的表示;次数少的字符用位数多的表示。
- 前缀编码:非定长编码有可能引起歧义。每个字符编码都不能是其他字符编码的前缀,满足条件称作前缀编码。
- 赫夫曼编码就是一种前缀编码。
举例说明:
赫夫曼编码:
思路:
- 统计字符次数
- 以次数为权值构建赫夫曼树(字符也存入节点中)
- 到这些节点的路径(唯一)作为该字符的编码,构造编码表。
- 将原来的字符重新根据编码表 编码(压缩),得到经过压缩的数据。
图示:
代码实现:
//准备工作
public class Node implements Comparable<Node> {
int weight;
Byte data;
Node leftNode;
Node rightNode;
public Node(Byte data,int weight) {
this.data = data;
this.weight = weight;
}
@Override
public int compareTo(Node o) {
return this.weight-o.weight;
}
@Override
public String toString() {...}
}
主函数:赫夫曼编码压缩
public static void main(String[] args) {
String str = "can you can a can as a can canner can a can.";
byte[] bytes = str.getBytes(); //得到字符的定长编码数组;
byte[] b = HuffmanZip(bytes); //得到经过压缩后的字符赫夫曼编码数组
}
压缩过程:
private static byte[] HuffmanZip(byte[] bytes) {
//统计字符出现的次数,并创建出节点集
List<Node> nodes = getNodes(bytes); //1、
//构造赫夫曼树
Node tree = createHuffmanTree(nodes); //2、
//构造赫夫曼编码表
Map<Byte, String> huffmanCodes = createHuffmanCodes(tree); //3、
//根据编码表,重新编码,进行压缩
byte[] b = zip(bytes, huffmanCodes); //4、
return b;
}
1、bytes 数组中每个元素都代表一个字符,统计这些字符出现的次数、以字符值、与出现次数创建节点,返回只有根节点的二叉树集合。
private static List<Node> getNodes(byte[] bytes) {
//以字符数值为键,出现次数为值。
Map<Byte, Integer> counts = new HashMap<>();
for (byte b : bytes) {
Integer count = counts.get(b);
if (count == null) {
counts.put(b, 1);
} else {
counts.put(b, count + 1);
}
}
List<Node> nodes = new ArrayList<>();
//遍历Map,将每个键值对创建成节点对象
for (Map.Entry<Byte, Integer> entry : counts.entrySet()) {
nodes.add(new Node(entry.getKey(), entry.getValue()));
}
return nodes;
}
2、将二叉树集合(一个个的节点)构造成赫夫曼树。
private static Node createHuffmanTree(List<Node> nodes) {
while (nodes.size() > 1) {
//排序
Collections.sort(nodes);
//取出权值最小的两棵二叉树
Node left = nodes.get(0);
Node right = nodes.get(1);
//创建一颗新的二叉树(根节点的权值为上面两棵二叉树的权值之和)
Node parent = new Node(null, left.weight + right.weight);
parent.leftNode = left;
parent.rightNode = right;
//移除取出的两棵二叉树
nodes.remove(left);
nodes.remove(right);
//加入新创建的二叉树到集合中
nodes.add(parent);
}
return nodes.get(0);
}
3、递归遍历赫夫曼树,用Map将每个叶子节点的字符数值和路径作为键值对,Map就是赫夫曼编码表
//临时存储路径
static StringBuilder sb = new StringBuilder();
//存储编码表
static Map<Byte, String> map = new HashMap<>();
private static Map<Byte, String> createHuffmanCodes(Node tree) {
if (tree == null) {
return null;
}
createHuffmanCodes(tree.leftNode, sb, "0");
createHuffmanCodes(tree.rightNode, sb, "1");
return map;
}
private static void createHuffmanCodes(Node node, StringBuilder sb, String str) {
StringBuilder sb2 = new StringBuilder(sb);
//这里容易出错,递归中不能只用一个全局变量,因为它不能及时清理掉上个节点的数据;
sb2.append(str);
if (node.data == null) { //没到叶子节点就继续递归
createHuffmanCodes(node.leftNode, sb2, "0");
createHuffmanCodes(node.rightNode, sb2, "1");
} else {
//说明到达叶子节点,将其存储进map
map.put(node.data, sb2.toString());
}
}
4、拿到编码表后,就对原来的字符重新编码(原来八位一个字符,现在可能只用2位表示)这样就是压缩了
private static byte[] zip(byte[] bytes, Map<Byte, String> huffmanCodes) {
//临时存放压缩后的二进制字符串
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(huffmanCodes.get(b));
}
//构造一个压缩后的byte数组,用于返回
int len; //数组的长度
if (sb.length() % 8 == 0) {
len = sb.length() / 8;
} else {
len = sb.length() / 8 + 1;
}
byte[] by = new byte[len];
//将压缩后的二进制字符串,重新以每八位划分,放入byte数组
int index = 0;
for (int i = 0; i < sb.length(); i += 8) {
String s;
if (i + 8 > sb.length()) {
s = sb.substring(i);
} else {
s = sb.substring(i, i + 8);
}
byte num =(byte) Integer.parseInt(s, 2); //八位二进制转换为byte十进制
by[index]= num;
index++;
}
return by;
}
最后:长度44的数据经过压缩后只剩下16
public static void main(String[] args) {
String str = "can you can a can as a can canner can a can.";
byte[] bytes = str.getBytes();
byte[] b = HuffmanZip(bytes);
System.out.println(bytes.length); //44
System.out.println(b.length); //16
}
二叉排序树
二叉排序树又称 二叉查找树、二叉搜索树。
条件:
任意一个节点的左子节点小于此节点,右子节点大于此节点。
创建:
调用树的add的方法
public void add(Node node){
if (root == null){
root = node;
}else {
root.add(node);
}
}
调用节点的add方法
public void add(Node node) {
if (node == null){
return;
}
if (node.val<this.val){
if (this.left==null){
this.left = node;
}else {
this.left.add(node);
}
}else {
if (this.right==null){
this.right = node;
}else {
this.right.add(node);
}
}
}
中序遍历:
public void midShow(Node node) {
if (node ==null){
return;
}
midShow(node.left);
System.out.println(node.val);
midShow(node.right);
}
查找:
//class BinarySortTree
public Node search(int i) {
if (root==null){
return null;
}else {
return root.search(i);
}
}
//class Node
public Node search(int i) {
if (this.val == i){
return this;
}else if (i <this.val){
if (this.left != null){
return this.left.search(i);
}else
return null;
}else {
if (this.right != null){
return this.right.search(i);
}else
return null;
}
}
删除:
删除分为三种情况:
- 删除叶子节点。
- 删除只有一个子节点的节点。
- 删除有两个子节点的节点。
思路:(不管哪种情况,都需要拿到要删除节点的父节点)
第一种:
直接删除即可,将其父节点的左或右置为空。
第二种:
将父节点的指向改为要删除节点的子节点。
第三种:
要在该节点的右子树中找到一个最小值,将其摘下,替换掉要删除的节点即可。
最小值也可能有两种情况:
一是它本身是叶子节点;二是它还有一个右子节点(可参考第二种情况);