史诗级计算机字符编码知识分享,万字长文,一文即懂!

Posted im中国人

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了史诗级计算机字符编码知识分享,万字长文,一文即懂!相关的知识,希望对你有一定的参考价值。

在人类探索外星文明、迈向星辰大海的宇宙征程里,也离不开这种最最基础的编码问题。 前一阵跟同事碰到了字符乱码的问题,了解后发现这个问题存在两年了,我们程序员每天都在跟编码打交道,但大家对字符编码都是一知半解:“天天吃猪肉却很少见过猪跑”,今天我就把它彻底讲透!

本文由阿里技术团队詹向阳(骁飏)分享,原题“一文读懂字符编码”,有修订和改动。

一、引言

说起计算机字符编码,让我想起了科幻巨作《三体-黑暗深林》人类遇到外星文明魔戒的画面(以下内容摘自大刘的原文)。

人类第一次近距离看到四维物体魔戒,卓文用中频电波发送了一个问候语。这是一幅简单的点阵图,图中由六行不同数量的点组成了一个质数数列:1,3,5,7,11,13。

他们没有指望得到应答,但应答立刻出现了

.....

太空艇收到了来自“魔戒”的一系列点阵图,第一幅是很整齐的一个8×8点阵,共六十四个点;第二幅图中点阵的一角少了一个点,剩下六十三个;第三幅图中又少一点,剩六十二个……“这是倒计数,也相当于一个进度条,可能表示‘它’已经收到了罗塞塔,正在译解,让我们等侍。”韦斯特说。

“可为什么是六十四点呢?”

“使用二进制时一个不大不小的数呗,与十进制的一百差不多。”

卓文和关一帆都很庆幸能带韦斯特来,在与未知的智慧体建立交流方面、心理学家确实很有才能。

在倒计数达到五十七时,令人激动的事情出现了:下一个计数没有用点阵表示,“魔戒”发来的图片上赫然显示出人类的阿拉伯数字56!

.....

在人类探索外星文明、迈向星辰大海的宇宙征程里,也离不开这种最最基础的编码问题。

前一阵跟同事碰到了字符乱码的问题,了解后发现这个问题存在两年了,我们程序员每天都在跟编码打交道,但大家对字符编码都是一知半解:“天天吃猪肉却很少见过猪跑”,今天我就把它彻底讲透!

* 推荐阅读:如果本文太“硬”,就看看这两篇吧:《史上最通俗,彻底搞懂字符乱码问题的本质》、《字符编码那点事:快速理解ASCII、Unicode、GBK和UTF-8》。

技术交流:

