啃论文俱乐部—统计压缩编码机理分析

系统 OpenHarmony
本文我们尝试介绍了无损压缩和有损压缩两种压缩类型,以及数据压缩中的一些主要概念、算法和方法,并讨论了他们的不同应用和工作方式,然后我们探讨了两个主要的日常应用;以JPEG为例进行图像压缩,以MPEG为例进行了视频压缩。

​想了解更多关于开源的内容,请访问:​

​51CTO 开源基础软件社区​

​https://ost.51cto.com​​。

【技术DNA】

本期涉及技术分布如下:

【智慧场景】

**********

********************

********************

********************

********************

********************

********************

********************

********************

********************

********************

********************

********************

********************

********************

*****************

*****************

********************

场景

自动驾驶 / AR

语音信号

流视频

GPU 渲染

科学、云计算

内存缩减

科学应用

医学图像

数据库服务器

人工智能图像

文本传输

GAN媒体压缩

图像压缩

文件同步

数据库系统

通用数据

系统数据读写

技术

点云压缩

‎稀疏快速傅里叶变换‎

有损视频压缩

网格压缩

动态选择压缩算法框架

无损压缩

分层数据压缩

医学图像压缩

无损通用压缩

人工智能图像压缩

短字符串压缩

GAN 压缩的在线多粒度蒸馏

图像压缩

文件传输压缩

快速随机访问字符串压缩

高通量并行无损压缩

增强只读文件系统

开源项目

​Draco​​​ / 基于深度学习算法/​​PCL​​​/​​OctNet​

​SFFT​

​AV1​​​ / ​​H.266编码​​​ / ​​H.266解码​​​/​​VP9​

​MeshOpt​​​ / ​​Draco​

​Ares​

​LZ4​

​HCompress​

​DICOM​

​Brotli​

​RAISR​

​AIMCS​

​OMGD​

​OpenJPEG​

​rsync​

​FSST​

​ndzip​

​EROFS​

开篇简要

  • 本期着重对传统经典压缩算法的分析与理解,从认识到实现的角度展开描述,主要涉及了 Shannon-Fano、Huffman、算术编码等编码方案。除此之外,还附带了小组新人对于数据压缩初识的部分。

统计编码是什么

  • ​统计编码(statistical compression),也可称为熵编码,其出现是为了弥补传统VLC可变长编码在编码时须进行特定方法匹配​的痛点,原因是VLC有时并非能找到最佳选择​,相较来说,统计编码是一类只需依据每个字符出现的次数 / 概率​,便可自生成一套高效编码的方案,正因如此,它们具备显著的通用性。
  • 统计编码的首要目的是,在信息和码之间找到明确的一一对应关系,从而保证在解码时准确无误地再现回来,或极接近地找到相当的对应关系,同时将失真率控制在一定范围内。但无论借助什么途径,核心总是要把平均码长 / 码率压低到最低限度。

分类

  • 四种常用的统计编码有:香农·范诺编码、Huffman 编码、算术编码以及 ANS,其中,香农·范诺编码称得上是现代第一个压缩编码,具有相当的历史意义。

香农·范诺编码

诞生背景

  • 早在香农(Claude Elwood Shannon)撰写《通信的数学理论》一文,并试图提出且证明一种可以按符号出现概率实现高效编码,以最大程度减少通信传输所需信道容量​的方法之前,时任 MIT 教授的罗伯特·范诺( Robert Mario Fano )也已对这一编码方法展开了相关研究。范诺不久后将其以技术报告的形式独立进行了发表,因而,这种编码被并称为香农·范诺编码( Shannon–Fano coding ),它是现代熵编码与数据压缩技术的雏形。即便它不是最佳的编码方案,但在有些时候仍会使用。

简单认识

  • 香农·范诺编码准确的说是一种前缀码​技术,所谓前缀码,是指编码后的每个码字都不会再作为其他码字的前缀出现,这为后续解码操作​时字符的唯一确定提供了条件。
  • 以EBACBDBEBCDEAABEEBDDBABEBABCDBBADBCBECA这样一串字符串为例,我们首先需要统计并计算其中每个字符的出现概率。

字符

A

B

C

D

E

计数

7

14

5

6

7

概率

0.179

0.359

0.128

0.154

0.179

  • 下一步,将它们按照概率大小降序排

符号

A

B

C

D

E

计数

7

14

5

6

7

编码

01

00

111

110

10

然后,找到这样一个两字符之间的最佳分割点,它使两侧概率和尽可能接近,也就是差值最小,反复进行下去:

经以上操作分组完毕后,五个字符已位于整棵树的最外层叶子处,在每个分支处的左半部分树干标上 0,右半部分树干标上 1。最后,从树根起始,沿树干依次遍历至最外层的叶子节点,便得到了每个字符的香农·范诺码。由于每个树干的 “0”、“1” 二进制码独一无二,所以最终的编码彼此不会重复。

符号

A

B

C

D

E

计数

7

14

5

6

7

编码

01

00

111

110

10

  • 出现概率较高的字符被编码成两位,概率较低的则被编码成三位。由此,我们便可计算出每个字符平均所需的编码位数:

  • 结果表明,每个字符平均只需约2.28 个位即可保证在信息不丢失的情况下完美表示。当然,实际在计算机中,是无法把位分割成小数的,2.28 需二次近似于 3。
  • 然而迄今为止,仍没有任何一种编码方案能够保证在通用情况下达到香农熵值。香农与范诺两位杰出科学家为后世压缩技术的发展开了一个好头。

哈夫曼编码

  • 香农·范诺编码固然强大,但它并非总是能产生最优前缀码,所以只能取得一定的压缩效果,离真正实用的压缩算法还相去甚远。为此,在其基础上演化出的第一个称得上实用的压缩编码哈夫曼编码( Huffman Code ),由大卫·哈夫曼(David Albert Huffman)于 1952 年的博士论文《最小冗余度代码的构造方法( A Method for the Construction of Minimum Redundancy Codes )》中提出。哈夫曼编码同样依据字符使用的频率来分配表示字符的码字,不同的是,频繁出现的字符被分配较短的编码,出现不是那么频繁的字符则会被分配较长的编码。
  • 哈夫曼编码效率高、运算速度快、实现方式灵活。自 Windows10 起所支持的 CompactOS 特性,便是利用哈夫曼压缩来减小操作系统体积的一项技术。直至今天,许多《数据结构》教材在讨论二叉树时仍绕不开哈夫曼这样一个话题,不过,比起算法本身,最为人们津津乐道的还是发明算法的过程。

