网页压缩算法deflate揭秘

网页数据或者图像数据往往比较大,对于传输和存储都不太友好,我们在请求静态资源时能看到Request Headers的accept-encoding通常会包含gzip, deflate, br三种格式,其中deflate也是PNG图片的核心压缩算法,它主要是由LZ77算法与哈夫曼编码(Huffman Coding)组成的一个无损数据压缩算法,下面分别介绍它们的基础原理与JS部分算法实现。

1. LZ77 算法

1.1 算法简介

LZ77算法是由 Lempel-Ziv 在1977发明的,其核心思想是比对相邻区域内的数据,获取数据中尽可能多的连续重复数据。然后使用坐标加长度的方式进行替换。LZ77本身是较为简单却非常高效的压缩算法,为了获得更大的压缩比例,它与Base64进行配合使用。我们在学习LZ77时必须了解三个关键词:

滑动窗口: 指固定长度范围(窗口大小)的地址区间,窗口内存放输入流前的多个数据,每次循环窗口向右移动。

缓冲区: 预先读取一定长度的数据内容到缓冲区,用作与窗口数据进行对比。每次循环时与滑动窗口一起向右移动

字典: 滑动窗口内的字符可以组成多个字符串,这些字符串的组合称为字典。在滑动窗口跟随指针滑动过程中,字典跟随滑动窗口不断的改变。当缓冲区内的字符串能与窗口的字符串相匹配时则进行标记,被标记的字符串通过起始坐标,距离及下一个字符替换,所以通常我们将长度大于3个匹配的字符串才放入字典。 例如ABCD中包含ABC,BCD,ABCD这三个。

1.2 LZ77压缩

1.2.1 压缩原理

假如现在有一串字符ABABCBABABCAD,设置缓冲区大小为4,滑动窗口大小为8个字节。在初始时前四个字符ABAB进入缓冲区,滑动窗口为空,此时记录下一个字符A

avatar

当窗口向右移动两个字符时,窗口内的AB和缓冲区的AB匹配,则进行标记替换,其中6表示AB在滑动窗口中的位置,2表示匹配到的字符长度,C为下一个字符。此时指针增加匹配长度2

avatar

继续移动一个字符,发现匹配到BAB,则继续添加匹配标识:

avatar

以此类推,直到匹配结束:

avatar

1.2.2 数据预处理

我们通常存储或者传输过程中为16进制数据或者字符,在编码时需要转换成对应的ASICII码:

const str = 'ababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcad'
const arr = str.split('').map(char => char.charCodeAt())

 得到结果:
[ 97,  98,  97,  98,  99,  98,  97, 98, 97, 98, 99, 97, 100,  97,  98,  97,  98,  99,  98, 97, 98, 97, 98, 99 ...]
1.2.3 滑动窗口压缩

压缩的比例,压缩的速度与滑动窗口和缓冲区的大小密切相关,通常被压缩的文件比较大,所以需要进行分片压缩,通常单片的大小为64K,片与片之间不会进行窗口比较。
扫描字符串开始编码, 移动编码原理见1.1.2

	var X = 1<<15 /* 距离用15位二进制表示*/
	for(var i=0;i<str.length;i++)
	{
		var max_len=0, max_off=0;// 最长匹配和位置
		if( i< str.length-3) {
			var d1 = String.fromCharCode(str[i],str[i+1],str[i+2]); //三字节字符串加入索引
			if(dic[d1]) {
				dic[d1].push(i);
			}else{
				dic[d1] = [i];
			}
			for(var k=dic[d1].length-2; k>=0; k--) {
				var j = dic[d1][k];
				if(j <= i - X) {
					break;
				}
				var len = strcmp(str, i, j); //在索引位置处查找最长匹配
				if(len > max_len) {
					max_len = len;
					max_off = i - j;
				}
			}
		}
		...
	}

实现 在字符串 str 中 比较a,b位置处的最长匹配:

	var L = 32 /* 匹配长度用5位二进制*/;
	function strcmp(str, a, b) {
		var i = 0, sl = str.length;
		for(;i< L && a+i<sl && b+i<sl;i++)
			if(str[a+i] != str[b+i])
				return i;
		return i<L?i:L;
	}

上面代码中,是否匹配的标识受分片大小X和 计算max_len得出。在整个计算过程中,需要存储的数据有三种:

  • 匹配标志:用于表示是否为匹配成功
  • 下一位字符:无论匹配成功或者失败都会存储下一位数据
  • 起始位置:表示匹配数据在滑动窗口的地址索引
  • 匹配长度:表示在进行压缩时替换的内容长度