(本文已同步发布于:http://www.52im.net/thread-4210-1-1.html

二、什么是字符编码?

我们知道计算机的世界只有0和1,如果没有字符编码,我们看到的就是一串"110010100101100111001....",我们的沟通就好像是在对牛弹琴,我看不懂它,它看不懂我。

字符编码就好比人类和机器之间的翻译程序,把我们熟知的字符文字翻译成机器能读懂的二进制,同时把二进制翻译成我们能看懂的字符。

以下是百科对字符编码的解释:

字符编码(Character encoding)也称字集码,是把字符集中的字符,编码为指定集合中的某一对象(例如:比特模式、自然数序列、8位组或者电脉冲),以便文本在计算机中存储或者通信网络的传递。常见的例子是将拉丁字母表编码成摩斯电码和ASCII,比如ASCII编码是将字母、数字和其它符号进行编号,并用7比特的二进制来表示这个整数。

字符集(Character set)是多个字符的集合,字符集种类较多,每个字符集包含的字符个数不同,常见字符集名称:ASCII字符集、GB2312字符集、BIG5字符集、 GB18030字符集、Unicode字符集等。计算机要准确的处理各种字符集文字,就需要进行字符编码,以便计算机能够识别和存储各种文字。

三、为什么计算机需要编码?

3.1、概述

编码(Encode)是信息从一种形式转换为另一种形式的过程,比如用预先规定的方法将字符(文字、数字、符号等)、图像、声音或其它对象转换成规定的电脉冲信号或二进制数字。

我们现在看到的一幅幅图画,听到的一首首音乐,甚至我们写的一行行代码,敲下的一个个字符,所看到的所听到的都是那么的真实,但其实在背后都是一串“01”的数字。你昨天在手机上看到的那个心动女孩,真实世界中并不存在,只是计算机用“01”数字帮你生成的“骷髅”而已(宅男梦碎。。。)。

3.2、二进制其实不存在

你可能认为计算机中的数据就是“01”二进制,但是实际上计算机中并没有二进制,即便我们知道所有的内容都是存储在硬盘中,但是你把它拆开可找不到里面有任何“0101”的数字,里面也只有盘片、磁道。就算我们放大了去看盘片,也只有凹凸不平的盘面,凸起的地方是被磁化过的,凹进去的地方是没有被磁化的;只是我们给凸起的地方取了个名字叫数字“1”,凹进的地方取名叫数字“0”。

同样内存里你也找不到二进制数字:内存放大了看就是一堆电容组,内存单元存储的是“0”还是“1”取决于电容是否有电荷,有电荷我们认为他是“1”,无电荷认为他是“0”。但是电容是会放电的,时间一长,代表“1”的电容会放电,代表“0”的电容会吸电,这也是我们内存不能断电的原因,需要定期对电容进行充电,保证“1”的电容电量有电。

再说显示器:这个大家感受是最直接的,你透过显示器看到的美女画皮、日月山川,其实就是一个个不同颜色的发光二极管发出强弱不一的光点,显示器就是一群发光二极管组成的矩阵,其中每一个二极管可以被称为一个像素,“1”表示亮,“0”表示灭,而我们平时能看到五彩的颜色,是把三种颜色(红绿蓝三原色)的发光二极管做到了一起。那对于一个ASCII编码“65”最后又怎么显示成“A”的呢?这就是显卡的功劳,显卡中存储了每一个字符的图形数据(也称字形码),将二维矩阵的图形数据传给显示器成像(如下图所示)。

因此:所谓的0和1都是电流脉冲信号,二进制其实是我们抽象出来的数学逻辑概念。那我们为什么要用二进制表示?

因为:二进制只有两种状态,使用有两个稳定状态的物理器件就可以表示二进制中的每一位,例如用高低电平或电荷的正负性、灯的亮和灭都可以很方便地用“0”和“1”来表示,这为计算机实现逻辑运算和逻辑判断提供了便利条件。

四、计算机编码转换过程

4.1、概述

正因为计算机只能表示“01”的逻辑概念,无法直接表示图片以及文字,所以我们需要一定的转换过程。

这其实就是我们按照一定的规则维护了字符-数字的映射关系,比如我们把“A”抽象成计算机中的“1”,当我们看到1的时候就认为这是“A”,本质上就是一张映射表,理论上你可以随意给每个字符分配一个独一无二的编号(character code,字符编码)。

比如下表这样:

接下来我们来看下一个文字从“输入-转码存储-输出(显示/打印)”的简单流程。

首先:我们知道计算机是美国人发明的,规则是美国人定的,键盘上的按键也都是英文字母,所以编号不是你想怎么分配就怎么分配。对于英文字母的输入,键盘和ASCII码之间是直接对应的,键盘按键“A”对应的编号“65”,存储到磁盘上也是“65”的二进制直译“01000001”,这很好理解。

但是:对于汉字输入就不是这么回事了,键盘上可没有汉字对应的输入按键,我们不可能直接敲出汉字字符。于是就有了输入码、机内码、字形码的转换关系,输入码帮助我们把英文键盘按键转换成汉字字符,机内码帮助我们把汉字字符转换成二进制序列,字形码帮助我们把二进制序列输出到显示器成像。

4.2、输入码

我们模拟下汉字的输入过程。

首先:打开txt文本敲下“nihao”的拼音字母,然后输入栏会弹出多个符合条件的汉字词组,最后我们会选择相应的编号,就能实现汉字的输入。

那这过程又是如何实现的呢?

计算机领域有一句如同摩西十诫般的神圣哲言:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。

这里我们再加一层按键字母组合和汉字的映射表,好比英汉字典,这层我们称为输入码,输入码到内码的过程就是一次查表转换操作,比如“nihao”这几个ASCII字符,大家可以随便修改映射表以及候选编号,我可以把他映射成“你好骁飏”(如下图所示)。

4.3、机内码

机内码也称内码,是字符编码最核心的部分。

机内码是字符集在计算机中实际存储、交换、通信使用的二进制编码,通过内码我们可以达到高效率的存储、传输文本的目的。我们的外码(输入码)实现了键盘按键和字符的映射转换,但是机内码是让字符真正变成了机器能读懂的二进制语言。

4.4、字形码

计算机中的字符都是以内码的二进制形式表示,我们怎么把数字对应的字符在显示器上显示出来呢,比如数字“1”代表汉字“你”,怎么把“1”显示成“你”?

这就需要依赖字形码,字形码本质上是一个n*n 的像素点阵,把某些位置的像素设置为白色(用 1 表示),其它位置像素设置为黑色(用 0 表示),每一个字符的字形都是预先存放在计算机内,而这样的字形信息库我们称为字库

比如中文“你”的点阵图,这样一个 16*16 的像素矩阵,需要 16 * 16 / 8 = 32 字节的空间来表示,右边的字模信息称为字形码。不同的字库(如宋体、黑体)对同一个字符的字形编码是不同的。

所以字符编码到显示的字形码,其实又是另一张查找表,也就是字符编码-字形码的映射关系表。

其实我们也可以认为字符编码是字形码的一种压缩方式,一个占32字节的像素点阵压缩成了2字节的机内码。

五、字符编码的历史

5.1、电报编码

从广义上来说,编码的历史很悠久,一直可以追溯到结绳记事的远古时期,但跟现代字符编码比较接近的还是摩尔斯电码的发明,自此开启了信息通信时代的大门。

摩尔斯电码是由美国人摩尔斯在1837年发明的,比起ASCII还要早100多年,在早期的无线电上作用非常大,它是每个无线电通讯者需必知的,它的是由点dot “.” 和划dash “-” 这两种符号所组成的,电报中表达为短滴和长嗒,跟二进制一样也是二元码。

一个二元肯定不够表示我们的字母,那么就用多个二元来表示,比如嘀嗒“.-”代表字母“A”,嗒嘀嘀嘀“-...”代表字母“B”。

摩尔斯电码表:

5.2、编码纪元

计算机一开始发明出来时是用来解决数学计算问题的,后来人们发现,计算机还可以做更多的事,例如文本处理等。那个时候的机器都很大,机器之间都是隔离的,没考虑过机器的通信问题,各大厂商也各干各个的,搞自己的硬件搞自己的软件,想怎么编码就怎么编码。

后来机器间需要相互通信的时候,发现在不同计算机上显示出来的字符不一样,在IBM上“00010100”数字代表“A”,跑到微软系统上显示成了“B”,大家就傻眼了。于是美国的标准化组织就跑出来制定了ASCII编码(American Standard Code for Information Interchange),统一了游戏规则,规定了常用符号用哪些二进制数来表示。

5.3、百花齐放

统一ASCII 码标准对于英语国家很开心,但是ASCII编码只考虑了英文字母,后来计算机传到欧洲地区,法国人需要加个字母符号(如:é),德国人又需要加几个字母(Ä ä、Ö ö、ü ü、ß),幸好ASCII只用了前127个编号,于是欧洲人就将ASCII没用完的编码(128-255)为自己特有的符号编码,也能很好的一起玩耍。

但是等传到我们中国后,做为博大精深的汉语言就彻底蒙圈了,我们有几万个汉字,255个编号完全不够用啊,所以有了后来的多字节编码… 因此,各个国家都推出了本国语言的编码表,也就有了后来的 ISO 8859 系列、GB系列(GB2312、GBK、GB18030、GB13000)、Big5、EUC-KR、JIS … ,不过为了能在计算机系统中通用,这些扩展的编码均直接或间接兼容 ASCII 码。

而微软/IBM这些国际化产商为了把自己的产品卖到全世界,就需要支持各个国家的语言,要在不同的地方采用当地的编码方式,于是他们就把全世界的编码方式都集中到一起并编上号,并且起了个名字叫代码页(Codepage,又称内码表),所以我们有时候也会看到xx代码页来指代某种字符编码,比如在微软系统里 中文GBK编码对应的是936代码页,繁体中文 Big5编码对应的是950代码页。

这些既兼容ASCII又互相之间不兼容的字符编码,后来又统称为ANSI编码。看到下面这张图估计大家就很熟悉了,window下面我们基本上都用ANSI编码保存。

ANSI的字面意思并非指字符编码,而是美国的一个非营利组织,是美国国家标准学会(American National Standards Institute)的缩写,ANSI这个组织为字符编码做了很多标准制定工作,后来大家习惯把这类混乱的多字节编码叫ANSI编码或者标准代码页。

ANSI编码只是一个范称,一般代表系统默认的编码方式,而且并不是确定的某一种编码方式——比如在Window操作系统里,中国区ANSI编码指的是GB编码,在香港地区ANSI编码指的是Big5编码,在韩国ANSI编码指的是EUC-KR编码。

5.4、天下一统

由于各个国家各搞各的字符编码,如果有些人想装逼中文里飚两句韩文怎么办呢?不好意思,你的逼级太高,没法支持,你选择了GB2312就只能打出中文字符。同时各大国际厂商在兼容各种字符编码问题上也深受折磨,于是忍无可忍之下,决定开发一套能容纳全世界所有字符的编码,就有了后面大名鼎鼎的Unicode。

Unicode也叫万国码,包括字符集、编码方案等。

Unicode是为了解决传统的字符编码方案的局限而产生的,它为每种语言中的每个字符设定了统一并且唯一的二进制编码,在这种语言环境下,不会再有语言的编码冲突,在同屏下也可以显示任何国家语言的内容,这就是Unicode的最大好处。

在Unicode编码方案里常见的有四种编码实现方案UTF-7、 UTF-8、UTF-16、UTF-32,最为知名的就是 UTF-8。Unicode设计之初是采用双字节定长编码的UTF-16,但是发现历史包袱太重推不动,最后出了个变长的UTF-8才被广泛接受。

六、字符编码模型

6.1、传统编码模型

在传统字符编码模型中,基本上都是将字符集里的字符用十进制进行逐一的编号,然后把十进制编号直接转成对应的二进制码,可以说该字符编号就是字符的编码。

计算机在处理字符与数字的转换关系上其实就是查找映射表的过程。

像ASCII编码就是给每个英文字符编一个独一无二的数字,整个编码处理过程相对还是比较简单的,计算机内部直接就映射成了二进制,十进制的编号只是方便我们看的。

6.2、现代编码模型

Unicode编码模型采用了一个全新的编码思路,将编码模型划分为4 个层次(也有说5个层次的),不过第五层是传输层的编码适配,放在编码模型里严格来说不是很恰当。

这 4 个层次分别是:

  • 1)第一层,抽象字符集 ACR(Abstract Character Repertoire):定义抽象字符集合,明确各个抽象字符;
  • 2)第二层,编号字符集 CCS(Coded Character Set):将抽象字符集进行数字编号;
  • 3)第三层,字符编码方式 CEF(Character Encoding Form):将字符编号编码为逻辑上的码元序列;
  • 4)第四层,字符编码方案 CES(Character Encoding Scheme):将逻辑上的码元序列编码为物理字节序列。

下面将分别来详细讲讲各层。

6.3、第一层:抽象字符集 ACR

所谓抽象字符集,就是抽象字符的合集。

它是一个无序集合,这里强调了字符是抽象的,也就是不仅包括我们视觉上能看到的狭义字符,比如“a”这样的有形字符,也包括一些我们看不到的无形字符,比如一些控制字符“DELETE”、“NULL”等。

抽象的另一层含义是有些字形是由多个字符组合成的,比如西班牙语的 “ñ” 由“n”和“~”两个字符组成,这一点上 Unicode 和传统编码标准不同,传统编码标准多是将 ñ 视作一个独立的字符,而 Unicode 中将其视为两个字符的组合。

同时一个字符也可能会有多种视觉上的字形表示,比如一个汉字有楷、行、草、隶等多种形体,这些都视为同一个抽象字符(即字符集编码是对字符而非字形编码),如何显示是字形库的事。

汉字“人”的不同形态:

抽象字符集有开放与封闭之分:开放的字符集指还会不断新增字符的字符集,封闭字符集是指不会新增字符的字符集。比如ASCII就是封闭式的,只有128个字符,以后也不会再加,但是Unicode是开放式的,会不断往里加新字符的,已经从最初的 7163 个增加到现在的144,697 个字符。

