基本概念
权:赋予某个实体一个量,是对该实体的某个或某些属性的数值化描述。在数据结构中,实体有节点与边两大类,因此对应的有节点权与边权。简单理解就是:为节点或边赋予一个数值。
节点的带权路径长度:从该结点到树根之间的路径长度乘以节点上的权的积。
树的带权路径长度:所有叶子节点的带权路径长度之和。通常记作WPL。
哈夫曼树:WPL最小的二叉树称为哈夫曼树。
哈夫曼编码:在哈夫曼树中,每一个叶子节点都有唯一的路径,该路径中左分支记0,右分支记1,那么可将该路径用01表示,这一串01便是哈夫曼编码。
构建
由哈夫曼树的定义可知,权越大,其离根节点应该越近。因此,可按如下步骤进行构建。
1,将每一个权值构建成一个棵树——该树只有根节点。
2,选取两棵根结点权值最小的树作为左右子树构建一棵新的二叉树,并且新树根节点的权值为两棵子树权值之和。
3,将2中选取的权值删除,并将新生成的权值添加到权值列表中。
4,重复2,3两个步骤,只到权值列表中只有一个权值。此时得到的二叉树并是哈夫曼树。代码如下:
private static List<Node> huffmanTree(List<Node> nodes) {//nodes是按value值从小到大排序好的
while (true) {//使用死循环,会一直循环到
if (nodes.size() == 1) {//只有一个节点,哈夫曼树已经生成
return nodes;
}
Node first = nodes.remove(0);//因为nodes排序过,所以直接取两个Node就是权值最小的节点
Node second = nodes.remove(0);
Node parent = new Node(first.value + second.value);//生成父节点
first.parent = parent;
second.parent = parent;
parent.lchild = first;
parent.rchild = second;
insert(nodes, parent);//将父节点插入到nodes中
}
}
private static void insert(List<Node> src, Node node) {
for (int x = 0; x < src.size(); x++) {
if (src.get(x).value > node.value) {
src.add(x, node);
return;
}
}
src.add(node);
}
Node类
public class Node {
int value;
Node parent,lchild,rchild;
public Node(int value) {
this.value = value;
}
}
获取编码
方法一
构建完哈夫曼树后,需要取出每一个叶子节点对应的哈夫曼编码。思路如下:
1,从根节点开始,遍历所有节点。若该节点为叶子节点,则执行3;否则执行2。
2,修改节点的value值。在构建哈夫曼树时,叶子必有父节点,而父节点的value值为叶子权值之和。在构建完成之后,该value值没有用了,可以将该value值修改为0或1——如果是父节点的左子树,则为0,否则为1。
3,从叶子节点往上回溯到根节点,依次记录每一个节点的value值(根节点除外)。将得到的值进行反转,并将反转后的值加上0或1——若叶子节点为左节点则加0,否则加1。
4,递归执行执行该节点的左节点与右节点。
private static void getHuffmanCode(Node node) {
if (isLeaf(node)) {//叶子节点,必有父节点
if (isLeft(node)) {
codes.put(String.valueOf(node.value), getCode(node) + "0");//得到父节点中记录的值,并加上0或1
} else {
codes.put(String.valueOf(node.value), getCode(node) + "1");
}
return;
}
if (node.parent != null) {//非叶子节点,且不为根节点——此时根据该节点是左节点还是右节点设置其value值为0或1
if (isLeft(node)) {//左子树
node.value = 0;
} else {
node.value = 1;
}
}
getHuffmanCode(node.lchild);//递归操作该节点的左右子节点
getHuffmanCode(node.rchild);
}
private static boolean isLeft(Node node) {
return node.parent != null && node.parent.lchild == node;
}
private static boolean isLeaf(Node node) {
return node.lchild == null && node.rchild == null;
}
private static String getCode(Node node) {
StringBuilder sb = new StringBuilder();
while(node.parent != null){
node = node.parent;
if(node.parent == null)//满足该node的节点为根节点
break;
sb.append(node.value);
}
return sb.reverse().toString();//将父节点的值进行反转
}
方法二
方法一中,在构建完哈夫曼树后通过一次遍历修改非叶子节点的value值,而且每一次获取叶子节点的哈夫曼编码时必须从叶子节点回溯到根节点中。可以通过下面方式进行优化:
1,构建过程不变。并创建一个栈用来记录遍历过程中遇到的节点
2,获取编码时。如果当前节点为非叶子节点,则往栈中加0,然后递归遍历其左子树;左子树遍历完成后,移除栈中最后一个元素,然后向栈中添加1,遍历完成之后移除栈中最后一个元素。若节点是叶子节点,则获取栈中所有元素并返回,即是节点对应的哈夫曼编码。如:
private static void getHuffmanCode(Node node) {
if (isLeaf(node)) {//叶子节点,必有父节点
if (getRoot(node) == node) {
codes.put(DataFactory.char2Str(node.c), "0");//只有一个节点,将其编码设置为0
} else {
codes.put(DataFactory.char2Str(node.c), getCode(node));
}
return;
}
list.addLast("0");//遍历左子树时,向栈中加0
getHuffmanCode(node.lchild);//遍历左子树
list.removeLast();//左子树遍历结束后,移除栈中最后一个元素
list.addLast("1");//同时向栈中加1
getHuffmanCode(node.rchild);//遍历右子树
list.removeLast();//右子树遍历完成后,移除栈中最后一个元素
}
而getRoot主要是为了防止只有一个节点的情况下,对应的编码为“”。如下:
private static Node getRoot(Node node) {
Node result = node;
while (result.parent != null)
result = result.parent;
return result;
}
应用
最常见的应用就是用来进行压缩。
编码
1,根据文件中字符出现的次数构建哈夫曼编码。这里的出现次数就相当于权。
2,依次读入文件的字符c,在哈夫曼编码表中查询到c对应的哈夫曼编码,最后将编码按位写入文件中。
java中,按位写入文件比较麻烦,可通过左移方式,将位依次写入int类型的低8位中,然后将该int写入文件。参考。具体压缩代码如下:
/**
* @param map 每一个字符对应的编码——该编码由0,1构成——编码的是string
*/
public static void zip(Map<String, String> map) {
try {
FileReader reader = new FileReader(new File("C:\\Users\\xx\\Desktop\\test.txt"));//要压缩的文件
FileOutputStream fos = new FileOutputStream("C:\\Users\\xx\\Desktop\\texxst.hf");//生成的压缩文件
char[] ca = new char[1];//保证一次只读取一个字符
while (reader.read(ca) == 1) {
String s = char2Str(ca[0]);//将读取的字符转成string,这里是为了方便处理\r,\n等字符
String code = map.get(s);//一个字符对应的哈夫曼编码,string类型的
for (char c : code.toCharArray()) {
buffer = buffer << 1;//通过左移,保证了新加入的位存储在buffer的最低位
if(c == '1')
buffer |= 1;//如果字符是1,表示buffer的最低位应该写入1,因此进行或运算
n++;//记录左移了几次。左移8次后,就需要写入。
sum++;//表示一共读入了多少位
if (n == 8) {
fos.write(buffer);//写入一个字节,虽然接收的是int,但写的时候只写低8位
buffer = 0;
n = 0;
}
}
fos.flush();
}
if (n > 0) {//最后一次读取的文件不满足8位
buffer = buffer << (8 - n);
fos.write(buffer);
buffer = 0;
n = 0;
fos.flush();
}
//将sum转为byte数组,然后存储
byte[] sumArray = new byte[8];
for (int x = 0; x < 8; x++) {
sumArray[x] = (byte) (sum >> (x * 8));
}
fos.write(sumArray);
} catch (Exception e) {
e.printStackTrace();
}
}
解码
1,以二进制形式读取压缩文件。
2,从哈夫曼树的根节点开始,若读取的位为0,则走节点的左孩子;否则走右孩子。
3,到走叶子节点时,读取节点对应的字符,并写入解压文件。并将节点重置为树的根节点。如下:
/**
* @return 元素值为true,则表示该位为1。参数的最高位用返回值的第0个元素表示
*/
private static boolean[] byte2Bit(byte b) {
boolean[] result = new boolean[8];
result[0] = (b & 0x80) == 0x80;
result[1] = (b & 0x40) == 0x40;
result[2] = (b & 0x20) == 0x20;
result[3] = (b & 0x10) == 0x10;
result[4] = (b & 0x08) == 0x08;
result[5] = (b & 0x04) == 0x04;
result[6] = (b & 0x02) == 0x02;
result[7] = (b & 0x01) == 0x01;
return result;
}
private static boolean isLeaf(Node n) {
return n.rchild == null && n.lchild == null;
}
public static void unzip(Node node) {
try {
long unzipSum = 0;
FileInputStream reader = new FileInputStream(new File("C:\\Users\\xx\\Desktop\\texxst.hf"));
FileWriter fos = new FileWriter("C:\\Users\\xx\\Desktop\\unzip.txt");
byte[] ca = new byte[1];//保证一次只读取一个字符
Node n = node;
while (reader.read(ca) == 1 && unzipSum <= sum) {//sum表示压缩文件位的总数
boolean[] bs = byte2Bit(ca[0]);//获取该字节上的各位——true表示1,false表示0。
for (int x = 0; x < bs.length && x< sum - unzipSum; x++) {
if(!isLeaf(n))//不是叶子节点才需要继续遍历
n = bs[x] ? n.rchild : n.lchild;
if(isLeaf(n)){//叶子节点就直接获取对应的字符,并写入解压文件
fos.write(String.valueOf(n.c));
fos.flush();
n = node;
}
}
unzipSum += 8;
}
} catch (Exception e) {
e.printStackTrace();
}
}
这里面关键的是:用boolean[]表示一个byte的各个位上是0还是1。