哈夫曼樹
給定N個權值作為N個葉子結點,構造一棵二叉樹,若該樹的帶權路徑長度達到最小,稱這樣的二叉樹為最優二叉樹,也稱為哈夫曼樹(Huffman Tree)。哈夫曼樹是帶權路徑長度最短的樹,權值較大的結點離根較近。
重要概念
路徑:從一個節點到它往下可以達到的節點所經shu過的所有節點,稱為兩個節點之間的路徑
路徑長度:即兩個節點的層級差,如A節點在第一層,B節點在第四層,那它們之間的路徑長度為4-1=3
權重值:為樹中的每個節點設置一個有某種含義的數值,稱為權重值(Weight),權重值在不同算法中可以起到不同的作用
節點的帶權路徑長度:從根節點到該節點的路徑長度與該節點權重值的乘積
樹的帶權路徑長度:所有葉子節點的帶權路徑長度之和,也簡稱為WPL
哈夫曼樹判斷
判斷一棵樹是不是哈夫曼樹只要判斷該樹的結構是否構成最短帶權路徑。
在下圖中3棵同樣葉子節點的樹中帶權路徑最短的是右側的樹,所以右側的樹就是哈夫曼樹。
代碼實現
案例:將數組{13,7,8,3,29,6,1}轉換成一棵哈夫曼樹
思路分析:從哈夫曼樹的概念中可以看出,要組成哈夫曼樹,權值越大的節點必須越靠近根節點,所以在組成哈夫曼樹時,應該由最小權值的節點開始。
步驟:
(1) 將數組轉換成節點,並將這些節點由小到大進行排序存放在集合中
(2) 從節點集合中取出權值最小的兩個節點,以這兩個節點為子節點創建一棵二叉樹,它們的父節點權值就是它們的權值之和
(3) 從節點集合中刪除取出的兩個節點,並將它們組成的父節點添加進節點集合中,跳到步驟(2)直到節點集合中只剩一個節點
public class HuffmanTreeDemo {
public static void main(String[] args) {
int array[] = {13,7,8,3,29,6,1};
HuffmanTree huffmanTree = new HuffmanTree();
Node root = huffmanTree.create(array);
huffmanTree.preOrder(root);
}
}
//哈夫曼樹
class HuffmanTree{
public void preOrder(Node root){
if (root == null){
System.out.println("哈夫曼樹為空,無法遍歷");
return;
}
root.preOrder();
}
/**
* 創建哈夫曼樹
* @param array 各節點的權值大小
* @return
*/
public Node create(int array[]){
//先將傳入的各權值轉成節點並添加到集合中
List<Node> nodes = new ArrayList<>();
for (int value : array){
nodes.add(new Node(value));
}
/*
當集合中的數組只有一個節點時,即集合內所有節點已經組合完成,
剩下的唯一一個節點即是哈夫曼樹的根節點
*/
while (nodes.size() > 1){
//將節點集合從小到大進行排序
//注意:如果在節點類沒有實現Comparable接口,則無法使用
Collections.sort(nodes);
//在集合內取出權值最小的兩個節點
Node leftNode = nodes.get(0);
Node rightNode = nodes.get(1);
//以這兩個節點創建一個新的二叉樹,它們的父節點的權值即是它們的權值之和
Node parent = new Node(leftNode.weight + rightNode.weight);
parent.left = leftNode;
parent.right = rightNode;
//再從集合中刪除已經組合成二叉樹的倆個節點,並把它們倆個的父節點加入到集合中
nodes.remove(leftNode);
nodes.remove(rightNode);
nodes.add(parent);
}
//返回哈夫曼樹的根節點
return nodes.get(0);
}
}
//因為要在節點的集合內,以節點的權值value,從小到大進行排序,所以要實現Comparable<>接口
class Node implements Comparable<Node>{
int weight;//節點的權值
Node left;
Node right;
public Node(int weight) {
this.weight = weight;
}
public void preOrder(){
System.out.println(this);
if (this.left != null){
this.left.preOrder();
}
if (this.right != null){
this.right.preOrder();
}
}
@Override
public String toString() {
return "Node{" +
"weight=" + weight +
'}';
}
@Override
public int compareTo(Node o) {
return this.weight - o.weight;
}
}
哈夫曼編碼
定長編碼
固定長度編碼一種二進制信息的信道編碼。這種編碼是一次變換的輸入信息位數固定不變。簡稱“定長編碼”。
如將字符串 ”
可變長度編碼
根據字符出現的頻率進行編碼,頻率越高編碼越短。
如上面的字符串中字符 i 出現了5次,那么可以將它編碼為0,而字符 d 只出現了1次,則將它編碼為0101.
哈夫曼編碼
在編碼優化時,不僅要縮短編碼后的長度,還有考慮是否符合前綴編碼的要求,否則編碼后將不能恢復回原先數據。而哈夫曼編碼就是符合前綴編碼要求的可變長度編碼。
前綴編碼:字符的編碼都不能是其他字符編碼的前綴,符合該條件的編碼稱為前綴編碼。
哈夫曼編碼思路
將字符出現的次數作為權值,把字符串轉換成哈夫曼樹,往左節點的路徑為0,往右節點的路徑為1。
如下圖,將字符串"i like like like java do you like a java“ 中的字符轉換成哈夫曼樹,轉換后的字符串的哈夫曼編碼為:
1010100110111101111010011011110111101001101111011110100001100001110011001101000011001111000100100100110111101111011100100001100001110
編碼長度為133,比定長編碼的長度縮短了大半,縮短率約為:(320-133) / 320 = 58.4%
注:轉換后的哈夫曼樹不一定要和下圖結構一致,只需樹的帶權路徑長度一致就行。
使用哈夫曼編碼解壓縮
基礎代碼
創建哈夫曼樹節點類
class Node2 implements Comparable<Node2>{
Byte data;//節點對應數據的字節值
int weight;//節點權值,即數據出現的次數
Node2 left;
Node2 right;
public Node2(Byte data, int weight) {
this.data = data;
this.weight = weight;
}
public void preOrder(){
System.out.println(this);
if (this.left != null){
this.left.preOrder();
}
if (this.right != null){
this.right.preOrder();
}
}
@Override
public String toString() {
return "Node2{" +
"data=" + data +
", weight=" + weight +
'}';
}
@Override
public int compareTo(Node2 o) {
return this.weight - o.weight;
}
}
創建哈夫曼樹類,在類中創建一些基本 的屬性和遍歷方法
class HuffmanCodeTree{
//儲存數據字節對應的哈夫曼樹
Map<Byte, String> huffmanCodes = new HashMap<>();
//用於拼接字符串
StringBuilder stringBuilder = new StringBuilder();
//前序遍歷
public void preOrder(Node2 root){
if (root == null){
System.out.println("哈夫曼樹為空,無法遍歷");
}else {
root.preOrder();
}
}
}
壓縮代碼
/**
* 創建哈夫曼樹
* @param bytes 需轉成哈夫曼樹的數據字節數組
* @return
*/
public Node2 create(byte[] bytes){
//儲存數據字節在數據字節組出現的次數
Map<Byte, Integer> counts = new HashMap<>();
//遍歷數據字節組,如果counts有該字節key則將次數+1,沒有則設置次數為1
for (byte b : bytes){
Integer count = counts.get(b);
if (count == null){
counts.put(b, 1);
}else {
counts.put(b, count+1);
}
}
//根據counts映射創建節點集合
List<Node2> node2s = new ArrayList<>();
for (Map.Entry<Byte, Integer> entry : counts.entrySet()){
node2s.add(new Node2(entry.getKey(), entry.getValue()));
}
while (node2s.size() > 1){
Collections.sort(node2s);
Node2 leftNode = node2s.get(0);
Node2 rightNode = node2s.get(1);
Node2 parent = new Node2(null, leftNode.weight + rightNode.weight);
parent.left = leftNode;
parent.right = rightNode;
node2s.remove(leftNode);
node2s.remove(rightNode);
node2s.add(parent);
}
return node2s.get(0);
}
2.創建一個生成哈夫曼編碼的類,根據創建的哈夫曼樹生成哈夫曼編碼
//重載
public void getHuffmanCodes(Node2 root){
if (root == null){
return null;
}
getHuffmanCodes(root.left, "0", stringBuilder);
getHuffmanCodes(root.right, "1", stringBuilder);
}
/**
* 根據數據的哈夫曼樹獲取哈夫曼編碼
* 如:{32=01, 97=100, 100=11000, 117=11001, 101=1110, 118=11011, 105=101, 121=11010, 106=0010, 107=1111, 108=000, 111=0011}
* @param node2 節點
* @param path 路徑:左節點為0,右節點為1
* @param stringBuilder 用於拼接路徑
*/
public void getHuffmanCodes(Node2 node2, String path, StringBuilder stringBuilder){
//為了不影響上個遞歸前進段,需創建一個新的StringBuilder
StringBuilder stringBuilder2 = new StringBuilder(stringBuilder);
//將路徑path拼接
stringBuilder2.append(path);
if (node2 != null){//如果該節點為空,則已經超過哈夫曼樹范圍
//判斷該節點是不是葉子節點
if (node2.data == null){
//如果是非葉子節點則分別向左和向右進行遞歸
getHuffmanCodes(node2.left, "0", stringBuilder2);
getHuffmanCodes(node2.right, "1", stringBuilder2);
}else {
//如果是葉子節點,則說明該葉子節點的哈夫曼編碼已經完成,添加進哈夫曼編碼映射
huffmanCodes.put(node2.data, stringBuilder2.toString());
}
}
}
3.在哈夫曼樹類中創建一個轉換方法,根據字符的哈夫曼編碼將數據字節數組轉換成哈夫曼編碼格式的字符串,在將該字符串存儲成字節數組
/**
* 根據哈夫曼編碼將傳入的數據字節組全部轉成哈夫曼編碼格式的字符串,
* 再將字符串以8位為1字節的格式儲存成字節數組(需將8位二進制轉成整型再儲存)
* 又因為壓縮時二進制轉字節和解壓時字節轉二進制會有所變動(解壓時說明),
* 所以需要在切割時將最后一段二進制的最后一個1前的所有0記錄下來,
* 並把這些零的數量保存在返回字節數組的最后
* 如:[105,32,108,105,107,101,32,108,105,107,101,32,108,105,107,101,32,106,97,118,97,32,100,111,32,121,111,117,32,108,105,107,101,32,97,32,106,97,118,97] =>
* 1010100010111111110010001011111111001000101111111100100101001101110001110000011011101000111100101000101111111100110001001010011011100 =>
* [-88, -65, -56, -65, -56, -65, -55, 77, -57, 6, -24, -14, -117, -4, -60, -90, 28, 0]
* @param huffmanCodes 哈夫曼編碼映射
* @param bytes 需轉碼的數據字節數組
* @return 哈夫曼編碼格式字符串的整形字節數組
*/
public byte[] getHuffmanCodeBytes(Map<Byte, String> huffmanCodes, byte[] bytes){
StringBuilder stringBuilder = new StringBuilder();
//根據傳入的數據字節數組從哈夫曼編碼映射中取出相應的編碼,並拼接
for (int i=0; i<bytes.length; i++){
stringBuilder.append(huffmanCodes.get(bytes[i]));
}
/*
雖然將數據字節數組轉成了哈夫曼編碼格式,但是轉換后的字符串長度並沒有比原先短,反而更長了,
所以要將轉換后的字符串再以8位為1字節(1字節最多只能存儲8位)轉換成整型存進新的字節數組
*/
int len;//轉換后新的字節數組的長度
int strLength = stringBuilder.length();//赫夫曼編碼格式字符串的長度
int zeroCount = 0;//解壓時需補零的數量
int subIndex = 0;//判斷補零數量的開始切割索引
//因為轉換后的字符串不一定剛好能被8整除,所以為了防止超出范圍異常,需判斷
if (stringBuilder.length() % 8 == 0){
//因為需要儲存補零的數量,所以需+1
len = strLength / 8 +1;
//當字符串長度能被8整除時,那補零數量判斷的開始索引就是字符串長度-8
subIndex = strLength - 8;
/*
補零數量判斷邏輯:
從開始索引進行判斷,當等於0時,就把補零的數量+1,並將開始索引往后移動一位
因為當開始索引到字符串最后一位時,仍然是0的話,即最后一段二進制全部都是0,
這時轉換成字節整型為0,當解壓時重新把字節轉換成二進制字符串時也是0,
所以字符串長度最后一位無論是不是零都不影響,無需判斷
*/
while (subIndex<strLength-1 && stringBuilder.substring(subIndex, subIndex+1).equals("0")) {
zeroCount++;//補零數量+1
subIndex++;//並將切割開始索引往后移動一位
}
}else {
//如果不能被8整除,字節數組的長度還需+1
len = strLength / 8 + 2;
//不能被8整除時,補零長度判斷的開始索引就是字符串數量減去它們的余數
subIndex = strLength - strLength % 8;
while (subIndex<strLength-1 && stringBuilder.substring(subIndex, subIndex+1).equals("0")) {
zeroCount++;
subIndex++;
}
}
//儲存哈夫曼編碼格式的數據字符串轉換后的字節
byte[] huffmanCodeBytes = new byte[len];
//將補零數量儲存在字節數組的最后的位置
huffmanCodeBytes[len-1] = (byte)zeroCount;
int index = 0;//字節數組的下標索引
//遍歷哈夫曼編碼格式的數據字符串, 每次遞增8位
for (int i=0; i<strLength; i+=8){
String strBytes;//儲存從字符串中切割出來的二進制
//為了防止范圍超出,需判斷字符串從i下標到轉換后的字節數組末尾的長度是否>8
if (i+8 < strLength){
strBytes = stringBuilder.substring(i, i+8);
}else {
//如果剩余不足8位,則把剩下的二進制取出即可
strBytes = stringBuilder.substring(i);
}
//將切割出的二進制轉換成整型,並儲存進轉換后的字節數組
huffmanCodeBytes[index] = (byte)Integer.parseInt(strBytes, 2);
index++;
}
return huffmanCodeBytes;
}
4.在哈夫曼樹類中創建一個壓縮方法,調用上面的所有方法
//使用哈夫曼編碼進行壓縮
public byte[] huffmanZip(byte[] bytes){
Node2 root = create(bytes);
getHuffmanCodes(root);
return getHuffmanCodeBytes(huffmanCodes, bytes);
}
解壓代碼
1.在哈夫曼樹類中創建一個字節轉二進制的方法,將傳入的字節轉換成二進制字符串
/**
* 將字節轉換成二級制字符串
* 因為在壓縮時除了最后一段二進制都是滿8位的,而在解壓時轉換后的二進制會自動去掉最后一位1前面的所有0
* 如:壓縮: 01001101 => 77,解壓: 77 => 1001101,差了個0,
* 所以在轉換成二進制字符串時,需要進行補高位,即在最后一個1前面補0直到長度等於8位
* 但是有一個是例外的,即是轉換字節組除開補零數的最后一個數,因為在壓縮時它可能是不滿8位二進制的,要額外處理
* @param b 需轉換的字節
* @param flag 是否是數組中最后一個數據
* @param zeroCount 補零的長度
* @return 返回字節對應的二進制字符串
*/
public String byteToBinaryString(byte b, boolean flag, int zeroCount){
//將字節轉成整型,以便使用Integer的方法轉成二進制字符串
int temp = b;
/*
因為除了數組的最后一個,其它數據在壓縮時的二進制都是滿足8位的,
所以在解壓時(除外最后一個數據),只需在不足8位的時候補高位,即只要按位或|256 (100000000),
如:77 => 1001101 按位或256(100000000) => 101001101
然后再取字符串的最后8位即可獲得原先壓縮時的二進制字符串
*/
//如果不是最后一個則按位或|256,不足8位的補高位,夠8位則不會變
if (!flag){
temp |= 256;
}
//將整型轉成二級制字符串
String binaryString = Integer.toBinaryString(temp);
if (!flag){//判斷是否是最后一個
//不是,則因為按位或256的原因,只需取二進制字符串的最后8位
return binaryString.substring(binaryString.length() - 8);
}else {//不是則補零后返回
//補零的字符串
String zeroStr = "";
for (int i=0; i<zeroCount; i++){
zeroStr += "0";
}
//將補零字符串添加到轉換后的二進制字符串前面
return (zeroStr + binaryString);
}
}
2.在哈夫曼類中創建一個解壓方法,調用上面的方法將整形字節數組轉成二進制字符串,再將二進制字符串通過字節的哈夫曼編碼映射恢復回原先數據字節數組
/**
* 使用根據哈夫曼編碼進行解壓
* @param huffmanCodes 數據對應的哈夫曼編碼映射
* @param bytes 需解壓的字節數組
* @return 解壓后數據的字節
*/
public byte[] decode(Map<Byte, String> huffmanCodes, byte[] bytes){
//用於拼接需解壓字節轉換后的二進制字符串
StringBuilder stringBuilder = new StringBuilder();
int zeroCount = bytes[bytes.length-1];//將補零數量從字節組中取出
//遍歷需解壓字節數組(最后一位是補零數量,要除開)
for (int i=0; i<bytes.length-1; i++){
//判斷是否是數組中除開補零數量的最后一個數
boolean flag = (i == bytes.length-2);
//把轉換后的二進制字符串進行拼接
stringBuilder.append(byteToBinaryString(bytes[i], flag, zeroCount) );
}
//因為解壓時需通過二進制字符串獲取哈夫曼編碼對應的字節,所以需將哈夫曼編碼映射關系倒過來
Map<String, Byte> decodeHuffmanCodes = new HashMap<>();
for (Map.Entry<Byte, String> entry : huffmanCodes.entrySet()){
decodeHuffmanCodes.put(entry.getValue(), entry.getKey());
}
//存放二進制字符串通過哈夫曼編碼映射轉換后的字節
List<Byte> list = new ArrayList<>();
/*
將拼接二級制字符串根據赫夫曼編碼轉換成字節的邏輯:
1. 設置倆個指針用於切割二級制字符串,一個指向起點(開始為0),一個指向終點(開始為起點+1)
2. 根據兩個指針切割二進制字符串得到一段二進制,再以該段二進制為key從哈夫曼編碼映射中取值
3.1 如果取到的值為空,則表示為該段二進制沒有對應的字節,則將終點指針后移一位,繼續步驟2的操作
3.2 如果取到的值不為空,則將該值添加進解壓結果集合,並將起點指針指向終點
*/
for (int i=0; i<stringBuilder.length();){
//儲存根據二進制從哈夫曼編碼映射中取出的字節
Byte b = null;
//切割終點指針
int end = i;
while (b==null){//判斷取值是否為空
end ++;//為空,將終點指針向后移一位
//從拼接后的二進制字符串切割除一段二進制
String key = stringBuilder.substring(i, end);
//根據切割出的二進制從哈夫曼編碼映射中取出相應的值
b = decodeHuffmanCodes.get(key);
}
//當取值不為空時,將取值添加進解壓結果集,並把起點指針指向終點
list.add(b);
i = end;
}
//將結果集合轉換成字節數組的形式,再返回
byte[] decodeResult = new byte[list.size()];
for (int i=0; i<decodeResult.length; i++){
decodeResult[i] = list.get(i);
}
return decodeResult;
}
對文件進行解壓縮
/**
* 壓縮文件
* @param srcFile 需壓縮的文件路徑
* @param dstFile 壓縮后的文件存放路徑
*/
public void fileZip(String srcFile, String dstFile){
InputStream is = null;
OutputStream os = null;
ObjectOutputStream oos = null;
try {
is = new FileInputStream(srcFile);
//將文件轉換成字節數組
byte[] bytes = new byte[is.available()];
is.read(bytes);
//對字節數組進行壓縮
byte[] huffmanBytes = huffmanZip(bytes);
os = new FileOutputStream(dstFile);
oos = new ObjectOutputStream(os);
//將壓縮后的字節數組和字節的哈夫曼編碼寫入文件中
oos.writeObject(huffmanBytes);
oos.writeObject(huffmanCodes);
}catch (Exception e){
System.out.println(e.getMessage());
}finally {
try {
oos.close();
os.close();
is.close();
}catch (IOException e){
System.out.println(e.getMessage());
}
}
}
/**
* 解壓文件
* @param srcFile 需解壓的文件路徑
* @param dstFile 解壓后的文件存放路徑
*/
public void unzipFile(String srcFile, String dstFile){
InputStream is = null;
ObjectInputStream ois = null;
OutputStream os = null;
try{
is = new FileInputStream(srcFile);
ois = new ObjectInputStream(is);
//將文件壓縮后的整形字節數組和字節的哈夫曼編碼從文件中取出
byte[] huffmanBytes = (byte[])ois.readObject();
Map<Byte, String> huffmanCodes = (Map<Byte, String>)ois.readObject();
byte[] unzipResult = decode(huffmanCodes, huffmanBytes);
os = new FileOutputStream(dstFile);
//將解壓后的字節數組寫入文件
os.write(unzipResult);
}catch (Exception e){
System.out.println(e.getMessage());
}finally {
try{
os.close();
ois.close();
is.close();
}catch (IOException e){
System.out.println(e.getMessage());
}
}
}