6.4、第二层:编号字符集 CCS

编号字符集就是对抽象字符集里的每个字符进行编号,映射到一个非负整数的集合

编号一般用方便人类阅读的十进制、十六进制来表示,比如“A”字符编号“65”,“B”字符编号是“66”。

大家需要清楚对于有些字符编码的编号就是存储的二进制序列,如ASCII编码;有些字符编码的编号跟存储的二进制序列并不一样,比如GB2312、Unicode等。

另外:编号字符集合是有范围限制的,比如ASCII字符集范围是0~127,ISO-8859-1范围是0~256,而GB2312是用一个94*94的二维矩阵空间来表示,Unicode是用Plane平面空间的概念来表示,这称为字符集的编号空间。

编号空间中的一个位置称为码点( Code Point 代码点 )。一个字符占用的码点所在的坐标(非负整数值对)或所代表的非负整数值,就是该字符的码值(码点编号)。

ASCII码点编号:

6.5、第三层:字符编码方式 CEF

抽象字符集和编号字符集是站在方便我们理解的角度来看的,所以最后我们需要翻译成计算机能懂的语言,将十进制的编号转换成二进制的形式。

因此:字符编码方式就是将字符集的码点编号,转换成二进制码元序列( Code Unit Sequence )的过程。

码元:字符编码的最小处理单元,比如ASCII一个字符等于一个字节,属于单字节码元;UTF-16一个字符等于两个字节,处理过程是按字“word”来处理,所以是双字节码元;UTF-8是多字节编码,有单字节字符,也有多字节字符,每次处理是按单个单个字节解析处理,所以处理最小单位是字节,也属于单字节码元。

这里大家可能会有疑问:十进制直接转二进制不就好了吗,为什么要单独抽出这么一层?

早期的字符编码确实也是这么处理的,十进制和二进制之间是直接转换过去的,比如ASCII码,字符“A”的十进制是“65”,那对应的二进制就是“1000001”,同时存储到硬盘里的也是这个二进制,所以那时候的编码比较简单。

随着后来多字节字符编码(Muilti-Bytes Character Set,MBCS多字节字符集)的出现,字符编号和二进制之间不是直接转换过去的,比如GB2312编码,“万”字的区位编号是“45,82”,对应的二进制机内码却是“1100 1101 1111 0010”(其十进制是“205,242”)。

如果这里不转换直接映射成二进制码会出什么问题呢?“万”字的字符编号“45,82”,45在ASCII里是“-”,82是“U”,那到底是显示两个字符“-U”还是显示一个字符“万”字,为了避免这种冲突 所以增加了前缀处理,详细的过程会在下文具体来讲解。

6.6、第四层:字符编码方案 CES

字符编码方案也称作“序列化格式“( Serialization Format ),指的是将字符编号进行编码之后的码元序列映射为字节序列(即字节流)的形式,以便经过编码后的字符能在计算机中进行处理、存储和传输。

字符编码方式CEF有点像我们数据库结构设计里的逻辑设计,而这一层编码方案CES就像是物理设计了,将码元序列映射为跟特定的计算机系统平台相关的物理意义上的二进制过程。

这里大家可能又会有疑问:为什么二进制的码元序列和实际存储的二进制又会不一样呢?

这主要是计算机的大小端序造成的,具体端序内容会在UTF-16编码部分详细介绍。

“大小端序名词”出自Jonathan Swift的《格列夫游记》一书 :

所有人都认为,吃鸡蛋前,原始的方法是打破鸡蛋较大的一端。可是当今皇帝的祖父小时候吃鸡蛋,一次按古法打鸡蛋时碰巧将一个手指弄破了,因此他的父亲,当时的皇帝,就下了一道敕令,命令全体臣民吃鸡蛋时打破鸡蛋较小的一端,违令者重罚。

老百姓们对这项命令极为反感。历史告诉我们,由此曾发生过六次叛乱,其中一个皇帝送了命,另一个丢了王位…关于这一争端,曾出版过几百本大部著作,不过大端派的书一直是受禁的,法律也规定该派的任何人不得做官。

▲ 图片引用自《面试必考,史上最通俗大小端字节序详解

对大小端字节序问题感兴趣的可以详读:《脑残式网络编程入门(九):面试必考,史上最通俗大小端字节序详解》一文。

七、常见字符编码1:ASCII

很久以前:计算机制造商都是按各自的方式来将字符渲染到屏幕上,当时的计算机动不动可就是一套房子的大小,这家伙可不是谁都能玩的起的,那时人们并不关心计算机如何交流。

随着上世纪七八十年代微处理器的出现,计算机变得越来越小,个人计算机开始进入大众的视线,随后出现了井喷式的发展,但是之前厂商都是各自为政,没考虑过自家的产品要兼容别人家的东西,导致在不同计算机体系间的数据转换变得十分蛋疼,因此美国的标准协会在1967年制定出了ASCII编码,到目前为止共定义了128个字符。

ASCII 编码:

(注意:该表是列表示字节高 4 位。上图引用自《字符编码那点事:快速理解ASCII、Unicode、GBK和UTF-8》)

其中:前 32 个(0~31)是不可见的控制字符,32~126 是可见字符,127 是 DELETE 命令(键盘上的 DEL 键)。

其实:早在ASCII之前,IBM在1963年也推出过一套字符编码系统EBCDIC,跟ASCII码一样囊括了控制字符、数字、常用标点、大小写英文字母。

EBCDIC 编码:

但是:他的字符编号并不是连续的,这给后续程序处理带来了麻烦,后来ASCII 编码吸取了 EBCDIC 的经验教训,给英文单词分配了连续的编码,方便程序处理,因此被后来广泛接受。

ASCII 和 EBCDIC 编码相比:除了字符连续排列之外,最大的优点是ASCII 只用了一个字节的低 7 位,最高位永远是 0。可别小看了这个最高位的 0,看似无足轻重,但这是ASCII设计最成功的地方,后面介绍各编码原理的时候你会发现,正是因为这个高位0,其它编码规范才能对 ASCII 码无缝兼容,使得 ASCII 被广泛接受。

八、常见字符编码2:ISO-8859系列

美国市场虽然统一了字符编码,但是计算机制造商在进入欧洲市场的时候又遇到了麻烦。。。

欧洲的主流语言虽然也是用拉丁字母,但却存在很多扩展体,比如法语的“é”,挪威语中的“Å”,都无法用 ASCII 表示。但是大家发现ASCII后面的128个还没有被使用可以利用起来,这对于欧洲主流语言就足够了。

于是就有了大家所熟知的这个ISO-8859-1(Latin-1),它只是扩展了ASCII后128个字符,还是属于单字节编码。同时为了兼容原先的 ASCII码,当最高位是0的时候仍然表示原先的 ASCII 字符不变,当最高位是1的时候表示扩展的欧洲字符。

但是到这里还没有完:刚说了这只是欧洲主流的语言,但主流语言里没有法语使用的 œ、Œ、Ÿ 三个字母,也没有芬兰语使用的 Š、š、Ž、ž ,而单字节编码里的256个码点都被用完了,于是就出现了更多的变种 ISO-8859-2/3/.../16 系列,他们都兼容 ASCII,但彼此间又不完全兼容。

ISO-8859-n系列字符集如下:

  • 1)ISO8859-1 字符集,也就是 Latin-1,是西欧常用字符,包括德法两国的字母;
  • 2)ISO8859-2 字符集,也称为 Latin-2,收集了东欧字符;
  • 3)ISO8859-3 字符集,也称为 Latin-3,收集了南欧字符;
  • 4)ISO8859-4 字符集,也称为 Latin-4,收集了北欧字符;
  • 5)ISO8859-5 字符集,也称为 Cyrillic,收集了斯拉夫语系字符;
  • 6)ISO8859-6 字符集,也称为 Arabic,收集了阿拉伯语系字符;
  • 7)ISO8859-7 字符集,也称为 Greek,收集了希腊字符;
  • .......

九、常见字符编码3:GB系列

9.1、概述

当计算机进入东亚国家的时候,厂商们更傻眼了,美国和欧洲国家语言基本都是表音字符,一个字节就足够用了,但亚洲国家有不少是表意字符,字符个数动辄几万十几万的,一个字节完全不够用。