青出于蓝而胜于蓝

  • 1951 年,哈夫曼作为一名 MIT 的学生,正在上一门由导师范诺教授的《信息学》课程。不过,既然正式上了一门课,那期末考核是在所难免的。范诺出了道选择题,给学生们两种通过考核的方式:第一选项是夜以继日地照常复习,最后参与期末考试;第二选项是完成期末论文,也被叫做大作业。同学们普遍认为,在 MIT 这样一个地方,考试的难度可不是个小儿科,尽管如此,比起要求逻辑严谨、证明充分的学科论文来说,大多数同学还是更倾向于去考试。哈夫曼选择了不随波逐流,他认为后者相对于他而言更简单,又能逃脱考试的浩劫,何乐而不为?
  • 不出所料,最终只有哈夫曼一人选择了独自开辟新路径 —— 范诺限定了这样一个课题:“给定一组字母、数字或其他各种符号,设法找到其最有效的二进制编码”。实际上,这即是范诺与香农等大科学家所正在研究的内容,是信息论与数据压缩领域尚未解决的难题,但他并未告诉学生们这一点。
  • 结合所学知识,哈夫曼知道“最有效”一词的意思是“编码长度足够短”。起初,哈夫曼认为这个问题应该不是什么难事,渐渐地,他发现事情其实远并非他想得那样。经过几个月的苦思冥想与文献查找,哈夫曼确实设计出了许多算法,但令人沮丧的是,没有一种算法可以被证明达到了“最有效”的条件…… 到了学期结束的前一周,仍旧没有取得任何实质性突破,哈夫曼开始为之感到疲倦。迫于即将结课的压力,他不得不撂掉手头上这已不可能完成的任务,回头转向为常规考试的准备。一天早餐后,就在哈夫曼随手抓起桌上的研究笔记将其扔进废纸篓之时,一切突然明朗了起来,他说那是他生命中最奇特的时刻。这样一个困扰领域专家许久的难题,被一个年仅 25 岁的小伙子当作课程作业给解决了。
  • 哈夫曼后来回忆道,如果他知道他的老师和信息学之父彼时也都在努力解决这个问题,他可能永远也不会想到去尝试。他很庆幸自己在正确的时间做了正确的事,庆幸他的老师在那时没有告诉他还有其他更优秀的人也曾在这个问题上苦苦挣扎。

编码方法

  • 哈夫曼编码是分组编码、可变长编码,是依据各字符出现的概率构造码字的。制作码表的基本原理是基于二叉树的编码思想:所有可能的输入字符在哈夫曼树上对应为一个叶子节点,节点的位置就是该字符的哈夫曼编码。其次,基于字符串中每个字符的累计出现次数进行编码,出现频率越高得到的编码越短。特别的,为了构造出唯一可译码,这些叶子节点都是哈夫曼树上的终极节点,不再延伸,不再出现前缀码。可以感受到,哈夫曼编码与香农·范诺编码的实现过程极其类似,但还是有些许不同,哈夫曼编码的大体步骤如下:
  1. 将信源消息符号按其出现的概率大小依次排列
  2. 取两个概率最小的字符分别配以 0 和 1 两个码元,并将这两个概率相加作为一个新字符的概率,与未分配二进制码的字符一起重新排队
  3. 对重排后的两个概率最小的字符重复步骤 2 的过程
  4. 不断重复上述过程,直到最后两个字符被配以 0 和 1 为止
  5. 从最后一级开始,向前返回得到各个信源符号所对应的码元序列,即相应码字

举个例子

  • 让我们浅试一下,现在有一串由 5 个不同字符 ( A, B, C, D, E ) 组成的字符串序列:

BACAB BACDA ABBBE

  • 步骤一:根据上述字符串,统计各个字符出现的次数并排序:

字符

B

A

C

D

E

次数

6

5

2

1

1

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 步骤二:把次数最少的两者放在一起并相加,同时将结果按顺序重新放入队列。显然,是 D: 1, E: 1, 1 + 1 = 2。

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 步骤三:继续抽出两个值最小的卡片,重复上一步并以此类推……

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 步骤四:现在,我们完成了步骤二的迭代,一棵二叉树的模型自然形成了,下面要做的就是分别在每个分支的左树干上标 0、右树干上标 1。

  • 步骤五:从树根到每片叶子依次遍历,将经过的 0、1 记录下来,即可得到哈夫曼码表。

字符

B

A

C

D

E

次数

6

5

2

1

1

编码

1

00

010

0110

0111

  • 所以,原本的字符串BACABBACDAABBBE用哈夫曼码表示为100010001100010011000001110111,符合字符出现次数越多编码长度越短的标准。

一些性质

  • 与香农·范诺编码相比,哈夫曼编码的平均码长更小,编码效率高,信息传输速率大。所以在压缩信源信息率的实用设备中,哈夫曼编码还是比较常用的。哈夫曼方法得到的码并非唯一,不唯一的原因有两点:
  1. 每次对信源进行压缩时,最后分配给两个概率最小的字符以 0 和 1 可以是任意的,由此可以得到不同的哈夫曼码,但不会影响码字的长度。
  2. 对信源进行缩减时,两个概率最小的字符合并后的概率与其他信源字符的概率相等时,它们在压缩信源中放置的前后相对次序可以是任意的,由此也会得到不同的哈夫曼码。此时将影响码字的长度,一般将合并的概率放在上面,这样可获得较小的码长方差。
  • 哈夫曼码是用概率匹配方法进行信源编码。它有两个明显特点:一是哈夫曼码的编码方法保证了概率大的符号对应于短码,概率小的符号对应于长码,充分利用了短码;二是压缩信源的最后二个码字总是最后一位不同,从而保证了哈夫曼码是即时码。
  • 编码平均长度等式:

  • 对于哈夫曼编码的基本理论,我们差不多都清楚了,下面尝试如何用代码去实现它。

算法实现

  • 哈夫曼算法的模型基于二叉树,树的节点分为终端节点(叶子节点)与非终端节点(内部节点)。为了达成一个在二叉树下更通用、标准的定义,我们将字符出现的频率抽象为权重。初始第一轮迭代时,每个最底层的节点都是叶子节点,包含两个字段:字符与权重;在第二轮及以后的迭代中,产生的每个节点都是内部节点,包含三个字段:权重、指向左子节点的链接与指向右子节点的链接。
  • 因此,首先需要具备的两个必要元素便是内部节点与叶子节点,同时,它们又都包含权重这一相同字段,所以我们先定义基类 INode:
// C++实现
class INode
{
public:
const unsigned weight; // 权重
virtual ~INode() {}
protected:
INode(unsigned weight) : weight(weight) {}
};
  • 为了避免不必要的干扰,将 INode 的构造函数声明为 protected。
  • 叶子节点与内部节点的定义即是继承 INode 后,把剩下的另外字段补充上去,通过调用父类 INode 的构造函数生成权值。
// 叶子节点
class LeafNode : public INode
{
public:
const char c; // 字符
LeafNode(unsigned weight, char c) : INode(weight), c(c) {}
};
  • 内部节点中指向左右子节点的链接毫无疑问使用指针来实现,且指向 INode 类型。自身权值则通过将左右子节点的权值相加得到。此外,还需显式声明一个析构函数,以便在后续操作中自动释放空间、防止野指针与内存泄漏。
// 内部节点
class InternalNode : public INode
{
public:
INode * const left; // 左指针
INode * const right; // 右指针
InternalNode(INode * leftChild, INode * rightChild) : INode(leftChild->weight + rightChild->weight), left(leftChild), right(rightChild) {}
~InternalNode()
{
delete left;
delete right;
}
};
  • 基本元素现已齐全,继续进行下一步操作。上一小节我们说到,静态 Huffman 算法需要对数据进行两次遍历,第一次是得到概率表并构建树,第二次才进行字符编码。先来看第一次,在构建树之前必须提供一套排好序的概率表,假设现在有这样一串字符DATACOMPRESSION,我们如何在计算机中用复杂度较低的算法统计并排序?肯定不能用眼睛盯着来数了。
  • 因为总字符数是一定的,所以用字符出现的次数,即频数,来代替概率是等效的。统计频数很简单 —— 声明一个容量足够保存任意字符的数组,将遍历到的每个字符作为参数传递给这个数组,由于字符在现代计算机中均以ASCII、Unicode 等编码集存储,所以每当遇到一个字符时就在数组中对应字符编码数值的位置递增 1 即可,省去了记录下标的麻烦。
// 生成频数表
#define CAPACITY 1<<CHAR_BIT // 得到最大编码值,保证在不同平台的通用性
char * ptr = "DATACOMPRESSION"; // 声明字符串DATACOMPRESSION
unsigned frequencies[CAPACITY] = {0}; // 声明并初始化数组
while (*ptr != '\0') // 依次在每个字符对应于数组的位置中递增1
++frequencies[*ptr++];
  • 经过一番操作后,得到的数组状态如下,下标反映指针所指位置:

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 尽管浪费了很多未被填充的空间,但这点数量级的浪费实际上微不足道,况且填充的数据越多利用率也越高。
  • 接下来,采用优先级队列 priority_queue 数据结构来构建二叉树是不二选择,既满足存储节点序列,又可自动排序,如此,事先也就不用再给频数表排序了。现在,封装函数 BuildTree,只需唯一形参 frequencies[CAPACITY]:

// 构建二叉树
INode* BuildTree(const unsigned (&frequencies)[CAPACITY])
{
struct NodeCmp // 声明仿函数用于priority_queue排序
{
bool operator()(const INode * lhs, const INode * rhs) const { return lhs->weight > rhs->weight; }
};
priority_queue<INode*, vector<INode*>, NodeCmp> tree; // 得到对象tree

for (unsigned i = 0; i < CAPACITY; ++i) // 构造叶子节点,返回地址到tree并排序
if (frequencies[i] != 0)
tree.push(new LeafNode(frequencies[i], static_cast<char>(i)));

while (tree.size() > 1) // 不断向上构造内部节点,直至tree中只剩树根
{
INode * leftChild = tree.top();
tree.pop();

INode * rightChild = tree.top();
tree.pop();

INode * parent = new InternalNode(leftChild, rightChild);
tree.push(parent);
}
return tree.top();
}

  • 得到 priority_queue 的实例 tree 之后,便可开始遍历频数表,将权值不为 0 的字符连同权值一起以叶子节点类型对象存进 tree,并会按权值递增的顺序排列。完毕后,循环依次取出队头前两个最小的叶子节点记录地址,生成上层内部节点再入队重新排序,最终返回树根地址。
  • 一切就绪,终于可以给字符编码了!字符编码两要素 —— 字符与码,一一对应,符合映射关系,用 vector<bool> 序列容器存储码、map 关联容器存储键值当是再好不过了。仍用一个函数实现此功能,需要三个参数:根节点地址、目的编码、map 容器。在函数体中,借助 dynamic_cast 类型识别符判断节点类型从而执行不同语句。若为内部节点,则在每层通过之前构建的二叉树指针划分为两路,左路添 0 ,右路添 1,再分别递归调用本身而进到下一层迭代;若为叶子节点,则说明已经到达我们要编码的字符处,于是插入<字符, 编码>键值对到 map 中。
// 搜索二叉树并编码
using HuffCode = vector<bool>;
using HuffCodeMap = map<char, HuffCode>;
void GenerateCodes(const INode * node, const HuffCode& prefix, HuffCodeMap& outCodes)
{
if (const InternalNode * in = dynamic_cast<const InternalNode*>(node)) // 验证是否为内部节点
{
// 划分左路
HuffCode leftPrefix = prefix;
leftPrefix.push_back(false);
GenerateCodes(in->left, leftPrefix, outCodes);
// 划分右路
HuffCode rightPrefix = prefix;
rightPrefix.push_back(true);
GenerateCodes(in->right, rightPrefix, outCodes);
}
else if (const LeafNode * lf = dynamic_cast<const LeafNode*>(node)) // 验证是否为叶子节点
outCodes[lf->c] = prefix; // 插入键值对
}
  • 至此,编码的整体流程我们已经基本实现了,接下来应对其进行测试、验证结果,用例如下:
#include <algorithm>
#include <cctype>
#include <climits>
#include <iostream>
#include <iterator>
#include <map>
#include <queue>
#include <string>
#include <vector>
#define CAPACITY 1<<CHAR_BIT
using namespace std;
using HuffCode = vector<bool>;
using HuffCodeMap = map<char, HuffCode>;
class INode
{
public:
const unsigned weight;
virtual ~INode() {}
protected:
INode(unsigned weight) : weight(weight) {}
};
class InternalNode : public INode
{
public:
INode * const left;
INode * const right;
InternalNode(INode * leftChild, INode * rightChild) : INode(leftChild->weight + rightChild->weight), left(leftChild), right(rightChild) {}
~InternalNode()
{
delete left;
delete right;
}
};
class LeafNode : public INode
{
public:
const char c;
LeafNode(unsigned weight, char c) : INode(weight), c(c) {}
};
// 构建树
INode* BuildTree(const unsigned (&frequencies)[CAPACITY])
{
struct NodeCmp
{
bool operator()(const INode * lhs, const INode * rhs) const { return lhs->weight > rhs->weight; }
};
priority_queue<INode*, vector<INode*>, NodeCmp> tree;

for (unsigned i = 0; i < CAPACITY; ++i)
if (frequencies[i] != 0)
tree.push(new LeafNode(frequencies[i], static_cast<char>(i)));

while (tree.size() > 1)
{
INode * leftChild = tree.top();
tree.pop();

INode * rightChild = tree.top();
tree.pop();

INode * parent = new InternalNode(leftChild, rightChild);
tree.push(parent);
}
return tree.top();
}
// 搜索二叉树并编码
void GenerateCodes(const INode * node, const HuffCode& prefix, HuffCodeMap& outCodes)
{
if (const InternalNode * in = dynamic_cast<const InternalNode*>(node))
{
HuffCode leftPrefix = prefix;
leftPrefix.push_back(false);
GenerateCodes(in->left, leftPrefix, outCodes);
HuffCode rightPrefix = prefix;
rightPrefix.push_back(true);
GenerateCodes(in->right, rightPrefix, outCodes);
}
else if (const LeafNode * lf = dynamic_cast<const LeafNode*>(node))
outCodes[lf->c] = prefix;
}
int main()
{
char* SampleString = nullptr; // 声明指向字符数组的指针
cout << "Input original string: ";
// 判定堆内存分配成功与否及读取输入行
while ((SampleString = new char[CAPACITY]) && cin.getline(SampleString, CAPACITY))
{
// 编码过程
cout << endl;
char * ptr = SampleString; // 创建地址副本
unsigned frequencies[CAPACITY] = {0}; // 初始化频率表
while (*ptr != '\0') // 统计频次
++frequencies[*ptr++];
INode * root = BuildTree(frequencies); // 得到对应哈夫曼树并返回根节点地址
HuffCodeMap codes;
GenerateCodes(root, HuffCode(), codes); // 为每个字符赋予哈夫曼码
delete root;

// 遍历map容器输出不同字符与编码
for (HuffCodeMap::const_iterator it = codes.begin(); it != codes.end(); ++it)
{
cout << it->first << ": ";
copy(it->second.begin(), it->second.end(), ostream_iterator<bool>(cout));
cout << endl;
}
cout << SampleString << ": ";
ptr = SampleString;

// 输出字符串完整编码
while (*ptr != '\0')
{
for (HuffCodeMap::const_iterator it = codes.begin(); it != codes.end(); ++it)
if (it->first == *ptr)
copy(it->second.begin(), it->second.end(), ostream_iterator<bool>(cout));
ptr++;
}
delete SampleString;
SampleString = nullptr;

// 解码过程
char choice;
cout << endl << endl << "Decoding? (Y/N): ";
cin.get(choice);
// 判定是否继续
if (toupper(choice) == 'Y')
{
char each; // 定义单字符
bool flag = true;
HuffCode total; // 定义bool向量
HuffCodeMap::const_iterator it = codes.begin(); // 创建初始迭代器

while (getchar() != '\n')
continue;
cout << "Input encoded string: ";
// 获取输入行单个字符
while ((each = cin.get()) && each != '\n')
{
each -= 48; // 转换为数字表示
total.push_back(static_cast<bool>(each)); // 强转为bool型压入容器
// 依据编码表反向匹配
while (it != codes.end())
{
if (total == it->second)
{
if (flag)
{
cout << "Original string: ";
flag = false;
}
cout << it->first; // 反向输出字符
total.clear(); // 清空容器
}
++it;
}
it = codes.begin();
}
}
else
while (getchar() != '\n')
continue;
cout << endl << string(60, '-') << endl << "Carry on, input next string: ";
}
cout << endl;

return 0;
}
  • 初始时,声明一个指向字符数组的指针用于保存字符串,然后从堆中创建一块 CAPACITY 大小的空间并获取用户输入。编码时,需要注意的点是声明频率表时应同时初始化为 0,避免最终频次统计错误。输出单个字符编码时,通过相应迭代器从头至尾遍历输出每对键、值。若输出完整编码,将每个字符进行一次比较匹配即可。解码时,用户输入的字符串为 01 长序列,因而定义单字符以方便逐位比较。每读取一位字符在 HuffCode 中尝试一轮全匹配,成功即输出,否则即进入下一轮迭代。

动态哈夫曼码的设计

  • 在此之前,我们一直所述的对象均为静态哈夫曼编码,静态哈夫曼码有个不太好的点,你差不多注意到了 —— 传统静态 Huffman 编码需要对数据进行两次遍历:第一次是构造和传输 Huffman 树到接收端,以收集消息中不同字符出现的频率计数;第二次再基于第一次构造的静态树结构,编码和传输消息本身。那么,这会导致在将其用于网络通信时产生较大延迟,或者在文件压缩应用程序中产生额外的磁盘访问进而减慢算法。
  • 于是,动态哈夫曼编码诞生了。动态哈夫曼编码(Dynamic Huffman coding),又称适应性哈夫曼编码(Adaptive Huffman coding),是基于哈夫曼编码的自适应编码技术。它允许在符号正在传输时构建代码,允许一次编码并适应数据中变化的条件,即随着数据流的到达,动态地收集和更新符号的概率(频率)。一次编码的好处是使得源程序可以实时编码,但由于单个丢失会损坏整个代码,因此它对传输错误更加敏感。
  • 所以,Faller 和 Gallager 两人各提出了一种单次遍历方案,后被 Knuth 大大改进,用于构造动态 Huffman 编码。发送器用来编码消息中第 t+1 个字符的二叉树(同时也是接收器用来重建第 t+1 个字符的二叉树)是消息前 t 个字符的二叉树。如此,发送器和接收器就都会从相同的初始树开始,发送器永远不需要将树发送给接收器。很显然,这与静态 Huffman 算法的情况不同。
  • 不久,又有研究者设计并证明了一种于所有单遍方案中,在最坏情况下表现仍然是最优的算法 A,它可以用于网络通信的通用编码方案,也可以作为基于文字的压缩算法中的一种高效子例程。