由于一个字符的存储通常需要2个字符,这里使用base64进行编码压缩,具体的原理在前文已经讲过。所以这里采用3个字节存储4 位 base64 编码。

		if(max_off < X && max_len > 3) { // 找到最长匹配
			write_bits(r, 1,1);           // 写入标识
			write_bits(r, max_off, 15);   // 写入位置
			write_bits(r, max_len-3, 5);  // 写入长度
			i+= max_len-1;
		}else{
			write_bits(r, 0,1);          // 没找到 写入标识和原字符
			write_bits(r, str[i], 8);
		}
  • 滑动窗口移动过程中,当未匹配到数据时:
    数据在内存中存储时采用9位二进制表示一位,第一位是标识位,用于表示是否匹配到可以替换的数据,未匹配到时为0,后面的8位二进制表示一个字符。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BrS2Do6W-1622697528254)(https://gitlab.aihaisi.com/qiexr/docs/uploads/e196e7275ac58c75daddf9830963145b/pic1.png)]

  • 当匹配到数据时:
    数据标识位第一位改成1,紧接着15位二进制用来表示滑动窗口中替换字符的偏移地址,5位二进制表示替换数据的长度。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-H40bsQuk-1622697528255)(https://gitlab.aihaisi.com/qiexr/docs/uploads/48451ceeb3c8e5acd0710a0781aeb1f9/pic2.png)]

  • 当超过3个字节时(24位二进制)时,自动开辟3个字节的空间进行连续存储。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fBtrnrHw-1622697528256)(https://gitlab.aihaisi.com/qiexr/docs/uploads/cb13c4f1837e65d2a4df81f98dfdc833/pic3.png)]

1.2.4 base64压缩

将得到的压缩数据继续进行base64压缩

const base64Encode = (r)=>{
	var z = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=".split("");
	var rs = [];
	for(var i=0;i<r.length;i++) {
		rs[rs.length] = z[(r[i]>>18) & 0x3f];
		rs[rs.length] = z[(r[i]>>12) & 0x3f];
		rs[rs.length] = z[(r[i]>> 6) & 0x3f];
		rs[rs.length] = z[(r[i]    ) & 0x3f];
	}
	return rs.join("");
}

得到的最终数据即LZ77压缩结果:AAASMJiMJiMZigAYRhMkAG9gCfsAg9gGjsBBMAAA。 原字符串长度为156,压缩后长度为40,重复字符串越多,连续重复内容越长,压缩比越高。

1.3 数据解压

LZ77 算法解压是压缩的逆向过程,大概分为base64解密,滑动窗口数据还原,字符还原等过程。

1.3.1 base64解码
const base64Decode = (s) => {
	var i,j, ch, z ={}, w = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='.split('');
	for(i=0;i<64;i++) // base64解码
		z[w[i]] = i;
	var r = [], x=1, y=0; // 将BASE64转为整型数组
	for(i=0,j=0;i<s.length;i+=4,j++)
		r[j] = (z[s.charAt(i)]<<18) | (z[s.charAt(i+1)]<<12) | (z[s.charAt(i+2)]<<6) | (z[s.charAt(i+3)]);
}
1.3.2 滑动窗口数据还原

读取压缩后的二进制数据,如果遇到标志为0则作为普通字符解析,如果为1,则连续解析2位,分别为滑动窗口偏移位置和对应的数据长度。

for(;;) {
		var c = read_bits(r, 1); // 读取一位标志位
		if(c<0)break;
		if(c==0){
			s[s.length] = read_bits(r, 8);//读取一个字节
			j++;
		}else{
			var off = read_bits(r, 15);// 位置
			var len = read_bits(r, 5)+3; // 长度
			for(var k=0;k<len;k++) { // copy 
				s[s.length]= s[j-off];
				j++;
			}
		}
	}
1.3.3 还原成字符串

通过上面的操作,我们已经得到了长度为156的数组,每位数据可以还原成一个字符。通过String.fromCharCode转换成原始字符串

	function utf8_to_str(s) {
		var r = [], fc = String.fromCharCode;
		for(var i=0;i<s.length;i++) {
			var ch = s[i];
			if(ch< 0x80)
				r[r.length] = fc(s[i]);
			else if(ch < 0xE0) {
				r[r.length] = fc( ((s[i]&0x1f) << 6) | (s[i+1] & 0x3f));
				i++;
			}else if(ch < 0xF0) {
				r[r.length] = fc( ((s[i] & 0xF) << 12) | ((s[i+1] & 0x3f)<<6) | (s[i+2] & 0x3f));
				i+=2;
			}
		}
		return r.join('');
	}

解压得到的字符串为:ababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcadababcbababcad,与压缩前数据一致。

2. 哈夫曼编码

2.1 算法简介

哈夫曼树(Huffman Tree),又称最优二叉树,是一类带权路径长度最短的树。假设有n个权值{w1,w2,…,wn},如果构造一棵有n个叶子节点的二叉树,而这n个叶子节点的权值是{w1,w2,…,wn},则所构造出的带权路径长度最小的二叉树就被称为哈夫曼树。

2.2 算法实现

我们现在有一串字符串 “hello world”。计算机中存储这段字符是通过ASCII码进行存储。

const str = "hello world";
const arr = str.split('').map(char => String.charAt(char))

得到该字符串在内存中存储内容为:

 104, 101, 108, 108, 111,  32, 119, 111, 114, 108, 100 

每一个字符在内存中占用两个字节,"hello world"这个字符串则一共占用了22个字节。 下面我们开始进行数据压缩

哈夫曼编码的思想是提取内容中频率最高的字符,使用最短的编码来替代。我们称每个元素出现的频率为权重,计算上面字符的权重并且排序。

// 获取权重
const getWeight = (arr) => {
	const obj = {};
	arr.forEach(char => {
		obj[char] = obj[char] ? obj[char] + 1 : 1
	})
	console.log("wight:", obj)
	return obj;
}
// 按权重排序
const sort = (wightObj) => {
	let arr = Object.keys(wightObj).map(key => ({
		data: key,
		wight: wightObj[key]
	}))
	let sortArr = arr.sort((a, b) => a.wight > b.wight ? 1 : -1)
	console.log(sortArr)
}

// 排序计算结果:
[
  { data: '119', wight: 1 },
  { data: '114', wight: 1 },
  { data: '104', wight: 1 },
  { data: '101', wight: 1 },
  { data: '100', wight: 1 },
  { data: '32', wight: 1 },
  { data: '111', wight: 2 },
  { data: '108', wight: 3 }
]
2.1.1 构建哈夫曼树

上面的字符 l 使用二进制的 1来替代,则内存占用仅仅为原来的 1/16 。但是其他字符不能再用二进制的1来表示,而且在解码的时候如果二进制11表示了另一个字符,则无法区分 1 和 11 的情况。为了解决上述问题,我们构造哈夫曼树。

哈夫曼树也叫做最优二叉树,构建哈夫曼树的两大原则:

  • 权重越高的点离根节点越近,权重越低的点离根结点越远
  • 同一父节点对应的两个字节点,左边节点的权重低于右边节点。

在上面这组权重数据: 1,1,1,1,1,1,2,3。 拼装的结果如图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EfsiPxfn-1622697528257)(https://gitlab.aihaisi.com/qiexr/docs/uploads/6296ea23a05d07540cfcbb20acc7e487/Huffman%E4%BA%8C%E5%8F%89%E6%A0%91__1_.png)]

代码实现哈夫曼树构建:

// 制造hafuman树
const makeTree = (arr) => {
	while (arr.length > 1) {
		// 取数组中最小的两个
		let [node1, node2] = arr.splice(0, 2);
		let node = {
			wight: 0 + node1.wight + node2.wight,
			children: [node1, node2]
		}
		arr.push(node);
		arr = arr.sort((a, b) => a.wight > b.wight ? 1 : -1)
		console.log(arr.length)
	}
	return arr[0];
}
2.1.2 哈夫曼对照表

通过哈夫曼树进行递归遍历,找到对应节点时所经过的所有路径即最优编码,最终将所有元素与路径的一一关系组成哈夫曼对照表。

const map = {};
const makeMap = (node, path) => {
	if (node.children) {
		node.children.forEach((child, index) => makeMap(child, path + index))
	} else {
		map[node.code] = path
	}
}

得到结果:

{
	"32": "1100",
	"100": "1101",
	"101": "001",
	"104": "000",
	"108": "10",
	"111": "111",
	"114": "011",
	"119": "010"
}

通过哈夫曼对照表即可进行数据加密:

const encode = (charList) => {
	const wightObj = getWeight(charList)// 计算权重
	const nodeList = sort(wightObj)// 获取节点列表
	const tree = makeTree(nodeList);//获取哈夫曼树
	makeMap(tree, '');//获取对照表

	let str = '';
	charList.forEach(char => str += map[char])
	return str;
}

得到加密后的结果为: 00000110101111100010111011101101。共32位二进制,占用4个字节。实际文件压缩或者网络传输过程中,除了被压缩的内容还包含哈夫曼对照表等其他数据,一般情况当文件小于200字节时压缩的结果反而会比源文件更大。

2.3 哈夫曼解码

获取到哈夫曼对照表及编码数据之后进行解码是非常容易的,设置临时变量temp作为移动窗口,与哈夫曼对照表的值进行一一对比,如果对比失败则temp添加一位继续对比。 如果其中某一位数据出错,则直接会对比失败,一般都会对数据进行校验。通常会用较为简单的循环冗余校验CRC。

// 哈夫曼对照表 键值对互换位置
const traversal = (map) => {
	let obj = {}
	Object.keys(map).forEach(key => {
		obj[map[key]] = key;
	})
	return obj;
}
// 解码
const decode = (code) => {
	let str = '', temp = '';
	let decodeMap = traversal(map);
	for (var i = 0; i < code.length; i++) {
		temp += code[i];
		if (decodeMap[temp]) {
			str += String.fromCodePoint(decodeMap[temp]);
			temp = ''
		}
	}
	return str;
}

最终得到的解码结果:hello world,与原字符一致。

defleate算法先通过LZ77算法压缩,然后在用哈夫曼算法进行压缩。两种算法都有很多变体版本,但是整体思路大同小异,值得我们学习。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值