所以我们国家有关部门按照ISO规范设计了GB2312双字节编码。但是GB2312是一个封闭字符集,只收录了常用字符总共也就7000多个字符,因此为了扩充更多的字符包括一些生僻字,才有了之后的GBK、GB18030、GB13000(“GB” 为 “国标” 的汉语拼音首字母缩写)。

按照 GB 系列编码方案,在一段文本中,如果一个字节是 0~127,那么这个字节的含义与 ASCII 编码相同,否则,这个字节和下一个字节共同组成汉字(或是 GB 编码定义的其他字符),所以GB系列都是兼容ASCII编码的。

9.2、GB2312

GB2312是使用两个字节来表示汉字的编码标准,共收入汉字6763个和非汉字图形字符682个。

为了避免与 ASCII 字符编码(0~127)相冲突,规定表示一个汉字的编码字节其值必须大于127(即字节的最高位为 1 ),并且必须是两个大于 127 的字节连在一起来共同表示一个汉字( GB2312 为双字节编码),所以GB2312 属于变长编码,当是英文字符的时候占一个字节,中文字符的时候占两个字节,可以认为 GB2312是对 ASCII 的中文扩展。

GB2312字符集编号空间是一个94*94的二维表,行表示区(高位字节),列表示位(低位字节),每区有94个位,每个区位对应一个字符,称为区位码。区位码上加2020H,就得到国标码,国标码上加8080H,就得到常用的计算机机内码。

这里引入了区位码、国标码、机内码概念,下面我们说下三者的关系。

9.2.1国标码

国标码是我国汉字信息交换的标准编码,规定由4位16进制数组成,用两个低7位字节表示,为了避开 ASCII 字符中的前32个控制指令字符,所以每个字节都是从第33个编号开始。

如下图所示:

9.2.2区位码

由于上述国标码的16进制可编码区不够直观不方便我们使用,所以我们把他映射成了十进制的94*94二维表编号空间,我们称之为区位码,同时区位码也可以当成一种外码使用,输入法可以直接切换成区位码进行汉字输入。

不过这种输入法无规则可言 人们很难记住区位编号,用的人也不多了。

下图是区位码的二维表,比如“万”字是45 区 82 位,所以“万” 字的区位码是“45,82”。

其中:

  • 1)01~09区(682个):特殊符号、数字、英文字符、制表符等(包括拉丁字母、希腊字母、日文平假名及片假名字母、俄语西里尔字母等在内的682个全角字符);
  • 2)10~15区:空区,留待扩展;
  • 3)16~55区(3755个):常用汉字(也称一级汉字),按拼音排序;
  • 4)56~87区(3008个):非常用汉字(也称二级汉字),按部首/笔画排序;
  • 5)88~94区:空区,留待扩展。

9.2.3机内码

GB2312国标码规范是覆盖掉ASCII中可见部分的符号和英文字母,使用两个7位码将其中的英文字母和符号重新编入。

但是这样产生一个弊端:早期用ASCII码编码的英文文章无法打开,一打开就是乱码,也就是说应该要兼容早期ASCII码而不是覆盖它。

后来微软为了解决这个问题:将字节的最高位设为1,因为ASCII中使用7位,最高位为0,转换后的编码称为机内码(内码),这种方式本质上是修改了GB2312的编码标准,最后被大家接受沿用。

总结下三者转换关系:区位码 ---> 区码和位码分别 + 32(即 + 20H )得到国标码 ---> 再分别 + 128(即 + 80H)得到机内码(与 ACSII 码不再冲突)。

9.3、GBK

GBK即“国标扩展”的意思,因为GB2312双字节的最高位都要求大于1,上限也不会超过1万个字符,所以对此进行了扩展,对GB2312的字符不重新编码直接沿用,因此完全兼容GB2312。

GBK虽然也是双字节编码,但是只要求第一个字节大于 127 就固定表示这是一个汉字的开始,正因为如此,GBK的编码空间比GB2312大很多。

GBK 整体编码范围为 8140-FEFE,首字节在 81-FE 之间,尾字节在 40-FE 之间,剔除 xx7F 一条线,总计 23940 个码位,共收入 21886 个汉字和图形符号。其中 GBK/1 收录除 GB 2312 字符外的其他增补字符,GBK/2 收录 GB2312 字符,GBK/3 收录 CJK 字符,GBK/4 收录 CJK 字符和增补字符,GBK/5 为非中文字符,UDC 为用户自定义字符。

详细如下如所示:

这里大家可能会有两个疑问:为什么尾字节要从40开始,而不是00开始?为什么要排除 FF、xx7F这两条线的编号?

GBK的尾字节编码高位没有强制要求是1,当高位是0时跟ASCII码是冲突的,ASCII码里00-40之间大部分都是控制字符,所以排除控制字符主要是为了防止丢失高字节导致出现系统性严重后果。

排除FF是为了兼容GB2312,GB2312这个位是保留不使用的;而7F表示DEL字符就是向后删除一个字符,如果传输过程中丢失首字节那么就会出现严重的后果,所以需要将xx7F也排除,这是所有编码方案都需要注意的地方。

9.4、GB18030

随着计算机的发展,GBK的2万多个字符也还是扛不住。

于是2000年我国又制定了新标准 GB18030,用来替代 GBK 标准。GB18030是强制性标准,现在在中国大陆销售的软件都支持 GB18030。

GB18030其实是对齐Unicode标准的,里面包括了所有Unicode字符集,也算是Unicode的一种实现(UTF)。

那既然有了UTF我们为什么还要搞一套Unicode实现?

主要是UTF-8/UCS-2他们是不兼容GB2312的,如果直接升级那么就全乱码了,所以GB18030是为了兼容GB系列,是GBK、GB2312的超集,当我们原先的GB2312(GBK)软件考虑升级到国际化Unicode时,可以直接使用GB18030进行升级。

GB18030虽然也是GB2312的扩展,但它和GBK的扩展方式不一样,GBK主要是充分利用了GB2312的一些没定义的编码空间,而GB18030采用的是字节变长编码,单字节区兼容ASCII、双字节区兼容GBK、四字节区对齐所有Unicode 码位。

实现原理上主要是采用第二字节未使用到的0x30~0x39编码空间来判断是否四字节。

具体就是:

  • 1)单字节,其值从0到0x7F。
  • 2) 双字节,第一个字节的值从0x81到0xFE,第二个字节的值从0x40到0xFE(不包括0x7F)。
  • 3) 四字节,第一个字节的值从0x81到0xFE,第二个字节的值从0x30到0x39,第三个字节的值从0x81到0xFE,第四个字节的值从0x30到0x39。

十、常见字符编码4:UNICODE

10.1、背景介绍

在统一码之前,各国创造了大量的节编码标准,有单字节的、双字节的(如 GB 2312、Shift JIS、Big5 、ISO8859等),各自又相互不兼容。在1987 年,苹果、Sun、微软等公司开始讨论囊括全世界所有字符的统一编码标准,组成了 Unicode 联盟,这个期间做了很多研讨工作,讨论核心要点如下。

1)目前世界上有多少个字符,需要几个字节存储?

工作组统计了当时全世界的报纸等刊物,结论是两个字节足以囊括全世界有实用意义的字符(当然这只统计了当前使用的字符,不包括古代语言或者废弃语言)。

2)采用固定长度编码还是变长编码?

一种采用变长编码形式,对于 ASCII 字符使用一个字节,其他字符使用两个字节,类似 GBK。另一种采用定长编码形式,不管是不是 ASCII 字符统一使用两个字节。

方案选择上主要从计算机处理过程中的时间和空间两个维度,也就是编解码的执行效率和存储大小两方面。

最后结论是采用双字节定长编码,因为定长带来的空间变大在整体传输、存储成本上其实影响并不大,而定长编码处理效率会明显高于变长编码,所以早期 Unicode 采用了定长编码形式。

3)中、日、韩中有很多相近的表意文字是否可以统一?

由于汉字表意文字字符量较大,如果可以统一那么能大幅减少收录汉字的数量。

所以最初收录汉字遵循两个基本原则:表意文字认同原则和字源分离原则。

所谓表意文字认同原则:即“只对字,不对形”编码,将同一字的不同字形(即异体字)合并。例如“房”字的第一笔,在中日韩的写法都不同,但它本身是同一个字,只给一个编码,而写法的不同交由字体进行区分。