算法 A 具备以下优点:

  1. 对于编码效率差异相对较大的小消息,每个字母占用更少的位
  2. 在 t 小于 104时,相比所有“两遍算法”都表现得更好
  3. 能对消息进行实时编解码,每个字符使用不到一个额外的比特位对消息编码
  4. 在文件压缩、网络通信和硬件实现方面有巨大应用潜力
  5. 可用来增强其他压缩方案

算术编码

  • 以上所讨论的编码方法建立在符号和码字相对应的基础上。若对信源单符号进行编码,则符号间的相关性就无法考虑:若将 m 个符号合起来编码,第一是会增加设备复杂度,第二是 m+1 个符号间以及组间符号的相关性还是无法考虑。这就使信源编码的匹配原则不能充分满足,编码效率就有所折损。为了克服这种局限性,研究了非分组码的编码方法。
  • 算术编码是一种非分组码,其基本原理是将编码的消息表示成实数0和1之间的一个间隔,消息越长,编码表示它的间隔就越小,表示这一间隔所需的二进制位就越多。
  • 算数编码是一种在有损压缩与无损压缩算法中都经常使用的一种算法,主要应用于图像压缩。算数编码与其它统计编码不同,其它的统计编码通常是把输入的消息分割成符号后对其进行编码,而算数编码则将输入的字符划分成若干个子区间来代表一个字符,计算其概率,进行编码。

基本机理

  • 算术编码的背后是深刻的数学思想,简单来说,它做了这样一件事情:
  1. 假设有一段数据需要编码,统计里面所有的字符和出现的次数
  2. 将区间 [0,1) 连续划分成多个子区间,每个子区间代表一个上述字符, 区间的大小正比于这个字符在文中出现的概率 p。概率越大,则区间越大。所有的子区间加起来正好是 [0,1)
  3. 编码从一个初始区间 [0,1) 开始,设置:

  1. 不断读入原始数据的字符,找到这个字符所在的区间,例如 [ L, H ),更新:

  1. 最后将得到的区间 [low, high)中任意一个小数以二进制形式输出即得到编码的数据

编码过程

  • 给出下面一段十分简单的原始数据:

ARBER

  • 像所有统计编码一样,统计各字符出现的次数与概率:

字符

次数

概率

A

1

0.2

B

1

0.2

E

1

0.2

R

2

0.4


  • 将这几个字符的区间在 [0,1) 上按照概率大小连续一字排开,我们得到一个划分好的 [0,1)区间:
  • #打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区


  • 开始编码,初始区间是 [0,1)。注意这里又用了区间这个词,不过这个区间不同于上面代表各个字符的概率区间 [0,1)。这里我们可以称之为编码区间,这个区间是会变化的,确切来说是不断变小。我们将编码过程用下图完整地表示出来:
  • #打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 我们对其进行步骤拆解:
  1. 刚开始编码区间是 [0,1),即:

  1. 第一个字符A的概率区间是 [0,0.2),则 L = 0,H = 0.2,更新:

  1. 第二个字符R的概率区间是 [0.6,1),则 L = 0.6,H = 1,更新:

  1. 第三个字符B的概率区间是 [0.2,0.4),则 L = 0.2,H = 0.4,更新:

  • 我们可以看到一个不断变化的小数编码区间。每次编码一个字符,就在现有的编码区间上,按照概率比例取出这个字符对应的子区间。例如一开始A落在0到0.2上,因此编码区间缩小为 [0,0.2),第二个字符是R,则在 [0,0.2)上按比例取出R对应的子区间 [0.12,0.2),以此类推。每次得到的新的区间都能精确无误地确定当前字符,并且保留了之前所有字符的信息,因为新的编码区间永远是在之前的子区间。最后我们会得到一个长长的小数,这个小数即神奇地包含了所有的原始数据,不得不说这真是一种非常巧妙的思想。

解码过程

  • 如果理解了编码的原理,那么解码的方法显而易见,就是编码过程的逆推。从编码得到的小数开始,不断地寻找小数落在了哪个概率区间,就能将原来的字符一个个地找出来。例如得到的小数是0.14432,则第一个字符显然是A,因为它落在了 [0,0.2)上,接下来再看0.14432落在了 [0,0.2)区间的哪一个相对子区间,发现是 [0.6,1), 就能找到第二个字符是R,依此类推。在此不再赘述具体步骤。

算法实现

#include <math.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
char inStr[100], chSet[20]; //输入字符串和字符集
float P[20]; //每个字符的概率
float pZone[20]; //概率区间
int strLen; //输入字符串长度
int chNum; //字符集中字符个数
int binary[100];
float infoLen; //信息量大小
void compress(); //编码函数
void uncompress(); //解码函数
int main()
{
int i,j;
printf("input the length of char set:\n");
scanf("%d", &chNum);
getchar();
printf("input the char and its p\n");
for (i=0; i < chNum; i++) {
printf("input char: ");
scanf("%c", &chSet[i]);
getchar();
//printf("sssss%c ", chSet[i]);
printf("\ninput its p: ");
scanf("%f",&P[i]);
getchar();
printf("\n");
}
/* test
for (i = 0; i < chNum; ++i)
printf("%c<-------------->%f\n", chSet[i], P[i]);
*/
// 计算概率区间
pZone[0] = 0;
for (i=1; i < chNum; ++i)
pZone[i] = pZone[i-1] + P[i-1];
printf("input the string \n");
fgets(inStr, 100, stdin);
strLen = strlen(inStr);
/************* test ***************/
printf("the string is: \n");
puts(inStr);
printf("*********** compress **************\n");
compress();
printf("\n*********** uncompress **************\n");
uncompress();
return 0;
}
void compress()
{
float low = 0, high = 1;
float L, H, zlen = 1;
float cp; //输入字符的概率
float result; //结果
int i, j;
for (i=0; i < strLen; i++) {
for (j=0; j < chNum; j++) {
if (inStr[i] == chSet[j]) {
//cp = P[j];
//L = pZone[j];
low = low + zlen * pZone[j];
zlen *= P[j];
break;
}
}
//low = low + zlen * L;
//zlen *= cp;
}
result = low;
printf("the result is %f\n", result);
infoLen = log(1/zlen) / log(2); //计算香农信息量
if(infoLen > (int)infoLen)
infoLen = (int)infoLen + 1;
else
infoLen = (int)infoLen;
for (i=0; i < infoLen; i++) {
result *= 2;
if (result > 1) {
result = result - 1;
binary[i] = 1;
} else if (result < 1) {
binary[i] = 0;
} else {
break;
}
}
if (i >= infoLen) {
for (j=i; j >= 1; j--) {
binary[j-1] = (binary[j-1]+1)%2;
if (binary[j-1] == 1)
break;
}
}
printf("****************** the compress result*****************\n");
for (j=0; j < i; j++)
printf("%d ", binary[j]);
}
void uncompress()
{
int i,j;
float w = 0.5;
float deResult=0;
float newLow,newLen;
float low=0,zlen=1;
for (i=0; i < infoLen; i++) {
deResult += w*binary[i];
w *= 0.5;
}
printf("uncompress to ten:%f\n", deResult);

printf("uncompress result:\n");
for (i=0; i < strLen; i++) {
for (j=chNum; j > 0; j--) {
newLow = low;
newLen = zlen;
newLow += newLen * pZone[j-1];
newLen *= P[j-1];
if (deResult >= newLow) {
low=newLow;
zlen=newLen;
printf("%c ",chSet[j-1]);
break;
}
}
}

}

