前言

哈夫曼树也有人称为 霍夫曼树最优二叉树

哈夫曼(David Huffman)是美国的一位数学家。他在 1952 年发明了哈夫曼编码(一种二进制编码),该编码中用到了一种特殊的二叉树,人们为了纪念他的成就,将所用到的特殊二叉树称为哈夫曼树。

当然,知道这个可不算真正了解哈夫曼树,在了解哈夫曼树之前,首先要学习几个基本概念。

1. 基本术语

首先,节点的权。它指的是给树中的各个节点一个数值,用来表示某种现实含义。

比如用 1-10 之间的数字表示某个节点的重要性或者表示该节点出现的频率等,这个数字就叫做节点的权(权值)。

如下图所示:

在这里插入图片描述

那么,什么是 从根节点到某节点的路径长度?

这个指的是从根节点到该节点所经过的路径段数。比如对于权值为 5 的节点(它是叶子节点),从根节点到该节点的路径长度为 3。换句话说,从权值为 5 的节点向根节点回溯要经过 3 个前辈节点,所以从根节点到权值为 5 的节点的路径长度为 3。

如下图所示:

在这里插入图片描述

接下来就是 2 个重要概念的铺垫了。

一个是 节点的带权路径长度。它的英文是 Weighted Path Length(缩写 WPL)。节点的带权路径长度表示从根节点到该节点的路径长度与该节点权值的乘积。比如对于权值为 5 的叶子节点,从根节点到该节点的路径长度为 3,所以该节点的带权路径长度为 3*5 = 15。

另一个是 树的带权路径长度。树的带权路径长度是树中所有叶子节点的带权路径长度之和。上图中,这棵树的带权路径长度是 ( 1 ∗ 3 ) + ( 3 ∗ 3 ) + ( 5 ∗ 3 ) + ( 10 ∗ 3 ) = 57 (1*3)+(3*3)+(5*3)+(10*3)=57 (13)+(33)+(53)+(103)=57

看一看下图中的 4 棵树的带权路径长度。

在这里插入图片描述

在图中:

  • 第 1 棵树的 WPL ( 1 ∗ 2 ) + ( 3 ∗ 2 ) + ( 5 ∗ 2 ) + ( 10 ∗ 2 ) = 38 (1*2)+(3*2)+(5*2)+(10*2)=38 (12)+(32)+(52)+(102)=38
  • 第 2 棵树的 WPL ( 5 ∗ 2 ) + ( 1 ∗ 3 ) + ( 3 ∗ 3 ) + ( 10 ∗ 1 ) = 32 (5*2)+(1*3)+(3*3)+(10*1)=32 (52)+(13)+(33)+(101)=32
  • 第 3 棵树的 WPL ( 5 ∗ 3 ) + ( 10 ∗ 3 ) + ( 1 ∗ 2 ) + ( 3 ∗ 1 ) = 50 (5*3)+(10*3)+(1*2)+(3*1)=50 (53)+(103)+(12)+(31)=50
  • 第 4 棵树的 WPL ( 1 ∗ 2 ) + ( 3 ∗ 3 ) + ( 5 ∗ 2 ) + ( 10 ∗ 3 ) = 51 (1*2)+(3*3)+(5*2)+(10*3)=51 (12)+(33)+(52)+(103)=51

基础概念了解之后,我们来看看它们和哈夫曼树的关系。通过前面的计算发现,上图中的第 2 棵树的 WPL 值最小。无论是什么形状的二叉树,只要叶子节点是 1、3、5、10,那么 WPL 值最小也不会低于 32。所以 WPL 值为 32 的这棵二叉树就是哈夫曼树(带权路径长度最小)。

现在,我们可以给出哈夫曼树的正式定义:在含有 n 个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树称为哈夫曼树,也称为最优二叉树。

当然,哈夫曼树也不唯一,比如上图中的第 2 棵树是一棵哈夫曼树,假如把该树的权值为 1 和 3 的节点互换后得到的二叉树 WPL 依旧为 32,也是一棵哈夫曼树。所以包含相同叶子节点的哈夫曼树也许会有多种形态。

2. 哈夫曼树的构造

如果给定 n 个叶子节点,如何构造包含这 n 个叶子节点的哈夫曼树呢?我们说下几个步骤。

  • 将这 n 个节点分别作为 n 棵仅含一个节点(根节点)的二叉树,这样就构成了一个二叉树集合 F。
  • 构造一个新节点,在集合 F 中选取两棵根节点权值最小的树作为这个新节点的左右子树,这里谁左谁右顺序任意,以此构造一棵新二叉树。这个新节点的权值是左右两棵子树根节点的权值之和。
  • 从集合 F 中删除刚刚选取的两棵树,并将新得到的树加入到集合 F 中。重复上面的 2 个步骤,直到集合 F 中只剩下一棵树为止,这棵树就是哈夫曼树。