字源分离原则:是指一个字源中同时收录了同一个字的不同字形,则给予两个字形分别编码。例如:之前GBK中就收录了“戶”、“户”、“戸”三个字,那么Unicode也需要保留三个字,如果直接合并会造成使用上的困扰。

例如下面这句话如果不做字源分离,会是什么情况呢?

原句 :户有三种写法,分别是“戶”、“户”、“戸”,

改写后:户有三种写法,分别是“户”、“户”、“户”

10.2、Unicode介绍

Unicode 称为统一码(也叫万国码),是按现代编码模型进行设计的一套字符编码体系,涵盖抽象字符集、编号、逻辑编码、编码实现。

Unicode是为了解决传统的字符编码方案的局限而产生的,在这种语言环境下,不会再有语言的编码冲突,可以在同屏下显示任何国家的语言。

UTF-n编码(Unicode Transformation Format Unicode字符集转换格式,n表示码元位数)是Unicode这套编码体系里的编码实现CES部分,像UTF-8、UTF-16、UTF-32都是将数字转换到实际的二进制编码实现,Unicode的编码实现除了UTF系列之外,还有UCS-2/4,GB18030等。但是现在很多人误把Unicode当成只是一个字符编号,这其实是不对的。

Unicode可以容纳世界上所有国家的文字和符号,其编号范围是0-0x10FFFF,有1,114,112个码位,为了方便管理划分成17个平面,现已定义的码位有238,605个,分布在平面0、平面1、平面2、平面14、平面15、平面16。其中平面0又称为基本多语言平面(Basic Multilingual Plane,简称BMP),这个平面基本涵盖了当今世界上正在使用中的常用字符。我们平常用到的字符,一般都是位于 BMP 平面上的,其范围拥有 65,536 个码点,其他平面统称增补平面,关于平面的概念会在UTF-16章节详细介绍。

10.3、与UCS的关系

说起Unicode我们不得不提UCS(全称Universal Multiple-Octet Coded Character Set 通用多八位编码字符集),国际标准编号ISO/IEC 10646,是由 ISO 和 IEC 两家国际标准组织联合成立的工作组设计的一套新的统一字符集项目,目的与Unicode 联盟一样致力于开发一款全世界通用的编码集。

早在1984 年ISO 和 IEC 两家组织就成立了一个联合工作组来设计一套新的统一字符集标准,但是这两个组织都不知道对方的存在,直到Unicode联盟1988年发布了Unicode草案(UCS草案1989年发布),才发现大家在做同一件事,没有必要搞两套标准 所以后面又考虑合并。

由于UCS 最初设计的是 31 位编码空间(UCS-4编码实现),可以容纳 2^31 约 21 亿个字符,而Unicode是16位空间(UTF-16编码实现),所以最开始Unicode 打算作为 UCS 的真子集,即 Unicode 中的每个字符都存在于 UCS 中,而且两者的码点相同,但 UCS 中的字符(编号超过65,536的)则不一定存在于 Unicode 中。

不过:由于双方利益关系并没有说谁解散谁,最后双方作出一些妥协保持一致共同发展,两个标准中相同字符的编码(码点)必须是一样的。这是一个屁股决定脑袋的决策,如果最初Unicode知道UCS的存在,就不会再出现Unicode了。

当然合并工作不是一蹴而就的而是经过多轮迭代, ISO/IEC 和 Unicode在 1993 年发布了第一版相互兼容版本,到了 1996年Unicode 2.0标准发布时,Unicode 字符集和 UCS 字符集(即 ISO/IEC 10646-1 )基本保持了一致,同时Unicode为了跟UCS的四字节保持一致推出了UTF-32编码实现,UCS为了跟Unicode的两字节保持一致推出了UCS-2编码实现。

所以:现在我们可以认为UCS和Unicode是同一个东西,比如我们常见的java内部运行就采用的是UTF-16编码,而window操作系统采用的是UCS-2,他们都是同一个Unicode标准。

为什么这里使用的是2字节编码,而不是4字节呢?先留个悬念,后续会详细讲解。

10.4、UTF-16(Java内部编码)

UTF是Unicode Transfer Format的缩写,即把Unicode转做某种格式的意思,所以UTF-16是Unicode编码里的其中一种实现方式,16代表的是字节位数,占两个字节(UTF-32则表示4个字节)。

Unicode 设计之初是采用UTF-16这种双字节定长编码的,其字符编号就是对应的二进制编号,也就是说第二层的CCS和第三层的CEF是一致的。比如汉字“万”的 Unicode 码点是 “U+4E07”,其二进制序列就是直译的“0100 1110 0000 0111 ”,这种编码方式的优点是高效,不需要检查标志位,但缺点是不兼容ASCII,ASCII编码的文本都会显示乱码。

不过:后来Unicode联盟发现 16 位编码空间根本不够用,与此同时 ISO/IEC组织也觉得 UCS的 32 位编码空间太多了,实际中根本没有几十亿字符,也挺浪费空间的。

所以最终 Unicode 联盟和 ISO/IEC 工作组达成一致:两者使用统一的编码空间“ 0000 ~ 10FFFF”(即 UCS 保证永远不分配大于 10FFFF 的字符码点),而且双方在字符编码上保持同步,即一方标准中增加了字符,也要通知另一方同步。

于是:Unicode在UTF-16基础上拓展编码空间到 21 位,UCS则搞了一个双字节的UCS-2编码实现。

UTF-16 编码是双字节的,上限也只有6w多个码点,怎么让他支持到10FFFF(100w+)个码点呢?

本质就是:多加几个字节来表示更多的字符,只是UTF-16不像UCS那样采用定长4字节,而是使用变长的形式,但是这个跟UTF-8变长方式又不太一样,他是采用代理对的方式实现,大部分常用字符用一个码元表示(定长2个字节),其他扩展的特殊字符用两个码元表示(定长4字节)。

10.4.1代理对

UTF-16跟UTF-8、GB系列等都算是变长字节,但是设计初衷却不一样,像GBK是为了兼容ASCII,但是UTF-16一开始就没考虑要兼容ASCII,所以他的变长是为了节约存储空间而采用的自然增长方案,当空间不够的时候增长到4个字节。

那问题来了,我怎么知道存储的4个字节是表示一个字符,还是两个字符呢?比如当程序遇到字节序列01001110 00101101 01010110 11111101时,到底是判断成一个字符还是两个字符?

这就需要一个前导识别,比如GB2312识别第一个字节高位是不是1来判断是单字节还是双字节,但是UTF-16的高位1已经被用来编码了,当然这也难不倒我们,第一位被用了那么就用前几位的组合形式。

UTF-16采用了代理对来解决,也就是高半区编码(前两个字节)范围D800-DBFF(称为代理码点),低半区编码(后两个字节)范围DC00-DFFF,组成一个四个字节表示的字符。

上述前导6位组合也是有讲究的,ISO组织要求编号范围是0~10FFFF,也就是说用20位就可以表示10FFFF个字符,对于双码元就是每个码元各自负责10位,一个码元是16位,数字位占去10位后,剩下的6位做为前导位。

当UTF-16使用一个码元表示的时候,Unicode字符编号跟码元序列是等值映射的,但是当采用双码元后,字符编号跟码元序列就需要转换了。

下面是码元和Unicode编号值之间的计算公式。

换算码元序列(CH高半区/CL低半区):

换算字符编号(CH高半区/CL低半区):

10.4.2平面空间

UTF-16把编码空间0000 ~ 10FFFF切成了17个平面,其实就是划分成17个区块,每个平面空间码点数都是=65536个,第一个平面称为基本多语言平面(Basic Multilingual Plane,简称BMP),这个平面涵盖了当今世界上最常用的字符,固定使用定长两个字节,除此之外的字符都放到增补平面里,都是使用两个码元的定长4个字节。

下面是各个平面的用途:

增补平面的编号是采用双码元4个字节来表示的,去除代理对之后有效位数是20位,然后将这20位的编号再划成16个平面区域,其中高半区的数字位里取出4位表示平面,剩下的16位表示每个平面可以表示的字符数也就是2的16次方65536个(两个字节大小)。

UTF-16可看成是UCS-2的父集。在没有辅助平面前,UTF-16与UCS-2所指的是同一的意思。但当引入辅助平面字符后,就称为UTF-16了。

10.4.3字节序

字节序顾名思义是指字节的顺序,对于单字节编码来说,一个字符对应一个字节,也就不存在字节序问题。但是对于UTF-16这种定长多字节编码,就有字节顺序问题了。

