贪心算法与数据结构结合4——最优前缀码及哈夫曼算法

我曾经和不少做独立游戏的人交流过。讲实话18年是国产游戏的兴盛期,这就激励了一大批想做游戏的人,和一大批独立游戏开发者的人开始不惜金钱、时间等代价开发游戏。当然他们也做出了很大的成功。
这一行大部分都是数媒专业,或者不断自学研究出来的东西。
现在的国家形式,当游戏开发人,做独立游戏真的是一种莫大的勇气。
游戏人去做游戏,为游戏产业做贡献。不单单是为了自己,还是为了国家,就是想拿出来一款可以称得上、或者代表国家的顶级游戏。说实话很难!因为国家在这一行业已经落后于其他国家很多很多了。但是这些游戏人都在不断摸索,不断尝试。即使不成功也会在这一行业有更大的突破。可以留给同样有梦想的人继续实现。
人生百年,吾道不孤。总会有人跟我们一起的!!!

本篇主要介绍贪心算法与数据结构结合的最后一个内容:最优前缀码及哈夫曼算法

1、最优前缀码

什么是最优前缀码

我们先看一个二元前缀码:
二元前缀码:用0-1字符串作为代码表示字符,任何字符的代码不能作为其它字符代码的前缀。
比如说:
a:001,b=00,c=010,d=01
很显然b可以作为a的前缀,d可以作为c的前缀。a前面是有00的,c前面是有01的。所以根据定义任何字符的代码不能作为其他字符代码的前缀。所以它们不是前缀码。

前缀码可以表示成一棵二叉树
我们假设左子树是0,右子树是1
(/localImg/ArtImage/2020/03/2020031901103634.jpg)]

我们看到01是由根结点向左走到子结点,然后向右走到01所属叶子结点。因为左子树是0(向左走是0),右子树是1(向右走是1)。因此可以得到01这个前缀码,同样的也可以得到其他的前缀码。我们发现二叉树的叶子结点正是这些前缀码。

下面我们来看下这些个前缀码的平均传输位数
(/localImg/ArtImage/2020/03/2020031901224335.jpg)]

二元前缀码上面的是出现这个二元前缀码的频率。平均传输位数就是二叉树左边的公式。
这里f(xi)是频率,d(xi)是这个前缀码所在的深度。
比如说左边的00000,它的频率是5,它的深度是5(根所在的深度是0);而它的兄弟结点00001的频率和深度也都是5
那么我们可以据此算出这棵二叉树的平均传输位数:
B=((5+5)·5+10·4+(15+10+10)·3+(25+20)·2)/100=2.85

那么什么是最优前缀码呢?
最优前缀码就是这个平均传输位数最小

我们据此来建立模型
问题:给定字符集C和每个字符的频率f(xi),求关于C的一个最优前缀码

我们可以寻找一个更好的贪心算法来解决这类问题
这个贪心法就是著名的哈夫曼树算法

2、哈夫曼算法

基本思想:选择权值小的叶子离根距离远些。

算法:
第一步:以每个结点作为根,构造只有一个根结点的n棵二叉树,根的权值就是结点的权。

第二步:在所有二叉树中选择根的权值最小的两棵二叉树作为左右子树构造一棵新的二叉树,根的权值等于其左右子树的根的权值之和。

第三步:去掉选中的二叉树、加入新生成的二叉树。

第四步:重复2、3步,直至只剩下一棵树为止。

下面我们来看个例子:
输入 a:45,b:13,c:12,d:16,e:9,f:5
首先选择权值最小的两个字符构造一棵二叉树,树的权值等于其左右子树的根的权值之和
这里我们看到e,f的权值是给出的输入实例中最小的两个权值
将e,f进行构造,其和作为根
(/localImg/ArtImage/2020/03/2020031908454205.jpg)]

随后将e,f去掉,然后将生成的14扔到输入实例中。
紧接着再从输入实例中找到两个最小的权值,即b,c。合成一棵新的二叉树
(/localImg/ArtImage/2020/03/2020031908500306.jpg)]

将b,c从输入实例中删掉,将25添加到输入实例中
我们看到输入实例中有a:45,d:16,两个和:14,25
然后再挑选两个最小权值,即14(e,f的和),d构造一棵二叉树
(/localImg/ArtImage/2020/03/2020031908530907.jpg)]