如下图所示,有权值分别为 1、2、2、4、8 的节点 a、b、c、d、e,构成了一个二叉树集合 F:

在这里插入图片描述

在集合 F 中,选取两棵根节点权值最小的树 a 和 b,当然,a 和 c 也行,因为 b 和 c 权值相同。然后构造一棵新二叉树,新二叉树根节点的权值是 1 + 2 = 3 1+2=3 1+2=3,如下图所示:

在这里插入图片描述

接着选取根节点权值为 2 和 3 的二叉树构造一棵新二叉树,其根节点的权值是 2 + 3 = 5 2+3=5 2+3=5,如图 6 所示:

在这里插入图片描述

接着选取根节点权值为 4 和 5 的二叉树构造一棵新二叉树,其根节点的权值是 4 + 5 = 9 4+5=9 4+5=9,如图 7 所示:

在这里插入图片描述

接着选取根节点权值为 8 和 9 的树构造一棵新二叉树,其根节点的权值是 8 + 9 = 17 8+9=17 8+9=17,如图 8 所示:

在这里插入图片描述

目前集合 F 中只剩下一棵树了,所以这棵树就是哈夫曼树。这棵哈夫曼树的 WPL ( 8 ∗ 1 ) + ( 4 ∗ 2 ) + ( 2 ∗ 3 ) + ( 2 ∗ 4 ) + ( 1 ∗ 4 ) = 34 (8*1)+(4*2)+(2*3)+(2*4)+(1*4)=34 (81)+(42)+(23)+(24)+(14)=34

观察上图,我们不难发现哈夫曼树的一些特性:

  • 初始给出的节点都是哈夫曼树的叶子节点。
  • 权值越大的叶子节点(比如节点 e)到根节点的路径长度越小,权值越小的叶子节点(比如节点 a、b)到根节点的路径长度越大。
  • 因为有 n 个叶子节点,并且一共进行了 n-1 次的两两合并最终得到哈夫曼树,而每合并一次多出一个节点(n-1 次合并就会多出 n-1 个节点),所以哈夫曼树的节点总数是 n + ( n − 1 ) = 2 n − 1 n+(n-1)=2n-1 n+(n1)=2n1(这个数据在编程的时候会用到)
  • 哈夫曼树中没有度为 1 的节点。注意这里,节点拥有的子树数目,叫做节点的度。因为构造哈夫曼树的过程中,每个新节点都是有左右子树的。
  • 哈夫曼树的子树仍旧是哈夫曼树。
  • 哈夫曼树并不是唯一的,但相同叶子节点的哈夫曼树的 WPL 值一定是相等的。

3. 哈夫曼树的代码实现

哈夫曼树的代码实现很简单,因为给定叶子节点权值以及叶子节点数量后,整个哈夫曼树的节点数量是可以确定的(2n-1),因此,可以创建动态数组来保存哈夫曼树。

🍑 哈夫曼树的节点

//哈夫曼树的节点
struct HFMTreeNode
{
	int weight; //权值
	int parent; //父亲(数组下标值)
	int lchild; //左孩子
	int rchild; //右孩子
};

用一个数组来保存哈夫曼树

//哈夫曼树
class HFMTree
{
private:
	HFMTreeNode* m_data;
	int m_length; //记录当前树有多少个节点【数组中多少个节点被使用了】
};

🍑 构造函数

HFMTree(int nodecount, int* pweight)
{
	m_length = nodecount; //节点个数
	int iMaxNodeCount = 2 * m_length - 1; //哈夫曼树的节点总数是2n-1(n代表哈夫曼树的叶子节点数量)
	m_data = new HFMTreeNode[iMaxNodeCount]; //2n-1个节点的哈夫曼数组

	for (int i = 0; i < iMaxNodeCount; ++i)
	{
		//-1标记未被使用
		m_data[i].parent = -1;
		m_data[i].lchild = -1;
		m_data[i].rchild = -1;
	}
	for (int i = 0; i < m_length; ++i)
	{
		m_data[i].weight = pweight[i];
	}
}

在 main 主函数中,增加下面的测试代码。

int main()
{
	int weighLst[] = { 1,2,2,4,8 }; //权值列表(数组),数组中的数据顺序无所谓	
	int sz = sizeof(weighLst) / sizeof(weighLst[0]);
	HFMTree hfmtobj(
		sz, //权值列表中元素个数
		weighLst //权值列表首地址
	);
	hfmtobj.CreateHFMTree(); //创建哈夫曼树
	hfmtobj.preOrder(hfmtobj.GetLength() - 1); //遍历哈夫曼树,参数其实就是根节点的下标(数组最后一个有效位置的下标)

	return 0;
}