与哈夫曼编码的比较

  • 我们首先回顾一下哈夫曼编码,换一组数据并统计字符的出现次数,生成哈夫曼树,我们可以得到以下字符编码集:

字符

次数

编码

a

3

00

b

3

01

c

2

10

d

1

110

e

2

111

  • 仔细观察编码所表示的小数,从0.0到0.111,其实就是构成了算数编码中的各个概率区间,并且概率越大,所用的bit数越少,区间则反而越大。如果用哈夫曼编码一段数据abcde,则得到:

000110110111

  • 如果点上小数点,把它也看成一个小数,其实和算数编码的形式很类似,不断地读入字符,找到它应该落在当前区间的哪一个子区间,整个编码过程形成一个不断收拢变小的区间。
  • 由此我们可以看到这两种编码,或者说熵编码的本质。概率越小的字符,用更多的bit去表示,这反映到概率区间上就是,概率小的字符所对应的区间也小,因此这个区间的上下边际值的差值越小,为了唯一确定当前这个区间,则需要更多的数字去表示它。我们仍以十进制来说明,例如大区间0.2到0.3,我们需要0.2来确定,一位足以表示;但如果是小的区间0.11112到0.11113,则需要0.11112才能确定这个区间,编码时就需要5位才能将这个字符确定。其实编码一个字符需要的bit数就等于 -log ( p ),这里是十进制,所以log应以10为底,在二进制下以2为底,也就是香农公式里的形式。
  • 哈夫曼编码的不同之处就在于,它所划分出来的子区间并不是严格按照概率的大小等比例划分的。例如上面的d和e,概率其实是不同的,但却得到了相同的子区间大小0.125;再例如c,和d,e构成的子树,c应该比d,e的区间之和要小,但实际上它们是一样的都是0.25。我们可以将哈夫曼编码和算术编码在这个例子里的概率区间做个对比:

  • 这说明哈夫曼编码可以看作是对算数编码的一种近似,它并不是完美地呈现原始数据中字符的概率分布。也正是因为这一点微小的偏差,使得哈夫曼编码的压缩率通常比算数编码略低一些。或者说,算数编码能更逼近香农给出的理论熵值。

数据压缩的定义、背景与分类

  • 首先我们要知道什么是数据压缩。数据压缩是指在不丢失有用信息的前提下,缩减数据量以减少储存空间,提高其传输、储存和处理效率,或按照一定的算法对数据重新组织,减少数据的冗余和存储空间的一种技术方法。
  • 那么为什么会出现数据压缩这门技术呢?一门技术的快速发展必然有其背景,有其诞生的必要性。
  • 数据压缩背景摘要:在信息储存和数据传输日益增长的今天,数据压缩变得越来越重要,数据压缩是一种用来减小数据大小的技术。当一些巨大的文件必须通过网络传输存储在数据存储设备上,且其大小超过数据存储的容量或将消耗大量的网络传输带宽时,这是非常有用的。随互联网和资源有限的移动设备的出现,数据压缩变得更加重要。它可以有效地用于节省储存空间和带宽,从而减少了下载时间.
  • 数据压缩算法发展至今,已经有了相当多的算法,按数据质量可分为有损压缩和无损压缩两大类。无损算法可以从压缩的信息中精确地重构原始消息,有损算法只能近似地重构原始消息。

游程编码(RLE)

  • 设想一下,一旦一个像素呈现出一种特定的颜色(黑色或白色),下面的像素极有可能也是相同的颜色。因此,与其单独编码每个像素的颜色,我们可以简单地编码每个颜色的运行长度。RLE是一种非常简单的数据压缩形式,其中数据序列(称为游程,重复的字符串)存储在两个部分:单个数值和计数。这对于包含许多这样的运行的数据是最有用的,例如,简单的图形图像,如图标、线条图和动画。但它对于运行次数不多的文件是没有用的,因为它可能会大大增加文件的大小。
  • 例如:纯白色背景上的纯黑色文本,b 代表一个黑色像素,w 代表一个白色像素:wwwwwbwwwwwbbbwwwwwbwwwww 用RLE表示为:5w1b5w3b5w1b5w
  • 由于游程编码执行无损数据压缩,它非常适合基于调色板的图像,如纹理。但通常不应用于现实的图像,如照片。另外,游程编码用于传真机非常高效,因为大多数传真文件都有很多空白,偶尔会有黑色的干扰。

Lempel-Ziv算法

  • lempel-Ziv 算法是一种基于字典的编码算法,而以往的算法往往基于概率编码,它是文件无损压缩的首选方法。这主要是由于它对不同文件格式的适应性但是对于小文件,字典的长度可能会超过原始文件的长度,但是对于大文件,这种方法是非常有效的。
  • Ziv 和 Lempel 在1977年和1978年的两篇独立论文中描述了该算法的两个主要变体,通常被称为 LZ77和 LZ78。
  • LZ77 算法基于滑动窗口的思想,该算法只在距离当前位置固定距离内的窗口查找匹配项。而 LZ78 算法基于一种更保守的方法向字典中添加字符串。
  • lempel-ziv-welch(LZW) 是目前使用最多的 Lempel-Ziv 算法。它是在 LZ77 和 LZ78 压缩算法的基础上改进的。编码器建立一个自适应字典来表示变长字符串,不需要任何先验概率信息。解码器根据接收到的代码动态地在编码器中构建相同的字典。
  • 现在 LZW 应用于 GIF 图像,UNIX 压缩等。

  • 基本步骤:
    1)初始化字典。
    2)将输入数据的符号按顺序组合到缓冲区中,直到在字典中找到最长的字符串
    3)在缓冲区中发送表示的代码。
    4)将缓冲区中的字符串与下一个空代码中的下一个符号结合保存到字典中。
    5)清空缓冲区,然后重复步骤 2~5,直到全部数据结束。
  • #打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • 表3是一个LZW的例子,输入字符串为ABABBABCABABBA,初始码为1、2、3,分别表示A、B、C。
    编码后的字符串为“124523461”。14个字符压缩为9个字符。因此压缩比为14/9=1.56。

