从1开始学Java数据结构与算法——树结构的实际应用(一):堆排序、赫夫曼树、赫夫曼编码
———————————————————————————————————
上一篇博客我们讲了树结构中树的基础部分,那么这一篇和下一篇博客我们会开始讲树结构的中树的实际应用
———————————————————————————————————
堆排序
在前面的一篇讲八种排序算法的博客中,我们还留下了一个堆排序没有讲,因为堆排序需要用到树结构,所以我们放在这篇博客中
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏最好和平均时间复杂度为O(nlogn),它也是不稳定排序
大顶堆和小顶堆
- 大顶堆:每个节点的值都大于等于其左右孩子节点的值的完全二叉树,但是对于左右孩子节点之间值的大小关系并没有要求
前面我们也讲过顺序二叉树,那么如果把该大顶堆顺序存储二叉树的形式存储在数组中之后,那么我们其实可以得到它会满足arr[i]>=arr[2i+1] && arr[i]>= arr[2i+2]
- 小顶堆:每个节点的值都小于等于其左右孩子节点的值的完全二叉树,但是对于左右孩子节点之间值的大小关系并没有要求
永阳的根据顺序二叉树的概念,那么如果把该小顶堆用的方式顺序存储二叉树存储在数组中之后,那么我们其实可以得到它会满足arr[i]<=arr[2i+1] && arr[i]<= arr[2i+2]
那我们用堆排序的时候,升序采用大顶堆,降序采用小顶堆
堆排序思路分析
基本思想(以升序为例):
1)将排序序列构建成一个大顶堆(以数组的形式构建,因为需要用到顺序存储二叉树的特点)
2)此时,整个序列的最大值就是堆顶的根节点
3)将其与末尾元素进行交换,此时末尾为最大值
4)然后将剩余的n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复,便能得到一个有序序列了
那么我们看到其实整个过程可以大致分为两步,构建大顶堆堆的过程和堆顶元素(也就是最大值元素)与末尾元素(也就是最小值元素)进行交换
思路图解(以{4、6、8、5、9}数组升序排列为例):
先对该序列构建一个大顶堆:
———————————————————————————————————
此时我们从最后一个叶子节点(叶子节点自然不用调整,第一个非叶子节点arr.length/2-1=5/2-1=1,也就是arr[1],下面的6号)开始,从左至右,从上至下进行调整
找到第二个非叶子节点(也就是4)进行调整,由于{4、9、8}中9元素最大,所以4和9交换位置
这里注意,由于4和9交换了位置之后,{4、5、6}这一部分又不满足大顶堆的要求,所以对该部分又要进行调整
构建好大顶堆之后,就堆堆顶元素与末尾元素进行交换
———————————————————————————————————
那么到这一步,我们就得到了一个大顶堆,接着就开始堆堆顶元素与末尾元素进行交换,也就是将{9、6、8、5、4}中的9与4进行交换,得到{4、6、8、5、9}
那么完成这一步之后,对于序列中最大值9,就已经放在升序排序中最后一个位置了,那么这个9就放好了,我们把这个9放好之后,我们就开始堆剩下的{4、6、8、5}四个元素重复上诉构建大顶堆与交换的过程,那么在一次次构建大顶堆并交换的过程中,元素逐渐减少,最后就达到了我们升序排序的目的
堆排序代码实现
public static void main(String[] args) {
int[] arr = new int[] {4,6,8,5,9};
int temp = 0;
for(int i = arr.length/2-1; i>=0; i--) {
//我们说了最开始从第一个非叶子节点开始,那么用公式找就是arr.length/2-1
//所以从arr.length/2-1,每次往前找一个节点进行调整
//其实在adjust函数中有些人可能会对里面的找子树中的最大值与其子树的根节点进行交换这部分有疑问
//可能有些人会认为按照adjust方法找出的子树中的最大值,并不是子树中的最大值
//因为我们钱买你分析的时候说了,堆排序,从第一个非叶子节点开始,所以对于第一次调用adjust来说
//开始的根节点,下面只有一层元素!!!
//而后买你当我们开始往上面的其他非叶子节点开始遍历构造扫大顶堆的时候
//他们其中包含的子树中都已经是大顶堆的形式了,只是该子树中的堆顶元素是不是一个最大值而已
//这里可能有点难理解,文字说明就解释到这了。。。。。。
adjust(arr, i, arr.length);
}
for(int j = arr.length-1; j>0; j--) {
//这个循环表示每次数组中还剩下多少个元素,因为每次调整交换都会减少一个元素
//这个循环里面做的事情就是将堆顶元素与末尾元素进行交换
temp = arr[0];
arr[0] = arr[j];
arr[j] = temp;
//每次交换完之后,任然要进行大顶堆的调正
//因为交换只是对堆顶和末尾元素进行了调整,其中见任然是局部大顶堆的形式
//所以这里的adjust的第二个参数我们直接放0,从堆顶开始就行了
adjust(arr, 0, j);
}
System.out.println(Arrays.toString(arr));
}
/**
* 因为是顺序春促二叉树,所以是数组的形式在进行操作。
* 该方法将以i为节点的子树的数组形式的二叉树调整为以数组为形式的大顶堆
* @param arr:待调整的数组
* @param i:从哪个节点开始调整
* @param length:调整的数组长度
*/
public static void adjust(int arr[], int i, int length) {
int temp = arr[i];//将开始调整节点值保存一下,在它的子树里面找最大值,然后与他交换
for(int k = 2*i + 1; k < length; k = 2*k + 1) {
//k从开始调整的节点i的左子节点开始,每次往他的左子节点往下循环
if(k+1 < length && arr[k] < arr[k+1]) {
//如果有右子节点,并且左子节点的值小于右子节点
k++;//让k指向右子节点
}if(arr[k] > temp) {
//如果子树中找到的最大值比一开始的节点大,就将找到的最大值赋给开始节点
arr[i] = arr[k];
//然后让i指向k,继续往下找看看是否还有值比i大
i=k;
}else {
break;
}
}
//for循环出来后,我们已经将以i为根节点的子树中的最大值放到了最上面
arr[i] = temp;//将temp得值放到调整之后的位置
}
堆排序的速度测试
方法 | 时间 |
---|---|
堆排序 | 800w个数据,耗时2s左右 |
赫夫曼树
赫夫曼树的基本介绍
给定n个权值作为n个叶子节点,构造一个二叉树,若该树的带权路径长度(wpl)达到最小,称这样的二叉树为最优二叉树,也成为赫夫曼树(HuffmanTree)
赫夫曼书是带权路径长度最短的树,权值较大的节点离根节点较近
下面我们来搞清楚几个概念:
- 路径和路径的长度:在一棵树中,从一个节点往下可以达到的孩子或者孙子节点之间的通路成为路径。路径中分支的数目成为路径的长度。若规定根节点的层数为1,则从根节点出发到第L层的路径长度为L-1
- 节点的权及带权路径的长度:若将树中节点赋给一个有着某种含义的数值,则称这个数值为该节点的权。节点的带权路径长度为从根节点到该节点之间的路径cc长度与该节点的权的乘积
比如下面这张图,从根节点到13这个节点的路径长度就为3-1=2,节点的带权路径长度就为2*13=26
- 树的带权路径长度:规定所有叶子节点的带权路径长度之和,记为WPL,权值越大的节点离根节点就越近的二叉树才是最优二叉树。
- WPL最小的就是赫夫曼树
赫夫曼树创建思路分析
1)从小到大进行排序,每一个数据都是一个节点,每个节点可以看作是一颗最简单的二叉树
2)取出根节点权值最小的两棵二叉树组成一颗新的二叉树,该二叉树的根节点的权值是前面的两颗二叉树根节点权值的和
3)再将这棵二叉树以根节点的权值大小再次排序,不断重复第二步,直到数列中所有数据都被处理掉,就得到一颗赫夫曼树
下面我们以{13,7,8,3,29,6,1}来图解一下:
先对数据进行排序{13,7,8,3,29,6,1}得到{1,3,6,7,8,13,29},然后挑出1和3两个权值最小的节点组成一颗新的二叉树,得到权值为4的节点,接着后续步骤重复,依次找出最小权值的两个节点组成一颗新的二叉树即可
到这一步,其实赫夫曼树已经创建好了,但是我们往往在开发中还会需要将赫夫曼树在构建过程中,有个自动排序的功能,也就是最后的形式为下图:也就是从上到小,从左往右都是从小到大的顺序
赫夫曼树创建代码实现
public static void main(String[] args) {
int[] arr = new int[]{13,7,8,3,29,6,1};
//下面为了操作方便,我们用一个ArrayList去存放数据
List<Node> Node_list = new ArrayList<Node>();
for(int n : arr) {
Node_list.add(new Node(n));
}
//加入完之后,开始进行赫夫曼树的构造
while(Node_list.size() > 1) {//因为到最后Node_list只会剩下一个节点,所以只要Node_list节点数大于1都可以继续
//首先进行从小到大排序
//这里是因为我们的ArraysList中存放的是Node类型的数据,并且Node类我们实现了Compareable接口
//这属于java基础部分,这里不做过多说明
Collections.sort(Node_list);
//排完序之后,取出权值最小的两个,也就是最前面的两个
Node LeftNode = Node_list.get(0);
Node RightNode = Node_list.get(1);
//将两个权值最小的节点,构造出他们的父节点
Node parent = new Node(LeftNode.value + RightNode.value);
//从Node_list中移除取出的两个节点
Node_list.remove(0);
Node_list.remove(1);
//加入新构建的父节点
Node_list.add(parent);
}
}
}
/**
* 节点类
* 这里为了方便的达到排序的效果,我们实现一个Java自带的compareble接口
* 实现里面的compareTo方法
*/
class Node implements Comparable<Node>{
Node left;
Node right;
int value;
//构造器
public Node(int value) {
this.value = value;
}
@Override
public int compareTo(Node o) {
//this.value - o.value将会从小到大排序
//反过来o.value - this.value就会从大到小排序
return this.value - o.value;
}
赫夫曼编码
定长与边长编码
- 定长编码:我们看到下图中,如果I like like like java do you like a java这句话使用定长编码的话,首先会将他们对应的字母转换为Ascii码,然后再将Ascii码转换为二进制的一大串数字,那么总长度是359
- 可变长编码:如果对同一句话采用可变长编码的话,首先统计各个字符的个数,然后最多的就用最小的编码,比如我们发现空格出现了9次,最多,那我们就用最小的编码一个0表示。
那么按照上述可变编码的方法之后,的确传输量小了很多,但是又多了一个新的问题,如下图
这就会发现,可变编码可能会造成二义性,这个问题就说明我们的这个可变长编码不是前缀编码(字符的编码不能是其他字符编码的前缀),那么我们的赫夫曼编码就是解决了这个问题
赫夫曼编码步骤
1)传输给定的字符串I like like like java do you like a java
2)统计其中各个字符的个数
3)d:1 ;y:1 ;u:1 ;j:2 ;v:2 ;o:2 ;l:4 ;k:4 ;e:4 ;i:5 ;a:5 ;空格:9 ;
4)根据上面字符出现的次数构建一颗赫夫曼树,次数作为权值
5)根据该赫夫曼树,给各个字符编码,向左路径编0,向右路编1,编码如下:
o:1000 ;u:10010 ;d:100110 ;y:100111 ;i:101 ;a:110 ;k:1110 ;e:1111 ;j:0000 ;v:0001 ;l:001 ;空格:01
那么这样编码出来的就是前缀编码,就不会造成二义性
赫夫曼编码代码实现
/**
* 根据给到的byte数组,统计其中的每个字符一共出现了多少次,并存入一个map中
* 最后根据map创建Node,并将所有Node放进一个ArrayList中
*/
private static ArrayList<ElementNode> createNodeList(byte[] contextbyte){
Map<Byte, Integer> counts_map = new HashMap<Byte, Integer>();
for(byte c : contextbyte) {
Integer number = counts_map.get(c);//现中map中获取一下看是否已经有该字符
if(number == null) {
//如果没有,就加入map
counts_map.put(c, 1);
}else {
//如果有了,就次数+1,put方法会自动覆盖键值相同的
counts_map.put(c, number+1);
}
}
//这个for循环出来后,map就构建完成,也就是我们统计每个字符的这一步就完成了
//接下来将每个字符构造一个节点放入ArrayList中
ArrayList<ElementNode> node_list = new ArrayList<ElementNode>();
//遍历Map,entrySet()的返回值也是返回一个Set集合,此集合的类型为Map.Entry。
for(Map.Entry<Byte, Integer> m: counts_map.entrySet()) {
node_list.add(new ElementNode(m.getKey(),m.getValue()));
}
return node_list;
}
/**
* 根据统计好的每个字符的次数构建出来的节点,构建赫夫曼树
*/
private static ElementNode creteHuffmanTree(ArrayList<ElementNode> node_list) {
//下面就是创建赫夫曼数的过程,和前面讲到的代码内容一致
while(node_list.size() > 1) {
Collections.sort(node_list);
ElementNode leftNode = node_list.get(0);
ElementNode rightNode = node_list.get(1);
ElementNode parent = new ElementNode(null,leftNode.weight+rightNode.weight);
parent.leftNode = leftNode;
parent.rightNode = rightNode;
node_list.remove(leftNode);
node_list.remove(rightNode);
node_list.add(parent);
}
return node_list.get(0);//最后返回仅剩的一个节点
}
/**
* 根据构建出的赫夫曼树,生成赫夫曼编码(因为这里需要遍历树,所以采用递归的方式)
* 对于结果:将产生的赫夫曼编码放在一个Map中<Byte,String>
* 对于过程:在从根节点往下找叶子节点的过程中,需要去拼接路径,定义一个StringBuilder去存放叶子节点的路径
*
* 说明:
* 由于String的对象是不可改变的,每次修改都会在内存中创建一个新的对象。
* 如果程序对附加字符串的需求很频繁,不建议使用+来进行字符串的串联。可以考虑使用java.lang.StringBuilder类
* 使用这个类所产生的对象默认会有16个字符的长度,你也可以自行指定初始长度。
* 如果附加的字符超出可容纳的长度,则StringBuilder 对象会自动增加长度以容纳被附加的字符。
* 如果有频繁作字符串附加的需求,使用StringBuilder 类能使效率大大提高。
*/
static StringBuilder stringbuilder = new StringBuilder();
static Map<Byte, String> huffmancodes =new HashMap<Byte, String>();
/**
* @param node:传入的node节点,对它进行编码
* @param code:左0右1的路径
* @param stringbuilder:用于编码字符串的拼接
*/
private static void createHuffmanCode(ElementNode node, String code, StringBuilder stringbuilder) {
StringBuilder stringBuilder2 = new StringBuilder(stringbuilder);
stringBuilder2.append(code);
//开始递归,如果节点不为空,往后递归
if(node != null) {
//判断是否到了叶子节点
if(node.date != null) {
//到了叶子节点,放入map
huffmancodes.put(node.date, stringBuilder2.toString());
}else {
//没到叶子节点,往左递归
createHuffmanCode(node.leftNode, "0", stringBuilder2);
//往右递归
createHuffmanCode(node.rightNode, "1", stringBuilder2);
}
}
}
}
/**
* 节点类
*/
class ElementNode implements Comparable<ElementNode>{
Byte date;//字符数据
int weight;//权重,这里就只每个字符出现的次数
ElementNode leftNode;
ElementNode rightNode;
//构造器
public ElementNode(Byte date, int weight) {
super();
this.date = date;
this.weight = weight;
}
//排序相关方法
@Override
public int compareTo(ElementNode o) {
return this.weight - o.weight;
}
@Override
public String toString() {
return "ElementNode [date=" + date + ", weight=" + weight + "]";
}
}
那么到这里,我们赫夫曼编码最核心的部分已经写完了,我们已经拿到了每个字符的编码,但其实如果要分析完整,要进行数据压缩的话,继续往下走我们还需要
1)将得到的编码按照原先字符串的对应顺序拼接起来得到一个二进制的一长串数字
2)用一个byte数组,对上一步获得的二进制长串数据每八个每八个的装在一起,然后转为10进制数字,这才达到了我们真正的压缩
3)再往后走,写完了压缩,那就还需要解压
大概步骤就放在这里,具体的我就不在这里实现了,我们只要了解了最核心的如何获取赫夫曼编码即可