字节序其实跟操作系统和底层硬件有关,不仅只是UTF-16这种多字节编码存在字节序,只要是多字节类型的数据都存在字节顺序问题,比如short、int、long。

为了方便说明,我们这里举个例子:比如存一个整数值“305419896”对应16进制是0x12345678,有人习惯从左到右按顺序去存,也有人说高位当然要放到高位地址而低位放到低位地址,要从右往左存。

于是就有了下面两种存取方式:

其实这两种方式没有孰优孰劣,只是我们认知习惯有所不同 最终的设计不同,说来这都是阿拉伯人的锅啊,为什么数字高位非要在左边,这也引起了著名的大小端之争。

因此字节序也就有了大端和小端的概念,也形成了各自的阵营,比如Windows、FreeBSD、Linux 是小端序,Mac是大端序。其实大小端序并没有技术上的好坏之分。

小端序(Little-Endian):就是低位字节(即小端字节、尾端字节)存放在内存的低地址,而高位字节(即大端字节、头端字节)存放在内存的高地址。

大端序(Big-Endian ):就是高位字节(即大端字节、头端字节)存放在内存的低地址,低位字节(即小端字节、尾端字节)存放在内存的高地址。

▲ 图片引用自《面试必考,史上最通俗大小端字节序详解

对大小端字节序问题感兴趣的可以详读:脑残式网络编程入门(九):面试必考,史上最通俗大小端字节序详解》一文。

10.5、UTF-8

10.5.1概述

Unicode还是UCS最初都是采用多字节定长编码,由于没有兼容现有的 ASCII 标准的文件和软件,新标准很难被推广,于是兼容ASCII版本的UTF-8就诞生了。

UTF-8(8-bit Unicode Transformation Format)是一种针对Unicode的可变长度字符编码,是现代字符编码模型中的第三层 CEF 。它可以用一至四个字节对 Unicode 字符集中的所有有效编码点进行编码,属于Unicode标准的一部分,UTF-8 就是为了解决向后兼容 ASCII 码而设计,Unicode 中前 128 个字符(与 ASCII 码一一对应),使用与 ASCII 码相同的二进制值的单个字节进行编码,这使得原来处理 ASCII 字符的软件无须或只须做少部分修改,即可继续使用。因此,它逐渐成为电子邮件、网页及其他存储或发送文字优先采用的编码方式。

—— 维基百科

UTF-8需要兼容ASCII,所以也需要有前缀码来控制,前缀规则如下:

  • 1)如果首字节以 0 开头,则是单字节编码(即单个单字节码元);
  • 2)如果首字节以 110 开头,则是双字节编码(即由两个单字节码元所组成的双码元序列);
  • 3)如果首字节以 1110 开头,则是三字节编码(即由三个单字节码元所组成的三码元序列),以此类推。

理论上UTF-8变长可以超过4个字节,只是Unicode联盟规范上限是10FFFF,所以UTF-8规则设计上也限制了大小。

10.5.2程序算法

用文字不太好描述算法结构,我们就直接来欣赏一下UTF-8鼻祖写的这段解析代码,这是Ken Thompson(B语言、C语言的作者、Unix之父)和 Rob Pike 用一个晚上写出来的编解码算法,代码非常简短精炼,为了方便阅读我加了注释解读。

typedefstruct

  intcmask; //前缀码掩码

  intcval;  //前缀码

  intshift; //移动位数

  longlmask; //Unicode值掩码

  longlval;  //Unicode下限值

Tab;

 

staticTab  tab[] =

  0x80, 0x00, 0*6, 0x7F,       0,         /* 1 byte sequence */

  0xE0, 0xC0, 1*6, 0x7FF,      0x80,      /* 2 byte sequence */

  0xF0, 0xE0, 2*6, 0xFFFF,     0x800,     /* 3 byte sequence */

  0xF8, 0xF0, 3*6, 0x1FFFFF,   0x10000,   /* 4 byte sequence */

  0xFC, 0xF8, 4*6, 0x3FFFFFF,  0x200000,  /* 5 byte sequence */

  0xFE, 0xFC, 5*6, 0x7FFFFFFF, 0x4000000, /* 6 byte sequence */

  0, /* end of table */

;

 

/**

* 把一个多字节序列转换为一个宽字符

*

* @param p 存放计算后的unicode值

* @param s 需要解析的UTF-8字节序列

* @param n 字节长度

* @return 解析的字节长度

*/

intmbtowc(wchar_t*p, char*s, size_tn)

  longl;  intc0, c, nc;  Tab *t;

  if(s == 0) return0;

  nc = 0;

  //异常校验(可不用关注)

  if(n <= nc) return-1;

  //c0 此处备份一下首字节,后续需要用到前缀码

  c0 = *s & 0xff;

  //l 保存 Unicode 结果

  l = c0;

  /* 遍历tab,从单字节结构->2字节结构->..依次检查找到对应tab */

  for(t=tab; t->cmask; t++)

    //字节数+1,字节数和tab结构是对应的,也就是当nc=1时 tab结构是单字节,nc=2是tab是两字节

    nc++;

    /* 判断前缀码跟当前的tab是否一致, 如果一致计算最终unicode值并返回*/

    if((c0 & t->cmask) == t->cval)

      //通过 & Unicode有效值掩码,移除高位前缀码,得到最终unicode值

      l &= t->lmask;

      //异常校验

      if(l < t->lval) return-1;

      //保存结果并反回

      *p = l;

      returnnc;

    

    //异常校验

    if(n <= nc) return-1;

    //读取下个字节;如果上面判断前缀码不一致,说明需要再读取下个字节

    s++;

    //计算有效位的值,目的是去除UTF-8 编码从第二个字节开始的高两位10

    // 例如 s=10101111、0x80=10000000 计算结果是00101111,这样就去除了高位前缀10

    c = (*s ^ 0x80) & 0xFF;

    //异常校验

    if(c & 0xC0) return-1;

    //重新计算unicode值,根据UTF-8规则c只有低 6 位有效,所以通过移位把c填入到l的低6位

    l = (l<<6) | c;

  

  //返回异常

  return-1;

10.5.3容错性

通过上面的程序我们知道:解析过程是一个字节一个字节往下处理的,我们在传输过程中如果发生局部的字节错误、丢失,或者中间有一个字节规则对不上,会不会影响整个文本的解析?

我们先来看下其他编码的容错情况:从对于单字节的ASCII码来说,丢失一个字节就丢失一个字符,并不影响后续文本的内容,比如Hello world,丢失b2字节后内容是Hllo world少个e而已。

我们再来看GB2312这种多字节编码:如果丢失了b2字节那么整个文本都乱套了,这是最糟糕的,大部分多字节编码都有类似问题,一旦出现错误可能导致整个文件都需要重传。

接下来我们看看UTF-8是如何避免这种“一颗老鼠屎坏了一锅粥”的情况:UTF-8 的码元序列的第一个字节指明了后面所跟字节的个数,比如首字节高位是0就表示单字节,110表示总共两个字节,1110表示三个字节依次类推,除首字节之外后续字节都是10开头。所以UTF-8的前缀码具有很强的鲁棒性,即使丢失、增加、改变个别字节也不会导致后续字符全部错乱这样的传递性、连锁性的错误问题。

十一、本文总结

看起来好像谁都懂的字符编码知识,深入了解之后发现也有这么浓重的发展历程,试想一下,如果计算机还是跟之前大型机一样,个人计算机没有井喷式发展起来就没有这些字符编码的事了,如果ASCII当初就设计成多字节编码,也没有后面UNICODE什么事了。

计算机字符编码发展历程其实就是一个很典型的架构设计问题。

到底好的架构是设计出来的,还是演化出来的?

有人说靠演化出来的:没有设计的产品架构是没有灵魂的,发展的路上死的很快。

有人说靠设计出来的:这是一种完美主义者,你超前设计个50年、100年等你设计出来了,说不定公司都已经倒闭了,有很多叫好不叫做的产品、架构也比比皆是。

其实:一个好的架构是既要靠设计又要靠演化,老话说的好三分靠设计七分靠演化,我们既要学会务实,也要懂得前瞻,至少我们首先需要活下来。

十二、参考资料

[1] Unicode中文编码表

[2] Every Developer Should Know About The Encoding

[3] 史上最通俗,彻底搞懂字符乱码问题的本质