将14和d删掉,把30加进去。
然后再从输入实例中将两个最小的权值,即25,30拿出来构造一棵二叉树
(/localImg/ArtImage/2020/03/2020031909045308.jpg)]

再将25,30从输入实例中删掉,将55放到输入实例中。
最后把最后两个元素拿出来构造一棵二叉树
(/localImg/ArtImage/2020/03/2020031909060909.jpg)]

这棵二叉树就是一棵哈夫曼树,我们看到叶子结点就是原输入实例的字符集,那么这棵哈夫曼树的平均传输位数是:
4·(0.05+0.09)+3·(0.16+0.12+0.13)+1·0.45=2.24

那么哈夫曼树算法是否是正确的呢,下面我们用数学归纳法证明

数学归纳法证明哈夫曼算法

先证明一个性质1:C是字符集,x,y∈C,f(x),f(y)频率最小,那么存在最优二元前缀码使得x,y码字等长且仅在最后一位不同
这个翻译过来就是x,y是最深叶子结点且还是兄弟结点,根据前缀码的二叉树原理,层数越大,那么二元前缀码获得位数越多,如果位数越多,频率就会越小。因为x,y的频率最小,那么x,y一定是在层数最大的叶子结点上。接下来,我们看个图
(/localImg/ArtImage/2020/03/2020031909320410.jpg)]

左边的图是x,y不是兄弟结点也不在最深层所构成的一棵二叉树T,右边的是x与a互换,y与b互换所构成的一棵二叉树T’,而且此时x,y到了树的最深层的叶子结点上,下面我们来计算平均传输位数:
(/localImg/ArtImage/2020/03/2020031910161011.jpg)]

我们看到T’的权值比T的权值要小,因此性质1是成立的。

下面我们来看性质2:设T是一棵二叉树,x,y∈T,x,y是树叶兄弟,z是x,y的父亲,且令f(z)=f(x)+f(y),这样得到一棵二叉树T’,那么就会有B(T)-B(T’)=f(x)+f(y)
(/localImg/ArtImage/2020/03/2020031915501312.jpg)](/localImg/ArtImage/2020/03/2020031913571313.jpg)]

这样看到,B(T)-B(T’)=f(x)+f(y)。实际上,我觉得大家想想,也能推出来这个性质是正确的,根本不用证明。

下面根据这两个性质,证明哈夫曼算法
命题:算法对任意规模为n(n>=2)的字符集C,都能得到关于C的最优前缀码的二叉树
归纳基础:当n=2时,对于任何的字符至少都需要1位二进制数字。然而哈夫曼算法正是让它们为1位。显然归纳基础成立
(/localImg/ArtImage/2020/03/202003191427481584599730519.png ''图片title'')]

归纳步骤:算法对于规模为n的字符集都得到最优前缀码,证明对于规模为n+1时也能得到最优前缀码
在C={x1,x2,…,xn,xn+1},其中x1,x2∈C是频率最小的两个字符,现在让f(z)=f(x1)+f(x2),让z作为父结点去替代x1,x2。根据归纳假设,算法得到了一棵关于C’={z,x3,x4,…,xk+1}的最优前缀码的二叉树T’

然后我们把x1,x2作为z的儿子附到T’上,得到树T
(/localImg/ArtImage/2020/03/202003191531091584603531776.png ''图片title'')]

那么这个T就是原问题,即C=(C’-{z})∪{x1,x2}。
证明T就是一棵最优前缀码的二叉树

如若不然,就会存在一棵更好的最优前缀码的二叉树T1,B(T1)<B(T)。根据性质1,T1的最深层兄弟结点就是x1和x2,而且x1和x2是在叶子结点上,将T1和T同时去掉x1,x2,得到T2。
那么我们有,B(T2)=B(T1)-(f(x1)+f(x2)),而B(T’)=B(T)-(f(x1)+f(x2)),因为B(T1)<B(T)
所以B(T2)<B(T’),而且B2和T’的输入集合一样都是C’,因此就和T’是一棵关于关于C’的最优前缀码的二叉树矛盾。故不存在一棵最优前缀码二叉树T1。所以哈夫曼算法是正确的。证毕!

下面我们对代码进行分析:
我们创建一个数组tree。tree数组的前面存放原数组的权值,后面要存放两个端点合并后的值。那我们思考一下。这个tree数组是多大长度的