LZ编码的应用

  • 在LZ的变体中,最流行的是LZW算法然而,LZW最初是实用最多的算法,专利问题导致越来越多的使用LZ算法。LZ算法最受欢迎的实现是Phil Katz最初设计的deflate算法。Deflate是一种无损数据压缩算法,使用了LZ算法和哈夫曼算法的组合,下面列举LZ算法的主要应用:

文件压缩 UNIX 压缩

  • UNIX compress 命令是LZW最早的应用之一。字典的大小是自适应的。当字典被填满时,大小逐渐增加一倍。代码字的最大大小bmax可以由用户设置为9到16之间,16位是默认值。而一旦字典包含2bmax条目,压缩就会成为一种静态字典编码技术,此时算法监视压缩比。如果压缩比低于阈值,则将刷新字典,并重新启动字典构建过程。这样一来,字典总是能反映源的地方特征。

图像压缩 gif 格式

  • 图形交换格式(GIF)是 Compuserve 信息服务公司开发的图形图像编码格式。它是 LZW 算法的另一种实现,与 Unix 中的 compress 命令非常相似,正如我们在前面的应用程序中提到的那样。

图像压缩 png 格式

  • PNG 标准是互联网上最早开发的标准之一。1994 年12 月,Unisys 公司(该公司从 Sperry 那里获得了 LZW的专利)和 CompuServe 公司宣布,他们将开始向支持GIF 的软件的作者收取版税。该公告导致了数据压缩领域的一场革命,行成了Usenet组comp.compression的核心。社区决定开发一种无专利的 GIF 替代品,三个月内 PNG 诞生了。Modem sV.42 上的压缩ITU-T 建议 V.42 之二是为通过电话网络使用而制定的压缩标准,并附有 C CITT 建议 V.42 中所述的纠错程序。该算法用于连接计算机和远程用户的调制解调器。该算法有两种运行模式和压缩模式。在透明模式下,数据以不压缩的形式传输,在压缩模式下,数据使用LZW算法进行压缩。

多媒体压缩JPEG和MPEG

背景

多媒体图像已经成为日常生活中一个至关重要且无处不在的组成部分。图像中编码的信息量是相当大的。即使有了带宽和储存能力的进步,如果图像不被压缩,许多应用程序的成本将会太高。

图像压缩:JPEG压缩算法

JPEG用于静态图像,图像压缩是减少表示数字图像所需的数据量的过程,这是通过删除所有冗余或不必要的信息来实现的。一个未压缩的图像需要大量的数据来表示。

编码算法:

1、颜色空间转换

如果颜色分量是独立的(不相关的),则可以获得最好的 压缩结果,例如在YCbCr中,大部分信息集中在亮度上,而色度上的信息较少。RGB颜色分量可以通过线性变换转换为YCbCr分量,如下式:

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

2、色度下降采样

使用YCbCr颜色空间,我们还可以通过压缩Cb和Cr分量的分辨率来节省空间。它们是色度分量,我们可以减少它们以使用图像压缩。由于亮度对眼睛的重要性,我们不需要色度像亮度一样频繁,所以我们可以对其进行下降采样。因此可以除去部分Cb和Cr的元素。因此,例如将RGB4:4:4格式转换为YCbCr4:2:2的格式,这样就可以获得一个1:5的数据压缩比,不过此步骤是一个可选的过程。

3、离散余弦变换(DCT)

在这一步,每88块的分量(Y,Cb,Cr)被转换成频域表示。DCT方程是一个相当复杂的方程,有两个余弦系数。细节参考JPEG标准。

4、量化

对人眼来说,亮度比色度更重要。对眼睛来说,在大范围内看到亮度的微小差异比高频亮度变化的确切强度更容易分辨。利用这一特性,我们可以大大减少高频成分中的信息。JPEG编码通过简单地将频率域中的每个分量除以该分量中的一个常数,然后四舍五入到最近的整数来实现这一点。因此,许多高频分量被四舍五入到零,其余大部分分量变成了小的正数或负数,占用更少的比特来储存。

5、熵编码

熵编码是一种无损数据压缩的方法。在这里,我们将图像组件排序为锯齿形,然后使用游程编码(RLE)算法,将相似的频率连接在一起,以压缩序列。

6、哈夫曼算法

应用前面的步骤,我们得到的数据就是DCT系数序列。这一步即是最后一步,我们用哈夫曼编码或者算数压缩算法来压缩这些系数。该方案主要采用Huffman压缩,将其视为第二次无损压缩。

视频压缩:MPEG压缩算法

MPEG压缩算法的基本思想是将离散样本流转换为符号的比特流。以减少占用空间,理论上,视频流是一组离散图像。MPEG使用这种连续帧之间的特殊或时间关系来压缩视频流。在一段数据中,利用这些关系的技术越有效,对数据的压缩就越有效。

在MPEG编码算法中,我们只对视频序列中的新部分和视频中运动部分的信息进行编码。例如,考虑图9,上面的三张图。对于压缩我们只需考虑新的部分,如图9,我们只需要考虑底部的三个序列。视频压缩的基本原理是图像对图像的预测。一组图像中的第一个图像是i帧。这些帧显示开始一个心得场景,因此不需要被压缩,因为他们没有依赖于该图像之外。但其他帧可以使用第一张图片的一部分作为参考。从一个参考图像预测的图像称为p帧,从两个其他参考图像双向预测的图像称为B帧。因此,总的来说,我们将有以下帧MPEG编码:
I-frames:独立的;不需要参考帧预测
P帧:从最后一个I或P参考帧预测
B-frames:双向:从两个参考帧预测,一个在过去,一个在未来,将考虑最佳匹配。