[4] 字符编码那点事:快速理解ASCII、Unicode、GBK和UTF-8

[5] 面试必考,史上最通俗大小端字节序详解

附录:阿里技术文章汇总

1. SVM算法简介

1.1 SVM算法导入

在很久以前的情⼈节,大侠要去救他的爱人,但魔鬼和他玩了⼀个游戏。

魔⻤在桌⼦上似乎有规律放了两种颜⾊的球,说:

“你⽤⼀根棍分开它们?要求:尽量在放更多球之后,仍然适⽤。”

于是⼤侠这样放,⼲的不错?


然后魔⻤,⼜在桌上放了更多的球,似乎有⼀个球站错了阵营。


怎么办?

把分解的⼩棍⼉变粗。

SVM就是试图把棍放在最佳位置,好让在棍的两边有尽可能⼤的间隙。

现在即使魔⻤放了更多的球,棍仍然是⼀个好的分界线。

然后,在SVM ⼯具箱中有另⼀个更加重要的技巧( trick)。 魔⻤看到⼤侠已经学会了⼀个trick,于是魔⻤给了⼤侠⼀ 个新的挑战。


现在,⼤侠没有棍可以很好帮他分开两种球了,现在怎么办呢?

当然像所有武侠⽚中⼀样⼤侠桌⼦⼀拍,球⻜到空中。然后,凭借⼤侠的轻功,⼤侠抓起⼀张纸,插到了两种球的中间。


现在,从魔⻤的⻆度看这些球,这些球看起来像是被⼀条曲线分开了。


再之后,⽆聊的⼤⼈们,把上⾯的物体起了别名:

球—— 「data」数据
棍⼦—— 「classifier」分类
最⼤间隙——「optimization」最优化
拍桌⼦——「kernelling」核⽅法
纸——「hyperplane」超平⾯

案例来源:http://bytesizebio.net/2014/02/05/support-vector-machines-explained-well/

⽀持向量机直观感受https://www.youtube.com/watch?v=3liCbRZPrZA

1.2 SVM算法定义

1.2.1 定义

SVM:SVM全称是supported vector machine(⽀持向量机),即寻找到⼀个超平⾯使样本分成两类,并且间隔最⼤。

SVM能够执⾏线性或⾮线性分类、回归,甚⾄是异常值检测任务。它是机器学习领域最受欢迎的模型之⼀。SVM特别适⽤于中⼩型复杂数据集的分类。

1.2.2 超平面最大间隔介绍


上左图显示了三种可能的线性分类器的决策边界:

虚线所代表的模型表现⾮常糟糕,甚⾄都⽆法正确实现分类。其余两个模型在这个训练集上表现堪称完美,但是它们的决策边界与实例过于接近,导致在⾯对新实例时,表现可能不会太好。

右图中的实线代表SVM分类器的决策边界,不仅分离了两个类别,且尽可能远离最近的训练实例。

1.2.3 硬间隔和软间隔

1.2.3.1 硬间隔分类

在上⾯我们使⽤超平⾯进⾏分割数据的过程中,如果我们严格地让所有实例都不在最⼤间隔之间,并且位于正确的⼀ 边,这就是硬间隔分类。

硬间隔分类有两个问题,⾸先,它只在数据是线性可分离的时候才有效;其次,它对异常值⾮常敏感。

当有⼀个额外异常值的鸢尾花数据:左图的数据根本找不出硬间隔,⽽右图最终显示的决策边界与我们之前所看到的⽆ 异常值时的决策边界也⼤不相同,可能⽆法很好地泛化。

1.2.3.2 软间隔分类

要避免这些问题,最好使⽤更灵活的模型。⽬标是尽可能在保持最⼤间隔宽阔和限制间隔违例(即位于最⼤间隔之上, 甚⾄在错误的⼀边的实例)之间找到良好的平衡,这就是软间隔分类。

要避免这些问题,最好使⽤更灵活的模型。⽬标是尽可能在保持间隔宽阔和限制间隔违例之间找到良好的平衡,这就是软间隔分类。


在Scikit-Learn的SVM类中,可以通过超参数C来控制这个平衡:C值越⼩,则间隔越宽,但是间隔违例也会越多。上图显示了在⼀个⾮线性可分离数据集上,两个软间隔SVM分类器各⾃的决策边界和间隔。

左边使⽤了⾼C值,分类器的错误样本(间隔违例)较少,但是间隔也较⼩。

右边使⽤了低C值,间隔⼤了很多,但是位于间隔上的实例也更多。看起来第⼆个分类器的泛化效果更好,因为⼤多数间隔违例实际上都位于决策边界正确的⼀边,所以即便是在该训练集上,它做出的错误预测也会更少。

1.3 小结

  • SVM算法定义
    • 寻找到⼀个超平⾯使样本分成两类,并且间隔最⼤。
  • 硬间隔和软间隔
    • 硬间隔
      • 只有在数据是线性可分离的时候才有效
      • 对异常值⾮常敏感
  • 软间隔
    • 尽可能在保持最⼤间隔宽阔和限制间隔违例之间找到良好的平衡

2. SVM算法API初步使用

>>> from sklearn import svm 
>>> X = [[0, 0], [1, 1]] 
>>> y = [0, 1] 
>>> clf = svm.SVC() 
>>> clf.fit(X, y) 
SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
 decision_function_shape='ovr', degree=3, gamma='scale', kernel='rbf', 
 max_iter=-1, probability=False, random_state=None, shrinking=True, 
 tol=0.001, verbose=False)

在拟合后, 这个模型可以用来预测新的值:

>>> clf.predict([[2., 2.]]) 
array([1])

3. SVM算法原理

3.1 定义输入数据

假设给定⼀个特征空间上的训练集为:

至于为什么正负用(-1,1)表示呢?
其实这⾥没有太多原理,就是⼀个标记,你也可以⽤(2,-3)来标记。只是为了⽅便,y /y = y ∗ y 的过程中刚好 可以相等,便于之后的计算。)

3.2 线性可分支持向量机

⽐如我们看到的特征有2个:
x1, x2,组成最先⻅到的线性函数可以是w x + w x .
但也许这两个特征并不能很好地描述数据,于是我们进⾏维度的转化,变成了
w1 x1 + w2 x2 + w3 x1 x2 + w4 x1^2 + w5 x2^2 .
于是我们多了三个特征。⽽这个就是笼统地描述x的映射的。
最简单直接的就是:Φ(x) = x

以上就是线性可分⽀持向量机的模型表达式。我们要去求出这样⼀个模型,或者说这样⼀个超平⾯y(x),它能够最优地分离两个集合。

其实也就是我们要去求⼀组参数(w,b),使其构建的超平⾯函数能够最优地分离两个集合。

如下就是⼀个最优超平⾯:


⼜⽐如说这样:


阴影部分是⼀个“过渡带”,“过渡带”的边界是集合中离超平⾯最近的样本点落在的地⽅。

3.3 SVM的计算过程与算法步骤

3.3.1 推导目标函数

我们知道了⽀持向量机是个什么东⻄了。现在我们要去寻找这个⽀持向量机,也就是寻找⼀个最优的超平⾯。

于是我们要建⽴⼀个⽬标函数。那么如何建⽴呢?


拓展:什么是∣∣w∣∣?

3.3.2 目标函数的求解

到这⼀步,终于把目标函数给建立起来了。

那么下⼀步⾃然是去求目标函数的最优值.

因为⽬标函数带有⼀个约束条件,所以我们可以⽤拉格朗⽇乘⼦法求解

3.3.2.1 朗格朗⽇乘⼦法

啥是拉格朗⽇乘⼦法呢?

拉格朗⽇乘⼦法 (Lagrange multipliers)是⼀种寻找多元函数在⼀组约束下的极值的⽅法.

通过引⼊拉格朗⽇乘⼦,可将有 d 个变量与 k 个约束条件的最优化问题转化为具有 d + k 个变量的⽆约束优化问题求解。

3.3.2.2 对偶问题


参考资料:https://wenku.baidu.com/view/7bf945361b37f111f18583d049649b6649d70975.html

如何获取对偶函数?


3.3.2.3 整体流程确定


3.4 举例


1) ⾸先确定⽬标函数


2) 求得⽬标函数的极值



ps:参考的另⼀种计算⽅式:https://blog.csdn.net/zhizhjiaodelaoshu/article/details/97112073

3.5 小结


4. SVM的损失函数