我们令m是一棵树中边的个数
根据离散数学中的握手定理:2m=一棵树结点的度数和
根据:树的所有结点个数-1=m

在哈夫曼树中,度为1的结点个数为n个(哈夫曼树中输入集合中的字符都是叶子结点);度为2的结点只有一个,那就是根结点;其他的都是度为3的点
设度为3的结点有x个,总结点数是t
则我们可以得出这样的式子:
2(x+1+n-1)=3x+2+n
2x+2n=3x+2+n
x=n-2
得出了度为3的结点有n-2个,则t=n-2+n+1=2n-1个,即哈夫曼树有2n-1个总结点。

所以tree数组的大小就是2n-1。一开始将n个结点的左右结点、父结点初始化为-1。

从i=n,i<tree.length,i++
然后将寻找tree数组中的最小的两个权值的结点:
找到两个没有父结点的点(没有父结点说明,这两个点还没有构造二叉树),再按照找最值的方式进行查找tree数组。找到两个端点之后的权值的和放到tree中第i个空间里。
紧接着让这两个端点的父结点指向i,然后i的左右孩子就是这两个端点。一会看下代码就明白了。
可以根据这样的算法编写代码:

public class Tree {
	int lChild;  //左子树
	int rChild;  //右子树
	int root;  //根节点
	int weight;  //权值
	
	public Tree() {
		// TODO Auto-generated constructor stub
	}

	public Tree(int lChild, int rChild, int root, int weight) {
		super();
		this.lChild = lChild;
		this.rChild = rChild;
		this.root = root;
		this.weight = weight;
	}
	
	
}

public static int v1;  //从数组中取出的第一个编码
	public static int v2;  //从数组中取出的第二个编码
	
	public static void main(String[] args) {
		// TODO Auto-generated method stub
		int a[]=new int[] {5,29,7,8,14,23,3,11};
		
		Tree tree[]=new Tree[a.length*2-1];
		
		HuffmanTree(tree, a);
		
		PrintHuffmanTree(tree, a);
	}
	
	//贪心算法解哈夫曼编码
	public static void HuffmanTree(Tree tree[],int a[]) {
		//初始化各结点,所有结点均没有双亲和孩子
		for(int i=0;i<tree.length;i++) {
			tree[i]=new Tree();
			tree[i].root=-1;
			tree[i].lChild=-1;
			tree[i].rChild=-1;
		}
		
		//构造只有根节点的n棵二叉树
		for(int i=0;i<a.length;i++)
			tree[i].weight=a[i];
		
		//将结点进行n-1次合并
		for(int i=a.length;i<tree.length;i++) {
			FindMinRoot(tree, i);
			tree[i].weight=tree[v1].weight+tree[v2].weight;  //将两个最小权值的和加起来,寄存到tree[i].root
			
			//此时将下标k作为v1,v2的根
			tree[v1].root=i;
			tree[v2].root=i;
			
			//下标为k的左子树是v1,右子树是v2
			tree[i].lChild=v1;
			tree[i].rChild=v2;
			
		}
	}

	//查找权值最小的俩个根节点
	public static void FindMinRoot(Tree tree[],int k) {
		//初始化v1
		for(int i=0;i<k;i++)
			if(tree[i].root==-1) {
				v1=i;
				break;
			}
		
		//遍寻找到第一个权值最小的根节点
		for(int i=v1+1;i<k;i++) 
			if(tree[i].root==-1&&tree[i].weight<tree[v1].weight)
				v1=i;
		
		//初始化v2
		for(int i=0;i<k;i++)
			if(tree[i].root==-1&&v1!=i) {
				v2=i;
				break;
			}
		
		//遍寻找到第二个权值最小的根节点
		for(int i=v2+1;i<k;i++)
			if(tree[i].root==-1&&tree[i].weight<tree[v2].weight&&i!=v1)
				v2=i;
	}
	
	//打印哈夫曼树
	public static void PrintHuffmanTree(Tree tree[],int a[]) {
		System.out.println("index weight root lChild rChild");
		for(int i=0;i<tree.length;i++) {
			System.out.print(i);
			System.out.printf("%7d",tree[i].weight);
			System.out.printf("%7d",tree[i].root);
			System.out.printf("%7d",tree[i].lChild);
			System.out.printf("%7d",tree[i].rChild);
			System.out.println();
		}
	}

}
哈夫曼算法代码分析