#打卡不停更#【ELT.ZIP】啃论文俱乐部——统计压缩编码机理分析-开源基础软件社区

  • MPEG应用程序:MPEG在现实世界中有很多的应用。我们在此列举其中一些:
    1、有限电视。一些电视系统通过有线电视线路发送MPEG-II节目
    2、直接广播卫星。MPEG视频流被跌形/解码器接收,它提取的数据为标准NTSC电视信号。
    3、媒体的金库。Silicon Graphics、Storage Tech和其他供应商正在生产按需视频系统,在一个安装上有两万个文件MPEG编码的电影。
    4、实时编码,这仍是专业人士的专属领域。结合特殊用途的并行硬件,实时编码器的成本可达2万至5万美元。

今天的数据压缩:应用程序和问题

  • 接受数据压缩算法的关键是在性能和复杂性之间找到一个可接受的折中。对于性能,我们有两个相互矛盾的因素:最终用户对压缩的感知和压缩后的数据速率。而系统的复杂性最终决定了编码和解码的成本。

网络

  • 今天随着用户数量的增加和远程办公,以及使用云计算的应用程序部署模型的出现,更多正在传输的数据对网络连接的压力导致了额外的问题。
  • 数据压缩重要的作用之一是将其应用于计算机网络。然而,在带宽有限的网络环境下,实现高的压缩比是提高应用程序性能的必要条件,如果压缩比过低,网络将保持饱和,性能增益将非常小。同样,如果压缩速度过低,压缩机也会成为瓶颈。许多网络数据的传输优化解决方案都只关注网络层的优化。这些解决方案不仅缺乏灵活性,而且没有包含能够进一步增强通过网络链接传输数据的应用程序性能的优化。

基于报文或会话的压缩

  • 许多网络压缩系统是基于数据包的。基于数据包的压缩系统使用解压器缓冲发送到远程网络的数据包。然后,这些数据包在单个时间内或作为一个组被压缩,然后发送到解压器,在那里这个过程被逆转。
  • 当压缩数据包时,这些系统必须在将小数据包写入网络和执行额外的工作来聚合和封装多个数据包写入网络和执行额外的工作来聚合和封装多个数据包之间做出选择,这两种选择都不会产生最佳效果。向网络写入小数据包会增加TCP/IP报头的开销,而聚合和封装数据包会增加流的封装报头。

字典压缩大小

  • 几乎所有压缩实用程序的一个共同限制是有限的储存空间。
  • 与对网络的请求相似,并不是所有在网络上传输的字节都以相同的频率重复。一些字节模式出现的频率很高,因为他们是流行文档或公共网络协议的一部分。其他字节模式只出现一次,并且永远不重复。经常重复的字节序列和不经常重复的字节序列之间的关系可以在Zipfs定律中看到。

基于块或基于字节的压缩

  • 基于块的压缩系统储存先前在网络上传输的数据片段。当第二次遇到这些块时,对这些网块的引用被传输到远程设备,然后远程设备重新构建原始数据。
  • 基于块的系统的一个关键缺点是,重复的数据几乎从不与块的长度完全相同。因此,匹配通常只是部分匹配,不压缩一些重复的数据。如图12说明了使用256字节块的系统试图压缩512自治街的数据时会发生什么。

能源效率

  • 能源效率领域,尤其是无线传感器网络是当今世界最热门的网络研究领域之一。无线传感器网络由分布在空间上的传感器组成,用于检测物理或环境条件,如温度、声音、振动、压力、运动或污染物,并协同将他们的数据通过网络传递到主要位置。而传感器的大小和成本限制导致了资源的限制,如能源、内存、计算速度和通信带宽。巨大共享传感器之间的数据需要能量高效、低延迟和高精度。
  • 目前,如果传感器系统设计者想要压缩获得的数据,他们必须卡法特定于应用程序的压缩算法,或者使用非为资源所限的传感器节点设计的现成算法。
  • 主要的尝试是实现一种专门为传感器网络设计的传感器Lempel-Ziv(S-LZW)压缩算法。针对无线传感网络中设计高效数据压缩算法的趋势,Vidhyapriyal和P.Vanathi设计并实现了两种集成了最短路径路由技术的无损数据压缩算法,以减少原始数据的大小,并在传感器网络中实现速率、能量和精度之间的最佳权衡。

总结

  • 在数据存储和信息传输量不断增长的今天,数据压缩技术发挥着重要作用。即使在带宽和存储能力方面有了进步,如果数据没有被压缩,许多应用程序的成本将太高,用户无法使用他们。本文我们尝试介绍了无损压缩和有损压缩两种压缩类型,以及数据压缩中的一些主要概念、算法和方法,并讨论了他们的不同应用和工作方式,然后我们探讨了两个主要的日常应用;以JPEG为例进行图像压缩,以MPEG为例进行了视频压缩。最后我们讨论了当今数据压缩的主要应用和存在的问题。

​想了解更多关于开源的内容,请访问:​

​51CTO 开源基础软件社区​

​https://ost.51cto.com​​。

责任编辑:jianghua 来源: 51CTO开源基础软件社区
相关推荐

2022-09-19 14:25:35

JSON压缩算法

2022-02-24 16:32:26

OpenHarmon压缩编码鸿蒙

2022-08-22 17:36:13

啃论文方法啃论文俱乐部

2022-04-07 15:03:07

Harmony计算机鸿蒙

2022-06-27 14:01:31

LZ4 分析数据密集型压缩算法

2022-06-08 16:29:45

无损压缩方案分布式

2022-05-12 15:05:32

云计算数据压缩

2022-06-08 11:46:29

字符串鸿蒙

2022-06-15 16:06:29

LZ4 算法硬件加速

2022-06-15 15:56:22

压缩算法神经网络

2022-06-15 15:44:21

无损数据压缩鸿蒙

2022-04-20 20:37:58

鸿蒙操作系统

2022-05-13 23:03:25

大数据Big Data巨量资料

2022-05-13 22:44:35

物联网算法鸿蒙

2018-05-28 21:51:25

Protocol bu数据存储序列化

2022-09-07 15:08:58

操作系统鸿蒙

2022-09-13 16:10:15

鸿蒙操作系统

2022-09-06 15:46:52

speexdsp鸿蒙

2022-09-16 15:01:37

操作系统技术鸿蒙

2022-09-14 15:28:19

操作系统鸿蒙
点赞
收藏

51CTO技术栈公众号