在SVM中,我们主要讨论三种损失函数:


小结

  • SVM的损失函数
    • 0/1损失函数
    • Hinge损失函数
    • Logistic损失函数

5. SVM的核方法

【SVM + 核函数】 具有极⼤威⼒。

核函数并不是SVM特有的,核函数可以和其他算法也进⾏结合,只是核函数与SVM结合的优势⾮常⼤。

5.1 什么是核函数

5.1.1 核函数概念

核函数,是将原始输⼊空间映射到新的特征空间,从⽽,使得原本线性不可分的样本可能在核空间可分。

下图所示的两类数据,分别分布为两个圆圈的形状,这样的数据本身就是线性不可分的,此时该如何把这两类数据分开呢?

  • 假设X是输⼊空间,
  • H是特征空间,
  • 存在⼀个映射ϕ使得X中的点x能够计算得到H空间中的点h,
  • 对于所有的X中的点都成⽴:

5.1.2 核函数举例

5.1.2.1 核方法举例1



经过上⾯公式,具体变换过过程为:

5.1.2.2 核方法举例2

  • 下⾯这张图位于第⼀、⼆象限内。我们关注红⾊的⻔,以及“北京四合院”这⼏个字和下⾯的紫⾊的字⺟。
  • 我们把红⾊的⻔上的点看成是“+”数据,字⺟上的点看成是“-”数据,它们的横、纵坐标是两个特征。
  • 显然,在这个⼆维空间内,“+”“-”两类数据不是线性可分的。


(前后轴为x轴,左右轴为y轴,上下轴为z轴)

  • 绿⾊的平⾯可以完美地分割红⾊和紫⾊,两类数据在三维空间中变成线性可分的了。
  • 三维中的这个判决边界,再映射回⼆维空间中:是⼀条双曲线,它不是线性的。
  • 核函数的作⽤就是⼀个从低维空间到⾼维空间的映射,⽽这个映射可以把低维空间中线性不可分的两类点变成线性可分的。

5.2 常见核函数

1.多项核中,d=1时,退化为线性核;
2.高斯核亦称为RBF核。


5.3 小结

  • SVM的核方法
    • 将原始输⼊空间映射到新的特征空间,从而,使得原本线性不可分的样本可能在核空间可分。
    • 常见核函数
      • 线性核
      • 多项式核
      • RBF核
      • Sigmoid核

6. SVM回归

SVM回归是让尽可能多的实例位于预测线上,同时限制间隔违例(也就是不在预测线距上的实例)。

线距的宽度由超参数ε控制。

7. SVM算法API再介绍

7.1 SVM算法API综述

  • SVM⽅法既可以⽤于分类(⼆/多分类),也可⽤于回归和异常值检测。
  • SVM具有良好的鲁棒性,对未知数据拥有很强的泛化能⼒,特别是在数据量较少的情况下,相较其他传统机器学习算法具有更优的性能。

使⽤SVM作为模型时,通常采⽤如下流程:

  1. 对样本数据进⾏归⼀化
  2. 应⽤核函数对样本进⾏映射(最常采⽤和核函数是RBF和Linear,在样本线性可分时,Linear效果要⽐RBF好)
  3. ⽤cross-validation和grid-search对超参数进⾏优选
  4. ⽤最优参数训练得到模型
  5. 测试

sklearn中⽀持向量分类主要有三种⽅法:SVC、NuSVC、LinearSVC,扩展为三个⽀持向量回归⽅法:SVR、 NuSVR、LinearSVR。

  • SVC和NuSVC方法基本⼀致,唯⼀区别就是损失函数的度量⽅式不同
    • NuSVC中的nu参数和SVC中的C参数;
  • LinearSVC是实现线性核函数的⽀持向量分类,没有kernel参数。

7.2 SVC

class sklearn.svm.SVC(C=1.0, kernel='rbf', degree=3,coef0=0.0,random_state=None)
  • C: 惩罚系数,⽤来控制损失函数的惩罚系数,类似于线性回归中的正则化系数。

    • C越⼤,相当于惩罚松弛变量,希望松弛变量接近0,即对误分类的惩罚增⼤,趋向于对训练集全分对的情 况,这样会出现训练集测试时准确率很⾼,但泛化能⼒弱,容易导致过拟合。
    • C值⼩,对误分类的惩罚减⼩,容错能⼒增强,泛化能⼒较强,但也可能⽋拟合。
  • kernel: 算法中采⽤的核函数类型,核函数是⽤来将⾮线性问题转化为线性问题的⼀种⽅法。

    • 参数选择有RBF, Linear, Poly, Sigmoid或者⾃定义⼀个核函数。
      • 默认的是"RBF",即径向基核,也就是⾼斯核函数;
      • ⽽Linear指的是线性核函数,
      • Poly指的是多项式核,
      • Sigmoid指的是双曲正切函数tanh核;。
  • degree:

    • 当指定kernel为’poly’时,表示选择的多项式的最⾼次数,默认为三次多项式;
    • 若指定kernel不是’poly’,则忽略,即该参数只对’poly’有⽤。
      • 多项式核函数是将低维的输⼊空间映射到⾼维的特征空间。
  • coef0: 核函数常数值(y=kx+b中的b值),

    • 只有‘poly’和‘sigmoid’核函数有,默认值是0。

7.3 NuSVC

class sklearn.svm.NuSVC(nu=0.5)
  • nu: 训练误差部分的上限和⽀持向量部分的下限,取值在(0,1)之间,默认是0.5

7.4 LinearSVC

class sklearn.svm.LinearSVC(penalty='l2', loss='squared_hinge', dual=True, C=1.0)
  • penalty:正则化参数,
    • L1和L2两种参数可选,仅LinearSVC有。
    • loss:损失函数,
    • 有hinge和squared_hinge两种可选,前者⼜称L1损失,后者称为L2损失,默认是squared_hinge,
    • 其中hinge是SVM的标准损失,squared_hinge是hinge的平⽅
  • dual:是否转化为对偶问题求解,默认是True。
  • C:惩罚系数,
    • ⽤来控制损失函数的惩罚系数,类似于线性回归中的正则化系数。

7.5 小结

  • SVM的核⽅法
    • 将原始输⼊空间映射到新的特征空间,从⽽,使得原本线性不可分的样本可能在核空间可分。
  • SVM算法api
    • sklearn.svm.SVC
    • sklearn.svm.NuSVC
    • sklearn.svm.LinearSVC

8. 案例:数字识别器

学习目标

应用SVM算法实现数字识别器

请参考【机器学习】SVM算法案例:数字识别器

9. SVM总结

9.1 SVM基本综述

  • SVM是⼀种⼆类分类模型。
  • 它的基本模型是在特征空间中寻找间隔最⼤化的分离超平⾯的线性分类器。
    • 1)当训练样本线性可分时,通过硬间隔最⼤化,学习⼀个线性分类器,即线性可分⽀持向量机;
    • 2)当训练数据近似线性可分时,引⼊松弛变量,通过软间隔最⼤化,学习⼀个线性分类器,即线性⽀持向量机;
    • 3)当训练数据线性不可分时,通过使⽤核技巧及软间隔最⼤化,学习⾮线性⽀持向量机。

9.2 SVM优缺点

  • SVM的优点:
    • 在⾼维空间中⾮常⾼效;
    • 即使在数据维度⽐样本数量⼤的情况下仍然有效;
    • 在决策函数(称为⽀持向量)中使⽤训练集的⼦集,因此它也是⾼效利⽤内存的;
    • 通⽤性:不同的核函数与特定的决策函数⼀⼀对应;
  • SVM的缺点:
    • 如果特征数量⽐样本数量⼤得多,在选择核函数时要避免过拟合;
    • 对缺失数据敏感;
    • 对于核函数的⾼维映射解释⼒不强

加油!

感谢!

努力!

以上是关于史诗级计算机字符编码知识分享,万字长文,一文即懂!的主要内容,如果未能解决你的问题,请参考以下文章

史诗级干货长文朴素贝叶斯

❤️BitmapsHyperLogLogGeospatial❤️——Redis三大特殊数据类型详述(万字长文原理讲解,大厂面试高频知识点,一文尽收囊中)

史诗级干货长文支持向量机

史诗级干货长文集成学习进阶(XGBoost & lightGBM)

nlp 电商评论处理 -史诗级长文

C++知识分享: Socket 编程详解,万字长文