这个代码的时间复杂度很容易就能看出是O(n^2)

基于堆实现的优先队列

我们可以采取一个基于堆实现的优先队列的方法
这个基于堆的优先队列,就是用这个方法PriorityQueue写,这个是要在BTreeNode类中要实现Comparable的接口,然后自己重写compareTo方法。如果PriorityQueue在构造时指定比较器Comparator,则用比较器对元素排序。经过排序后的队列在寻找两个最小权值的时候更方便些。
下面进行代码实现:

package greedy;
import java.util.PriorityQueue;
import java.util.Queue;

class BTreeNode implements Comparable<BTreeNode> {

    private int data;//权值
    private BTreeNode left = null;//左子树
    private BTreeNode right = null;//右子树

    public BTreeNode(int data,BTreeNode left,BTreeNode right) {
        this.data = data;
        this.left = left;
        this.right = right;
    }

    public int getData() {
        return data;
    }

    public void setData(int data) {
        this.data = data;
    }

    public BTreeNode getLeft() {
        return left;
    }

    public void setLeft(BTreeNode left) {
        this.left = left;
    }

    public BTreeNode getRight() {
        return right;
    }

    public void setRight(BTreeNode right) {
        this.right = right;
    }

    @Override
    public int compareTo(BTreeNode o) {
        // TODO Auto-generated method stub
        return this.getData() - o.getData();
    }

}
public class HuffmanCode {

    public static void main(String[] args) {
        int[] a = new int[]{9,12,6,3,5,15};
        BTreeNode huffman = createHuffman(a,a.length);
        String huffmanCode = "";
        printHuffmanCode(huffman,huffmanCode);
    }


    public static BTreeNode createHuffman(int a[],int n) {
        Queue<BTreeNode> priorityQueue = new PriorityQueue<>();//利用JavaAPI的优先队列实现类
        for(int i=0;i<n;i++) {
            BTreeNode node = new BTreeNode(a[i],null,null);
            priorityQueue.add(node);
        }
        
        BTreeNode node1 = null;//最小权值结点
        BTreeNode node2 = null;//次最小权值结点
        BTreeNode node3 = null;//最小权值结点和次最小权值结点合并后的结点
        for(int j=0;j<n-1;j++) {
            node1 = priorityQueue.poll();//获得最小权值结点
            node2 = priorityQueue.poll();//获得次最小权值结点
            node3 = new BTreeNode(node1.getData()+node2.getData(),node1,node2);
            priorityQueue.add(node3);
        }
        return priorityQueue.poll();

    }
    private static void printHuffmanCode(BTreeNode huffman,String huffmanCode) {
        // TODO Auto-generated method stub
        if (huffman.getLeft() == null && huffman.getRight() == null) {
            System.out.println("权值为" + huffman.getData() + "结点的编码为:"+ huffmanCode);
        }
        if (huffman.getLeft() != null) {
            printHuffmanCode(huffman.getLeft(), huffmanCode+"0");
        }
        if (huffman.getRight() != null) {
            printHuffmanCode(huffman.getRight(), huffmanCode+"1");
        }
    }
}

基于堆实现的优先队列分析

排序是O(nlogn)的时间复杂度
而选择算法是O(n)的时间复杂度
综上基于堆实现的优先队列实现哈夫曼算法是O(nlogn)的时间复杂度

3、贪心算法总结

1、贪心法适合于解决组合优化问题,求解过程是多步判断过程,最终的判断序列对应于问题的最优解
2、判断依据某种“短视的”贪心选择性质,这种短视的就是只看眼前。性质的好坏决定了算法的正确性,贪心性质的选择往往依赖于直觉或者经验。
3、要想说明一个贪心策略是否正确,往往是需要证明的。那么对于贪心法的正确性证明方法有数学归纳法和交换论证等;证明贪心策略不对:举反例
4、贪心法的优势:算法简单,时间和空间复杂性低

对于贪心法代码编写,我们是要根据贪心策略的形式来进行编写。一般排序犹多,排序所占的时间复杂度是O(nlogn)。在编写过程中,不断进行迭代,将每个过程求一个最优解。最终得到原问题的最优解。

              立志在坚不欲锐,成功在久不在速。(张孝祥《论治体札》)
  • 6
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值