🍑 析构函数

~HFMTree() //析构函数
{
	delete[] m_data;
}

🍑 创建哈夫曼树

开始创建哈夫曼树

void CreateHFMTree()
{
	int idx1 = 0;
	int idx2 = 0;

	int iMaxNodeCount = 2 * m_length - 1; //2n-1是整个哈夫曼树的节点数量
	int initlength = m_length;
	for (int i = initlength; i < iMaxNodeCount; ++i)
	{
		SelectTwoMinValue(idx1, idx2);
		m_data[i].weight = m_data[idx1].weight + m_data[idx2].weight; //新节点的权值等于左右孩子
		m_data[i].lchild = idx1;
		m_data[i].rchild = idx2;

		m_data[idx1].parent = i;
		m_data[idx2].parent = i;

		m_length++; //SelectTwoMinValue()函数要用到该值
	}
	return;
}

选择两个根权重最小的节点,通过引用返回这个节点所在的下标

void SelectTwoMinValue(int& rtnIdx1, int& rtnIdx2)
{
	int minval1 = INT_MAX;
	int minval2 = INT_MAX;

	//找最小值
	for (int i = 0; i < m_length; ++i)
	{
		if (m_data[i].parent == -1) //父标记未被使用
		{
			if (minval1 > m_data[i].weight)
			{
				minval1 = m_data[i].weight; //记录最小值
				rtnIdx1 = i; //记录下标
			}
		}
	} 

	//找第二个最小的值
	for (int i = 0; i < m_length; ++i)
	{
		if (m_data[i].parent == -1 && i != rtnIdx1) //注意&&后的条件,目的是把第一个找到的最小权值的节点排除
		{
			if (minval2 > m_data[i].weight)
			{
				minval2 = m_data[i].weight; //记录最小值
				rtnIdx2 = i; //记录下标
			}
		}
	}
	return;
}

🍑 获取树中节点数量

int GetLength()
{
	return m_length;
}

🍑 前序遍历

void preOrder(int idx)
{
	if (idx != -1)
	{
		cout << m_data[idx].weight << " ";
		preOrder(m_data[idx].lchild);
		preOrder(m_data[idx].rchild);
	}
}

🍑 测试函数

主函数中,增加下面的测试代码。

int main()
{
	int weigh[] = { 1,2,2,4,8 }; //权值列表(数组),数组中的数据顺序无所谓	
	int sz = sizeof(weigh) / sizeof(weigh[0]);
	HFMTree hfmt(
		sz, //权值列表中元素个数
		weigh //权值列表首地址
	);
	hfmt.CreateHFMTree(); //创建哈夫曼树
	hfmt.preOrder(hfmt.GetLength() - 1); //遍历哈夫曼树,参数其实就是根节点的下标(数组最后一个有效位置的下标)

	return 0;
}

执行结果如下。这个结果是对生成的哈夫曼树进行前序遍历(根左右)的结果。

在这里插入图片描述

4. 代码分析

通过对上述代码的阅读和分析不难发现,利用 1、2、2、4、8 这 5 个权值作为叶子节点创建的哈夫曼树,用数组保存后,数组内容应该如下图所示:

在这里插入图片描述

如果我们从上到下排列观察可能更加明显,如下图所示:

在这里插入图片描述

我们也很容易知道哈夫曼树的构造过程直至最后得到所需的哈夫曼树,结果如下图所示:

在这里插入图片描述

利用前序遍历(根左右)对上图的最后一幅图中显示的树进行遍历,得到的结果(权值)的顺序正是 17、8、9、4、5、2、3、1、2。

5. 总结

哈夫曼树的概念:在含有 n 个带权叶节点的二叉树中,其中带权路径长度(WPL)最小的二叉树,也叫做最优二叉树。想要根据给定的 n 个叶子节点来构造哈夫曼树,要经历 4 个步骤:

  • 将这 n 个节点分别作为 n 棵仅含一个节点(根节点)的二叉树,这样就构成了一个二叉树集合 F。
  • 构造一个新节点,在集合 F 中选取两棵根节点权值最小的树作为这个新节点的左右子树(谁左谁右顺序任意)构造一棵新二叉树。这个新节点的权值是左右两棵子树根节点的权值之和。
  • 从集合 F 中删除刚刚选取的两棵树,并将新得到的树加入到集合 F 中。
  • 重复上面的 2 个步骤,直到集合 F 中只剩下一棵树为止,这棵树就是哈夫曼树。
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