11-程序员的数学基础课

开篇词 | 作为程序员，为什么你应该学好数学？

你好，我是黄申，目前在 LinkedIn 从事数据科学的工作，主要负责全球领英的搜索引擎优化，算法和数据架构的搭建。

2006 年，我博士毕业于上海交通大学计算机科学与工程专业，在接下来十余年时间里，我曾经在微软亚洲研究院、IBM 研究院、eBay 中国研发中心做机器学习方向的研究工作，也负责过大润发飞牛网和 1 号店这两家互联网公司的核心搜索和推荐项目，还写过一本书《大数据架构商业之路》。

对于数学和计算机编程的联系，我之前也没有思考过。直到有一次，在硅谷的一个技术交流 Meetup 上，我听到一位嘉宾分享说：“如果你只想当一个普通的程序员，那么数学对你来说，并不重要。但是如果你想做一个顶级程序员，梦想着改变世界，那么数学对你来说就很重要了。”

听完这句话，我马上感受到强烈的共鸣，因为就我自己的工作经历而言，越是往高处走，就越能发现数学的重要性。我知道，数学对于我们每一个程序员来说，都是最熟悉的陌生人。你从小就开始学习数学，中考、高考、研究生考试还要考数学，所以那些熟悉的数学定理、数学公式，陪伴你至少也有 10 年时间了。

但是，自从做了程序员，你可能早就把数学抛在了脑后，甚至觉得曾经为了应试而“硬学”的数学应该是彻底没什么用了，终于可以和他们 say goodbye 了。毕竟作为一个基础学科，数学肯定是没操作系统、数据结构、计算机网络这样的课程看起来“实用”。

起码我之前就是这么认为的。大学的时候，我非常喜欢编程，甚至还翘过数学课，专门在图书馆看计算机类的图书。那会儿我觉得，数学这东西，完全就是应试教育，我更喜欢计算机这样操作类的课程，不喜欢待在教室里听数学老师讲那些枯燥的理论和定理。

再到后来，我读了硕士，开始接触机器学习，猛然间才发现，机器学习表面上是“写程序”，但实际上剥去外表，本质上就是在研究数学。从那会儿开始，我对数学的认知也才逐步客观和理性起来。

再到现在，我参加了工作，写了这么多年代码，我想说，数学学得好不好，将会直接决定一个程序员有没有发展潜力。因为往大了说，数学它其实是一种思维模式，考验的是一个人归纳、总结和抽象的能力。把这个能力放到程序员的世界里，其实就是解决问题的能力。

往小了说，不管是数据结构与算法还是程序设计，其实底层很多原理或者思路都是源自于数学，所以很多大公司，在招人时，也会优先考虑数学专业的毕业生，这些人他们数学基础很好，学起编程也更容易上手。

所以我觉得，如果编程语言是血肉，数学的思想和知识就是灵魂。它可以帮助你选择合适的数据结构和算法、提升系统效率、并且赋予机器智慧。尤其是在大数据和智能化的时代，更是如此。

举个例子，比如我们小学就学到的余数，其实在编程的世界里也有很多应用。你经常用到的分页功能，根据记录的总条数和每页展示的条数，最后来计算整体的页数，这里面就会有余数的思想。再难一点，奇偶校验、循环冗余检验、散列函数、密码学等等都有余数相关的知识。

遇到这些问题的时候，你能说你不懂余数吗？我想你肯定懂，只是很多时候没有想到可以用余数的思想来解决相关问题罢了。那为什么没有想到呢？我认为，本质原因还是你没有数学思维，还是你数学的基础不够好。

所以，在这个专栏里，我想和你重点聊聊数学。当然，我知道数学博大精深，所以在一开始做专栏的时候，我就和极客时间团队一起定义好了专栏的边界，用一句话来说就是“只做程序员需要学的数学知识”。

首先，我梳理了编程中最常用的数学概念，由浅入深剖析它们的本质，希望能够帮你彻底掌握这些最基础、也最核心的数学知识。这其中包括那些你曾经熟悉的数学名词，比如数学归纳法、迭代法、递归、排列、组合等等。

其次，我把线性代数和概率统计中的抽象概念、公式、定理都由内而外地讲了出来，并分析它们在编程中的应用案例，帮助你提升编程的高阶能力。对于这些内容，我会从基本的概念入手，结合生活和工作中的实际案例，让你更轻松地理解概念的含义。

比如，对于朴素贝叶斯方法，我会从基本的随机现象、随机变量和概率分布等着手。随后，我会逐步深入，结合这些数学知识在编程算法中的应用进行展开。比方说，贝叶斯定理是什么，随机变量之间的独立性是什么，这些是如何构成朴素贝叶斯方法的，而最终朴素贝叶斯又是如何被运用在机器学习的分类算法之中的。

这样的讲解路线，既能让你巩固基础的概念和知识，同时也能让你明白这些基础性的内容，对计算机编程和算法究竟意味着什么。

不过话又说回来，我认为数学理论和编程实践的结合其实是“决裂”的，所以学习数学的时候，你不能太功利，觉得今天学完明天就能用得着，我觉得这个学习思路可以用在其他课程上，但放在数学里绝对不合适。

因为数学知识总是比较抽象，特别是概率统计和线性代数中的概率、数据分布、矩阵、向量等概念。它们真的很不好理解，也需要我们花时间琢磨，但是对于高级一点的程序设计而言，特别是和数据相关的算法，这些概念就非常重要了，这可都是先人总结出来的经验。

如果你能够将这些基本概念和核心理论都搞懂、搞透，那么面对系统框架设计、性能优化、准确率提升这些难题的时候，你就能从更高的角度出发去解决问题，而不只是站在一个“熟练工”的视角，去增删改查。

最后，我希望数学能够成为你的一种基础能力，希望这个专栏能帮你用数学思维来分析问题和解决问题。数学思想是启发我们思维的中枢，如果你对数学有更好的理解，遇到问题的时候就能追本溯源，快、准、稳地找到解决方案。

伽利略曾经说过，“宇宙这本书是用数学语言写成的”，数学是人类科学进步的重要基础，所以，你我都要怀着敬畏的心态去学习、思考数学。同样，我还要求我自己的孩子一定要学好数学，因为我确信，这对于他未来的发展来说，至关重要。

编程的世界远不止条件和循环语句，程序员的人生应当是创造的舞台。我希望，通过这个专栏的学习，能够让你切实感受到数学这个古老学科的活力和魅力。

好了，说了这么多，相信你已经下定决心和我一起攻克数学。重新开始就要告别过去，你可以在留言区做个“数学学习复盘”，在之前的学习过程中，你的学习状况是怎样的？你遇到的最大困难是什么？现在，你最希望学到的是什么？

Now，你说，我听！

导读：程序员应该怎么学数学？

你好，我是黄申。

在开篇，我详细讲了程序员为什么需要学数学。那么，怎样的学习方法才是行之有效的呢？我想你现在心里还没有一个固定的答案，而我不想一味地去讲我自己的一家之言，毕竟没有什么学习方法是最好的。

你能做的是要多看，去找适合自己的。而我能做的，就是尽量给你更多的参考，让你可以自己来选择。所以，我邀请了几位朋友，让他们来谈一谈，自己学习数学的一些心得体会，希望对你有所启发。

刘超 | 《趣谈网络协议》专栏作者：“学数学就像学一门新技术”

程序员是否需要学好数学？原本学数学分析、概率论、线性代数的时候，我也没想到数学和写程序有啥关系，但是随着研究的开源软件越来越多，我发现很多技术深入下去，本质就是数学。

程序员应该怎么学习数学呢？我不建议你将大学的数学书拿出来啃一遍，一来耗费大量时间，二来和实际应用结合不起来，往往该看的忽略了，不该看的费了半天劲用不上，过一阵又忘了。

我们了解一个新技术有三个阶段，第一阶段是，怎么使用；第二阶段是，如何实现，原理是什么；第三阶段是，为什么这样实现。学数学和学一门新技术一样，也有这样三个阶段，先用起来，了解原理，然后了解为什么。

用一句话来说，我们不能为了数学而学数学，学数学要和具体的应用结合在一起。

徐文浩 | bothub.ai 创始人：“先广度，再深度”

随着过去几年深度学习成为程序员界的显学，不少程序员开始回头复习微积分、概率论和线性代数这样的基础数学课，乃至开始学习最优化、博弈论这样的应用数学课。我自己则是因为从 2010 年一头扎入了计算广告的大坑之后，重新开始学习数学。

可以说，这次重新学习数学，为我自己推开了程序世界中另一扇门，使得我在埋头具体写代码之外，有机会重新从另一个角度，去认识和理解“程序”和“问题的解决方案”这两件事情。

我为什么要学点数学?

投入时间学习数学，于我来说主要是两个原因，一来，数学在工作中用得上；二来，学点数学很多时候是个有趣的事。

譬如说，过去几年火起来的深度学习，以及之前没那么火，但是实际上早早就在搜索、广告这些领域，应用的机器学习和推荐算法。这里面其实就是结合了微积分、线性代数、概率论之后的最优化问题。

事实上，大部分应用领域的核心解决方案，都是把应用领域的问题，形式化为一个个数学问题。在找到数学问题的“解法”之后，用写程序的方式翻译成实际应用的“算法”。而能够应用“数学”的方式来解决问题，是从一个只能套用现成方案的“码农”，向能够将新问题形式化、并找出创新解决方案的“研发工程师”迈出的第一步。

很多问题当你知道如何用数学来解决的时候，常常会有醍醐灌顶的感觉。譬如当我第一次搞明白，广告中的竞价问题，居然能够变成一个博弈论中“寻找上策均衡”的问题，并且能够通过简简单单的公式表示出来的时候，我是很有满足感的。

此外，一旦熟悉了机器学习中用到的数学知识，很多想要解决的系统问题，都能通过定义更好的数学优化目标，变成一个能够找到最优解的程序算法，最后通过写个程序，翻译成数学问题来解决，这个过程带给我巨大的身心愉悦。

我是如何学数学的？

数学整个领域很大，如果想要学点什么，我建议从工作相关的领域开始，先广度，再深度。

从工作相关的领域开始，是让自己一是能有实际用得上学到的知识的机会，二是日常工作中容易耳濡目染，相当于常常在复习。而先有广度，是让自己在心中有一个问题到解决方法的“地图”，遇到具体的问题能够对得上，容易获得正反馈；然后再有深度，具体去对一个特定的主题学习应用。

当开始深入学一个特定问题的时候，最好的方式是，追一门在线课程，譬如 Coursera、TEDx，或者在极客时间上找一门课程来学习。

这是因为，在线课程有明确的节奏，通常还会提供作业和测验。通过作业和测验，让自己对自己的学习有一个联系和反馈的过程。即使实践中没有足够的应用，过一段时间有些知识没有那么熟悉了，但是也容易建立自己很快可以“捡”回来的信心，降低“复习”的启动成本。跟随在线课程的节奏，可以有效避免“三天打渔两天晒网”的恶习，让学习有始有终。

最后，给你介绍一个有程序员特色的学习方法，针对学习的内容写一点程序。把正在学习的问题的解法，写一个算法实现出来。这是一个非常有效的练习方式。譬如学习线性代数，理解仿射，反复读书的效率对我来说，就不如找来 Coding The Matrix，通过写程序，让学习、理解变得更深入。

王天一 | 《人工智能基础课》《机器学习 40 讲》专栏作者：“数学是工具而非问题，是手段而非目的”

在数学的学习中，首要的问题是明确需求。作为非数学专业出身的“外行”，我们使用数学的目的不是顶天，而是立地；不是上下求索艰深的理论问题，而是将生活中的具体问题抽象化，进而加以解决。

因此，对于我们这些票友来说，学习数学的基础在于经验而非哲学，比较实际的思路是秉持功利主义的原则，用多少学多少。掌握基本的线性代数与矩阵论、概率论与数理统计知识足以应付日常的使用，盲目地好高骛远通常有害无益。理论化和公理化这些比较深邃的尝试固然让人着迷，但它们可能并没有肉眼可见的实用性，对于绝大部分计算机从业者恐怕过于阳春白雪。

其次，在学习时还要理解数学的本质。数学是工具而非问题，是手段而非目的。探索世界奥秘的学科是“格物穷理”的物理学，相形之下，数学更像是个任人打扮的小姑娘，它存在的意义就是通过合理的设计简化物理学的研究。

正因如此，在数学中存在着各种各样在现实中不可能出现的理想化模型（比如无穷小和极限的诞生），也存在着对同一个物理过程不同的建模方式（比如矩阵力学和波动力学）。充分理解数学的人造特质，可以在学习中少走很多无谓的弯路。

理解数学的工具属性就会自然而然地引出了数学学习中的另一个关键点，那就是工具设计的出发点，也就是所谓的数学思想与数学逻辑。

任何一个工具都不是平白无故地设计出来的，它必然要解决某个特定的问题，比如线性代数与矩阵论是对具体对象的抽象表示与运算，比如概率论和数理统计是对不确定性及其定型定量表示的建模。因此，在掌握每一种数学工具的微观技巧之前，理解它们的宏观目标是更加重要的。只有掌握了工具诞生的背景与目的，才有可能有效地使用它们。

在这里还要多说一句，数学绝不仅仅是算术，把主要精力放在计算上未免因小失大。在经典科幻《银河系漫游指南》中，超级计算机告诉人们，世界的终极答案是“42”——这更像是对数字主义者善意嘲讽的一个梗。但对算术的过度强调并不鲜见，在相当数量的现行数学教材中，讲解线性代数时开篇便给出行列式的计算方法，这种编排着实让人费解。

具体到数学每个子学科的学习方法上，相关的经验教训已然汗牛充栋，很多内容都无需在此赘言。但在我看来，学习时值得突出强调的一点是举一反三的能力。同一种工具及其背后的思想可以出现在不同的场景下，解决不同的问题，但是一旦深入到本质层面，就会发现它们实际上是相通的。如何透过现象看本质，将不同场景融会贯通，才是值得锻炼的高级能力。

同一个工具存在不同应用的例子不胜枚举：

特征向量计算的是系统的不动点，在数据降维中有举足轻重的作用，但如果熟悉电子通信的话你就会知道，对线性时不变系统的分析（也就是各种变换）都是基于特征向量展开的；

在给定隐马尔可夫模型的观测序列时，可以利用维特比算法求解后验概率最大的状态序列，将这一方法应用在信道编码中，就是最经典的卷积码译码算法；

在分类问题中，以类间方差最大化为标准可以推导出线性判别分析和决策树等模型，应用在图像处理中，类间方差最大化原理给出的就是图像分割中的 Otsu 方法。

凡此种种都说明，即使是不同的学科，使用的数学基础也有着千丝万缕的联系，将基本的数学概念和充分的想象力结合起来，触类旁通就变得轻而易举。

总结起来，我对数学学习的几点拙见是：把握数学的工具属性，学习具体方法时先溯因再求果，勤于思考解决相同问题的不同方法，与解决不同问题的相同方法之间的联系与区别。希望这几条建议能够在数学的学习中助你一臂之力。

好了，三位老师都分享完了自己的学习方法。其实他们有一个共同的观点，那就是数学要是“实用”的，这和我的想法如出一辙。

因此，我在这里绘制了一张“程序员的数学应用地图”，里面包含四个部分，分别是数据结构、编程语句、基础算法，以及机器学习算法。

（长按保存后可查看大图）

我们来一起看看这张图里都有什么内容。

首先我们来看最常用的数据结构和编程语句，我想你对它们应该非常熟悉。在我眼里，这些基础的内容，同样富含了数学思维。例如，数组和链表就体现了迭代和递归的思想，判断语句就是使用了逻辑（布尔）代数。

对于架构在这些数据结构和编程语句之上的算法（为了将这些算法和机器学习的算法区分，我称其为通用算法），除了迭代和递归，也体现了排列、组合和动态规划等思想。

对于机器学习的算法而言，我们更需要理解概率统计和线性代数的核心思想，包括什么是概率、贝叶斯定理、数据的统计分布、向量、矩阵、线性方程等等。

整个专栏我基本上都是从数学的角度出发，逐步推进到这些知识在计算机中的应用。不过在绘制这张应用地图的时候，我特意反其道而行之，从计算机编程的角度出发，为你展示程序员应该如何看待编程中的数学知识。

我觉得在开始学习之前，这个地图会给你一个大体的认识，告诉你计算机领域常用的数学思想有哪些。这时，你也许会产生一些疑惑，同时你可以带着自己的思考和问题去逐篇学习。等你学完整个专栏之后，再回头来看看这个地图，应该会有更深的感触。我希望这种双向打通，能够进一步加强你的学习体验。

好了，听了这么多心得和方法，有没有什么地方是最触动你的呢？下一节我们就要进入正式的学习了，我想听你讲讲，你准备怎么来学习这门课呢？

欢迎留言和我分享，也欢迎点击“请朋友读”，把今天的内容分享给你的朋友，和他一起精进。

01 | 二进制：不了解计算机的源头，你学什么编程

我们都知道，计算机的起源是数学中的二进制计数法。可以说，没有二进制，就没有如今的计算机系统。那什么是二进制呢？为什么计算机要使用二进制，而不是我们日常生活中的十进制呢？如何在代码中操作二进制呢？专栏开始，我们就从计算机认知的起源——二进制出发，讲讲它在计算机中的“玄机”。

什么是二进制计数法？

为了让你更好地理解二进制计数法，我们先来简单地回顾一下人类计数的发展史。

原始时代，人类用路边的小石子，来统计放牧归来的羊只数量，这表明我们很早就产生了计数的意识。后来，罗马人用手指作为计数的工具，并在羊皮上画出Ⅰ、Ⅱ、Ⅲ来代替手指的数量。表示一只手时，就写成“Ⅴ”形，表示两只手时，就画成“ⅤⅤ”形等等。

公元 3 世纪左右，印度数学家（也有说法是阿拉伯人）发明了阿拉伯数字。阿拉伯数字由从 0 到 9 这样 10 个计数符号组成，并采取进位制法，高位在左，低位在右，从左往右书写。由于阿拉伯数字本身笔画简单，演算便利，因此它们逐渐在各国流行起来，成为世界通用的数字。

日常生活中，我们广泛使用的十进制计数法，也是基于阿拉伯数字的。这也是十进制计数法的基础。因此，相对其他计数方法，十进制最容易被我们所理解。

让我们来观察一个数字：2871。

其中 ^ 表示幂或次方运算。十进制的数位（千位、百位、十位等）全部都是 10^n 的形式。需要特别注意的是，任何非 0 数字的 0 次方均为 1。在这个新的表示式里，10 被称为十进制计数法的基数，也是十进制中“十”的由来。这个我想你应该好理解，因为这和我们日常生活的习惯是统一的。

明白了十进制，我们再试着用类似的思路来理解二进制的定义。我以二进制数字 110101 为例，解释给你听。我们先来看，这里 110101 究竟代表了十进制中的数字几呢？

刚才我们说了，十进制计数是使用 10 作为基数，那么二进制就是使用 2 作为基数，类比过来，二进制的数位就是 2^n 的形式。如果需要将这个数字转化为人们易于理解的十进制，我们就可以这样来计算：

按照这个思路，我们还可以推导出八进制（以 8 为基数）、十六进制（以 16 为基数）等等计数法，很简单，我在这里就不赘述了。

至此，你应该已经理解了什么是二进制。但是仅有数学的理论知识是不够的，结合相关的代码实践，相信你会有更深刻的印象。

基于此，我们来看看二进制和十进制数在 Java 语言中是如何互相转换的，并验证一下我们之前的推算。我这里使用的是 Java 语言来实现的，其他主流的编程语言实现方式都是类似的。

这段代码的实现采用了 Java 的 BigInteger 类及其 API 函数，我都加了代码注释，并且穿插一些解释，你应该可以看懂。

首先，我们引入 BigInteger 包，通过它和 Integer 类的 API 函数进行二进制和十进制的互相转换。

import java.math.BigInteger;

public class Lesson1_1 {

   /**

    * @Description: 十进制转换成二进制

    * @param decimalSource

    * @return String

    */

    public static String decimalToBinary(int decimalSource) {

       BigInteger bi = new BigInteger(String.valueOf(decimalSource)); // 转换成 BigInteger 类型，默认是十进制

       return bi.toString(2); // 参数 2 指定的是转化成二进制

    }

    /**

    * @Description: 二进制转换成十进制

    * @param binarySource

    * @return int

    */

    public static int binaryToDecimal(String binarySource) {

       BigInteger bi = new BigInteger(binarySource, 2);  // 转换为 BigInteger 类型，参数 2 指定的是二进制

       return Integer.parseInt(bi.toString());     // 默认转换成十进制

    }

}

然后，我们通过一个十进制数和一个二进制数，来验证一下上述代码的正确性。

 public static void main(String[] args) {

      int a = 53;

      String b = "110101";

      System.out.println(String.format(" 数字 %d 的二进制是 %s", a, Lesson1_1.decimalToBinary(a))); // 获取十进制数 53 的二进制数

      System.out.println(String.format(" 数字 %s 的十进制是 %d", b, Lesson1_1.binaryToDecimal(b))); // 获取二进制数 110101 的十进制数

   }

这段代码运行的结果是：十进制数字 53 的二进制是 110101，二进制数字 110101 的十进制是 53。

好了，关于十进制和二进制的概念以及进制之间的相互转换，你应该都很清楚了。既然有十进制，又有二进制，你可能就要问了，为啥计算机使用的是二进制而不是十进制呢？

计算机为什么使用二进制？

我觉得，计算机使用二进制和现代计算机系统的硬件实现有关。组成计算机系统的逻辑电路通常只有两个状态，即开关的接通与断开。

断开的状态我们用“0”来表示，接通的状态用“1”来表示。由于每位数据只有断开与接通两种状态，所以即便系统受到一定程度的干扰时，它仍然能够可靠地分辨出数字是“0”还是“1”。因此，在具体的系统实现中，二进制的数据表达具有抗干扰能力强、可靠性高的优点。

相比之下，如果用十进制设计具有 10 种状态的电路，情况就会非常复杂，判断状态的时候出错的几率就会大大提高。

另外，二进制也非常适合逻辑运算。逻辑运算中的“真”和“假”，正好与二进制的“0”和“1”两个数字相对应。逻辑运算中的加法（“或”运算）、乘法（“与”运算）以及否定（“非”运算）都可以通过“0”和“1”的加法、乘法和减法来实现。

二进制的位操作

了解了现代计算机是基于二进制的，我们就来看看，计算机语言中针对二进制的位操作。这里的位操作，也叫作位运算，就是直接对内存中的二进制位进行操作。常见的二进制位操作包括向左移位和向右移位的移位操作，以及“或”“与”“异或”的逻辑操作。下面我们一一来看。

向左移位

我们先来看向左移位。

二进制 110101 向左移一位，就是在末尾添加一位 0，因此 110101 就变成了 1101010。请注意，这里讨论的是数字没有溢出的情况。

所谓数字溢出，就是二进制数的位数超过了系统所指定的位数。目前主流的系统都支持至少 32 位的整型数字，而 1101010 远未超过 32 位，所以不会溢出。如果进行左移操作的二进制已经超出了 32 位，左移后数字就会溢出，需要将溢出的位数去除。

在这个例子中，如果将 1101010 换算为十进制，就是 106，你有没有发现，106 正好是 53 的 2 倍。所以，我们可以得出一个结论：二进制左移一位，其实就是将数字翻倍。

向右移位

接下来我们来看向右移位。

二进制 110101 向右移一位，就是去除末尾的那一位，因此 110101 就变成了 11010（最前面的 0 可以省略）。我们将 11010 换算为十进制，就是 26，正好是 53 除以 2 的整数商。所以二进制右移一位，就是将数字除以 2 并求整数商的操作。

下面我们来看看，用代码如何进行移位操作。

import java.math.BigInteger;

public class Lesson1_2 {

   /**

    * @Description: 向左移位

    * @param num- 等待移位的十进制数, m- 向左移的位数

    * @return int- 移位后的十进制数

    */

   public static int leftShift(int num, int m) {

      return num << m;

   }

   /**

    * @Description: 向右移位

    * @param num- 等待移位的十进制数, m- 向右移的位数

    * @return int- 移位后的十进制数

    */

   public static int rightShift(int num, int m) {

      return num >>> m;

   }

}

然后，我们用一段测试代码验证下结果。

public static void main(String[] args) {

      int num = 53;

      int m = 1;

      System.out.println(String.format(" 数字 %d 的二进制向左移 %d 位是 %d", num, m, Lesson1_2.leftShift(num, m)));   // 测试向左移位

      System.out.println(String.format(" 数字 %d 的二进制向右移 %d 位是 %d", num, m, Lesson1_2.rightShift(num, m)));   // 测试向右移位

      System.out.println();

      m = 3;

      System.out.println(String.format(" 数字 %d 的二进制向左移 %d 位是 %d", num, m, Lesson1_2.leftShift(num, m)));   // 测试向左移位

      System.out.println(String.format(" 数字 %d 的二进制向右移 %d 位是 %d", num, m, Lesson1_2.rightShift(num, m)));   // 测试向右移位

   }

这段代码的运行结果是：数字 53 向左移 1 位是 106；数字 53 向右移 1 位是 26。数字 53 向左移 3 位是 424，数字 53 向右移 3 位是 6。

我来解释一下。其中，移位 1 次相当于乘以或除以 2，而移位 3 次就相当于乘以或除以 8（即 2 的 3 次方）。细心的话，你可能已经发现，Java 中的左移位和右移位的表示是不太一样的。

左移位是 <<，那右移位为什么是 >>> 而不是 >> 呢？实际上，>> 也是右移操作。简单来说，之所以有这两种表达方式，根本原因是 Java 的二进制数值中最高一位是符号位。这里我给你详细解释一下。

当符号位为 0 时，表示该数值为正数；当符号位为 1 时，表示该数值为负数。我们以 32 位 Java 为例，数字 53 的二进制为 110101，从右往左数的第 32 位是 0，表示该数是正数，只是通常我们都将其省略。

如果数字是 -53 呢？那么第 32 位就不是 0，而是 1。请注意我这里列出的是补码。

那么这个时候向右移位，就会产生一个问题：对于符号位（特别是符号位为 1 的时候），我们是否也需要将其右移呢？因此，Java 里定义了两种右移，逻辑右移和算术右移。逻辑右移 1 位，左边补 0 即可。

算术右移时保持符号位不变，除符号位之外的右移一位并补符号位 1。补的 1 仍然在符号位之后。

逻辑右移在 Java 和 Python 语言中使用 >>> 表示，而算术右移使用 >> 表示。如果你有兴趣，可以自己编码尝试一下，看看这两种操作符输出的结果有何不同。

在 C 或 C++ 语言中，逻辑右移和算数右移共享同一个运算符 >>。那么，编译器是如何决定使用逻辑右移还是算数右移呢？答案是，取决于运算数的类型。如果运算数类型是 unsigned，则采用逻辑右移；而是 signed，则采用算数右移。如果你针对 unsigned 类型的数据使用算数右移，或者针对 signed 类型的数据使用逻辑右移，那么你首先需要进行类型的转换。

由于左移位无需考虑高位补 1 还是补 0（符号位可能为 1 或 0），所以不需要区分逻辑左移和算术左移。

位的“或”

我们刚才说了，二进制的“1”和“0”分别对应逻辑中的“真”和“假”，因此可以针对位进行逻辑操作。

逻辑“或”的意思是，参与操作的位中只要有一个位是 1，那么最终结果就是 1，也就是“真”。如果我们将二进制 110101 和 100011 的每一位对齐，进行按位的“或”操作，就会得到 110111。

位的“与”

同理，我们也可以针对位进行逻辑“与”的操作。“与”的意思是，参与操作的位中必须全都是 1，那么最终结果才是 1（真），否则就为 0（假）。如果我们将二进制 110101 和 100011 的每一位对齐，进行按位的“与”操作，就会得到 100001。

位的“异或”

逻辑“异或”和“或”有所不同，它具有排异性，也就是说如果参与操作的位相同，那么最终结果就为 0（假），否则为 1（真）。所以，如果要得到 1，参与操作的两个位必须不同，这就是此处“异”的含义。我们将二进制 110101 和 100011 的每一位对齐，进行按位的“异或”操作，可以得到结果是 10110。

我总结一下，“异或”操作的本质其实就是，所有数值和自身进行按位的“异或”操作之后都为 0。而且要通过“异或”操作得到 0，也必须通过两个相同的数值进行按位“异或”。这表明了两个数值按位“异或”结果为 0，是这两个数值相等的必要充分条件，可以作为判断两个变量是否相等的条件。

接下来，我们来学习一下，在代码中如何实现二进制的逻辑操作。Java 中使用|表示按位的“或”，& 表示按位“与”，^ 表示按位“异或”。

import java.math.BigInteger;

public class Lesson1_3 {

   /**

    * @Description: 二进制按位“或”的操作

    * @param num1- 第一个数字，num2- 第二个数字

    * @return 二进制按位“或”的结果

    */

   public static int or(int num1, int num2) {

      return (num1 | num2);

   }

   /**

    * @Description: 二进制按位“与”的操作

    * @param num1- 第一个数字，num2- 第二个数字

    * @return 二进制按位“与”的结果

    */

   public static int and(int num1, int num2) {

      return (num1 & num2);

   }

   /**

    * @Description: 二进制按位“异或”的操作

    * @param num1- 第一个数字，num2- 第二个数字

    * @return 二进制按位“异或”的结果

    */

   public static int xor(int num1, int num2) {

      return (num1 ^ num2);

   }

}

同样，我们写一段测试代码，验证一下上面三个函数。

 public static void main(String[] args) {

      int a = 53;

      int b = 35;

      System.out.println(String.format(" 数字 %d(%s) 和数字 %d(%s) 的按位‘或’结果是 %d(%s)",

            a, decimalToBinary(a), b, decimalToBinary(b), Lesson2_3.or(a, b), decimalToBinary(Lesson1_3.or(a, b)))); // 获取十进制数 53 和 35 的按位“或”

      System.out.println(String.format(" 数字 %d(%s) 和数字 %d(%s) 的按位‘与’结果是 %d(%s)",

            a, decimalToBinary(a), b, decimalToBinary(b), Lesson2_3.and(a, b), decimalToBinary(Lesson1_3.and(a, b))));  // 获取十进制数 53 和 35 的按位“与”

      System.out.println(String.format(" 数字 %d(%s) 和数字 %d(%s) 的按位‘异或’结果是 %d(%s)",

            a, decimalToBinary(a), a, decimalToBinary(a), Lesson2_3.xor(a, a), decimalToBinary(Lesson1_3.xor(a, a))));  // 获取十进制数 53 和 35 的按位“异或”

   }

这段代码的运行结果是：数字 53(110101) 和数字 35(100011) 的按位‘或’结果是 55(110111)，数字 53(110101) 和数字 35(100011) 的按位‘与’结果是 33(100001)，数字 53(110101) 和数字 53(110101) 的按位‘异或’结果是 0(0)。

小结

今天我们聊了二进制，你可能会问：学习二进制究竟有什么用呢？平时的编程中，我们好像并没有使用相关的知识啊？确实，目前的高级语言可以帮助我们将人类的思维逻辑转换为使用 0 和 1 的机器语言，我们不用再为此操心了。但是，二进制作为现代计算机体系的基石，这些基础的概念和操作，你一定要非常了解。

二进制贯穿在很多常用的概念和思想中，例如逻辑判断、二分法、二叉树等等。逻辑判断中的真假值就是用二进制的 1 和 0 来表示的；二分法和二叉树都是把要处理的问题一分为二，正好也可以通过二进制的 1 和 0 来表示。因此，理解了二进制，你就能更加容易地理解很多计算机的数据结构和算法，也为我们后面的学习打下基础。

思考题

如果不使用 Java 语言自带的 BigInteger 类，我们还有什么方法来实现十进制到二进制的转换呢？（提示：可以使用二进制的移位和按位逻辑操作来实现。）

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

02 | 余数：原来取余操作本身就是个哈希函数

你好，我是黄申。今天我们来聊聊“余数”。

提起来余数，我想你肯定不陌生，因为我们生活中就有很多很多与余数相关的例子。

比如说，今天是星期三，你想知道 50 天之后是星期几，那你可以这样算，拿 50 除以 7（因为一个星期有 7 天），然后余 1，最后在今天的基础上加一天，这样你就能知道 50 天之后是星期四了。

再比如，我们做 Web 编程的时候，经常要用到分页的概念。如果你要展示 1123 条数据，每页 10 条，那该怎么计算总共的页数呢？我想你肯定是拿 1123 除以 10，最后得到商是 112，余数是 3，所以你的总页数就是 112+1=113，而最后的余数就是多出来，凑不够一页的数据。

看完这几个例子，不知道你有没有发现，余数总是在一个固定的范围内。

比如你拿任何一个整数除以 7，那得到的余数肯定是在 0～6 之间的某一个数。所以当我们知道 1900 年的 1 月 1 日是星期一，那便可以知道这一天之后的第 1 万天、10 万天是星期几，是不是很神奇？

你知道，整数是没有边界的，它可能是正无穷，也可能是负无穷。但是余数却可以通过某一种关系，让整数处于一个确定的边界内。我想这也是人类发明星期或者礼拜的初衷吧，任你时光变迁，我都是以 7 天为一个周期，“周”而复始地过着确定的生活。因为从星期的角度看，不管你是哪一天，都会落到星期一到星期日的某一天里。

我们再拿上面星期的例子来看。假如今天是星期一，从今天开始的 100 天里，都有多少个星期呢？你拿 100 除以 7，得到商 14 余 2，也就是说这 100 天里有 14 周多 2 天。换个角度看，我们可以说，这 100 天里，你的第 1 天、第 8 天、第 15 天等等，在余数的世界里都被认为是同一天，因为它们的余数都是 1，都是星期一，你要上班的日子。同理，第 2 天、第 9 天、第 16 天余数都是 2，它们都是星期二。

这些数的余数都是一样的，所以被归类到了一起，有意思吧？是的，我们的前人早已注意到了这一规律或者特点，所以他们把这一结论称为同余定理。简单来说，就是两个整数 a 和 b，如果它们除以正整数 m 得到的余数相等，我们就可以说 a 和 b 对于模 m 同余。

也就是说，上面我们说的 100 天里，所有星期一的这些天都是同余的，所有星期二的这些天就是同余的，同理，星期三、星期四等等这些天也都是同余的。

还有，我们经常提到的奇数和偶数，其实也是同余定理的一个应用。当然，这个应用里，它的模就是 2 了，2 除以 2 余 0，所以它是偶数；3 除以 2 余 1，所以它是奇数。2 和 4 除以 2 的余数都是 0，所以它们都是一类，都是偶数。3 和 5 除以 2 的余数都是 1，所以它们都是一类，都是奇数。

你肯定会说，同余定理就这么简单吗，这个定理到底有什么实际的用途啊？其实，我上面已经告诉你答案了，你不妨先自己思考下，同余定理的意义到底是什么。

简单来说，同余定理其实就是用来分类的。你知道，我们有无穷多个整数，那怎么能够全面、多维度地管理这些整数？同余定理就提供了一个思路。

因为不管你的模是几，最终得到的余数肯定都在一个范围内。比如我们上面除以 7，就得到了星期几；我们除以 2，就得到了奇偶数。所以按照这种方式, 我们就可以把无穷多个整数分成有限多个类。

这一点，在我们的计算机中，可是有大用途。

哈希（Hash）你应该不陌生，在每个编程语言中，都会有对应的哈希函数。哈希有的时候也会被翻译为散列，简单来说，它就是将任意长度的输入，通过哈希算法，压缩为某一固定长度的输出。这话听着是不是有点耳熟？我们上面的求余过程不就是在做这事儿吗？

举个例子，假如你想要快速读写 100 万条数据记录，要达到高速地存取，最理想的情况当然是开辟一个连续的空间存放这些数据，这样就可以减少寻址的时间。但是由于条件的限制，我们并没有能够容纳 100 万条记录的连续地址空间，这个时候该怎么办呢？

我们可以考察一下，看看系统是否可以提供若干个较小的连续空间，而每个空间又能存放一定数量的记录。比如我们找到了 100 个较小的连续空间，也就是说，这些空间彼此之间是被分隔开来的，但是内部是连续的，并足以容纳 1 万条记录连续存放，那么我们就可以使用余数和同余定理来设计一个散列函数，并实现哈希表的结构。

那这个函数应该怎么设计呢？你可以先停下来思考思考，提醒你下，你可以再想想星期几的那个例子，因为这里面用的就是余数的思想。

下面是我想到的一种方法：

在这个公式中，x 表示等待被转换的数值，而 size 表示有限存储空间的大小，mod 表示取余操作。通过余数，你就能将任何数值，转换为有限范围内的一个数值，然后根据这个新的数值，来确定将数据存放在何处。

具体来说，我们可以通过记录标号模 100 的余数，指定某条记录存放在哪个空间。这个时候，我们的公式就变成了这样：

假设有两条记录，它们的记录标号分别是 1 和 101。我们把这些模 100 之后余数都是 1 的，存放到第 1 个可用空间里。以此类推，将余数为 2 的 2、102、202 等，存放到第 2 个可用空间，将 100、200、300 等存放到第 100 个可用空间里。

这样，我们就可以根据求余的快速数字变化，对数据进行分组，并把它们存放到不同的地址空间里。而求余操作本身非常简单，因此几乎不会增加寻址时间。

除此之外，为了增加数据散列的随机程度，我们还可以在公式中加入一个较大的随机数 MAX，于是，上面的公式就可以写成这样：

我们假设随机数 MAX 是 590199，那么我们针对标号为 1 的记录进行重新计算，最后的计算结果就是 0，而针对标号 101 的记录，如果随机数 MAX 取 627901，对应的结果应该是 2。这样先前被分配到空间 1 的两条记录，在新的计算公式作用下，就会被分配到不同的可用空间中。

你可以尝试记录 2 和 102，或者记录 100 和 200，最后应该也是同样的情况。你会发现，使用了 MAX 这个随机数之后，被分配到同一个空间中的记录就更加“随机”，更适合需要将数据重新洗牌的应用场景，比如加密算法、MapReduce 中的数据分发、记录的高速查询和定位等等。

让我以加密算法为例，在这里面引入 MAX 随机数就可以增强加密算法的保密程度，是不是很厉害？举个例子，比如说我们要加密一组三位数，那我们设定一个这样的加密规则：

先对每个三位数的个、十和百位数，都加上一个较大的随机数。

然后将每位上的数都除以 7，用所得的余数代替原有的个、十、百位数；

最后将第一位和第三位交换。

这就是一个基本的加密变换过程。

假如说，我们要加密数字 625，根据刚才的规则，我们来试试。假设随机数我选择 590127。那百、十和个位分别加上这个随机数，就变成了 590133，590129，590132。然后，三位分别除以 7 求余后得到 5，1，4。最终，我们可以得到加密后的数字就是 415。因为加密的人知道加密的规则、求余所用的除数 7、除法的商、以及所引入的随机数 590127，所以当拿到 415 的时候，加密者就可以算出原始的数据是 625。是不是很有意思？

小结

到这里，余数的所有知识点我们都讲完了。我想在此之前，你肯定是知道余数，也明白怎么求余。但对于余数的应用不知道你之前是否有思考过呢？我们经常说，数学是计算机的基础，在余数这个小知识点里，我们就能找到很多的应用场景，比如我前面介绍的散列函数、加密算法，当然，也还有我们没有介绍到的，比如循环冗余校验等等。

余数只是数学知识中的沧海一粟。你在中学或者大学的时候，肯定接触过很多的数学知识和定理，编程的时候也会经常和数字、公式以及数据打交道，但是真正学懂数学的人却没几个。希望我们可以从余数这个小概念开始，让你认识到数学思想其实非常实用，用好这些知识，对你的编程，甚至生活都有意想不到的作用。

思考题

你可以想想，在生活和编程中，还有哪些地方用到了余数的思想呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

03 | 迭代法：不用编程语言的自带函数，你会如何计算平方根？

你好，我是黄申。

今天我们来说一个和编程结合得非常紧密的数学概念。在解释这个重要的概念之前，我们先来看个有趣的小故事。

古印度国王舍罕酷爱下棋，他打算重赏国际象棋的发明人宰相西萨·班·达依尔。这位聪明的大臣指着象棋盘对国王说：“陛下，我不要别的赏赐，请您在这张棋盘的第一个小格内放入一粒麦子，在第二个小格内放入两粒，第三小格内放入给四粒，以此类推，每一小格内都比前一小格加一倍的麦子，直至放满 64 个格子，然后将棋盘上所有的麦粒都赏给您的仆人我吧！”

国王自以为小事一桩，痛快地答应了。可是，当开始放麦粒之后，国王发现，还没放到第二十格，一袋麦子已经空了。随着，一袋又一袋的麦子被放入棋盘的格子里，国王很快看出来，即便拿来全印度的粮食，也兑现不了对达依尔的诺言。

放满这 64 格到底需要多少粒麦子呢？这是个相当相当大的数字，想要手动算出结果并不容易。如果你觉得自己厉害，可以试着拿笔算算。其实，这整个算麦粒的过程，在数学上，是有对应方法的，这也正是我们今天要讲的概念：迭代法（Iterative Method）。

到底什么是迭代法？

迭代法，简单来说，其实就是不断地用旧的变量值，递推计算新的变量值。

我这么说可能还是比较抽象，不容易理解。我们还回到刚才的故事。大臣要求每一格的麦子都是前一格的两倍，那么前一格里麦子的数量就是旧的变量值，我们可以先记作 Xn−1Xn−1；而当前格子里麦子的数量就是新的变量值，我们记作 XnXn。这两个变量的递推关系就是这样的：

如果你稍微有点编程经验，应该能发现，迭代法的思想，很容易通过计算机语言中的循环语言来实现。你知道，计算机本身就适合做重复性的工作，我们可以通过循环语句，让计算机重复执行迭代中的递推步骤，然后推导出变量的最终值。

那接下来，我们就用循环语句来算算，填满格子到底需要多少粒麦子。我简单用 Java 语言写了个程序，你可以看看。

public class Lesson3_1 {

    /**

    * @Description: 算算舍罕王给了多少粒麦子

    * @param grid- 放到第几格

    * @return long- 麦粒的总数

    */

    public static long getNumberOfWheat(int grid) {

     long sum = 0;      // 麦粒总数

     long numberOfWheatInGrid = 0;  // 当前格子里麦粒的数量

     numberOfWheatInGrid = 1;  // 第一个格子里麦粒的数量

     sum += numberOfWheatInGrid;

     for (int i = 2; i <= grid; i ++) {

      numberOfWheatInGrid *= 2;   // 当前格子里麦粒的数量是前一格的 2 倍

      sum += numberOfWheatInGrid;   // 累计麦粒总数

     }

     return sum;

    }

}

下面是一段测试代码，它计算了到第 63 格时，总共需要多少麦粒。

  public static void main(String[] args) {

  System.out.println(String.format(" 舍罕王给了这么多粒：%d",   Lesson3_1.getNumberOfWheat(63)));

  }

计算的结果是 9223372036854775807，多到数不清了。我大致估算了一下，一袋 50 斤的麦子估计有 130 万粒麦子，那么 9223372036854775807 相当于 70949 亿袋 50 斤的麦子！

这段代码有两个地方需要注意。首先，用于计算每格麦粒数的变量以及总麦粒数的变量都是 Java 中的 long 型，这是因为计算的结果实在是太大了，超出了 Java int 型的范围；第二，我们只计算到了第 63 格，这是因为计算到第 64 格之后，总数已经超过 Java 中 long 型的范围。

迭代法有什么具体应用？

看到这里，你可能大概已经理解迭代法的核心理念了。迭代法在无论是在数学，还是计算机领域都有很广泛的应用。大体上，迭代法可以运用在以下几个方面：

求数值的精确或者近似解。典型的方法包括二分法（Bisection method）和牛顿迭代法（Newton’s method）。

在一定范围内查找目标值。典型的方法包括二分查找。

机器学习算法中的迭代。相关的算法或者模型有很多，比如 K- 均值算法（K-means clustering）、PageRank 的马尔科夫链（Markov chain）、梯度下降法（Gradient descent）等等。迭代法之所以在机器学习中有广泛的应用，是因为很多时候机器学习的过程，就是根据已知的数据和一定的假设，求一个局部最优解。而迭代法可以帮助学习算法逐步搜索，直至发现这种解。

这里，我详细讲解一下求数值的解和查找匹配记录这两个应用。

1. 求方程的精确或者近似解

迭代法在数学和编程的应用有很多，如果只能用来计算庞大的数字，那就太“暴殄天物”了。迭代还可以帮助我们进行无穷次地逼近，求得方程的精确或者近似解。

比如说，我们想计算某个给定正整数 n（n>1）的平方根，如果不使用编程语言自带的函数，你会如何来实现呢？

假设有正整数 n，这个平方根一定小于 n 本身，并且大于 1。那么这个问题就转换成，在 1 到 n 之间，找一个数字等于 n 的平方根。

我这里采用迭代中常见的二分法。每次查看区间内的中间值，检验它是否符合标准。

举个例子，假如我们要找到 10 的平方根。我们需要先看 1 到 10 的中间数值，也就是 11/2=5.5。5.5 的平方是大于 10 的，所以我们要一个更小的数值，就看 5.5 和 1 之间的 3.25。由于 3.25 的平方也是大于 10 的，继续查看 3.25 和 1 之间的数值，也就是 2.125。这时，2.125 的平方小于 10 了，所以看 2.125 和 3.25 之间的值，一直继续下去，直到发现某个数的平方正好是 10。

我把具体的步骤画成了一张图，你可以看看。

我这里用 Java 代码演示一下效果，你可以结合上面的讲解，来理解迭代的过程。

public class Lesson3_2 {

 /**

    * @Description: 计算大于 1 的正整数之平方根

    * @param n- 待求的数, deltaThreshold- 误差的阈值, maxTry- 二分查找的最大次数

    * @return double- 平方根的解

    */

    public static double getSqureRoot(int n, double deltaThreshold, int maxTry) {

     if (n <= 1) {

      return -1.0;

     }

     double min = 1.0, max = (double)n;

     for (int i = 0; i < maxTry; i++) {

      double middle = (min + max) / 2;

      double square = middle * middle;

      double delta = Math.abs((square / n) - 1);

      if (delta <= deltaThreshold) {

       return middle;

      } else {

       if (square > n) {

        max = middle;

       } else {

        min = middle;

       }

      }

     }

     return -2.0;

    }

}

这是一段测试代码，我们用它来找正整数 10 的平方根。如果找不到精确解，我们就返回一个近似解。

public static void main(String[] args) {

  int number = 10;

  double squareRoot = Lesson3_2.getSqureRoot(number, 0.000001, 10000);

  if (squareRoot == -1.0) {

   System.out.println(" 请输入大于 1 的整数 ");

  } else if (squareRoot == -2.0) {

   System.out.println(" 未能找到解 ");

  } else {

   System.out.println(String.format("%d 的平方根是 %f", number, squareRoot));

  }

 }

这段代码的实现思想就是我前面讲的迭代过程，这里面有两个小细节我解释下。

第一，我使用了 deltaThreshold 来控制解的精度。虽然理论上来说，可以通过二分的无限次迭代求得精确解，但是考虑到实际应用中耗费的大量时间和计算资源，绝大部分情况下，我们并不需要完全精确的数据。

第二，我使用了 maxTry 来控制循环的次数。之所以没有使用 while(true) 循环，是为了避免死循环。虽然，在这里使用 deltaThreshold，理论上是不会陷入死循环的，但是出于良好的编程习惯，我们还是尽量避免产生的可能性。

说完了二分迭代法，我这里再简单提一下牛顿迭代法。这是牛顿在 17 世纪提出的一种方法，用于求方程的近似解。这种方法以微分为基础，每次迭代的时候，它都会去找到比上一个值 x0x0 更接近的方程的根，最终找到近似解。该方法及其延伸也被应用在机器学习的算法中，在之后机器学习中的应用中，我会具体介绍这个算法。

2. 查找匹配记录

二分法中的迭代式逼近，不仅可以帮我们求得近似解，还可以帮助我们查找匹配的记录。我这里用一个查字典的案例来说明。

在自然语言处理中，我们经常要处理同义词或者近义词的扩展。这时，你手头上会有一个同义词 / 近义词的词典。对于一个待查找的单词，我们需要在字典中找出这个单词，以及它所对应的同义词和近义词，然后进行扩展。比如说，这个字典里有一个关于“西红柿”的词条，其同义词包括了“番茄”和“tomato”。

那么，在处理文章的时候，当我们看到了“西红柿”这个词，就去字典里查一把，拿出“番茄”“tomato”等等，并添加到文章中作为同义词 / 近义词的扩展。这样的话，用户在搜索“西红柿”这个词的时候，我们就能确保出现“番茄”或者“tomato”的文章会被返回给用户。

乍一看到这个任务的时候，你也许想到了哈希表。没错，哈希表是个好方法。不过，如果不使用哈希表，你还有什么其他方法呢？这里，我来介绍一下，用二分查找法进行字典查询的思路。

第一步，将整个字典先进行排序（假设从小到大）。二分法中很关键的前提条件是，所查找的区间是有序的。这样才能在每次折半的时候，确定被查找的对象属于左半边还是右半边。

第二步，使用二分法逐步定位到被查找的单词。每次迭代的时候，都找到被搜索区间的中间点，看看这个点上的单词，是否和待查单词一致。如果一致就返回；如果不一致，要看被查单词比中间点上的单词是小还是大。如果小，那说明被查的单词如果存在字典中，那一定在左半边；否则就在右半边。

第三步，根据第二步的判断，选择左半边或者后半边，继续迭代式地查找，直到范围缩小到单个的词。如果到最终仍然无法找到，则返回不存在。

当然，你也可以对单词进行从大到小的排序，如果是那样，在第二步的判断就需要相应地修改一下。

我把在 a 到 g 的 7 个字符中查找 f 的过程，画成了一张图，你可以看看。

这个方法的整体思路和二分法求解平方根是一致的，主要区别有两个方面：第一，每次判断是否终结迭代的条件不同。求平方根的时候，我们需要判断某个数的平方是否和输入的数据一致。而这里，我们需要判断字典中某个单词是否和待查的单词相同。第二，二分查找需要确保被搜索的空间是有序的。

我把具体的代码写出来了，你可以看一下。

import java.util.Arrays;

public class Lesson3_3 {

 /**

    * @Description: 查找某个单词是否在字典里出现

    * @param dictionary- 排序后的字典, wordToFind- 待查的单词

    * @return boolean- 是否发现待查的单词

    */

    public static boolean search(String[] dictionary, String wordToFind) {

     if (dictionary == null) {

      return false;

     }

     if (dictionary.length == 0) {

      return false;

     }

     int left = 0, right = dictionary.length - 1;

     while (left <= right) {

      int middle = (left + right) / 2;

      if (dictionary[middle].equals(wordToFind)) {

       return true;

      } else {

       if (dictionary[middle].compareTo(wordToFind) > 0) {

        right = middle - 1;

       } else {

        left = middle + 1;

       }

      }

     }

     return false;

    }

}

我测试代码首先建立了一个非常简单的字典，然后使用二分查找法在这个字典中查找单词“i”。

public static void main(String[] args) {

  String[] dictionary = {"i", "am", "one", "of", "the", "authors", "in", "geekbang"};

  Arrays.sort(dictionary);

  String wordToFind = "i";

  boolean found = Lesson3_3.search(dictionary, wordToFind);

  if (found) {

   System.out.println(String.format(" 找到了单词 %s", wordToFind));

  } else {

   System.out.println(String.format(" 未能找到单词 %s", wordToFind));

  }

 }

说的这两个例子，都属于迭代法中的二分法，我在第一节的时候说过，二分法其实也体现了二进制的思想。

小结

到这里，我想你对迭代的核心思路有了比较深入的理解。

实际上，人类并不擅长重复性的劳动，而计算机却很适合做这种事。这也是为什么，以重复为特点的迭代法在编程中有着广泛的应用。不过，日常的实际项目可能并没有体现出明显的重复性，以至于让我们很容易就忽视了迭代法的使用。所以，你要多观察问题的现象，思考其本质，看看不断更新变量值或者缩小搜索的区间范围，是否可以获得最终的解（或近似解、局部最优解），如果是，那么你就可以尝试迭代法。

思考题

在你曾经做过的项目中，是否使用过迭代法？如果有，你觉得迭代法最大的特点是什么？如果还没用过，你想想看现在的项目中是否有可以使用的地方？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

04 | 数学归纳法：如何用数学归纳提升代码的运行效率？

你好，我是黄申。

上次我们聊了迭代法及其应用，并用编程实现了几个小例子。不过你知道吗，对于某些迭代问题，我们其实可以避免一步步的计算，直接从理论上证明某个结论，节约大量的计算资源和时间，这就是我们今天要说的数学归纳法。

平时我们谈的“归纳”，是一种从经验事实中找出普遍特征的认知方法。比如，人们在观察了各种各样动物之后，通过它们的外观、行为特征、生活习性等得出某种结论，来区分哪些是鸟、哪些是猫等等。比如我这里列出的几个动物的例子。

通过上面的表格，我们可以进行归纳，并得出这样的结论：

如果一个动物，身上长羽毛并且会飞，那么就是属于鸟；

如果一个动物，身上长绒毛、不会飞、而且吃小鱼和老鼠，那么就属于猫。

通过观察 55 个动物样本的 33 个特征，从而得到某种动物应该具有何种特征，这种方法就是我们平时所提到的归纳法。

我们日常生活中所说的这种归纳法和数学归纳法是不一样的，它们究竟有什么区别呢？具体数学归纳法可以做什么呢？我们接着上一节舍罕王赏麦的故事继续说。

什么是数学归纳法？

上节我们提到，在棋盘上放麦粒的规则是，第一格放一粒，第二格放两粒，以此类推，每一小格内都比前一小格多一倍的麦子，直至放满 6464 个格子。

我们假想一下自己穿越到了古印度，正站在国王的身边，看着这个棋盘，你发现第 11 格到第 88 格的麦子数分别是：1、2、4、8、16、32、64、1281、2、4、8、16、32、64、128。这个时候，国王想知道总共需要多少粒麦子。我们小时候都玩过“找规律”，于是，我发现了这么一个规律，你看看是不是这样？

根据这个观察，我们是不是可以大胆假设，前 nn 个格子的麦粒总数就是 2n−12n−1 呢？如果这个假设成立，那么填满 64 格需要的麦粒总数，就是 1+2+22+23+24+……＋2631+2+22+23+24+……＋263
＝264−1＝18446744073709551615＝264−1＝18446744073709551615。

这个假设是否成立，我们还有待验证。但是对于类似这种无穷数列的问题，我们通常可以采用数学归纳法（Mathematical Induction）来证明。

在数论中，数学归纳法用来证明任意一个给定的情形都是正确的，也就是说，第一个、第二个、第三个，一直到所有情形，概不例外。

数学归纳法的一般步骤是这样的：

证明基本情况（通常是 n=1n=1 的时候）是否成立；

假设 n=k−1n=k−1 成立，再证明 n=kn=k 也是成立的（kk 为任意大于 11 的自然数）。

只要学过数学，我想你对这个步骤都不陌生。但是，现在你需要牢记这个步骤，然后我们用这个步骤来证明下开头的例子。

为了让你更好地理解，我将原有的命题分为两个子命题来证明。第一个子命题是，第 nn 个棋格放的麦粒数为 2n−12n−1。第二个子命题是，前 nn 个棋格放的麦粒数总和为 2n−12n−1。

首先，我们来证明第一个子命题。

基本情况：我们已经验证了 n=1n=1 的时候，第一格内的麦粒数为 11，和 21−121−1 相等。因此，命题在 k=1k=1 的时候成立。

假设第 k−1k−1 格的麦粒数为 2k−22k−2。那么第 kk 格的麦粒数为第 k−1k−1 格的 22 倍，也就是 2k−2∗2=2k−12k−2∗2=2k−1。因此，如果命题在 k=n−1k=n−1 的时候成立，那么在 k=nk=n 的时候也成立。

所以，第一个子命题成立。在这个基础之上，我再来证明第二个子命题。

基本情况：我们已经验证了 n=1n=1 的时候，所有格子的麦粒总数为 11。因此命题在 k=1k=1 的时候成立。

假设前 k−1k−1 格的麦粒总数为 2k−1−12k−1−1，基于前一个命题的结论，第 k 格的麦粒数为 2k−12k−1。那么前 kk 格的麦粒总数为 (2k−1−1)+(2k−1)=2∗2k−1−1=2k−1(2k−1−1)+(2k−1)=2∗2k−1−1=2k−1。因此，如果命题在 k=n−1k=n−1 的时候成立，那么在 k=nk=n 的时候也成立。

说到这里，我已经证明了这两个命题都是成立的。和使用迭代法的计算相比，数学归纳法最大的特点就在于“归纳”二字。它已经总结出了规律。只要我们能够证明这个规律是正确的，就没有必要进行逐步的推算，可以节省很多时间和资源。

说到这里，我们也可以看出，数学归纳法中的“归纳”是指的从第一步正确，第二步正确，第三步正确，一直推导到最后一步是正确的。这就像多米诺骨牌，只要确保第一张牌倒下，而每张牌的倒下又能导致下一张牌的倒下，那么所有的骨牌都会倒下。从这里，你也能看出来，这和开篇提到的广义归纳法是不同的。数学归纳法并不是通过经验或样本的观察，总结出事物的普遍特征和规律。

好了，对数学归纳法的概念，我想你现在已经理解了。这里，我对上一节中有关麦粒的代码稍作修改，增加了一点代码来使用数学归纳法的结论，并和迭代法的实现进行了比较，你可以看看哪种方法耗时更长。

public static void main(String[] args) {

  int grid = 63;

  long start, end = 0;

  start = System.currentTimeMillis();

  System.out.println(String.format(" 舍罕王给了这么多粒：%d", Lesson3_1.getNumberOfWheat(grid)));

  end = System.currentTimeMillis();

  System.out.println(String.format(" 耗时 %d 毫秒 ", (end - start)));

  start = System.currentTimeMillis();

  System.out.println(String.format(" 舍罕王给了这么多粒：%d", (long)(Math.pow(2, grid)) - 1));

  end = System.currentTimeMillis();

  System.out.println(String.format(" 耗时 %d 毫秒 ", (end - start)));

 }

在我的电脑上，这段代码运行的结果是：舍罕王给了 92233720368547758079223372036854775807 粒，耗时 44 毫秒。舍罕王给了这么多粒：92233720368547758069223372036854775806，耗时 00 毫秒。

你可能已经发现，当 grid=6363 时，结果差了 11 个。这个是由于 Math.pow() 函数计算精度导致的误差。正确的结果应该是 92233720368547758079223372036854775807。不过，基于数学归纳结论的计算明显在耗时上占有优势。虽然在我的笔记本电脑上只有 4 毫秒的差距，但是在生产项目的实践中，这种点点滴滴的性能差距都有可能累积成明显的问题。

递归调用和数学归纳的逻辑是一样的？

我们不仅可以使用数学归纳法从理论上指导编程，还可以使用编程来模拟数学归纳法的证明。如果你仔细观察一下数学归纳法的证明过程，会不会觉得和函数的递归调用很像呢？

这里我通过总麦粒数的命题来示范一下。首先，我们要把这个命题的数学归纳证明，转换成一段伪代码，这个过程需要经过这样两步：

第一步，如果 nn 为 11，那么我们就判断麦粒总数是否为 21−1=121−1=1。同时，返回当前棋格的麦粒数，以及从第 11 格到当前棋格的麦粒总数。

第二步，如果 nn 为 k−1k−1 的时候成立，那么判断 nn 为 kk 的时候是否也成立。此时的判断依赖于前一格 k−1k−1 的麦粒数、第 11 格到 k−1k−1 格的麦粒总数。这也是上一步我们所返回的两个值。

你应该看出来了，这两步分别对应了数学归纳法的两种情况。在数学归纳法的第二种情况下，我们只能假设 n=k−1n=k−1 的时候命题成立。但是，在代码的实现中，我们可以将伪代码的第二步转为函数的递归（嵌套）调用，直到被调用的函数回退到 n=1n=1 的情况。然后，被调用的函数逐步返回 k−1k−1 时命题是否成立。

如果要写成具体的函数，就类似下面这样：

class Result {

 public long wheatNum = 0;  // 当前格的麦粒数

 public long wheatTotalNum = 0;  // 目前为止麦粒的总数

}

public class Lesson4_2 {

 /**

    * @Description: 使用函数的递归（嵌套）调用，进行数学归纳法证明

    * @param k- 放到第几格，result- 保存当前格子的麦粒数和麦粒总数

    * @return boolean- 放到第 k 格时是否成立

    */

    public static boolean prove(int k, Result result) {

     // 证明 n = 1 时，命题是否成立

     if (k == 1) {

      if ((Math.pow(2, 1) - 1) == 1) {

       result.wheatNum = 1;

       result.wheatTotalNum = 1;

       return true;

      } else return false;

     }

     // 如果 n = (k-1) 时命题成立，证明 n = k 时命题是否成立

     else {

      boolean proveOfPreviousOne = prove(k - 1, result);

      result.wheatNum *= 2;

      result.wheatTotalNum += result.wheatNum;

      boolean proveOfCurrentOne = false;

      if (result.wheatTotalNum == (Math.pow(2, k) - 1)) proveOfCurrentOne = true;

      if (proveOfPreviousOne && proveOfCurrentOne) return true;

      else return false;

     }

    }

}

其中，类 Result 用于保留每一格的麦粒数，以及目前为止的麦粒总数。这个代码递归调用了函数 prove(int, Result)。

从这个例子中，我们可以看出来，递归调用的代码和数学归纳法的逻辑是一致的。一旦你理解了数学归纳法，就很容易理解递归调用了。只要数学归纳证明的逻辑是对的，递归调用的逻辑就是对的，我们没有必要纠结递归函数是如何嵌套调用和返回的。

不过，和数学归纳证明稍有不同的是，递归编程的代码需要返回若干的变量，来传递 k−1k−1 的状态到 kk。这里，我使用类 Result 来实现这一点。

这里是一段测试的代码。

public static void main(String[] args) {

  int grid = 63;

  Result result = new Result();

  System.out.println(Lesson4_2.prove(grid, result));

 }

我们最多测试到 6363。因为如果测试到第 6464 格，麦粒总数就会溢出 Java 的 long 型数据。

你可以自己分析一下函数的调用和返回。我这里列出了一开始嵌套调用和到递归结束并开始返回值得的几个状态：

从这个图可以看出，函数从 k=63k=63 开始调用，然后调用 k−1k−1，也就是 6262，一直到 k=1k=1 的时候，嵌套调用结束，k=1k=1 的函数体开始返回值给 k=2k=2 的函数体，一直到 k=63k=63 的函数体。从 k=63,62,…,2,1k=63,62,…,2,1 的嵌套调用过程，其实就是体现了数学归纳法的核心思想，我把它称为逆向递推。而从 k=1,2,…,62,63k=1,2,…,62,63 的值返回过程，和上一篇中基于循环的迭代是一致的，我把它称为正向递推。

小结

今天，我介绍了一个编程中非常重要的数学概念：数学归纳法。

上一节我讲了迭代法是如何通过重复的步骤进行计算或者查询的。与此不同的是，数学归纳法在理论上证明了命题是否成立，而无需迭代那样反复计算，因此可以帮助我们节约大量的资源，并大幅地提升系统的性能。

数学归纳法实现的运行时间几乎为 00。不过，数学归纳法需要我们能做出合理的命题假设，然后才能进行证明。虽然很多时候要做这点比较难，确实也没什么捷径。你就是要多做题，多去看别人是怎么解题的，自己去积累经验。

最后，我通过函数的递归调用，模拟了数学归纳法的证明过程。如果你细心的话，会发现递归的函数值返回实现了从 k=1k=1 开始到 k=nk=n 的迭代。说到这里，你可能会好奇：既然递归最后返回值的过程和基于循环的迭代是一致，那为什么还需要使用递归的方法呢？下一节，我们继续聊这个问题。

思考题

在你日常工作的项目中，什么地方用到了数学归纳法来提升代码的运行效率？如果没有遇到过，你可以尝试做做实验，看看是否有提升？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

05 | 递归（上）：泛化数学归纳，如何将复杂问题简单化？

你好，我是黄申。上一节的结尾，我们用递归模拟了数学归纳法的证明。同时，我也留下了一个问题：既然递归的函数值返回过程和基于循环的迭代法一致，我们直接用迭代法不就好了，为什么还要用递归的数学思想和编程方法呢？这是因为，在某些场景下，递归的解法比基于循环的迭代法更容易实现。这是为什么呢？我们继续来看舍罕王赏麦的故事。

如何在限定总和的情况下，求所有可能的加和方式？

舍罕王和他的宰相西萨·班·达依尔现在来到了当代。这次国王学乖了，他对宰相说：“这次我不用麦子奖赏你了，我直接给你货币。另外，我也不用棋盘了，我直接给你一个固定数额的奖赏。”

宰相思考了一下，回答道：“没问题，陛下，就按照您的意愿。不过，我有个小小的要求。那就是您能否列出所有可能的奖赏方式，让我自己来选呢？假设有四种面额的钱币，1 元、2 元、5 元和 10 元，而您一共给我 10 元，那您可以奖赏我 1 张 10 元，或者 10 张 1 元，或者 5 张 1 元外加 1 张 5 元等等。如果考虑每次奖赏的金额和先后顺序，那么最终一共有多少种不同的奖赏方式呢？”

让我们再次帮国王想想，如何解决这个难题吧。这个问题和之前的棋盘上放麦粒有所不同，它并不是要求你给出最终的总数，而是在限定总和的情况下，求所有可能的加和方式。你可能会想，虽然问题不一样，但是求和的重复性操作仍然是一样的，因此是否可以使用迭代法？好，让我们用迭代法来试一下。

我还是使用迭代法中的术语，考虑 k=1,2,3,…,n 的情况。在第一步，也就是当 n=1 的时候，我们可以取四种面额中的任何一种，那么当前的奖赏就是 1 元、2 元、5 元和 10 元。当 n=2 的时候，奖赏的总和就有很多可能性了。如果第一次奖赏了 1 元，那么第二次有可能取 1、2、5 元三种面额（如果取 10，总数超过了 10 元，因此不可能）。

所以，在第一次奖赏 1 元，第二次奖赏 1 元后，总和为 2 元；第一次奖赏 1 元，第二次奖赏 2 元后，总和为 3 元；第一次奖赏 1 元，第二次奖赏 5 元后，总和为 6 元。好吧，这还没有考虑第一次奖赏 2 元和 5 元的情况。我来画个图，从图中你就能发现这种可能的情况在快速地“膨胀”。

你应该能看到，虽然迭代法的思想是可行的，但是如果用循环来实现，恐怕要保存好多中间状态及其对应的变量。说到这里，你是不是很容易就想到计算编程常用的函数递归？

在递归中，每次嵌套调用都会让函数体生成自己的局部变量，正好可以用来保存不同状态下的数值，为我们省去了大量中间变量的操作，极大地方便了设计和编程。

不过，这里又有新的问题了。之前用递归模拟数学归纳法还是非常直观的。可是，这里不是要计算一个最终的数值，而是要列举出所有的可能性。那应该如何使用递归来解决呢？上一节，我只是用递归编程体现了数学归纳法的思想，但是如果我们把这个思想泛化一下，那么递归就会有更多、更广阔的应用场景。

如何把复杂的问题简单化？

首先，我们来看，如何将数学归纳法的思想泛化成更一般的情况？数学归纳法考虑了两种情况：

初始状态，也就是 n=1 的时候，命题是否成立；

如果 n=k-1 的时候，命题成立。那么只要证明 n=k 的时候，命题也成立。其中 k 为大于 1 的自然数。

将上述两点顺序更换一下，再抽象化一下，我写出了这样的递推关系：

假设 n=k-1 的时候，问题已经解决（或者已经找到解）。那么只要求解 n=k 的时候，问题如何解决（或者解是多少）；

初始状态，就是 n=1 的时候，问题如何解决（或者解是多少）。

我认为这种思想就是将复杂的问题，每次都解决一点点，并将剩下的任务转化成为更简单的问题等待下次求解，如此反复，直到最简单的形式。回到开头的例子，我们再将这种思想具体化。

假设 n=k-1 的时候，我们已经知道如何去求所有奖赏的组合。那么只要求解 n=k 的时候，会有哪些金额的选择，以及每种选择后还剩下多少奖金需要支付就可以了。

初始状态，就是 n=1 的时候，会有多少种奖赏。

有了这个思路，就不难写出这个问题的递归实现。我这里列一个基本的实现。

import java.util.ArrayList;

public class Lesson5_1 {

	public static long[] rewards = {1, 2, 5, 10};	// 四种面额的纸币

	/**

    * @Description:	使用函数的递归（嵌套）调用，找出所有可能的奖赏组合

    * @param totalReward- 奖赏总金额，result- 保存当前的解

    * @return void

    */

    public static void get(long totalReward, ArrayList<Long> result) {

    	// 当 totalReward = 0 时，证明它是满足条件的解，结束嵌套调用，输出解

    	if (totalReward == 0) {

    		System.out.println(result);

    	 return;

     }

    	// 当 totalReward < 0 时，证明它不是满足条件的解，不输出

    	else if (totalReward < 0) {

    		return;

    	} else {

    		for (int i = 0; i < rewards.length; i++) {

    			ArrayList<Long> newResult = (ArrayList<Long>)(result.clone());	// 由于有 4 种情况，需要 clone 当前的解并传入被调用的函数

    			newResult.add(rewards[i]);						// 记录当前的选择，解决一点问题

    			get(totalReward - rewards[i], newResult);		// 剩下的问题，留给嵌套调用去解决

    		}

    	}

    }

}

我们测试一下总金额为 10 元的时候，有多少种解。

 public static void main(String[] args) {

  int totalReward = 10;

  Lesson5_1.get(totalReward, new ArrayList<Long>());

 }

最终，程序运行后大致是这种结果：

[1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

[1, 1, 1, 1, 1, 1, 1, 1, 2]

[1, 1, 1, 1, 1, 1, 1, 2, 1]

[1, 1, 1, 1, 1, 1, 2, 1, 1]

[1, 1, 1, 1, 1, 1, 2, 2]

...

[5, 5]

[10]

这里面每一行都是一种可能。例如第一行表示分 10 次奖赏，每次 1 元；第二行表示分 9 次奖赏，最后一次是 2 元；以此类推。最终结果的数量还是挺多的，一共有 129 种可能。试想一下，如果总金额为 100 万的话，会有多少种可能啊！

这个代码还有几点需要留意的地方，我再来解释一下：

1. 由于一共只有 4 种金额的纸币，所以无论是 n=1 的时候还是 n=k 的时候，我们只需要关心这 4 种金额对组合产生的影响，而中间状态和变量的记录和跟踪这些繁琐的事情都由函数的递归调用负责。

2. 这个案例的限制条件不再是 64 个棋格，而是奖赏的总金额，因此判断嵌套调用是否结束的条件其实不是次数 k，而是总金额。这个金额确保了递归不会陷入死循环。

3. 我这里从奖赏的总金额开始，每次嵌套调用的时候减去一张纸币的金额，直到所剩的金额为 0 或者少于 0，然后结束嵌套调用，开始返回结果值。当然，你也可以反向操作，从金额 0 开始，每次嵌套调用的时候增加一张纸币的金额，直到累计的金额达到或超过总金额。

小结

递归和循环其实都是迭代法的实现，而且在某些场合下，它们的实现是可以相互转化的。但是，对于某些应用场景，递归确很难被循环取代。我觉得主要有两点原因：

第一，递归的核心思想和数学归纳法类似，并更具有广泛性。这两者的类似之处体现在：将当前的问题化解为两部分：一个当前所采取的步骤和另一个更简单的问题。

1. 一个当前所采取的步骤。这种步骤可能是进行一次运算（例如每个棋格里的麦粒数是前一格的两倍），或者做一个选择（例如选择不同面额的纸币），或者是不同类型操作的结合（例如今天讲的赏金的案例）等等。

2. 另一个更简单的问题。经过上述步骤之后，问题就会变得更加简单一点。这里“简单一点”，指运算的结果离目标值更近（例如赏金的总额），或者是完成了更多的选择（例如纸币的选择）。而“更简单的问题”，又可以通过嵌套调用，进一步简化和求解，直至达到结束条件。

我们只需要保证递归编程能够体现这种将复杂问题逐步简化的思想，那么它就能帮助我们解决很多类似的问题。

第二，递归会使用计算机的函数嵌套调用。而函数的调用本身，就可以保存很多中间状态和变量值，因此极大的方便了编程的处理。

正是如此，递归在计算机编程领域中有着广泛的应用，而不仅仅局限在求和等运算操作上。在下一节中，我将介绍如何使用递归的思想，进行“分而治之”的处理。

思考题

一个整数可以被分解为多个整数的乘积，例如，6 可以分解为 2x3。请使用递归编程的方法，为给定的整数 n，找到所有可能的分解（1 在解中最多只能出现 1 次）。例如，输入 8，输出是可以是 1x8, 8x1, 2x4, 4x2, 1x2x2x2, 1x2x4, ……

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

06 | 递归（下）：分而治之，从归并排序到MapReduce

你好，我是黄申。

上一节，我解释了如何使用递归，来处理迭代法中比较复杂的数值计算。说到这里，你可能会问了，有些迭代法并不是简单的数值计算，而要通过迭代的过程进行一定的操作，过程更加复杂，需要考虑很多中间数据的匹配或者保存。例如我们之前介绍的用二分查找进行数据匹配，或者我们今天将要介绍的归并排序中的数据排序等等。那么，这种情况下，还可以用递归吗？具体又该如何来实现呢？

我们可以先分析一下，这些看似很复杂的问题，是否可以简化为某些更小的、更简单的子问题来解决，这是一般思路。如果可以，那就意味着我们仍然可以使用递归的核心思想，将复杂的问题逐步简化成最基本的情况来求解。因此，今天我会从归并排序开始，延伸到多台机器的并行处理，详细讲讲递归思想在“分而治之”这个领域的应用。

归并排序中的分治思想

首先，我们来看，如何使用递归编程解决数字的排序问题。

对一堆杂乱无序的数字，按照从小到大或者从大到小的规则进行排序，这是计算机领域非常经典，也非常流行的问题。小到 Excel 电子表格，大到搜索引擎，都需要对一堆数字进行排序。因此，计算机领域的前辈们研究排序问题已经很多年了，也提出了许多优秀的算法，比如归并排序、快速排序、堆排序等等。其中，归并排序和快速排序都很好地体现了分治的思想，今天我来说说其中之一的归并排序（merge sort）。

很明显，归并排序算法的核心就是“归并”，也就是把两个有序的数列合并起来，形成一个更大的有序数列。

假设我们需要按照从小到大的顺序，合并两个有序数列 A 和 B。这里我们需要开辟一个新的存储空间 C，用于保存合并后的结果。

我们首先比较两个数列的第一个数，如果 A 数列的第一个数小于 B 数列的第一个数，那么就先取出 A 数列的第一个数放入 C，并把这个数从 A 数列里删除。如果是 B 的第一个数更小，那么就先取出 B 数列的第一个数放入 C，并把它从 B 数列里删除。

以此类推，直到 A 和 B 里所有的数都被取出来并放入 C。如果到某一步，A 或 B 数列为空，那直接将另一个数列的数据依次取出放入 C 就可以了。这种操作，可以保证两个有序的数列 A 和 B 合并到 C 之后，C 数列仍然是有序的。

为了你能更好地理解，我举个例子说明一下，这是合并有序数组{1, 2, 5, 8}和{3, 4, 6}的过程。

为了保证得到有序的 C 数列，我们必须保证参与合并的 A 和 B 也是有序的。可是，等待排序的数组一开始都是乱序的，如果无法保证这点，那归并又有什么意义呢？

还记得上一篇说的递归吗？这里我们就可以利用递归的思想，把问题不断简化，也就是把数列不断简化，一直简化到只剩 1 个数。1 个数本身就是有序的，对吧？

好了，现在剩下的疑惑就是，每一次如何简化问题呢？最简单的想法是，我们把将长度为 n 的数列，每次简化为长度为 n-1 的数列，直至长度为 1。不过，这样的处理没有并行性，要进行 n-1 次的归并操作，效率就会很低。

所以，我们可以在归并排序中引入了分而治之（Divide and Conquer）的思想。分而治之，我们通常简称为分治。它的思想就是，将一个复杂的问题，分解成两个甚至多个规模相同或类似的子问题，然后对这些子问题再进一步细分，直到最后的子问题变得很简单，很容易就能被求解出来，这样这个复杂的问题就求解出来了。

归并排序通过分治的思想，把长度为 n 的数列，每次简化为两个长度为 n/2 的数列。这更有利于计算机的并行处理，只需要 log₂n 次归并。

我们把归并和分治的思想结合起来，这其实就是归并排序算法。这种算法每次把数列进行二等分，直到唯一的数字，也就是最基本的有序数列。然后从这些最基本的有序数列开始，两两合并有序的数列，直到所有的数字都参与了归并排序。

我用一个包含 0～9 这 10 个数字的数组，给你详细讲解一下归并排序的过程。

假设初始的数组为{7, 6, 2, 4, 1, 9, 3, 8, 0, 5}，我们要对它进行从小到大的排序。

第一次分解后，变成两个数组{7, 6, 2, 4, 1}和{9, 3, 8, 0, 5}。

然后，我们将{7, 6, 2, 4, 1}分解为{7, 6}和{2, 4, 1}，将{9, 3, 8, 0, 5}分解为{9, 3}和{8, 0, 5}。

如果细分后的组仍然多于一个数字，我们就重复上述分解的步骤，直到每个组只包含一个数字。到这里，这些其实都是递归的嵌套调用过程。

然后，我们要开始进行合并了。我们可以将{4, 1}分解为{4}和{1}。现在无法再细分了，我们开始合并。在合并的过程中进行排序，所以合并的结果为{1,4}。合并后的结果将返回当前函数的调用者，这就是函数返回的过程。

重复上述合并的过程，直到完成整个数组的排序，得到{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}。

为了方便你的理解，我画了张图，给你解释整个归并排序的过程。

说到这里，我想问你，这个归并排序、分治和递归到底是什么关系呢？用一句话简单地说就是，归并排序使用了分治的思想，而这个过程需要使用递归来实现。

归并排序算法用分治的思想把数列不断地简化，直到每个数列仅剩下一个单独的数，然后再使用归并逐步合并有序的数列，从而达到将整个数列进行排序的目的。而这个归并排序，正好可以使用递归的方式来实现。为什么这么说？首先，我们来看看这张图，分治的过程是不是和递归的过程一致呢？

分治的过程可以通过递归来表达，因此，归并排序最直观的实现方式就是递归。所以，我们从递归的步骤出发，来看归并排序如何实现。

我们假设 n=k-1 的时候，我们已经对较小的两组数进行了排序。那我们只要在 n=k 的时候，将这两组数合并起来，并且保证合并后的数组仍然是有序的就行了。

所以，在递归的每次嵌套调用中，代码都将一组数分解成更小的两组，然后将这两个小组的排序交给下一次的嵌套调用。而本次调用只需要关心，如何将排好序的两个小组进行合并。

在初始状态，也就是 n=1 的时候，对于排序的案例而言，只包含单个数字的分组。由于分组里只有一个数字，所以它已经是排好序的了，之后就可以开始递归调用的返回阶段。我这里画了张图，便于你的理解。

你现在应该已经明白了归并排序的基本过程，最难的已经过去了，编写代码实现就不难了。我这里给出示范性代码，你可以参考看看。

import java.util.Arrays;

public class Lesson6_1 {

	/**

    * @Description:	使用函数的递归（嵌套）调用，实现归并排序（从小到大）

    * @param to_sort- 等待排序的数组

    * @return int[]- 排序后的数组

    */

	public static int[] merge_sort(int[] to_sort) {

		if (to_sort == null) return new int[0];

		// 如果分解到只剩一个数，返回该数

		if (to_sort.length == 1) return to_sort;

		// 将数组分解成左右两半

		int mid = to_sort.length / 2;

		int[] left = Arrays.copyOfRange(to_sort, 0, mid);

		int[] right = Arrays.copyOfRange(to_sort, mid, to_sort.length);

		// 嵌套调用，对两半分别进行排序

		left = merge_sort(left);

		right = merge_sort(right);

		// 合并排序后的两半

		int[] merged = merge(left, right);

		return merged;

	}

}

这里要注意一下，在归并的步骤中，由于递归的调用确保了被合并的两个较小的数组是有序的，所以我们无需比较组内的数字，只需要比较组间的数字就行了。

这个合并过程具体的实现代码是这样的：

/**

    * @Description: 合并两个已经排序完毕的数组（从小到大）

    * @param a- 第一个数组，b- 第二个数组

    * @return int[]- 合并后的数组

    */

    public static int[] merge(int[] a, int[] b) {

     if (a == null)  a = new int[0];

     if (b == null) b = new int[0];

     int[] merged_one = new int[a.length + b.length];

     int mi = 0, ai = 0, bi = 0;

     // 轮流从两个数组中取出较小的值，放入合并后的数组中

     while (ai < a.length && bi < b.length) {

      if (a[ai] <= b[bi]) {

       merged_one[mi] = a[ai];

       ai ++;

      } else {

       merged_one[mi] = b[bi];

       bi ++;

      }

      mi ++;

     }

     // 将某个数组内剩余的数字放入合并后的数组中

     if (ai < a.length) {

      for (int i = ai; i < a.length; i++) {

       merged_one[mi] = a[i];

       mi ++;

      }

     } else {

      for (int i = bi; i < b.length; i++) {

       merged_one[mi] = b[i];

       mi ++;

      }

     }

     return merged_one;

    }

上述两段代码的结合，就是归并排序的递归实现。你可以用这段代码进行测试：

public static void main(String[] args) {

  int[] to_sort = {3434, 3356, 67, 12334, 878667, 387};

  int[] sorted = Lesson6_1.merge_sort(to_sort);

  for (int i = 0; i < sorted.length; i++) {

   System.out.println(sorted[i]);

  }

 }

分布式系统中的分治思想

聊到这里，你应该已经了解归并排序算法是如何运作的了，也对分而治之的思想有了认识。不过，分而治之更有趣的应用其实是在分布式系统中。

例如，当需要排序的数组很大（比如达到 1024GB 的时候），我们没法把这些数据都塞入一台普通机器的内存里。该怎么办呢？有一个办法，我们可以把这个超级大的数据集，分解为多个更小的数据集（比如 16GB 或者更小），然后分配到多台机器，让它们并行地处理。

等所有机器处理完后，中央服务器再进行结果的合并。由于多个小任务间不会相互干扰，可以同时处理，这样会大大增加处理的速度，减少等待时间。

在单台机器上实现归并排序的时候，我们只需要在递归函数内，实现数据分组以及合并就行了。而在多个机器之间分配数据的时候，递归函数内除了分组及合并，还要负责把数据分发到某台机器上。

在这个框架图中，你应该可以看到，分布式集群中的数据切分和合并，同单台机器上归并排序的过程是一样的，因此也是使用了分治的思想。从理论的角度来看，上面这个图很容易理解。不过在实际运用中，有个地方需要注意一下。

上图中的父结点，例如机器 1、2、3，它们都没有被分配排序的工作，只是在子结点的排序完成后进行有序数组的合并，因此集群的性能没有得到充分利用。那么，另一种可能的数据切分方式是，每台机器拿出一半的数据给另一台机器处理，而自己来完成剩下一半的数据。

如果分治的时候，只进行一次问题切分，那么上述层级型的分布式架构就可以转化为类似 MapReduce 的架构。我画出了 MapReduce 的主要步骤，你可以看看，这里面有哪些步骤体现了分治的思想？

这里面主要有三个步骤用到了分治的思想。

1. 数据分割和映射

分割是指将数据源进行切分，并将分片发送到 Mapper 上。映射是指 Mapper 根据应用的需求，将内容按照键 - 值的匹配，存储到哈希结构中。这两个步骤将大的数据集合切分为更小的数据集，降低了每台机器节点的负载，因此和分治中的问题分解类似。不过，MapReduce 采用了哈希映射来分配数据，而普通的分治或递归不一定需要。

2. 归约

归约是指接受到的一组键值配对，如果是键内容相同的配对，就将它们的值归并。这和本机的递归调用后返回结果的过程类似。不过，由于哈希映射的关系，MapReduce 还需要洗牌的步骤，也就是将键 - 值的配对不断地发给对应的 Reducer 进行归约。普通的分治或递归不一定需要洗牌的步骤。

3. 合并

为了提升洗牌阶段的效率，可以选择减少发送到归约阶段的键 - 值配对。具体做法是在数据映射和洗牌之间，加入合并的过程，在每个 Mapper 节点上先进行一次本地的归约。然后只将合并的结果发送到洗牌和归约阶段。这和本机的递归调用后返回结果的过程类似。

说了这么多，你现在对分治应该有比较深入的理解了。实际上，分治主要就是用在将复杂问题转化为若干个规模相当的小问题上。分治思想通常包括问题的细分和结果的合并，正好对应于递归编程的函数嵌套调用和函数结果的返回。细分后的问题交给嵌套调用的函数去解决，而结果合并之后交由函数进行返回。所以，分治问题适合使用递归来实现。同时，分治的思想也可以帮助我们设计分布式系统和并行计算，细分后的问题交给不同的机器来处理，而其中的某些机器专门负责收集来自不同机器的处理结果，完成结果的合并。

小结

这两节我们学习了递归法。递归采用了和数学归纳法类似的思想，但是它用的是逆向递推，化繁为简，把复杂的问题逐步简化。再加上分治原理，我们就可以更有效地把问题细分，进行并行化的处理。

而计算机编程中的函数嵌套调用，正好对应了数学中递归的逆向递推，所以你只要弄明白了数学递推式，就能非常容易的写出对应的递归编码。这是为什么递归在编程领域有着非常广泛的应用。不过，需要注意的是，递归编程在没有开始返回结果之前，保存了大量的中间结果，所以比较消耗系统资源。这也是一般的编程语言都会限制递归的深度（也就是嵌套的次数）的原因。

思考题

你有没有想过，在归并排序的时候，为什么每次都将原有的数组分解为两组，而不是更多组呢？如果分为更多组，是否可行？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

07 | 排列：如何让计算机学会“田忌赛马”？

你好，我是黄申。

“田忌赛马”的故事我想你肯定听过吧？田忌是齐国有名的将领，他常常和齐王赛马，可是总是败下阵来，心中非常不悦。孙膑想帮田忌一把。他把这些马分为上、中、下三等。他让田忌用自己的下等马来应战齐王的上等马，用上等马应战齐王的中等马，用中等马应战齐王的下等马。三场比赛结束后，田忌只输了第一场，赢了后面两场，最终赢得与齐王的整场比赛。

孙膑每次都从田忌的马匹中挑选出一匹，一共进行三次，排列出战的顺序。是不是感觉这个过程很熟悉？这其实就是数学中的排列过程。

我们初高中的时候，都学过排列，它的概念是这么说的：从 n 个不同的元素中取出 m（1≤m≤n）个不同的元素，按照一定的顺序排成一列，这个过程就叫排列（Permutation）。当 m=n 这种特殊情况出现的时候，比如说，在田忌赛马的故事中，田忌的三匹马必须全部出战，这就是全排列（All Permutation）。

如果选择出的这 m 个元素可以有重复的，这样的排列就是为重复排列（Permutation with Repetition），否则就是不重复排列（Permutation without Repetition）。

看出来没有？这其实是一个树状结构。从树的根结点到叶子结点，每种路径都是一种排列。有多少个叶子结点就有多少种全排列。从图中我们可以看出，最终叶子结点的数量是 3x2x1=6，所以最终排列的数量为 6。

{上等，中等，下等}

{上等，下等，中等}

{中等，上等，下等}

{中等，下等，上等}

{下等，上等，中等}

{下等，中等，上等}

我用 t1，t2 和 t3 分别表示田忌的上、中、下等马跑完全程所需的时间，用 q1，q2 和 q3 分别表示齐王的上、中、下等马跑全程所需的时间，因此，q1<t1<q2<t2<q3<t3。

如果你将这些可能的排列，仔细地和齐王的上等、中等和下等马进行对比，只有{下等，上等，中等}这一种可能战胜齐王，也就是 t3>q1，t1<q2，t2<q3。

对于最终排列的数量，这里我再推广一下：

对于 n 个元素的全排列，所有可能的排列数量就是 nx(n-1)x(n-2)x…x2x1，也就是 n!；

对于 n 个元素里取出 m(0<m≤n) 个元素的不重复排列数量是 nx(n-1)x(n-2)x…x(n - m + 1)，也就是 n!/(n-m)!。

这两点都是可以用数学归纳法证明的，有兴趣的话你可以自己尝试一下。

如何让计算机为田忌安排赛马？

我们刚才讨论了 3 匹马的情况，这倒还好。可是，如果有 30 匹马、300 匹马，怎么办？30 的阶乘已经是天文数字了。更糟糕的是，如果两组马之间的速度关系也是非常随机的，例如 q1<q2<t1<t2<q3<t3， 那就不能再使用“最差的马和对方最好的马比赛”这种战术了。这个时候，人手动肯定是算不过来了，计算机又要帮我们大忙啦！我们使用代码来展示如何生成所有的排列。

如果你细心的话，就会发现在新版舍罕王赏麦的案例中，其实已经涉及了排列的思想，不过那个案例不是以“选取多少个元素”为终止条件，而是以“选取元素的总和”为终止条件。尽管这样，我们仍然可以使用递归的方式来快速地实现排列。

不过，要把田忌赛马的案例，转成计算机所能理解的内容，还需要额外下点功夫。

首先，在不同的选马阶段，我们都要保存已经有几匹马出战、它们的排列顺序、以及还剩几匹马没有选择。我使用变量 result 来存储到当前函数操作之前，已经出战的马匹及其排列顺序。而变量 horses 存储了到当前函数操作之前，还剩几匹马还没出战。变量 new_result 和 rest_horses 是分别从 result 和 horses 克隆而来，保证不会影响上一次的结果。

其次，孙膑的方法之所以奏效，是因为他看到每一等马中，田忌的马只比齐王的差一点点。如果相差太多，可能就会有不同的胜负结局。所以，在设置马匹跑完全程的时间上，我特意设置为 q1<t1<q2<t2<q3<t3，只有这样才能保证计算机得出和孙膑相同的结论。

import java.util.ArrayList;

import java.util.Arrays;

import java.util.HashMap;

public class Lesson7_1 {

	// 设置齐王的马跑完所需时间

	public static HashMap<String, Double> q_horses_time = new HashMap<String, Double>(){

		{

		 	  put("q1", 1.0);

		 	  put("q2", 2.0);

		    put("q3", 3.0);

		}

	};

	// 设置田忌的马跑完所需时间

	public static HashMap<String, Double> t_horses_time = new HashMap<String, Double>(){

		{

		 	  put("t1", 1.5);

		 	  put("t2", 2.5);

		    put("t3", 3.5);

		}

	};

	public	static ArrayList<String> q_horses = new ArrayList<String>(Arrays.asList("q1", "q2", "q3"));

	/**

    * @Description:	使用函数的递归（嵌套）调用，找出所有可能的马匹出战顺序

    * @param horses- 目前还剩多少马没有出战，result- 保存当前已经出战的马匹及顺序

    * @return void

    */

    public static void permutate(ArrayList<String> horses, ArrayList<String> result) {

    	// 所有马匹都已经出战，判断哪方获胜，输出结果

    	if (horses.size() == 0) {

    		System.out.println(result);

    		compare(result, q_horses);

    		System.out.println();

 	 			return;

   		}

   		for (int i = 0; i < horses.size(); i++) {

    		// 从剩下的未出战马匹中，选择一匹，加入结果

 	 			ArrayList<String> new_result = (ArrayList<String>)(result.clone());

   			new_result.add(horses.get(i));

    		// 将已选择的马匹从未出战的列表中移出

 	 			ArrayList<String> rest_horses = ((ArrayList<String>)horses.clone());

 	 			rest_horses.remove(i);

    		// 递归调用，对于剩余的马匹继续生成排列

   			permutate(rest_horses, new_result);

   		}

    }

}

另外，我还使用了 compare 的函数来比较田忌和齐王的马匹，看哪方获胜。

    public static void compare(ArrayList<String> t, ArrayList<String> q) {

    	int t_won_cnt = 0;

    	for (int i = 0; i < t.size(); i++) {

			System.out.println(t_horses_time.get(t.get(i)) + " " +  q_horses_time.get(q.get(i)));

			if (t_horses_time.get(t.get(i)) < q_horses_time.get(q.get(i))) t_won_cnt ++;

		}

		if (t_won_cnt > (t.size() / 2)) System.out.println(" 田忌获胜！");

		else System.out.println(" 齐王获胜！");

		System.out.println();

    }

下面是测试代码。当然你可以设置更多的马匹，并增加相应的马匹跑完全程的时间。

  public static void main(String[] args) {

		ArrayList<String> horses = new	ArrayList<String>(Arrays.asList("t1", "t2", "t3"));

		Lesson7_1.permutate(horses,	new ArrayList<String>());

	}

在最终的输出结果中，6 种排列中只有一种情况是田忌获胜的。

[t3, t1, t2]

3.5 1.0

1.5 2.0

2.5 3.0

田忌获胜！

如果田忌不听从孙膑的建议，而是随机的安排马匹出战，那么他只有 1/6 的获胜概率。

说到这里，我突然产生了一个想法，如果齐王也是随机安排他的马匹出战顺序，又会是怎样的结果？如果动手来实现的话，大体思路是我们为田忌和齐王两方都生成他们马匹的全排序，然后再做交叉对比，看哪方获胜。这个交叉对比的过程也是个排列的问题，田忌这边有 6 种顺序，而齐王也是 6 种顺序，所以一共的可能性是 6x6=36 种。

我用代码模拟了一下，你可以看看。

public static void main(String[] args) {

		ArrayList<String> t_horses = new ArrayList<String>(Arrays.asList("t1", "t2", "t3"));

		Lesson7_2.permutate(t_horses, new ArrayList<String>(), t_results);

		ArrayList<String> q_horses = new ArrayList<String>(Arrays.asList("q1", "q2", "q3"));

		Lesson7_2.permutate(q_horses, new ArrayList<String>(), q_results);

		System.out.println(t_results);

		System.out.println(q_results);

		System.out.println();

		for (int i = 0; i < t_results.size(); i++) {

			for (int j = 0; j < q_results.size(); j++) {

				Lesson7_2.compare(t_results.get(i), q_results.get(j));

			}

		}

	}

由于交叉对比时只需要选择 2 个元素，分别是田忌的出战顺序和齐王的出战顺序，所以这里使用 2 层循环的嵌套来实现。从最后的结果可以看出，田忌获胜的概率仍然是 1/6。

暴力破解密码如何使用排列思想？

聊了这么多，相信你对排列有了更多了解。在概率中，排列有很大的作用，因为排列会帮助我们列举出随机变量取值的所有可能性，用于生成这个变量的概率分布，之后在概率统计篇我还会具体介绍。此外，排列在计算机领域中有着很多应用场景。我这里讲讲最常见的密码的暴力破解。

我们先来看去年网络安全界的两件大事。第一件发生在 2017 年 5 月，新型“蠕虫”式勒索病毒 WannaCry 爆发。当时这个病毒蔓延得非常迅速，电脑被感染后，其中的文件会被加密锁住，黑客以此会向用户勒索比特币。第二件和美国的信用评级公司 Equifax 有关。仅在 2017 年内，这个公司就被黑客盗取了大约 1.46 亿用户的数据。

看样子，黑客攻击的方式多种多样，手段也高明了很多，但是窃取系统密码仍然是最常用的攻击方式。有时候，黑客们并不需要真的拿到你的密码，而是通过“猜”，也就是列举各种可能的密码，然后逐个地去尝试密码的正确性。如果某个尝试的密码正好和原先管理员设置的一样，那么系统就被破解了。这就是我们常说的暴力破解法。

我们可以假设一个密码是由英文字母组成的，那么每位密码有 52 种选择，也就是大小写字母加在一起的数量。那么，生成 m 位密码的可能性就是 52^m 种。也就是说，从 n（这里 n 为 52）个元素取出 m（0<m≤n）个元素的可重复全排列，总数量为 n^m。如果你遍历并尝试所有的可能性，就能破解密码了。

不过，即使存在这种暴力法，你也不用担心自己的密码很容易被人破解。我们平时需要使用密码登录的网站或者移动端 App 程序，基本上都限定了一定时间内尝试密码的次数，例如 1 天之内只能尝试 5 次等等。这些次数一定远远小于密码排列的可能性。

这也是为什么有些网站或 App 需要你一定使用多种类型的字符来创建密码，比如字母加数字加特殊符号。因为类型越多，n^m 中的 n 越大，可能性就越多。如果使用英文字母的 4 位密码，就有 52^4=7311616 种，超过了 700 万种。如果我们在密码中再加入 0～9 这 10 个阿拉伯数字，那么可能性就是 62^4=14776336 种，超过了 1400 万。

同理，我们也可以增加密码长度，也就是用 n^m 中的 m 来实现这一点。如果在英文和阿拉伯数字的基础上，我们把密码的长度增加到 6 位，那么就是 62^6=56800235584 种，已经超过了 568 亿了！这还没有考虑键盘上的各种特殊符号。有人估算了一下，如果用上全部 256 个 ASCII 码字符，设置长度为 8 的密码，那么一般的黑客需要 10 年左右的时间才能暴力破解这种密码。

小结

排列可以帮助我们生成很多可能性。由于这种特性，排列最多的用途就是穷举法，也就是，列出所有可能的情况，一个一个验证，然后看每种情况是否符合条件的解。

古代的孙膑利用排列的思想，穷举了田忌马匹的各种出战顺序，然后获得了战胜齐王的策略。现代的黑客，通过排列的方法，穷举了各种可能的密码，试图破坏系统的安全性。如果你所面临的问题，它的答案也是各种元素所组成的排列，那么你就可以考虑，有没有可能排列出所有的可能性，然后通过穷举的方式来获得最终的解。

思考题

假设有一个 4 位字母密码，每位密码是 a～e 之间的小写字母。你能否编写一段代码，来暴力破解该密码？（提示：根据可重复排列的规律，生成所有可能的 4 位密码。）

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

08 | 组合：如何让计算机安排世界杯的赛程？

你好，我是黄申。

2018 年足球世界杯结束有半年了，当时激烈的赛况你现在还记忆犹新吧？你知道这场足球盛宴的比赛日程是怎么安排的吗？如果现在你是组委会，你会怎么安排比赛日程呢？我们可以用上一节的排列思想，让全部的 32 支入围球队都和其他球队进行一次主客场的比赛。

自己不可能和自己比赛，因此在这种不可重复的排列中，主场球队有 32 种选择，而客场球队有 31 种选择。那么一共要进行多少场比赛呢？很简单，就是 32x31=992 场！这也太夸张了吧？一天看 2 场，也要 1 年多才能看完！即使球迷开心了，可是每队球员要踢主客场共 62 场，早已累趴下了。

好吧，既然这样，我们是否可以取消主客场制，让任意两个球队之间只要踢 1 场就好啦？取消主客场，这就意味着原来两队之间的比赛由 2 场降为 1 场，那么所有比赛场次就是 992/2=496 场。还是很多，对吧？

是的，这就是为什么要将所有 32 支队伍分成 8 个小组先进行小组赛的原因。一旦分成小组，每个小组的赛事就是 (4x3)/2=6 场。所有小组赛就是 6x8=48 场。

再加上在 16 强阶段开始采取淘汰制，两两淘汰，所以需要 8+4+2+2=16 场淘汰赛（最后一次加 2 是因为还有 3、4 名的决赛），那么整个世界杯决赛阶段就是 48+16=64 场比赛。

当然，说了这么多，你可能会好奇，这两两配对比赛的场次，我是如何计算出来的？让我引出今天的概念，组合（Combination）。

组合可以说是排列的兄弟，两者类似但又有所不同，这两者的区别，不知道你还记得不，上学的时候，老师肯定说过不止一次，那就是，组合是不考虑每个元素出现的顺序的。

从定义上来说，组合是指，从 n 个不同元素中取出 m（1≤m≤n）个不同的元素。例如，我们前面说到的世界杯足球赛的例子，从 32 支球队里找出任意 2 支球队进行比赛，就是从 32 个元素中取出 2 个元素的组合。如果上一讲中，田忌赛马的规则改一下，改为从 10 匹马里挑出 3 匹比赛，但是并不关心这 3 匹马的出战顺序，那么也是一个组合的问题。

对于所有 m 取值的组合之全集合，我们可以叫作全组合（All Combination）。例如对于集合{1, 2, 3}而言，全组合就是{空集, {1}, {2}, {3}, {1, 2}, {1,3} {2, 3}, {1, 2, 3}}。

如果我们安排足球比赛时，不考虑主客场，也就是不考虑这两只球队的顺序，两队只要踢一次就行了。那么从 n 个元素取出 m 个的组合，有多少种可能呢？

我们假设某种运动需要 3 支球队一起比赛，那么 32 支球队就有 32x31x30 种排列，如果三支球队在一起只要比一场，那么我们要抹除多余的比赛。三支球队按照任意顺序的比赛有 3x2x1=6 场，所以从 32 支队伍里取出 3 支队伍的组合是 (32x31x30)/(3x2x1)。基于此，我们可以扩展成以下两种情况。

n 个元素里取出 m 个的组合，可能性数量就是 n 个里取 m 个的排列数量，除以 m 个全排列的数量，也就是 (n! / (n-m)!) / m!。

对于全组合而言，可能性为 2^n 种。例如，当 n=3 的时候，全组合包括了 8 种情况。

这两点都可以用数学归纳法证明，有兴趣的话你可以自己尝试一下。

如何让计算机来组合队伍？

上一节，我用递归实现了全排列。全组合就是将所有元素列出来，没有太大意义，所以我这里就带你看下，如何使用递归从 3 个元素中选取 2 个元素的组合。

我们假设有 3 个队伍，t1，t2 和 t3。我还是把递归的选择画成图，这样比较直观，你也好理解。从图中我们可以看出，对于组合而言，由于{t1, t2}已经出现了，因此就无需{t2, t1}。同理，出现{t1, t3}，就无需{t3, t1}等等。对于重复的，我用叉划掉了。这样，最终只有 3 种组合了。

那么，如何使用代码来实现呢？一种最简单粗暴的做法是：

先实现排列的代码，输出所有的排列。例如{t1, t2}, {t2, t1}；

针对每种排列，对其中的元素按照一定的规则排序。那么上述两种排列经过排序后，就是{t1, t2}, {t1, t2}；

对排序后的排列，去掉重复的那些。上述两种排列最终只保留一个{t1, t2}。

这样做效率就会比较低，很多排列生成之后，最终还是要被当做重复的结果去掉。

显然，还有更好的做法。从图中我们可以看出被划掉的那些，都是那些出现顺序和原有顺序颠倒的元素。

例如，在原有集合中，t1 在 t2 的前面，所以我们划掉了{t2, t1}的组合。这是因为，我们知道 t1 出现在 t2 之前，t1 的组合中一定已经包含了 t2，所以 t2 的组合就无需再考虑 t1 了。因此，我只需要在原有的排列代码中，稍作修改，每次传入嵌套函数的剩余元素，不再是所有的未选择元素，而是出现在当前被选元素之后的那些。具体代码是这样的：

import java.util.ArrayList;

import java.util.Arrays;

public class Lesson8_1 {

	/**

    * @Description:	使用函数的递归（嵌套）调用，找出所有可能的队伍组合

    * @param teams- 目前还剩多少队伍没有参与组合，result- 保存当前已经组合的队伍

    * @return void

    */

    public static void combine(ArrayList<String> teams, ArrayList<String> result, int m) {

    	// 挑选完了 m 个元素，输出结果

    	if (result.size() == m) {

    		System.out.println(result);

    		return;

    	}

    	for (int i = 0; i < teams.size(); i++) {

    		// 从剩下的队伍中，选择一队，加入结果

    		ArrayList<String> newResult = (ArrayList<String>)(result.clone());

    		newResult.add(teams.get(i));

    		// 只考虑当前选择之后的所有队伍

    		ArrayList<String> rest_teams = new ArrayList<String>(teams.subList(i + 1, teams.size()));

    		// 递归调用，对于剩余的队伍继续生成组合

    		combine(rest_teams, newResult, m);

    	}

    }

}

这是一段测试代码，可以帮助我们找到从 3 个元素中选择 2 个元素的所有组合。

public static void main(String[] args) {

  ArrayList<String> teams = new ArrayList<String>(Arrays.asList("t1", "t2", "t3"));

  Lesson8_1.combine(teams, new ArrayList<String>(), 2);

 }

组合的应用：如何高效地处理词组？

组合在计算机领域中也有很多的应用场景。比如大型比赛中赛程的自动安排、多维度的数据分析以及自然语言处理的优化等等。

在我之前的研究工作中，经常要处理一些自然语言，用组合的思想提升系统性能。今天我结合自己亲身的经历，先来说说组合在自然语言处理中的应用。

当时，我们需要将每篇很长的文章，分隔成一个个的单词，然后对每个单词进行索引，便于日后的查询。但是很多时候，光有单个的单词是不够的，还要考虑多个单词所组成的词组。例如，“red bluetooth mouse”这样的词组。

处理词组最常见的一种方式是多元文法。什么是多元文法呢？这词看起来很复杂，其实就是把临近的几个单词合并起来，组合一个新的词组。比如我可以把“red”和“bluetooth”合并为“red bluetooth”，还可以把“bluetooth”和“mouse”合并为“bluetooth mouse”。

设计多元文法只是为了方便计算机的处理，而不考虑组合后的词组是不是有正确的语法和语义。例如“red bluetooth”，从人类的角度来看，这个词就很奇怪。但是毕竟它还会生成很多合理的词组，例如“bluetooth mouse”。所以，如果不进行任何深入的语法分析，我们其实没办法区分哪些多元词组是有意义的，哪些是没有意义的，因此最简单的做法就是保留所有词组。

普通的多元文法本身存在一个问题，那就是定死了每个元组内单词出现的顺序。例如，原文中可能出现的是“red bluetooth mouse”，可是用户在查询的时候可能输入的是“bluetooth mouse red”。这么输入肯定不符合语法，但实际上互联网上的用户经常会这么干。

那么，在这种情况下，如果我们只保留原文的“red bluetooth mouse”，就无法将其和用户输入的“bluetooth red mouse”匹配了。所以，如果我们并不要求查询词组中单词所出现的顺序和原文一致，那该怎么办呢？

我当时就在想，可以把每个二元或三元组进行全排列，得到所有的可能。但是这样的话，二元组的数量就会增加 1 倍，三元组的数量就会增加 5 倍，一篇文章的数据保存量就会增加 3 倍左右。我也试过对用户查询做全排列，把原有的二元组查询变为 2 个不同的二元组查询，把原有的三元组查询变为 6 个不同的三元组查询，但是事实是，这样会增加实时查询的耗时。

于是，我就想到了组合。多个单词出现时，我并不关心它们的顺序（也就是不关心排列），而只关心它们的组合。因为无需关心顺序，就意味着我可以对多元组内的单词进行某种形式的标准化。即使原来的单词出现顺序有所不同，经过这个标准化过程之后，都会变成唯一的顺序。

例如，“red bluetooth mouse”，这三个词排序后就是“bluetooth,mouse,red”，而“bluetooth red mouse”排序后也是“bluetooth,mouse,red”，自然两者就能匹配上了。我需要做的事情就是在保存文章多元组和处理用户查询这两个阶段分别进行这种排序。这样既可以减少保存的数据量，同时可以减少查询的耗时。这个问题很容易就解决了。怎么样，组合是不是非常神奇？

此外，组合思想还广泛应用在多维度的数据分析中。比如，我们要设计一个连锁店的销售业绩报表。这张报表有若干个属性，包括分店名称、所在城市、销售品类等等。那么最基本的总结数据包括每个分店的销售额、每个城市的销售额、每个品类的销售额。除了这些最基本的数据，我们还可以利用组合的思想，生成更多的筛选条件。

小结

组合和排列有相似之处，都是从 n 个元素中取出若干个元素。不过，排列考虑了取出的元素它们之间的顺序，而组合无需考虑这种顺序。这是排列和组合最大的区别。因此，组合适合找到多个元素之间的联系而并不在意它们之间的先后顺序，例如多元文法中的多元组，这有利于避免不必要的数据保存或操作。

具体到编程，组合和排列两者的实现非常类似。区别在于，组合并不考虑挑选出来的元素之间，是如何排序的。所以，在递归的时候，传入下一个嵌套调用函数的剩余元素，只需要包含当前被选元素之后的那些，以避免重复的组合。

思考题

假设现在需要设计一个抽奖系统。需要依次从 100 个人中，抽取三等奖 10 名，二等奖 3 名和一等奖 1 名。请列出所有可能的组合，需要注意的每人最多只能被抽中 1 次。

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

09 | 动态规划（上）：如何实现基于编辑距离的查询推荐？

你好，我是黄申。

上一篇讲组合的时候，我最后提到了有关文本的关键字查询。今天我接着文本搜索的话题，来聊聊查询推荐（Query Suggestion）的实现过程，以及它所使用的数学思想，动态规划（Dynamic Programming）。

那什么是动态规划呢？在递归那一节，我说过，我们可以通过不断分解问题，将复杂的任务简化为最基本的小问题，比如基于递归实现的归并排序、排列和组合等。不过有时候，我们并不用处理所有可能的情况，只要找到满足条件的最优解就行了。在这种情况下，我们需要在各种可能的局部解中，找出那些可能达到最优的局部解，而放弃其他的局部解。这个寻找最优解的过程其实就是动态规划。

动态规划需要通过子问题的最优解，推导出最终问题的最优解，因此这种方法特别注重子问题之间的转移关系。我们通常把这些子问题之间的转移称为状态转移，并把用于刻画这些状态转移的表达式称为状态转移方程。很显然，找到合适的状态转移方程，是动态规划的关键。

因此，这两节我会通过实际的案例，给你详细解释如何使用动态规划法寻找最优解，包括如何分解问题、发现状态转移的规律，以及定义状态转移方程。

编辑距离

当你在搜索引擎的搜索框中输入单词的时候，有没有发现，搜索引擎会返回一系列相关的关键词，方便你直接点击。甚至，当你某个单词输入有误的时候，搜索引擎依旧会返回正确的搜索结果。

搜索下拉提示和关键词纠错，这两个功能其实就是查询推荐。查询推荐的核心思想其实就是，对于用户的输入，查找相似的关键词并进行返回。而测量拉丁文的文本相似度，最常用的指标是编辑距离（Edit Distance）。

我刚才说了，查询推荐的这两个功能是针对输入有缺失或者有错误的字符串，依旧返回相应的结果。那么，将错误的字符串转成正确的，以此来返回查询结果，这个过程究竟是怎么进行的呢？

由一个字符串转成另一个字符串所需的最少编辑操作次数，我们就叫作编辑距离。这个概念是俄罗斯科学家莱文斯坦提出来的，所以我们也把编辑距离称作莱文斯坦距离（Levenshtein distance）。很显然，编辑距离越小，说明这两个字符串越相似，可以互相作为查询推荐。编辑操作有这三种：把一个字符替换成另一个字符；插入一个字符；删除一个字符。

比如，我们想把 mouuse 转换成 mouse，有很多方法可以实现，但是很显然，直接删除一个“u”是最简单的，所以这两者的编辑距离就是 1。

状态转移

对于 mouse 和 mouuse 的例子，我们肉眼很快就能观察出来，编辑距离是 1。但是我们现实的场景中，常常不会这么简单。如果给定任意两个非常复杂的字符串，如何高效地计算出它们之间的编辑距离呢？

我们之前讲过排列和组合。我们先试试用排列的思想来进行编辑操作。比如，把一个字符替换成另一个字符，我们可以想成把 A 中的一个字符替换成 B 中的一个字符。假设 B 中有 m 个不同的字符，那么替换的时候就有 m 种可能性。对于插入一个字符，我们可以想成在 A 中插入来自 B 的一个字符，同样假设 B 中有 m 个不同的字符，那么也有 m 种可能性。至于删除一个字符，我们可以想成在 A 中删除任何一个字符，假设 A 有 n 个不同的字符，那么有 n 种可能性。

可是，等到实现的时候，你会发现实际情况比想象中复杂得多。

首先，计算量非常大。我们假设字符串 A 的长度是 n，而 B 字符串中不同的字符数量是 m，那么 A 所有可能的排列大致在 m^n 这个数量级，这会导致非常久的处理时间。对于查询推荐等实时性的服务而言，服务器的响应时间太长，用户肯定无法接受。

其次，如果需要在字符串 A 中加字符，那么加几个呢，加在哪里呢？同样，删除字符也是如此。因此，可能的排列其实远不止 m^n。

我们现在回到问题本身，其实编辑距离只需要求最小的操作次数，并不要求列出所有的可能。而且排列过程非常容易出错，还会浪费大量计算资源。看来，排列的方法并不可行。

好，这里再来思考一下，其实我们并不需要排列的所有可能性，而只是关心最优解，也就是最短距离。那么，我们能不能每次都选择出一个到目前为止的最优解，并且只保留这种最优解？如果是这样，我们虽然还是使用迭代或者递归编程来实现，但效率上就可以提升很多。

我们先考虑最简单的情况。假设字符串 A 和 B 都是空字符串，那么很明显这个时候编辑距离就是 0。如果 A 增加一个字符 a1，B 保持不动，编辑距离就增加 1。同样，如果 B 增加一个字符 b1，A 保持不动，编辑距离增加 1。但是，如果 A 和 B 有一个字符，那么问题就有点复杂了，我们可以细分为以下几种情况。

我们先来看插入字符的情况。A 字符串是 a1 的时候，B 空串增加一个字符变为 b1；或者 B 字符串为 b1 的时候，A 空串增加一个字符变为 a1。很明显，这种情况下，编辑距离都要增加 1。

我们再来看替换字符的情况。当 A 和 B 都是空串的时候，同时增加一个字符。如果要加入的字符 a1 和 b1 不相等，表示 A 和 B 之间转化的时候需要替换字符，那么编辑距离就是加 1；如果 a1 和 b1 相等，无需替换，那么编辑距离不变。

最后，我们取上述三种情况中编辑距离的最小值作为当前的编辑距离。注意，这里我们只需要保留这个最小的值，而舍弃其他更大的值。这是为什么呢？因为编辑距离随着字符串的增长，是单调递增的。所以，要求最终的最小值，必须要保证对于每个子串，都取得了最小值。有了这点，之后我们就可以使用迭代的方式，一步步推导下去，直到两个字符串结束比较。

刚才我说的情况中没有删除，这是因为删除就是插入的逆操作。如果我们从完整的字符串 A 或者 B 开始，而不是从空串开始，这就是删除操作了。

从上述的过程可以看出，我们确实可以把求编辑距离这个复杂的问题，划分为更多更小的子问题。而且，更为重要的一点是，我们在每一个子问题中，都只需要保留一个最优解。之后的问题求解，只依赖这个最优值。这种求编辑距离的方法就是动态规划，而这些子问题在动态规划中被称为不同的状态。

如果文字描述不是很清楚的话，我这里又画一张表，把各个状态之间的转移都标示清楚，你就一目了然了。

我还是用 mouuse 和 mouse 的例子。我把 mouuse 的字符数组作为表格的行，每一行表示其中一个字母，而 mouse 的字符数组作为列，每列表示其中一个字母，这样就得到下面这个表格。

这张表格里的不同状态之间的转移，就是状态转移。其中红色部分表示字符串演变（或者说状态转移）的方式以及相应的编辑距离计算。对于表格中其他空白的部分，我暂时不给出，你可以试着自己来推导。

编辑距离是具有对称性的，也就是说从字符串 A 到 B 的编辑距离，和从字符串 B 到 A 的编辑距离，两者一定是相等的。这个应该很好理解。

你可以把刚才那个状态转移表的行和列互换一下，再推导一下，看看得出的编辑距离是否还是 1。我现在从理论上解释下这一点。这其实是由编辑距离的三种操作决定的。比如说，从字符串 A 演变到 B 的每一种操作，都可以转换为从字符串 B 演变到 A 的某一种操作。

所以说，从字符串 A 演变到 B 的每一种变化方式，都可以找到对应的从字符串 B 演变到 A 的某种方式，两者的操作次数一样。自然，代表最小操作次数的编辑距离也就一样了。

小结

我今天介绍了用于查询推荐的编辑距离。编辑距离的定义很好理解，不过，求任意两个字符串之间的编辑距离可不是一件容易的事情。我先尝试用排列来分析问题，发现这条路走不通，而后我们仍然使用了化繁为简的思路，把编辑距离的计算拆分为 3 种情况，并建立了子串之间的联系。

你不要觉得这样的分析过程比较繁琐，我想说的是，学数学固然是为了得到结果，但是学习的过程，是要学会解决问题的方法和思路。比如面对一个问题的时候，你可能不知道用什么方法来解决，但是你可以尝试用我们学过的这些基础思想去分析，去比对，在这个分析的过程中去总结这些方法的使用规律，久而久之，你就能摸索出自己解决问题的套路。

比如说，动态规划虽然也采用了把问题逐步简化的思想，但是它和基于递归的归并排序、排列组合等解法有所不同。能够使用动态规划解决的问题，通常只关心一个最优解，而这个最优解是单调改变的，例如最大值、最小值等等。因此，动态规划中的每种状态，通常只保留一个当前的最优解，这也是动态规划效率比较高的原因。

思考题

理解了动态规划法和状态转移之后，你觉得根据编辑距离来衡量字符串之间的相似程度有什么局限性？你有什么优化方案吗？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

10 | 动态规划（下）：如何求得状态转移方程并进行编程实现？

你好，我是黄申。

上一节，我从查询推荐的业务需求出发，介绍了编辑距离的概念，今天我们要基于此，来获得状态转移方程，然后才能进行实际的编码实现。

状态转移方程和编程实现

上一节我讲到了使用状态转移表来展示各个子串之间的关系，以及编辑距离的推导。不过，我没有完成那张表格。现在我把它补全，你可以和我的结果对照一下。

这里面求最小值的 min 函数里有三个参数，分别对应我们上节讲的三种情况的编辑距离，分别是：替换、插入和删除字符。在表格的右下角我标出了两个字符串的编辑距离 1。

概念和分析过程你都理解了，作为程序员，最终还是要落脚在编码上，我这里带你做些编码前的准备工作。

我们假设字符数组 A[] 和 B[] 分别表示字符串 A 和 B，A[i] 表示字符串 A 中第 i 个位置的字符，B[i] 表示字符串 B 中第 i 个位置的字符。二维数组 d[,] 表示刚刚用于推导的二维表格，而 d[i,j] 表示这张表格中第 i 行、第 j 列求得的最终编辑距离。函数 r(i, j) 表示替换时产生的编辑距离。如果 A[i] 和 B[j] 相同，函数的返回值为 0，否则返回值为 1。

有了这些定义，下面我们用迭代来表达上述的推导过程。

如果 i 为 0，且 j 也为 0，那么 d[i, j] 为 0。

如果 i 为 0，且 j 大于 0，那么 d[i, j] 为 j。

如果 i 大于 0，且 j 为 0，那么 d[i, j] 为 i。

如果 i 大于 0，且 j 大于 0，那么 d[i, j]=min(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + r(i, j))。

这里面最关键的一步是 d[i, j]=min(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + r(i, j))。这个表达式表示的是动态规划中从上一个状态到下一个状态之间可能存在的一些变化，以及基于这些变化的最终决策结果。我们把这样的表达式称为状态转移方程。我上节最开始就说过，在所有动态规划的解法中，状态转移方程是关键，所以你一定要掌握它。

有了状态转移方程，我们就可以很清晰地用数学的方式，来描述状态转移及其对应的决策过程，而且，有了状态转移方程，具体的编码其实就很容易了。基于编辑距离的状态转移方程，我在这里列出了一种编码的实现，你可以看看。

我们首先要定义函数的参数和返回值，你需要注意判断一下 a 和 b 为 null 的情况。

 public class Lesson10_1 {

	/**

    * @Description:	使用状态转移方程，计算两个字符串之间的编辑距离

    * @param a- 第一个字符串，b- 第二个字符串

    * @return int- 两者之间的编辑距离

    */

	public static int getStrDistance(String a, String b) {

		if (a == null || b == null) return -1;

然后，初始化状态转移表。我用 int 型的二维数组来表示这个状态转移表，并对 i 为 0 且 j 大于 0 的元素，以及 i 大于 0 且 j 为 0 的元素，赋予相应的初始值。

  // 初始用于记录化状态转移的二维表

		int[][] d = new int[a.length() + 1][b.length() + 1];

		// 如果 i 为 0，且 j 大于等于 0，那么 d[i, j] 为 j

		for (int j = 0; j <= b.length(); j++) {

			d[0][j] = j;

		}

		// 如果 i 大于等于 0，且 j 为 0，那么 d[i, j] 为 i

		for (int i = 0; i <= a.length(); i++) {

			d[i][0] = i;

		}

我这里实现的时候，i 和 j 都是从 0 开始，所以我计算的 d[i+1, j+1]，而不是 d[i, j]。而 d[i+1, j+1] = min(d[i, j+1] + 1, d[i+1, j] + 1, d[i, j] + r(i, j)。

  // 实现状态转移方程

		// 请注意由于 Java 语言实现的关系，代码里的状态转移是从 d[i, j] 到 d[i+1, j+1]，而不是从 d[i-1, j-1] 到 d[i, j]。本质上是一样的。

		for (int i = 0; i < a.length(); i++) {

			for (int j = 0; j < b.length(); j++) {

				int r = 0;

				if (a.charAt(i) != b.charAt(j)) {

					r = 1;

				}

				int first_append = d[i][j + 1] + 1;

				int second_append = d[i + 1][j] + 1;

				int replace = d[i][j] + r;

				int min = Math.min(first_append, second_append);

				min = Math.min(min, replace);

				d[i + 1][j + 1] = min;

			}

		}

		return d[a.length()][b.length()];

	}

}

最后，我们用测试代码测试不同字符串之间的编辑距离。

public static void main(String[] args) {

  // TODO Auto-generated method stub

  System.out.println(Lesson10_1.getStrDistance("mouse", "mouuse"));

 }

从推导的表格和最终的代码可以看出，我们相互比较长度为 m 和 n 的两个字符串，一共需要求 mxn 个子问题，因此计算量是 mxn 这个数量级。和排列法的 m^n 相比，这已经降低太多太多了。

我们现在可以快速计算出编辑距离，所以就能使用这个距离作为衡量字符串之间相似度的一个标准，然后就可以进行查询推荐了。

到这里，使用动态规划来实现的编辑距离其实就讲完了。我把两个字符串比较的问题，分解成很多子串进行比较的子问题，然后使用状态转移方程来描述状态（也就是子问题）之间的关系，并根据问题的定义，保留最小的值作为当前的编辑距离，直到过程结束。

如果我们使用动态规划法来实现编辑距离的测算，那就能确保查询推荐的效率和效果。不过，基于编辑距离的算法也有局限性，它只适用于拉丁语系的相似度衡量，所以通常只用于英文或者拼音相关的查询。如果是在中文这种亚洲语系中，差一个汉字（或字符）语义就会差很远，所以并不适合使用基于编辑距离的算法。

实战演练：钱币组合的新问题

和排列组合等穷举的方法相比，动态规划法关注发现某种最优解。如果一个问题无需求出所有可能的解，而是要找到满足一定条件的最优解，那么你就可以思考一下，是否能使用动态规划来降低求解的工作量。

还记得之前我们提到的新版舍罕王奖赏的故事吗？国王需要支付一定数量的赏金，而宰相要列出所有可能的钱币组合，这使用了排列组合的思想。如果这个问题再变化为“给定总金额和可能的钱币面额，能否找出钱币数量最少的奖赏方式？”，那么我们是否就可以使用动态规划呢？

思路和之前是类似的。我们先把这个问题分解成很多更小金额的子问题，然后试图找出状态转移方程。如果增加一枚钱币 c，那么当前钱币的总数量就是增加 c 之前的钱币总数再加上当前这枚。举个例子，假设这里我们有三种面额的钱币，2 元、3 元和 7 元。为了凑满 100 元的总金额，我们有三种选择。

第一种，总和 98 元的钱币，加上 1 枚 2 元的钱币。如果凑到 98 元的最少币数是 x1x1，那么增加一枚 2 元后就是 (x1x1 + 1) 枚。

第二种，总和 97 元的钱币，加上 1 枚 3 元的钱币。如果凑到 97 元的最少币数是 x2x2，那么增加一枚 3 元后就是 (x2x2 + 1) 枚。

第三种，总和 93 元的钱币，加上 1 枚 7 元的钱币。如果凑到 93 元的最少币数是 x3x3，那么增加一枚 7 元后就是 (x3x3 + 1) 枚。

比较一下以上三种情况的钱币总数，取最小的那个就是总额为 100 元时，最小的钱币数。换句话说，由于奖赏的总金额是固定的，所以最后选择的那枚钱币的面额，将决定到上一步为止的金额，同时也决定了上一步为止钱币的最少数量。根据这个，我们可以得出如下状态转移方程：

其中，c[i] 表示总额为 i 的时候，所需要的最少钱币数，其中 j=1,2,3,…,n，表示 n 种面额的钱币，value[j] 表示第 j 种钱币的面额。c[i - values(j)] 表示选择第 j 种钱币的时候，上一步为止最少的钱币数。需要注意的是，i - value(j) 需要大于等于 0，而且 c[0] = 0。

我这里使用这个状态转移方程，做些推导，具体的数据你可以看下面这个表格。表格每一行表示奖赏的总额，前 3 列表示 3 种钱币的面额，最后一列记录最少的钱币数量。表中的“/”表示不可能，或者说无解。

这张状态转移表同样可以帮助你来理解状态转移方程的正确性。一旦状态转移方程确定了，要编写代码来实现就不难了。

小结

通过这两节的内容，我讲述了动态规划主要的思想和应用。如果仅仅看这两个案例，也许你觉得动态规划不难理解。不过，在实际应用中，你可能会产生这些疑问：什么时候该用动态规划？这个问题可以用动态规划解决啊，为什么我没想到？我这里就讲一些我个人的经验。

首先，如果一个问题有很多种可能，看上去需要使用排列或组合的思想，但是最终求的只是某种最优解（例如最小值、最大值、最短子串、最长子串等等），那么你不妨试试是否可以使用动态规划。

其次，状态转移方程是个关键。你可以用状态转移表来帮助自己理解整个过程。如果能找到准确的转移方程，那么离最终的代码实现就不远了。当然，最好的方式，还是结合工作中的项目，不断地实践，尝试，然后总结。

思考题

对于总金额固定、找出最少钱币数的题目，用循环或者递归的方式该如何进行编码呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

11 | 树的深度优先搜索（上）：如何才能高效率地查字典？

你好，我是黄申。

你还记得迭代法中的的二分查找吗？在那一讲中，我们讨论了一个查字典的例子。如果要使用二分查找，我们首先要把整个字典排个序，然后每次都通过二分的方法来缩小搜索范围。

不过在平时的生活中，咱们查字典并不是这么做的。我们都是从单词的最左边的字母开始，逐个去查找。比如查找“boy”这个单词，我们一般是这么查的。首先，在 a～z 这 26 个英文字母里找到单词的第一个字母 b，然后在 b 开头的单词里找到字母 o，最终在 bo 开头的单词里找到字母 y。

你可以看我画的这种树状图，其实就是从树顶层的根结点一直遍历到最下层的叶子结点，最终逐步构成单词前缀的过程。对应的数据结构就是前缀树（prefix tree），或者叫字典树（trie）。我个人更喜欢前缀树这个名称，因为看到这个名词，这个数据结构的特征就一目了然。

那前缀树究竟该如何构建呢？有了前缀树，我们又该如何查询呢？今天，我会从图论的基本概念出发，来给你讲一下什么样的结构是树，以及如何通过树的深度优先搜索，来实现前缀树的构建和查询。

图论的一些基本概念

前缀树是一种有向树。那什么是有向树？顾名思义，有向树就是一种树，特殊的就是，它的边是有方向的。而树是没有简单回路的连通图。

如果一个图里所有的边都是有向边，那么这个图就是有向图。如果一个图里所有的边都是无向边，那么这个图就是无向图。既含有向边，又含无向边的图，称为混合图。

在有向图中，以结点 vv 为出发点的边的数量，我们叫作 vv 的出度。而以 v为v为 终点的边之数量，称为 vv 的入度。在上图中，结点 v2v2 的入度是 1，出度是 2。

还有两个和有向树有关的概念，回路和连通，我这里简单给你解释一下，你很容易就能明白了。

结点和边的交替序列组成的就是通路。所以，通路上的任意两个结点其实就是互为连通的。如果一条通路的起始点 v1v1 和终止点 vnvn 相同，这种特殊的通路我们就叫作回路。从起始点到终止点所经过的边之数量，就是通路的长度。这里我画了一张图，这里面有 1 条通路和 1 条回路，它们的长度都是 4。

理解了图的基本概念，我们再来看树和有向树。树是一种特殊的图，它是没有简单回路的连通无向图。这里的简单回路，其实就是指，除了第一个结点和最后一个结点相同外，其余结点不重复出现的回路。你可以看我画的这几幅图。

那么，什么是有向树呢？顾名思义，有向树是一种特殊的树，其中的边都是有向的，而且它满足以下几个条件：

有且仅有一个结点的入度为 0，这个结点被称为根；

除根以外的所有结点，入度都为 1。从树根到任一结点有且仅有一条有向通路。

除了这些基本定义，有向树还有几个重要的概念，父结点、子结点、兄弟结点、先辈结点、后辈结点、叶子结点、结点的高度（或深度）、树的高度（或深度）。这些都不难理解，我画个图展示一下，你就能明白了。我把根结点的高度设置为 0，根据需要你也可以设置为 1。

前缀树的构建和查询

好了，说了这么些，你对有向树应该有了理解。接下来，我们来看，如何使用有向树来实现前缀树呢？这整个过程主要包括两个部分：构建前缀树和查询前缀树。

1. 构建前缀树

首先，我们把空字符串作为树的根。对于每个单词，其中每一个字符都代表了有向树的一个结点。而前一个字符就是后一个字符的父结点，后一个字符是前一个字符的子结点。这也意味着，每增加一个字符，其实就是在当前字符结点下面增加一个子结点，相应地，树的高度也增加了 1。

我们以单词 geek 为例，从根结点开始，第一次我增加字符 g，在根结点下增加一个“g”的结点。第二次，我在“g”结点下方增加一个“e”结点。以此类推，最终我们可以得到下面的树。

那如果这个时候，再增加一个单词，geometry 会怎样？我们继续重复这个过程，就能得到下面这个图。

到这里为止，我们已经建立了包含两个单词的前缀树。在这棵树的两个叶子结点“k”和“y”上，我们可以加上额外的信息，比如单词的解释。那么在匹配成功之后，就可以直接返回这些信息，实现字典的功能了。假设我把牛津词典里所有的英文单词都按照上述的方法处理一遍，就能构造一棵包含这个字典里所有单词的前缀树，并实现常用单词的查找和解释。

2. 查询前缀树

假设我们已经使用牛津词典，构建完了一个完整的前缀树，现在我们就能按照开篇所说的那种方式，查找任何一个单词了。从前缀树的根开始，查找下一个结点，顺着这个通路走下去，一直走到到某个结点。如果这个结点及其前缀代表了一个存在的单词，而待查找的单词和这个结点及其前缀正好完全匹配，那就说明成功找到了一个单词。否则，就表示无法找到。

这里还有几种特殊情况，需要注意。

如果还没到叶子结点的时候，待查的单词就结束了。这个时候要看最后匹配上的非叶子结点是否代表一个单词；如果不是，那说明被查单词并不在字典中。

如果搜索到前缀树的叶子结点，但是被查单词仍有未处理的字母。由于叶子结点没有子结点，这时候，被查单词不可能在字典中。

如果搜索到一半，还没到达叶子结点，被查单词也有尚未处理的字母，但是当前被处理的字母已经无法和结点上的字符匹配了。这时候，被查单词不可能在字典中。

前缀树的构建和查询这两者在本质上其实是一致的。构建的时候，我们需要根据当前的前缀进行查询，然后才能找到合适的位置插入新的结点。而且，这两者都存在一个不断重复迭代的查找过程，我们把这种方式称为深度优先搜索（Depth First Search）。

所谓树的深度优先搜索，其实就是从树中的某个结点出发，沿着和这个结点相连的边向前走，找到下一个结点，然后以这种方式不断地发现新的结点和边，一直搜索下去，直到访问了所有和出发点连通的点、或者满足某个条件后停止。

如果到了某个点，发现和这个点直接相连的所有点都已经被访问过，那么就回退到在这个点的父结点，继续查看是否有新的点可以访问；如果没有就继续回退，一直到出发点。由于单棵树中所有的结点都是连通的，所以通过深度优先的策略可以遍历树中所有的结点，因此也被称为深度优先遍历。

为了让你更容易理解，我用下面这张图来展示在一棵有向树中进行深度优先搜索时，结点被访问的顺序。

其中，结点上的数字表示结点的 ID，而虚线表示遍历前进的方向，结点边上的数字表示该结点在深度优先搜索中被访问的顺序。在深度优先的策略下，我们从点 110 出发，然后发现和 110 相连的点 123，访问 123 后继续发现和 123 相连的点 162，再往后发现 162 没有出度，因此回退到 123，查看和 123 相连的另一个点 587，根据 587 的出度继续往前推进，如此类推。

把深度优先搜索，和在前缀树中查询单词的过程对比一下，你就会发现两者的逻辑是一致的。不过，使用前缀树匹配某个单词的时候，只需要沿着一条可能的通路搜索下去，而无需遍历树中所有的结点。

小结

在这一讲，我从数学中图的一些基本定义入手，介绍了有向树，以及有向树的一个应用，前缀树。树在计算机领域中运用非常广泛。比如，二叉树和满二叉树。

二叉树是每个结点最多有两个子树的树结构，它可用于二叉查找树和二叉堆。二叉树甚至可以用于图示化我们之前聊过的二分迭代。

满二叉树是一棵高度为 n（高度从 1 开始计），且有 2^n-1 个结点的二叉树。在高度为 k(0<k≤n）的这一层上，结点的数量为 2^(k-1)。如果把树的根标为 0，每个结点的左子结点标为 0，每个结点的右子结点标为 1，那么把根到叶子结点的所有 0 或 1 连起来，就正好对应一个二进制数。

既然树是如此重要，那么我们该如何高效率地访问树中的结点呢？下一讲，我会继续前缀树的话题，讨论如何遍历树中所有结点。

思考题

现在给你一个字典，请尝试实现其前缀树，包括树的构建和查询两个过程。这里，字典可以用字符串数组来表示，每个字符串代表一个单词。

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

12 | 树的深度优先搜索（下）：如何才能高效率地查字典？

你好，我是黄申。今天咱们继续聊前缀树。

上节结尾我给你留了道思考题：如何实现前缀树的构建和查询？如果你动手尝试之后，你会发现，这个案例的实现没有我们前面讲的那些排列组合这么直观。

这是因为，从数学的思想，到最终的编程实现，其实需要一个比较长的流程。我们首先需要把问题转化成数学中的模型，然后使用数据结构和算法来刻画数学模型，最终才能落实到编码。

而在前缀树中，我们需要同时涉及树的结构、树的动态构建和深度优先搜索，这个实现过程相对比较复杂。所以，这节我就给你仔细讲解一下，这个实现过程中需要注意的点。只要掌握这些点，你就能轻而易举实现深度优先搜索。

如何使用数据结构表达树？

首先，我想问你一个问题，什么样的数据结构可以表示树？

我们知道，计算机中最基本的数据结构是数组和链表。数组适合快速地随机访问。不过，数组并不适合稀疏的数列或者矩阵，而且数组中元素的插入和删除操作也比较低效。相对于数组，链表的随机访问的效率更低，但是它的优势是，不必事先规定数据的数量，表示稀疏的数列或矩阵时，可以更有效地利用存储空间，同时也利于数据的动态插入和删除。

我们再来看树的特点。树的结点及其之间的边，和链表中的结点和链接在本质上是一样的，因此，我们可以模仿链表的结构，用编程语言中的指针或对象引用来构建树。

除此之外，我们其实还可以用二维数组。用数组的行或列元素表示树中的结点，而行和列共同确定了两个树结点之间是不是存在边。可是在树中，这种二维关系通常是非常稀疏的、非常动态的，所以用数组效率就比较低下。

基于上面这些考虑，我们可以设计一个 TreeNode 类，表示有向树的结点和边。这个类需要体现前缀树结点最重要的两个属性。

这个结点所代表的字符，要用 label 变量表示。

这个结点有哪些子结点，要用 sons 哈希映射表示。之所以用哈希，是为了便于查找某个子结点（或者说对应的字符）是否存在。

另外，我们还可以用变量 prefix 表示当前结点之前的前缀，用变量 explanation 表示某个单词的解释。和之前一样，为了代码的简洁，所有属性都用了 public，避免读取和设置类属性的代码。

这里我写了一段 TreeNode 类的代码，来表示前缀树的结点和边，你可以看看。

/**

* @Description: 前缀树的结点

*

*/

 public class TreeNode {

  public char label;  // 结点的名称，在前缀树里是单个字母

  public HashMap<Character, TreeNode> sons = null; // 使用哈希映射存放子结点。哈希便于确认是否已经添加过某个字母对应的结点。

  public String prefix = null;   // 从树的根到当前结点这条通路上，全部字母所组成的前缀。例如通路 b->o->y，对于字母 o 结点而言，前缀是 b；对于字母 y 结点而言，前缀是 bo

  public String explanation = null;  // 词条的解释

  // 初始化结点

  public TreeNode(char l, String pre, String exp) {

   label = l;

   prefix = pre;

   explanation = exp;

   sons = new HashMap<>();

  }

 }

说到这里，你可能会好奇，为什么只有结点的定义，而没有边的定义呢？实际上，这里的有向边表达的是父子结点之间的关系，我把这种关系用 sons 变量来存储父结点。

需要注意的是，我们需要动态地构建这棵树。每当接收一个新单词时，代码都需要扫描这个单词的每个字母，并使用当前的前缀树进行匹配。如果匹配到某个结点，发现相应的字母结点并不存在，那么就建立一个新的树结点。这个过程不好理解，我也写了几行代码，你可以结合来看。其中，str 表示还未处理的字符串，parent 表示父结点。

// 处理当前字符串的第一个字母

char c = str.toCharArray()[0];

TreeNode found = null;

// 如果字母结点已经存在于当前父结点之下，找出它。否则就新生成一个

if (parent.sons.containsKey(c)) {

	found = parent.sons.get(c);

} else {

	TreeNode son = new TreeNode(c, pre, "");

	parent.sons.put(c, son);

	found = son;

}

如何使用递归和栈实现深度优先搜索？

构建好了数据结构，我们现在需要考虑，什么样的编程方式可以实现对树结点和边的操作？

仔细观察前缀树构建和查询，你会发现这两个不断重复迭代的过程，都可以使用递归编程来实现。换句话说，深度优先搜索的过程和递归调用在逻辑上是一致的。

我们可以把函数的嵌套调用，看作访问下一个连通的结点；把函数的返回，看作没有更多新的结点需要访问，回溯到上一个结点。在之前的案例中，我已经讲过很多次递归编程的例子，这里我就不列举代码细节了。如果忘记的话，你可以回去前面章节复习一下。

在查询的过程中，至少有三种情况是无法在字典里找到被查的单词的。于是，我们需要在递归的代码中做相应的处理。

第一种情况：被查单词所有字母都被处理完毕，但是我们仍然无法在字典里找到相应的词条。

每次递归调用的函数开始，我们都需要判断待查询的单词，看看是否还有字母需要处理。如果没有更多的字母需要匹配了，那么再确认一下当前匹配到的结点本身是不是一个单词。如果是，就返回相应的单词解释，否则就返回查找失败。对于结点是不是一个单词，你可以使用 Node 类中的 explanation 变量来进行标识和判断，如果不是一个存在的单词，这个变量应该是空串或者 Null 值。

第二种情况：搜索到前缀树的叶子结点，但是被查单词仍有未处理的字母，就返回查找失败。

我们可以通过结点对象的 sons 变量来判断这个结点是不是叶子结点。如果是叶子结点，这个变量应该是空的 HashMap，或者 Null 值。

第三种情况：搜索到中途，还没到达叶子结点，被查单词也有尚未处理的字母，但是当前被处理的字母已经无法和结点上的 label 匹配，返回查找失败。是不是叶子仍然通过结点对象的 sons 变量来判断。

好了，现在你已经可以很方便地在字典里查找某个单词，看看它是否存在，或者看看它的解释是什么。我这里又有一个新的问题了：如果我想遍历整个字典中所有的单词，那该怎么办呢？

仔细观察一下，你应该能发现，查找一个单词的过程，其实就是在有向树中，找一条从树的根到代表这个单词的结点之通路。那么如果要遍历所有的单词，就意味着我们要找出从根到所有代表单词的结点之通路。所以，在每个结点上，我们不再是和某个待查询单词中的字符进行比较，而是要遍历该结点所有的子结点，这样才能找到所有可能的通路。我们还可以用递归来实现这一过程。

尽管函数递归调用非常直观，可是也有它自身的弱点。函数的每次嵌套，都可能产生新的变量来保存中间结果，这可能会消耗大量的内存。所以这里我们可以用一个更节省内存的数据结构，栈（Stack）。

栈的特点是先进后出（First In Last Out），也就是，最先进入栈的元素最后才会得到处理。我画了一张元素入栈和出栈的过程图，你可以看看。

为什么栈可以进行深度优先搜索呢？你可以先回顾一下上一节，我解释深度优先搜索时候的例子。为了方便你回想，我把图放在这里了。

然后，我们用栈来实现一下这个过程。

第 1 步，将初始结点 110 压入栈中。

第 2 步，弹出结点 110，搜出下一级结点 123、879、945 和 131。

第 3 步，将结点 123、879、945 和 131 压入栈中。

第 4 步，重复第 2 步和第 3 步弹出和压入的步骤，处理结点 123，将新发现结点 162 和 587 压入栈中。

第 5 步，处理结点 162，由于 162 是叶子结点，所以没有发现新的点。第 6 步，重复第 2 和第 3 步，处理结点 587，将新发现结点 681 压入栈中。

……

第 n-1 步，重复第 2 和第 3 步，处理结点 131，将新发现结点 906 压入栈中。

第 n 步，重复第 2 和第 3 步，处理结点 906，没有发现新的结点，也没有更多待处理的结点，整个过程结束。

从上面的步骤来看，栈先进后出的特性，可以模拟函数的递归调用。实际上，计算机系统里的函数递归，在内部也是通过栈来实现的。如果我们不使用函数调用时自动生成的栈，而是手动使用栈的数据结构，就能始终保持数据的副本只有一个，大大节省内存的使用量。

用 TreeNode 类和栈实现深度优先搜索的代码我写出来了，你可以看看。

// 使用栈来实现深度优先搜索

public void dfsByStack(TreeNode root) {

  Stack<TreeNode> stack = new Stack<TreeNode>();

	  // 创建堆栈对象，其中每个元素都是 TreeNode 类型

  stack.push(root);		// 初始化的时候，压入根结点

  while (!stack.isEmpty()) {	// 只要栈里还有结点，就继续下去

	TreeNode node = stack.pop();	// 弹出栈顶的结点

	if (node.sons.size() == 0) {

		// 已经到达叶子结点了，输出

		System.out.println(node.prefix + node.label);

	} else {

		// 非叶子结点，遍历它的每个子结点

		Iterator<Entry<Character, TreeNode>> iter

			= node.sons.entrySet().iterator();

		// 注意，这里使用了一个临时的栈 stackTemp

		// 这样做是为了保持遍历的顺序，和递归遍历的顺序是一致的

		// 如果不要求一致，可以直接压入 stack

		Stack<TreeNode> stackTemp = new Stack<TreeNode>();

		while (iter.hasNext()) {

			stackTemp.push(iter.next().getValue());

		}

		while (!stackTemp.isEmpty()) {

			stack.push(stackTemp.pop());

		}

	}

  }

}

这里面有个细节需要注意一下。当我们把某个结点的子结点压入栈的时候，由于栈“先进后出”的特性，会导致子结点的访问顺序，和递归遍历时子结点的访问顺序相反。如果你希望两者保持一致，可以用一个临时的栈 stackTemp 把子结点入栈的顺序颠倒过来。

小结

这一节我们用递归来实现了深度优先搜索。说到这，你可能会想到，之前讨论的归并排序、排列组合等课题，也采用了递归来实现，那它们是不是也算深度优先搜索呢？

我把归并排序和排列的分解过程放在这里，它们是不是也可以用有向树来表示呢？

在归并排序的数据分解阶段，初始的数据集就是树的根结点，二分之前的数据集代表父节点，而二分之后的左半边的数据集和右半边的数据集都是父结点的子结点。分解过程一直持续到单个的数值，也就是最末端的叶子结点，很明显这个阶段可以用树来表示。如果使用递归编程来进行数据的切分，那么这种实现就是深度优先搜索的体现。

在排列中，我们可以把空集认为是树的根结点，如果把每次选择的元素作为父结点，那么剩下可选择的元素，就构成了这个父结点的子结点。而每多选择一个元素，就会把树的高度加 1。因此，我们也可以使用递归和深度优先搜索，列举所有可能的排列。

从这两个例子，我们可以看出有些数学思想都是相通的，例如递归、排列和深度优先搜索等等。

我来总结一下，其实深度优先搜索的核心思想，就是按照当前的通路，不断地向前进，当遇到走不通的时候就回退到上一个结点，通过另一个新的边进行尝试。如果这一个点所有的方向都走不通的时候，就继续回退。这样一次一次循环下去，直到到达目标结点。树中的每个结点，既可以表示某个子问题和它所对应的抽象状态，也可以表示某个数据结构中一部分具体的值。

所以，我们需要做的是，观察问题是否可以使用递归的方式来逐步简化，或者是否需要像前缀树这样遍历，如果是，就可以尝试使用深度优先搜索来帮助我们思考并解决问题。

思考题

这两节我讲的是树的深度优先搜索。如果是在一般的图中进行深度优先搜索，会有什么不同呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

13 | 树的广度优先搜索（上）：人际关系的六度理论是真的吗？

你好，我是黄申。

上一节，我们探讨了如何在树的结构里进行深度优先搜索。说到这里，有一个问题，不知道你有没有思考过，树既然是两维的，我们为什么一定要朝着纵向去进行深度优先搜索呢？是不是也可以朝着横向来进行搜索呢？今天我们就来看另一种搜索机制，广度优先搜索。

社交网络中的好友问题

LinkedIn、Facebook、微信、QQ 这些社交网络平台都有大量的用户。在这些社交网络中，非常重要的一部分就是人与人之间的“好友”关系。

在数学里，为了表示这种好友关系，我们通常使用图中的结点来表示一个人，而用图中的边来表示人和人之间的相识关系，那么社交网络就可以用图论来表示。而“相识关系”又可以分为单向和双向。

单向表示，两个人 a 和 b，a 认识 b，但是 b 不认识 a。如果是单向关系，我们就需要使用有向边来区分是 a 认识 b，还是 b 认识 a。如果是双向关系，双方相互认识，因此直接用无向边就够了。在今天的内容里，我们假设相识关系都是双向的，所以我们今天讨论的都是无向图。

从上面的例图可以看出，人与人之间的相识关系，可以有多条路径。比如，张三可以直接连接赵六，也可以通过王五来连接赵六。比较这两条通路，最短的通路长度是 1，因此张三和赵六是一度好友。也就是说，这里我用两人之间最短通路的长度，来定义他们是几度好友。照此定义，在之前的社交关系示意图中，张三、王五和赵六互为一度好友，而李四和赵六、王五为二度好友。

寻找两个人之间的最短通路，或者说找出两人是几度好友，在社交中有不少应用。例如，向你推荐新的好友、找出两人之间的关系的紧密程度、职场背景调查等等。在 LinkedIn 上，有个功能就是向你推荐了你可能感兴趣的人。下面这张图是我的 LinkedIn 主页里所显示的好友推荐。

这些被推荐的候选人，和我都有不少的共同连接，也就是共同好友。所以他们都是我的二度好友。但是，他们和我之间还没有建立直接的联系，因此不是一度好友。也就是说，对于某个当前用户，LinkedIn 是这么来选择好友推荐的：

被推荐的人和当前用户不是一度好友；

被推荐的人和当前用户是二度好友。

那为什么我们不考虑“三度“甚至是“四度”好友呢？我前面已经说过，两人之间最短的通路长度，表示他们是几度好友。那么三度或者四度，就意味着两人间最短的通路也要经历 2 个或更多的中间人，他们的关系就比较疏远，互相添加好友的可能性就大大降低。

所以呢，总结一下，如果我们想进行好友推荐，那么就要优先考虑用户的“二度“好友，然后才是“三度”或者“四度”好友。那么，下一个紧接着要面临的问题就是：给定一个用户，如何优先找到他的二度好友呢？

深度优先搜索面临的问题

这种情况下，你可能会想到上一篇介绍的深度优先搜索。深度优先搜索不仅可以用在树里，还可以应用在图里。不过，我们要面临的问题是图中可能存在回路，这会增加通路的长度，这是我们在计算几度好友时所不希望的。所以在使用深度优选搜索的时候，一旦遇到产生回路的边，我们需要将它过滤。具体的操作是，判断新访问的点是不是已经在当前通路中出现过，如果出现过就不再访问。

如果过滤掉产生回路的边，从一个用户出发，我们确实可以使用深度优先的策略，搜索完他所有的 n 度好友，然后再根据关系的度数，从二度、三度再到四度进行排序。这是个解决方法，但是效率太低了。为什么呢？

你也许听说过社交关系的六度理论。这个理论神奇的地方在于，它说地球上任何两个人之间的社交关系不会超过六度。咋一听，感觉不太可能。仔细想想，假设每个人平均认识 100 个人（我真心不觉得 100 很多，不信你掰着指头数数看自己认识多少人），那么你的二度好友就是 100^2，这个可以用我们前面讲的排列思想计算而来。

以此类推，三度好友是 100^3，到五度好友就有 100 亿人了，已经超过了地球目前的总人口。即使存在一些好友重复的情况下，例如，你的一度好友可能也出现在你的三度好友中，那这也不可能改变结果的数量级。所以目前来看，地球上任何两个人之间的社会关系不会超过六度。

六度理论告诉我们，你的社会关系会随着关系的度数增加，而呈指数级的膨胀。这意味着，在深度搜索的时候，每增加一度关系，就会新增大量的好友。但是你仔细回想一下，当我们在用户推荐中查看可能的好友时，基本上不会看完所有推荐列表，最多也就看个几十个人，一般可能也就看看前几个人。所以，如果我们使用深度优先搜索，把所有可能的好友都找到再排序，那效率实在太低了。

什么是广度优先搜索？

更高效的做法是，我们只需要先找到所有二度的好友，如果二度好友不够了，再去找三度或者四度的好友。这种好友搜索的模式，其实就是我们今天要介绍的广度优先搜索。

广度优先搜索（Breadth First Search），也叫宽度优先搜索，是指从图中的某个结点出发，沿着和这个点相连的边向前走，去寻找和这个点距离为 1 的所有其他点。只有当和起始点距离为 1 的所有点都被搜索完毕，才开始搜索和起始点距离为 2 的点。当所有和起始点距离为 2 的点都被搜索完了，才开始搜索和起始点距离为 3 的点，如此类推。

我用上一节介绍深度优先搜索顺序的那棵树，带你看一下广度优先搜索和深度优先搜索，在结点访问的顺序上有什么不一样。

同样，我们用结点上的数字表示结点的 ID，用虚线表示遍历前进的方向，用结点边上的数字表示该结点在广度优先搜索中被访问的顺序。从这个图中，你有没有发现，广度优先搜索其实就是横向搜索一颗树啊！

尽管广度优先和深度优先搜索的顺序是不一样的，它们也有两个共同点。

第一，在前进的过程中，我们不希望走重复的结点和边，所以会对已经被访问过的点做记号，而在之后的前进过程中，就只访问那些还没有被标记的点。这一点上，广度优先和深度优先是一致的。有所不同的是，在广度优先中，如果发现和某个结点直接相连的点都已经被访问过，那么下一步就会看和这个点的兄弟结点直接相连的那些点，从中看看是不是有新的点可以访问。

例如，在上图中，访问完结点 945 的两个子结点 580 和 762 之后，广度优先策略发现 945 没有其他的子结点了，因此就去查看 945 的兄弟结点 131，看看它有哪些子结点可以访问，因此下一个被访问的点是 906。而在深度优先中，如果到了某个点，发现和这个点直接相连的所有点都已经被访问过了，那么不会查看它的兄弟结点，而是回退到这个点的父节点，继续查看和父结点直接相连的点中是不是存在新的点。例如在上图中，访问完结点 945 的两个子结点之后，深度优先策略会回退到点 110，然后访问 110 的子结点 131。

第二，广度优先搜索也可以让我们访问所有和起始点相通的点，因此也被称为广度优先遍历。如果一个图包含多个互不连通的子图，那么从起始点开始的广度优先搜索只能涵盖其中一个子图。这时，我们就需要换一个还没有被访问过的起始点，继续深度优先遍历另一个子图。深度优先搜索可以使用同样的方式来遍历有多个连通子图的图，这也回答了上一讲的思考题。

如何实现社交好友推荐？

第 12 讲中我说深度优先是利用递归的嵌套调用、或者是栈的数据结构来实现的。然而，广度优先的访问顺序是不一样的，我们需要优先考虑和某个给定结点距离为 1 的所有其他结点。等距离为 1 的结点访问完，才会考虑距离为 2 的结点。等距离为 2 的结点访问完，才会考虑距离为 3 的结点等等。在这种情况下，我们无法不断地根据结点的边走下去，而是要先遍历所有距离为 1 的点。

那么，如何在记录所有已被发现的结点情况下，优先访问距离更短的点呢？仔细观察，你会发现和起始点更近的结点，会先更早地被发现。也就是说，越早被访问到的结点，越早地处理它，这是不是很像我们平时排队的情形？早到的人可以优先接受服务，而晚到的人需要等前面的人都离开，才能轮到。所以这里我们需要用到队列这种先进先出（First In First Out）的数据结构。

如果你不是很熟悉队列的数据结构，我这里简短地回顾一下。队列是一种线性表，要被访问的下一个元素来自队列的头部，而所有新来的元素都会加入队列的尾部。

我画了张图给你讲队列的工作过程。首先，读取已有元素的时候，都是从队列的头部来取，例如 x1x1，x2x2 等等。所有新的元素都加入队列的尾部，例如 xmxm，xm+1xm+1。

那么在广度优先搜索中，队列是如何工作的呢？这主要分为以下几个步骤。

首先，把初始结点放入队列中。然后，每次从队列首位取出一个结点，搜索所有在它下一级的结点。接下来，把新发现的结点加入队列的末尾。重复上述的步骤，直到没有发现新的结点为止。

我以上面的树状图为例，并通过队列实现广度优先搜索。

第 1 步，将初始结点 110 加入队列中。

第 2 步，取出结点 110，搜出下一级结点 123、879、945 和 131。

第 3 步，将点 123、879、945 和 131 加入队列的末尾。

第 4 步，重复第 2 和第 3 步，处理结点 123，将新发现结点 162 和 587 加入队列末尾。

第 5 步，重复第 2 和第 3 步，处理结点 879，没有发现新结点。

第 6 步，重复第 2 和第 3 步，处理结点 945，将新发现的结点 580 和 762 加入队列末尾。

……

第 n-1 步，重复第 2 和第 3 步，处理结点 906，没有发现新结点。

第 n 步，重复第 2 和第 3 步，处理结点 681，没有发现新的结点，也没有更多待处理的结点，整个过程结束。

理解了如何使用队列来实现广度优先搜索之后，我们就可以开始着手编写代码。我们现在没有现成的用户关系网络数据，所以我们需要先模拟生成一些用户结点及其间的相识关系，然后利用队列的数据结构进行广度优先的搜索。基于此，主要使用的数据结构包括：

用户结点 Node。这次设计的用户结点和前缀树结点 TreeNode 略有不同，包含了用户的 ID user_id，以及这个用户的好友集合。我用 HashSet 实现，便于在生成用户关系图的时候，确认是否会有重复的好友。

表示整个图的结点数组 Node[]。由于每个用户使用 user_id 来表示，所以我可以使用连续的数组表示所有的用户。用户的 user_id 就是数组的下标。

队列 Queue。由于 Java 中 Queue 是一个接口，因此需要用一个拥有具体实现的 LinkedList 类。

首先我们列出结点 Node 类的示例代码。

public class Node {

		public int user_id;		// 结点的名称，这里使用用户 id

		public HashSet<Integer> friends = null;

				// 使用哈希映射存放相连的朋友结点。哈希便于确认和某个用户是否相连。

		public int degree;		// 用于存放和给定的用户结点，是几度好友

		// 初始化结点

		public Node(int id) {

			user_id = id;

			friends = new HashSet<>();

			degree = 0;

  }

	}

我们可以用代码随机生成用户间的关系。首先根据指定的用户数量，生成 Node[] 数组，以及数组中的每个用户的节点 Node。然后根据边的数量，生成用户之间的相识关系。需要注意的是，自己不能是自己的好友，而且某个用户的所有好友之中不能有重复的人。

Node[] user_nodes = new Node[user_num];

// 生成所有表示用户的结点

for (int i = 0; i < user_num; i++) {

	user_nodes[i] = new Node(i);

}

// 生成所有表示好友关系的边

for (int i = 0; i < relation_num; i++) {

	int friend_a_id = rand.nextInt(user_num);

	int friend_b_id = rand.nextInt(user_num);

	if (friend_a_id == friend_b_id) continue;

      // 自己不能是自己的好友。如果生成的两个好友 id 相同，跳过

	Node friend_a = user_nodes[friend_a_id];

	Node friend_b = user_nodes[friend_b_id];

          friend_a.friends.add(friend_b_id);

          friend_b.friends.add(friend_a_id);

}

其中，user_num- 用户的数量，也就是结点的数量。relation_num- 好友关系的数量，也就是边的数量。由于 HashSet 有去重的功能，所以我这里做了简化处理，没有判断是否存在重复的边，也没有因为重复的边而重新生成另一条边。

随后我们的主角，广度优先搜索就要出场了。这里我使用了一个 visited 变量，存放已经被访问过的结点，防止回路的产生。

/**

* @Description:	通过广度优先搜索，查找好友

* @param user_nodes- 用户的结点；user_id- 给定的用户 ID，我们要为这个用户查找好友

* @return void

*/

public static void bfs(Node[] user_nodes, int user_id) {

		if (user_id > user_nodes.length) return;	// 防止数组越界的异常

		Queue<Integer> queue = new LinkedList<Integer>();	// 用于广度优先搜索的队列

		queue.offer(user_id);		// 放入初始结点

		HashSet<Integer> visited = new HashSet<>();	// 存放已经被访问过的结点，防止回路

		visited.add(user_id);

		while (!queue.isEmpty()) {

			int current_user_id = queue.poll();		// 拿出队列头部的第一个结点

			if (user_nodes[current_user_id] == null) continue;

			// 遍历刚刚拿出的这个结点的所有直接连接结点，并加入队列尾部

			for (int friend_id : user_nodes[current_user_id].friends) {

				if (user_nodes[friend_id] == null) continue;

				if (visited.contains(friend_id)) continue;

				queue.offer(friend_id);

				visited.add(friend_id);	// 记录已经访问过的结点

				user_nodes[friend_id].degree = user_nodes[current_user_id].degree + 1;		// 好友度数是当前结点的好友度数再加 1

				System.out.println(String.format("\t%d 度好友：%d",  user_nodes[friend_id].degree, friend_id));

			}

		}

	}

需要注意的是，这里用户结点之间的边是随机生成的，所以每次结果会有所不同。如果想重现固定的结果，可以从某个文件加载用户之间的关系。

小结

在遍历树或者图的时候，如果使用深度优先的策略，被发现的结点数量可能呈指数级增长。如果我们更关心的是最近的相连结点，比如社交关系中的二度好友，那么这种情况下，广度优先策略更高效。也正是由于这种特性，我们不能再使用递归编程或者栈的数据结构来实现广度优先，而是需要用到具有先进先出特点的队列。

思考题

在计算机的操作系统中，我们常常需要查看某个目录下的文件或子目录。现在给定一个目录的路径，请分别使用深度优先和广度优先搜索，列出该目录下所有的文件和子目录。对于子目录，需要进一步展示其下的文件和子目录，直到没有更多的子目录。

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

14 | 树的广度优先搜索（下）：为什么双向广度优先搜索的效率更高？

你好，我是黄申。

上一讲，我们通过社交好友的关系，介绍了为什么需要广度优先策略，以及如何通过队列来实现它。有了广度优先搜索，我们就可以知道某个用户的一度、二度、三度等好友是谁。不过，在社交网络中，还有一个经常碰到的问题，那就是给定两个用户，如何确定他们之间的关系有多紧密？

最直接的方法是，使用这两人是几度好友来衡量他们关系的紧密程度。今天，我就这个问题，来聊聊广度优先策略的一种扩展：双向广度优先搜索，以及这种策略在工程中的应用。

如何更高效地求两个用户间的最短路径？

基本的做法是，从其中一个人出发，进行广度优先搜索，看看另一个人是否在其中。如果不幸的话，两个人相距六度，那么即使是广度优先搜索，同样要达到万亿级的数量。

那究竟该如何更高效地求得两个用户的最短路径呢？我们先看看，影响效率的问题在哪里？很显然，随着社会关系的度数增加，好友数量是呈指数级增长的。所以，如果我们可以控制这种指数级的增长，那么就可以控制潜在好友的数量，达到提升效率的目的。

如何控制这种增长呢？我这里介绍一种“双向广度优先搜索”。它巧妙地运用了两个方向的广度优先搜索，大幅降低了搜索的度数。现在我就带你看下，这个方法的核心思想。

假设有两个人 aa、bb。我们首先从 aa 出发，进行广度优先搜索，记录 aa 的所有一度好友 a1a1，然后看点 bb 是否出现在集合 a1a1 中。如果没有，就再从 bb 出发，进行广度优先搜索，记录所有一度好友 b1b1，然后看 aa 和 a1a1 是否出现在 bb 和 b1b1 的并集中。如果没有，就回到 aa，继续从它出发的广度优先搜索，记录所有二度好友 a2a2，然后看 bb 和 b1b1 是否出现在 aa、a1a1 和 a2a2 三者的并集中。如果没有，就回到 bb，继续从它出发的广度优先搜索。如此轮流下去，直到找到 aa 的好友和 bb 的好友的交集。

如果有交集，就表明这个交集里的点到 aa 和 bb 都是通路。我们假设 cc 在这个交集中，那么把 aa 到 cc 的通路长度和 bb 到 cc 的通路长度相加，得到的就是从 aa 到 bb 的最短通路长（这个命题可以用反证法证明），也就是两者为几度好友。这个过程略有点复杂，我画了一张图帮助你来理解。

思路你应该都清楚了，现在我们来看看如何用代码来实现。

要想实现双向广度优先搜索，首先我们要把结点类 Node 稍作修改，增加一个变量 degrees。这个变量是 HashSet 类型，用于存放从不同用户出发，到当前用户是第几度结点。比如说，当前结点是 4，从结点 1 到结点 4 是 3 度，结点 2 到结点 4 是 2 度，结点 3 到结点 4 是 4 度，那么结点 4 的 degrees 变量存放的就是如下映射：

有了变量 degrees，我们就能随时知道某个点和两个出发点各自相距多少。所以，在发现交集之后，根据交集中的点，和两个出发点各自相距多少，就能很快地算出最短通路的长度。理解了这点之后，我们在原有的 Node 结点内增加 degrees 变量的定义和初始化。

public class Node {

		......

		public HashMap<Integer, Integer> degrees;		// 存放从不同用户出发，当前用户结点是第几度

		// 初始化结点

		public Node(int id) {

			......

			degrees = new HashMap<>();

			degrees.put(id, 0);

		}

	}

为了让双向广度优先搜索的代码可读性更好，我们可以先实现两个模块化的函数：getNextDegreeFriend 和 hasOverlap。函数 getNextDegreeFriend 是根据给定的队列，查找和起始点相距度数为指定值的所有好友。而函数 hasOverlap 用来判断两个集合是不是有交集。有了这些模块化的函数，双向广度优先搜索的代码就更直观了。

在函数一开始，我们先进行边界条件判断。

/**

* @Description:	通过双向广度优先搜索，查找两人之间最短通路的长度

* @param user_nodes- 用户的结点；user_id_a- 用户 a 的 ID；user_id_b- 用户 b 的 ID

* @return void

*/

	public static int bi_bfs(Node[] user_nodes, int user_id_a, int user_id_b) {

		if (user_id_a > user_nodes.length || user_id_b > user_nodes.length) return -1;	// 防止数组越界的异常

		if (user_id_a == user_id_b) return 0;		// 两个用户是同一人，直接返回 0

由于同时从两个用户的结点出发，对于所有有两条搜索的路径，我们都需要初始化两个用于广度优先搜索的队列，以及两个用于存放已经被访问结点的 HashSet。

  Queue<Integer> queue_a = new LinkedList<Integer>();	// 队列 a，用于从用户 a 出发的广度优先搜索

		Queue<Integer> queue_b = new LinkedList<Integer>();	// 队列 b，用于从用户 b 出发的广度优先搜索

		queue_a.offer(user_id_a);		// 放入初始结点

		HashSet<Integer> visited_a = new HashSet<>();	// 存放已经被访问过的结点，防止回路

		visited_a.add(user_id_a);

		queue_b.offer(user_id_b);		// 放入初始结点

		HashSet<Integer> visited_b = new HashSet<>();	// 存放已经被访问过的结点，防止回路

		visited_b.add(user_id_b);

接下来要做的是，从两个结点出发，沿着各自的方向，每次广度优先搜索一度，并查找是不是存在重叠的好友。

int degree_a = 0, degree_b = 0, max_degree = 20;		// max_degree 的设置，防止两者之间不存在通路的情况

while ((degree_a + degree_b) < max_degree) {

			degree_a ++;

			getNextDegreeFriend(user_id_a, user_nodes, queue_a, visited_a, degree_a);

	// 沿着 a 出发的方向，继续广度优先搜索 degree + 1 的好友

			if (hasOverlap(visited_a, visited_b)) return (degree_a + degree_b);

	// 判断到目前为止，被发现的 a 的好友，和被发现的 b 的好友，两个集合是否存在交集

			degree_b ++;

			getNextDegreeFriend(user_id_b, user_nodes, queue_b, visited_b, degree_b);

	// 沿着 b 出发的方向，继续广度优先搜索 degree + 1 的好友

			if (hasOverlap(visited_a, visited_b)) return (degree_a + degree_b);

	// 判断到目前为止，被发现的 a 的好友，和被发现的 b 的好友，两个集合是否存在交集

		}

		return -1;

    // 广度优先搜索超过 max_degree 之后，仍然没有发现 a 和 b 的重叠，认为没有通路

	}

你可以同时实现单向广度优先搜索和双向广度优先搜索，然后通过实验来比较两者的执行时间，看看哪个更短。如果实验的数据量足够大（比如说结点在 1 万以上，边在 5 万以上），你应该能发现，双向的方法对时间和内存的消耗都更少。为什么双向搜索的效率更高呢？我以平均好友度数为 4，给你举例讲解。

左边的图表示从结点 aa 单向搜索走 2 步，右边的图表示分别从结点 aa 和 bb 双向搜索各走 1 步。很明显，左边的结点有 16 个，明显多于右边的 8 个结点。而且，随着每人认识的好友数、搜索路径的增加，这种差距会更加明显。

我们假设每个地球人平均认识 100 个人，如果两个人相距六度，单向广度优先搜索要遍历 100^6=1 万亿左右的人。如果是双向广度优先搜索，那么两边各自搜索的人只有 100^3=100 万。

当然，你可能会说，单向广度优先搜索之后查找匹配用户的开销更小啊。的确如此，假设我们要知道结点 aa 和 bb 之间的最短路径，单向搜索意味着要在 aa 的 1 万亿个好友中查找 bb。如果采用双向搜索的策略，从结点 aa 和 bb 出发进行广度优先搜索，每个方向会产生 100 万的好友，那么需要比较这两组 100 万的好友是否有交集。假设我们使用哈希表来存储 aa 的 1 万亿个好友，并把搜索 bb 是否存在其中的耗时记作 x，而把判断两组 100 万好友是否有交集的耗时记为 y，那么通常 x<y。

不过，综合考虑广度优先搜索出来的好友数量，双向广度优先搜索还是更有效。为什么这么说呢？稍后介绍算法复杂度的概念和衡量方法时，我会具体来分析这个例子。

广度优先搜索的应用场景有很多，下面我来说说这种策略的一个应用。

如何实现更有效的嵌套型聚合？

广度优先策略可以帮助我们大幅优化数据分析中的聚合操作。聚合是数据分析中一个很常见的操作，它会根据一定的条件把记录聚集成不同的分组，以便我们统计每个分组里的信息。目前，SQL 语言中的 GROUP BY 语句，Python 和 Spark 语言中 data frame 的 groupby 函数，Solr 的 facet 查询和 Elasticsearch 的 aggregation 查询，都可以实现聚合的功能。

我们可以嵌套使用不同的聚合，获得层级型的统计结果。但是，实际上，针对一个规模超大的数据集，聚合的嵌套可能会导致性能严重下降。这里我来谈谈如何利用广度优先的策略，对这种问题进行优化。

首先，我用一个具体的例子来给你讲讲，什么是多级嵌套的聚合，以及为什么它会产生严重的性能问题。

这里我列举了一个数据表，它描述了一个社交网络中，每个人的职业经历。字段包括项目的 ID、用户 ID、公司 ID 和同事的 IDs。

对于这张表，我们可以进行三层嵌套的聚集。第一级是根据用户 ID 来聚，获取每位用户一共参与了多少项目。第二级是根据公司 ID 来聚，获取每位用户在每家公司参与了多少项目。第三级根据同事 ID 来聚，获取每位用户在每家公司，和每位同事共同参与了多少项目。最终结果应该是类似下面这样的：

用户 u88，总共 50 个项目（包括在公司 c42 中的 10 个，c26 中的 8 个...）

		在公司 c42 中，参与 10 个项目（包括和 u120 共事的 4 个，和 u99 共事的 3 个...）

				和 u120 共同参与 4 个项目

				和 u99 共同参与 3 个项目

				和 u72 共同参与 3 个项目

		在公司 c26 中，参与了 8 个项目

				和 u145 共同参与 5 个项目

				和 u128 共同参与 3 个项目

		（用户 u88 在其他公司的项目...）

用户 u66，总共 47 个项目

		在公司 c28 中，参与了 16 个项目

		  和 u65 共同参与了 5 个项目

(用户 u66 的剩余数据...）

...

（其他用户的数据...）

为了实现这种嵌套式的聚合统计，你会怎么来设计呢？看起来挺复杂的，其实我们可以用最简单的排列的思想，分别为“每个用户”“每个用户 + 每个公司”“每个用户 + 每个公司 + 每位同事”，生成很多很多的计数器。可是，如果用户的数量非常大，那么这个“很多”就会成为一个可怕的数字。

我们假设这个社交网有 5 万用户，每位用户平均在 5 家公司工作过，而用户在每家公司平均有 10 名共事的同事，那么针对用户的计数器有 5 万个，针对“每个用户 + 每个公司”的计数器有 25 万个，而到了“每个用户 + 每个公司 + 每位同事”的计数器，就已经达到 250 万个了，三个层级总共需要 280 万计数器。

我们假设一个计数器是 4 个字节，那么 280 万个计数器就需要消耗超过 10M 的内存。对于高并发、低延迟的实时性服务，如果每个请求都要消耗 10M 内存，很容易就导致服务器崩溃。另外，实时性的服务，往往只需要前若干个结果就足以满足需求了。在这种情况下，完全基于排列的设计就有优化的空间了。

从刚才那张图中，其实我们就能想到一些优化的思路。

对于只需要返回前若干结果的应用场景，我们可以对图中的树状结构进行剪枝，去掉绝大部分不需要的结点和边，这样就能节省大量的内存和 CPU 计算。

比如，如果我们只需要返回前 100 个参与项目最多的用户，那么就没有必要按照深度优先的策略，去扩展树中高度为 2 和 3 的结点了，而是应该使用广度优先策略，首先找出所有高度为 1 的结点，根据项目数量进行排序，然后只取出前 100 个，把计数器的数量从 5 万个一下子降到 100 个。

以此类推，我们还可以控制高度为 2 和 3 的结点之数量。如果我们只要看前 100 位用户，每位用户只看排名第一的公司，而每家公司只看合作最多的 3 名同事，那么最终计数器数量就只有 50000+100x5+100x1x10=51500。只有文字还是不太好懂，我画了一张图，帮你理解这个过程。

如果一个项目用到排列组合的思想，我们需要在程序里使用大量的变量，来保存数据或者进行计算，这会导致内存和 CPU 使用量的急剧增加。在允许的情况下，我们可以考虑使用广度优先策略，对排列组合所生成的树进行优化。这样，我们就可以有效地缩减树中靠近根的结点数量，避免之后树的爆炸性生长。

小结

广度优先搜索，相对于深度优先搜索，没有函数的嵌套调用和回溯操作，所以运行速度比较快。但是，随着搜索过程的进行，广度优先需要在队列中存放新遇到的所有结点，因此占用的存储空间通常比深度优先搜索多。

相比之下，深度优先搜索法只保留用于回溯的结点，而扩展完的结点会从栈中弹出并被删除。所以深度优先搜索占用空间相对较少。不过，深度优先搜索的速度比较慢，而并不适合查找结点之间的最短路径这类的应用。

思考题

今天所说的双向广度优先比单向广度优先更高效，其实是要基于一个前提条件的。你能否说出，在什么情况下，单向广度优先更高效呢？针对这种情况，又该如何优化双向广度优先呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

15 | 从树到图：如何让计算机学会看地图？

你好，我是黄申。

我们经常使用手机上的地图导航 App，查找出行的路线。那计算机是如何在多个选择中找到最优解呢？换句话说，计算机是如何挑选出最佳路线的呢？

前几节，我们讲了数学中非常重要的图论中的概念，图，尤其是树中的广度优先搜索。在广度优先的策略中，因为社交网络中的关系是双向的，所以我们直接用无向边来求解图中任意两点的最短通路。

这里，我们依旧可以用图来解决这个问题，但是，影响到达最终目的地的因素有很多，比如出行的交通工具、行驶的距离、每条道路的交通状况等等，因此，我们需要赋予到达目的地的每条边不同的权重。而我们想求的最佳路线，其实就是各边权重之和最小的通路。

我们前面说了，广度优先搜索只测量通路的长度，而不考虑每条边上的权重。那么广度优先搜索就无法高效地完成这个任务了。那我们能否把它改造或者优化一下呢？

我们需要先把交通地图转为图的模型。图中的每个结点表示一个地点，每条边表示一条道路或者交通工具的路线。其中，边是有向的，表示单行道等情况。其次，边是有权重的。

假设你关心的是路上所花费的时间，那么权重就是从一点到另一点所花费的时间；如果你关心的是距离，那么权重就是两点之间的物理距离。这样，我们就把交通导航转换成图论中的一个问题：在边有权重的图中，如何让计算机查找最优通路？

基于广度优先或深度优先搜索的方法

我们以寻找耗时最短的路线为例来看看。

一旦我们把地图转换成了图的模型，就可以运用广度优先搜索，计算从某个出发点，到图中任意一个其他结点的总耗时。基本思路是，从出发点开始，广度优先遍历每个点，当遍历到某个点的时候，如果该点还没有耗时的记录，记下当前这条通路的耗时。如果该点之前已经有耗时记录了，那就比较当前这条通路的耗时是不是比之前少。如果是，那就用当前的替换掉之前的记录。

实际上，地图导航和之前社交网络最大的不同在于，每个结点被访问了一次还是多次。在之前的社交网络的案例中，使用广度优先策略时，对每个结点的首次访问就能获得最短通路，因此每个结点只需要被访问一次，这也是为什么广度优先比深度优先更有效。

而在地图导航的案例中，从出发点到某个目的地结点，可能有不同的通路，也就意味着耗时不同。而耗时是通路上每条边的权重决定的，而不是通路的长度。因此，为了获取达到某个点的最短时间，我们必须遍历所有可能的路线，来取得最小值。这也就是说，我们对某些结点的访问可能有多次。

我画了一张图，方便你理解多条通路对最终结果的影响。这张图中有 A、B、C、D、E 五个结点，分别表示不同的地点。

从这个图中可以看出，从 A 点出发到到目的地 B 点，一共有三条路线。如果你直接从 A 点到 B 点，度数为 1，需要 50 分钟。从 A 点到 C 点再到 B 点，虽然度数为 2，但总共只要 40 分钟。从 A 点到 D 点，到 E 点，再到最后的 B 点，虽然度数为 3，但是总耗时只有 35 分钟，比其他所有的路线更优。这种情形之下，使用广度优先找到的最短通路，不一定是最优的路线。所以，对于在地图上查找最优路线的问题，无论是广度优先还是深度优先的策略，都需要遍历所有可能的路线，然后取最优的解。

在遍历所有可能的路线时，有几个问题需要注意。

第一，由于要遍历所有可能的通路，因此一个点可能会被访问多次。当然，这个“多次“是指某个结点出现在不同通路中，而不是多次出现在同一条通路中。因为我们不想让用户总是兜圈子，所以需要避免回路。

第二，如果某个结点 x 和起始点 s 之间存在多个通路，每当 x 到 s 之间的最优路线被更新之后，我们还需要更新所有和 x 相邻的结点之最优路线，计算复杂度会很高。

一个优化的版本：Dijkstra 算法

无论是广度优先还是深度优先的实现，算法对每个结点的访问都可能多于一次。而访问多次，就意味着要消耗更多的计算机资源。那么，有没有可能在保证最终结果是正确的情况下，尽可能地减少访问结点的次数，来提升算法的效率呢？

首先，我们思考一下，对于某些结点，是不是可以提前获得到达它们的最终的解（例如最短耗时、最短距离、最低价格等等），从而把它们提前移出遍历的清单？如果有，是哪些结点呢？什么时候可以把它们移出呢？Dijkstra 算法要登场了！它简直就是为了解决这些问题量身定制的。

Dijkstra 算法的核心思想是，对于某个结点，如果我们已经发现了最优的通路，那么就无需在将来的步骤中，再次考虑这个结点。Dijkstra 算法很巧妙地找到这种点，而且能确保已经为它找到了最优路径。

1.Dijkstra 算法的主要步骤

让我们先来看看 Dijkstra 算法的主要步骤，然后再来理解，它究竟是如何确定哪些结点已经拥有了最优解。

首先你需要了解几个符号。

第一个是 source，我们用它表示图中的起始点，缩写是 s。

然后是 weight，表示二维数组，保存了任意边的权重，缩写为 w。w[m, n] 表示从结点 m 到结点 n 的有向边之权重，大于等于 0。如果 m 到 n 有多条边，而且权重各自不同，那么取权重最小的那条边。

接下来是 min_weight，表示一维数组，保存了从 s 到任意结点的最小权重，缩写为 mw。假设从 s 到某个结点 m 有多条通路，而每条通路的权重是这条通路上所有边的权重之和，那么 mw[m] 就表示这些通路权重中的最小值。mw[s]=0，表示起始点到自己的最小权重为 0。

最后是 Finish，表示已经找到最小权重的结点之集合，缩写为 F。一旦结点被放入集合 F，这个结点就不再参与将来的计算。

初始的时候，Dijkstra 算法会做三件事情。第一，把起始点 s 的最小权重赋为 0，也就是 mw[s] = 0。第二，往集合 F 里添加结点 s，F 包含且仅包含 s。第三，假设结点 s 能直接到达的边集合为 M，对于其中的每一条边 m，则把 mw[m] 设为 w[s, m]，同时对于所有其他 s 不能直接到达的结点，将通路的权重设为无穷大。

然后，Dijkstra 算法会重复下列两个步骤。

第一步，查找最小 mw。从 mw 数组选择最小值，则这个值就是起始点 s 到所对应的结点的最小权重，并且把这个点加入到 F 中，针对这个点的计算就算完成了。比如，当前 mw 中最小的值是 mw[x]=10，那么结点 s 到结点 x 的最小权重就是 10，并且把结点 x 放入集合 F，将来没有必要再考虑点 x，mw[x] 可能的最小值也就确定为 10 了。

第二步，更新权重。然后，我们看看，新加入 F 的结点 x，是不是可以直接到达其他结点。如果是，看看通过 x 到达其他点的通路权重，是否比这些点当前的 mw 更小，如果是，那么就替换这些点在 mw 中的值。例如，x 可以直接到达 y，那么把 (mw[x] + w[x, y]) 和 mw[y] 比较，如果 (mw[x] + w[x, y]) 的值更小，那么把 mw[y] 更新为这个更小的值，而我们把 x 称为 y 的前驱结点。

然后，重复上述两步，再次从 mw 中找出最小值，此时要求 mw 对应的结点不属于 F，重复上述动作，直到集合 F 包含了图的所有结点，也就是说，没有结点需要处理了。

字面描述有些抽象，我用一个具体的例子来解释一下。你可以看我画的这个图。

我们把结点 s 放入集合 F。同 s 直接相连的结点有 a、b、c 和 d，我把它们的 mw 更新为 w 数组中的值，就可以得到如下结果：

然后，我们从 mw 选出最小的值 0.2，把对应的结点 c 加入集合 F，并更新和 c 直接相连的结点 f、h 的 mw 值，得到如下结果：

然后，我们从 mw 选出最小的值 0.3，把对应的结点 b 加入集合 F，并更新和 b 直接相连的结点 a 和 f 的 mw 值。以此逐步类推，可以得到如下的最终结果：

你可以试着自己从头到尾推导一下，看看结果是不是和我的一致。

说到这里，你可能会产生一个疑问：Dijkstra 算法提前把一些结点排除在计算之外，而且没有遍历全部可能的路径，那么它是如何确保找到最优路径的呢？下面，我们就来看看这个问题的答案。Dijkstra 算法的步骤看上去有点复杂，不过其中最关键的两步是：第一个是每次选择最小的 mw；第二个是，假设被选中的最小 mw，所对应的结点是 x，那么查看和 x 直接相连的结点，并更新它们的 mw。

2. 为什么每次都要选择最小的 mw？

最小的、非无穷大的 mw 值，对应的结点是还没有加入 F 集合的、且和 s 有通路的那些结点。假设当前 mw 数组中最小的值是 mw[x]，对应的结点是 x。如果边的权重都是正值，那么通路上的权重之和是单调递增的，所以其他通路的权重之和一定大于当前的 mw[x]，因此即使存在其他的通路，其权重也会比 mw[x] 大。

你可以结合这个图，来理解我刚才这段话。

图中的虚线表示省去了通路中间的若干结点。mw[x] 是当前 mw 数组中的最小值，所以它小于等于任何一个 mw[xn]，其中 xn 不等于 x。

我们假设存在另一个通路，通过 xnxn 达到 x，那么通路的权重总和为 mw[xnxn] + w[xnxn, x] ≥ mw[xnxn] ≥ mw[x]。所以我们可以得到一个结论：拥有最小 mw 值的结点 x 不可能再找到更小的 mw 值，可以把它放入“已完成“的集合 F。

这就是为什么每次都要选择最小的 mw 值，并认为对应的结点已经完成了计算。和广度优先或者深度优先的搜索相比，Dijkstra 算法可以避免对某些结点，重复而且无效的访问。因此，每次选择最小的 mw，就可以提升了搜索的效率。

3. 为什么每次都要看 x 直接相连的结点？

我们已经确定 mw[x] 是从点 s 到点 x 的最小权重，那么就可以把这个确定的值传播到和 x 直接相连、而且不在 F 中的结点。通过这一步，我们就可以获得从点 s 到这些点、而且经过 x 的通路中最小的那个权重。我画了张图帮助你理解。

在这个图中，x 直接相连 y1y1，y2y2，…，ynyn。从点 s 到点 x 的 mw[x] 已经确定了，那么对于从 s 到 yn 的所有通路，只有两种可能，经过 x 和不经过 x。如果这条通路经过 x，那么其权重的最小值就是 mw’[yiyi] = mw[x] + w[x, yiyi] 中的一个（1≤i≤n），我们只需要把这个值和其他未经过 x 结点的通路之权重对比就足够了。这就是为什么每次要更新和 x 直接相连的结点之 mw。

这一步和广度优先策略中的查找某个结点的所有相邻结点类似。但是，之后，Dijkstra 算法重复挑选最小权重的步骤，既没有遵从广度优先，也没有遵从深度优先。即便如此，它仍然保证了不会遗漏任意一点和起始点 s 之间、拥有最小权重的通路，从而保证了搜索的覆盖率。你可能会奇怪，这是如何得到保证的？我使用数学归纳法，来证明一下。

你还记得数学归纳法的一般步骤吗？刚好借由这个例子我们也来复习一下。

我们的命题是，对于任意一个点，Dijkstra 算法都可以找到它和起始点 s 之间拥有最小权重的通路。

首先，当 n=1 的时候，也就是只有起始点 s 和另一个终止点的时候，Dijkstra 算法的初始化阶段的第 3 步，保证了命题的成立。

然后，我们假设 n=k-1 的时候命题成立，同时需要证明 n=k 的时候命题也成立。命题在 n=k-1 时成立，表明从点 s 到 k-1 个终点的任何一个时，Dijkstra 算法都能找到拥有最小权重的通路。那么再增加一个结点 x，Dijkstra 算法同样可以为包含 x 的 k 个终点找到最小权重通路。

这里我们只需要考虑 x 和这 k-1 个点连通的情况。因为如果不连通，就没有必要考虑 x 了。既然连通，x 可能会指向之前 k-1 个结点，也有可能被这 k-1 个结点所指向。假设 x 指向了 y，而 z 指向了 x，y 和 z 都是之前 k-1 个结点中的一员。

我们先来看 x 对 y 的影响。如果 x 不在从 s 到 y 的最小权重通路上，那么 x 的加入并不影响 mw[y] 的最终结果。如果 x 在从 s 到 y 的最小权重通路上，那么就意味着 mw[x] + w[x, y]≤mw’[y]，mw’表示没有引入结点 x 的时候，mw 的值。所以有 mw[x]≤mw’[y]，这就意味着 Dijkstra 算法在查找最小 mw 的步骤中，会在 mw’[y] 之前挑出 mw[x]，也就是找到了从 s 到 y，且经过 x 的最小权重通路。

我们再来看 z 对 x 的影响。假设有多个 z 指向 x，分别是 z1z1, z2z2, …,zmzm，从 s 到 x 的通路必定会经过这 m 个 z 结点中的一个。Dijkstra 算法中找最小 mw 的步骤，一定会遍历 mw[zizi]（1<=i<=m），而更新权重的步骤，可以并保证从 (mw[zizi] + w[zizi, x]) 中找出最小值，最终找到从 s 到 x 的最优通路。

有了详细的推导，想要写出代码就不难了。我这里只给你说几点需要注意的地方。

在自动生成图的函数中，你需要把广度优先搜索的相应代码做两处修改。第一，现在边是有向的了，所以生成的边只需要添加一次；第二，要给边赋予一个权重值，例如可以把边的权重设置为 [0,1.0) 之间的 float 型数值。

为了更好地模块化，你可以实现两个函数：findGeoWithMinWeight 和 updateWeight。它们分别对应于我之前提到的最重要的两步：每次选择最小的 mw；更新和 x 直接相连的结点之 mw。

每次查找最小 mw 的时候，我们需要跳过已经完成的结点，只考虑那些不在 F 集合中的点。这也是 Dijkstra 算法比较高效的原因。此外，如果你想输出最优路径上的每个结点，那么在 updateWeight 函数中就要记录每个结点的前驱结点。

如果你能跟着我进行一步步的推导，并且手写代码进行练习，相信你对 Dijkstra 算法会有更深刻的印象。

小结

我们使用 Dijkstra 算法来查找地图中两点之间的最短路径，而今天我所介绍的 Dijkstra 使用了更为抽象的“权重”。如果我们把结点作为地理位置，边的权重设置为路上所花费的时间，那么 Dijkstra 算法就能帮助我们找到，任意两个点之间耗时最短的路线。

除了时间之外，你也可以对图的边设置其他类型的权重，比如距离、价格，这样 Dijkstra 算法可以让用户找到地图任意两点之间的最短路线，或者出行的最低价格等等。有的时候，边的权重越大越好，比如观光车开过某条路线的车票收入。对于这种情况，Dijkstra 算法就需要调整一下，每次找到最大的 mw，更新邻近结点时也要找更大的值。所以，你只要掌握核心的思路就可以了，具体的实现可以根据情况去灵活调整。

思考题

今天的思考题和地图数据的特殊情况有关。

如果边的权重是负数，我们还能用今天讲的 Dijkstra 算法吗？

如果地图中存在多条最优路径，也就是说多条路径的权重和都是相等的，那么我刚刚介绍的 Dijkstra 算法应该如何修改呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

16 | 时间和空间复杂度（上）：优化性能是否只是“纸上谈兵”？

你好，我是黄申。

作为程序员，你一定非常清楚复杂度分析对编码的重要性。计算机系统从最初的设计、开发到最终的部署，要经过很多的步骤，而影响系统性能的因素有很多。我把这些因素分为三大类：算法理论上的计算复杂度、开发实现的方案和硬件设备的规格。

如果将整个系统的构建比作生产汽车，那么计算复杂度相当于在蓝图设计阶段，对整个汽车的性能进行评估。如果我们能够进行准确的复杂度分析，那么就能从理论上预估汽车的各项指标，避免生产出一辆既耗油又开得很慢的汽车。

可是，你也常常会发现，要准确地分析复杂度并不容易。这一讲，我来说说如何使用数学的思维，来进行系统性的复杂度分析。

基本概念

我先带你简短回顾一下几个重要概念，便于你稍后更好地理解本节的内容。

算法复杂度是一个比较抽象的概念，通常只是一个估计值，它用于衡量程序在运行时所需要的资源，用于比较不同算法的性能好坏。同一段代码处理不同的输入数据所消耗的资源也可能不同，所以分析复杂度时，需要考虑三种情况，最差情况、最好情况和平均情况。

复杂度分析会考虑性能的各个方面，不过我们最关注的是两个部分，时间和空间。时间因素是指程序执行的耗时多少，空间因素是程序占用内存或磁盘存储的多少。因此，我们把复杂度进一步细分为时间复杂度和空间复杂度。

我们通常所说的时间复杂度是指渐进时间复杂度，表示程序运行时间随着问题复杂度增加而变化的规律。同理，空间复杂度是指渐进空间复杂度，表示程序所需要的存储空间随着问题复杂度增加而变化的规律。我们可以使用大 O 来表示这两者。

我这里不会讲太多的基本概念，而是通过数学的思维，总结一些比较通用的方法和规则，帮助你快速、准确地进行复杂度分析。

6 个通用法则

复杂度分析有时看上去很难，其实呢，我们只要通过一定的方法进行系统性的分析，就能得找正确的结论。我通过自身的一些经验，总结了 6 个法则，相信它们对你会很有帮助。

1. 四则运算法则

对于时间复杂度，代码的添加，意味着计算机操作的增加，也就是时间复杂度的增加。如果代码是平行增加的，就是加法。如果是循环、嵌套或者函数的嵌套，那么就是乘法。

比如二分查找的代码中，第一步是对长度为 n 的数组排序，第二步是在这个已排序的数组中进行查找。这两个部分是平行的，所以计算时间复杂度时可以使用加法。第一步的时间复杂度是 O(nlogn)，第二步的时间复杂度是 O(logn)，所以时间复杂度是 O(nlogn)+O(logn)。

你还记得在第 3 讲我讲的查字典的例子吗？

String[] dictionary = {"i", "am", "one", "of", "the", "authors", "in", "geekbang"};

Arrays.sort(dictionary);  // 时间复杂度为 O(nlogn)

String wordToFind = "i";

boolean found = Lesson3_3.search(dictionary, wordToFind); // 时间复杂度 O(logn)

if (found) {

  System.out.println(String.format(" 找到了单词 %s", wordToFind));

} else {

  System.out.println(String.format(" 未能找到单词 %s", wordToFind));

}

这里面的 Arrays.sort(dictionary)，我用了 Java 自带的排序函数，时间复杂度为 O(nlogn)，而 Lesson3_3.search 是我自己实现的二分查找，时间复杂度为 O(logn)。两者是并行的，并依次执行，因此总的时间复杂度是两者相加。

我们再来看另外一个例子。从 n 个元素中选出 3 个元素的可重复排列，使用 3 层循环的嵌套，或者是 3 层递归嵌套，这里时间复杂度计算使用乘法。由于 n*n*n=n³，时间复杂度是 O(n³)。对应加法和乘法，分别是减法和除法。如果去掉平行的代码，就减掉相应的时间复杂度。如果去掉嵌套内的循环或函数，就除去相应的时间复杂度。

对于空间复杂度，同样如此。需要注意的是，空间复杂度看的是对内存空间的使用，而不是计算的次数。如果语句中没有新开辟空间，那么无论是平行增加还是嵌套增加代码，都不会增加空间复杂度。

2. 主次分明法则

这个法则主要是运用了数量级和运算法则优先级的概念。在刚刚介绍的第一个法则中，我们会对代码不同部分所产生的复杂度进行相加或相乘。使用加法或减法时，你可能会遇到不同数量级的复杂度。这个时候，我们只需要看最高数量级的，而忽略掉常量、系数和较低数量级的复杂度。

在介绍第一个法则的时候，我说了先排序、后二分查找的总时间复杂度是 O(nlogn) + O(logn)。实际上，我贴出的代码中还有数组初始化、变量赋值、Console 输出等步骤，如果细究的话，时间复杂度应该是 O(nlogn) + O(logn) + O(3)，但是和 O(nlogn) 相比，常量和 O(logn) 这种数量级都是可以忽略的，所以最终简化为 O(nlogn)。

再举个例子，我们首先通过随机函数生成一个长度为 n 的数组，然后生成这个数组的全排列。通过循环，生成 n 个随机数的时间复杂度为 O(n)，而全排列的时间复杂度为 O(n!)，如果使用四则运算法则，总的时间复杂为 O(n)+O(n!)。

不过，由于 n! 的数量级远远大于 n，所以我们可以把总时间复杂度简化为 O(n!)。这对于空间复杂度同样适用。假设我们计算一个长度为 n 的向量和一个维度为 [n*n] 的矩阵之乘积，那么总的空间复杂度可以由 (O(n)+O(n²)) 简化为 O(n²)。

注意，这个法则对于乘法或除法并不适用，因为乘法或除法会改变参与运算的复杂度的数量级。

3. 齐头并进法则

这个法则主要是运用了多元变量的概念，其核心思想是复杂度可能受到多个因素的影响。在这种情况下，我们要同时考虑所有因素，并在复杂度公式中体现出来。

我在之前的文章中，介绍了使用动态规划解决的编辑距离问题。从解决方案的推导和代码可以看出，这个问题涉及两个因素：参与比较的第一个字符串的长度 n 和第二个字符串的长度 m。代码使用了两次嵌套循环，第一层循环的长度是 n，第二层循环的长度为 m，根据乘法法则，时间复杂度为 O(n*m)。而空间复杂度，很容易从推导结果的状态转移表得出，也是 O(n*m)。

4. 排列组合法则

排列组合的思想不仅出现在数学模型的设计中，同样也会出现在复杂度分析中，它经常会用在最好、最坏和平均复杂度分析中。

我们来看个简单的算法题。

给定两个不同的字符 a 和 b，以及一个长度为 n 的字符数组。字符数组里的字符都只出现过一次，而且一定存在一个 a 和一个 b，请输出 a 和 b 之间的所有字符，包括 a 和 b。假设我们的算法是按照数组下标从低到高的顺序依次扫描数组，那么时间复杂度是多少呢？这里时间复杂度是由被扫描的数组元素之数量决定的，但是要准确的求解并不容易。仔细思考一下，你会发现被扫描的元素之数量存在很多可能的值。

首先，考虑字母出现的顺序，第一个遇到的字母有 2 个选择，a 或者 b。而第二个字母只有 1 个选择，这就是 2 个元素的全排列。下面我们把两种情况分开来看.

第一种情况是 a 在 b 之前出现。接下来是 a 和 b 之间的距离，这会决定我们要扫描多少个字符。两者之间的距离最大为 n-1，最小为 1，所以最坏的时间复杂度为 O(n-1)，根据主次分明法则，简化为 O(n)，最好复杂度为 O(1)。

平均复杂度的计算稍微繁琐一些。如果距离为 n-1，只有 1 种可能，a 为数组中第一个字符，b 为数组中最后一个字符。如果距离为 n-2，那么 a 字符的位置有 2 种可能，b 在 a 位置确定的情况下只有 1 种可能，因此排列数是 2。以此类推，如果距离为 n-3，那么有 3 种可能，一直到距离 1，有 n-1 种可能。所以平均的扫描次数为 (1 *(n-1) + 2 *(n-2) + 3 (n -3) + … + (n-1) 1) / (1 + 2 + … + n)，最后时间复杂度简化为 O(n)。

第二种情况是 b 在 a 之前出现。这个分析过程和第一种情况类似。我们假设第一种和第二种情况出现的几率相等，那么综合两种情况，可以得出平均复杂度为 O(n)。

5. 一图千言法则

在之前的文章中，我提到了很多数学和算法思想都体现了树这种结构，通过画图它们内在的联系就一目了然了。同样，这些树结构也可以帮助我们分析某些算法的复杂度。

就以我们之前介绍的归并排序为例。这个算法分为数据的切分和归并两大阶段，每个阶段的数据划分不同，分组数量也不同，感觉上时间复杂度不太好计算。下面我们来看一个例子，帮助你理解。

假设等待排序的数组长为 n。首先，看数据切分阶段。数据切分的次数，就是切分阶段那棵树的非叶子结点之数量。这个切分阶段的树是一棵满二叉树，叶子结点是 n 个，那么非叶子结点的数量就是 n-1 个，所以切分的次数也就是 n-1 次。如果我们切分数据的时候，并不重新生成新的数据，而只是生成切分边界的下标，那么时间复杂度就是 O(n-1)。

在数据归并阶段，我们看二叉树的高度，为 log₂n，因此归并的次数为 log₂n。另外，无论数组被细分成多少个小的部分，每次归并都需要扫描整个长度为 n 的数组，因此归并阶段的时间复杂度为 nlog₂n。

两个阶段加起来的时间复杂度为 O(n-1)+nlog₂n，最终简化为 nlogn。是不是很直观？

我再放出我们之前讲二分查找所用的图，你可以结合这个例子进一步理解。

当然，除了图论，很多简单的图表也能帮助到我们的分析。

例如，在使用动态规划法的时候，我们经常要画出状态转移的表格。看到这类表格，我们可以很容易地得出该算法的时间复杂度和空间复杂度。以编辑距离为例，参看下面这个示例的图表，我们可以发现每个单元格都对应了 3 次计算，以及一个存储单元，而总共的单元格数量为 m*n，m 为第一个字符串的长度，n 为第二个字符串的长度。所以，我们很快就能得出这种算法的时间复杂度为 O(3m*n)，简写为 O(m*n)，空间复杂度为 O(m*n)。

6. 时空互换法则

在给定的计算量下，通常时间复杂度和空间复杂度呈现数学中的反比关系。这就说明，如果我们没法降低整体的计算量，那么也许可以通过增加空间复杂度来达到降低时间复杂度的目的，或者反之，通过增加时间复杂度来降低空间复杂度。

对于这个规则最直观的例子就是缓存系统。在没有缓存系统的时候，每次请求都要服务器来处理，因此时间复杂度比较高。如果使用了缓存系统，那么我们会消耗更多的内存空间，但是降低了请求相应的时间。

说到这，你也许会问，在使用广度优先策略优化聚合操作的时候，无论是时间还是空间复杂度，都大幅降低了啊？请注意，这里时空互换法则有个前提条件，就是计算量固定。而聚合操作的优化，是利用了广度优先的特点，大幅减少了整体的计算量，因此可以保证时间和空间复杂度都得到降低。

小结

时间复杂度和空间复杂度的概念，你一定不陌生。可是，在实际运用中，你可能就会发现复杂度分析并不是那么简单。这一节我通过个人的一些经验，从数学思维的角度出发，总结了几条常用的法则，对你会有所帮助。

这些总结可能还是过于抽象，下一讲中，我会通过几个案例分析，来讲讲如何使用这些法则。

思考题

请尝试使用本次介绍的规则，分析一下双向广度优先搜索的时间和空间复杂度。

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

17 | 时间和空间复杂度（下）：如何使用六个法则进行复杂度分析？

你好，我是黄申，今天我们接着聊复杂度分析的实战。

上一讲，我从数学的角度出发，结合自身经验给你总结了几个分析复杂度的法则。但是在实际工作中我们会碰到很多复杂的问题，这个时候，正确地运用这些法则并不是件容易的事。今天，我就结合几个案例，教你一步步使用这几个法则。

案例分析一：广度优先搜索

在有关图遍历的专栏中，我介绍了单向广度优先和双向广度优先搜索。当时我提到了通常情况下，双向广度优先搜索性能更好。那么，我们应该如何从理论上分析，谁的效率更高呢？

首先我们来看单向广度优先搜索。我们先快速回顾一下搜索的主要步骤。

第一步，判断边界条件，时间和空间复杂度都是 O(1)。

第二步，生成空的队列。常量级的 CPU 和内存操作，根据主次分明法则，时间和空间复杂度都是 O(1)。

第三步，把搜索的起始结点放入队列 queue 和已访问结点的哈希集合 visited，类似上一步，常量级操作，时间和空间复杂度都是 O(1)。

第四步，也是最核心的步骤，包括了 while 和 for 的两个循环嵌套。

我们首先看时间复杂度。根据四则运算法则，时间复杂度是两个循环的次数相乘。对于嵌套在内的 for 循环，这个次数很好理解，和每个结点的直接连接点有关。如果要计算平均复杂度，我们就取直接连接点的平均数量，假设它为 m。

现在的难题在于，第一个 while 循环次数是多少呢？我们考虑一下齐头并进法则，是否存在其他的因素来决定计算的次数？第一次的 while 循环，只有起始点一个。从起始点出发，会找到 m 个一度连接点，把它们放入队列，那么第二次 while 循环就是 m 次，依次类推，到第 l 次，那么总次数就是 (m+m*m+m*m*m+…+m^l)。这里我们假设被重复访问的结点不多，可以忽略不计。

在循环内部，所有操作都是常量级的，包括通过哈希集合判断是否找到终止结点。所以时间复杂度就是 O(m+m*m+m*m*m+…+m^l)，取最高数量级 m^l，最后可以简化成 O(m^l)，其中 l 是从起始点开始所走的边数。这就是除了 m 之外的第二个关键因素。

如果你觉得还是不太好理解，可以使用一图千言法则，我画了一张图来帮助你理解。

我们再来看这个步骤的空间复杂度。通过代码你应该可以看出来，只有 queue 和 visited 变量新增了数据，而图的结点本身没有发生改变。所以，考虑内存空间使用时，只需要考虑 queue 和 visited 的使用情况。两者都是在新发现一个结点时进行操作，因此新增的内存空间和被访问过的结点数成正比，同样为 O(m^l)。

最后，这四步是平行的，所以我们只需要把这几个时间复杂度相加就行了。很明显前三步都是常量，只有最后一步是决定性因素，因此时间和空间复杂度都是 O(m^l)。

我这里没有考虑图的生成，因为这步在单向搜索和双向搜索中是一样的，而且在实际项目中，我们也不会采用随机生成的方式。

接下来，我们来看看双向广度优先搜索。我刚才已经把单向的搜索过程分析得很透彻了，所以双向的复杂度你应该很容易就能得出来。但是，有两处关键点需要你注意。

第一个关键点是双向搜索所要走的边数。如果单向需要走 l 条边，那么双向是 l/2。因此时间和空间复杂度都会变为 O(2*m^(l/2)，简写为 O(m^(l/2))。这里 l/2 中的 2 不能省去，因为它是在指数上，改变了数量级。仅从这点来看，双向比单向的复杂度低。

第二个关键点是双向搜索过程中，判断是否找到通路的方式。单向搜索只需要判断一个点是否存在集合中，每次只有 O(1) 的复杂度。而双向搜索需要比较两个集合是否存在交集，复杂度肯定要高于 O(1)。最常规的实现方法是，循环遍历其中一个集合 A，看看 A 中的每个元素是不是出现在集合 B 中。假设两个集合中元素的数量都为 n，那么循环 n 次，那么时间复杂度就为 O(n)。基于这些，我们重新写一下双向广度优先搜索的时间复杂度。

假设我们分别从 aa 点和 bb 点出发。从 aa 点出发，找到 m 个一度连接点 a1a1，时间复杂度 O(m)，然后查看 bb 是否在这 m 个结点中，时间复杂度是 O(1)。然后从 bb 点出发，找到 m 个一度连接点 b1b1，时间复杂度 O(m)，然后查看 aa 和 a1a1 是不是在 bb 和 b1b1 中，时间复杂度是 O(m+1)，简写为 O(m)。从 aa 点继续推进到第二度的结点 a2a2，这个时候 aa、a1a1 和 a2a2 的并集的数量已经有 1+m+m^2，而 bb 和 b1b1 的并集数量只有 1+m，因此，针对 bb 和 b1b1 的集合进行循环更高效一些，时间复杂度是 O(m)。

逐步递推下去，我们可以得到下面这个式子：

O(m) + O(1) + O(m) + O(m) + O(m^2) + O(m) ... + O(m^(l/2)) + O(m^(l/2)) = O(1) + O(4m) + O(4m^2) + ... + O(3m^(l/2))

虽然这个式子简化后仍然为 O(m^(l/2))，但是我们可以通过这些推导的步骤了解整个算法运行的过程，以及对最终复杂度的影响。

最后比较单向广度搜索的复杂度 O(m^l) 和双向广度搜索的复杂度 O(m^(l/2))，双向的方法更优。

不过，上面讨论的内容，都是假设每个点的直接相连点数量都很均匀，都是 m 个。如果数据不是均匀的呢？你可以利用排列组合的思想，想想看各种不同的情况。我想到了三种情况。

第一种情况，我用 a=b 来表示，也就是前面讨论的，不管从 a 和 b 哪个点出发，每个点的直接连接数量都是相当的。这个时候的最好、最坏和平均复杂度非常接近。

第二种情况，我用 a<b 来表示，表示从 a 点出发，每个点的直接连接数量远远小于从 b 点出发的那些。例如，从 a 点出发，2 度之内所有的点都只有 1、2 个直接相连点，而从 b 点出发，2 度之内的大部分点都有 100 个以上的直接相连点。

第三种情况和第二种类似，我用 a>b 表示，表示从 b 点出发，每个点的直接连接数量远远小于从 a 点出发的那些。

对于第二和第三种情况，双向搜索的最坏、最好和平均的复杂度是多少？还会是双向的方法更优吗？仔细分析一下各种情况，你就能回答第 14 讲的思考题了。

案例分析二：全文搜索

刚才的分析中，我们已经使用了 6 个复杂度分析法则中的 5 个，不过还没涉及最后一个时空互换。这个原则有自己的特殊性，我们需要通过牺牲空间复杂度来降低时间复杂度，或者反其道行之。因此，在实际运用中，我们更多的是使用这个原则来指导和优化系统的设计。今天，我用搜索引擎的例子，来给你讲讲如何做到这一点。

搜索引擎你一定用的很多了，它最基本的也最重要的功能，就是根据你输入的关键词，查找指定的数据对象。这里，我以文本搜索为例。要查找某个关键词是不是出现在一篇文章里，最基本的处理方式有两种。

第一，把全文作为一个很长的字符串，把用户输入的关键词作为一个子串，那这个搜索问题就变成了子串匹配的问题。假设字符串平均长度为 n 个字符，关键词平均长度为 m 个字符，使用最简单的暴力法，就是把代表全文的字符串的每个字符，和关键词字符串的每个字符两两相比，那么时间复杂度就是 O(n*m)。

第二，对全文进行分词，把全文切分成一个个有意义的词，那么这个搜索问题就变成了把输入关键词和这些切分后的词进行匹配的问题。

拉丁文分词比较简单，基本上就是根据各种分隔符来切分。而中文分词涉及很多算法，不过这不是我们讨论的重点，我们假设无论何种语言、何种分词方法，时间复杂度都是 O(n)，其中 n 为文章的长度。而在词的集合中查找输入的关键词，时间复杂度是 O(m)，m 为词集合中元素的数量。我们也可以先对词的集合排序，时间复杂度是 O(m*logm)，然后使用二分查找，时间复杂度都只有 O(logm)。如果文章很少改变，那么全文的分词和词的排序，基本上都属于一次性的开销，对于关键词查询来说，每次的时间复杂度都只有 O(logm)。

无论使用上述哪种方法，看上去时间复杂都不算太高，是吧？可是，别忘了，我们可是在海量的文章中查找信息，还需要考虑文章数量这个因素。假设文章数量是 k，那么时间复杂度就变为 O(k*n)，或者 O(k*logm)，数量级一下子就增加了。

为了降低搜索引擎在查询时候的时间复杂度，我们要引入倒排索引（或逆向索引），这就是典型的牺牲空间来换取时间。如果你对倒排索引的概念不熟悉，我打个比方给你解释一下。

假设你是一个热爱读书的人，当你进入图书馆或书店的时候，怎样快速找到自己喜爱的书籍？没错，就是看书架上的标签。如果看到一个架子上标着“极客时间 - 数学专栏”，那么恭喜你，离程序员的数学书就不远了。而倒排索引做的就是“贴标签”的事情。

为了实现倒排索引，对于每篇文章我们都要先进行分词，然后将分好的词作为该篇的标签。让我们看看下面三篇样例文章和对应的分词，也就是标签。其中，分词之后，我也做了一些标准化的处理，例如全部转成小写、去掉时态等。

上面这个表格看上去并没有什么特别。好，体现“倒排”的时刻来了。我们转换一下，不再从文章的角度出发，而是从标签的角度出发来看问题。也就是说，从每个标签，我们能找到哪些文章？通过这样的思考，我们可以得到下面这张表。

你看看，有了这张表格，想知道查找某个关键词在哪些文章中出现，是不是很容易了呢？整个过程就像在哈希表中查找一样，时间复杂度只有 O(1) 了。当然，我们所要付出的成本就是倒排索引这张表。假设有 n 个不同的单词，而每个单词所对应的文章平均数为 m 的话，那么这种索引的空间复杂度就是 O(n*m)。好在 n 和 m 通常不会太大，对内存和磁盘的消耗都是可以接受的。

小结

这一讲，我分析了两个复杂度的案例，并在其中穿插了 6 个法则的运用和讲解。随着项目经验的累积，你会发现复杂度分析是个很有趣，也很有成就感的事情。更重要的是，它可以告诉我们哪些方法是可行的，哪些是不可行的，避免不必要的资源浪费。这里资源浪费可能是硬件资源的浪费，也有可能是开发资源的浪费。这些法则中的数学思想并不高深，却可以帮我们有效地分析复杂度，运筹帷幄于帐中，决胜于千里之外。

思考题

在你日常的工作中，有没有经历过性能分析相关的项目？如果有，你都使用了哪些方法来分析问题的症结？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

18 | 总结课：数据结构、编程语句和基础算法体现了哪些数学思想？

你好，我是黄申。

之前的 17 讲，我们从小处着眼，介绍了离散数学中最常用的一些知识点。我讲到了很多数据结构、编程语句和基础性算法。这些知识点看似是孤立的，但是内部其实有很多联系。今天这一节，我们就来总结一下前面讲过的内容，把之前讲过的内容串联起来。

数据结构

首先，我们来看一些基本的数据结构，你可别小看这些数据结构，它们其实就是一个个解决问题的“模型”。有了这些模型，你就能把一个个具体的问题抽象化，然后再来解决。

我们从最简单的数据结构数组开始说。自从你开始接触计算机编程，数组一定是你经常使用的数据结构。它的特点你应该很清楚。数组可以通过下标，直接定位到所需的数据，因此数组特别适合快速地随机访问。它常常和循环语句相结合，来实现迭代法，例如二分查找、斐波那契数列等等。

另外，我们将要在“线性代数篇”介绍的矩阵，也可以使用多维数组来表示。不过，数组只对稠密的数列更有效。如果数列非常稀疏，那么很多数组的元素就是无效值，浪费了存储空间。此外，数组中元素的插入和删除也比较麻烦，需要进行数据的批量移动。

那么对于稀疏的数列而言，什么样的数据结构更有效呢？答案是链表。链表中的结点存储了数据，而链表结点之间的相连关系，在 C 和 C++ 语言中是通过指针来实现的，而在 Java 语言中是通过对象引用来实现的。

链表的特点是不能通过下标来直接访问数据，而是必须按照存储的结构逐个读取。这样做的优势在于，不必事先规定数据的数量，也不再需要保存无效的值，表示稀疏的数列时可以更有效的利用存储空间，同时也利于数据的动态插入和删除。但是，相对于数组而言，链表无法支持快速地随机访问，进行读写操作时就更耗时。

和数组一样，链表也可以是多维的。对于非常稀疏的矩阵，也可以用多维链表的结构来表达。此外，在链表结构中，点和点之间的连接，分别体现了图论中的顶点和边。因此，我们还可以使用指针、对象引用等来表示图结构中的顶点和边。常见的图模型，例如多叉树、无向图和有向图等，都可以用指针或引用来实现。

在数组和链表这些基础的数据结构之上，我们可以构建更复杂的数据结构，比如哈希表、队列和栈等等。这些数据结构，提供了逻辑更复杂的模型，可以通过数组、链表或两者的结合来实现。

第 2 讲我提到了哈希的概念，而哈希表就可以通过数组和链表来构造。在很多编程语言中，哈希表的实现采用的是链地址哈希表。这种方法的主要思想是，先分配一个很大的数组空间，而数组中的每一个元素都是一个链表的头部。随后，我们就可以根据哈希函数算出的哈希值（也叫哈希的 key），找到数组的某个元素及对应的链表，然后把数据添加到这个链表中。

之所以要这样设计，是因为存在哈希冲突。对于不同的数据，哈希函数可能产生相同的哈希值，这就是哈希冲突。如果数组的每个元素都只能存放一个数据，那就无法解决冲突。如果每个元素对应了一个链表，那么当发生冲突的时候，我们就可以把多个数据添加到同一个链表中。可是，把多个数据存放在一个链表，就代表访问效率不高。所以，我们要尽量找到一个合理的哈希函数，减少冲突发生的机会，提升检索的效率。

在第 2 讲中，我还提到了使用求余相关的操作来实现哈希函数。我这里举个例子。你可以看我画的这幅图。

我们把对 100 求余作为哈希函数。因此数组的长度是 100。对于每一个数字，通过它对 100 求余，确定它在数组中的位置。如果多个数字的求余结果一样，就产生冲突，使用链表来解决。我们可以看到，表中位置 98 的链表没有冲突，而 0、1、2、3 和 99 位置的链表都有冲突。

说完了哈希，我们来看看栈这种数据结构。我在介绍树的深度优先搜索时讲到栈。它是先进后出的。在我们进行函数递归的时候，函数调用和返回的顺序，也是先进后出，所以，栈体现了递归的思想，可以实现基于递归的编程。实际上，计算机系统里的函数递归，在内部也是通过栈来实现的。虽然直接通过栈来实现递归不如函数递归调用那么直观，但是，由于栈可以避免过多的中间变量，它可以节省内存空间的使用。

我在介绍广度优先搜索策略时，谈到了队列。队列和栈最大的不同在于，它是一种先进先出的数据结构，先进入队列的元素会优先得到处理。队列模拟了日常生活中人们排队的现象，其思想已经延伸到很多大型的数据系统中，例如消息队列。

在消息系统中，生产者会源源不断地推送新的数据，而消费者会对这些消息进行处理。可是，有时消费者的处理速度会慢于生产者推送的速度，这会带来很多复杂的后续问题，因此我们可以通过队列实现消息的缓冲。新产生的数据会先进入队列，直到消费者处理它。经过这样的异步处理，消息的队列实现了生产者和消费者的松耦合，对消费者起到了保护作用，使它不容易被数据洪流冲垮。

比哈希表，队列和栈更为复杂的数据结构是基于图论中的各种模型，例如各种二叉树、多叉树、有向图和无向图等等。通常，这些模型表示了顶点和顶点之间的稀疏关系，所以它们常常是基于指针或者对象引用来实现的。我在讲前缀树、社交关系图和交通地图的案例中，都使用了这些模型。另外，树模型中的多叉树、特别是二叉树体现了递归的思想。之前的递归编程的案例中的图示也可以对应到多叉树的表示。

编程语句

在你刚刚开始学习编程的时候，肯定接触过条件语句、循环语句和函数调用这些基本的语句。

条件语句的一个关键元素是布尔表达式。它其实体现了逻辑代数中逻辑和集合的概念。逻辑代数，也被称为布尔代数，主要包括了逻辑表达式及其相关的逻辑运算，可以帮助我们消除自然语言所带来的歧义，并严格、准确地描述事物。在编程语言中，我们把逻辑表达式和控制语言结合起来，比如 Java 语言的 If 语句：

if(表达式) {函数体 1} else {函数体 2}：若表达式为真，执行函数体 1，否则执行函数体 2。

当然，逻辑代数在计算机中的应用，远不止条件语句。例如 SQL 语言中的 Select 语句和布尔检索模型。Select 是 SQL 查询语言中十分常用的语句。这个语句将根据指定的逻辑表达式，在一个数据库中进行查询并返回结果，而返回的结果就是满足条件的记录之集合。类似地，布尔检索模型利用逻辑表达式，确定哪些文档满足检索的条件并把它们作为结果返回。

这里顺便提一下，除了条件语句中的布尔表达式，逻辑代数还体现在编程中的其他地方。例如，SQL 语言中的 Join 操作。Join 有多种类型，每种类型其实都对应了一种集合的操作。

内连接（inner join）：假设被连接的两张数据表分别是左表和右表，那么内连接查询能将左表和右表中能关联起来的数据连接后返回，返回的结果就是两个表中所有相匹配的数据。如果认为左表是集合 A，右表是集合 B，那么从集合的角度来说，内连接产生的结果是 A、B 两个集合的交集。

外连接（outer join）：外连接可以保留左表，右表或全部表。根据这些行为的不同，可分为左外连接、右外连接和全连接。无论哪一种，都是对应于不同的集合操作。

循环语句可以让我们进行有规律性的重复性操作，直到满足某个条件。这和迭代法中反复修改某个值的操作非常一致。所以循环常用于迭代法的实现，例如二分或者牛顿法求解方程的根。在之前的迭代法讲解中，我经常使用循环来实现编码。另外，循环语句也会经常和布尔表达式相结合。嵌套的多层循环，常常用于比较多个元素的大小，或者计算多个元素之间的相似度等等，这也体现了排列组合的思想。

至于函数的调用，一个函数既可以调用自己，也可以调用其他不同的函数。如果不断地调用自己，这就体现了递归的思想。同时，函数的递归调用也可以体现排列组合的思想。

基础算法

在前面的专栏中，我介绍了一些常见算法及其对应的数学思想。而这些思想，在算法中的体现无处不在。

介绍分治思想的时候，我谈及了 MapReduce 的数据切分。在分布式系统中，除了数据切分，我们还要经常处理的问题是：如何确定服务请求被分配到哪台机器上？这就引出了负载均衡算法。

常见的包括轮询或者源地址哈希算法。轮询算法把请求按顺序轮流地分配到后端服务器上，它并不关心每台服务器当前的负载。如果我们对每个请求标上一个自动增加的 ID，我们可以认为轮询算法是对请求的 ID 进行求余操作（或者是求余的哈希函数），被除数就是可用服务器的数量，余数就是接受请求的服务器 ID。而源地址哈希进一步扩展了这个思想，扩展主要体现在：

它可以对请求的 IP 或其他唯一标识进行哈希，而不一定是请求的 ID；

哈希函数的变换操作不一定是求余。

不管是对何种数据进行哈希变换，也不管是何种哈希函数，只要能为每个请求确定哈希 key 之后，我们就能为它查找对应的服务器。

另外，在第 9 节中，我谈到了字符串的编辑距离，但是没有涉及字符串匹配的算法。知名的 RK（Rabin-Karp）匹配算法，在暴力匹配（Brute Force）基础之上，充分利用了迭代法和哈希，提升了算法的效率。

首先，RK 算法可以根据两个字符串哈希后的值。来判断它们是不是相同。如果哈希值不同，则两个字符串肯定不同，不用再比较；此外，RK 算法中的哈希设计非常巧妙，让相邻两个子字符串的哈希值产生了固定的联系，让我们可以通过前一个子串的哈希值，推导出后一个子串的哈希值，这样就能使用迭代法来计算每个子串的哈希值，大大减少了用于哈希函数的计算。

除了分治和动态规划，另一个常用的算法思想是回溯。我们可以使用回溯来解决的问题包括八皇后和 0/1 背包等等。回溯实际上体现了递归和排列的思想。不过，它对搜索空间做了一些优化，提前排除了不可能的情况，提升了算法整体的效率。当然，既然回溯体现了递归的思想，也可以把整个搜索状态表示成树，而对结果的搜索就是树的深度优先遍历。

在前两节讲述算法复杂度分析的时候，我已经从数学的角度出发，总结了几个常用的法则，包括四则运算、主次分明、齐头并进、排列组合、一图千言和时空互换。这些法则体现了数学中的运算优先级、数量级、多元变量、图论等思想。这些我们上两节刚刚讲过，我就不多说了，你可以参考之前的内容快速复习一下。

小结

这一讲，我对常用的数据结构、编程语句和算法中所体现的数学思想，做了一个大体的梳理。可以看到，不同的数据结构，都是在编程中运用数学思维的产物。每种数据结构都有自身的特点，有利于我们更方便地实现某种特定的数学模型。

从数据结构的角度来看，最基本的数组遍历体现了迭代的思想，而链表和树的结构可用于刻画图论中的模型。栈的先进后出、以及队列的先进先出，分别适用于图的深度优先和广度优先遍历。哈希表则充分利用了哈希函数的特点，大幅降低了查询的时间复杂度。

当然，仅仅使用数据结构来存储数据还不够，我们还需要操作这些数据。为了实现操作的流程，条件语句使用了布尔代数来控制编程逻辑，循环和函数嵌套使用迭代、递归和排列组合等思想来实现更精细的数学模型。

但是，有时候我们面对的问题太复杂了，除了数据结构和基本的编程语句，我们还需要发明一些算法。为了提升算法的效率，我们需要对其进行复杂度分析。通常，这些算法中的数学思想就更为明显，因为它们都是为了解决特定的问题，根据特定的数学模型而设计的。

有的时候，某个算法会体现多种数学思想，例如 RK 字符串匹配算法，同时使用了迭代法和哈希。此外，多种数学思维可能都是相通的。比如，递归的思想、排列的结果、二进制数的枚举都可以用树的结构来图示化，因此我们可以通过树来理解。

所以，在平时学习编程的时候，你可以多从数学的角度出发，思考其背后的数学模型。这样不仅有利于你对现有知识的融会贯通，还可以帮助你优化数据结构和算法。

思考题

在你日常的工作项目中，应该经常用到数据结构和算法，能不能列举一下，其中有哪些数学思想呢？

欢迎在留言区交作业，并写下你今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

19 | 概率和统计：编程为什么需要概率和统计？

你好，我是黄申。

通过第一个模块的学习，我想你对离散数学在编程领域中的应用，已经有了比较全面的认识。可以看出来，数据结构和基础算法体现的大多数都是离散数学的思想。这些思想更多的时候是给我们提供一种解决问题的思路，在具体指导我们解决问题的时候，我们还需要更多的数学知识。

比如说，在机器学习、数据挖掘等领域，概率统计就发挥着至关重要的作用。那关于概率统计，我们需要掌握哪些知识呢？这些知识究竟可以用在什么地方呢？第二模块的第一节，我们就来聊聊这些问题，让你对这一模块的学习做到心中有数。

概率和统计里有哪些需要掌握的概念？

在第一个模块中，我们认为所有事件都是一分为二的，要么必然发生，要么必然不发生。换句话说，事件的发生只有必然性，没有随机性。但是现实生活中，我们常常会碰到一些模棱两可的情况。

比如，你读到一则新闻，它报道了某个娱乐圈明星投资了一家互联网公司。那么，这则报道是属于娱乐新闻，还是科技新闻呢？你仔细读了读，觉得全篇大部分的内容都是讲述这家互联网企业的发展，而只有少部分的内容涉及了这位明星的私生活。你可能会说，这篇新闻 80% 的可能属于科技新闻，只有 20% 的可能属于娱乐新闻。这里面的数字表示了事件发生的可能性。概率（Probability）就是描述这种可能性的一个数值。

在概率的世界里，有很多概念。但是这几个非常基本的概念，你一定要知道。

我们用随机变量（Random Variable）来描述事件所有可能出现的状态，并使用概率分布（Probability Distribution）来描述每个状态出现的可能性。而随机变量又可以分为离散型随机变量（Discrete Random Variable）和连续型随机变量（Continuous Random Variable）。

这几个概念非常简单，但是其实也很抽象。我举个具体的例子，帮你加深印象。

假设我们使用一个随机变量 x 来表示新闻类型，如果在 100 篇新闻中，有 60 篇是娱乐新闻，有 20 篇是科技新闻，有 20 篇是体育新闻，那么你看到娱乐新闻的概率就是 60%，看到科技新闻的概率就是 20%，看到体育新闻的概率就是 20%。而这三组数据就可以构成变量 x 的概率分布P(x)。

在这个概率分布中，我们只有一个随机变量 x，现在我再添加另一个随机变量 y，表示新闻属于国际的还是国内的。这个时候，新的概率分布就需要由 x 和 y 这两个变量联合起来才能决定，我们把这种概率称为联合概率（Joint Probability）。

比如说，刚才那 100 篇新闻中有 30 篇是国际新闻，而这 30 篇中有 5 篇是科技新闻，那么国际科技新闻的联合概率就是 5/100=5%。不同的 x 和 y 取值的组合，就对应了不同的联合概率，我们用 P(x, y) 来表示。

对于离散型随机变量，通过联合概率 P(x, y) 在 y 上求和，就可以得到 P(x)，这个 P(x) 就是边缘概率（Marginal Probability）。对于连续型随机变量，我们可以通过联合概率 P(x, y) 在 y 上的积分，推导出边缘概率 P(x)。边缘概率有啥用呢？有的时候，情况看起来很复杂，而我们其实只需要研究单个事件对概率分布的影响就可以了。这个时候，边缘概率可以帮助我们去除那些我们不需要关心的事件，把联合概率转换为非联合概率，例如从 P(x, y) 得到 P(x)，从而忽略 y 事件。

对于多个随机变量，还有一个很重要的概念是条件概率。我估计很多人可能搞不清楚这个概念。我还是举例子跟你说。比如说，我们现在假设 100 篇中有 30 篇是国际新闻，而这 30 篇中有 5 篇是科技新闻，那在国际新闻中出现科技新闻的概率是多少呢？

这时候，我们就需要条件概率了。也就是某个事件受其他事件影响之后出现的概率，放到咱们的例子里，在国际新闻中出现科技新闻的概率就是 5/30=16.67%，在科技新闻中出现国际新闻的概率就是 5/20=25%。

说了这么多，不知道你有没有一种感觉，其实概率论研究的就是这些概率之间相互转化的关系，比如联合概率、条件概率和边缘概率。通过这些关系，概率论中产生了著名的贝叶斯定理（Bayes’ theorem）。加上变量的独立性，我们就可以构建朴素贝叶斯（Naive Bayes）分类算法，这个算法在机器学习中的应用非常广泛，我们后面也会有一节课专门来讲。

此外，基于概率发展而来的信息论，提出了很多重要的概率，例如信息熵（Entropy）/ 香农熵（Shannon Entropy）、信息增益（Information Gain）、基尼指数（Gini）等。这些概念都被运用到了决策树（Decision Tree）的算法中。

提到概率论，就一定要提统计学。这是因为，概率和统计其实是互逆的。怎么个互逆呢？概率论是对数据产生的过程进行建模，然后研究某种模型所产生的数据有什么特性。而统计学正好相反，它需要通过已知的数据，来推导产生这些数据的模型是怎样的。因此统计特别关注数据的各种分布、统计值及其对应的统计意义。

比如，现在有一大堆的新闻稿，我们想知道这里面有多少是娱乐新闻，有多少是科技新闻等等。我们可以先拿出一小部分采样数据，逐个来判断它属于哪个类型。比如说，分析了 10 篇之后，我们发现有 7 篇是科技新闻，2 篇是娱乐新闻，1 篇是体育新闻，那么从统计结果来看，三个类型的概率分别是 70%、20% 和 10%。然后，我们根据从这个小采样得来的结论，推测出科技新闻、娱乐新闻和体育新闻所占的比例。这就是统计学要做的事情。

在真实的世界里，我们通常只能观测到一些数据，而无法事先知道，是什么模型产生了这些数据，这时候就要依赖统计学。所以，海量数据的分析、实验和机器学习，都离不开统计学。

概率和统计可以帮我们做什么？

弄清楚这些基本概念之后，我们来看看概率和统计的知识能帮我们做点什么。

首先，我还是要提到复杂度分析。你可能会奇怪，之前讨论的复杂度分析好像没有涉及到概率啊。这是因为，在计算平均复杂度的时候，我们其实做了一个假设：所有情况出现的概率都是一样的。

我以最简单的查找算法为例。假设一个数组包含了 n 个元素，我们对其中的元素采取逐个扫描的方式，来查找其中的某个元素。如果这个元素一定会被找到，那么最好时间复杂度是 O(1)，最差时间复杂度是 O(n)，平均时间复杂度是 O((n+1)/2)。

等等，为什么平均复杂度是 O((n+1)/2) 呢？我们假设一共扫描了 n 次，第 1 次扫描了 1 个元素，第 2 次扫描了 2 个元素，一直到第 n 次扫描了 n 个元素，那么总共的扫描次数是 (1+2+…+n) = ((n+1)*n)/2，然后除以 n 次，得到每次扫描的元素数量平均值是 (n+1)/2，所以时间复杂度就是 O((n+1)/2)。

我把上述求和式改写成下面这样：

如果 1/n 是每种情况发生的概率，那么平均的扫描次数就是，不同情况下扫描次数按照概率进行的加权平均。问题来了，为什么这 n 种情况发生的概率都是 1/n 呢？这是因为之前我们做了一个默认的假设，我们每种情况发生的概率是一样的。但在实际生活中，概率很可能不是均匀分布的。

比如说，一个网站要对它的用户发放优惠券，那我们就需要先找到这些用户。我们用一个长度为 n 的数组代表某个网站的用户列表。我们假设第一个注册用户 ID 是 1，第二个注册用户的 ID 是 2，以此类推，最近刚刚注册的用户 ID 为 n。如果网站的发放策略是倾向于奖励新用户，那么被查找的用户 ID 有很大的概率会非常接近 n，因此平均复杂度就会非常接近 O(n)。相反，如果网站的发放策略是倾向于奖励老用户，那么搜索的用户 ID 有很大的概率是非常接近 1 的，因此平均复杂度会非常接近 O(1)。

你可以看到，现实中每种情况出现的可能性是不一样的，这也就意味者概率分布其是不均匀的。而不均匀的概率分布，最终会影响平均复杂度的加权平均计算。因此，要想获得更加准确的复杂度分析结果，我们必须要学习概率知识。

除此之外，概率和统计对于机器学习和大数据分析而言更为重要。对于机器学习而言，统计的运用是显而易见的。机器学习中的监督式学习，就是通过训练样本，估计出模型的参数，最后使用训练得出的模型，对新的数据进行预测。通过训练样本来估计模型，我们可以交给统计来完成。在机器学习的特征工程步骤中，我们可以使用统计的正态分布，标准化（standardization）不同取值范围的特征，让它们具有可比性。

此外，对机器学习算法进行效果评估时，AB 测试可以减少不同因素对评测结果的干扰。为了获得更可靠的结论，我们需要理解统计意义，并为每个 AB 测试计算相应的统计值。

最后，概率模型从理论上对某些机器学习算法提供了支持。朴素贝叶斯分类充分利用了贝叶斯定理，使用先验概率推导出后验概率，并通过变量之间相互独立的假设，把复杂的计算进行大幅的简化。简化之后，我们就可以把这个算法运用在海量文本的分类任务上。

而决策树使用了信息熵和信息增益，挑出最具有区分力的条件，构建决策树的结点和分支。这样构建出的树，不仅分类效率更高，而且更利于人脑的理解。谷歌的 PageRank 算法利用马尔科夫链的概率转移，有效地刻画了人们浏览互联网的行为，大幅提升了互联网搜索的体验。

学习这部分内容，需要做哪些准备？

听我说了这么多专栏的内容，你是不是有点担心，专栏的内容太深奥，不好理解。甚至在想，有没有必要做些准备？

学习不是件容易的事，因此进步的过程，从来不会轻松。努力需要你自己来，但是我也会从我的角度出发助力你的学习。我会争取讲清楚每个知识点背后的前因后果，以及不同知识点之间的联系，避免平铺直叙地罗列一堆理论和公式。但是，这部分内容，有公式是不可避免的，我尽量只保留那些最核心的公式。因此，即使你之前不太了解概率和统计，也没有关系。只有跟着我的节奏，搞懂每一节的重点，相信你很快就能领悟其中的精髓。

另外，我们无法脱离应用来讲知识，不然就本末倒置了。毕竟，我们学任何知识，都是为了用的。机器学习的知识纷繁复杂，涉及广泛，很多问题甚至是跨学科、跨领域的。不过，你不用担心，这里面会有太多看不懂的名词。我在讲解的时候，尽量给你抽象出最核心的部分，讲清楚来龙去脉，让你了解它整体的运作方式，不影响你对核心知识点的吸收。

当然，你可以适度地补一些概率知识，这样理解起来会更快。我在之前的加餐三中推荐了几本书，你可以找来看看，了解一些基本概念。另外，你可以准备一些实际工作和项目中的问题。例如，你之前参与的任务，哪些可以使用概率论来解决？碰到的难题有哪些？你是如何解决的？带着这些问题，再来看我的专栏，并且多在留言区写下你的疑问和收获，相信会有到事半功倍的效果。

小结

概率中的概念看起来很多，但是，其实最重要就是你耳熟能详的这几个：随机变量、概率分布、联合概率、条件概率和边缘概率。它们是整个概率的基础，我后面会详细来讲。

通过这些概念之间的相互推导，我们可以得到贝叶斯定理，这是朴素贝叶斯等系列算法的核心。而在概率基础之上发展而来的信息论，定义了信息熵、信息增益和基尼指数等，构成了决策树等系列算法的核心。

概率研究的是模型如何产生数据，统计研究的是如何通过数据来推导其背后的模型。所以说，概率和统计其实是互逆的。

概率和统计的运用非常多，我这里主要讲了三个方面。第一，概率可以帮助我们进行更精准的复杂度分析；第二，概率统计更多的是用在机器学习和大数据分析中；第三，概率统计还可以用在各种机器学习的算法中。这些内容，在之后的章节我会进行非常详细的讲解。

思考题

之前你对概率统计的认识是什么样的呢？对这块内容，你觉得最难的是什么？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

20 | 概率基础（上）：一篇文章帮你理解随机变量、概率分布和期望值

你好，我是黄申。

相信你对变量这个概念并不陌生，数学方程式和编程代码里经常会用到变量。那什么是变量呢？我们在概率中常说的随机变量（ random variable）和普通的变量（variable）又有什么不同呢？

这些问题其实很简单，我一说你就明白了。

在没有发生运算之前，普通变量的值并不会发生变化，也就是说，它可以取不同的值，但是一旦取值确定之后，它总会是一个固定的值，除非有新的运算操作。而随机变量的值并不固定，比如说，某个随机变量可能有 10% 的概率等于 10，有 20% 的概率等于 5，有 30% 的概率等于 28 等等。

我们上节说了，随机变量根据其取值是否连续，可分为离散型随机变量和连续型随机变量。举几个例子，抛硬币出现正反面的次数以及每周下雨的天数，都是离散的值，所以对应的随机变量为离散型。而汽车每小时行驶的速度和银行排队的时间，都是连续的值，对应的随机变量为连续型。换句话，从计算的角度来说，我们可以直接求和得出的，就是“离散的”，需要用积分计算的，就是“连续的”。

而随机变量的取值对应了随机现象的一种结果。正是结果的不确定性，才导致了随机变量取值的不确定性，于是我们就引入了概率。我们可以说，每种值是以一定的概率出现的。

概率分布

随机变量的每种取值的出现都遵从一定的可能性，把这个可能性用具体的数值表示出来就是概率。如果将随机变量所有可能出现的值，及其对应的概率都罗列出来，我们就能获得这个变量的概率分布。

我们拿最简单的抛硬币事件来看。从理论上说来，出现正面和反面的概率都是 50%（我们假设不存在硬币站立的情况）。

我们可以通过一小段 Python 代码，做个模拟实验，验证一下这个分布。

import random

import pandas as pd

import numpy as np

import matplotlib.pyplot as plt

def flip_coin(times):

  data_array = np.empty(times)

  weights_array = np.empty(times)

  weights_array.fill(1 / times)

  for i in range(0, times):                       # 抛 times 次的硬币

    data_array[i] = random.randint(0, 1)        # 假设 0 表示正面，1 表示反面

  data_frame = pd.DataFrame(data_array)

  data_frame.plot(kind = 'hist', legend = False)  # 获取正反面统计次数的直方图

  data_frame.plot(kind = 'hist', legend = False, weights = weights_array).set_ylabel("Probability")  # 获取正反面统计概率的直方图

  plt.show()

flip_coin(10)

该代码随机生成若干次的 0 或 1（0 表示硬币正面朝上，1 表示硬币反面朝上）。下表是我生成 10 次的结果，其中正面 4 次，反面 6 次。

对应的正反面次数和概率直方图如下，而概率的直方图就表示了其概率分布。

通过修改 flip_coin 函数中的数字，我们可以修改抛硬币的次数。我又尝试“抛了”100 次，得到的结果是正面 47 次，反面 53 次。

接下来是抛 10000 次的结果，正面是 4982 次，反面是 5018 次。

你可能已经发现了，根据计算机模拟的结果所统计的概率，并不是精确的正反面各 50%。如果你运行同样的代码，也会发现类似的情况。这是因为理论上的概率，是基于无限次的实验。而我们这里实验的次数是有限的，是一种统计采样。

从 10 次、100 次到 10000 次，我们能看到，概率会变得越来越稳定，越来越趋近于正反各 50% 的分布。也就是说，统计的采样次数越多，越趋近于我们理论上的情况。因此，从这个统计实验我们可以看出，概率分布描述的其实就是随机变量的概率规律。

抛硬币正面次数、每周下雨天数这种离散型随机变量，对应的概率分布是很好理解的，但是对于连续型的随机变量，如何理解它们的概率分布呢？

如果我们把连续的值离散化，你会发现这个问题其实不难理解。就拿汽车每小时行驶的公里数来说吧。现实生活中我们通过汽车的仪表盘所读取速度，都是整数值，例如每小时 60 公里。也许比较高档的车会显示数字化的速度，带有小数位，但实际上汽车最精确的速度是一个无限位数的小数，是从 0 到最高公里数的一个任意数值。所以仪表盘所显示的数字，是将实际速度离散化处理之后的数字。除了仪表盘上的速度，汽车行驶在时间维度上也是连续的。类似地，我们还需要对时间进行离散化，比如每分钟查看仪表盘一次并读取速度值。

好的，理解了这些之后，我同样使用代码来随机一些行驶速度的数据。第一次模拟，假设我们手头上有一辆老爷车，它的仪表盘最小刻度是 5，也就是说，它只能显示 55、60、65 这种公里数。然后我们每 1 分钟采样一次（读一次仪表盘），那么 1 小时内我们将生成 60 个数据。示例代码如下：

def check_speed(time_gap, speed_gap, total_time, min_speed, max_speed):

  times = (int)(total_time / time_gap)   # 获取读取仪表盘的次数

  data_array = np.empty(times)

  weights_array = np.empty(times)

  weights_array.fill(1 / times)

  for i in range(0, times):

    if (speed_gap < 1):

      data_array[i] = random.random() * max_speed  # 随机生成一个最高速和最低速之间的速度

    else:

      data_array[i] = random.randint(0, max_speed / speed_gap) * speed_gap  # 随机生成一个最高速和最低速之间的速度，先除以 speed_gap 然后乘以 speed_gap 进行离散化

  data_frame = pd.DataFrame(data_array)

  bin_range = np.arange(0, 200, speed_gap)

  data_frame.plot(kind = 'hist', bins = bin_range, legend = False)  # 获取时速统计次数的直方图

  data_frame.plot(kind = 'hist', bins = bin_range, legend = False, weights = weights_array, ).set_ylabel("Probability")  # 获取时速统计概率的直方图

  plt.show()

check_speed(1, 5, 60, 0, 200)

对生成的 60 个数据，我们统计其出现在每个速度区间的频次以及相应的概率。

我们将频次和概率以直方图的形式来展示。

第二次模拟，假设我们把车升级到当今的主流车，仪表盘的最小刻度已经到 1 了，然后时间维度上，我们细分到 0.1 分钟，那么 1 小时我们将生成 600 个数据。我们还可以进行第三次、第四次、甚至是无穷次的模拟，每次模拟的时候我们都将行驶速度的精度进一步提升、将时间间隔进一步缩小，让两者都趋近于 0，那么我们的模拟就从离散逐步趋近于连续的值了。

随机变量的概率分布由离散型的直方图变为了连续型的曲线图。通过下面的图，你可以看到这整个演变的过程。

当速度间隔和时间间隔（精度）逐步缩小的时候，直方图的分组（bin）就越小，所以你会看到 x 轴上的数据越浓密，y 轴上的数据越平滑。当间隔或精度无穷小并趋近于 0 的时候，y 轴的数据就会随着 x 轴连续变化而变化。

不过，当时间间隔小于数秒时，我们需要考虑随机产生的数据是否具备真实性，毕竟现实中汽车的速度不可能在数秒中从 0 到 200 公里，因此临近两次的采样数据不能相差太大。

上面我通过两个模拟实验，分别展示了离散型和连续型概率的分布。其实，人们在实际运用中，已经总结出了一些概率分布，我这里挑几个最常见的给你讲解。

首先我们来看看离散分布模型。常用的离散分布有伯努利分布、分类分布、二项分布、泊松分布等等，这里我重点给你介绍两个。

第一个是伯努利分布（Bernoulli Distribution），这是单个随机变量的分布，而且这个变量的取值只有两个，0 或 1。伯努利分布通过参数λ来控制这个变量为 1 的概率，我把具体的公式列出来，并画了张图便于你理解。

或者写作：

其中 x 只能为 0 或 1。

从这个图也可以看出，之前抛硬币的概率分布就属于伯努利分布。

另一个是分类分布（Categorical Distribution），也叫 Multinoulli 分布。它描述了一个具有 k 个不同状态的单个随机变量。这里的 k，是有限的数值，如果 k 为 2 的时候，那么分类分布就变成了伯努利分布。我把这个分布的公式和图解都列了出来。

离散型随机变量的状态数量是有限的，所以可以通过伯努利和分类分布来描述。可是对于连续型随机变量来说，状态是无穷多的，这时我们就需要连续分布模型。比较经典的连续分布有正态分布、均匀分布、指数分布、拉普拉斯分布等等。如果你只需要掌握一个的话，那肯定是正态分布。

这个分布可以近似表示日常生活中很多数据的分布，我们经常使用它进行机器学习的特征工程，对原始数据实施标准化，使得不同范围的数据具有可比性。所以，如果想要学习机器学习，一定要掌握正态分布。

正态分布（Normal Distribution），也叫高斯分布（Gaussian Distribution）。我把这个分布的公式列在这里：

在这个公式中有两个参数，μ表示均值，σ表示方差。看这个公式不太直观，我们来看一看对应的分布图。

从这个图可以看出，越靠近中心点μ，出现的概率越高，而随着渐渐远离μ，出现的概率先是加速下降，然后减速下降，直到趋近于 0。蓝色区域上的数字，表示了这个区域的面积，也就是数据取值在这个范围内的概率。例如，数据取值在 [-1σ, μ] 之间的概率为 34.1%。

现实中，很多数据分布都是近似于正态分布的。例如人类的身高体重。拿身高来说，大部分人都是接近平均值身高，偏离平均身高越远，相对应的人数越少。这也是为什么正态分布很常用的原因。

正态分布可以扩展到多元正态分布或多维正态分布（Multivariate Normal Distribution），不过最实用的还是一元标准正态分布，这种分布的μ为 0，σ为 1。在专栏后面的内容里，我会详细介绍如何使用这种分布，进行特征值的标准化。

期望值

理解了概率分布，你还需要了解期望值。为什么要了解期望值呢？

期望值，也叫数学期望，是每次随机结果的出现概率乘以其结果的总和。如果我们把每种结果的概率看作权重，那么期望值就是所有结果的加权平均值。它在我们的生活中十分常见，例如计算多个数值的平均值，其实就是求期望值，只不过我们假设每个数值出现的概率是相同的。

上一节，我提到如何使用概率来解决复杂度分析，通过概率的加权平均来获得平均时间复杂度，就是时间复杂度的期望值。当然，这个概念能帮助你解决的实际问题远不止这些。

在我看来，一个问题只要满足两个要素，我们就可以考虑使用期望值：

第一个要素，在这个问题中可能出现不同的情况，而且各种情况的出现满足了一定的概率分布；

第二个要素，每种情况都对应一个数值，这个数值代表了具体的应用含义。

如果你觉得这个还是过于抽象，我再回到本节的案例给你讲解。我先给你提个问题：给定了行驶速度的概率分布，如何计算汽车在 1 小时内每分钟行驶的平均速度？我们还从比较容易理解的离散型随机变量开始。

没错，这个问题的答案就是使用 1 小时所行驶的总距离除以 60 分钟。以之前的每分钟读取仪表盘、仪表盘最小刻度是 5 为例。概率分布如下图。

1 小时行驶的总距离为每种速度乘以用该速度行驶的时间的乘积之总和。

1 小时内每分钟平均的行驶速度为总距离除以 60 分钟。

将上述式子变化一下，我们可以得到：

你会发现，每分钟的平均值，就是每种速度的加权平均，而每种速度的权重就是其在概率分布中出现的概率。汽车可能按照不同的速度行驶，每种速度都有一个出现的概率，就是我前面提到的第一个要素。而每种速度所对应的每分钟多少公里这个数值，就是第二个要素。结合这两个要素，计算得到的平均值，也是汽车每分钟行驶速度的期望值。

那么，对于连续型的随机变量，这种期望值应该如何计算呢？我们需要使用下面的积分公式：

总结

通过这讲的内容，你对概率的基本知识应该有所了解。本讲中，我通过抛硬币和汽车驾驶的例子，讲述了概率论中一些最基本，也是最重要的概念，包括随机现象、随机变量、概率分布和期望值。

离散型的随机变量在计算机编程中的应用更为广泛。它可以和排列组合的思想结合起来，通过不同排列或组合的数量，计算每种情况出现的概率。如果把这种概率和每种情况下的复杂度数值结合起来，就可以计算复杂度的期望值。

另外，离散型概率也可以运用在机器学习的分类算法中。例如，对于文本进行分类时，我们可以通过离散型随机变量，表示每个分类或者每个单词出现的概率。当然，仅靠今天的内容，还不足以让我们打造一个分类算法。我们还需要了解联合概率、条件概率、贝叶斯定理等重要的概念。下一讲，我会详细解释它们。

思考题

每天我们的朋友圈都会浮现很多新的“动态”。假设我们按照字数，把这些动态分为以下几类：10 个字以内，10～30 个字，30～50 字，50～100 字，100 字以上。

尝试统计一下你朋友圈某个时间段的动态，看看每个类有多少条，转换成概率分布应该如何表示？你的分布和今天介绍的哪个概率分布最接近？请注意，统计字数时只需要看你朋友自己所写的内容，无需考虑转发的文章。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

21 | 概率基础（下）：联合概率、条件概率和贝叶斯法则，这些概率公式究竟能做什么？

你好，我是黄申。

上一节我介绍了随机现象、随机变量以及概率分布这些比较简单的概念。学习这些概念是为了做什么呢？其实就是为了更精确地描述我们生活中的现象，用数学的视角看世界，以此解决其中的问题。

但是实际生活中的现象并非都会像“投硬币”那样简单。有很多影响因素都会影响我们去描述这些现象。比如，看似很简单的“投硬币”，我们其实只是考虑最主要的情况，粗暴地把硬币出现的情况一分为二。比如说，不同类型的硬币是否会影响正反面的概率分布呢？站立的情况如何考虑呢？再比如说，在汽车速度的例子中，经过的交通路线，不同的路线是否会影响速度的概率分布呢？

一旦影响因素变多了，我们需要考虑的问题就多了。想要解决刚才那几个问题，更精确地描述这些现象，我们就需要理解几个新的概念，联合概率、条件概率以及贝叶斯法则。从数学的角度来说，这些概念能描述现实世界中更为复杂的现象，建立更精细的数学模型。比如，我们后面要讲的朴素贝叶斯算法就是建立在联合概率、条件概率和边缘概率之上的。所以，这一节的内容也非常重要，你一定要认真学习并且掌握。

联合概率、条件概率和边缘概率

最近，我一直在操心儿子的教育问题，所以一直在研究他班级的成绩单。为了弄清我儿子在班级上的成绩排名，我向老师要了张全班成绩的分布表。

这张表中有两个随机变量，一个是学生的性别，一个是分数区间。我们很容易就可以得出，这个班中男生的概率是 P(男生)=10/20=50%，90 分及以上的学生的概率是 P(90-100)=4/20=20%。那全班考了 90 分以上的男生的概率是多少呢？我们只要找到 90 分以上的男生人数，用这个人数除以全班总人数就行了，也就是 P(男生, 90-100)=2/20=10%。

你有没有发现，“90 分以上的男生”这个概率和之前单独求男生的概率或 90 分以上的概率不一样。之前只有一个决定因素，现在这个概率由性别和分数这两个随机变量同时决定。这种由多个随机变量决定的概率我们就叫联合概率，它的概率分布就是联合概率分布。随机变量 x 和 y 的联合概率使用 P(x, y) 表示。我算出了这个例子里所有的联合概率分布。

这里的例子只有两个随机变量，但是我们可以很容易扩展到更多的随机变量，比如再增加一个学科的变量。那么，我们就可以观测这样的数据：“班级上女生的数学考了 90 分及以上的概率是多少？”，其中女生是关于性别变量，数学是关于学科变量，而 90 分及以上是关于分数变量。

那么联合概率和单个随机变量的概率之间有什么关联呢？对于离散型随机变量，我们可以通过通过联合概率 P(x, y) 在 y 上求和，就可以得到 P(x)。对于连续型随机变量，我们可以通过联合概率 P(x, y) 在 y 上的积分，推导出概率 P(x)。这个时候，我们称 P(x) 为边缘概率。

除了边缘概率的推导，多个变量的联合概率和单个变量的概率之间还存在一个有趣的关系。在解释这个关系之前，让我先来介绍条件概率。

条件概率也是由多个随机变量决定，但是和联合概率不同的是，它计算了给定某个（或多个）随机变量的情况下，另一个（或多个）随机变量出现的概率，其概率分布叫做条件概率分布。给定随机变量 x，随机变量 y 的条件概率使用 P(y | x) 表示。

回到成绩分布的案例。我能理解在不同的阶段，男生和女生的成绩可能无法直接相比。所以我更关心的是，自己儿子和其他男生相比是否落后了。那么我的脑子里就产生了这样一个问题：“在男生中，考 90 分及以上的概率是多少？”。

仔细看，这个问题和前面几个有所不同，我只关心男生这个群体，所以解答应该是找到考了 90 分以上的男生之人数，然后用这个人数除以男生总人数（注意，不再是全部总人数）。根据上述表格的数据来计算，P(90-100|男生)= 2/10=20%。

解释清楚了条件概率，我就可以列出概率、条件概率和联合概率之间的“三角”关系了。简单的说，联合概率是条件概率和概率的乘积，采用通用的公式来表达就是：

同样的道理，我们也可以得到：

我们仍然可以使用成绩的案例，来验证这个公式。为了更清晰地表述这个问题，我们使用如下的符号：

|男, 90-100|表示考了 90 分以上的男生人数；

|男|表示男生人数；

|全班|表示全班人数。

男生中考了 90 分及以上的概率为 P(90-100 | 男生) = |男生, 90-100| / |男生|，全班中男生的概率为 P(男) = |男生| / |全班|。如果我们将 p(90-100 | 男生) 乘以 P(男) 会得到什么结果呢？

(|男, 90-100| / |男生|) * (|男生| / |全班|) = |男, 90-100| / |全班|

咦，这不就是全班中男生考了 90 分及以上的联合概率吗？其实，概率、条件概率和联合概率之间的这种“三角”关系，也是著名的贝叶斯定理的核心，下面我来详细解释什么是贝叶斯定理，以及它可以运用在什么场景之中。

贝叶斯定理

我们假设有这样一个场景，我想知道男生考了 90～100 分的概率有多少，来评估一下儿子在男生中算什么水平。可是老师出于隐私保护，并没有把全班数据的分布告诉我，她说道“我可以告诉你全班考 90～100 分的概率，以及 90～100 分中男生的概率，但是不能告诉你其他信息了”。这个时候，贝叶斯定理就可以帮上忙啦。

刚刚我们提到：

所以就有：

这就是非常经典的贝叶斯法则。为什么说经典呢？是因为它有很多应用的场景，比如朴素贝叶斯，你可以多多熟悉一下这个公式。在这个公式中，还包含了先验概率（Prior Probability）、似然函数（Likelihood）、边缘概率（Marginal Probability）和后验概率（Posterior Probability）的概念。

在这里面，我们把 P(x) 称为先验概率。之所以称为“先验”，是因为它是从数据资料统计得到的，不需要经过贝叶斯定理的推算。

P(y | x) 是给定 x 之后 y 出现的条件概率。在统计学中，我们也把 P(y | x) 写作似然函数 L(x | y)。在数学里，似然函数和概率是有区别的。概率是指已经知道模型的参数来预测结果，而似然函数是根据观测到的结果数据，来预估模型的参数。不过，当 y 值给定的时候，两者在数值上是相等的，在应用中我们可以不用细究。

另外，我们没有必要事先知道 P(y)。P(y) 可以通过联合概率 P(x, y) 计算边缘概率得来，而联合概率 P(x, y) 可以由 P(y|x) * P(x) 推出。针对离散型和连续型的边缘概率推导分别如下：

而 P(x|y) 是根据贝叶斯定理，通过先验概率 P(x)、似然函数 P(y | x) 和边缘概率 P(y) 推算而来，因此我们把它称作后验概率。

回到刚刚的案例，我可以通过这样的式子来计算男生考 90-100 分的概率：

P(90-100 | 男生) = (P(男生 | 90-100) * P(90 -100)) / P(男生)

我只需要数数看，班上男生有多少、总人数多少，就能算出 P(男生)，或者也可以使用 P(男生 | 90-100) 和 P(90-100) 算出边缘概率 P(男生)。在加上之前，老师告诉我的 P(男生 | 90-100) 和 P(90 -100)，就能推算出 P(90-100 | 男生) 了。这个例子就是通过先验概率，推导出后验概率，这就是贝叶斯定理神奇的地方，也是它最主要的应用场景。

随机变量之间的独立性

说到多个随机变量的联合和条件概率，你可能会产生一个问题：这些随机变量是否会相互影响呢？比如，性别和分数之间有怎样的关系？性别是否会影响分数的概率分布？在之前的成绩分布表中，我们可以得到：

p(90-100 | 男生) = 20%

p(90-100 | 女生) = 20%

p(90-100) = 20%

所以，p(90-100 | 男生) = p(90-100 | 女生) = p(90-100)，也就是全班中考 90 分及以上的概率、男生中考 90 分及以上的概率、以及女生中考 90 分及以上的概率，这三者都是一样。以此类推到其他的分数区间，同样如此，那么，从这个数据上得出的结论是性别对考分的区间没有影响。反之，我们也可以看到 p(男生 | 90-100) = p(男生 | 80-90) = p(男生 | 70-80) = … = p(男生) = 50%，也就是说考分区间对性别没有影响。这种情况下我们就说性别和分数这两个随机变量是相互独立的。

相互独立会产生一些有趣的现象，刚刚我们提到：

另外，将 p(x | y) = p(x) 带入贝叶斯公式，就可以得出：

变量之间的独立性，可以帮我们简化计算。举个例子，假设有 6 个随机变量，而每个变量有 10 种可能的取值，那么计算它们的联合概率 p(x1x1, x2x2, x3x3, x4x4, x5x5, x6x6)，在实际中是非常困难的一件事情。

根据排列，可能的联合取值，会达到 10 的 6 次方，也就是 100 万这么多。那么使用实际的数据进行统计时，我们也至少需要这个数量级的样本，否则的话很多联合概率分布的值就是 0，产生了数据稀疏的问题。但是，如果假设这些随机变量都是相互独立的，那么我们就可以将联合概率 p(x1,x1, x2x2, x3x3, x4x4, x5x5, x6x6) 转换为 p(x1x1) * p(x2x2) * p(x3x3) * p(x4x4) * p(x5x5) * p(x6x6)。如此一来，我们只需要计算 p(x1x1) 到 p(x6x6 ) 就行了。

不过，班主任刚刚打电话给我，说之前的数据表格有点笔误，实际的分布应该是这样的：

你再推算一下，就会发现 p(分数 | 性别) = p(分数) 和 p(性别 | 分数) = p(性别) 并不成立，所以两者不再是相互独立的。所以下面这个式子就不再成立了，这点你在计算的时候需要注意。

在实际项目中，我们会假设多个随机变量是相互独立的，并基于这个假设大幅简化计算，降低对数据统计量的要求。虽然这个假设通常是不成立的，但是仍然可以帮助我们得到近似的解。相比较实现的可行性和求解的精确度，可行性更为重要。在讲解朴素贝叶斯方法中，我会充分利用这一点，从有限的训练样本中构建分类器。

总结

这一讲我继续讨论了概率分布相关的内容，不过这次的重点是多个随机变量相关的联合概率、条件概率、边缘概率。这里概念有点多，但是都很重要，因为这三者之间的推算关系，直接构成了贝叶斯定理的核心，所以你要花点时间理解并记住它们。

而贝叶斯定理定义了先验概率、后验概率和似然函数，后验概率和似然函数及先验概率的乘积成正比关系。此外，通过多个变量之间的独立性，我们可以简化联合概率的计算问题。贝叶斯定理和变量之间独立性的假设，对后面理解朴素贝叶斯算法很有帮助。

如果有一定数量的标注数据，那么通过统计的方法，我们可以很方便地得到先验概率和似然函数，然后推算出后验概率，最后依据后验概率来做预测。这整个过程符合监督式机器学习的模型训练和新数据预测这两个阶段，因此朴素贝叶斯算法被广泛运用在机器学习的分类问题中。下一节中，我们详细讨论这个算法。

思考题

大年三十晚上，爷爷奶奶发红包。爷爷准备的红包是 4 个 50 元的，6 个 100 元的。奶奶准备的红包是 8 个 50 元的，4 个 100 元的。全家人随机抽，你运气很好，拿到一个 100 元的红包。请问这个红包来自爷爷的概率有多少？来自奶奶的概率有多少？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

22 | 朴素贝叶斯：如何让计算机学会自动分类？

你好，我是黄申。今天我们来聊聊朴素贝叶斯。

在开始正式的内容之前，我想问你一个问题，你是如何区分苹果、甜橙和西瓜的？你可能要说了，这个问题还用得着讲吗？是不是你们博士都喜欢将简单的问题复杂化？还真不是，如果你将计算机想象成一个两三岁的孩子，你会怎么教一个孩子区分这些水果呢？

比如我曾经就和一个小朋友有过这样一段对话：

小朋友：黄叔叔，你和我讲讲，什么样的水果才是苹果呀？

我：圆形的、绿色的水果。

小朋友：那西瓜也是圆形的、绿色的呀？

我：嗯……苹果也有可能是黄色或红色的，但西瓜不是。

小朋友：那甜橙也是圆形的、黄色的呀？

我：好吧，你看到的大部分情况下的甜橙都是黄色的，而苹果只有很少情况（少数品种）是黄色的。而且你还可以尝尝，它们的味道也是不同的。

哈哈，你是不是觉得想要描述清楚，并没有想象中的那么容易？但是，在这个对话中，有两点我觉得你需要关注一下：

我使用了“可能”“大部分情况”“很少情况”等等这种词语，这些词包含了概率的概念；

我使用了多个条件来判断一个水果属于哪个类别。

基于此，我接下来就要聊聊，我们是如何通过数学的思想和方法，系统性地解决这个问题的。这其中，朴素贝叶斯（Naive Bayesian）就提供了一个切实可行的方案。不过，在深入了解它之前，我们还需要做点准备工作。

如何将原始信息转化为计算机能看懂的数据？

事实上，计算机并不像两三岁的小孩那样，可以看到水果的颜色、形状和纹理，或者能尝到水果的味道。我们需要将水果的特征转化为计算机所能理解的数据。最常用的方式就是提取现实世界中的对象之属性，并将这些转化为数字。

以水果为例，你会提取它们的哪些属性呢？我会考虑这些，比如：形状、外皮颜色、斑马纹理、重量、握感、口感。我手边刚好有一个苹果、一个甜橙和一个西瓜，我把它们的属性分别统计了一下，你可以看看。

然后，我们需要这些属性转化为计算机能够理解的东西——数字，也就是说，我给每种属性都定义了具体的数值，用来代表它们的具体属性。

比较细心的话，你可能已经发现了，我偷偷地把重量由连续值转化成了离散值，这是因为朴素贝叶斯处理的都是离散值。

好了，仅仅 3 个水果还不足以构成朴素贝叶斯分类所需的训练样本。为了保证训练的质量，我们可以继续扩展到 10 个水果。

朴素贝叶斯的核心思想

我们现在已经拿到了这 10 个水果的数据，那如果现在我手上有一个新的水果，它也有一定的形状、颜色、口感等等，你怎么判断它是哪种水果呢？

之前的文章我们讲过先验概率、后验概率、条件概率和贝叶斯法则，它们是朴素贝叶斯分类的核心组成部分。通过贝叶斯法则，我们可以根据先验概率和条件概率，推导出后验概率。首先让我们快速回想一下贝叶斯公式。

上一节，我已经详细解释了这个公式的推导和每一部分的含义，这里再强调一下贝叶斯定理的核心思想：用先验概率和条件概率估计后验概率。

那具体到这里的分类问题，我们该如何运用这个公式呢？为了便于理解，我们可以将上述公式改写成这样：

其中，c 表示一个分类（class），f 表示属性对应的数据字段（field）。如此一来，等号左边的 P(c|f) 就是待分类样本中，出现属性值 f 时，样本属于类别 c 的概率。而等号右边的 P(f|c) 是根据训练数据统计，得到分类 c 中出现属性 f 的概率。P©是分类 c 在训练数据中出现的概率，P(f) 是属性 f 在训练样本中出现的概率。

看到这里，你可能要问了，这里的贝叶斯公式只描述了单个属性值属于某个分类的概率，可是我们要分析的水果每个都有很多属性啊，这该怎么办呢？

别急，朴素贝叶斯在这里就要发挥作用了。这是基于一个简单假设建立的一种贝叶斯方法，并假定数据对象的不同属性对其归类影响时是相互独立的。此时若数据对象 o 中同时出现属性 fi 与 fj，则对象 o 属于类别 c 的概率就是这样：

现在，我们应该已经可以用 10 个水果的数据，来建立朴素贝叶斯模型了。

其中，苹果的分类中共包含 3 个数据实例，对于形状而言，出现 2 次不规则圆、1 次圆形和 0 次椭圆形，因此各自的统计概率为 0.67、0.33 和 0.00。我们将这些值称为，给定一个水果分类时，出现某个属性值的条件概率。以此类推，所有的统计结果就是下面这个表格中这样：

对于上表中出现的 0.00 概率，在做贝叶斯公式中的乘积计算时，会出现结果为 0 的情况，因此我们通常取一个比这个数据集里最小统计概率还要小的极小值，来代替“零概率”。比如，我们这里取 0.01。在填充训练数据中从来没有出现过的属性值的时候，我们就会使用这种技巧，我们给这种技巧起个名字就叫作平滑（Smoothing）。

有了这些条件概率，以及各类水果和各个属性出现的先验概率，我们已经建立起了朴素贝叶斯模型。现在，我们就可以用它进行朴素贝叶斯分类了。

假设我们有一个新的水果，它的形状是圆形，口感是甜的，那么根据朴素贝叶斯，它属于苹果、甜橙和西瓜的概率分别是多少呢？我们先来计算一下，它属于苹果的概率有多大。

其中，apple 表示分类为苹果，shape-2 表示形状属性的值为 2（也就是圆形），taste-2 表示口感属性的值为 2。以此类推，我们还可计算该水果属于甜橙和西瓜的概率。

比较这三个数值，0.00198<0.00798<0.26934，所以计算机可以得出的结论，该水果属于甜橙的可能性是最大的，或者说，这个水果最有可能是甜橙。

你可能已经注意到了，这几个公式里的概率乘积通常都非常小，在物品的属性非常多的时候，这个乘积可能就小到计算机无法处理的地步。因此，在实际运用中，我们还会采用一些数学手法进行转换（比如取 log 将小数转换为绝对值大于 1 的负数），原理都是一样的。

内容比较多，我稍微总结一下。朴素贝叶斯分类主要包括这几个步骤：

准备数据：针对水果分类这个案例，我们收集了若干水果的实例，并从水果的常见属性入手，将其转化为计算机所能理解的数据。这种数据也被称为训练样本。

建立模型：通过手头上水果的实例，我们让计算机统计每种水果、属性出现的先验概率，以及在某个水果分类下某种属性出现的条件概率。这个过程也被称为基于样本的训练。

分类新数据：对于一颗新水果的属性数据，计算机根据已经建立的模型进行推导计算，得到该水果属于每个分类的概率，实现了分类的目的。这个过程也被称为预测。

朴素贝叶斯分类 VS 其他分类算法

用朴素贝叶斯进行分类的内容差不多就是这样，你可能要问了，朴素贝叶斯是唯一的分类算法吗？现实中需要分类的场景那么多，朴素贝叶斯都适用吗？确实，我们有很多种分类算法，它们也都有各自的优劣。我这里就把朴素贝叶斯和常用的几种分类算法做个总结和比较。

和KNN 最近邻相比，朴素贝叶斯需要更多的时间进行模型的训练，但是它在对新的数据进行分类预测的时候，通常效果更好、用时更短。

和决策树相比，朴素贝叶斯并不能提供一套易于人类理解的规则，但是它可以提供决策树通常无法支持的模糊分类（一个对象可以属于多个分类）。

和SVM 支持向量机相比，朴素贝叶斯无法直接支持连续值的输入。所以，在前面的案例中，我将连续值转化成了离散值，便于朴素贝叶斯进行处理。

为了便于你理解记忆，我这里也做一下总结。

如果一个分类的应用场景中，待分类对象的属性值大部分都是离散的（或者很容易转化为离散的）、需要支持模糊分类，并且需要快速可靠的实时分类，那么这种场景通常就非常适合使用朴素贝叶斯方法。

总结

今天我从一个看似非常简单的判断水果的例子出发，介绍了如何通过物体的属性及其数值，让计算机理解现实世界中的事物，并通过朴素贝叶斯方法来对其进行分类。

在朴素贝叶斯方法的推导过程中，我给你讲了如何使用贝叶斯法则，将后验概率的估计转换为先验概率和条件概率。朴素贝叶斯训练过程包括基于样本数据的先验概率和条件概率统计，分类过程就包括了使用贝叶斯法则，结合新样本的属性数据以及训练好的模型数据，进行最终的预测。

最后，我将朴素贝叶斯和其他常见分类算法，比如 KNN 近邻、决策树、SVM 向量机，做了对比。朴素贝叶斯适用离散属性值中，训练过程耗时长但是分类预测速度快，支持模糊分类。这一节的内容比较偏理论，下一节，我会着重来讲朴素贝叶斯的应用场景，告诉你哪些场合下更适合使用朴素贝叶斯法。

思考题

除了文本分类，你还知道什么地方可以使用朴素贝叶斯方法来处理分类问题？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

23 | 文本分类：如何区分特定类型的新闻？

你好，我是黄申。

你平时应该见过手机 App 推送的新闻吧？你有没有觉得这些 App 的推荐算法很神奇？它们竟然可以根据你的喜好来推荐新闻。想要实现这些推荐算法，有一个非常重要的步骤就是给新闻分类。可是，新闻头条这种综合性的平台，需要处理的新闻都是海量的，我们不可能完全靠人工手动处理这些事情。这个时候，我们就要用到计算机技术，来对文本进行自动分类。

上一节，我给你介绍了如何利用朴素贝叶斯方法，教会计算机进行最基本的水果分类。基于水果分类，今天我们继续深入分类这个话题，告诉你如何利用自然语言处理和朴素贝叶斯方法，对新闻这种长篇文本进行分类。

文本分类系统的基本框架

想要实现一个完整的文本分类系统，我们通常需要进行这些步骤：

1. 采集训练样本

对于每个数据对象，我们必须告诉计算机，它属于哪个分类。上一节的水果案例里，我们给每个水果打上“苹果”“甜橙”和“西瓜”的标签，这就是采集训练样本。

同样，我们可以给每一篇新闻打上标签，也就是说，我们首先要分辨某条新闻是什么类型的，比如是政治的、军事的、财经的、体育的，还是娱乐的等等。这一点非常关键，因为分类标签就相当于计算机所要学习的标准答案，其质量高低直接决定了计算机的分类效果。此外，我们也可以在一开始就预留一些训练样本，专门用于测试分类的效果。

2. 预处理自然语言

在水果的案例中，当我们把这些水果的特征值提取出来后，能很容易地将它们的属性转化成计算机所能处理的数据，可是这一步对于文本而言就没有那么容易了。好在专家们已经发明出了一套相对成熟的方法，包括词包（bag of words）、分词、词干（Stemming）和归一化（Normalization）、停用词（Stopword）、同义词（Synonyms）和扩展词处理。这里你只需要了解有这么些方法就可以了，我们使用这些方法的目的就是让计算机能够理解文本，所以如果你对刚才提到的这些专业词汇比较陌生，完全不用担心，这并不会影响对后面知识的理解。

3. 训练模型

训练模型就是算法通过训练数据进行模型拟合的过程。对于朴素贝叶斯方法而言，训练的过程就是要获取每个分类的先验概率、每个属性的先验概率以及给定某个分类时，出现某个属性的条件概率。

4. 实时分类预测

算法模型在训练完毕后，根据新数据的属性来预测它属于哪个分类的过程。对于朴素贝叶斯方法而言，分类预测的过程就是根据训练阶段所获得的先验概率和条件概率，来预估给定一系列属性的情况下属于某个分类的后验概率。

整个流程大致可以用下图来描述：

这四个步骤你大致了解就可以了，这里面有两点我会重点讲一下，一是对文本中的自然语言进行预处理，并从文本集合建立字典；另一个是如何使用建好的字典，统计朴素贝叶斯方法所需的数据。自然语言的预处理是让计算机“理解”人类语言的关键步骤，如果没有这一步，计算机就无法将大量的自然语言转换成自己可以处理的数据。而条件概率和先验概率是朴素贝叶斯方法所必须的，因此如何准确地统计它们也是需要详细解释的。

基于自然语言的预处理

和之前的水果案例相比，新闻这种文本数据最大的区别在于，它包含了大量的自然语言。那么如何让计算机理解自然语言呢？我们的计算机体系没有思维，要理解人类的语言在现阶段是不现实的。但是，我们仍然可以对自然语言进行适当的处理，将其变为机器所能处理的数据。

首先，我们要知道，文本的的重要属性是什么，这样我们才能提取出它的特征。怎么才能知道哪些属性是重要的呢？

我举个例子，假如说，有人给你一篇几千字的文章，让你在 10 秒钟之内说出文章大意，你会怎么办？我想大部分人的解决方案是“找关键词”！没错，我们也可以交给计算机用同样的办法。而计算机处理文本的基本单位就是字和单词，这就是人们最常用的方法：词袋（Bag of words）模型。

这种模型会忽略文本中的词语出现的顺序以及相应的语法，将整篇文章仅仅看做是一个大量单词的组合。文本中每个词的出现都是独立的，不依赖于其他词的出现情况。讲到这里，你有没有发现在词包模型中，所有单词相互之间是独立的，这个假设和朴素贝叶斯模型的独立假设是不是一致呀？

没错！这里我们就可以很巧妙地将朴素贝叶斯和文本处理结合起来了。不过先不要急，我们还要掌握一些方法，才能将文本中的长篇大论处理成关键词。

1. 分词

计算机处理自然语言的基本单位是单词和词组。对于英语等拉丁语系的语言来说，单词之间是以空格作为自然分界符的，所以我们可以直接使用空格对句子进行分割，然后来获取每个单词。但是，中文、日文、韩文这些语言在书写的时候，词和词之间并没有空格可以进行自然分界，所以我们就需要使用一些算法，来估计词语之间的划分，我们将这个过程称为分词。

这里有一个给中文句子分词的例子，你可以看一看。

分词前：今天我们一起来学习计算机学科中的数学知识

分词后：今天 我们 一起 来 学习 计算机 学科 中 的 数学 知识

是不是觉得给句子分词很简单？这是因为你的中文水平比较高，你想想，计算机怎么才能知道如何分词呢？我们有很多现成的分词模型可以使用。我这里介绍两种目前比较主流的分词模型，你只需要了解就行。

第一种是基于字符串匹配。其实就是扫描字符串。如果发现字符串的子串和词相同，就算匹配成功。匹配规则通常是“正向最大匹配”“逆向最大匹配”“长词优先”。这些算法的优点是只需使用基于字典的匹配，因此计算复杂度低；缺点是处理歧义词效果不佳。

第二种是基于统计和机器学习。这类分词基于人工标注的词性和统计特征，对中文进行建模。训练阶段，根据标注好的语料对模型参数进行估计。 在分词阶段再通过模型计算各种分词出现的概率，将概率最大的分词作为最终结果。常见的序列标注模型有隐马尔科夫模型（HMM，Hidden Markov Model）和条件随机场（CRF，Conditional Random Field），我们后面章节会讲到，这里我先不展开。

2. 取词干和归一化

我们刚才说了，相对中文而言，英文完全不需要考虑分词。不过它也有中文不具有的单复数、各种时态，因此它需要考虑取词干（stemming）。取词干的目标就是为了减少词的变化形式，将派生词转化为基本形式，就像下面这样：

将 am，is，are，was，were 全部转换为 be

将 car，cars，car’s，cars’全部转换为 car

最后，我们还要考虑大小写转化和多种拼写（例如 color 和 colour）这样的统一化，我们把这种做法称为归一化。

3. 停用词

无论何种语言，都会存在一些不影响（或基本不影响）相关性的词。有的时候干脆可以指定一个叫停用词（stop word）的字典，直接将这些词过滤，不予以考虑。例如英文中的 a、an、the、that、is、good、bad 等。中文“的、个、你、我、他、好、坏”等。

如此一来，我们可以在基本不损失语义的情况下，减少数据文件的大小，提高计算机处理的效率。当然，也要注意停用词的使用场景，例如用户观点分析，good 和 bad 这样的形容词反而成为了关键。不仅不能过滤，反而要加大它们的权重。

4. 同义词和扩展词

不同的地域或者不同时代，会导致人们对于同样的物品叫法也不同。例如，在中国北方“番茄“应该叫“西红柿”，而台湾地区将“菠萝”称为“凤梨”。对于计算机而言，需要意识到这两个词是等价的。添加同义词就是一个很好的手段。我们可以维护如下一个同义词的词典：

番茄，西红柿

菠萝，凤梨

洋山芋，土豆

泡面，方便面，速食面，快餐面

山芋，红薯

鼠标，滑鼠

……

有了这样的词典，当看到文本中出现“番茄”关键词的时候，计算机系统就会将其等同于“西红柿”这个词。有的时候我们还需要扩展词。如果简单地将 Dove 分别和多芬、德芙简单地等价，那么多芬和德芙这两个完全不同的品牌也变成了同义词，这样做明显是有问题的。那么我们可以采用扩展关系，当系统看到文本中的“多芬”时将其等同于“Dove”，看到“德芙”时将其等同于“Dove”。但是看到“Dove”的时候并不将其等同于“多芬”或“德芙”。

通过词包模型的假设，以及上述这些自然语言处理的方法，我们可以将整篇的文字，切分为一个个的单词，这些是表示文章的关键属性。你不难发现，每个单词可以作为文章的属性，而通过这些单词的词频（出现的频率），我们很容易进行概率的统计。下面我对分类的先验概率、单词的先验概率、某个分类下某个单词的条件概率分别给出了示例。

在上表中，你会发现某些单词从未在某个分类中出现，例如“航母”这个词从未在“体育”和“娱乐”这两个分类中出现。对于这种情况，我们可以使用平滑（smoothing）的技术，将其词频或条件概率设置为一个极小的值。这里，我设置了最小的词频，也就是 1。

有了这些单词属性以及相应的概率统计，下一步就是如何使用朴素贝叶斯模型进行文本的分类了。

运用朴素贝叶斯模型

首先我们先来回顾下，上一节推导的朴素贝叶斯公式。

在新闻分类中，o就表示一篇文章，而c表示新闻的种类（包括政治、军事、财经等等）。而属性字段f就是我们从文档集而建立的各种单词。公式等号左边的P(c|f)就是待分类新闻中，出现单词 f 时，该新闻属于类别 c 的概率。而等号右边的 P(f|c) 是根据训练数据统计，得到分类 c 中出现单词 f 的概率。P( c ) 是分类 c 在新闻训练数据中出现的概率，P(f) 是单词 f 在训练样本中出现的概率。

我们用刚才表格中的数据来计算下“中国航母”这个短语属于每个分类的概率。

可以看出来，“中国航母”这个短语本身属于“政治”和“军事”分类的可能性最高，而属于“财经”的可能性最低。需要注意的是，我在上述公式使用了中文词便于你的理解，在真正的实现中，我们需要将中文词和中文分类名称转换为数字型的 ID，以提高系统的效率。

当然，一篇文章所包含的不同的单词数量要远远大于 2 个，之前如果我们只看“中国航母”这个短语，那么它属于“政治”和“军事”的概率基本一致。如果我们考虑更长的文章（也就是更多的单词），那么这个情况也许就会发生变化。为了支持更多的单词，我们将上述公式扩展为：

这里需要注意一个很实际的问题：文章的篇幅很长，常常会导致非常多的 P(f|c) 连续乘积。而 P(f|c) 通常是非常小的数值，因此最后的乘积将快速趋近于 0 以至于计算机无法识别。这里可以使用我们之前提到的一些数学手法进行转换，比如取 log，将小数转换为绝对值大于 1 的负数。这样的转换，虽然会改变每篇文章属于每个分类的概率之绝对值，但是并不会改变这些概率的相对大小。

总结

在这一讲中，我讲了一个文本分类系统的几个关键步骤，其中最重要的是自然语言的处理和分类模型的训练和预测。

自然语言的处理是关键的预处理步骤，它将文本转换成计算机所能处理的数据。常见方法包括中文分词，英文的取词干和归一化，还有适用于各种语言的停用词、同义词和扩展词等。如果不考虑这些词出现的先后顺序，以及表达的深层次语义，那么我们就可以使用词包的方法，将大段的文章和语句转化成单词所组成的集合。之后，我们就能统计每个单词属于每个分类的条件概率，以及分类和单词的先验概率。

一旦将文章表示为单词的集合，我们就会发现，朴素贝叶斯的模型非常适合文章的分类。因为所有单词出现的词频都是离散值，非常适合统计概率。此外，许多新闻之类的文章本身也是跨了多个领域，因此有可能属于多个分类，朴素贝叶斯也能支持这点。我们针对“中国航母”这个短语进行了测试，发现其属于“政治”和“军事”分类的可能性最高。不过要注意的是，文章可能包含非常多的单词，朴素贝叶斯模型中的连续乘积会导致过小的值，甚至计算机都无法处理。为了避免这种情况，我们可以使用 log 的数学变换。

思考题

你也许已经通过“中国航母”这个简单的例子，见识到了计算机自动分类的神奇功效。使用同样的分类模型和概率分布，你能否计算一下“美国电影”属于政治、军事、财经、体育和娱乐分类的概率，分别是多少？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

24 | 语言模型：如何使用链式法则和马尔科夫假设简化概率模型？

你好，我是黄申。

之前我给你介绍了用于分类的朴素贝叶斯算法。我们讲了，朴素贝叶斯算法可以利用贝叶斯定理和变量之间的独立性，预测一篇文章属于某个分类的概率。除了朴素贝叶斯分类，概率的知识还广泛地运用在其他机器学习算法中，例如语言模型、马尔科夫模型、决策树等等。

今天我就来说说，基于概率和统计的语言模型。语言模型在不同的领域、不同的学派都有不同的定义和实现，因此为了避免歧义，我这里先说明一下，我们谈到的语言模型，都是指基于概率和统计的模型。

语言模型是什么？

在解释语言模型之前，我们先来看两个重要的概念。第一个是链式法则，第二个是马尔科夫假设及其对应的多元文法模型。为什么要先说这两个概念呢？这是因为链式法则可以把联合概率转化为条件概率，而马尔科夫假设通过变量间的独立性来减少条件概率中的随机变量，两者结合就可以大幅简化计算的复杂度。

1. 链式法则

链式法则是概率论中一个常用法则。它使用一系列条件概念率和边缘概率，来推导联合概率，我用一个公式来给你看看它的具体表现形式。

其中，x1x1 到 xnxn 表示了 n 个随机变量。

这个公式是怎么来的呢？你还记得联合概率、条件概率和边缘概率之间的“三角”关系吗？我们用这三者的关系来推导一下，最终我们可以得到链式法则。

推导的每一步，都是使用了三种概率之间的关系，这个应该不难理解。

2. 马尔科夫假设

理解了链式法则，我们再来看看马尔可夫假设。这个假设的内容是：任何一个词 wiwi 出现的概率只和它前面的 1 个或若干个词有关。基于这个假设，我们可以提出多元文法（Ngram）模型。Ngram 中的“N”很重要，它表示任何一个词出现的概率，只和它前面的 N-1 个词有关。

我以二元文法模型为例，来给你解释。按照刚才的说法，二元文法表示，某个单词出现的概率只和它前面的 1 个单词有关。也就是说，即使某个单词出现在一个很长的句子中，我们也只需要看前面那 1 个单词。用公式来表示出来就是这样：

如果是三元文法，就说明某个单词出现的概率只和它前面的 2 个单词有关。即使某个单词出现在很长的一个句子中，它也只看相邻的前 2 个单词。用公式来表达就是这样：

你也许会好奇，那么一元文法呢？按照字面的意思，就是每个单词出现的概率和前面 0 个单词有关。这其实说明，每个词的出现都是相互独立的。用公式来表达就是这样的：

弄明白链式法则和马尔科夫假设之后，我们现在来看语言模型。

假设我们有一个统计样本文本 dd，ss 表示某个有意义的句子，由一连串按照特定顺序排列的词 w1，w2,…,wnw1，w2,…,wn 组成，这里 nn 是句子里单词的数量。现在，我们想知道根据文档 d 的统计数据，ss 在文本中出现的可能性，即 P(s|d)P(s|d)，那么我们可以把它表示为 P(s|d)=P(w1,w2,…,wn|dP(s|d)=P(w1,w2,…,wn|d)。假设我们这里考虑的都是在集合 d 的情况下发生的概率，所以可以忽略 dd，写为 P(s)=P(w1,w2,…,wn)P(s)=P(w1,w2,…,wn)。

到这里，我们碰到了第一个难题，就是如何计算 P(w1,w2,…,wn)P(w1,w2,…,wn) 要在集合中找到一模一样的句子，基本是不可能的。这个时候，我们就需要使用链式法则。我们可以把这个式子改写为：

咋一看，问题似乎是解决了。因为通过文档集合 C，你可以知道 P(w1)P(w1)，P(w2|w1)P(w2|w1) 这种概率。不过，再往后看，好像 P(w3|w1,w2)P(w3|w1,w2) 出现概率很低，P(w4|w1,w2,w3)P(w4|w1,w2,w3) 出现的概率就更低了。一直到 P(wn|w1,w2,…,wn−1)P(wn|w1,w2,…,wn−1)，基本上又为 0 了。我们可以使用上一节提到的平滑技巧，减少 0 概率的出现。不过，如果太多的概率都是通过平滑的方式而得到的，那么模型和真实的数据分布之间的差距就会加大，最终预测的效果也会很差，所以平滑也不是解决 0 概率的最终办法。

除此之外，P(w1,w2,…,wn)和P(wn|w1,w2,…,wn−1)P(w1,w2,…,wn)和P(wn|w1,w2,…,wn−1) 还不只会导致 0 概率，它还会使得模型存储空间的急速增加。

为了统计现有文档集合中 P(w1,w2,…,wn)P(w1,w2,…,wn) 这类值，我们就需要生成很多的计数器。我们假设文档集合中有 m 个不同的单词，那么从中挑出 nn 个单词的可重复排列，数量就是 mnmn。此外，还有 mn−1mn−1, mn−2mn−2 等等。这也意味着，如果要统计并存储的所有 P(w1,w2,…,wn)P(w1,w2,…,wn) 或 P(wn|w1,w2,…,wn−1)P(wn|w1,w2,…,wn−1) 这类概率，就需要大量的内存和磁盘空间。当然，你可以做一些简化，不考虑单词出现的顺序，那么问题就变成了可重复组合，但是数量仍然非常巨大。

如何解决 0 概率和高复杂度的问题呢？马尔科夫假设和多元文法模型能帮上大忙了。如果我们使用三元文法模型，上述公式可以改写为：

这样，系统的复杂度大致在 (C(m, 1) + C(m, 2) + C(m, 3)) 这个数量级，而且 P(wn|wn−2,wn−1)P(wn|wn−2,wn−1) 为 0 的概率也会大大低于 P(wn|w1,w2,…,wn−1)P(wn|w1,w2,…,wn−1) （其中 n>>3n>>3）为 0 的概率。当然，多元文法模型中的 N 还是不能太大。随着 N 的增大，系统复杂度仍然会快递升高，就无法体现出多元文法的优势了。

语言模型的应用

基于概率的语言模型，本身不是新兴的技术。它已经在机器翻译、语音识别和中文分词中得到了成功应用。近几年来，人们也开始在信息检索领域中尝试语言模型。下面我就来讲讲语言模型在信息检索和中文分词这两个方面里是如何发挥作用的。

1. 信息检索

信息检索很关心的一个问题就是相关性，也就是说，给定一个查询，哪篇文档是更相关的呢？为了解决相关性问题，布尔模型和向量空间检索模型都是从查询的角度出发，观察查询和文档之间的相似程度，并以此来决定如何找出相关的文档。这里的“相似程度”，你可以理解为两者长得有多像。那么，语言模型如何来刻画查询和文档之间的相关度呢？

它不再使用相似度定义，而是采用了概率。一种常见的做法是计算 P(d|q)P(d|q)，其中 qq 表示一个查询，dd 表示一篇文档。P(d|q)P(d|q) 表示用户输入查询 qq 的情况下，文档 dd 出现的概率是多少？如果这个概率越高，我们就认为 qq 和 dd 之间的相关性越高。

通过我们手头的文档集合，并不能直接获得 P(d|q)P(d|q)。好在我们已经学习过了贝叶斯定理，通过这个定理，我们可以将 P(d|q)P(d|q) 重写如下：

对于同一个查询，其出现概率 P(q)P(q) 都是相同的，同一个文档 dd 的出现概率 P(d)P(d) 也是固定的。因此它们可以忽略，我们只要关注如何计算 P(q|d)P(q|d)。而语言模型，为我们解决了如何计算 P(q|d)P(q|d) 的问题，让 k1,k2,…,knk1,k2,…,kn 表示查询 q 里包含的 nn 个关键词。那么根据之前的链式法则公式，可以重写为这样：

为了提升效率，我们也使用马尔科夫假设和多元文法。假设是三元文法，那么我们可以写成这样：

最终，当用户输入一个查询 qq 之后，对于每一篇文档 dd，我们都能获得 P(d|q)P(d|q) 的值。根据每篇文档所获得的 P(d|q)P(d|q) 这个值，由高到低对所有的文档进行排序。这就是语言模型在信息检索中的常见用法。

2. 中文分词

和拉丁语系不同，中文存在分词的问题。如果想进行分词，你就可以实用语言模型。我举个例子给你解释一下，你就明白了。

最普遍的分词方法之一是基于常用词的词典。如果一个尚未分词的句子里发现了存在于字典里的词，我们就认为找到一个新的词，并把它切分出来。这种切分不会出现完全离谱的结果，但是无法解决某些歧义。我下面来举个例子，原句是“兵乓球拍卖完了”。我在读的时候，会有所停顿，你就能理解分词应该如何进行。可是，仅仅从书面来看，至少有以下几种分词方式：

第一种，兵乓|球|拍卖|完了

第二种，兵乓球|拍卖|完了

第三种，兵乓|球拍|卖完|了

第四种，兵乓|球拍|卖|完了

上面分词的例子，从字面来看都是合理的，所以这种歧义无法通过这句话本身来解决。那么这种情况下，语言模型能为我们做什么呢？我们知道，语言模型是基于大量的语料来统计的，所以我们可以使用这个模型来估算，哪种情况更合理。

假设整个文档集合是 D，要分词的句子是 ss，分词结果为 w1w1, … wnwn，那么我们可以求 P(s)P(s) 的概率为：

请注意，在信息检索中，我们关心的是每篇文章产生一个句子（也就是查询）的概率，而这里可以是整个文档集合 D 产生一个句子的概率。

根据链式法则和三元文法模型，那么上面的式子可以重写为：

也就是说，语言模型可以帮我们估计某种分词结果，在文档集合中出现的概率。但是由于不同的分词方法，会导致 w1w1 到 wnwn 的不同，因此就会产生不同的 P(s)P(s)。接下来，我们只要取最大的 P(s)P(s)，并假设这种分词方式是最合理的，就可以在一定程度上解决歧义。我们可以使用这个公式来求解：

其中，WiWi 表示第 ii 种分词方法。

回到“兵乓球拍卖完了”这句话，如果文档集合都是讲述的有关体育用品的销售，而不是拍卖行，那么“兵乓|球拍|卖完|了”这种分词的可能性应该更高。

小结

这一节，我介绍了基于概率论的语言模型，以及它在信息检索和中文分词领域中的应用。这一节的公式比较多，你刚开始看可能觉得有点犯晕。不用急，我给你梳理了几个要点，你只要掌握这几个要点，依次再进行细节学习，就会事半功倍。

第一，使用联合概率，条件概率和边缘概率的“三角”关系，进行相互推导。链式法则就是很好的体现。

第二，使用马尔科夫假设，把受较多随机变量影响的条件概率，简化为受较少随机变量影响的条件概率，甚至是边缘概率。

第三，使用贝叶斯定理，通过先验概率推导后验概率。在信息检索中，给定查询的情况下推导文档的概率，就需要用到这个定理。

如果你记住了这几点，那么不仅能很快的理解本篇的内容，还能根据实际需求，设计出满足自己需要的语言模型。

思考题

在中文分词的时候，我们也可以考虑文章的分类。比如，这样一句话“使用纯净水源浇灌的大米”，正确的切分应该是：

使用|纯净|水源|浇灌|的|大米

如果我们知道这句描述来自于“大米”类商品，而不是“纯净水”类商品，那么就不会错误地切分为：

使用|纯净水|源|浇灌|的|大米

想想看，如何对我介绍的语言模型加以改进，把分类信息也包含进去？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

25 | 马尔科夫模型：从PageRank到语音识别，背后是什么模型在支撑？

你好，我是黄申。

上一节，我们介绍了基于概率的语言模型。概率语言模型的研究对象其实是一个词的序列，以及这个词序列出现的概率有多大。那语言模型是不是也可以用于估算其他序列出现的概率呢？答案是肯定的。

通过上一节我们知道，语言模型中有个重点：马尔科夫假设及对应的多元文法模型。如果我们把这一点进一步泛化，就能引出马尔科夫模型。也就是说，只要序列的每个状态之间存在转移的概率，那么我们就可以使用马尔科夫模型。有时候情况会更复杂，不仅每个状态之间的转移是按照一定概率进行的，就连每个状态本身也是按照一定概率分布出现的，那么还需要用到隐马尔科夫模型。

今天这一节，我们就来学习马尔科夫模型、隐马尔科夫模型，以及它们在 PageRank 和中文分词中的应用。

马尔科夫模型

在介绍语言模型的时候，我们提到了马尔科夫假设，这个假设是说，每个词出现的概率和之前的一个或若干个词有关。我们换个角度思考就是，每个词按照一定的概率转移到下一个词。怎么个转移呢？我来解释一下。

如果把词抽象为一个状态，那么我们就可以认为，状态到状态之间是有关联的。前一个状态有一定的概率可以转移到到下一个状态。如果多个状态之间的随机转移满足马尔科夫假设，那么这类随机过程就是一个马尔科夫随机过程。而刻画这类随机过程的统计模型，就是马尔科夫模型（Markov Model）。

前面讲多元文法的时候，我提到了二元文法、三元文法。对于二元文法来说，某个词出现的概率只和前一个词有关。对应的，在马尔科夫模型中，如果一个状态出现的概率只和前一个状态有关，那么我们称它为一阶马尔科夫模型或者马尔科夫链。对应于三元、四元甚至更多元的文法，我们也有二阶、三阶等马尔科夫模型。

我们先从最简单的马尔科夫模型 - 马尔科夫链开始看。我画了一张示意图，方便你理解马尔科夫链中各个状态的转移过程。

在这张图中，你可以看到，从状态 A 到 B 的概率是 0.1，从状态 B 到状态 C 的概率是 0.2 等等。我们也可以使用状态转移表来表示这张图。

我们可以根据某个应用的需要，把上述状态转移表具体化。例如，对于语言模型中的二元文法模型，我这里列出了一个示意表。

当然，除了二元文法模型，马尔科夫链还有很多应用的场景。

Google 公司最引以为傲的 PageRank 链接分析算法，它的核心思想就是基于马尔科夫链。这个算法假设了一个“随机冲浪者”模型，冲浪者从某张网页出发，根据 Web 图中的链接关系随机访问。在每个步骤中，冲浪者都会从当前网页的链出网页中随机选取一张作为下一步访问的目标。在整个 Web 图中，绝大部分网页节点都会有链入和链出。那么冲浪者就可以永不停歇地冲浪，持续在图中走下去。

在随机访问的过程中，越是被频繁访问的链接，越是重要。可以看出，每个节点的 PageRank 值取决于 Web 图的链接结构。假如一个页面节点有很多的链入链接，或者是链入的网页有较高的被访问率，那么它也将会有更高的被访问概率。

那么，PageRank 的公式和马尔科夫链有什么关系呢？我先给你看一张 Web 的拓扑图。

其中 A、B、C 等结点分别代表了页面，而结点之间的有向边代表了页面之间的超链接。看了这张图中，你是不是觉得 Web 拓扑图和马尔科夫链的模型图基本上是一致的？我们可以假设每张网页就是一个状态，而网页之间的链接表明了状态转移的方向。这样，我们很自然地就可以使用马尔科夫链来刻画“随机冲浪者”。

另外，在最基本的 PageRank 算法中，我们可以假设每张网页的出度是 nn，那么从这张网页转移到任何下一张相连网页的概率都是 1n1n，因此这个转移的概率只和当前页面有关，满足一阶马尔科夫模型的假设。我在之前的拓扑结构中添加了转移的概率。

PageRank 在标准的马尔科夫链上，引入了随机的跳转操作，也就是假设冲浪者不按照 Web 图的拓扑结构走下去，只是随机挑选了一张网页进行跳转。这样的处理是类比人们打开一张新网页的行为，也是符合实际情况的，避免了信息孤岛的形成。最终，根据马尔科夫链的状态转移和随机跳转，可以得到如下的 PageRank 公式。

其中，pipi 表示第 ii 张网页，MiMi 是 pipi 的入链接集合，pjpj 是 MiMi 集合中的第 jj 张网页。PR(pj)PR(pj) 表示网页 pjpj 的 PageRank 得分，L(pj)L(pj) 表示网页 pjpj 的出链接数量，1L(pj)1L(pj) 就表示从网页 pjpj 跳转到 pipi 的概率。αα 是用户不进行随机跳转的概率，NN 表示所有网页的数量。

从最简单的马尔科夫链，到多阶的马尔科夫模型，它们都可以刻画基于马尔科夫假设的随机过程，例如概率语言模型中的多元文法和 PageRank 这类链接分析算法。但是，这些模型都是假设每个状态对我们都是已知的，比如在概率语言模型中，一个状态对应了单词“上学”，另一个状态对应了单词“书包”。可是，有没有可能某些状态我们是未知的呢？下面我们就来详细说说这种情况。

隐马尔科夫模型

在某些现实的应用场景中，我们是无法确定马尔科夫过程中某个状态的取值的。这种情况下，最经典的案例就是语音识别。使用概率对语音进行识别的过程，和语言模型类似，因此我们可以把每个等待识别的词对应为马尔科夫过程中的一个状态。不过，语音识别所面临的困难更大。为什么呢？你先看看下面这个句子。这个句子里全都是拼音，你能看出它表示什么意思吗？

ni(三声) zhi(一声) dao(四声) wo(三声) zai(四声) deng(三声) ni(三声) ma(一声)

中国有句古话说得好，“白纸黑字”，写在文档里的文字对于计算机是确定的，“嘛”“吗” “妈”不会弄错。可是，如果你说一句“你知道我在等你吗”，听众可能一直弄不明白为什么要等别人的妈妈，除非你给他们看到文字版的内容，证明最后一个字是口字旁的“吗”。另外，再加上各种地方的口音、唱歌的发音或者不标准的拼读，情况就更糟糕了。

计算机只知道某个词的发音，而不知道它具体怎么写，对于这种情况，我们就认为计算机只能观测到每个状态的部分信息，而另外一些信息被“隐藏”了起来。这个时候，我们就需要用隐马尔科夫模型来解决这种问题。隐马尔科夫模型有两层，一层是我们可以观测到的数据，称为“输出层”，另一层则是我们无法直接观测到的状态，称为“隐藏状态层”。我画了一张图方便你理解。

其中，x1，x2，x3x1，x2，x3 等等属于隐藏状态层，a12a12 表示了从状态 x1x1 到 x2x2 的转移概率，a23a23 表示了从状态 x2x2 到 x3x3 的转移概率。这一层和普通的马尔科夫模型是一致的，可惜在隐马尔科夫模型中我们无法通过数据直接观测到这一层。我们所能看到的是，y1，y2，y3y1，y2，y3 等等代表的“输出层”。另外，b11b11 表示了从状态 x1x1 到 y1y1 的输出概率，b22b22 表示了从状态 x2x2 到 y2y2 的输出概率，b33b33 表示了从状态 x3x3 到 y3y3 的输出概率等等。

那么在这个两层模型示例中，“隐藏状态层”产生“输出层”的概率是多少呢？这是一系列条件概率决定的，具体的公式我列在这里。

如果你觉得这个两层的模型不太好理解，我来给你说个浅显易懂的例子。假设正在进行普通话语音识别，计算机接受了一个词组的发音。我在下面列出了它的拼音。

xiang(四声)mu(四声) kai(一声)fa(一声) shi(四声)jian(四声)

假设根据我们手头上的语料数据，这个词组有多种可能，我列出两种。

第一种情况

第一种情况下，三个确定的状态是“项目”“开发”和“时间”这三个词。从“项目”转移到“开发”的概率是 0.25，从“开发”转移到“时间”的概率是 0.3。从“项目”输出“xiang（三声）mu（四声）”的概率是 0.1，输出”xiang（四声）mu（四声）”的概率是 0.8，输出“xiang（四声）mu（一声）”的概率是 0.1，“开发”和“时间”也有类似的输出概率。

这个时候你可能会奇怪，“项目”的普通话发音就是“xiang（四声）mu（四声）”，为什么还会输出其他的发音呢？这是因为，前面说的这些概率都是通过历史语料的数据统计而来。在进行语音识别的时候，我们会通过不同地区、不同性别、不同年龄等等的人群，采集发音的样本。如此一来，影响这个发音的因素就很多了，比如方言，口音、误读等等。当然，在正常情况下，大部分的发音还是标准的，所以“项目”这个词输出到“xiang（四声）mu（四声）”的概率是最高的。

好，有了这些概率的分布，我们来看看“项目开发时间”这个词组最后生成的概率是多少。在两层模型的条件概率公式中，我代入了具体的概率值并使用了如下的推导：

第二种情况

在第二种的可能性中，三个确定的状态是“橡木”“开发”和“事件”这三个词。从“橡木”转移到"开发"的概率是 0.015，从“开发”转移到“事件”的概率是 0.05。从“橡木”输出“xiang（一声）mu（四声）”的概率是 0.2，输出“xiang（四声）mu（四声）”的概率是 0.8，“开发”和“事件”也有类似的输出概率。和第一种情况类似，我们可以计算“橡木开发事件”这个词组最后生成的概率是多少，我用下面这个公式来推导：

最后比较第一种和第二种情况产生的概率，分别是 P(项目)x0.0027 和 P(橡木)x0.000459。假设 P(项目) 和 P(橡木) 相等，那么“项目开发时间”这个词组的概率更高。所以“xiang（四声）mu（四声）kai（一声）fa（一声）shi（四声）jian（四声）”这组发音，计算机会识别为“项目开发时间”。从中我们可以看出，尽管“事件”这个词产生“shi（四声）jian（四声）”这个发音的可能性更高，但是“橡木开发事件”这个词组出现的概率极低，因此最终计算机还是选择了“项目开发时间”，隐藏的状态层起到了关键的作用。

总结

马尔科夫模型考虑了 n 个状态之间的转移及其对应的关系。这个状态是比较抽象的含义，在不同的应用领域代表不同的含义。在概率语言模型中，状态表示不同的词，状态之间的转移就代表了词按照一定的先后顺序出现。在 PageRank 这种链接分析中，状态表示不同的网页，状态之间的转移就代表了人们在不同网页之间的跳转。

在马尔科夫模型中，我们知道了每种状态及其之间转移的概率，然后求解序列出现的概率。然而，有些现实的场景更为复杂，比如说我们观测到的不是状态本身，而是状态按照一定概率分布所产生的输出。针对这种情况，隐马尔科夫模型提出了一种两层的模型，同时考虑了状态之间转移的概率和状态产生输出的概率，为语音识别、手写识别、机器翻译等提供了可行的解决方案。

隐马尔科夫模型需要回答的最主要问题是：给定一个模型和某个特定的输出序列，如何找到最可能产生这个输出的状态序列？在本节中，我使用了“项目开发时间”这个例子展示隐马尔科夫模型是如何工作的。不过这个例子很简单，我只比较了两种可能性。但是，实际中可能性是非常多的，如果我们使用穷举法，那么复杂度一定很高。

我们可以把两层的模型看作图结构。其中，状态和输出是结点，转移和输出关系是边，相应的概率是边的权重，这个时候我们就可以对 Dijkstra 算法稍加修改，来找出权重乘积最大的最优路径，提升查找的效率。我们还可以利用状态序列之间存在的先后关系，使用基于动态规划的维特比（Viterbi）算法来找出最优路径。

思考题

机器翻译会使用大量的语料，自动学习不同语言之间词和词的匹配。如果在机器翻译中使用隐马尔科夫进行建模，你认为“隐藏状态层”表示的是什么？“输出层”表示的又是什么？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

26 | 信息熵：如何通过几个问题，测出你对应的武侠人物？

你好，我是黄申。

之前和你聊了概率在朴素贝叶斯分类算法中的应用。其实，概率在很多像信息论这样的应用数学领域都有广泛的应用。信息论最初就是运用概率和统计的方法，来研究信息传递的。最近几十年，人们逐步开始使用信息论的概念和思想，来描述机器学习领域中的概率分布，并衡量概率分布之间的相似性。随之而来的是，人们发明了不少相关的机器学习算法。所以接下来的几节，我来介绍一些基于信息论知识的内容。

信息论的概念比较枯燥，为了让你更轻松地学习，让我从一个生动的案例开始。最近我在朋友圈看到一个小游戏，叫“测一测你是金庸笔下的哪个人物？”。玩这个游戏的步骤是，先做几道题，然后根据你的答案，生成对应的结果。下面是我几位朋友答题之后得到的结果。

这种测试挺好玩的，而且好像有很多类似的，比如测星座啊、测运势啊等等。那你知道这种心理或者性格测试的题目是怎么设计的吗？

通常，这种心理测试会有一个题库，包含了许多小题目，也就是从不同的方面，来测试人的性格。不过，针对特定的测试目标，我们可能没必要让被测者回答所有的问题。那么，问卷设计者应该如何选择合适的题目，才能在读者回答尽量少的问题的同时，相对准确地测出自己是什么“性格”呢？这里，我们就需要引入基于概率分布的信息熵的概念，来解决这个问题。

什么是信息熵？

我还是拿刚刚那个“测测你是哪个武侠人物”的小游戏举例子。我设计了一个测试题，你可以看看下面这个图表。这个表里一共有 10 个人物。每个人物都有性别、智商、情商、侠义和个性共 5 个属性。相应地，我会设计 5 道题目分别测试这 5 个属性所占的比例。最后，将测出的 5 个属性和答案中的武侠人物对照，就可以找到最接近的答案，也就是被测者对应的武侠人物。

这个过程非常简单，你应该很容易就能理解。在这个设计过程中，起决定性作用的环节其实就是，如何设计这 5 道题目。比如，题目的先后顺序会不会直接影响要回答问题的数量？每个问题在人物划分上，是否有着不同的区分能力？这些都是信息熵要解决的问题。

我们先来看，这里的区分能力指的是什么呢？每一个问题都会将被测试者划分为不同的人物分组。如果某个问题将属于不同人物分组的被测者，尽可能地划分到了相应的分组，那么我们认为这个问题的区分能力较强。相反，如果某个问题无法将属于不同人物分组的被测者划分开来，那么我们认为这个问题的区分能力较弱。为了帮你进一步理解，我们先来比较一下“性别”和“智商”这两个属性。

首先，性别属性将武侠人物平均地划分为一半一半，也就是说“男”和“女”出现的先验概率是各 50%。如果我们假设被测试的人群，其男女性别的概率分布也是 50% 和 50%，那么关于性别的测试题，就能将被测者的群体大致等分。

我们再来看智商属性。我们也将武侠人物划分为 2 个小集合，不过“智商高”的先验概率是 80%，而“智商中等”的先验概率只有 20%。同样，我们假设被测试的人群，其智商的概率分布也是类似地，那么经过关于智商的测试题之后，仍然有 80% 左右的不同人物还是属于同一个集合，并没有被区分开来。因此，我们可以认为关于“智商”的测试题，在对人物进行分组这个问题上，其能力要弱于“性别”的测试题。

上述这些是不是都很简单？这些都是我们按照感觉，或者说经验来划分的。现在，我们试着用两个科学的度量指标，信息熵（Entropy）和信息增益（Information Gain），来衡量每道题目的区分能力。

首先，怎么来理解信息熵呢？信息熵，我们通常简称为熵，其实就是用来刻画给定集合的纯净度的一个指标。你可能要问了，那纯净度是啥呢？我举个例子给你解释一下。比如说，一个集合里的元素全部是属于同一个分组，这个时候就表示最纯净，我们就说熵为 0；如果这个集合里的元素是来自不同的分组，那么熵是大于 0 的值。其具体的计算公式如下：

其中，nn 表示集合中分组的数量，pipi 表示属于第 ii 个分组的元素在集合中出现的概率。

你可能要问了，这个公式是怎么来的呢？想要解释这个，我们还要从信息量说起。熵的公式是用来计算某个随机变量的信息量之期望，而信息量是信息论中的一个度量，简单来说就是，当我们观察到某个随机变量的具体值时，接收到了多少信息。而我们接收到的信息量跟发生事件的概率有关。事情发生的概率越大，产生的信息量越小；事情发生的概率越小，产生的信息量越大。

因此，我们想要设计一个能够描述信息量的函数，就要同时考虑到下面这三个特点：

信息量应该为正数；

一个事件的信息量和它发生的概率成反比；

H(x)H(x) 与 P(x)P(x) 的对数有关。其中 H(x)H(x) 表示 xx 的信息量，P(x)P(x) 表示 xx 出现的概率。假设有两个不相关的事件 xx 和 yy，我们观察到这两个事件同时发生时获得的信息量，应该等于这两个事件各自发生时获得的信息量之和，用公式表达出来就是 H(x,y)=H(x)+H(y)H(x,y)=H(x)+H(y)。之前我们说过，如果 x，yx，y 是两个不相关的事件，那么就有 P(x,y)=P(x)∗P(y)P(x,y)=P(x)∗P(y)。

依照上述这三点，我们可以设计出信息量公式：H(x)=−log(P(x),2)H(x)=−log(P(x),2)。函数 log 的使用是体现了 H(x)H(x) 和 P(x)P(x) 的对数关系（我们可以使用其他大于 1 的数字作为对数的底，我这里使用 2 只是约定俗成。而最开始的负号是为了保证信息量为正）。这个公式可以量化随机变量某种取值时，所产生的信息量。最后，加上计算随机变量不同可能性所产生的信息量之期望，我们就得到了熵的公式。

从集合和分组的角度来说，如果一个集合里的元素趋向于落在同一分组里，那么告诉你某个元素属于哪个分组的信息量就越小，整个集合的熵也越小，换句话说，整个集合就越“纯净”。相反，如果一个集合里的元素趋向于分散在不同分组里，那么告诉你某个元素属于哪个分组的信息量就越大，整个集合的熵也越大，换句话说，整个集合就越“混乱”。

为了帮你理解运用，这里我再举几个例子帮助你更好地消化这个公式。我们首先来看一个集合，它只包含了来自 A 组的元素。

那么集合中分组的数量 nn 为 1，A 分组的元素在集合中出现的概率为 100%，所以这个集合的熵为 -100%*log(100%, 2) = 0。

我们再来看另一个集合，它只包含了来自 A 组和 B 组的元素，其中 A、B 两组元素数量一样多，各占一半。

那么集合中分组的数量 nn 为 2，A 和 B 分组的元素在集合中出现的概率各为 50%，所以这个集合的熵为 2*(-50%*log(50%, 2)) = 1，高于刚才那个集合。

从上述两个集合的对比可以看出，一个集合中所包含的分组越多、元素在这些分组里分布得越均匀，熵值也越大。而熵值表示了纯净的程度，或者从相反的角度来说，是混乱的程度。

好了，你已经知道单个集合的熵是如何计算的了。那么，如果将一个集合划分成多个更小的集合之后，又该如何根据这些小集合，来计算整体的熵呢？之前我们提到了信息量和熵具有加和的性质，所以对于包含多个集合的更大集合，它的信息量期望值是可以通过每个小集合的信息量期望值来推算的。具体来说，我们可以使用如下公式：

其中，TT 表示一种划分，PvPv 表示划分后其中某个小集合，Entropy(Pv)Entropy(Pv) 表示某个小集合的熵， 而 |Pv||P||Pv||P| 表示某个小集合出现的概率。所以这个公式其实就表示，对于多个小集合而言，其整体的熵等于各个小集合之熵的加权平均。而每个小集合的权重是其在整体中出现的概率。

我用个例子进一步解释这个公式。假设 A、B、C 三个集合是一个大的整体，我们现在将 C 组的元素和 A、B 组分开。

根据之前单个集合的熵计算，A 和 B 组元素所组成的小集合，它的熵是 1。而 C 组没有和其他组混合，所形成的小集合其熵为 0。在计算前两个小集合的整体熵时，A 组和 B 组形成的集合出现的概率为 2323，而 C 组形成的集合出现概率为 1313，所有整体熵 =23∗1+13∗0=0.67=23∗1+13∗0=0.67。

什么是信息增益？

如果我们将划分前后的整体熵做个对比，你会发现划分后的整体熵要小于划分之前的整体熵。这是因为每次划分，都可能将不同分组的元素区分开来，降低划分后每个小集合的混乱程度，也就是降低它们的熵。我们将划分后整体熵的下降，称为信息增益（Information Gain）。如果划分后整体熵下降的越多，信息增益就越大。我列出公式便于你的理解。

其中 T 表示当前选择的特征，Entropy§Entropy§ 表示选择特征 TT 之前的熵，Entropy(Pv)Entropy(Pv) 表示特征 TT 取值为 vv 分组的熵。减号后面的部分表示选择 T 做决策之后，各种取值加权平均后整体的熵。

Gain(P,T)Gain(P,T) 表示两个熵值之差，越大表示信息增益越多，应该选择这维特征 TT。

我们把这个概念放到咱们的小游戏里就是，如果一个测试问题能够将来自不同分组的人物尽量的分开，也就是该划分对应的信息增益越高，那么我们就认为其区分能力越高，提供的信息含量也越多。好，说到这里，让我们从游戏的最开始出发，比较一下有关性别和智商的两个测试题。

在提出任何问题之前，我们无法知道被测者属于哪位武侠人物，因此所有被测者属于同一个集合。假设被测者的概率分布和这 10 位武侠人物的先验概率分布相同，那么被测者集合的熵为 10*(-1 * 0.1 * log(0.1, 2))=3.32。

通过性别的测试问题对人物进行划分后，我们得到了两个更小的集合，每个小集合都包含 5 种不同的人物分组，因此每个小集合的熵是 (-1 * 5 * 0.2 * log(0.2, 2)) = 2.32，两个小集合的整体熵是 0.5 * 2.32 + 0.5 * 2.32 = 2.32。因此使用性格的测试题后，信息增益是 3.32 - 2.32 = 1。

而通过智商的测试问题对人物分组后，我们也得到了两个小集合，一个包含了 8 种人物，另一个包含了 2 种人物。包含 8 种人物的小集合其熵是 (-1* 8 * 0.125 * log(0.125, 2)) = 3，包含 2 种人物的小集合其熵是 (-1* 2 * 0.5 * log(0.5, 2)) = 1。两个小集合的整体熵是 0.8 * 3 + 0.2 * 1 = 2.6。因此使用智商的测试题后，信息增益是 3.32 - 2.6 = 0.72，低于基于性别的测试。所以，我们可以得出结论，有关性别的测试题比有关智商的测试题更具有区分能力。

信息增益和信息熵是紧密相关的。如果说信息熵衡量了某个状态下，每个分组的纯净程度或者说混乱程度，那么信息增益就是比较了不同状态下，信息熵的差异程度。

总结

这一讲中，我们从一个有趣的人物性格测试开始，探讨了如何高效率地进行问卷调查。其中主要包含了两个要点：信息熵和信息增益。熵的计算是基于集合内各组元素分布的概率来进行的。而信息增益是集合划分前后整体熵的差值。对某个集合进行划分，都会将其中元素细分到更小的集合，而每个细分的集合纯净度就会提高，整体熵就会下降，其中下降的部分就是信息增益。

了解信息熵和信息增益的定义之后，我们可以用它们来安排测试问题的先后顺序。其核心的思路是，利用信息增益找出区分力最强的测试题。如果一道测试题可以将来自不同分组的元素分隔开来，那么就说它是有区分力的。如果分隔后，每个细分的集合其熵越趋近于 0，那么我们说这个测试题的区分力越强。

思考题

假设一个集合包含了 64 个元素，而每个元素的分类都互不相同，那么这个集合的信息熵是多少？仔细观察一下你所计算的结果，和二进制有没有什么联系？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

27 | 决策树：信息增益、增益比率和基尼指数的运用

你好，我是黄申。

上一节，我通过问卷调查的案例，给你解释了信息熵和信息增益的概念。被测者们每次回答一道问题，就会被细分到不同的集合，每个细分的集合纯净度就会提高，而熵就会下降。在测试结束的时候，如果所有被测者都被分配到了相应的武侠人物名下，那么每个人物分组都是最纯净的，熵值都为 0。于是，测试问卷的过程就转化为“如何将熵从 3.32 下降到 0”的过程。

由于每道问题的区分能力不同，而我们对问题的选择会影响熵下降的幅度。这个幅度就是信息增益。如果问卷题的顺序选择得好，我们可以更快速地完成对用户性格的判定。这一节我们就继续这个话题，看看如何获得一个更简短的问卷设计，把这个核心思想推广到更为普遍的决策树分类算法中。

如何通过信息熵挑选合适的问题？

为了实现一个更简短的问卷，你也许很自然地就想到，每次选择问题的时候，我们可以选择信息增益最高的问题，这样熵值下降得就最快。这的确是个很好的方法。我们来试一试。

我们现在开始选择第一个问题。首先，依次计算“性别”“智商”“情商”“侠义”和“个性”对人物进行划分后的信息增益。我们得到如下结果：

显然，第一步我们会选择“侠义”，之后用户就会被细分为 3 组。

针对第一组，我们继续选择在当前这组中，区分力最强、也是就信息增益最高的问题。根据计算的结果我们应该选择有关“性别”的问题，然后进一步地细分。后续的步骤依次类推，直到所有人物都被分开，对于第二组和第三组我们也进行同样地操作。整个过程稍微有点复杂，为了帮你理解，我把它画成了一个图。

从这个图可以看出来，对于每种人物的判断，我们至多需要问 3 个问题，没有必要问全 5 个问题。比如，对于人物 J 和 C，我们只需要问 2 个问题。假设读者属于 10 种武侠人物的概率是均等的，那么我们就可以利用之前介绍的知识，来计算读者需要回答的问题数量之期望值。每种人物出现的概率是 0.1，8 种人物需要问 3 个问题，2 种人物需要问 2 个问题，那么回答问题数的期望值是 0.8 * 3 + 0.2 * 2 = 2.8（题）。

如果我们每次不选熵值最高的问题，而选择熵值最低的问题呢？

我计算了一下，最差的情况下，我们要问完全部 5 个问题，才能确定被测者所对应的武侠人物。而且问 4 个问题的情况也不少，回答问题数的期望值会在 4 到 5 之间，明显要多于基于最高熵来选择题目的方法。当然，如果测试的目标和问题很多，基于熵的问题选择其运算量就会比较大，我们就可以通过编程来自动化整个过程，最终达到优化问卷设计的目的。

好了，现在我们总结一下，如何才能进行高效的问卷调查。最核心的思想是，根据当前的概率分布，挑选在当前阶段区分能力更强的那些问题。具体的步骤有三个。

第一步，根据分组中的人物类型，为每个集合计算信息熵，并通过全部集合的熵之加权平均，获得整个数据集的熵。注意，一开始集合只有一个，并且包含了所有的武侠人物。

第二步，根据信息增益，计算每个问卷题的区分能力。挑选区分能力最强的题目，并对每个集合进行更细的划分。

第三步，有了新的划分之后，回到第一步，重复第一和第二步，直到没有更多的问卷题，或者所有的人物类型都已经被区分开来。这一步也体现了递归的思想。

其实，上述这个过程就体现了训练决策树（Decision Tree）的基本思想。决策树学习属于归纳推理算法之一，适用于分类问题。在前面介绍朴素贝叶斯的时候，我说过，分类算法主要包括了建立模型和分类新数据两个阶段。决定问卷题出现顺序的这个过程，其实就是建立决策树模型的过程。

你可以看到，整个构建出来的图就是一个树状结构，这也是“决策树”这个名字的由来。而根据用户对每个问题的答案，从决策树的根节点走到叶子节点，最后来判断其属于何种人物类型，这个过程就是分类新数据的过程。

让我们把问卷案例泛化一下，将武侠人物的类型变为机器学习中的训练样本，将问卷中的题目变为机器学习中的特征，那么问卷调查的步骤就可以泛化为决策树构建树的步骤。

第一步，根据集合中的样本分类，为每个集合计算信息熵，并通过全部集合的熵之加权平均，获得整个数据集的熵。注意，一开始集合只有一个，并且包含了所有的样本。

第二步，根据信息增益，计算每个特征的区分能力。挑选区分能力最强的特征，并对每个集合进行更细的划分。

第三步，有了新的划分之后，回到第一步，重复第一步和第二步，直到没有更多的特征，或者所有的样本都已经被分好类。

有点需要注意的是，问卷案例中的每类武侠人物。都只有一个样本，而在泛化的机器学习问题中，每个类型对应了多个样本。也就是说，我们可以有很多个郭靖，而且每个人的属性并不完全一致，但是它们的分类都是“郭靖”。正是因为这个原因，决策树通常都只能把整体的熵降低到一个比较低的值，而无法完全降到 0。这也意味着，训练得到的决策树模型，常常无法完全准确地划分训练样本，只能求到一个近似的解。

几种决策树算法的异同

随着机器学习的快速发展，人们也提出了不少优化版的决策树。采用信息增益来构建决策树的算法被称为ID3（Iterative Dichotomiser 3，迭代二叉树 3 代）。但是这个算法有一个缺点，它一般会优先考虑具有较多取值的特征，因为取值多的特征会有相对较大的信息增益。这是为什么呢？

你仔细观察一下信息熵的定义，就能发现背后的原因。更多的取值会把数据样本划分为更多更小的分组，这样熵就会大幅降低，信息增益就会大幅上升。但是这样构建出来的树，很容易导致机器学习中的过拟合现象，不利于决策树对新数据的预测。为了克服这个问题，人们又提出了一个改进版，C4.5 算法。

这个算法使用信息增益率（Information Gain Ratio）来替代信息增益，作为选择特征的标准，并降低决策树过拟合的程度。信息增益率通过引入一个被称作分裂信息（Split Information）的项来惩罚取值较多的特征，我把相应的公式给你列出来了。

其中，训练数据集 PP 通过属性 TT 的属性值，划分为 nn 个子数据集，|Pi||Pi| 表示第 ii 个子数据集中样本的数量，|P||P| 表示划分之前数据集中样本总数量。 这个公式看上去和熵很类似，其实并不相同。

熵计算的时候考虑的是，集合内数据是否属于同一个类，因此即使集合数量很多，但是集合内的数据如果都是来自相同的分类（或分组），那么熵还是会很低。而这里的分裂信息是不同的，它只考虑子集的数量。如果某个特征取值很多，那么相对应的子集数量就越多，最终分裂信息的值就会越大。正是因为如此，人们可以使用分裂信息来惩罚取值很多的特征。具体的计算公式如下：

其中 Gain(P,T)Gain(P,T) 是数据集 PP 使用特征 TT 之后的信息增益，GainRatio(P,T)GainRatio(P,T) 是数据集 PP 使用特征 TT 之后的信息增益率。

另一种常见的决策树是 CART 算法（Classification and Regression Trees，分类与回归树）。这种算法和 ID3、C4.5 相比，主要有两处不同：

在分类时，CART 不再采用信息增益或信息增益率，而是采用基尼指数（Gini）来选择最好的特征并进行数据的划分；

在 ID3 和 C4.5 决策树中，算法根据特征的属性值划分数据，可能会划分出多个组。而 CART 算法采用了二叉树，每次把数据切成两份，分别进入左子树、右子树。

当然，CART 算法和 ID3、C4.5 也有类似的地方。首先，CART 中每一次迭代都会降低基尼指数，这类似于 ID3、C4.5 降低信息熵的过程。另外，基尼指数描述的也是纯度，与信息熵的含义相似。我们可以用下面这个公式来计算每个集合的纯度。

其中，nn 为集合 PP 中所包含的不同分组（或分类）数量。如果集合 PP 中所包含的不同分组越多，那么这个集合的基尼指数越高，纯度越低。

然后，我们需要计算整个数据集的基尼指数。

其中，mm 为全集使用特征 TT 划分后，所形成的子集数量。PjPj 为第 jj 个集合。

无论是何种决策树算法，来自信息论的几个重要概念：信息熵、信息增益、信息增益率、基尼指数都起到了重要的作用。如果你能很好的学习并运用这些概念，那么决策树这种类型的算法就不难理解了。

总结

通过这两节的介绍，我想你对信息熵、信息增益、基尼指数等信息论的概念，以及基于这些概念的决策树分类算法应该有了一定了解。决策树算法的优势在于，容易理解和实现。此外，对于通过样本训练所得的树结构，其每个结点都是基于某个数据特征的判定，对于我们的阅读和解释来说都是很方便的。

当然，决策树也有不足。之前我已经提到，这类算法受训练样本的影响很大，比较容易过拟合。在预测阶段，如果新的数据和原来的训练样本差异较大，那么分类效果就会比较差。为此人们也提出了一些优化方案，比如剪枝和随机森林。如果感兴趣，你可以自己去研究一下。

思考题

刚刚我提到了，如果每次都选择使得信息增益最小的问题，那么构建出来的答题路径就相对冗长。你可以自己动手计算一下用户要回答问题数的期望。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。如果你有朋友对决策树感兴趣，你可以点击“请朋友读”，把今天的内容分享给他，说不定就帮他解决一个问题。

28 | 熵、信息增益和卡方：如何寻找关键特征？

你好，我是黄申。今天我们来说说特征选择。

我们已经讨论过信息熵和信息增益在决策树算法中的重要作用。其实，它们还可以运用在机器学习的其他领域，比如特征选择。你可能对“特征选择”这个名词不太熟悉，没有关系，我先花点时间，给你介绍一下什么是特征选择，以及机器学习为什么需要这个步骤。

什么是特征选择？

在编程领域中，机器学习已经有了十分广泛的应用，它主要包括监督式学习（Supervised Learning）和非监督式的学习（Unsupervised Learning）。监督式学习，是指通过训练资料学习并建立一个模型，并依此模型推测新的实例，主要包括分类（Classification）和回归（Regression）。

无论是在监督学习还是非监督学习中，我们都可以使用特征选择。不过，我今天要聊的特征选择，会聚焦在监督式学习中的特征处理方法。因此，为了说清楚特征选择是什么，以及为什么要进行这个步骤，我们先来看看监督式机器学习的主要步骤。

机器学习的步骤主要包括数据的准备、特征工程、模型拟合、离线和在线测试。测试过程也许会产生新的数据，用于进一步提升模型。在这些处理中，特征工程是非常重要的一步。

“特征”（Feature），是机器学习非常常用的术语，它其实就是可用于模型拟合的各种数据。前面讲朴素贝叶斯分类时，我解释了如何把现实世界中水果的各类特征转化为计算机所能理解的数据，这个过程其实就是最初级的特征工程。当然，特征工程远不止原始特征到计算机数据的转化，还包括特征选择、缺失值的填补和异常值的去除等等。这其中非常重要的一步就是特征选择。

越来越多的数据类型和维度的出现，会加大机器学习的难度，并影响最终的准确度。针对这种情形，特征选择尝试发掘和预定义任务相关的特征，同时过滤不必要的噪音特征。它主要包括特征子集的产生、搜索和评估。我们可以使用穷举法来找到最优的结果，但是如果特征有 NN 个，那么复杂度会达到 O(2N)O(2N)。所以穷举法并不适合特征数量庞大的问题，比如我们之前讲过的文本分类。

因此，在这个领域诞生了一类基于分类标签的选择方法，它们通过信息论的一些统计度量，看特征和类标签的关联程度有多大。这里我还是使用文本分类的案例，来展示如何基于信息论，来进行特征选择。

利用信息熵进行特征选择

我们之前讲过如何为文本数据提取特征。对于一篇自然语言的文章，我们主要使用词包（Bag of Words）模型和分词，把完整的文章切分成多个单词或词组，而它们就表示了文章的关键属性，也就是用于机器学习的特征。

你会发现有些文本预处理的步骤已经在做特征选择的事情了，比如“停用词”。它会直接过滤一些不影响或基本不影响文章语义的词，这就是在减少噪音特征。不过，我之前也提到了，停用词的使用过于简单粗暴，可能会产生适得其反的效果。例如在进行用户观点分类时，“good”和“bad”这样的停用词反而成为了关键。不仅不能过滤，反而要加大它们的权重。

那么，我们怎么能知道哪些特征是更重要的呢？对于分类问题，我们更关心的是如何正确地把一篇文章划分到正确的分类中。一个好的特征选择，应该可以把那些对分类有价值的信息提取出来，而过滤掉那些对分类没有什么价值的信息。既然如此，我们能不能充分利用分类标签来进行挑选呢？答案是肯定的。前两节，我描述了信息熵和信息增益的工作原理。这里，我就可以使用它们来进行特征选择。

首先，我们来看这个问题，什么是对分类有价值的特征？

如果一个特征，经常只在某个或少数几个分类中出现，而很少在其他分类中出现，那么说明这个特征具有较强的区分力，它的出现很可能预示着整个数据属于某个分类的概率很高或很低。

这个时候，对于一个特征，我们可以看看包含这个特征的数据，是不是只属于少数几个类。举个例子，出现“电影”这个词的文章，经常出现在“娱乐”这个分类中，而很少出现在“军事”“政治”等其他分类中。

是否属于少数几个类这一点，可以使用信息熵来衡量。我用 DfiDfi 来表示所有出现特征 fifi 的数据集合，这个集合一共包含了 nn 个分类 CC，而 cjcj 表示这 nn 个分类中的第 jj 个。然后我们就可以根据 DfiDfi 中分类 CC 的分布，来计算熵。我们用这个公式来计算：

如果熵值很低，说明包含这个特征的数据只出现在少数分类中，对于分类的判断有价值。计算出每个特征所对应的数据集之熵，我们就可以按照熵值由低到高对特征进行排序，挑选出排列靠前的特征。

当然，这个做法只考虑了单个特征出现时，对应数据的分类情况，而并没有考虑整个数据集的分类情况。比如，虽然出现“电影”这个词的文章，经常出现在“娱乐”这个分类中，很少出现在其他分类中，但是可能整个样本数据中，“娱乐”这个分类本来就已经占绝大多数，所以“电影”可能并非一个很有信息含量的特征。

为了改进这一点，我们可以借用决策树中信息增益的概念。我们把单个特征 f 是不是出现作为一个决策条件，将数据集分为 DfiDfi 和 Dfi¯Dfi¯ ，DfiDfi 表示出现了这个特征的数据，而 Dfi¯Dfi¯ 表示没有出现这个特征的数据。那么使用特征 fifi 进行数据划分之后，我们就能得到基于两个新数据集的熵，然后和没有划分之前的熵进行比较，得出信息增益。

如果基于某个特征的划分，所产生的信息增益越大，说明这个特征对于分类的判断越有价值。所以，我们可以为计算基于每个特征的划分，所产生的信息增益，然后按照增益值由高到低对特征进行排序，挑选出排列靠前的特征。

利用卡方检验进行特征选择

在统计学中，我们使用卡方检验来检验两个变量是否相互独立。把它运用到特征选择，我们就可以检验特征与分类这两个变量是否独立。如果两者独立，证明特征和分类没有明显的相关性，特征对于分类来说没有提供足够的信息量。反之，如果两者有较强的相关性，那么特征对于分类来说就是有信息量的，是个好的特征。为了检验独立性，卡方检验考虑了四种情况的概率：P(fi,cj)P(fi,cj) 、P(fi¯,cj¯)P(fi¯,cj¯)、P(fi,cj¯)P(fi,cj¯) 和 P(fi¯,cj)P(fi¯,cj)。

在这四种概率中，P(fi,cj)P(fi,cj) 和 P(fi¯,cj¯)P(fi¯,cj¯) 表示特征 fifi 和分类 cjcj 是正相关的。如果 P(fi,cj)P(fi,cj) 很高，表示特征 fi 的出现意味着属于分类 cjcj 的概率更高；如果 P(fi¯,cj¯)P(fi¯,cj¯) 很高，表示特征 fifi 不出现意味着不属于分类 cjcj 的概率更高。

类似地，P(fi,cj¯)P(fi,cj¯) 和 P(fi¯,cj)P(fi¯,cj) 表示特征 fifi 和分类 cjcj 是负相关的。如果 P(fi,cj¯)P(fi,cj¯) 很高，表示特征 fifi 的出现意味着不属于分类 cjcj 的概率更高；如果 P(fi¯,cj)P(fi¯,cj) 很高，表示特征 fifi 不出现意味着属于分类 cjcj 的概率更高。

如果特征和分类的相关性很高，要么是正向相关值远远大于负向相关值，要么是负向相关值远远大于正向相关值。如果特征和分类相关性很低，那么正向相关值和负向相关的值就会很接近。卡方检验就是利用了正向相关和负向相关的特性。

其中，NN 表示数据的总个数。通过这个公式，你可以看到，如果一个特征和分类的相关性很高，无论是正向相关还是负向相关，那么正向相关和负向相关的差值就很大，最终计算的值就很高。最后，我们就可以按照卡方检验的值由高到低对特征进行排序，挑选出排列靠前的特征。

总结

在之前水果的案例中，可用的特征并不是很多，每种特征都是有价值的。对于文本分类，每种单词或词组都是特征，再加上多元文法，特征的数量会成倍的增加。过多的特征会影响模型分析的速度和准确度。

对于监督式学习而言，我们没有必要进行 O(2N)O(2N) 这种数量级的特征子集搜索，而是直接考虑特征和分类标签直接的关系。这个时候信息论等统计度量就可以帮上忙了，它们可以衡量特征和分类之间的关联程度，从而判断哪些特征对于分类来说更重要。

无论是使用何种统计度量，我们都可以计算相应的数值、排序、并得到排名靠前的若干特征。从文本分类的角度来说，我们只会挑选对分类最有价值的那些单词或词组，而去除其他不重要的那些词。如果特征选择得当，我们既可以减少模型存储的空间，还可以提升分类的准确度。当然，过度的减少特征最终会导致准确度的下降，所以对于不同的数据集要结合实验，要把握一个合理的度。

思考题

在之前介绍决策树的时候，我除了解释信息增益，还阐述了基尼指数的概念。既然信息增益可用于特征选择，那么基尼指数是不是也可以呢？你可以试着写出相应的公式。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

29 | 归一化和标准化：各种特征如何综合才是最合理的？

你好，我是黄申，今天我来说说特征值的变换。

上一节我讲了如何在众多的特征中，选取更有价值的特征，以提升模型的效率。特征选择是特征工程中的重要步骤，但不是全部。今天，我来说说特征工程中的另一块内容，数值变换。也就是说，我们可以使用统计中的数据分布，对连续型的数值特征进行转换，让多个特征的结合更有效。具体怎么理解呢？我下面就来详细讲一讲。

为什么需要特征变换？

我们在很多机器学习算法中都会使用特征变换。我使用其中一种算法线性回归作为例子，来解释为什么要进行数值型特征的变换。

我们之前介绍的监督式学习会根据某个样本的一系列特征，最后判定它应该属于哪个分类，并给出一个离散的分类标签。除此之外，还有一类监督式学习算法，会根据一系列的特征输入，给出连续的预测值。

举个例子，房地产市场可以根据销售的历史数据，预估待售楼盘在未来的销售情况。如果只是预估卖得“好”还是“不好”，那么这个粒度明显就太粗了。如果我们能做到预估这些房屋的售价，那么这个事情就变得有价值了。想要达成这个预测目的的过程，就需要最基本的因变量连续回归分析。

因变量连续回归的训练和预测，和分类的相应流程大体类似，不过具体采用的技术有一些不同。它采用的是研究一个或多个随机变量 y1y1，y2y2，…，yiyi 与另一些变量 x1x1，x2x2，…，xkxk 之间关系的统计方法，又称多重回归分析。

我们将 y1y1，y2y2，…，yiyi 称为因变量，x1x1，x2x2，…，xkxk 称为自变量。通常情况下，因变量的值可以分解为两部分，一部分是受自变量影响的，即表示为自变量相关的函数，其中函数形式已知，可能是线性也可能是非线性函数，但包含一些未知参数；另一部分是由于其他未被考虑的因素和随机性的影响，即随机误差。

如果因变量和自变量为线性关系时，就称为线性回归模型；如果因变量和自变量为非线性关系，则称为非线性回归分析模型。今天我们要说的是回归中常用的多元线性回归，它的基本形式是：

其中，x1x1，x2x2，…，xnxn 是自变量，yy 是因变量，εε 是随机误差，通常假定随机误差的均值为 0。而 w0 是截距，w1w1，w_{2w_{2，…，wnwn 是每个自变量的系数，表示每个自变量对最终结果的影响是正面还是负面，以及影响的程度。如果某个系数大于 0，表示对应的自变量对结果是正面影响，这个自变量越大，结果就越大。否则就是负面影响，这个自变量越大，结果就越小。而系数的绝对值表示了影响程度的大小，如果绝对值趋于 0，表示基本没有影响。

线性回归也是统计概率中常用的算法。不过它的实现通常会涉及很多线性代数的知识，所以下一个模块的时候，我会再详细介绍这个算法。这一节，你只需要知道线性回归所要达到的目标，以及怎么使用它就可以了。

线性回归和其他算法相比，有很强的可解释性。我们可以通过回归后为每个自变量确定的系数，来判哪些自变量对最终的因变量影响更大。可是，在正式开始线性回归分析之前，还有一个问题，那就是不同字段的数据没有可比性。

比如，房屋的面积和建造的年份，它们分别代表了不同的含义，也有不一样的取值范围。在线性回归中，如果直接将没有可比性的数字型特征线性加和，那么模型最终的解释肯定会受影响。

这里我用 Boston Housing 数据集对房价数据进行回归分析，这个数据来自 70 年代美国波斯顿周边地区的房价，是用于机器学习的经典数据集，你可以在 Kaggle 的网站（https://www.kaggle.com/c/boston-housing#description）下载到它。这个数据一共有 14 个特征或者说自变量，而有 1 个目标值或者说因变量。

这里，我只使用其中的 train.csv。使用一小段 Python 代码，我们就能很快的得到一个线性回归的结果。

import pandas as pd

from sklearn.linear_model import LinearRegression

df = pd.read_csv("/Users/shenhuang/Data/boston-housing/train.csv")       # 读取 Boston Housing 中的 train.csv

df_features = df.drop(['medv'], axis=1)     #Dataframe 中除了最后一列，其余列都是特征，或者说自变量

df_targets = df['medv']         #Dataframe 最后一列是目标变量，或者说因变量

regression = LinearRegression().fit(df_features, df_targets)        # 使用特征和目标数据，拟合线性回归模型

print(regression.score(df_features, df_targets))    # 拟合程度的好坏

print(regression.coef_)            # 各个特征所对应的系

使用上述代码之前，请确保你已经按照了 Python 中的 sklearn 和 pandas 包。运行这段代码，你可以得到如下的结果：

0.735578647853312

[-4.54789253e-03 -5.17062363e-02  4.93344687e-02  5.34084254e-02

  3.78011391e+00 -1.54106687e+01  3.87910457e+00 -9.51042267e-03

 -1.60411361e+00  3.61780090e-01 -1.14966409e-02 -8.48538613e-01

  1.18853164e-02 -6.01842329e-01]

因为不是所有的数据都是可以使用线性回归模型来表示，所以我们需要使用 regression.score 函数，来看拟合的程度。如果完美拟合，这个函数就会输出 1；如果拟合效果很差，这个函数的输出可能就是一个负数。

这里 regression.score 函数的输出大约为 0.74，接近于 1.0。它表示这个数据集使用线性模型拟合的效果还是不错的。如果你还是不理解，不用担心，具体的我们会在线性代数部分详细解答。这里你可以简单的理解为，0.74 仅仅表示我们可以使用线性回归来解决 Boston Housing 这个问题。

这里，你更需要关注的是每个特征所对应的权重，因为它们可以帮助我们解释哪个特征对最终房价的中位值有更大的影响。参看 train.csv 中的数据，你会发现最主要的两个正相关特征是 nox（系数为 3.78011391e+00）和 age（系数为 3.87910457e+00）。其中 nox 表示空气污染浓度，age 表示老房子占比，也就是说空气污染越多、房龄越高，房价中位数越高，这好像不太合乎常理。我们再来看看最主要的负相关特征 rm（系数为 -1.54106687e+01），也就是房间数量。房间数量越多，房价中位数越低，也不合理。

造成这些现象最重要的原因是，不同类型的特征值没有转换到同一个可比较的范围内，所以线性回归后所得到的系数不具有可比性，因此我们无法直接对这些权重加以解释。

两种常见的特征变换方法

该怎么解决这个问题呢？我们就需要对特征值进行转换。今天我介绍两种最常见的变换方法：归一化和标准化。

归一化

我们先来看最常用的方法，归一化（Normalization）。它其实就是获取原始数据的最大值和最小值，然后把原始值线性变换到 [0,1] 之间，具体的变换函数为：

其中 xx 是原始值，maxmax 为样本数据的最大值，minmin 为样本数据的最小值，x′x′ 是变换后的值。这种方法有个不足最大值与最小值非常容易受噪音数据的影响。

这里面需要注意的是，“归一化”这个词在不同的领域的含义可能不同。这里我们特指基于最大和最小值的变换。

接下来，我们来看看在 Python 中如何实现归一化，以及归一化对回归后系数的影响。

from sklearn.preprocessing import StandardScaler

from sklearn.preprocessing import MinMaxScaler

minMaxScaler = MinMaxScaler()       # 基于 min 和 max 值的归一化

df_normalized = minMaxScaler.fit_transform(df)  # 对原始数据进行归一化，包括特征值和目标变量

df_features_normalized = df_normalized[:, 0:-1] # 获取归一化之后的特征值

df_targets_normalized = df_normalized[:, -1]    # 获取归一化之后的目标值

# 再次进行线性回归

regression_normalized = LinearRegression().fit(df_features_normalized, df_targets_normalized)

print(regression_normalized.score(df_features_normalized, df_targets_normalized))

print(regression_normalized.coef

其中，df 还是之前加载的 dataframe。运行这段代码，你可以得到如下结果：

0.7355786478533118

[-0.05103746 -0.08448544  0.10963215  0.03204506  0.08400253 -0.16643522

  0.4451488  -0.01986622 -0.34152292  0.18490982 -0.13361651 -0.16216516

  0.10390408 -0.48468369]

你可以看到，表示拟合程度的分数没有变，但是每个特征对应的系数或者说权重，发生了比较大的变化。仔细观察一下，你会发现，这次最主要的正相关特征是 age（0.4451488）和 tax（0.18490982），也就是老房子占比和房产税的税率，其中至少房产税的税率是比较合理的，因为高房价的地区普遍税率也比较高。而最主要的负相关特征是 rad（-0.34152292）和 lstat（-0.48468369），rad 表示高速交通的便利程度，它的值越大表示离高速越远，房价中位数越低。而 lstat 表示低收入人群的占比，这个值越大房价中位数越低，这两点都是合理的。

标准化

另一种常见的方法是基于正态分布的 z 分数（z-score）标准化（Standardization）。该方法假设数据呈现标准正态分布。

什么是标准正态分布呢？我们之前介绍过，正态分布是连续随机变量概率分布的一种。在现实生活中，大量随机现象的数据分布都近似于正态分布。

我这里再快速回顾一下这种分布的特点。

它以经过平均数的垂线为轴，左右对称展开，中间点最高，然后逐渐向两侧下降，分布曲线和 x 轴组成的面积为 1，表示不同事件出现的概率和为 1。平均数和标准差是正态分布的关键参数，它们会决定分布的具体形态。而标准正态分布是正态分布的一种，平均数为 0，标准差为 1。

理解了什么是标准正态分布，我们来看看 z 分数这个方法是如何运作的。实际上，z 分数标准化是利用标准正态分布的特点，计算一个给定分数距离平均数有多少个标准差。它的具体转换公式如下：

其中 xx 为原始值，uu 为均值，σσ 为标准差，x′x′ 是变换后的值。

经过 z 分数的转换，高于平均数的分数会得到一个正的标准分，而低于平均数的分数会得到一个负的标准分数。更重要的是，转换后的数据是符合标准正态分布的。你通过理论或者具体的数值来推导一下，就会发现转换后的数据均值为 0，标准差为 1。

和归一化相比，z 分数这样的标准化不容易受到噪音数据的影响，并且保留了各维特征对目标函数的影响权重。

下面我们来看看，在 Python 中如何实现标准化，以及标准化对回归后系数的影响。

standardScaler = StandardScaler()    # 基于 Z 分数的标准化

standardScaler.fit(df)

df_standardized = standardScaler.transform(df)  # 对原始数据进行标准化，包括特征值和目标变量

df_features_standardized = df_standardized[:, 0:-1] # 获取标准化之后的特征值

df_targets_standardized = df_standardized[:, -1]    # 获取标准化之后的特征值

# 再次进行线性回归

regression_standardized = LinearRegression().fit(df_features_standardized, df_targets_standardized)

print(regression_standardized.score(df_features_standardized, df_targets_standardized))

print(regression_standardized.coef

其中，df 还是之前加载的 dataframe。运行这段代码，这次你得到的结果如下：

0.7355786478533118

[-0.07330367 -0.04144107  0.12194378  0.04074345  0.09805446 -0.19311408

  0.29767387 -0.02916672 -0.34642803  0.34477088 -0.21410757 -0.19904179

  0.11218058 -0.46369483]

表示拟合程度的分数任然没有变。再次对比不同特征所对应的系数，你会发现这次最主要的正相关特征还是 age（0.29767387）和 tax（0.34477088），但是相比之前，明显房产税的税率占了更高的权重，更加合理。而最主要的负相关特征还是 rad（-0.34152292）和 lstat（-0.48468369），这两点都是合理的。

总结

今天我介绍了在机器学习领域里，如何使用统计里的数据分布来进行特征值的转换。这里，我帮你梳理了几个要点，便于你的记忆。

第一点，为什么有时候需要转换特征值？因为不同类型的特征取值范围不同，分布也不同，相互之间没有可比性。因此在线性回归中，通过这些原始值分析得到的权重，并不能代表每个特征实际的重要性。

第二点，如何使用归一化进行特征值转换？这里的归一化是指使用特征取值范围中的最大值和最小值，把原始值转换为 0 到 1 之间的值。这样处理的好处在于简单易行，便于理解。不过，它的缺点也很明显，由于只考虑了最大最小值，因此很容易受到异常数据点的干扰。

第三点，如何使用标准化进行转换？经过标准化处理之后，每种特征的取值都会变成一个标准正态分布，以 0 为均值，1 为标准差。和归一化相比，标准化使用了数据是正态分布的假设，不容易受到过大或过小值的干扰。

掌握了上面几个点，你就能很好的理解这一节的内容了。在实际的数据分析或者是统计建模的项目中，对于数值型的特征要保持敏感，看到它们的时候都要考虑一下，是不是需要进行特征值的转换？这样就能避免由于多种特征的不同分布而产生的误导性结论。

思考题

今天我们使用了三种方式处理 Boston Housing 的数据，并训练出三种线性回归的模型。请尝试使用这些模型的 predict 方法，对 test.csv 数据进行预测，看看每种模型的预测效果。（提示：如果你在 train.csv 上使用了某种特征值的转换，那么相应的 test.csv 数据也需要经过同样的处理。）

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你也可以把今天的内容分享给你的好友，和他一起在实战中重新理解数学。

30 | 统计意义（上）：如何通过显著性检验，判断你的A/B测试结果是不是巧合？

你好，我是黄申，今天我们来聊聊统计意义和显著性检验。

之前我们已经讨论了几种不同的机器学习算法，包括朴素贝叶斯分类、概率语言模型、决策树等等。不同的方法和算法会产生不同的效果。在很多实际应用中，我们希望能够量化这种效果，并依据相关的数据进行决策。

为了使这种量化尽可能准确、客观，现在的互联网公司通常是根据用户的在线行为来评估算法，并比较同类算法的表现，以此来选择相应的算法。在线测试有一个很大的挑战，那就是如何排除非测试因素的干扰。

从图中可以看出，自 2016 年 1 月 12 日开始，转化率曲线的趋势发生了明显的变化。假如说这天恰好上线了一个新版的技术方案 A，那么转化率上涨一定是新方案导致的吗？不一定吧？很有可能，1 月 12 日有个大型的促销，使得价格有大幅下降，或者有个和大型企业的合作引入了很多优质顾客等，原因有非常多。如果我们取消 12 日上线的技术方案 A，然后用虚线表示在这种情况下的转化率曲线，这个时候得到了另一张图。

从图中可以发现，不用方案 A，反而获得了更好的转化率表现，所以，简单地使用在线测试的结果往往会导致错误的结论，我们需要一个更健壮的测试方法，A/B 测试。

A/B 测试，简单来说，就是为同一个目标制定两个或多个方案，让一部分用户使用 A 方案，另一部分用户使用 B 方案，记录下每个部分用户的使用情况，看哪个方案产生的结果更好。这也意味着，通过 A/B 测试的方式，我们可以拿到使用多个不同方法之后所产生的多组结果，用于对比。

问题来了，假设我们手头上有几组不同的结果，每组对应一个方案，包含了最近 30 天以来每天的转化率，如何判断哪个方案的效果更好呢？你可能会想，对每一组的 30 个数值取平均数，看看谁的均值大不就好了？但是，这真的就够了吗？

假设有两组结果需要比较，每一组都有 5 个数据，而且这两组都符合正态分布。我用一张图画一下这两个正态分布之间的关系。

从这张图可以看出，左边的正态分布 A 均值μ1 比较小，右侧的正态分布 B 均值μ2 比较大。可是，如果我们无法观测到 A 和 B 这两个分布的全部，而只根据这两个分布的采样数据来做判断，会发生什么情况？我们很有可能会得出错误的结论。

比如说，在这张图的采样中，红色的点表示 B 的采样，它们都是来自 B 分布的左侧，而蓝色的点表示 A 的采样，它们都是来自 A 分布的右侧。如果我们仅仅根据这两组采样数据的均值来判断，很可能会得出“B 分布的均值小于 A 分布均值”这样的错误结论。

A/B 测试面临的就是这样的问题。我们所得到的在线测试结果，实际上只是一种采样。所以我们不能简单地根据每个组的平均值，来判断哪个组更优。那有没有更科学的办法呢？在统计学中，有一套成熟的系统和对应的方法，今天我们就来讲讲这种方法。

为了让你能够充分理解这个，我先介绍几个基本概念，显著性差异、统计假设检验和显著性检验、以及 P 值。

显著性差异

从刚刚那两张正态分布图，我们可以分析得出，两组数据之间的差异可能由两个原因引起。

第一，两个分布之间的差异。假设 A 分布的均值小于 B 分布，而两者的方差一致，那么 A 分布随机产生的数据有更高的概率小于 B 分布随机产生的数据。第二，采样引起的差异，也就是说采样数据不能完全体现整体的数据分布。我在之前的图中，用来自 A、B 两组的 10 个数据展示了采样所导致的误差。

如果差异是第一个原因导致的，在统计学中我们就认为这两组“有显著性差异”。如果差异是第二种原因导致的，我们就认为这两组“无显著性差异”。可以看出来，显著性差异（Significant Difference），其实就是研究多组数据之间的差异，是由于不同的数据分布导致的，还是由于采样的误差导致的。通常，我们也把“具有显著性差异”，称为“差异具有统计意义”或者“差异具有显著性”。

这里你还需要注意“差异具有显著性”和“具有显著差异”的区别。如前所说，“差异具有显著性“表示不同的组很可能来自不同的数据分布，也就是说多个组的数据来自同一分布的可能性非常小。而“具有显著差异”，是指差异的幅度很大，比如相差 100 倍。

不过，差异的显著性和显著差异没有必然联系。举两个例子，比如说，两个不同的数据分布，它们的均值分别是 1 和 1.2，这两个均值相差的绝对值很小，也就是没有显著差异，但是由于它们源自不同的数据分布，所以差异是具有显著性的。再比如说，来自同一个数据分布的两个采样，它们的均值分别是 1 和 100，具有显著的差异，但是差异没有显著性。

统计假设检验和显著性检验

统计假设检验是指事先对随机变量的参数或总体分布做出一个假设，然后利用样本信息来判断这个假设是否合理。在统计学上，我们称这种假设为虚无假设（Null Hypothesis），也叫原假设或零假设，通常记作H0。而和虚无假设对立的假设，我们称为对立假设（Alternative Hypothesis），通常记作H1。也就是说，如果证明虚无假设不成立，那么就可以推出对立假设成立。

统计假设检验的具体步骤是，先认为原假设成立，计算其会导致什么结果。若在单次实验中产生了小概率的事件，则拒绝原假设 H0，并接受对立假设 H1。若不会产生小概率的事件，则不能拒绝原假设 H0，从而接受它。因此，统计学中的假设是否成立，并不像逻辑数学中的绝对“真”或“假”，而是需要从概率的角度出发来看。

那么，问题来了，多少才算是“小概率”呢？按照业界的约定俗成，通常我们把概率不超过 0.05 的事件称为“小概率事件”。当然，根据具体的应用，偶尔也会取 0.1 或 0.01 等。在假设检验中，我们把这个概率记为α，并称它为显著性水平。

显著性检验是统计假设检验的一种，顾名思义，它可以帮助我们判断多组数据之间的差异，是采样导致的“偶然”，还是由于不同的数据分布导致的“必然“。当然，这里的“偶然”和“必然”都是相对的，和显著性水平α有关。显著性检验的假设是，多个数据分布之间没有差异。如果样本发生的概率小于显著性水平α，证明小概率事件发生了，所以拒绝原假设，也就是说认为多个分布之间有差异。否则呢，接受原假设，认为多个分布之间没有差异。换句话说，显著性水平α即为拒绝原假设的标准。

P 值

既然已经定义了显著性检验和显著性水平，那么我们如何为多组数据计算它们之间差异的显著性呢？我们可以使用 P 值（P-value）。P 值中的 P 代表 Probability，就是当 H0 假设为真时，样本出现的概率，或者换句话说，其实就是我们所观测到的样本数据符合原假设 H0 的可能性有多大。

如果 P 值很小，说明观测值与假设 H0 的期望值有很大的偏离，H0 发生的概率很小，我们有理由拒绝原假设，并接受对立假设。P 值越小，表明结果越显著，我们越有信心拒绝原假设。反之，说明观测值与假设 H0 的期望值很接近，我们没有理由拒绝 H0。

在显著性检验中，原假设认为多个分组内的数据来自同一个数据分布，如果 P 值足够小，我们就可以拒绝原假设，认为多个分组内的数据来自不同的数据分布，它们之间存在显著性的差异。所以说，只要能计算出 P 值，我们就能把 P 值和显著性水平α进行比较，从而决定是否接受原假设。

总结

今天我从互联网公司常见的 A/B 测试实验入手，给你讲解了一个更科学的方法来比较不同算法的效果，它就是统计学里的差异显著性检验。这个方法包含了一些你平时可能不太接触的概念，你首先需要理解显著性差异、统计假设检验和 P 值。其中最为重要的就是显著性差异的概念，因为这是差异显著性检验区别于简单的平均值方法的关键。

为了便于你的记忆，我这里再用一个形象的比喻来带你复习一遍。

儿子考了 90 分，我问他：“你比班上平均分高多少？”如果他回答：“我不太确定，我只看到了周围几个人的分数，我猜大概高出了 10 分吧”，那么说明他对“自己分数比平均分高出 10 分”这个假设信心不足，结论有较大的概率是错误的，所以即使可能高了 10 分，我也高兴不起来。

如果他回答：“老师说了，班级平均分是 88 分，我比平均分高出了 2 分”，那我就很开心了，因为老师掌握了全局的信息，她说的话让儿子对“自己分数比平均分高出 2 分”的假设是非常有信心的。即使只高出了 2 分，但是结论有很大的概率是正确的。

理解了概念之后，我们就要进入实战环节了。其实显著性检验的具体方法有很多，例如方差分析（F 检验）、t 检验、卡方检验等等。不同的方法计算 P 值的方法也不同，在下一节，我会用 A/B 测试的案例来详细解释。

思考题

在对比两组数据的差异时，如果不断增加采样次数，也就是样本的数量，使用平均值和使用显著性检验这两者的结论，会不会逐渐变得一致？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

31 | 统计意义（下）：如何通过显著性检验，判断你的A/B测试结果是不是巧合？

你好，我是黄申，今天我们接着来聊显著性检验。

上一节，我介绍了差异显著性检验的概念，它是指从统计的角度来说，差异的产生有多大的概率、是不是足够可信。这点和数值差异的大小是有区别的。既然我们不能通过差异的大小来推断差异是否可信，那么有没有什么方法，可以帮助我们检验不同数据分布之间，是否存在显著差异呢？具体的方法有不少，比如方差分析（F 检验）、t 检验、卡方检验等等。我这里以方差分析为例，来讲这个方法是如何帮助我们解决 AB 测试中的问题。

方差分析

方差分析（Analysis of Variance, ANOVA），也叫F 检验。这种方法可以检验两组或者多组样本的均值是否具备显著性差异。它有四个前提假设，分别是：

随机性：样本是随机采样的；

独立性：来自不同组的样本是相互独立的；

正态分布性：组内样本都来自一个正态分布；

方差齐性：不同组的方差相等或相近。

根据第三个前提，我们假设数据是正态分布，那么分布就有两个参数，一个是平均数，一个是方差。如果我们仅仅知道两个分组的平均值，但并不知道它们的方差相差多大，那么我们所得出的两个分布是否有显著差异的结论就不可靠了。

为了突出重点，我们先假设咱们的数据都符合上述四个前提，然后我来详细讲解一下方差分析的主要思想。最后，我会通过 Python 语言来验证各个假设和最终的 F 检验结果。

这里，我使用之前提到的 A/B 测试案例，通过方差分析来检验多种算法所产生的用户转化率有没有显著性差异。我们把“转化率”称为“因变量”，把“算法”称为“因素”。这里我们只有算法一个因素，所以所进行的方差分析是单因素方差分析。在方差分析中，因素的取值是离散型的，我们称不同的算法取值为“水平”。如果我们比较算法 a 和 b，那么 a 和 b 就是算法这个因素的两个水平。

我们假设只有两种算法 a 和 b 参与了 A/B 测试。为了检验这些算法导致的转化率，是不是存在显著的差异，我们进行一个为期 10 天的测试，每天都为每种算法获取一个转化率。具体的数据我列在下面这张表格中。

我使用 YijYij 来表示这种表格中的数据，ii 表示第 ii 次采样（或第 ii 天），jj 表示第 jj 种水平（或第 jj 种算法）。以上面这张表格为例，Y51=0.34Y51=0.34。

如果我们把每种算法导致的转化率看作一个数据分布，那么方差分析要解决的问题就是：这两个转化率分布的均值，是不是相等。如果我把两种数据分布的均值记做μ1 和μ2，那么原假设 H0 就是μ1=μ2。而对立假设 H1 就是μ1 <> μ2。

之前我们提到，差异是不是显著性，关键要看这个差异是采样的偶然性引起的，还是分布本身引起的。方差分析的核心思想也是围绕这个展开的，因此它计算了三个数值：SST、SSM 和 SSE。SST 表示所有采样数据的因变量方差（Total Sum of Squares），我把它的计算公式列在这里。

在这个公式中， YijYij 如前所说，表示了第 ii 天第 jj 种算法所导致的转化率。而 Y¯¯¯¯¯¯¯¯¯Y¯¯ 表示了 10 天里，2 种算法全部 20 个数据的平均值。SSM 表示数据分布所引起的方差，我们称它为模型平方和（Sum Of Squares for Model），它的计算公式如下：

在这个公式中，njnj 为水平 jj 下的观测数量，在我们的案例中为 10。Y¯¯¯¯jY¯j 为第 jj 个水平的平均值，在案例中为算法 a 或算法 b 在这 10 天的平均值。Y¯¯¯¯j−Y¯¯¯¯¯¯¯¯¯Y¯j−Y¯¯ 表示的是某个算法的采样均值和所有采样均值之间的差异，njnj 是相应的权重。我们这里的两个算法都被测试了 10 天，所以权重相同。根据我们的案例，SSM 是 0.00018。SSE 表示采样引起的方差，我们称它为误差平方和（Sum of Squaress for Error）。它的计算公式如下：

根据我们的案例，SSE 是 0.01652。我们刚刚介绍的三个统计量，SST、SSM 和 SSE 这三者的关系其实是这样的：

你可以把这三者的公式代入，自己证明一下等式是否成立。由此可以看出，SST 是由 SSM 和 SSE 构成的。如果在 SST 中，SSM 的占比更大，那么说明因素对因变量的差异具有显著的影响；如果 SSE 的占比更大，那么说明采样误差对因变量的差异具有更显著的影响。我们使用这两部分的比例来衡量显著性，并把这个比例称为 F 值。具体公式如下：

在这个公式中，s 是水平的个数，n 为所有样本的总数量，s-1 为分布的自由度，n-s 为误差的自由度。你可能对自由度这个概念比较陌生，这里我稍微解释一下。

自由度（degree of freedom），英文缩写是 df，它是指采样中能够自由变化的数据个数。对于一组包含 n 个数据的采样来说，如果方差是一个固定值，那么只有 n-1 个数据可以自由变化，最后一个数的取值是给定的方差和其他 n-1 个数据决定的，而不由它自己随意变化，所以自由度就是 n-1。这也是为什么在计算一组数的方差时，我们在下面这个公式中使用的除数是 n-1，而不是 n。

回到方差分析，对于 SSM 来说，如果 SSM 是固定的，那么对于 s 个水平来说，只能有 s-1 个组数据自由变化，而最后一组数据必须固定，所以对应于 SSM 的自由度为 s-1。对于 SSE 来说，如果 SSE 是固定的，那么对于 n 个采样、s 个水平数据来说，只有 n-s 个数据是可以自由变化的。因为每个水平中，都要有一个数据需要保证该组的平均值 Y¯¯¯¯jY¯j 而无法自由变化。

在我们的案例中，s 为不同算法的个数，也就是水平的个数 s 为 2，采样数据的个数 n 为 20，所以分布的自由度为 2-1=1，误差的自由度为 20-2=18。

在我们的案例中，F=(0.00018/(2-1))/(0.01652/(20-2))=0.196125908。有了 F 值，我们需要根据 F 检验值的临界表来查找对应的 P 值。我列出了这张表的常见内容，你可以看看。

通过这张表以及 n 和 m 的值，我们可以找到，在显著性水平α为 0.05 的时候，F 值的临界值。如果大于这个临界值，那么 F 检验的 P 值就会小于显著性水平α，证明差异具有显著性。

在咱们的案例中，n=20，m=s-1=1，所以对应的 F 值为 4.414。而我们计算得到的 F 值为 0.196，远远小于 4.414，因此说明差异没有显著性。虽然算法 a 所导致的平均转化率要比算法 b 的相对高出约 2%（要注意，2% 的相对提升在转化率中已经算很高了），但是由于差异没有显著性，所以这个提升的偶然性很大，并不意味着算法 a 比算法 b 更好。

如果需要，你可以在网上相关的统计资料里查找到完整的 F 检验临界值表。

使用 Python 代码进行验证

除了手动的计算，我们还可以用一些 Python 的代码来验证手动计算是不是准确。

首先，我们要确保自己安装了 Python 的扩展包 statsmodels。如果没有安装，你可以在命令行中输入下面这行：

pip install -U statsmodels

我们可以把下列数据输入一个 oneway.csv 文件。

algo,ratio

a,0.29

a,0.36

a,0.32

a,0.29

a,0.34

a,0.24

a,0.27

a,0.29

a,0.31

a,0.27

b,0.29

b,0.33

b,0.31

b,0.30

b,0.31

b,0.26

b,0.25

b,0.30

b,0.28

b,0.29

安装完了 statsmodels，并建立了数据文件 oneway.csv，我们就可以运行下面这段 Python 代码来进行 F 检验了。

import pandas as pd

from statsmodels.formula.api import ols

from statsmodels.stats.anova import anova_lm

import scipy.stats as ss

# 读取数据，d1 对应于算法 a，d2 对应于算法 b

df = pd.read_csv("/Users/shenhuang/Data/oneway.csv") # 设置为你自己的文件路径

d1 = df[df['algo'] == 'a']['ratio']

d2 = df[df['algo'] == 'b']['ratio']

# 检测两个水平的正态性

print(ss.normaltest(d1))

print(ss.normaltest(d2))

# 检测两个水平的方差齐性

args = [d1, d2]

print(ss.levene(*args))

# F 检验的第一种方法

print(ss.f_oneway(*args))

# F 检验的第二种方法

model = ols('ratio ~ algo', df).fit()

anovat = anova_lm(model)

print(ano

我们假设用于 A/B 测试的两个算法是相互独立且随机的，所以这里只检测了正态分布性和方差齐性。

其中，ss.normaltest 分别测试了两个水平的正态分布性，两次结果如下：

NormaltestResult(statistic=0.16280747339563784, pvalue=0.9218214431590781)

NormaltestResult(statistic=0.4189199849120419, pvalue=0.8110220857858036)

ss.normaltest 的原假设是数据符合正态分布，两次检验 P 值都是远远大于 0.05 的，所以原假设成立，这两者都符合正态分布。

而 ss.levene 分析了两者的方差齐性，同样 P 值都是远远大于 0.05，因此符合方差齐的前提。

LeveneResult(statistic=0.7944827586206901, pvalue=0.38450823419725666)

ss.f_oneway 和 anova_lm 都可以进行 F 检验。ss.f_oneway 给出的结果比较简洁。

F_onewayResult(statistic=0.19612590799031476, pvalue=0.663142430745588)

而 anova_lm 提供了更多的信息，但是两种 F 检验函数都证明了我们之前的手动推算结果是正确的。

            df   sum_sq   mean_sq         F    PR(>F)

algo       1.0  0.00018  0.000180  0.196126  0.663142

Residual  18.0  0.01652  0.000918       NaN       NaN

总结

方差分析可以帮助我们检测差异的显著性，它分析的内容是受一个或多个因素影响的因变量在不同水平分组的差异。不过单因素的方差分析要求因变量属于正态分布总体，并具有方差齐性。如果因变量的分布明显的是非正态，或者方差的差异很显著，那么我们就不能直接使用这种方法。对于方差不齐的情况，我们可以选择适当的函数，例如对数、倒数等等，对原始数据进行转换，直到方差齐性变得显著，或者剔除明显属于“均值±标准差”之外的数据。

当然，对于非正态分布的数据，我们也可以使用非参数的分析。非参数检验是在总体的方差知道很少的情况下，利用样本数据对总体分布形态等进行推断的方法。名字中的“非参数”的由来，就是因为这种检验方法在推断过程中不涉及有关总体分布的参数，而只是进行分布位置、分布形状之间的比较，因此不受总体分布的限定，适用范围比较广。常见的非参数检验包括二项分布检验、K-S 检验、卡方检验等等。

思考题

请尝试使用 Python 语言实现你自己的方差分析函数，然后通过测试数据来比较你实现的函数和 Python 扩展包里的函数（例如 statsmodels.stats.anova.anova_lm 或 scipy.stats.f_oneway）。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

32 | 概率统计篇答疑和总结：为什么会有欠拟合和过拟合？

你好，我是黄申。

在概率统计这个模块中，我们讲了很多监督式机器学习相关的概念。你可能对朴素贝叶斯、决策树、线性回归这类监督式算法中的一些概念还是不太清楚。比如说，为什么要使用大量的文档集合或者语料库来训练一个朴素贝叶斯模型呢？这个过程最后得到的结果是什么？为什么训练后的结果可以用于预测新的数据？这里面其实涉及了很多模型拟合的知识。

为了帮助你更好地理解这些内容，今天我就来说说监督式学习中几个很重要的概念：拟合、欠拟合和过拟合，以及如何处理欠拟合和过拟合。

拟合、欠拟合和过拟合

每种学习模型都有自己的假设和参数。虽然朴素贝叶斯和决策树都属于分类算法，但是它们各自的假设和参数都不相同。朴素贝叶斯的假设是贝叶斯定理和变量之间的独立性，而决策树的假设是集合的纯净程度或者混乱程度。我们这里所说的参数，是指根据模型假设和训练样本推导出来的数据，例如朴素贝叶斯中的参数是各种先验概率和条件概率，而决策树的参数是各个树结点以及结点上的决策条件。

了解了什么是模型的假设和参数，我们来看看什么是模型的拟合（Model Fitting）。在监督式学习中，我们经常提到“训练一个模型”，其实更学术的说法应该是“拟合一个模型”。

拟合模型其实就是指通过模型的假设和训练样本，推导出具体参数的过程。有了这些参数，我们就能对新的数据进行预测。这样说有些抽象，我画了张一元回归的图来帮助你理解。假设我们的数据点分布在一个二维空间。

其中黑色的点表示训练数据所对应的点，x 轴表示唯一的自变量，y 轴表示因变量。根据这些训练数据，拟合回归模型之后，所得到的模型结果是一条黑色的曲线。

有了这条曲线，我们就能根据测试数据的 x 轴取值（如图中的 x’）来获取 y 轴的取值（如图中的 y’），也就是根据自变量的值来获取因变量的值，达到预测的效果。这种情况就是适度拟合（right fitting）。

可是，有的时候拟合得到的模型过于简单，和训练样本之间的误差非常大，这种情况就是欠拟合（Under Fitting）。比如下面这根黑色的曲线，和第一根曲线相比，它离数据点的距离更大。这种拟合模型和训练样本之间的差异，我们就称为偏差（Bias）。

欠拟合说明模型还不能很好地表示训练样本，所以在测试样本上的表现通常也不好。例如图中预测的值 y’’和测试数据 x’对应的真实值 y’相差很大。

相对于欠拟合，另一种情况是，拟合得到的模型非常精细和复杂，和训练样本之间的误差非常小，我们称这种情况为过拟合（Over Fitting）。比如下面这根黑色的曲线，和第一根曲线相比，离数据点的距离更近，也就是说偏差更小。

初学者通常都会觉得过拟合很好，其实并不是这样。过拟合的模型虽然在训练样本中表现得非常优越，但是在测试样本中可能表现不理想。为什么会这样呢？这主要是因为，有的时候，训练样本和测试样本不太一致。

比如，用于训练的数据都是苹果和甜橙，但是用于测试的数据都是西瓜。在上图中，测试数据 x’所对应的 y 值应该是 y’，而不是预测的 y’’。这种训练样本和测试样本之间存在的差异，我们称为方差（Variance）。在过拟合的时候，我们认为模型缺乏泛化的能力，无法很好地处理新的数据。

类似地，我以二维空间里的分类为例，展示了适度拟合、欠拟合和过度拟合的情况。仍然假设训练数据的点分布在一个二维空间，我们需要拟合出一个用于区分两个类的分界线。我分别用三张图展示了这三种情况下的分界线。

首先，第一张是适度拟合的情况。

这张图中，蓝色的点表示分类 1 的训练数据点，红色的点表示分类 2 的训练数据点。在适度拟合的时候，分界线比较好的区分了蓝色和红色的点。

在欠拟合的时候，模型过于简单，分界线区分训练样本中蓝色和红色点的能力比较弱，存在比较多的错误分类。

在过拟合的时候，模型过于复杂，分界线区分训练样本中蓝色和红色点的能力近乎完美，基本上没有错误的分类。但是，如果测试样本和这个训练样本不太一样，那么这个模型就会产生比较大的误差。

在常见的监督式学习过程中，适度拟合、欠拟合和过拟合，这三种状态是逐步演变的。我也用一张图来解释这个过程。

在这个图中，x 轴表示模型的复杂程度，y 轴表示预测的误差。蓝色曲线表示模型在训练样本上的表现，它和 x 轴之间的距离表示了偏差。而红色曲线表示模型在测试样本上的表现，它和蓝色曲线之间的距离表示了方差。

从图的左侧往右侧看，模型的复杂度由简单逐渐复杂。越复杂的模型，越近似训练样本，所以偏差就不断下降。可是，由于过于近似训练样本，模型和测试样本的差距就会加大，因此在模型复杂度达到一定程度之后，在训练样本上的预测误差反而会开始增加，这样就会导致训练和测试样本之间的方差不断增大。

在这个图中，最左边是高偏差、低方差，就是我们所说的欠拟合，最右边是低偏差、高方差，就是我们所说的过拟合。在靠近中间的位置，我们希望能找到一个偏差和方差都比较均衡的区域，也就是适度拟合的情况。

如何处理欠拟合和过拟合？

解释了什么是模型拟合、欠拟合和过拟合，我们下面来说说，有哪些常见的处理过拟合和欠拟合的方法。

想要解决一个问题，我们先要搞清楚产生这个问题的原因。欠拟合问题，产生的主要原因是特征维度过少，拟合的模型不够复杂，无法满足训练样本，最终导致误差较大。因此，我们就可以增加特征维度，让输入的训练样本具有更强的表达能力。

之前讲解朴素贝叶斯的时候，我提到“任何两个变量是相互独立的假设”，这种假设和马尔科夫假设中的一元文法的作用一致，是为了降低数据稀疏程度、节省计算资源所采取的措施。可是，这种假设在现实中往往不成立，所以朴素贝叶斯模型的表达能力是非常有限的。当我们拥有足够的计算资源，而且希望建模效果更好的时候，我们就需要更加精细、更加复杂的模型，朴素贝叶斯可能就不再适用了。

比如，在最近非常火的电影《流浪地球》中，计算机系统莫斯拥有全人类文明的数字资料库。假设我们手头也有一个庞大的资料库，也有莫斯那么强大的计算能力，那么使用一元文法来处理数据就有点大材小用了。我们完全可以放弃朴素贝叶斯中关于变量独立性的假设，而使用二元、三元甚至更大的 N 元文法来处理这些数据。这就是典型的通过增加更多的特征，来提升模型的复杂度，让它从欠拟合阶段往适度拟合阶段靠拢。

相对应的，过拟合问题产生的主要原因则是特征维度过多，导致拟合的模型过于完美地符合训练样本，但是无法适应测试样本或者说新的数据。所以我们可以减少特征的维度。之前在介绍决策树的时候，我提到了这类算法比较容易过拟合，可以使用剪枝和随机森林来缓解这个问题。

剪枝，顾名思义，就是删掉决策树中一些不是很重要的结点及对应的边，这其实就是在减少特征对模型的影响。虽然去掉一些结点和边之后，决策树对训练样本的区分能力变弱，但是可以更好地应对新数据的变化，具有更好的泛化能力。至于去掉哪些结点和边，我们可以使用前面介绍的特征选择方法来进行。

随机森林的构建过程更为复杂一些。“森林”表示有很多决策树，可是训练样本就一套，那这些树都是怎么来的呢？随机森林算法采用了统计里常用的可重复采样法，每次从全部 n 个样本中取出 m 个（m<n），然后构建一个决策树。重复这种采样并构建决策树的过程若干次，我们就能获得多个决策树。对于新的数据，每个决策树都会有自己的判断结果，我们取大多数决策树的意见作为最终结果。由于每次采样都是不完整的训练集合，而且有一定的随机性，所以每个决策树的过拟合程度都会降低。

从另一个角度来看，过拟合表示模型太复杂，而相对的训练数据量太少。因此我们也可以增加训练样本的数据量，并尽量保持训练数据和测试数据分布的一致性。如果我们手头上有大量的训练数据，则可以使用交叉验证（Cross Validation）的划分方式来保持训练数据和测试数据的一致性。其核心思想是在每一轮中，拿出大部分数据实例进行建模，然后用建立的模型对留下的小部分实例进行预测，最终对本次预测结果进行评估。这个过程反复进行若干轮，直到所有的标注样本都被预测了一次而且仅一次。如果模型所接受的数据总是在变化，那么我们就需要定期更新训练样本，重新拟合模型。

总结

第二模块中，我介绍了很多概率统计中常用的概念。随机变量和它的概率分布体现了事物发生的不确定性。而条件概率、联合概率和边缘概率体现了多个随机变量之间的关系以及是不是相互独立，通过这三者的关系，我们可以推导出贝叶斯定理。在贝叶斯定理和变量独立性假设的基础之上，我讲了朴素贝叶斯算法中的公式推导，以及如何使用先验概率来预测后验概率。由于朴素贝叶斯假定多个变量之间相互独立，因此特别适合特征维度很多、特征向量和矩阵很稀疏的场景。基于词包方法的文本分类就是个非常典型的例子。

文本分类涉及了词与词之间相互独立的假设，然后延伸出多元文法，而多元文法也广泛应用在概率语言模型中。语言模型是马尔科夫模型的一种，而隐马尔科夫模型在马尔科夫模型的基础之上，提出了两层的结构，解决了我们无法直接观测到转移状态的问题。

由概率知识派生而来的信息论，也能帮助我们设计机器学习的算法，比如决策树和特征选择。统计中的数据分布可以让特征值转换更合理，而假设检验可以告诉我们哪些结论是更可靠的。

由于很多监督式学习都是基于概率统计的，所以我使用了一些学习算法来进行讲解。你会发现，概率和统计可以帮助这些算法学习训练样本中的特征，并可以对新的数据进行预测，这就是模型拟合的过程。

思考题

学习完了概率和统计模块，你觉得自己最大的收获和感触是什么？

欢迎留言和我分享。你可以把今天的内容分享给你的好友，和他一起精进。

33 | 线性代数：线性代数到底都讲了些什么？

你好，我是黄申。

通过第二模块的学习，我想你对概率统计在编程领域，特别是机器学习算法中的应用，已经有了一定理解。概率统计关注的是随机变量及其概率分布，以及如何通过观测数据来推断这些分布。可是，在解决很多问题的时候，我们不仅要关心单个变量之间的关系，还要进一步研究多个变量之间的关系，最典型的例子就是基于多个特征的信息检索和机器学习。

在信息检索中，我们需要考虑多个关键词特征对最终相关性的影响，而在机器学习中，无论是监督式还是非监督式学习，我们都需要考虑多个特征对模型拟合的影响。在研究多个变量之间关系的时候，线性代数成为了解决这类问题的有力工具。

另一方面，在我们日常生活和工作中，很多问题都可以线性化，小到计算两个地点之间的距离，大到计算互联网中全部网页的 PageRank。所以，为了使用编程来解决相应的问题，我们也必须掌握一些必要的线性代数基础知识。因此，我会从线性代数的基本概念出发，结合信息检索和机器学习领域的知识，详细讲解线性代数的运用。

关于线性代数，究竟都需要掌握哪些方面的知识呢？我们今天就来看一看，让你对之后一段时间所要学习的知识有个大体的了解。

向量和向量空间

我们之前所谈到的变量都属于标量（Scalar）。它只是一个单独的数字，而且不能表示方向。从计算机数据结构的角度来看，标量就是编程中最基本的变量。这个很好理解，你可以回想一下刚开始学习编程时接触到的标量类型的变量。

和标量对应的概念，就是线性代数中最常用、也最重要的概念，向量（Vector），也可以叫作矢量。它代表一组数字，并且这些数字是有序排列的。我们用数据结构的视角来看，向量可以用数组或者链表来表达。

后面的文章里，我会用加粗的小写字母表示一个向量，例如 xx，而 x1，x2，x3，…，xnx1，x2，x3，…，xn 等等，来表示向量中的每个元素，这里面的 n 就是向量的维。

向量和标量最大的区别在于，向量除了拥有数值的大小，还拥有方向。向量或者矢量中的“向”和“矢”这两个字，都表明它们是有方向的。你可能会问，为什么这一串数字能表示方向呢？

这是因为，如果我们把某个向量中的元素看作坐标轴上的坐标，那么这个向量就可以看作空间中的一个点。以原点为起点，以向量代表的点为终点，就能形成一条有向直线。而这样的处理其实已经给向量赋予了代数的含义，使得计算的过程中更加直观。在后面讨论向量空间、向量夹角、矩阵特征值等概念的时候，我会进一步展示给你看。

由于一个向量包含了很多个元素，因此我们自然地就可以把它运用在机器学习的领域。上一个模块，我讲过如何把自然界里物体的属性，转换为能够用数字表达的特征。由于特征有很多维，因此我们可以使用向量来表示某个物体的特征。其中，向量的每个元素就代表一维特征，而元素的值代表了相应特征的值，我们称这类向量为特征向量（Feature Vector）。

需要注意的是，这个特征向量和矩阵的特征向量（Eigenvector）是两码事。那么矩阵的特征向量是什么意思呢？矩阵的几何意义是坐标的变换。如果一个矩阵存在特征向量和特征值，那么这个矩阵的特征向量就表示了它在空间中最主要的运动方向。如果你对这几个概念还不太理解，也不用担心，在介绍矩阵的时候，我会详细说说什么是矩阵的特征向量。

向量的运算

标量和向量之间可以进行运算，比如标量和向量相加或者相乘时，我们直接把标量和向量中的每个元素相加或者相乘就行了，这个很好理解。可是，向量和向量之间的加法或乘法应该如何进行呢？我们需要先定义向量空间。向量空间理论上的定义比较繁琐，不过二维或者三维的坐标空间可以很好地帮助你来理解。这些空间主要有几个特性：

空间由无穷多个的位置点组成；

这些点之间存在相对的关系；

可以在空间中定义任意两点之间的长度，以及任意两个向量之间的角度；

这个空间的点可以进行移动。

有了这些特点，我们就可以定义向量之间的加法、乘法（或点乘）、距离和夹角等等。

两个向量之间的加法，首先它们需要维度相同，然后是对应的元素相加。

所以说，向量的加法实际上就是把几何问题转化成了代数问题，然后用代数的方法实现了几何的运算。我下面画了一张图，来解释二维空间里，两个向量的相加，看完你就能理解了。

在这张图中，有两个向量 x 和 y，它们的长度分别是 x’和 y’，它们的相加结果是 x+y，这个结果所对应的点相当于 x 向量沿着 y 向量的方向移动 y’，或者是 y 向量沿着 x 向量的方向移动 x’。

向量之间的乘法默认是点乘，向量 x 和 y 的点乘是这么定义的：

点乘的作用是把相乘的两个向量转换成了标量，它有具体的几何含义。我们会用点乘来计算向量的长度以及两个向量间的夹角，所以一般情况下我们会默认向量间的乘法是点乘。至于向量之间的夹角和距离，它们在向量空间模型（Vector Space Model）中发挥了重要的作用。信息检索和机器学习等领域充分利用了向量空间模型，计算不同对象之间的相似程度。在之后的专栏里，我会通过向量空间模型，详细介绍向量点乘，以及向量间夹角和距离的计算。

矩阵的运算

矩阵由多个长度相等的向量组成，其中的每列或者每行就是一个向量。因此，我们把向量延伸一下就能得到矩阵（Matrix）。

从数据结构的角度看，向量是一维数组，那矩阵就是一个二维数组。如果二维数组里绝大多数元素都是 0 或者不存在的值，那么我们就称这个矩阵很稀疏（Sparse）。对于稀疏矩阵，我们可以使用哈希表的链地址法来表示。所以，矩阵中的每个元素有两个索引。

我用加粗的斜体大写字母表示一个矩阵，例如 XX，而 X12，X22，…，XnmX12，X22，…，Xnm 等等，表示矩阵中的每个元素，而这里面的 n 和 m 分别表示矩阵的行维数和列维数。

我们换个角度来看，向量其实也是一种特殊的矩阵。如果一个矩阵是 n × m 维，那么一个 n × 1 的矩阵也可以称作一个 n 维列向量；而一个 1 × m 矩阵也称为一个 m 维行向量。

同样，我们也可以定义标量和矩阵之间的加法和乘法，我们只需要把标量和矩阵中的每个元素相加或相乘就可以了。剩下的问题就是，矩阵和矩阵之间是如何进行加法和乘法的呢？矩阵加法比较简单，只要保证参与操作的两个矩阵具有相同的行维度和列维度，我们就可以把对应的元素两两相加。而乘法略微繁琐一些，如果写成公式就是这种形式：

其中，矩阵 ZZ 为矩阵 XX 和 YY 的乘积，XX 是形状为 i x k 的矩阵，而 YY 是形状为 k × j 的矩阵。XX 的列数 k 必须和 YY 的行数 k 相等，两者才可以进行这样的乘法。

我们可以把这个过程看作矩阵 XX 的行向量和矩阵 YY 的列向量两两进行点乘，我这里画了张图，你理解了这张图就不难记住这个公式了。

两个矩阵中对应元素进行相乘，这种操作也是存在的，我们称它为元素对应乘积，或者 Hadamard 乘积。但是这种乘法咱们用得比较少，所以你只要知道有这个概念就可以了。

除了加法和乘法，矩阵还有一些其他重要的操作，包括转置、求逆矩阵、求特征值和求奇异值等等。

转置（Transposition）是指矩阵内的元素行索引和纵索引互换，例如 XijXij 就变为 XjiXji，相应的，矩阵的形状由转置前的 n × m 变为转置后的 m × n。从几何的角度来说，矩阵的转置就是原矩阵以对角线为轴进行翻转后的结果。下面这张图展示了矩阵 XX 转置之后的矩阵 X′X′：

除了转置矩阵，另一个重要的概念是逆矩阵。为了理解逆矩阵或矩阵逆（Matrix Inversion），我们首先要理解单位矩阵（Identity Matrix）。单位矩阵中，所有沿主对角线的元素都是 1，而其他位置的所有元素都是 0。通常我们只考虑单位矩阵为方阵的情况，也就是行数和列数相等，我们把它记做 InIn，nn 表示维数。我这里给出一个 I5I5 的示例。

如果有矩阵 XX，我们把它的逆矩阵记做 X−1X−1，两者相乘的结果是单位矩阵，写成公式就是这种形式：

特征值和奇异值的概念以及求解比较复杂了，从大体上来理解，它们可以帮助我们找到矩阵最主要的特点。通过这些操作，我们就可以在机器学习算法中降低特征向量的维度，达到特征选择和变换的目的。我会在后面的专栏，结合案例给你详细讲解。

总结

相对于概率统计，线性代数中的基本概念和知识点可能没有那么多。但是对于刚入门的初学者，这些内容理解起来会比较费力。在这一节里，我进行了大致的梳理，帮助你学习。

标量和向量的区别，标量只是单独的一个数，而向量是一组数。矩阵是向量的扩展，就是一个二维数组。我们可以使用哈希表的链地址法表示稀疏矩阵。

标量和向量或矩阵的加法、乘法比较简单，就是把这个标量和向量或矩阵中所有的元素轮流进行相加或相乘。向量之间的加法和矩阵之间的加法，是把两者对应的元素相加。向量之间的相乘分为叉乘和点乘，我在专栏里默认向量乘法为点乘。而矩阵的乘法默认为左矩阵的行向量和右矩阵的列向量两两点乘。

说到这里，你可能还是不太理解线性代数对于编程有什么用处。在这个模块之后的内容中，我会详细介绍向量空间模型、线性方程组、矩阵求特征值和奇异值分解等，在信息检索和机器学习领域中，有怎样的应用场景。

思考题

之前你对线性代数的认识是什么样的呢？对这块内容，你觉得最难的是什么？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

34 | 向量空间模型：如何让计算机理解现实事物之间的关系？

你好，我是黄申。

之前我们讲过如何让计算机理解现实世界中的事物，方法是把事物的各种特性转为机器所能理解的数据字段。而这些数据字段，在机器学习里通常被称为特征。有了特征，我们不仅可以刻画事物本身，还能刻画不同事物之间的关系。

上一个模块我们只是了解了监督式学习，重点考察了特征和分类标签之间的关系。但是在信息检索和非监督式学习中，我们更关注的是不同事物之间的相似程度。这就需要用到线性代数中的向量空间模型了。

提到向量空间模型，你可能对其中的概念有点陌生，所以我会从向量空间的基本概念开始说起，讲到向量空间模型的相关知识，最后再讲讲它是如何应用在不同的编程中的。

什么是向量空间？

上一节，我讲到了向量和向量空间的一些基本概念。为了帮助你更好地理解向量空间模型，我这里给出向量和向量空间的严格定义。

首先假设有一个数的集合 FF，它满足“FF 中任意两个数的加减乘除法（除数不为零）的结果仍然在这个 FF 中”，我们就可以称 FF 为一个“域”。我们处理的数据通常都是实数，所以这里我只考虑实数域。而如果域 FF 里的元素都为实数，那么 FF 就是实数域。

如果 x1，x2，……，xn∈Fx1，x2，……，xn∈F，那么 FF 上的 nn 维向量就是：

或者写成转置的形式：

向量中第 ii 个元素，也称为第 ii 个分量。FnFn 是由 FF 上所有 nn 维向量构成的集合。

我们已经介绍过向量之间的加法，以及标量和向量的乘法。这里我们使用这两个操作来定义向量空间。

假设 VV 是 FnFn 的非零子集，如果对任意的向量 xx、向量 y∈Vy∈V，都有 (x+y)∈V(x+y)∈V，我们称为 VV 对向量的加法封闭；对任意的标量 k∈Vk∈V，向量 x∈Vx∈V，都有 kxkx 属于 VV，我们称 VV 对标量与向量的乘法封闭。

如果 VV 满足向量的加法和乘法封闭性，我们就称 VV 是 FF 上的向量空间。向量空间除了满足这两个封闭性，还满足基本运算法则，比如交换律、结合律、分配律等等。这里介绍的定义和法则有点多，不过你可以不用都死记硬背下来。只要用的时候，知道有这些东西就可以了。

向量空间的几个重要概念

有了刚才的铺垫，接下来我们来看几个重要的概念：向量的长度、向量之间的距离和夹角。

向量之间的距离

有了向量空间，我们就可以定义向量之间的各种距离。我们之前说过，可以把一个向量想象为 n 维空间中的一个点。而向量空间中两个向量的距离，就是这两个向量所对应的点之间的距离。距离通常都是大于 0 的，这里我介绍几种常用的距离，包括曼哈顿距离、欧氏距离、切比雪夫距离和闵可夫斯基距离。

曼哈顿距离（Manhattan Distance）

这个距离度量的名字由来非常有趣。你可以想象一下，在美国人口稠密的曼哈顿地区，从一个十字路口开车到另外一个十字路口，驾驶距离是多少呢？当然不是两点之间的直线距离，因为你无法穿越挡在其中的高楼大厦。你只能驾车绕过这些建筑物，实际的驾驶距离就叫作曼哈顿距离。由于这些建筑物的排列都是规整划一的，形成了一个个的街区，所以我们也可以形象地称把它为“城市街区”距离。我这里画了张图方便你理解这种距离。

从图中可以看出，从 A 点到 B 点有多条路径，但是无论哪条，曼哈顿距离都是一样的。

在二维空间中，两个点（实际上就是二维向量）x(x1,x2)x(x1,x2) 与 y(y1,y2)y(y1,y2) 间的曼哈顿距离是：

推广到 nn 维空间，曼哈顿距离的计算公式为：

其中 nn 表示向量维度，xixi 表示第一个向量的第 ii 维元素的值，yiyi 表示第二个向量的第 ii 维元素的值。

欧氏距离（Euclidean Distance）

欧氏距离，其实就是欧几里得距离。欧氏距离是一个常用的距离定义，指在 n 维空间中两个点之间的真实距离，在二维空间中，两个点 x(x1,x2)x(x1,x2) 与 y(y1,y2)y(y1,y2) 间的欧氏距离是：

推广到 n 维空间，欧氏距离的计算公式为：

切比雪夫距离（Chebyshev Distance）

切比雪夫其实是在模拟国际象棋里国王的走法。国王可以走临近 8 个格子里的任何一个，那么国王从格子 (x1,x2)(x1,x2) 走到格子 (y1,y2)(y1,y2) 最少需要多少步呢？其实就是二维空间里的切比雪夫距离。

一开始，为了走尽量少的步数，国王走的一定是斜线，所以横轴和纵轴方向都会减 1，直到国王的位置和目标位置在某个轴上没有差距，这个时候就改为沿另一个轴每次减 1。所以，国王走的最少格子数是 |x1−y1||x1−y1| 和 |x2−y2||x2−y2| 这两者的较大者。所以，在二维空间中，两个点 x(x1,x2)x(x1,x2) 与 y(y1,y2)y(y1,y2) 间的切比雪夫距离是：

推广到 n 维空间，切比雪夫距离的计算公式为：

上述三种距离，都可以用一种通用的形式表示，那就是闵可夫斯基距离，也叫闵氏距离。在二维空间中，两个点 x(x1,x2)x(x1,x2) 与 y(y1,y2)y(y1,y2) 间的闵氏距离是：

两个 nn 维变量 x(x1,x2,…,xn)x(x1,x2,…,xn) 与 y(y1,y2,…,yn)y(y1,y2,…,yn) 间的闵氏距离的定义为：

其中 pp 是一个变参数，尝试不同的 p 取值，你就会发现：

当 p=1p=1 时，就是曼哈顿距离；

当 p=2p=2 时，就是欧氏距离；

当 pp 趋近于无穷大的时候，就是切比雪夫距离。这是因为当 pp 趋近于无穷大的时候，最大的 |xi−yi||xi−yi| 会占到全部的权重。

距离可以描述不同向量在向量空间中的差异，所以可以用于描述向量所代表的事物之差异（或相似）程度。

向量的长度

有了向量距离的定义，向量的长度就很容易理解了。向量的长度，也叫向量的模，是向量所对应的点到空间原点的距离。通常我们使用欧氏距离来表示向量的长度。

当然，我们也可以使用其他类型的距离。说到这里，我也提一下“范数”的概念。范数满足非负性、齐次性、和三角不等式。你可以不用深究这三点的含义，不过你需要知道范数常常被用来衡量某个向量空间中向量的大小或者长度。

L1L1 范数 ||x||||x|| ，它是为 xx 向量各个元素绝对值之和，对应于向量 xx 和原点之间的曼哈顿距离。

L2L2 范数 ||x||2||x||2 ，它是 xx 向量各个元素平方和的 1212 次方，对应于向量 xx 和原点之间的欧氏距离。

LpLp 范数 ||x||p||x||p ，为 xx 向量各个元素绝对值 pp 次方和的 1/p 次方，对应于向量 xx 和原点之间的闵氏距离。

L∞L∞ 范数 ||x||∞||x||∞ ，为 xx 向量各个元素绝对值最大那个元素的绝对值，对应于向量 xx 和原点之间的切比雪夫距离。

所以，在讨论向量的长度时，我们需要弄清楚是 L 几范数。

向量之间的夹角

在理解了向量间的距离和向量的长度之后，我们就可以引出向量夹角的余弦，它计算了空间中两个向量所形成夹角的余弦值，具体的计算公式我列在了下面：

从公式可以看出，分子是两个向量的点乘，而分母是两者长度（或 L2 范数）的乘积，而 L2 范数可以使用向量点乘自身的转置来实现。夹角余弦的取值范围在 [-1,1]，当两个向量的方向重合时夹角余弦取最大值 1，当两个向量的方向完全相反夹角余弦取最小值 -1。值越大，说明夹角越小，两点相距就越近；值越小，说明夹角越大，两点相距就越远。

向量空间模型

理解了向量间距离和夹角余弦这两个概念，你再来看向量空间模型（Vector Space Model）就不难了。

向量空间模型假设所有的对象都可以转化为向量，然后使用向量间的距离（通常是欧氏距离）或者是向量间的夹角余弦来表示两个对象之间的相似程度。我使用下图来展示空间中向量之间的距离和夹角。

由于夹角余弦的取值范围已经在 -1 到 1 之间，而且越大表示越相似，所以可以直接作为相似度的取值。相对于夹角余弦，欧氏距离 ED 的取值范围可能很大，而且和相似度呈现反比关系，所以通常要进行 1/(1-ED) 这种归一化。

当 ED 为 0 的时候，变化后的值就是 1，表示相似度为 1，完全相同。当 ED 趋向于无穷大的时候，变化后的值就是 0，表示相似度为 0，完全不同。所以，这个变化后的值，取值范围是 0 到 1 之间，而且和相似度呈现正比关系。

早在上世纪的 70 年代，人们把向量空间模型运用于信息检索领域。由于向量空间可以很形象地表示数据点之间的相似程度，因此现在我们也常常把这个模型运用在基于相似度的一些机器学习算法中，例如 K 近邻（KNN）分类、K 均值（K-Means) 聚类等等。

总结

为了让计算机理解现实世界中的事物，我们会把事物的特点转换成为数据，并使用多维度的特征来表示某个具体的对象。多个维度的特征很容易构成向量，因此我们就可以充分利用向量和向量空间，来刻画事物以及它们之间的关系。

我们可以在向量空间中定义多种类型的向量长度和向量间距离，用于衡量向量之间的差异或者说相似程度。此外，夹角余弦也是常用的相似度衡量指标。和距离相比，夹角余弦的取值已经控制在 [-1, 1] 的范围内，不会因为异常点所产生的过大距离而受到干扰。

向量空间模型充分利用了空间中向量的距离和夹角特性，来描述文档和查询之间的相似程度，或者说相关性。虽然向量空间模型来自信息检索领域，但是也被用广泛运用在机器学习领域中。在接下来的文章里，我会结合具体的案例，分别来说说如何在这些领域使用向量空间模型。

思考题

假设在三维空间中有两个点，它们的坐标分别是 (3, -1, 8) 和 (-2, 3, -6)，请计算这两个点之间的欧氏距离和夹角余弦。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

35 | 文本检索：如何让计算机处理自然语言？

你好，我是黄申。

上一节，我详细解释了向量空间和向量空间模型。你也许觉得理论上的内容还是过于抽象，不太好理解。别急，今天我就来具体演示一下如何使用这个模型。由于学者们最初是在信息检索领域使用这个模型的，所以我会结合文本信息检索领域的知识，阐述如何在这个领域使用向量空间模型。

什么是信息检索？

首先，我们先来看一下，什么是信息检索，以及最基本的排序模型有哪些。这样，你就能理解为什么我们需要使用向量空间模型了。

现在的信息检索技术已经相当成熟，并影响我们日常生活的方方面面。搜索引擎就是这项技术的最佳体现，人们输入一个查询，然后系统就能返回相关的信息。

笼统地说，信息检索就是让计算机根据用户信息需求，从大规模、非结构化的数据中，找出相关的资料。这里的“非结构化”其实是针对经典的关系型数据库（Relation Database）而言的，比如 DB2、Oracle DB、MySQL 等等。

数据库里的记录都有严格的字段定义（Schema），是“结构化”数据的典型代表。相反，“非结构化”没有这种严格的定义，互联网世界里所存储的海量文本就是“非结构化“数据的典型代表。因为这些文章如果没有经过我们的分析，对于其描述的主题、写作日期、作者等信息，我们是一无所知的。自然，我们也就无法将其中的内容和已经定义好的数据库字段进行匹配，所以这也是数据库在处理非结构化数据时非常乏力的原因。这时候就需要采用信息检索的技术来帮助我们。

在信息检索中，相关性是个永恒的话题。“这篇文章是否和体育相关？”当被问及这个问题，我们要大致看一下文章的内容，才能做出正确的判断。可是，迄今为止，计算机尚无法真正懂得人类的语言，它们该如何判定呢？好在科学家们设计了很多模型，帮助计算机处理基于文本的相关性。

最简单的模型是布尔模型，它借助了逻辑（布尔）代数的基本思想。如果我想看一篇文章是否关于体育，最简单的方法莫过于看看其中是否提到和体育相关的关键词，比如“足球”“NBA”“奥运会”等等。如果有，就相当于返回值为“真”，我就认为这篇文章就是相关的。如果没有，就相当于返回值为“假”，我就认为这篇文章不相关。这就是布尔模型的核心思想。

这里我列出了要求全部关键词都出现的查询条件。

当然，我们可以根据具体的需求，在查询条件中加入“OR”，允许进行部分关键词的匹配。

和布尔模型相比，向量空间模型更为复杂，也更为合理。如我之前介绍的，此模型的重点是将文档转换为向量，然后比较向量之间的距离或者相似程度。在转换的时候，我们通常会使用词包（Bag Of Word）的方式，忽略了单词在文章中出现的顺序，简化计算复杂度。类似地，这个模型也会把用户输入的查询转换为向量。如此一来，相关性问题就转化为计算查询向量和文档向量之间的距离或者相似度了。距离越小或者说相似度越高，那么我们就认为相关度越高。

相对于标准的布尔数学模型，向量空间模型的主要优势在于，允许文档和查询之间的部分匹配

连续的相似程度、以及基于这些的的排序。结果不再局限于布尔模型的“真”“假”值。此外，单词或词组的权重可以不再是二元的，而是可以使用例如tf-idf（term frequency–inverse document frequency）的机制。

上面我简要地说明了为什么在信息检索领域，向量空间模型相比布尔模型更具优势。接下来，我来详细讲解如何在一个文档集合上，使用向量空间模型，查找和给定查询相关的文档。

信息检索中的向量空间模型

整个方法从大体上来说，可以分为四个主要步骤。

第一步，把文档集合都转换成向量的形式。

第二步，把用户输入的查询转换成向量的形式，然后把这个查询的向量和所有文档的向量，进行比对，计算出基于距离或者夹角余弦的相似度。

第三步，根据查询和每个文档的相似度，找出相似度最高的文档，认为它们是和指定查询最相关的。

第四步，评估查询结果的相关性。

这一节，我主要侧重讲解和向量空间模型最相关的前两步。

把文档转为特征向量

任何向量都有两个主要的构成要素：维度和取值。这里的维度表示向量有多少维分量、每个分量的含义是什么，而取值表示每个分量的数值是多少。而原始的文本和向量差别很大，我们需要经过若干预处理的步骤。

我们首先来看看如何为文本创建向量的维度。简单地说，我们要把文章中唯一的单词或者词组，作为向量的一个维度。

在概率统计的模块中，我说过如何基于词包（Bag of Word）的方式来预处理文本，包括针对中文等语系的分词操作、针对英文等拉丁语系的词干（Stemming）和归一化（Normalization）处理，以及所有语言都会碰到的停用词（Stopword）、同义词和扩展词处理。完成了前面这些预处理，我们就可以获得每篇文档出现的单词和词组。而通过对所有文档中的单词和词组进行去重，我们就可以构建整个文档集合的词典（Vocabulary）。向量空间模型把词典中的每个词条作为向量的一个维度。

有了向量的维度，我们再来考虑每个维度需要取什么值。最简单的方法是用“1”表示这个词条出现在文档中，“0”表示没有出现。不过这种方法没有考虑每个词的权重。有些词经常出现，它更能表达文章的主要思想，对于计算机的分析能起到更大的作用。对于这点，有两种常见的改进方法，分别是使用词频和词频 x 逆文档频率来实现的。

我们先来看基于词频的方法。假设我们有一个文档集合 c，d 表示 c 中的一个文档，t 表示一个单词，那么我们使用 tf 表示词频（Term Frequency），也就是一个词 t 在文档 d 中出现的次数。这种方法的假设是，如果某个词在文档中的 tf 越高，那么这个词对于这个文档来说就越重要。

另一种改进方法，不仅考虑了 tf，还考虑了 idf。这里 idf 表示逆文档频率（Inverse Document Frequency）。

首先，df 表示文档频率（Document Frequency），也就是文档集合 c 中出现某个词 t 的文档数量。一般的假设是，某个词 t 在文档集合 c 中，出现在越多的文档中，那么其重要性越低，反之则越高。刚开始可能感觉有点困惑，但是仔细想想不难理解。

在讨论体育的文档集合中，“体育”一词可能会出现在上万篇文章中，它的出现并不能使得某篇文档变得和“体育”这个主题更相关。相反，如果只有 3 篇文章讨论到中国足球，那么这 3 篇文章和中国足球的相关性就远远高于其他文章。“中国足球”这个词组在文档集合中就应该拥有更高的权重，用户检索“中国足球”时，这 3 篇文档应该排在更前面。所以，我们通常用 df 的反比例指标 idf 来表示这种重要程度，基本公式如下：

其中 N 是整个文档集合中文章数量，log 是为了确保 idf 分值不要远远高于 tf 而埋没 tf 的贡献。这样一来，单词 t 的 df 越低，其 idf 越高，t 的重要性越高。那么综合起来，tf-idf 的基本公式表示如下：

一旦完成了从原始文档到向量的转换，我们就可以接受用户的查询（Query）。

查询和文档的匹配

在计算查询和文档的相似度之前，我们还需要把查询转换成向量。由于用户的查询也是由自然语言组成，所以这个转换的流程和文档的转换流程是基本一致的。不过，查询也有它的特殊性，因此需要注意下面几个问题。

第一，查询和文档长度不一致。人们输入的查询通常都很短，甚至都不是一个句子，而只是几个关键词。这种情况下，你可能会觉得两个向量的维度不同，无法计算它们之间的距离或夹角余弦。对于这种情况，我们可以使用文档字典中所有的词条来构建向量。如果某维分量所对应的词条出现在文档或者查询中，就取 1、tf 或 tf-idf 值，如果没有就取 0。这样，文档向量和查询向量的维度就相同了，只是查询向量更稀疏、拥有多维度的 0。

第二，查询里出现了文档集合里没有的词。简单的做法是直接去除这维分量，也可以使用相对于其他维度来说极小的一个数值，这和分类中的平滑技术类似。

第三，查询里词条的 idf 该如何计算。如果我们使用 tf-idf 机制来计算向量中每个维度的取值，那么就要考虑这个问题。由于查询本身并不存在文档集合的概念，所以也就不存在 df 和 idf。对于这种情况，我们可以借用文档集合里对应词条的 idf。

把查询转换成向量之后，我们就可以把这个查询的向量和所有文档的向量依次对比，看看查询和哪些文档更相似。我们可以结合上一节所说的，计算向量之间的距离或者夹角余弦。由于夹角余弦不用进行归一化，所以这种方法更为流行。需要注意的是，信息检索里，夹角余弦的取值范围通常是 [0,1]，而不再是 [-1,1]。这是因为在进行文本处理的时候，我们根据单词的出现与否，设置 0、1/tf/tf-idf，因此向量每个分量的取值都是正的。

在概率统计模块中，我介绍过特征选择和特征值的转换。由于文本向量往往是非常稀疏的，我们也可能需要对转换后的文档和查询向量，进行这两项操作。

排序和评估

完成了前两步，后面的排序和评估就很直观了。我们按照和输入查询的相似程度，对所有文档进行相似度由高到低的排序，然后取出前面的若干个文档，作为相关的信息返回。当然，这里你需要注意，这里所说的“相关性”是从向量空间模型的角度出发，不代表所返回的信息一定满足用户的需求。因此，我们还需要设计各种离线或者在线的评估，来衡量向量空间模型的效果。由于这些内容不是线性代数的关注点，我就不展开了。如果你有兴趣，可以自己去研究一下。

总结

今天我从文本的信息检索出发，介绍如何使用向量空间模型。在使用这个模型之前，很重要的处理步骤，就是要把原始数据转换成向量。这里所说的数据类型是文本，所以我们要进行分词等操作，然后构建文档的字典，并使用字典的词条来构建向量。如果是其他类型的数据，我们则需要提取相应的特征，并利用这些特征来构建向量。

如果我们把查询也转换成向量，那么就可以计算查询向量和文档向量之间的相似度。通过这种相似度，我们就能对所有的文档进行排序，找出向量空间模型认为“最相关”的文章。

不过，我今天介绍的计算相似度并排序的过程，只是最基本的实现，而这种实现并没有考虑效率的问题。我们这里可以简单分析一下时间复杂度。

假设查询的平均长度（或词条数量）远远小于文档的平均长度，我们把查询的平均长度记做 m，那么对于每次计算查询向量和文档向量的相似度，时间复杂度都是 O(m)。假设文档集中文档的数量平均是 n，那么根据时间复杂度的四则运算法则，把查询和所有文档比较的时间复杂度是 O(m*n)。

其实，在第 17 讲我曾经提到过了倒排索引的案例，我们可以把倒排索引和向量空间模型相结合。倒排索引可以快速找到包含查询词的候选文档，这样就避免了不必要的向量计算。更多具体的内容，我会在之后的实战模块为你详细讲解。

思考题

假设你使用了 tf-idf 的机制来构造向量，那么当文档集合中新增了文档之后，你是不是只需要为新增文档构建向量？原有文档的向量是否需要更新？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

36 | 文本聚类：如何过滤冗余的新闻？

你好，我是黄申。

前两节，我讲了向量空间模型，以及如何在信息检索领域中运用向量空间模型。向量空间模型提供了衡量向量之间的距离或者相似度的机制，而这种机制可以衡量查询和被查询数据之间的相似程度，而对于文本检索来说，查询和文档之间的相似程度可作为文档的相关性。

实际上，除了文档的相关性，距离或者相似度还可以用在机器学习的算法中。今天，我们就来聊聊如何在聚类算法中使用向量空间模型，并最终实现过滤重复文章。

聚类算法

在概率统计模块中，我们介绍了分类（Classification/Categorization）和回归（Regression）这两种监督式学习（Supervised Learning）。监督式学习通过训练资料学习并建立一个模型，并依此模型对新的实例进行预测。

不过，在实际场景中，我们常常会遇到另一种更为复杂的情况。这时候不存在任何关于样本的先验知识，而是需要机器在没人指导的情形下，去将很多东西进行归类。由于缺乏训练样本，这种学习被称为“非监督学习”（Unsupervised Learning），也就是我们通常所说的聚类（Clustering）。在这种学习体系中，系统必须通过一种有效的方法发现样本的内在相似性，并把数据对象以群组（Cluster）的形式进行划分。

谈到相似性，你可能已经想到了利用特征向量和向量空间模型，这确实是可行的方法。不过，为了让你全面了解在整个非监督式学习中，如何运用向量空间，让我先从一个具体的聚类算法开始。

这个算法的名称是 K 均值（K-Means）聚类算法，它让我们可以在一个任意多的数据上，得到一个事先定好群组数量（K）的聚类结果。这种算法的中心思想是：尽量最大化总的群组内相似度，同时尽量最小化群组之间的相似度。群组内或群组间的相似度，是通过各个成员和群组质心相比较来确定的。想法很简单，但是在样本数量达到一定规模后，希望通过排列组合所有的群组划分，来找到最大总群组内的相似度几乎是不可能的。于是人们提出如下的求近似解的方法。

从 N 个数据对象中随机选取 k 个对象作为质心，这里每个群组的质心定义是，群组内所有成员对象的平均值。因为是第一轮，所以第 i 个群组的质心就是第 i 个对象，而且这时候我们只有这一个组员。

对剩余的对象，测量它和每个质心的相似度，并把它归到最近的质心所属的群组。这里我们可以说距离，也可以说相似度，只是两者呈现反比关系。

重新计算已经得到的各个群组的质心。这里质心的计算是关键，如果使用特征向量来表示的数据对象，那么最基本的方法是取群组内成员的特征向量，将它们的平均值作为质心的向量表示。

迭代上面的第 2 步和第 3 步，直至新的质心与原质心相等或相差之值小于指定阈值，算法结束。

我以二维空间为例子，画张图来展示一下数据对象聚类的过程。

在这张图中，( a )、( b )、( c ) 三步分别展示了质心和群组逐步调整的过称。我们一一来看。(a) 步骤是选择初始质心，质心用不同颜色的 x 表示；( b ) 步骤开始进行聚类，把点分配到最近的质心所在的组；( c ) 步骤重新计算每个群组的质心，你会发现 x 的位置发生了改变。之后就是如此重复，进入下一轮聚类。

总的来说，K 均值算法是通过不断迭代、调整 K 个聚类质心的算法。而质心或者群组的中心点，是通过求群组所包含的成员之平均值来计算的。

使用向量空间进行聚类

明白了 K 均值聚类算法的核心思想，再来理解向量空间模型在其中的运用就不难了。我还是以文本聚类为例，讲讲如何使用向量空间模型和聚类算法，去除重复的新闻。

我们在看新闻的时候，一般都希望不断看到新的内容。可是，由于现在的报道渠道非常丰富，经常会出现热点新闻霸占版面的情况。假如我们不想总是看到重复的新闻，应该怎么办呢？有一种做法就是对新闻进行聚类，那么内容非常类似的文章就会被聚到同一个分组，然后对每个分组我们只选择 1 到 2 篇显示就够了。

基本思路确定后，我们可以把整个方法分为三个主要步骤。

第一步，把文档集合都转换成向量的形式。这块我上一节讲过了，你要是不记得了，可以自己回去复习一下。

第二步，使用 K 均值算法对文档集合进行聚类。这个算法的关键是如何确定数据对象和分组质心之间的相似度。针对这点，我们有两个点需要关注。

使用向量空间中的距离或者夹角余弦度量，计算两个向量的相似度。

计算质心的向量。K 均值里，质心是分组里成员的平均值。所以，我们需要求分组里所有文档向量的平均值。求法非常直观，就是分别为每维分量求平均值，我把具体的计算公式列在这里：

其中，xixi 表示向量的第 i 个分量，xijxij 表示第 j 格向量的第 ii 个分量，而 j=1,2,…,nj=1,2,…,n 表示属于某个分组的所有向量。

第三步，在每个分类中，选出和质心最接近的几篇文章作为代表。而其他的文章作为冗余的内容过滤掉。

下面，我使用 Python 里的 sklearn 库，来展示使用欧氏距离的 K 均值算法。

Python 中的 K 均值算法

在尝试下面的代码之前，你需要看看自己的机器上是不是已经安装了 scikit-learn。Scikit-learn 是 Python 常用的机器学习库，它提供了大量的机器学习算法的实现和相关的文档，甚至还内置了一些公开数据集，是我们实践机器学习算法的好帮手。

首先，我使用 sklearn 库中的 CountVectorizer，对一个测试的文档集合构建特征，也就是词典。这个测试集合有 7 句话，2 句关于篮球，2 句关于电影，还有 3 句关于游戏。具体代码如下：

from sklearn.feature_extraction.text import CountVectorizer

# 模拟文档集合

corpus = ['I like great basketball game',

          'This video game is the best action game I have ever played',

          'I really really like basketball',

          'How about this movie? Is the plot great?',

          'Do you like RPG game?',

          'You can try this FPS game',

          'The movie is really great, so great! I enjoy the plot']

# 把文本中的词语转换为词典和相应的向量

vectorizer = CountVectorizer()

vectors = vectorizer.fit_transform(corpus)

# 输出所有的词条（所有维度的特征）

print('所有的词条（所有维度的特征）')

print(vectorizer.get_feature_names())

print('\n')

# 输出 (文章 ID, 词条 ID) 词频

print('(文章 ID, 词条 ID) 词频')

print(vectors)

print('\n')

从运行的结果中，你可以看到，整个词典里包含了哪些词，以及每个词在每个文档里的词频。

这里，我们希望使用比词频 tf 更好的 tf-idf 机制，TfidfTransformer 可以帮助我们做到这点，代码和注释如下：

from sklearn.feature_extraction.text import TfidfTransformer

# 构建 tfidf 的值

transformer = TfidfTransformer()

tfidf = transformer.fit_transform(vectorizer.fit_transform(corpus))

# 输出每个文档的向量

tfidf_array = tfidf.toarray()

words = vectorizer.get_feature_names()

for i in range(len(tfidf_array)):

    print ("********* 第 ", i + 1, " 个文档中，所有词语的 tf-idf*********")

    # 输出向量中每个维度的取值

    for j in range(len(words)):

        print(words[j], ' ', tfidf_array[i][j])

    print('\n')

运行的结果展示了每个文档中，每个词的 tfidf 权重，你可以自己手动验算一下。

最后，我们就可以进行 K 均值聚类了。由于有篮球、电影和游戏 3 个类别，我选择的 K 是 3，并在 KMeans 的构造函数中设置 n_clusters 为 3。

from sklearn.cluster import KMeans

# 进行聚类，在我这个版本里默认使用的是欧氏距离

clusters = KMeans(n_clusters=3)

s = clusters.fit(tfidf_array)

# 输出所有质心点，可以看到质心点的向量是组内成员向量的平均值

print('所有质心点的向量')

print(clusters.cluster_centers_)

print('\n')

# 输出每个文档所属的分组

print('每个文档所属的分组')

print(clusters.labels_)

# 输出每个分组内的文档

dict = {}

for i in range(len(clusters.labels_)):

    label = clusters.labels_[i]

    if label not in dict.keys():

        dict[label] = []

        dict[label].append(corpus[i])

    else:

        dict[label].append(corpus[i])

print(dict)

为了帮助你的理解，我输出了每个群组的质心，也就是其中成员向量的平均值。最后，我也输出了 3 个群组中所包含的句子。在我机器上的运行结果显示，系统可以把属于 3 个话题的句子区分开来。如下所示：

{2: ['I like great basketball game', 'I really really like basketball'], 0: ['This video game is the best action game I have ever played', 'Do you like RPG game?', 'You can try this FPS game'], 1: ['How about this movie? Is the plot great?', 'The movie is really great, so great! I enjoy the plot']}

不过，由于 KMeans 具体的实现可能不一样，而且初始质心的选择也有一定随机性，所以你看到的结果可能稍有不同。

总结

这一节，我介绍了如何在机器学习的聚类算法中，使用向量空间模型。在聚类中，数据对象之间的相似度时很关键的。如果我们把样本转换为向量，然后使用向量空间中的距离或者夹角余弦，就很自然的能获得这种相似度，所以向量空间模型和聚类算法可以很容易的结合在一起。

为了给你加深印象，我介绍了一个具体的 K 均值算法，以及向量空间模型在其中所起到的作用，并通过 Python 的 sklearn 代码演示了几个关键的步骤。

向量空间模型和 K 均值算法的结合，虽然简单易懂，但是一开始怎样选择这个群组的数量，是个关键问题。我今天演示的测试数据很小，而且主题划分的也非常明显。所以我选择聚类的数量为 3。

可是在实际项目中，对于一个新的数据集合，选择多少比较合适呢？如果这个 K 值取得太小，群组可能切分太细，每个之间区别不大。如果 K 值取得太大，群组的粒度又太粗，造成群组内差异比较明显。对非监督式的学习来说，这个参数确实难以得到准确预估。我们可以事先在一个较小的数据集合上进行尝试，然后根据结果和应用场景确定一个经验值。

思考题

今天我使用的是 sklearn 里的 KMeans 包，它使用了向量间的欧氏距离来进行聚类。你可以尝试实现自己的 K 均值聚类，并使用向量间的夹角余弦作为相似度的度量。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

37 | 矩阵（上）：如何使用矩阵操作进行PageRank计算？

你好，我是黄申。今天我来说说矩阵。

前面我说过，矩阵由多个长度相等的向量组成，其中的每列或者每行就是一个向量。从数据结构的角度来看，我们可以把向量看作一维数组，把矩阵看作二维数组。

具有了二维数组的特性，矩阵就可以表达二元关系了，例如图中结点的邻接关系，或者是用户对物品的评分关系。而通过矩阵上的各种运算操作，我们就可以挖掘这些二元关系，在不同的应用场景下达到不同的目的。今天我就从图的邻接矩阵出发，展示如何使用矩阵计算来实现 PageRank 算法。

回顾 PageRank 链接分析算法

在讲马尔科夫模型的时候，我已经介绍了 PageRank 链接分析算法。所以，在展示这个算法和矩阵操作的关系之前，我们快速回顾一下它的核心思想。

PageRank 是基于马尔科夫链的。它假设了一个“随机冲浪者”模型，冲浪者从某张网页出发，根据 Web 图中的链接关系随机访问。在每个步骤中，冲浪者都会从当前网页的链出网页中，随机选取一张作为下一步访问的目标。此外，PageRank 还引入了随机的跳转操作，这意味着冲浪者不是按 Web 图的拓扑结构走下去，只是随机挑选了一张网页进行跳转。

基于之前的假设，PageRank 的公式定义如下：

其中，pipi 表示第 ii 张网页，MiMi 是 pipi 的入链接集合，pjpj 是 MiMi 集合中的第 jj 张网页。PR(pj)PR(pj) 表示网页 pjpj 的 PageRank 得分，L(pj)L(pj) 表示网页 pjpj 的出链接数量，1L(pj)1L(pj) 就表示从网页 pjpj 跳转到 pipi 的概率。αα 是用户不进行随机跳转的概率，NN 表示所有网页的数量。

PageRank 的计算是采样迭代法实现的：一开始所有网页结点的初始 PageRank 值都可以设置为某个相同的数，例如 1，然后我们通过上面这个公式，得到每个结点新的 PageRank 值。每当一张网页的 PageRank 发生了改变，它也会影响它的出链接所指向的网页，因此我们可以再次使用这个公式，循环地修正每个网页结点的值。由于这是一个马尔科夫过程，所以我们能从理论上证明，所有网页的 PageRank 最终会达到一个稳定的数值。整个证明过程很复杂，这里我们只需要知道这个迭代计算的过程就行了。

简化 PageRank 公式

那么，这个计算公式和矩阵操作又有什么联系呢？为了把问题简化，我们暂时不考虑随机跳转的情况，而只考虑用户按照网页间链接进行随机冲浪。那么 PageRank 的公式就简化为：

这个公式只包含了原公式中的 ΣPR(pj)L(pj))ΣPR(pj)L(pj)) 部分。我们再来对比看看矩阵点乘的计算公式。

以上两个公式在形式上是基本一致的。因此，我们可以把 ΣPR(pj)L(pj))ΣPR(pj)L(pj)) 的计算，分解为两个矩阵的点乘。一个矩阵是当前每张网页的 PageRank 得分，另一个矩阵就是邻接矩阵。所谓邻接矩阵，其实就是表示图结点相邻关系的矩阵。

假设 xi,jxi,j 是矩阵中第 ii 行、第 jj 列的元素，那么我们就可以使用 xi,jxi,j 表示从结点 ii 到结点 jj 的连接，放到 PageRank 的应用场景，xi,jxi,j 就表示网页 pipi 到网页 pjpj 的链接。最原始的邻接矩阵所包含的元素是 0 或 1，0 表示没有链接，而 1 表示有链接。

考虑到 PageRank 里乘积是 1L(pj)1L(pj)，我们可以对邻接矩阵的每一行进行归一化，用原始的值（0 或 1）除以 L(pj)L(pj)，而 L(pj)L(pj) 表示有某张网页 pjpj 的出链接，正好是矩阵中 pjpj 这一行的和。所以，我们可以对原始的邻接矩阵，进行基于行的归一化，这样就能得到每个元素为 1L(pj)1L(pj) 的矩阵，其中 jj 表示矩阵的第 jj 行。注意，这里的归一化是指让所有元素加起来的和为 1。

为了方便你理解，我用下面这个拓扑图作为例子给你详细解释。

基于上面这个图，原始矩阵为：

其中第 i 行、第 j 列的元素值表示从结点 i 到 j 是不是存在链接。如果是，那么这个值为 1；否则就为 0。

按照每一行的和，分别对每一行进行归一化之后的矩阵就变为：

有了上述这个邻接矩阵，我们就可以开始最简单的 PageRank 计算。PageRank 的计算是采样迭代法实现的。这里我把初始值都设为 1，并把第一次计算的结果列在这里。

好了，我们已经成功迈出了第一步，但是还需要考虑随机跳转的可能性。

考虑随机跳转

经过上面的步骤，我们已经求得 ΣPR(pj)L(pj))ΣPR(pj)L(pj)) 部分。不过，PageRank 引入了随机跳转的机制。这一部分其实也是可以通过矩阵的点乘来实现的。我们把 ΣPR(pj)L(pj))ΣPR(pj)L(pj)) 部分用 AA 表示，那么完整的 PageRank 公式就可以表示为：

PR(Pi)=αA+1−αNPR(Pi)=αA+1−αN

于是，我们可以把上述公式分解为如下两个矩阵的点乘：

我们仍然使用前面的例子，来看看经过随机跳转之后，PageRank 值变成了多少。这里 αα 取 0.9。

我们前面提到，PageRank 算法需要迭代式计算。为了避免计算后的数值越来越大甚至溢出，我们可以进行归一化处理，保证所有结点的数值之和为 1。经过这个处理之后，我们得到第一轮的 PageRank 数值，也就是下面这个行向量：

[0.37027027 0.24864865 0.37027027 0.00540541 0.00540541]

接下来，我们只需要再重复之前的步骤，直到每个结点的值趋于稳定就可以了。

使用 Python 进行实现

说到这里，我已经把如何把整个 PageRank 的计算，转换成多个矩阵的点乘这个过程讲完了。这样一来，我们就可以利用 Python 等科学计算语言提供的库，来完成基于 PageRank 的链接分析。为了展示具体的代码，我以之前的拓扑图为例，给你详细讲述每一步。

首先，我们要进行一些初始化工作，包括设置结点数量、确定随机跳转概率的 αα、代表拓扑图的邻接矩阵以及存放所有结点 PageRank 值的数组。下面是一段示例代码，在代码中我提供了注释供你参考。

import numpy as np

# 设置确定随机跳转概率的 alpha、网页结点数

alpha = 0.9

N = 5

# 初始化随机跳转概率的矩阵

jump = np.full([2,1], [[alpha], [1-alpha]], dtype=float)

# 邻接矩阵的构建

adj = np.full([N,N], [[0,0,1,0,0],[1,0,1,0,0],[1,0,0,0,0],[0,0,0,0,0],[0,1,0,0,0]], dtype=float)

# 对邻接矩阵进行归一化

row_sums = adj.sum(axis=1)      # 对每一行求和

row_sums[row_sums == 0] = 0.1   # 防止由于分母出现 0 而导致的 Nan

adj = adj / row_sums[:, np.newaxis] # 除以每行之和的归一化

# 初始的 PageRank 值，通常是设置所有值为 1.0

pr = np.full([1,N], 1, dtype=float)

之后，我们就能采用迭代法来计算 PageRank 值。一般我们通过比较每个结点最近两次计算的值是否足够接近，来确定数值是不是已经稳定，以及是不是需要结束迭代。这里为简便起见，我使用了固定次数的循环来实现。如果你的拓扑图比较复杂，需要更多次迭代，我把示例代码和注释列在这里。

# PageRank 算法本身是采样迭代方式进行的，当最终的取值趋于稳定后结束。

for i in range(0, 20):

    # 进行点乘，计算Σ(PR(pj)/L(pj))

    pr = np.dot(pr, adj)

    # 转置保存Σ(PR(pj)/L(pj)) 结果的矩阵，并增加长度为 N 的列向量，其中每个元素的值为 1/N，便于下一步的点乘。

    pr_jump = np.full([N, 2], [[0, 1/N]])

    pr_jump[:,:-1] = pr.transpose()

    # 进行点乘，计算α(Σ(PR(pj)/L(pj))) + (1-α)/N)

    pr = np.dot(pr_jump, jump)

    # 归一化 PageRank 得分

    pr = pr.transpose()

    pr = pr / pr.sum()

    print("round", i + 1, pr)

如果成功运行了上述两段代码，你就能看到每个结点最终获得的 PageRank 分数是多少。

Python 中还有一些很不错的库，提供了直接构建拓扑图和计算 PageRank 的功能，例如 networkx（https://networkx.github.io/）。你可以尝试使用这种库，构建样例拓扑图并计算每个结点的 PageRank 得分，最后和上述代码所计算的 PageRank 得分进行比较，验证一下上述代码的结果是不是合理。

总结

我们可以把向量看作一维数组，把矩阵看作二维数组。矩阵的点乘，是由若干个向量的点乘组成的，所以我们可以通过矩阵的点乘操作，挖掘多组向量两两之间的关系。

今天我们讲了矩阵的点乘操作在 PageRank 算法中的应用。通过表示网页的邻接二元关系，我们可以使用矩阵来计算 PageRank 的得分。在这个应用场景下，矩阵点乘体现了多个马尔科夫过程中的状态转移。

矩阵点乘和其他运算操作，还可以运用在很多其他的领域。例如，我在上一节介绍 K 均值聚类算法时，就提到了需要计算某个数据点向量、其他数据点向量之间的距离或者相似度，以及使用多个数据点向量的平均值来获得质心点的向量，这些都可以通过矩阵操作来完成。

另外，在协同过滤的推荐中，我们可以使用矩阵点乘，来实现多个用户或者物品之间的相似程度，以及聚集后的相似程度所导致的最终推荐结果。下一节，我会使用矩阵来表示用户和物品的二元关系，并通过矩阵来计算协同过滤的结果。

思考题

在介绍 PageRank 算法时，我提到了它的计算是一个迭代的过程。这一节我使用了固定次数的循环来实现这一点。请尝试使用计算前后两次 PageRank 数值的差，来判断是否需要结束迭代。（提示：你可以使用矩阵元素对应的减法，以及在第 3 讲和加餐 2 中提到的相对误差。）

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

38 | 矩阵（下）：如何使用矩阵操作进行协同过滤推荐？

你好，我是黄申。今天我们来聊聊矩阵操作和推荐算法的关系。

我这里说的推荐，是指为用户提供可靠的建议、并协助用户挑选物品的一种技术。一个好的推荐系统需要建立在海量数据挖掘基础之上，并根据用户所处的情景和兴趣特点，向用户推荐可能感兴趣的信息和商品。

协同过滤（Collaborative Filtering）是经典的推荐算法之一，它充分利用了用户和物品之间已知的关系，为用户提供新的推荐内容。我会从这种二元关系出发，给你讲讲如何使用矩阵计算，来实现协同过滤推荐算法。

用矩阵实现推荐系统的核心思想

矩阵中的二维关系，除了可以表达图的邻接关系，还可以表达推荐系统中用户和物品的关系。如果你不懂推荐系统，不用急，我这里先给你简单讲讲它的核心思想。

简单地理解就是，推荐系统会根据用户所处的场景和个人喜好，推荐他们可能感兴趣的信息和商品。比如，你在阅读一部电影的影评时，系统给你推荐了其他“你可能也感兴趣的电影”。可以看出来，推荐系统中至少有 2 个重要的角色：用户和物品。用户是系统的使用者，物品就是将要被推荐的候选对象。

例如，亚马逊网站的顾客就是用户，网站所销售的商品就是物品。需要注意的是，除了用户角色都是现实中的自然人，某些场景下被推荐的物品可能也是现实中的自然人。例如，一个招聘网站会给企业雇主推荐合适的人才，这时候应聘者承担的是物品角色。

而一个好的推荐算法，需要充分挖掘用户和物品之间的关系。我们可以通过矩阵来表示这种二元关系。我这里有一个例子，我们用矩阵 XX 来表示用户对物品喜好程度。

其中第 ii 行是第 ii 个用户的数据，而第 j 列是用户对第 j 格物品的喜好程度。我们用 xi,jxi,j 表示这个数值。这里的喜好程度可以是用户购买商品的次数、对书籍的评分等等。

假设我们用一个 0 到 1 之间的小数表示。有了这种矩阵，我们就可以通过矩阵的操作，充分挖掘用户和物品之间的关系。下面，我会使用经典的协同过滤算法，来讲解矩阵在其中的运用。

在此之前，我们先来看什么是协同过滤。你可以把它理解为最直观的“口口相传”。假设我们愿意接受他人的建议，尤其是很多人都向你建议的时候。其主要思路就是利用已有用户群过去的行为或意见，预测当前用户最可能喜欢哪些东西。根据推荐依据和传播的路径，又可以进一步细分为基于用户的过滤和基于物品的过滤。

基于用户的过滤

首先，我们来看基于用户的协同过滤。它是指给定一个用户访问（我们假设有访问就表示有兴趣）物品的数据集合，找出和当前用户历史行为有相似偏好的其他用户，将这些用户组成“近邻”，对于当前用户没有访问过的物品，利用其近邻的访问记录来预测。我画了一张图方便你理解。

根据这张图的访问关系来看，用户 A 访问了物品 A 和 C，用户 B 访问了物品 B，用户 C 访问了物品 A，C 和 D。我们计算出来，用户 C 是 A 的近邻，而 B 不是。因此系统会更多地向用户 A 推荐用户 C 访问的物品 D。

理解了这个算法的基本概念，我们来看看如何使用公式来表述它。假设有 m 个用户，n 个物品，那么我们就能使用一个 m×n 维的矩阵 XX 来表示用户对物品喜好的二元关系。基于这个二元关系，我们可以列出下面这两个公式：

其中，第一个公式比较容易理解，它的核心思想是计算用户和用户之间的相似度。完成了这一步我们就能找到给定用户的“近邻”。

我们可以使用向量空间模型中的距离或者是夹角余弦来处理，在这里我使用了夹角余弦，其中 usi1usi1,i2i2 表示用户 i1i1 和 i2i2 的相似度，而 Xi1Xi1, 表示矩阵中第 i1i1 行的行向量，Xi2Xi2, 表示矩阵中第 i2i2 行的行向量。分子是两个表示用户的行向量之点乘，而分母是这两个行向量 L2L2 范数的乘积。

第二个公式利用第一个公式所计算的用户间相似度，以及用户对物品的喜好度，预测任一个用户对任一个物品的喜好度。其中 pi,jpi,j 表示第 ii 用户对第 jj 个物品的喜好度，usi,kusi,k 表示用户 ii 和 kk 之间的相似度，xk,jxk,j 表示用户 kk 对物品 jj 的喜好度。注意这里最终需要除以 Σusi,kΣusi,k，是为了进行归一化。

从这个公式可以看出，如果 usi,kusi,k 越大，xk,jxk,j 对最终 pi,jpi,j 的影响越大，反之如果 usi,kusi,k 越小，xk,jxk,j 对最终 pi,jpi,j 的影响越小，充分体现了“基于相似用户”的推荐。

如果你无法理解如何把这两个公式对应为矩阵操作，没关系，我下面会通过之前介绍的喜好度矩阵 XX 的示例，把这两个公式逐步拆解，并对应到矩阵上的操作，你一看就能明白了。

首先，我们来看第一个关于夹角余弦的公式。

在介绍向量空间模型的时候，我提到夹角余弦可以通过向量的点乘来实现。这对矩阵同样适用，我们可以采用矩阵点乘自身的转置来实现，也就是 XX′XX′。矩阵 XX 的每一行是某个用户的行向量，每个分量表示用户对某个物品的喜好程度。而矩阵 X′X′ 的每一列是某个用户的列向量，每个分量表示用户对某个物品的喜好程度。

我们假设 XX′XX′ 的结果为矩阵 YY，那么 yi,jyi,j 就表示用户 ii 和用户 jj 这两者喜好度向量的点乘结果，它就是夹角余弦公式中的分子。如果 ii 等于 jj，那么这个计算值也是夹角余弦公式分母的一部分。从矩阵的角度来看，YY 中任何一个元素都可能用于夹角余弦公式的分子，而对角线上的值会用于夹角余弦公式的分母。这里我们仍然使用之前的喜好度矩阵示例，来计算矩阵 YY 和矩阵 USUS。

首先我们来看 YY 的计算。

然后我们使用 YY 来计算 USUS。我用下面这张图表示矩阵中的元素和夹角余弦计算的对应关系。

明白了上面这个对应关系，我们就可以利用矩阵 YY，获得任意两个用户之间的相似度，并得到一个 m×m 维的相似度矩阵 USUS。矩阵 USUS 中 usi,jusi,j 的取值为第 ii 个用户与第 jj 个用户的相似度。这个矩阵是一个沿对角线对称的矩阵。根据夹角余弦的定义，usi,jusi,j 和 usj,iusj,i 是相等的。通过示例的矩阵 YY，我们可以计算矩阵 USUS。我把相应的结果列在了下方。

接下来，我们再来看第二个公式。

从矩阵的角度来看，现在我们已经得到用户相似度矩阵 USUS，再加上用户对物品的喜好度矩阵 XX，现在需要计算任意用户对任意物品的喜好度推荐矩阵 PP。

为了实现上面这个公式的分子部分，我们可以使用 USUS 和 XX 的点乘。我们假设点乘后的结果矩阵为 USPUSP。这里我列出了根据示例计算得到的矩阵 USPUSP。

分母部分可以使用 USUS 矩阵的按行求和来实现。我们假设按行求和的矩阵为 USRUSR。根据示例计算就可以得到 USRUSR。

最终，我们使用 USPUSP 和 *USRUSR 的元素对应除法，就可以求得矩阵 PP。

既然已经有 XX 这个喜好度矩阵了，为什么还要计算 PP 这个喜好度矩阵呢？实际上，XX 是已知的、有限的喜好度。例如用户已经看过的、购买过的、或评过分的物品。而 PP 是我们使用推荐算法预测出来的喜好度。

即使一个用户对某个物品从未看过、买过、或评过分，我们依然可以通过矩阵 PP，知道这位用户对这个物品大致的喜好程度，从而根据这个预估的分数进行物品的推荐，这也是协同过滤的基本思想。从根据示例计算的结果也可以看出这点，在原始矩阵 XX 中第 1 个用户对第 3 个物品的喜好度为 0。可是在最终的喜好度推荐矩阵 P 中，第 1 个用户对第 3 个物品的喜好度为 0.278，已经明显大于 0 了，因此我们就可以把物品 3 推荐给用户 1。

上面这种基于用户的协同过滤有个问题，那就是没有考虑到用户的喜好程度是不是具有可比性。假设用户的喜好是根据对商品的评分来决定的，有些用户比较宽容，给所有的商品都打了很高的分，而有些用户比较严苛，给所有商品的打分都很低。分数没有可比性，这就会影响相似用户查找的效果，最终影响推荐结果。这个时候我们可以采用之前介绍的特征值变化，对于原始的喜好度矩阵，按照用户的维度对用户所有的喜好度进行归一化或者标准化处理，然后再进行基于用户的协同过滤。

基于物品的过滤

基于物品的协同过滤是指利用物品相似度，而不是用户间的相似度来计算预测值。我同样用图来帮助你理解。

在这张图中，物品 A 和 C 因为都被用户 A 和 B 同时访问，因此它们被认为相似度更高。当用户 C 访问过物品 A 后，系统会更多地向用户推荐物品 C，而不是其他物品。

基于用户的协同过滤同样有两个公式，你可以看一下。

如果你弄明白了基于用户的过滤，那么这两个公式也就不难理解了。第一个公式的核心思想是计算物品和物品之间的相似度，在这里我仍然使用夹角余弦。其中 isj1isj1,j2j2 表示物品 j1j1 和 j2j2 的相似度，而 Xj1Xj1 表示了 XX 中第 j1j1 列的列向量，而 Xj2Xj2 表示了 XX 中第 j2j2 列的列向量。分子是两个表示物品的列向量之点乘，而分母是这两个列向量 L2L2 范数的乘积。

第二个公式利用第一个公式所计算的物品间相似度，和用户对物品的喜好度，预测任一个用户对任一个物品的喜好度。其中 pi,jpi,j 表示第 ii 用户对第 jj 个物品的喜好度，xi,kxi,k 表示用户 ii 对物品 kk 的喜好度，isk,jisk,j 表示物品 kk 和 jj 之间的相似度，注意这里除以 Σisk,jΣisk,j 是为了进行归一化。从这个公式可以看出，如果 isk,jisk,j 越大，xi,kxi,k 对最终 pi,jpi,j 的影响越大，反之如果 isk,jisk,j 越小，xi,kxi,k 对最终 pi,jpi,j 的影响越小，充分体现了“基于相似物品”的推荐。

类似地，用户喜好程度的不一致性，同样会影响相似物品查找的效果，并最终影响推荐结果。我们也需要对于原始的喜好度矩阵，按照用户的维度对用户的所有喜好度，进行归一化或者标准化处理。

总结

今天我首先简要地介绍了推荐系统的概念和主要思想。为了给用户提供可靠的结果，推荐系统需要充分挖掘历史数据中，用户和物品之间的关系。协同过滤的推荐算法就很好地体现了这一点。

一旦涉及用户和物品的这种二元关系，矩阵就有用武之地了。我通过矩阵来表示用户和物品的关系，并通过矩阵计算来获得协同过滤的结果。协同过滤分为基于用户的过滤和基于物品的过滤两种，它们的核心思想都是相同的，因此矩阵操作也是类似的。在这两个应用场景下，矩阵点乘体现了多个用户或者物品之间的相似程度，以及聚集后的相似程度所导致的最终推荐结果。

当然，基于用户和物品间关系的推荐算法有很多，对矩阵的操作也远远不止点乘、按行求和、元素对应乘除法。我后面会介绍如何使用矩阵的主成分分析或奇异值分解，来进行物品的推荐。

思考题

我在介绍推荐算法时，提到了基于物品的协同过滤。请参照基于用户的协同过滤，写出相应的矩阵操作步骤。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

39 | 线性回归（上）：如何使用高斯消元求解线性方程组？

你好，我是黄申。

之前我使用 Boston Housing 的数据，阐述了如何使用多元线性回归。可是，计算机系统究竟是如何根据观测到的数据，来拟合线性回归模型呢？这两节，我就从最简单的线性方程组出发，来说说如何求解线性回归的问题。

在第 29 讲中，我讲过机器学习中两类很重要的方法：回归分析以及线性回归。回归分析属于监督式学习算法，主要研究一个或多个随机变量 y1y1，y2y2，…，yiyi 与另一些变量 x1x1，x2x2，…，xkxk 之间的关系。其中，我们将 y1，y2、…，yiy1，y2、…，yi 称为因变量，x1，x2，…，xkx1，x2，…，xk 称为自变量。按照不同的维度，我们可以把回归分为三种。

按照自变量数量，当自变量 xx 的个数大于 1 时就是多元回归。

按照因变量数量，当因变量 yy 个数大于 1 时就是多重回归。

按照模型种类，如果因变量和自变量为线性关系时，就是线性回归模型；如果因变量和自变量为非线性关系时时，就是非线性回归分析模型。

高斯消元法

对于回归分析来说，最简单的情形是只有一个自变量和一个因变量，且它们大体上是有线性关系的，这就是一元线性回归。对应的模型很简单，就是 Y=a+bX+εY=a+bX+ε。这里的 XX 是自变量，YY 是因变量，aa 是截距，b 是自变量的系数。前面这些你估计都很熟悉，最后还有个 εε，这表示随机误差，只不过我们通常假定随机误差的均值为 00。进一步来说，如果我们暂时不考虑 a 和ε，把它扩展为多元的形式，那么就可以得到类似下面这种形式的方程：

b1⋅x1+b2⋅x2+…+bn−1⋅xn−1+bn⋅xn=yb1·x1+b2·x2+…+bn−1·xn−1+bn·xn=y

假设我们有多个这样的方程，就能构成线性方程组，我这里列出一个例子。

2x1+x2+x3=02x1+x2+x3=0
4x1+2x2+x3=564x1+2x2+x3=56
2x1−x2+4x3=42x1−x2+4x3=4

对于上面这个方程组，如果存在至少一组 x1、x2x1、x2 和 x3x3 使得三个方程都成立，那么就叫方程有解；如果没有，那么我们就说方程无解。如果方程有解，那么解可能是唯一，也可能是多个。我们通常关心的是，方程组是不是有解，以及 x1x1 一直到 xnxn 分别是多少。

为了实现这个目的，人们想了很多方法来求解方程组，这些方法看起来多种多样，其实主要就是两大类，直接法和迭代法。

直接法就是通过有限次的算术运算，计算精确解。而迭代法，我们在第 3 讲就提到过，它是一种不断用变量的旧值递推新值的过程。我们可以用迭代法不断地逼近方程的精确解。

这里，我就从上面这个方程组的例子出发，阐述最常见的高斯消元法，以及如何使用矩阵操作来实现它。

高斯消元法主要分为两步，消元（Forward Elimination）和回代（Back Substitution）。所谓消元，就是要减少某些方程中元的数量。如果某个方程中的元只剩一个了 xmxm 了，那么这个自变量 xmxm 的解就能知道了。所谓的回代，就是把已知的解 xmxm 代入到方程式中，求出其他未知的解。

我们先从消元开始，来看这个方程组。

2x1+x2+x3=02x1+x2+x3=0
4x1+2x2+x3=564x1+2x2+x3=56
2x1−x2+4x3=42x1−x2+4x3=4

首先保持第一个方程不变，然后消除第二个和第三个方程中的 x1x1。对于第二个方程，方法是让第二个方程式减去第一个方程式的两倍，方程的左侧为：

(4x1+2x2+x3)−2(2x1+x2+x3)=−x3(4x1+2x2+x3)−2(2x1+x2+x3)=−x3

方程的右侧变为：

56−2⋅0=5656−2·0=56

所以第二个方程变为：

−x3=56−x3=56

这样三个方程式就变为：

2x1+x2+x3=02x1+x2+x3=0
−x3=56−x3=56
2x1−x2+4x3=42x1−x2+4x3=4

对于第三个方程同样如此，我们需要去掉其中的 x1x1。方法是让第三个方程减去第一个方程，之后三个方程式变为：

2x2+x2+x3=02x2+x2+x3=0
−x3=56−x3=56
−2x2+3x3=4−2x2+3x3=4

至此，我们使用第一个方程式作为参照，消除了第二个和第三个方程式中的 x1x1，我们称这里的第一个方程式为“主元行”。

接下来，我们要把第二个方程式作为“主元行”，来消除第三个方程中的 x2x2。你应该能发现，第二个方程中的 x2x2 已经没有了，失去了参照，这个时候我们需要把第二个方程和第三个方程互换，变为：

2x1+x2+x3=02x1+x2+x3=0
−2x1+3x3=4−2x1+3x3=4
−x3=56−x3=56

到了这个时候，由于第三个方程以及没有 x2x2 了，所以无需再消元。如果还有 x2x2，那么就需要参照第二个方程式来消除第三个方程中的 x2x2。

观察一下现在的方程组，第一个方程有 3 个自变量，第二个方程有 2 个自变量，第三个方程只有 1 个自变量。这个时候，我们就可以从第三个方程开始，开始回代的过程了。通过第三个方程，显然我们可以得到 x3=−56x3=−56，然后把这个值代入第二个方程，就可以得到 x2=−86x2=−86。最后把 x2x2 和 x3x3 的值代入第一个方程式，我们可以得到 x1=71x1=71。

使用矩阵实现高斯消元法

如果方程和元的数量很小，那么高斯消元法并不难理解。可是如果方程和元的数量很多，整个过程就变得比较繁琐了。实际上，我们可以把高斯消元法转为矩阵的操作，便于自己的理解和记忆。

为了进行矩阵操作，首先我们要把方程中的系数 bibi 转成矩阵，我们把这个矩阵记作 BB。对于上面的方程组示例，系数矩阵为：

那么，最终我们通过消元，把系数矩阵 B 变为：

从次可以看出，消元的过程就是把原始的系数矩阵变为上三角矩阵。这里的上三角矩阵表示，矩阵中只有主对角线以及主对角线以上的三角部分里有数字。我们用 UU 表示上三角矩阵。

而回代呢，我们最终得到的结果是：

x1=71x1=71
x2=−86x2=−86
x3=−56x3=−56

我们可以把这几个结果看作：

1⋅x1+0⋅x2+0⋅x3=711·x1+0·x2+0·x3=71
0⋅x1+1⋅x2+0⋅x3=−860·x1+1·x2+0·x3=−86
0⋅x1+0⋅x2+1⋅x3=−560·x1+0·x2+1·x3=−56

再把系数写成矩阵的形式，就是：

发现没？这其实就是单位矩阵。所以说，回代的过程是把上三角矩阵变为单位矩阵的过程。

为了便于后面的回代计算，我们也可以把方程式等号右边的值加入到系数矩阵，我们称这个新的矩阵为增广矩阵，我把这个矩阵记为 AA。

好，现在让我们来观察一下这个增广矩阵 AA。

对于这个矩阵，我们的最终目标是，把除了最后一列之外的部分，变成单位矩阵，而此时最后一列中的每个值，就是每个自变量所对应的解了。

之前我已经讲过矩阵相乘在向量空间模型、PageRank 算法和协同过滤推荐中的应用。这里，我们同样可以使用这种操作来进行消元。为了方便你理解，我们可以遵循之前消元的步骤一步步来看。

还记得这个方程组消元的第一步吗？对，首先保持第一个方程不变，然后消除第二个和第三个方程中的 x1x1。这就意味着要把 A2，1A2，1 和 A3A3，11 变为 00。

对于第一个方程式，如果要保持它不变，我们可以让向量 [1,0,0][1,0,0] 左乘 AA。对于第二个方程，具体操作是让第二个方程式减去第一个方程式的两倍，达到消除 x1x1 的目的。我们可以让向量 [−2,1,0][−2,1,0] 左乘 AA。对于第三个方程式，具体操作是让第三个方程式减去第一个方程式，达到消除 x1x1 的目的。我们可以让向量 [−1,0,1][−1,0,1] 左乘 AA。我们使用这三个行向量组成一个矩阵 E1E1。

因此，我们可是用下面这个矩阵 E1E1 和 AA 的点乘，来实现消除第二个和第三个方程式中 x1x1 的目的。

你会发现，由于使用了增广矩阵，矩阵中最右边的一列，也就是方程等号右边的数值也会随之发生改变。

下一步是消除第三个方程中的 x2x2。依照之前的经验，我们要把第二个方程式作为“主元行”，来消除第三个方程中的 x2x2。可是第二个方程中的 x2x2 已经没有了，失去了参照，这个时候我们需要把第二个方程和第三个方程互换。这种互换的操作如何使用矩阵来实现呢？其实不难，例如使用下面这个矩阵 E2E2 左乘增广矩阵 AA。

上面这个矩阵第一行 [100][100] 的意思就是我们只取第一行的方程，而第二行 [001][001] 的意思是只取第三个方程，而第三行 [010][010] 表示只取第二个方程。

我们先让 E1E1 左乘 AA，然后再让 E2E2 左乘 E1AE1A 的结果，就能得到消元后的系数矩阵。

我们把 E1E1 点乘 E2E2 的结果记作 E3E3，并把 E3E3 称为消元矩阵。

对于目前的结果矩阵来说，除了最后一列，它已经变成了一个上三角矩阵，也就是说消元步骤完成。接下来，我们要使得最后一列之外的部分变成一个单位矩阵，就能得到最终的方程组解。和消元不同的是，我们将从最后一行开始。对于最后一个方程，我们只需要把所有系数取反就行了，所以会使用下面这个矩阵 S1S1 实现。

接下来要去掉第二个方程中的 x3x3，我们要把第二个方程减去 3 倍的第三个方程，然后除以 -2。首先是减去 3 倍的第三个方程。

然后把第二个方程除以 -2。

最后，对于第一个方程，我们要把第一个方程减去第二个和第三个方程，最后除以 2，我把这几步合并了，并列在下方。

最终，结果矩阵的最后一列就是方程组的解。我们把回代部分的矩阵，都点乘起来。

而消元矩阵 E3E3 为：

我们可以让矩阵 SS 左乘矩阵 E3E3，就会得到下面的结果。

我们把这个矩阵记作 SESE，把乘以最初的系数矩阵 BB，就得到了一个单位矩阵。根据逆矩阵的定义，SESE 就是 BB 的逆矩阵。换个角度来思考，使用消元法进行线性方程组求解的过程，就是在找系数矩阵的逆矩阵的过程。

总结

今天我们一起探讨了求解线性方程组最常见的方法之一，高斯消元法。这个方法主要包含了消元和回代两个步骤。这些步骤都可以使用矩阵的操作来进行。从矩阵的角度来说，消元就是把系数矩阵变为上三角矩阵，而回代是把这个上三角矩阵变为单位矩阵。我们可以直接把用于消元和回代的矩阵，用于由系数和因变量值组成的增广矩阵，并获得最终的方程解。

线性方程组的概念，也是线性回归分析的基础。在线性回归时，我们也能获得由很多观测数据值所组成的方程组。但是，在进行线性回归分析时，方程组的处理方式和普通的方程组求解有一些不同。其中有两个最主要的区别。

第一个区别是，在线性回归分析中，样本数据会告诉我们自变量和因变量的值，要求的是系数。而在线性方程组中，我们已知系数和因变量的值，要求的是自变量的值。

第二个区别是，在线性回归分析中，方程的数量要远远大于自变量的数量，而且我们不要求每个方程式都是完全成立。这里，不要求完全成立的意思是，拟合出来的因变量值可以和样本数据给定的因变量值存在差异，也就允许模型拟合存在误差。模型拟合的概念我在上一模块的总结篇中重点讲解了，所以你应该能理解，模型的拟合不可能 100% 完美，这和我们求解线性方程组精确解的概念是不同的。

正是因为这两点差异，我们无法直接使用消元法来求解线性回归。下一节，我会来详细解释，如何使用最小二乘法来解决线性回归的问题。

思考题

请分别写出下面这个方程组的消元矩阵和回代矩阵，并求出最终的解。

x1−2x2+x3−4x4=4x1−2x2+x3−4x4=4
x2−x3+x4=−3x2−x3+x4=−3
x1+3x2+x4=1x1+3x2+x4=1
−7x2+3x3+x4=−3−7x2+3x3+x4=−3

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

40 | 线性回归（中）：如何使用最小二乘法进行直线拟合？

你好，我是黄申。

上一节，我提到了，求解线性回归和普通的线性方程组最大的不同在于误差ε。在求解线性方程组的时候，我们并不考虑误差的存在，因此存在无解的可能。而线性回归允许误差ε的存在，我们要做的就是尽量把ε最小化，并控制在一定范围之内。这样我们就可以求方程的近似解。而这种近似解对于海量的大数据分析来说是非常重要的。

但是现实中的数据一定存在由于各种各样原因所导致的误差，因此即使自变量和因变量之间存在线性关系，也基本上不可能完美符合这种线性关系。总的来说，线性回归分析并不一定需要 100% 精确，而误差ε的存在可以帮助我们降低对精度的要求。通常，多元线性回归会写作：

y=b0+b1⋅x1+b2⋅x2+…+y=b0+b1·x1+b2·x2+…+
bn−1⋅xn−1+bn⋅xn+εbn−1·xn−1+bn·xn+ε

这里的 x1，x2，…，xnx1，x2，…，xn 是自变量，yy 是因变量，b0b0 是截距，b1b1，b2b2，…，bnbn 是自变量的系数，εε 是随机误差。

在线性回归中，为了实现最小化 εε 的目标，我们可以使用最小二乘法进行直线的拟合。最小二乘法通过最小化误差的平方和，来寻找和观测数据匹配的最佳函数。由于这些内容有些抽象，下面我会结合一些例子来解释最小二乘法的核心思想，以及如何使用这种方法进行求解。

使用观测值拟合

在详细阐述最小二乘法之前，我们先来回顾一下第 32 讲介绍的模型拟合。在监督式学习中，拟合模型其实是指通过模型的假设和训练样本，推导出具体参数的过程。有了这些参数，我们就能对新的数据进行预测。而在线性回归中，我们需要找到观测数据之间的线性关系。

假设我们有两个观测数据，对应于二维空间中的两个点，这两个点可以确定唯一的一条直线，两者呈现线性关系。你可以参考下面这张图。

之后，我们又加入了一个点。这个点不在原来的那条直线上。

这个时候，从线性方程的角度来看，就不存在精确解了。因为没有哪条直线能同时穿过这三个点。这张图片也体现了线性回归分析和求解线性方程组是不一样的，线性回归并不需要求精确解。

如果我们加入更多的观察点，就更是如此了。比如下面这张图。

从上图中你应该可以看出，这根直线不是完全精准地穿过这些点，而只是经过了其中两个，大部分点和这根直线有一定距离。这个时候，线性回归就有用武之地了。

由于我们假设ε的存在，因此在线性回归中，我们允许某条直线只穿过其中少量的点。不过，既然我们允许这种情况发生，那么就存在无穷多这样的直线。比如下面我随便画了几条，都是可以的。

当然，我们从直觉出发，一定不会选取那些远离这些点的直线，而是会选取尽可能靠近这些点的那些线。比如下面这张图里展示的这两条。

好了，即然这样，我们就需要定义哪根线是最优的，以及在给出了最优的定义之后，如何能求解出这条最优的直线呢？最小二乘法可以回答这两个问题，下面我们具体来看。

最小二乘法

最小二乘法的主要思想就是求解未知参数，使得理论值与观测值之差（即误差，或者说残差）的平方和达到最小。我们可以使用下面这个公式来描述。

其中，yiyi 表示来自数据样本的观测值，而 yy^ 是假设的函数的理论值，εε 就是我们之前提到的误差，在机器学习中也常被称为损失函数，它是观测值和真实值之差的平方和。最小二乘法里的“二乘”就是指的平方操作。有了这个公式，我们的目标就很清楚了，就是要发现使ε最小化时候的参数。

那么最小二乘法是如何利用最小化 εε 的这个条件来求解的呢？让我们从矩阵的角度出发来理解整个过程。

有了上面的定义之后，我们就可以写出最小二乘问题的矩阵形式。

min||XB−Y||22min||XB−Y||22

其中 BB 为系数矩阵，XX 为自变量矩阵，YY 为因变量矩阵。换句话说，我们要在向量空间中，找到一个 BB，使向量 XBXB 与 YY 之间欧氏距离的平方数最小的 BB。

结合之前所讲的矩阵点乘知识，我们把上述式子改写为：

||XB−Y||22=tr((XB−Y)′(XB−Y))||XB−Y||22=tr((XB−Y)′(XB−Y))

其中 (XB−Y)′(XB−Y)′ 表示矩阵 (XB−Y)(XB−Y) 的转置。而 tr()tr() 函数表示取对角线上所有元素的和，对于某个矩阵 AA 来说，tr(A)tr(A) 的值计算如下：

进一步，根据矩阵的运算法则，我们有：

tr((XB−Y)′(XB−Y))tr((XB−Y)′(XB−Y))
=tr(B′X′−Y′)(XB−Y)=tr(B′X′−Y′)(XB−Y)
=tr(B′X′XB−B′X′Y−Y′XB+Y′Y)=tr(B′X′XB−B′X′Y−Y′XB+Y′Y)

因此我们可以得到：

||XB−Y||22||XB−Y||22
=tr((XB−Y)′(XB−Y))=tr((XB−Y)′(XB−Y))
=tr(B′X′−Y′)(XB−Y)=tr(B′X′−Y′)(XB−Y)
=tr(B′X′XB−B′X′Y−Y′XB+Y′Y)=tr(B′X′XB−B′X′Y−Y′XB+Y′Y)

我们知道，求最极值问题直接对应的就是导数为 0，因此我对上述的矩阵形式进行求导，得到如下的式子：

d||XB−Y||22dBd||XB−Y||22dB
=d(tr(B′X′XB−B′X′Y−Y′XB+Y′Y))dB=d(tr(B′X′XB−B′X′Y−Y′XB+Y′Y))dB
=X′XB+X′XB−X′Y−X′Y=X′XB+X′XB−X′Y−X′Y
=2X′XB−2X′Y=2X′XB−2X′Y

如果要 ||XB−Y||22||XB−Y||22 最小，就要满足两个条件。

第一个条件是 d||XB−Y||22dBd||XB−Y||22dB 为 0，也就是 2X′XB−2X′Y=02X′XB−2X′Y=0。

第二个条件是 d(2X′XB−2X′Y)dB>0d(2X′XB−2X′Y)dB>0。

由于 d(2X′XB−2X′Y)dB=2X′X>0d(2X′XB−2X′Y)dB=2X′X>0，所以，第二个条件是满足的。只要 2X′XB=2X′Y2X′XB=2X′Y。

我们就能获得 εε 的最小值。从这个条件出发，我们就能求出矩阵 BB：

2X′XB=2X′Y2X′XB=2X′Y
X′XB=X′YX′XB=X′Y
(X′X)−1X′XB=(X′X)−1X′Y(X′X)−1X′XB=(X′X)−1X′Y
IB=(X′X)−1X′YIB=(X′X)−1X′Y
B=(X′X)−1X′YB=(X′X)−1X′Y

其中 II 为单位矩阵。而 (X′X)−1(X′X)−1 表示 X′XX′X 的逆矩阵。所以，最终系数矩阵为：

B=(X′X)−1X′YB=(X′X)−1X′Y

补充证明和解释

为了保持推导的连贯性，在上述的推导过程中，我跳过了几个步骤的证明。下面我会给出详细的解释，供你更深入的学习和研究。

步骤 a：

(XB)′=B′X′(XB)′=B′X′

证明：

对于 XBXB 中的每个元素 xbi,jxbi,j，有：

而对于 (XB)′(XB)′ 中的每个元素 xb′i,jxbi,j′，有：

对于 B′B′ 中的每个元素有：

$b’{i,k}=b{k,i}$

X′X′ 中的每个元素有：

$x’{k,j}=x{j,k}$

那么，对于 B′X′B′X′ 中的每个元素 b′x′i,jb′xi,j′，就有：

所以有 (XB)′=B′X′(XB)′=B′X′。

步骤 b：

(XB−Y)′=B′X′−Y′(XB−Y)′=B′X′−Y′

证明：

和步骤 a 类似，对于 XB−YXB−Y 中的每个元素 xb−y′i,jxb−yi,j′ 有：

步骤 c：

d(tr(B′X′Y))dB=X′Yd(tr(B′X′Y))dB=X′Y

证明：

同理，可以证明：

d(tr(Y′XB))dB=(Y′X)′=X′Yd(tr(Y′XB))dB=(Y′X)′=X′Y

步骤 d：

d(tr(B′X′XB))dB=2X′XBd(tr(B′X′XB))dB=2X′XB

证明：

d(tr(B′X′XB))dBd(tr(B′X′XB))dB
=d(tr(B′(X′XB)))dB+d(tr((B′X′X)B))dB=d(tr(B′(X′XB)))dB+d(tr((B′X′X)B))dB
=(X′XB)+(B′X′X)′=(X′XB)+(B′X′X)′
=X′XB+X′XB=X′XB+X′XB
=2X′XB=2X′XB

步骤 e：

常量对于变量求导为 0，例如：

d(Y′Y)dB=0d(Y′Y)dB=0

好了，弄明白了这些细节上的证明，你就能更好地理解最小二乘法中的推导步骤。不过，你可能还是会奇怪，为什么最终要对矩阵求导数来求ε的最小值。最后，我们就聊聊如何使用求导获取极小值。

极值是一个函数的极大值或极小值。如果一个函数在一点的某个邻域内每个地方都有确定的值，而以该点所对应的值是最大（小）的，那么这函数在该点的值就是一个极大（小）值。而函数的极值可以通过它的一阶和二阶导数来确定。

对于一元可微函数 f(x)f(x)，它在某点 x0x0 有极值的充分必要条件是 f(x)f(x) 在 x0x0 的邻域上一阶可导，在 x0x0 处二阶可导，且一阶导数 f′(x0)=0f′(x0)=0，二阶导数 f′′(x0)≠0f″(x0)≠0。其中 f′f′ 和 f′′f″ 分别表示一阶导数和二阶导数。

在一阶导数 f′(x0)=0f′(x0)=0 的情况下，如果 f′′(x0)<0f″(x0)<0，则 ff 在 x0x0 取得极大值；如果 f′′(x0)>0f″(x0)>0，则 ff 在 x0x0 取得极小值。这就是为什么在求矩阵 BB 的时候，我们要求 2X′XB−2X′Y2X′XB−2X′Y 为 00，并且 2X′XB−2X′Y2X′XB−2X′Y 的导数要大于 00，这样我们才能确保求得极小值。

总结

今天我们探讨了为什么简单的线性方程组无法满足线性函数拟合的需求，最主要的原因就是现实的观测数据往往不是精确的线性关系，存在一定的误差。我们所要做的就是，在允许一定范围的误差前提下，找到一种线性关系，尽量的满足观察数据，使得我们所定义的误差最小。

最小二乘法通过向量空间的欧氏距离之平方，定义了预测值和真实值之间的误差。在给定自变量和因变量的观测值之后，最小二乘法可以帮助我们推导出所有自变量的系数，并最小化误差。我使用矩阵的形式，为你推导了整个过程。

不过，到目前为止，我们都只是从理论上理解最小二乘法，可能你还没有太深的感触。下一节，我会通过一个具体的例子来逐步进行演算，并使用 Python 代码对最终的结果进行验证。

思考题

还记得在 29 讲的线性回归案例吗？我们使用了 Boston Housing 的数据，拟合出了十多个自变量的系数。请使用这些系数，计算 train.csv 中所有样本因变量预测值和真实值之间的误差。你可以使用 Python 代码来实现一下。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

41 | 线性回归（下）：如何使用最小二乘法进行效果验证？

你好，我是黄申。

上一节我们已经解释了最小二乘法的核心思想和具体推导过程。今天我们就用实际的数据操练一下，这样你的印象就会更加深刻。我会使用几个具体的例子，演示一下如何使用最小二乘法的结论，通过观测到的自变量和因变量值，来推算系数，并使用这个系数来进行新的预测。

基于最小二乘法的求解

假想我们手头上有一个数据集，里面有 3 条数据记录。每条数据记录有 2 维特征，也就是 2 个自变量，和 1 个因变量。

如果我们假设这些自变量和因变量都是线性的关系，那么我们就可以使用如下这种线性方程，来表示数据集中的样本：

b1⋅0+b2⋅1=1.5b1·0+b2·1=1.5
b1⋅1−b2⋅1=−0.5b1·1−b2·1=−0.5
b1⋅2+b2⋅8=14b1·2+b2·8=14

也就是说，我们通过观察数据已知了自变量 x1x1、x2x2 和因变量 _y_ 的值，而要求解的是 b1b1 和 b2b2 这两个系数。如果我们能求出 b1b1 和 b2b2，那么在处理新数据的时候，就能根据新的自变量 x1x1 和 x2x2 的取值，来预测 yy 的值。

可是我们说过，由实际项目中的数据集所构成的这类方程组，在绝大多数情况下，都没有精确解。所以这个时候我们没法使用之前介绍的高斯消元法，而是要考虑最小二乘法。根据上一节的结论，我们知道对于系数矩阵 BB，有：

B=(X′X)−1X′YB=(X′X)−1X′Y

既然有了这个公式，要求 BB 就不难了，让我们从最基本的几个矩阵开始。

矩阵 (X′X)−1(X′X)−1 的求解稍微繁琐一点。逆矩阵的求法我还没讲解过，之前我们说过线性方程组之中，高斯消元和回代的过程，就是把系数矩阵变为单位矩阵的过程。我们可以利用这点，来求解 X−1X−1。我们把原始的系数矩阵 XX 列在左边，然后把单位矩阵列在右边，像 [X|I][X|I] 这种形式，

其中 II 表示单位矩阵。

然后我们对左侧的矩阵进行高斯消元和回代，把左边矩阵 X 变为单位矩阵。同时，我们也把这个相应的矩阵操作运用在右侧。这样当左侧变为单位矩阵之后，那么右侧的矩阵就是原始矩阵 XX 的逆矩阵 X−1X−1，具体证明如下：

[X|I][X|I]
[X−1X|X−1I][X−1X|X−1I]
[I|X−1I][I|X−1I]
[I|X−1][I|X−1]

好了，给定下面的 X′XX′X 矩阵之后，我们使用上述方法来求 (X′X)−1(X′X)−1 。我把具体的推导过程列在了这里。

求出 (X′X)−1(X′X)−1 之后，我们就可以使用 B=(X′X)−1X′YB=(X′X)−1X′Y 来计算矩阵 B。

最终，我们求出系数矩阵为 [11.5][11.5]，也就是说 b1=1b1=1, b2=1.5b2=1.5。实际上，这两个数值是精确解。我们用高斯消元也是能获得同样结果的。接下来，让我稍微修改一下 yy 值，让这个方程组没有精确解。

b1⋅0+b2⋅1=1.4b1·0+b2·1=1.4
b1⋅1−b2⋅1=−0.48b1·1−b2·1=−0.48
b1⋅2+b2⋅8=13.2b1·2+b2·8=13.2

你可以尝试高斯消元法对这个方程组求解，你会发现只要两个方程就能求出解，但是无论是哪两个方程求出的解，都无法满足第三个方程。

那么通过最小二乘法，我们能不能求导一个近似解，保证 _ε_ 足够小呢？下面，让我们遵循之前求解 (X′X)−1X′Y(X′X)−1X′Y 的过程，来计算 BB。

计算完毕之后，你会发现两个系数的值分别变为 b1=0.938,b2=1.415b1=0.938,b2=1.415。由于这不是精确解，所以让我们看看有了这系数矩阵 BB 之后，原有的观测数据中，真实值和预测值的差别。

首先我们通过系数矩阵 BB 和自变量矩阵 XX 计算出来预测值。

然后是样本数据中的观测值。这里我们假设这些值是真实值。

根据误差 εε 的定义，我们可以得到：

说到这里，你可能会怀疑，通过最小二乘法所求得的系数 b1=0.949b1=0.949 和 b2=1.415b2=1.415，是不是能让 εε 最小呢？这里，我们随机的修改一下这两个系数，变为 b1=0.95b1=0.95 和 b2=1.42b2=1.42，然后我们再次计算预测的 yy 值和 εε。

很明显，0.064 是大于之前的 0.0158。

这两次计算预测值 _y_ 的过程，其实也是我们使用线性回归，对新的数据进行预测的过程。简短地总结一下，线性回归模型根据大量的训练样本，推算出系数矩阵 BB，然后根据新数据的自变量 XX 向量或者矩阵，计算出因变量的值，作为新数据的预测。

Python 代码实现

这一部分，我们使用 Python 的代码，来验证一下之前的推算结果是不是正确，并看看最小二乘法和 Python sklearn 库中的线性回归，这两种结果的对比。

首先，我们使用 Python numpy 库中的矩阵操作来实现最小二乘法。主要的函数操作涉及矩阵的转置、点乘和求逆。具体的代码和注释我列在了下方。

from numpy import *

x = mat([[0,1],[1,-1],[2,8]])

y = mat([[1.4],[-0.48],[13.2]])

# 分别求出矩阵 X'、X'X、(X'X) 的逆

# 注意，这里的 I 表示逆矩阵而不是单位矩阵

print("X 矩阵的转置 X'：\n", x.transpose())

print("\nX'点乘 X：\n", x.transpose().dot(x))

print("\nX'X 矩阵的逆\n", (x.transpose().dot(x)).I)

print("\nX'X 矩阵的逆点乘 X'\n", (x.transpose().dot(x)).I.dot(x.transpose()))

print("\n 系数矩阵 B：\n", (x.transpose().dot(x)).I.dot(x.transpose()).dot(y))

通过上述代码，你可以看到每一步的结果，以及最终的矩阵 BB。你可以把输出结果和之前手动推算的结果进行对比，看看是不是一致。

除此之外，我们还可把最小二乘法的线性拟合结果和 sklearn 库中的 LinearRegression().fit() 函数的结果相比较，具体的代码和注释我也放在了这里。

import pandas as pd

from sklearn.linear_model import LinearRegression

df = pd.read_csv("/Users/shenhuang/Data/test.csv")

df_features = df.drop(['y'], axis=1)     #Dataframe 中除了最后一列，其余列都是特征，或者说自变量

df_targets = df['y']                     #Dataframe 最后一列是目标变量，或者说因变量

print(df_features, df_targets)

regression = LinearRegression().fit(df_features, df_targets)        # 使用特征和目标数据，拟合线性回归模型

print(regression.score(df_features, df_targets))        # 拟合程度的好坏

print(regression.intercept_)

print(regression.coef_)            # 各个特征所对应的系数

其中，test.csv 文件的内容我也列在了这里。

x1,x2,yx1,x2,y
0,1,1.40,1,1.4
1,−1,−0.481,−1,−0.48
2,8,13.22,8,13.2

这样写是为了方便我们使用 pandas 读取 csv 文件并加载为 dataframe。

在最终的结果中，1.0 表示拟合程度非常好，而 -0.014545454545452863 表示一个截距，[0.94909091 1.41454545] 表示系数 b1b1 和 b2b2 的值。这个结果和我们最小二乘法的结果有所差别，主要原因是 LinearRegression().fit() 默认考虑了有线性函数存在截距的情况。那么我们使用最小二乘法是不是也可以考虑有截距的情况呢？答案是肯定的，不过我们首先要略微修改一下方程组和矩阵 XX。如果我们假设有截距存在，那么线性回归方程就要改写为：

b0+b1⋅x1+b2⋅x2+…+bn−1⋅xn−1+bn⋅xn=yb0+b1·x1+b2·x2+…+bn−1·xn−1+bn·xn=y

其中，b0b0 表示截距，而我们这里的方程组用例就要改写为：

b0+b1⋅0+b2⋅1=1.4b0+b1·0+b2·1=1.4
b0+b1⋅1−b2⋅1=−0.48b0+b1·1−b2·1=−0.48
b0+b1⋅2+b2⋅8=13.2b0+b1·2+b2·8=13.2

而矩阵 XX 要改写为：

然后我们再执行下面这段代码。

from numpy import *

x = mat([[1,0,1],[1,1,-1],[1,2,8]])

y = mat([[1.4],[-0.48],[13.2]])

print("\n 系数矩阵 B：\n", (x.transpose().dot(x)).I.dot(x.transpose()).dot(y))

你就会得到：

 系数矩阵 B：

     [[-0.01454545]

     [ 0.94909091]

     [ 1.41454545]]

这个结果和 LinearRegression().fit() 的结果就一致了。

需要注意的是，使用线性回归的时候，我们都有一个前提假设，那就是数据的自变量和因变量之间现线性关系。如果不是线性关系，那么使用线性模型来拟合的效果一定不好。比如，之前在解释欠拟合的时候，我用过下面这个例子。

上面这张图的数据分布并没有表达线性关系，所以我们需要对原始的数据进行非线性的变换，或者是使用非线性的模型来拟合。

那么，我们如何判断一个数据集是不是能用线性模型表示呢？在线性回归中，我们可以使用决定系数 R2。这个统计指标使用了回归平方和与总平方和之比，是反映模型拟合度的重要指标。它的取值在 0 到 1 之间，越接近于 1 表示拟合的程度越好、数据分布越接近线性关系。随着自变量个数的增加，R2 将不断增大，因此我们还需要考虑方程所包含的自变量个数对 R2 的影响，这个时候可使用校正的决定系数 Rc2。所以，在使用各种科学计算库进行线性回归时，你需要关注 R2 或者 Rc2，来看看是不是一个好的线性拟合。在之前的代码实践中，我们提到的 regression.score 函数，其实就是返回了线性回归的 R2。

总结

今天我们使用了具体的案例来推导最小二乘法的计算过程，并用 Python 代码进行了验证。通过最近 3 节的讲解，相信你对线性方程组求精确解、求近似解、以及如何在线性回归中运用这些方法，有了更加深入的理解。

实际上，从广义上来说，最小二乘法不仅可以用于线性回归，还可以用于非线性的回归。其主要思想还是要确保误差ε最小，但是由于现在的函数是非线性的，所以不能使用求多元方程求解的办法来得到参数估计值，而需要采用迭代的优化算法来求解，比如梯度下降法、随机梯度下降法和牛顿法。

思考题

我这里给出一个新的方程组，请通过最小二乘法推算出系数的近似解，并使用你熟悉的语言进行验证。

b1+b2⋅3+b3⋅(−7)=−7.5b1+b2·3+b3·(−7)=−7.5
b1⋅2+b2⋅5+b3⋅4=5.2b1·2+b2·5+b3·4=5.2
b1⋅(−3)+b2⋅(−7)+b3⋅(−2)=−7.5b1·(−3)+b2·(−7)+b3·(−2)=−7.5
b1⋅1+b2⋅4+b3⋅(−12)=−15b1·1+b2·4+b3·(−12)=−15

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

42 | PCA主成分分析（上）：如何利用协方差矩阵来降维？

你好，我是黄申。

在概率统计模块，我详细讲解了如何使用各种统计指标来进行特征的选择，降低用于监督式学习的特征之维度。接下来的几节，我会阐述两种针对数值型特征，更为通用的降维方法，它们是主成分分析 PCA（Principal Component Analysis）和奇异值分解 SVD（Singular Value Decomposition）。这两种方法是从矩阵分析的角度出发，找出数据分布之间的关系，从而达到降低维度的目的，因此并不需要监督式学习中样本标签和特征之间的关系。

PCA 分析法的主要步骤

我们先从主成分分析 PCA 开始看。

在解释这个方法之前，我先带你快速回顾一下什么是特征的降维。在机器学习领域中，我们要进行大量的特征工程，把物品的特征转换成计算机所能处理的各种数据。通常，我们增加物品的特征，就有可能提升机器学习的效果。可是，随着特征数量不断的增加，特征向量的维度也会不断上升。这不仅会加大机器学习的难度，还会影响最终的准确度。针对这种情形，我们需要过滤掉一些不重要的特征，或者是把某些相关的特征合并起来，最终达到在减少特征维度的同时，尽量保留原始数据所包含的信息。

了解了这些，我们再来看今天要讲解的 PCA 方法。它的主要步骤其实并不复杂，我一说你就能明白，但是为什么要这么做，你可能并不理解。咱们学习一个概念或者方法，不仅要知道它是什么，还要明白是怎么来的，这样你就能知其然，知其所以然，明白背后的逻辑，达到灵活运用。因此，我先从它的运算步骤入手，给你讲清楚每一步，然后再解释方法背后的核心思想。

和线性回归的案例一样，我们使用一个矩阵来表示数据集。我们假设数据集中有 m 个样本、n 维特征，而这些特征都是数值型的，那么这个集合可以按照如下的方式来展示。

那么这个样本集的矩阵形式就是这样的：

这个矩阵是 m×n 维的，其中每一行表示一个样本，而每一列表示一维特征。让我们把这个矩阵称作样本矩阵，现在，我们的问题是，能不能通过某种方法，找到一种变换，可以降低这个矩阵的列数，也就是特征的维数，并且尽可能的保留原始数据中有用的信息？

针对这个问题，PCA 分析法提出了一种可行的解决方案。它包括了下面这样几个主要的步骤：

标准化样本矩阵中的原始数据；

获取标准化数据的协方差矩阵；

计算协方差矩阵的特征值和特征向量；

依照特征值的大小，挑选主要的特征向量；

生成新的特征。

下面，我们一步步来看。

1. 标准化原始数据

之前我们已经介绍过特征标准化，这里我们需要进行同样的处理，才能让每维特征的重要性具有可比性。为了便于你回顾，我把标准化的公式列在了这里。

x′=x−μσx′=x−μσ

其中 xx 为原始值，uu 为均值，σσ 为标准差，x′x′ 是变换后的值。需要注意的是，这里标准化的数据是针对同一种特征，也是在同一个特征维度之内。不同维度的特征不能放在一起进行标准化。

2. 获取协方差矩阵

首先，我们来看一下什么是协方差（Covariance），以及协方差矩阵。协方差是用于衡量两个变量的总体误差。假设两个变量分别是 xx 和 yy，而它们的采样数量都是 mm，那么协方差的计算公式就是如下这种形式：

其中 xkxk 表示变量 xx 的第 kk 个采样数据，x¯x¯ 表示这 kk 个采样的平均值。而当两个变量是相同时，协方差就变成了方差。

那么，这里的协方差矩阵又是什么呢？我们刚刚提到了样本矩阵，假设 X,1X,1 表示样本矩阵 XX 的第 11 列，X,2X,2 表示样本矩阵 XX 的第 22 列，依次类推。而 cov(X,1,X,1)cov(X,1,X,1) 表示第 1 列向量和自己的协方差，而 cov(X,1,X,2)cov(X,1,X,2) 表示第 1 列向量和第 2 列向量之间的协方差。结合之前协方差的定义，我们可以得知：

其中，xk,ixk,i 表示矩阵中第 kk 行，第 ii 列的元素。 X,i¯X,i¯ 表示第 ii 列的平均值。

有了这些符号表示，我们就可以生成下面这种协方差矩阵。

从协方差的定义可以看出，cov(X,i,X,j)=cov(X,j,X,i)cov(X,i,X,j)=cov(X,j,X,i)，所以 COVCOV 是个对称矩阵。另外，我们刚刚提到，对于 cov(X,i,X,j)cov(X,i,X,j)，如果 i=ji=j，那么 cov(X,i,X,j)cov(X,i,X,j) 也就是 X,jX,j 这组数的方差。所以这个对称矩阵的主对角线上的值就是各维特征的方差。

3. 计算协方差矩阵的特征值和特征向量

需要注意的是，这里所说的矩阵的特征向量，和机器学习中的特征向量（Feature Vector）完全是两回事。矩阵的特征值和特征向量是线性代数中两个非常重要的概念。对于一个矩阵 XX，如果能找到向量 vv 和标量 λλ，使得下面这个式子成立。

Xv=λvXv=λv

那么，我们就说 vv 是矩阵 XX 的特征向量，而 λλ 是矩阵 XX 的特征值。矩阵的特征向量和特征值可能不止一个。说到这里，你可能会好奇，特征向量和特征值表示什么意思呢？我们为什么要关心这两个概念呢？简单的来说，我们可以把向量 vv 左乘一个矩阵 XX 看做对 vv 进行旋转或拉伸，而这种旋转和拉伸都是由于左乘矩阵 XX 后，所产生的“运动”所导致的。特征向量 vv 表示了矩阵 XX 运动的方向，特征值 λλ 表示了运动的幅度，这两者结合就能描述左乘矩阵 XX 所带来的效果，因此被看作矩阵的“特征”。在 PCA 中的主成分，就是指特征向量，而对应的特征值的大小，就表示这个特征向量或者说主成分的重要程度。特征值越大，重要程度越高，我们要优先现在这个主成分，并利用这个主成分对原始数据进行变换。

如果你还是有些困惑，我会在下面一节，讲解更多的细节。现在，让我们先来看看给定一个矩阵，如何计算它的特征值和特征向量，并完成 PCA 分析的剩余步骤。我在下面列出了计算特征值的推导过程：

Xv=λvXv=λv
Xv−λv=0Xv−λv=0
Xv−λIv=0Xv−λIv=0
(X−λI)v=0(X−λI)v=0

其中 I 是单位矩阵。对于上面推导中的最后一步，我们需要计算矩阵的行列式。

(x1,1−λ)(x2,2−λ)…(xn,n−λ)+x1,2x2,3…xn−1,nxn,1+…)−(xn,1xn−1,2…x2,n−1x1,n)=0(x1,1−λ)(x2,2−λ)…(xn,n−λ)+x1,2x2,3…xn−1,nxn,1+…)−(xn,1xn−1,2…x2,n−1x1,n)=0

最后，通过解这个方程式，我们就能求得各种λ的解，而这些解就是特征值。计算完特征值，我们可以把不同的λ值代入 λE−AλE−A，来获取特征向量。

4. 挑选主要的特征向量，转换原始数据

假设我们获得了 k 个特征值和对应的特征向量，那么我们就有：

Xv1=λ1v1Xv1=λ1v1
Xv2=λ2v2Xv2=λ2v2
……
Xvk=λkvkXvk=λkvk

按照所对应的λ数值的大小，对这 k 组的 v 排序。排名靠前的 v 就是最重要的特征向量。

假设我们只取前 k1 个最重要的特征，那么我们使用这 k1 个特征向量，组成一个 n×k1 维的矩阵 D。

把包含原始数据的 m×n 维矩阵 X 左乘矩阵 D，就能重新获得一个 m×k1 维的矩阵，达到了降维的目的。

有的时候，我们无法确定 k1 取多少合适。一种常见的做法是，看前 k1 个特征值的和占所有特征值总和的百分比。假设一共有 10 个特征值，总和是 100，最大的特征值是 80，那么第一大特征值占整个特征值之和的 80%，我们认为它能表示 80% 的信息量，还不够多。那我们就继续看第二大的特征值，它是 15，前两个特征值之和有 95，占比达到了 95%，如果我们认为足够了，那么就可以只选前两大特征值，把原始数据的特征维度从 10 维降到 2 维。

小结

这一节，我首先简要地重温了为什么有时候需要进行特征的降维和基于分类标签的特征选择。随后，我引出了和特征选择不同的另一种方法，基于矩阵操作的 PCA 主成分分析。这种方法的几个主要步骤包括，标准化原始数据、获得不同特征的协方差矩阵、计算协方差矩阵的特征值和特征向量、选择最重要的主成分，以及通过所选择的主成分来转换原始的数据集。

要理解 PCA 分析法是有一定难度的，主要是因为两点原因：第一，计算的步骤有些复杂。第二，这个方法的核心思路有些抽象。这两点可能会让刚刚接触 PCA 的学习者，感到无从下手。

为了帮助你更好的理解，下一节，我会使用一个示例的矩阵进行详细的推算，并用两种 Python 代码进行结果的验证。除此之外，我还会分析几个要点，包括 PCA 为什么使用协方差矩阵？这个矩阵的特征值和特征向量又表示什么？为什么特征值最大的主成分涵盖最多的信息量？明白了这些，你就能深入理解为什么 PCA 分析法要有这些步骤，以及每一步都代表什么含义。

思考题

给定这样一个矩阵：

假设这个矩阵的每一列表示一个特征的维度，每一行表示一个样本。请完成

按照列（也就是同一个特征维度）进行标准化。

生成这个矩阵的协方差矩阵。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

43 | PCA主成分分析（下）：为什么要计算协方差矩阵的特征值和特征向量？

你好，我是黄申，今天我们继续来聊 PCA 主成分分析的下半部分。

上一节，我们讲解了一种特征降维的方法：PCA 主成分分析。这个方法主要是利用不同维度特征之间的协方差，构造一个协方差矩阵，然后获取这个矩阵的特征值和特征向量。根据特征值的大小，我们可以选取那些更为重要的特征向量，或者说主成分。最终，根据这些主成分，我们就可以对原始的数据矩阵进行降维。

PCA 方法的操作步骤有些繁琐，并且背后的理论支持也不是很直观，因此对于初学者来说并不好理解。考虑到这些，我今天会使用一个具体的矩阵示例，详细讲解每一步操作的过程和结果，并辅以基于 Python 的核心代码进行分析验证。除此之外，我还会从多个角度出发，分析 PCA 方法背后的理论，帮助你进一步的理解和记忆。

基于 Python 的案例分析

这么说可能有一些抽象，让我使用一个具体的案例来帮你理解。假设我们有一个样本集合，包含了 3 个样本，每个样本有 3 维特征 x1x1，x2x2 和 x3x3。

在标准化的时候，需要注意的是，我们的分母都使用 m 而不是 m-1，这是为了和之后 Python 中 sklearn 库的默认实现保持一致。

首先需要获取标准化之后的数据。

第一维特征的数据是 1，2，-3。平均值是 0，方差是

1+4+93−−−−−√≈2.161+4+93≈2.16

标准化之后第一维特征的数据是 1/2.16=0.463，2/2.16=0.926，-3/2.16=-1.389。以此类推，我们可以获得第二个维度和第三个维度标准化之后的数据。

当然，全部手动计算工作量不小，这时可以让计算机做它擅长的事情：重复性计算。下面的 Python 代码展示了如何对样本矩阵的数据进行标准化。

from numpy import *

from numpy import linalg as LA

from sklearn.preprocessing import scale

# 原始数据，包含了 3 个样本和 3 个特征，每一行表示一个样本，每一列表示一维特征

x = mat([[1,3,-7],[2,5,-14],[-3,-7,2]])

# 矩阵按列进行标准化

x_s = scale(x, with_mean=True, with_std=True, axis=0)

print(" 标准化后的矩阵：", x_s)

其中，scale 函数使用了 axis=0，表示对列进行标准化，因为目前的矩阵排列中，每一列代表一个特征维度，这点需要注意。如果矩阵排列中每一行代表一个特征维度，那么可以使用 axis=1 对行进行标准化。

最终标准化之后的矩阵是这样的：

接下来是协方差的计算。对于第 1 维向量的方差，有

0.4632+0.9262+(−1.3892)2≈1.50.4632+0.9262+(−1.3892)2≈1.5

第 1 维和第 2 维向量之间的协方差是

0.463×0.508+0.926×0.889+(−1.389)×(−1.397)2≈1.50.463×0.508+0.926×0.889+(−1.389)×(−1.397)2≈1.5

以此类推，我们就可以获得完整的协方差矩阵。同样的，为了减少推算的工作量，我们可以使用 Python 代码获得协方差矩阵。

# 计算协方差矩阵，注意这里需要先进行转置，因为这里的函数是看行与行之间的协方差

x_cov = cov(x_s.transpose())

# 输出协方差矩阵

print(" 协方差矩阵：\n", x_cov, "\n")

和 sklearn 中的标准化函数 scale 有所不同，numpy 中的协方差函数 cov 除以的是 (m-1)，而不是 m。最终完整的协方差矩阵是：

然后，我们要求解协方差矩阵的特征值和特征向量。

最后化简为：

−λ3+4.5λ2=0.343λ=0−λ3+4.5λ2=0.343λ=0
λ(0.0777−λ)(λ−4.4223)=0λ(0.0777−λ)(λ−4.4223)=0

所以 λλ 有 3 个近似解，分别是 0、0.0777 和 4.4223。

特征向量的求解过程如果手动推算比较繁琐，我们还是利用 Python 语言直接求出特征值和对应的特征向量。

# 求协方差矩阵的特征值和特征向量

eigVals,eigVects = LA.eig(x_cov)

print(" 协方差矩阵的特征值：", eigVals)

print(" 协方差的特征向量（主成分）：\n", eigVects, "\n")

我们可以得到三个特征值及它们对应的特征向量。

需要注意，Python 代码输出的特征向量是列向量，而我表格中列出的是行向量。

我使用下面的这段代码，找出特征值最大的特征向量，也就是最重要的主成分，然后利用这个主成分，对原始的样本矩阵进行变换。

# 找到最大的特征值，及其对应的特征向量

max_eigVal = -1

max_eigVal_index = -1

for i in range(0, eigVals.size):

    if (eigVals[i] > max_eigVal):

        max_eigVal = eigVals[i]

        max_eigVal_index = i

    eigVect_with_max_eigVal = eigVects[:,max_eigVal_index]

# 输出最大的特征值及其对应的特征向量，也就是第一个主成分

print(" 最大的特征值：", max_eigVal)

print(" 最大特征值所对应的特征向量：", eigVect_with_max_eigVal)

# 输出变换后的数据矩阵。注意，这里的三个值是表示三个样本，而特征从 3 维变为 1 维了。

print(" 变换后的数据矩阵：", x_s.dot(eigVect_with_max_eigVal), "\n")

很明显，最大的特征值是 4.422311507725755，对应的特征向量是 [-0.58077228 -0.57896098 0.57228292]。变换后的样本矩阵是：

它从原来的 3 个特征维度降维 1 个特征维度了。

Python 的 sklearn 库也实现了 PCA，我们可以通过下面的代码来尝试一下。

from sklearn.decomposition import PCA

# 挑选前 2 个主成分

pca = PCA(n_components=2)

# 进行 PCA 分析

pca.fit(x_s)

# 输出变换后的数据矩阵。注意，这里的三个值是表示三个样本，而特征从 3 维变为 1 维了。

print(" 方差（特征值）: ", pca.explained_variance_)

print(" 主成分（特征向量）", pca.components_)

print(" 变换后的样本矩阵：", pca.transform(x_s))

print(" 信息量: ", pca.explained_variance_ratio_)

这段代码中，我把输出的主成分设置为 2，也就是说挑出前 2 个最重要的主成分。相应的，变化后的样本矩阵有 2 个特征维度。

除了输出主成分和变换后的矩阵，sklearn 的 PCA 分析还提供了信息量的数据。

信息量:  [0.98273589 0.01726411]

它是各个主成分的方差所占的比例，表示第一个主成分包含了原始样本矩阵中的 98.27% 的信息，而第二个主成分包含了原始样本矩阵中的 1.73% 的信息，可想而知，最后一个主成分提供的信息量基本为 0 了，我们可以忽略不计了。如果我们觉得 95% 以上的信息量就足够了，那么就可以只保留第一个主成分，把原始的样本矩阵的特征维度降到 1 维。

当然，学习的更高境界，不是仅仅“知其然”，还要做到“知其所以然”。即使现在你对 PCA 的操作步骤了如指掌，可能还是有不少疑惑，比如，为什么我们要使用协方差矩阵？这个矩阵的特征值和特征向量又表示什么？为什么选择特征值最大的主成分，就能涵盖最多的信息量呢？不用着急，接下来，我会给你做出更透彻的解释，让你不仅明白如何进行 PCA 分析，同时还明白为什么要这么做。

PCA 背后的核心思想

为什么要使用协方差矩阵？

首先要回答的第一个问题是，为什么我们要使用样本数据中，各个维度之间的协方差，来构建一个新的协方差矩阵？要弄清楚这一点，首先要回到 PCA 最终的目标：降维。降维就是要去除那些表达信息量少，或者冗余的维度。

我们首先来看如何定义维度的信息量大小。这里我们认为样本在某个特征上的差异就越大，那么这个特征包含的信息量就越大，就越重要。相反，信息量就越小，需要被过滤掉。很自然，我们就能想到使用某维特征的方差来定义样本在这个特征维度上的差异。

另一方面，我们要看如何发现冗余的信息。如果两种特征是有很高的相关性，那我们可以从一个维度的值推算出另一个维度的值，所表达的信息就是重复的。在概率和统计模块，我介绍过多个变量间的相关性，而在实际运用中，我们可以使用皮尔森（Pearson）相关系数，来描述两个变量之间的线性相关程度。这个系数的取值范围是 [−1,1][−1,1]，绝对值越大，说明相关性越高，正数表示正相关，负数表示负相关。

我使用下面这张图，来表示正相关和负相关的涵义。左侧 XX 曲线和 YY 曲线有非常近似的变化趋势，当 XX 上升 YY 往往也是上升的，XX 下降 YY 往往也下降，这表示两者有较强的正相关性。右侧 XX 和 YY 两者相反，当 XX 上升的时候，YY 往往是下降的，XX 下降的时候，YY 往往是上升，这表示两者有较强的负相关性。

皮尔森系数计算公式如下：

其中 nn 表示向量维度，xk,ixk,i 和 xk,jxk,j 分别为两个特征维度 ii 和 jj 在第 kk 个采样上的数值。 x,i¯x,i¯ 和 x,j¯x,j¯ 分别表示两个特征维度上所有样本的均值，σxσx 和 σyσy 分别表示两个特征维度上所有样本的标准差。

我把皮尔森系数的公式稍加变化，你来观察一下皮尔森系数和协方差之间的关系。

你看，变换后的分子不就是协方差吗？而分母类似于标准化数据中的分母。所以在本质上，皮尔森相关系数和数据标准化后的协方差是一致的。

考虑到协方差既可以衡量信息量的大小，也可以衡量不同维度之间的相关性，因此我们就使用各个维度之间的协方差所构成的矩阵，作为 PCA 分析的对象。就如前面说讲述的，这个协方差矩阵主对角线上的元素是各维度上的方差，也就体现了信息量，而其他元素是两两维度间的协方差，也就体现了相关性。

既然协方差矩阵提供了我们所需要的方差和相关性，那么下一步，我们就要考虑对这个矩阵进行怎样的操作了。

为什么要计算协方差矩阵的特征值和特征向量？

关于这点，我们可以从两个角度来理解。

第一个角度是对角矩阵。所谓对角矩阵，就是说只有矩阵主对角线之上的元素有非 0 值，而其他元素的值都为 0。我们刚刚解释了协方差矩阵的主对角线上，都是表示信息量的方差，而其他元素都是表示相关性的协方差。既然我们希望尽可能保留大信息量的维度，而去除相关的维度，那么就意味着我们希望对协方差进行对角化，尽可能地使得矩阵只有主对角线上有非 0 元素。

假如我们确实可以把矩阵尽可能的对角化，那么对角化之后的矩阵，它的主对角线上元素就是、或者接近矩阵的特征值，而特征值本身又表示了转换后的方差，也就是信息量。而此时，对应的各个特征向量之间是基本正交的，也就是相关性极低甚至没有相关性。

第二个角度是特征值和特征向量的几何意义。在向量空间中，对某个向量左乘一个矩阵，实际上是对这个向量进行了一次变换。在这个变换的过程中，被左乘的向量主要发生旋转和伸缩这两种变化。如果左乘矩阵对某一个向量或某些向量只发生伸缩变换，不对这些向量产生旋转的效果，那么这些向量就称为这个矩阵的特征向量，而伸缩的比例就是特征值。换句话来说，某个矩阵的特征向量表示了这个矩阵在空间中的变换方向，这些方向都是趋于正交的，而特征值表示每个方向上伸缩的比例。

如果一个特征值很大，那么说明在对应的特征向量所表示的方向上，伸缩幅度很大。这也是为什么，我们需要使用原始的数据去左乘这个特征向量，来获取降维后的新数据。因为这样做可以帮助我们找到一个方向，让它最大程度地包含原有的信息。需要注意的是，这个新的方向，往往不代表原始的特征，而是多个原始特征的组合和缩放。

小结

这两节，我详细讲解了 PCA 主成分分析法，它是一种针对数值型特征、较为通用的降维方法。和特征选择不同，它并不需要监督式学习中的样本标签，而是从不同维度特征之间的关系出发，进行了一系列的操作和分析。主要步骤包括，标准化原始的数据矩阵、构建协方差矩阵、计算这种协方差矩阵的特征值和特征向量、挑选较大特征值所对应的特征向量、进行原始特征数据的转换。如果排名靠前的特征向量，或者说主成分，已经包括了足够的信息量，那么我们就可以通过选择较少的主成分，对原始的样本矩阵进行转换，从而达到降维的目的。

PCA 方法一开始不是很好理解，其主要的原因之一是它背后的核心思想并不是很直观。为此，我详细解释了为什么 PCA 会从标准化和协方差入手来构建协方差矩阵。对于同类的特征来说，标准化之后的协方差就是方差，表示了这一维特征所包含的信息量。而对于不同类的特征来说，标准化之后的协方差体现了这两维特征的相关性。鉴于这两个特性，我们需要求解协方差矩阵的特征值和特征向量。如果你弄清楚了这几个关键点，那么 PCA 方法也就不难理解了。

思考题

到目前为止，我们讲解了两种特征降维的方法。第一，在监督式学习中，基于分类标签的特征选择；第二，基于特征协方差矩阵的 PCA 主成分分析。请尝试从你自己的理解，来说说这两种降维方法各自的优缺点。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

44 | 奇异值分解：如何挖掘潜在的语义关系？

你好，我是黄申。

今天，我们来聊另一种降维的方法，SVD 奇异值分解（Singular Value Decomposition）。它的核心思路和 PCA 不同。PCA 是通过分析不同纬特征之间的协方差，找到包含最多信息量的特征向量，从而实现降维。而 SVD 这种方法试图通过样本矩阵本身的分解，找到一些“潜在的因素”，然后通过把原始的特征维度映射到较少的潜在因素之上，达到降维的目的。

这个方法的思想和步骤有些复杂，它的核心是矩阵分解，首先，让我们从方阵的矩阵分解开始。

方阵的特征分解

在解释方阵的分解时，我们会用到两个你可能不太熟悉的概念：方阵和酉矩阵。为了让你更顺畅的理解整个分解的过程，我先给你解释下这两个概念。

方阵（Square Matrix）是一种特殊的矩阵，它的行数和列数相等。如果一个矩阵的行数和列数都是 n，那么我们把它称作 n 阶方阵。

如果一个矩阵和其转置矩阵相乘得到的是单位矩阵，那么它就是一个酉矩阵（Unitary Matrix）。

X′X=IX′X=I

其中 X’表示 X 的转置，I 表示单位矩阵。换句话说，矩阵 X 为酉矩阵的充分必要条件是 X 的转置矩阵和 X 的逆矩阵相等。

X′=X−1X′=X−1

理解这两个概念之后，让我们来观察矩阵的特征值和特征向量。前两节我们介绍了，对于一个 n×n 维的矩阵 XX，nn 维向量 vv，标量 λλ，如果有 Xv=λvXv=λv。

那么我们就说 λλ 是 XX 的特征值，vv 是 XX 的特征向量，并对应于特征值 λλ。

之前我们说过特征向量表示了矩阵变化的方向，而特征值表示了变化的幅度。实际上，通过特征值和特征矩阵，我们还可以把矩阵 X 进行特征分解（Eigendecomposition）。这里矩阵的特征分解，是指把矩阵分解为由其特征值和特征向量表示的矩阵之积的方法。如果我们求出了矩阵 XX 的 kk 个特征值 λ1，λ2，…，λnλ1，λ2，…，λn，以及这 nn 个特征值所对应的特征向量 v1，v2，…，vnv1，v2，…，vn，那么就有 XV=VΣXV=VΣ。

其中，VV 是这 nn 个特征向量所张成的 n×n 维矩阵，而Σ为这 n 个特征值为主对角线的 n×n 维矩阵。进一步推导，我们可以得到：

XVV−1=VΣV−1XVV−1=VΣV−1
XI=VΣV−1XI=VΣV−1
X=VΣV−1X=VΣV−1

如果我们会把 VV 的这 nn 个特征向量进行标准化处理，那么对于每个特征向量 ViVi，就有 ||Vi||2=1||Vi||2=1，而这表示 V′iVi=1Vi′Vi=1，此时 V 的 n 个特征向量为标准正交基，满足 V′V=IV′V=I ， 也就是说 V 为酉矩阵，有 V′=V−1V′=V−1 。这样一来，我们就可以把特征分解表达式写作 X=VΣV′X=VΣV′。

我们以介绍 PCA 分析时所用的矩阵为例，验证矩阵的特征分解。当时，我们有一个：

下面我们需要证明 X=VΣV′X=VΣV′ 成立，我把推算的过程写在下面了。

讲到这里，相信你对矩阵的特征分解有了一定程度的认识。可是，矩阵 XX 必须为对称方阵才能进行有实数解的特征分解。那么如果 XX 不是方阵，那么应该如何进行矩阵的分解呢？这个时候就需要用到奇异值分解 SVD 了。

矩阵的奇异值分解

SVD 分解和特征分解相比，在形式上是类似的。假设矩阵 XX 是一个 m×n 维的矩阵，那么 XX 的 SVD 为 X=UΣV′X=UΣV′。

不同的地方在于，SVD 并不要求要分解的矩阵为方阵，所以这里的 UU 和 V′V′ 并不是互为逆矩阵。其中 UU 是一个 m×m 维的矩阵，VV 是一个 n×n 维的矩阵。而 ΣΣ 是一个 m×n 维的矩阵，对于 ΣΣ 来说，只有主对角线之上的元素可以为非 00，其他元素都是 00，而主对角线上的每个元素就称为奇异值。UU 和 VV 都是酉矩阵，即满足 U′U=I,V′V=IU′U=I,V′V=I。

现在问题来了，我们应该如何求出，用于 SVD 分解的 U,Σ和VU,Σ和V 这三个矩阵呢？之所以不能使用有实数解的特征分解，是因为此时矩阵 X 不是对称的方阵。我们可以把 XX 的转置 X′X′ 和 XX 做矩阵乘法，得到一个 n×n 维的对称方阵 X′XX′X。这个时候，我们就能对 X′XX′X 这个对称方阵进行特征分解了，得到的特征值和特征向量满足 (XX′)vi=λivi(XX′)vi=λivi。

这样一来，我们就得到了矩阵 X′XX′X 的 nn 个特征值和对应的 nn 个特征向量 vv。通过 X′XX′X 的所有特征向量构造一个 n×n 维的矩阵 VV，这就是上述 SVD 公式里面的 VV 矩阵了。通常我们把 VV 中的每个特征向量叫作 XX 的右奇异向量。

同样的道理，如果我们把 X 和 X’做矩阵乘法，那么会得到一个 m×m 维的方阵 XX’。由于 XX’也是方阵，因此我们同样可以对它进行特征分解，得到的特征值和特征向量满足 (XX′)ui=λiui(XX′)ui=λiui。

类似地，我们得到了矩阵 XX′XX′ 的 m 个特征值和对应的 m 个特征向量 uu。通过 XX’的所有特征向量构造一个 m×m 的矩阵 UU。这就是上述 SVD 公式里面的 UU 矩阵了。通常，我们把 U 中的每个特征向量叫作 X 的左奇异向量。

现在，包含左右奇异向量的 UU 和 VV 都求解出来了，只剩下奇异值矩阵 ΣΣ 了。之前我提到，ΣΣ 除了对角线上是奇异值之外，其他位置的元素都是 00，所以我们只需要求出每个奇异值 σσ 就可以了。这个解可以通过下面的公式推导求得：

X=UΣV′X=UΣV′
XV=UΣV′VXV=UΣV′V

由于 VV 是酉矩阵，所以 V′V=IV′V=I，就有：

XV=UΣIXV=UΣI
XV=UΣXV=UΣ
Xvi=σiuiXvi=σiui
σi=Xviuiσi=Xviui

其中 vivi 和 uiui 都是列向量。一旦我们求出了每个奇异值 σσ，那么就能得到奇异值矩阵 ΣΣ。

通过上述几个步骤，我们就能把一个 mxn 维的实数矩阵，分解成 X=UΣV′X=UΣV′ 的形式。说到这里，你可能会疑惑，把矩阵分解成这个形式有什么用呢？实际上，在不同的应用中，这种分解表示了不同的含义。下面，我会使用潜在语义分析的案例，带你看看，在发掘语义关系的时候，SVD 分解起到了怎样的关键作用。

潜在语义分析和 SVD

在讲向量空间模型的时候，我解释了文档和词条所组成的矩阵。对于一个大的文档集合，我们首先要构造字典，然后根据字典构造每篇文档的向量，最后通过所有文档的向量构造矩阵。矩阵的行和列分别表示文档和词条。基于这个矩阵、向量空间中的距离、余弦夹角等度量，我们就可以进行基于相似度的信息检索或文档聚类。

不过，最简单的向量空间模型采样的是精确的词条匹配，它没有办法处理词条形态的变化、同义词、近义词等情况。我们需要使用拉丁语系的取词根（Stemming）操作，并手动建立同义词、近义词词典。这些处理方式都需要人类的语义知识，也非常依赖人工的干预。另外，有些词语并不是同义词或者近义词，但是相互之间也是有语义关系的。例如“学生”“老师”“学校”“课程”等等。

那么，我们有没有什么模型，可以自动地挖掘在语义层面的信息呢？当然，目前的计算机还没有办法真正的理解人类的自然语言，它们需要通过大量的数据，来找到词语之间的关系。下面我们就来看看潜在语义分析 LSA（Latent Semantic Analysis）或者叫潜在语义索引 LSI（Latent Semantic Index）这种方法，是如何做到这点的。

和一般的向量空间模型有所不同，LSA 通过词条和文档所组成的矩阵，发掘词和词之间的语义关系，并过滤掉原始向量空间中存在的一些“噪音”，最终提高信息检索和机器学习算法的精确度。LSA 主要包括以下这些步骤。

第一步，分析文档集合，建立表示文档和词条关系的矩阵。

第二步，对文档 - 词条矩阵进行 SVD 奇异值分解。在 LSA 的应用场景下，分解之后所得到的奇异值σ对应了一个语义上的“概念”，而 σσ 值的大小表示这个概念在整个文档集合中的重要程度。UU 中的左奇异值向量表示了每个文档和这些语义“概念”的关系强弱，VV 中的右奇异值向量表示每个词条和这些语义“概念”的关系强弱。所以说，SVD 分解把原来的词条 - 文档关系，转换成了词条 - 语义概念 - 文档关系。

我画了一张图帮助你理解这个过程。

在这种图中，我们有一个 7×5 维的矩阵 XX，表示 7 个文档和 5 个单词。经过 SVD 分解之后，我们得到了两个主要的语义概念，一个概念描述了计算机领域，另一个概念描述了医学领域。矩阵 U 描述文档和这两个概念之间的关系，而矩阵 V′V′ 描述了各个词语和这两个概念之间的关系。如果要对文档进行检索，我们可以使用 UU 这个降维之后的矩阵，找到哪些文档和计算机领域相关。同样，对于聚类算法，我们也可以使用 U 来判断哪些文档属于同一个类。

第三步，对 SVD 分解后的矩阵进行降维，这个操作和 PCA 主成分分析的降维操作是类似的。

第四步，使用降维后的矩阵重新构建概念 - 文档矩阵，新矩阵中的元素不再表示词条是不是出现在文档中，而是表示某个概念是不是出现在文档中。

总的来说，LSA 的分解，不仅可以帮助我们找到词条之间的语义关系，还可以降低向量空间的维度。在这个基础之上在运行其他的信息检索或者机器学习算法，就更加有效。

总结

之前介绍的 PCA 主成分分析，要求矩阵必须是对称的方阵，因此只适用于刻画特征之间关系的协方差矩阵。但是，有的时候我们需要挖掘的是样本和特征之间的关系，例如文档和词条。这个时候矩阵并不是对称的方阵，因此无法直接使用 PCA 分析。

为此，SVD 奇异值分解提供了一种可行的方案。它巧妙的运用了矩阵 X 和自己的转置相乘，生成了两种对称的方阵，并通过这两者的特征分解，获得了 SVD 中的左奇异向量所组成的矩阵 U 和右奇异向量所组成的矩阵 V，并最终推导出奇异值矩阵Σ。这样，SVD 就可以对原始的数据矩阵进行分解，并运用最终的奇异向量进行降维。

我们可以把 SVD 运用在很多场合中，在不同的应用场景下，U，VU，V 和 ΣΣ 代表了不同的含义。例如，在 LSA 分析中，通过对词条和文档矩阵的 SVD 分解，我们可以利用Σ获得代表潜在语义的一些概念。而矩阵 UU 表示了这些概念和文档之间的关系，矩阵 VV 表示了这些概念和单个词语之间的关系。

思考题

请使用你自己熟悉的语言，实现 SVD 分解。（提示：如果使用 Python 等科学计算语言，你可以参考本节所讲述的矩阵分解步骤，也可以使用一些现成的科学计算库。）

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

45 | 线性代数篇答疑和总结：矩阵乘法的几何意义是什么？

你好，我是黄申。今天是线性代数的答疑和总结。

在这个模块中，我们讲了不少向量、矩阵、线性方程相关的内容。看到大家在留言区的问题，今天我重点说说矩阵乘法的几何意义，以及为什么 SVD 中 X′XX′X 的特征向量组成了 VV 矩阵，而 XX′XX′ 的特征向量组成了 UU 矩阵。最后，我会对整个线性代数的模块做一个总结。

矩阵乘法的几何意义

首先，我们来说说矩阵乘法所代表的几何意义。

在阐述 PCA 主成分分析的时候，我们聊过为什么这个方法要研究协方差矩阵的特征值和特征向量。其中，我提到对某个向量左乘一个矩阵，实际上是对这个向量进行了一次变换。某个矩阵的特征向量表示了这个矩阵在空间中的变换方向，这些方向都是正交或者趋于正交的，而特征值表示每个方向上伸缩的比例。今天，我会继续深入这个话题，结合实例，给出更详细地解释。

多维的向量空间很难理解，所以我们还是从最简答的二维空间开始。首先，我们需要明白什么是二维空间中的正交向量。正交向量的定义非常简单，只要两个向量的点乘结果为 0，那么它们就是正交的。在酉矩阵之中，矩阵和矩阵的转置相乘为单位矩阵，只有向量自己点乘自己值为 1，而不同向量之间点乘值为 0，所以不同的向量之间是正交的。

理解了正交向量之后，我们来定义一个二维空间，这个空间的横坐标为 xx，纵坐标为 yy，空间中的一个点坐标为 (1,2)(1,2)，对于这个点，我们可以把从原点到它的直线投影到 xx 轴和 yy 轴，这个直线在 xx 轴上投影的长度为 1，在 y 轴上投影的长度为 2。我使用下图来表示。

对于这个点，我们使用一个矩阵 X1X1 左乘这个点的坐标，你可以看看会发生什么。

我们把结果转成坐标系里的点，它的坐标是 (3,4)(3,4)，把从原点到 (1,2)(1,2) 的直线，和从原点到 (3,4)(3,4) 的直线进行比较，你会发现直线发生了旋转，而且长度也发生了变化，这就是矩阵左乘所对应的几何意义。我们还可以对这个矩阵 X1X1 分析一下，看看它到底表示了什么含义，以及为什么它会导致直线的旋转和长度发生变化。

之前我讲过，要看一个矩阵的特征，需要分析它的特征向量和特征值。由于矩阵 X1X1 是一个对角矩阵，所以特征值很容易求解，分别是 3 和 2。而对应的特征向量是 [1,0][1,0] 和 [0,1][0,1]。在二维坐标中，坐标 [1, 0] 实际上表示的是 xx 轴的方向，而 [0, 1] 实际上表示的是 yy 轴的方向。特征值 3 对应特征向量 [1, 0] 就表明在 xx 轴方向拉伸为原来的 3 倍，特征值 2 对应特征向量 [0, 1] 就表明在 yy 轴方向拉伸 2 倍。所以，矩阵 X1X1 的左乘，就表示把原有向量在 xx 轴上拉伸为原来的 3 倍，而在 yy 轴上拉伸为原来的 2 倍。我用下面这张图来展示。

我们还可以从另一个角度来验证这点，把从原点到 (3,4)(3,4) 的直线进行分解，我们会发现这个直线在 xx 轴上投影的长度为 3，为原来的 3 倍，而在 yy 轴上投影的长度为 4，为原来的 2 倍。

当然，矩阵的特征向量不一定是 xx 轴和 yy 轴，它们可以是二维空间中任何相互正交的向量。下面，我们再来看一个稍微复杂一点的例子。这次我们从两个正交的向量开始。

我使用下面这张图展示了这两个向量在空间的方向。

然后我用这两个向量构建一个矩阵 VV。

之所以使用这样一个例子，是因为 VV 是一个酉矩阵，也就是说 VV′=IVV′=I，所以我们可以使用它，外加一个特征值组成的对角矩阵 ΣΣ，来构建另一个用于测试的矩阵 X2X2。我在 SVD 的那一讲，介绍过对称方阵可以进行特征值分解，所以我们可以通过 VV 和 ΣΣ，获得一个对称方阵 X2=VΣV′X2=VΣV′。

我们假设两个特征值分别是 0.5 和 2，所以有：

根据我们之间的解释，如果让这个矩阵 X2X2 左乘任何一个向量，就是让向量沿 [12√12√][1212] 方向压缩一半，而在 [12√−12√][12−12] 方向增加两倍。为了验证这一点，我们让 X2X2 左乘向量 (1,2)(1,2)，获得新向量：

把这个新的坐标 (−0.25,1.75)(−0.25,1.75) 和原坐标 (1,2)(1,2) 都放到二维坐标系中，并让它们分别在 [12√12√][1212] 和 [12√−12√][12−12] 这两个方向进行投影，然后比较一下投影的长度，你就会发现伸缩的变化了。我使用下面这张图来帮你理解。

弄清楚了矩阵左乘向量的几何意义，那么矩阵左乘矩阵的几何意义也就不难理解了。假设我们让矩阵 XX 左乘矩阵 YY，那么可以把右矩阵 YY 看作一堆列向量的集合，而左乘矩阵 XX 就是对每个 YY 中的列向量进行变换。另外，如果二维空间理解了，那么三维、四维直到 nn 维空间就可以以此类推了。

SVD 分解中的 UU 和 VV 矩阵

在讲解 SVD 奇异值分解的时候，我们解释了 X′XX′X 的特征向量组成了 SVD 中的 VV 矩阵，而 XX′XX′ 的特征向量组成了 SVD 中的 UU 矩阵。不过，我们还没有证明这两点。今天我来说说如何证明它们。首先，我们来看看 VV 矩阵的证明。

X=UΣV′X=UΣV′
X=UΣ′V′X=UΣ′V′
X′X=(VΣ′U)(UΣV′)=VΣ′(U′U)Σ′V′=VΣ2V′)X′X=(VΣ′U)(UΣV′)=VΣ′(U′U)Σ′V′=VΣ2V′)

其中，(UΣV′)′=VΣ′U′(UΣV′)′=VΣ′U′ 的证明，我们在最小二乘法的讲解过程中证明过。另外，UU 是酉矩阵，所以 U′U=IU′U=I。ΣΣ 是对角矩阵，所以 Σ′Σ=Σ2Σ′Σ=Σ2，而且 Σ2Σ2 仍然是对角矩阵。

由于 Σ2Σ2 是对角矩阵，所以通过 X′X=VΣ2V′X′X=VΣ2V′，我们可以看出 VV 中的向量就是 X′XX′X 的特征向量，而特征值是 Σ2Σ2 对角线上的值。

同理，我们也可以证明 UU 中的向量就是 XX′XX′ 的特征向量。

X=UΣV′X=UΣV′
X′=UΣ′V′X′=UΣ′V′
XX′=(UΣV′)(VΣ′U′)=UΣ(V′V)Σ′U′=UΣ2U′)XX′=(UΣV′)(VΣ′U′)=UΣ(V′V)Σ′U′=UΣ2U′)

从这个证明的过程，我们也发现了，XX’或者 X’X 特征值矩阵等于奇异值矩阵的平方，也就是说我们可以通过求出 X’X 特征值的平方根来求奇异值。

总结

回答完两个问题之后，我来总结一下线性代数这个模块。

线性代数最基本的概念包括了向量、矩阵以及对应的操作。向量表示了一组数的概念，非常适合表示一个对象的多维特征，因此被广泛的运用在信息检索和机器学习的领域中。而矩阵又包含了多个向量，所以适合表示多个数据对象的集合。同时，矩阵也可以用于表达二维关系，例如网页的邻接矩阵，用户对物品的喜好程度，关键词在文档中的 tf-idf 等等。

由于向量和矩阵的特性，我们可以把它们运用在很多算法和模型之中。向量空间模型定义了向量之间的距离或者余弦夹角，我们可以利用这些指标来衡量数据对象之间的相似程度，并把这种相似程度用于定义查询和文档之间的相关性，或者是文档聚类时的归属关系。矩阵的运算体现了对多个向量同时进行的操作，比如最常见的左乘，就可以用在计算 PageRank 值，协同过滤中的用户或者物品相似度等等。

当然，矩阵的运用还不止计算数据对象之间的关系。最小二乘法的实现、PCA 主成分的分析、SVD 奇异值的分解也可以基于矩阵的运算。这些都可以帮助我们发现不同维度特征之间的关系，并利用这些关系找到哪些特征更为重要，选择或者创建更为重要的特征。

有的时候，线性代数涉及的公式和推导比较繁琐。在思考的过程中，我们可以把矩阵的操作简化为向量之间的操作，而把向量之间的操作简化为多个变量之间的运算。另外，我们可以多结合实际的案例，结合几何空间、动手推算，甚至可以编程实现某些关键的模块，这些都有利于理解和记忆。

思考题

我想听你说说，学习完了编程领域中常用的线性代数知识，你有哪些收获和心得？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

46 | 缓存系统：如何通过哈希表和队列实现高效访问？

你好，我是黄申。

经过前三大模块的学习，我带你纵览了数学在各个计算机编程领域的重要应用。离散数学是基础数据结构和编程算法的基石，而概率统计论和线性代数，是很多信息检索和机器学习算法的核心。

因此，今天开始，我会综合性地运用之前所讲解的一些知识，设计并实现一些更有实用性的核心模块或者原型系统。通过这种基于案例的讲解，我们可以融汇贯通不同的数学知识，并打造更加高效、更加智能的计算机系统。首先，让我们从一个缓存系统入手，开始综合应用篇的学习。

什么是缓存系统？

缓存（Cache）是计算机系统里非常重要的发明之一，它在编程领域中有非常非常多的应用。小到电脑的中央处理器（CPU）、主板、显卡等硬件，大到大规模的互联网站点，都在广泛使用缓存来提升速度。而在网站的架构设计中，一般不会像 PC 电脑那样采用高速的缓存介质，而是采用普通的服务器内存。但是网站架构所使用的内存容量大得多，至少是数个吉字节 （GB）。

我们可以把缓存定义为数据交换的缓冲区。它的读取速度远远高于普通存储介质，可以帮助系统更快地运行。当某个应用需要读取数据时，会优先从缓存中查找需要的内容，如果找到了则直接获取，这个效率要比读取普通存储更高。如果缓存中没有发现需要的内容，再到普通存储中寻找。

理解了缓存的概念和重要性之后，我们来看下缓存设计的几个主要考量因素。

第一个因素是硬件的性能。缓存的应用场景非常广泛，因此没有绝对的条件来定义何种性能可以达到缓存的资格，我们只要确保以高速读取介质可以充当相对低速的介质的缓冲。

第二个因素是命中率。缓存之所以能提升访问速度，主要是因为能从高速介质读取，这种情况我们称为“命中”（Hit）。但是，高速介质的成本是非常昂贵的，而且一般也不支持持久化存储，因此放入数据的容量必须受到限制，只能是全局信息的一部分。那么，一定是有部分数据无法在缓存中读取，而必须要到原始的存储中查找，这种情况称之为“错过”（Missed）。

我们通常使用能够在缓存中查找到数据的次数（|H||H|），除以整体的数据访问次数（|V||V|）计算命中率。如果命中率高，系统能够频繁地获取已经在缓存中驻留的数据，速度会明显提升。

HitRatio=|H||V|HitRatio=|H||V|

接下来的问题就是，如何在缓存容量有限的情况下，尽可能的提升命中率呢？人们开始研究缓存的淘汰算法，通过某种机制将缓存中可能无用的数据剔除，然后向剔除后空余的空间中补充将来可能会访问的数据。

最基本的策略包括最少使用 LFU（Least Frequently Used）策略和最久未用 LRU（Least Recently Used）策略。LFU 会记录每个缓存对象被使用的频率，并将使用次数最少的对象剔除。LRU 会记录每个缓存对象最近使用的时间，并将使用时间点最久远的对象给剔除。很显然，我们都希望缓存的命中率越高越好。

第三个因素是更新周期。虽然缓存处理的效率非常高，但是，被访问的数据不会一成不变，对于变化速度很快的数据，我们需要将变动主动更新到缓存中，或者让原有内容失效，否则用户将读取到过时的内容。在无法及时更新数据的情况下，高命中率反而变成了坏事，轻则影响用户交互的体验，重则会导致应用逻辑的错误。

为了方便你的理解，我使用下面这张图，来展现这几个主要因素之间的关系，以及缓存系统的工作流程。

如何设计一个缓存系统？

了解这些基本概念之后，我们就可以开始设计自己的缓存系统了。今天，我重点讲解如何使用哈希表和队列，来设计一个基于最久未用 LRU 策略的缓存。

从缓存系统的工作流程可以看出，首先我们需要确认某个被请求的数据，是不是存在于缓存系统中。对于这个功能，哈希表是非常适合的。第 2 讲我讲过哈希的概念，我们可以通过哈希值计算快速定位，加快查找的速度。不论哈希表中有多少数据，读取、插入和删除操作只需要耗费接近常量的时间，也就是 O (1) 的时间复杂度 ，这正好满足了缓存高速运作的需求。

在第 18 讲，我讲了用数组和链表来构造哈希表。在很多编程语言中，哈希表的实现采用的是链地址哈希表。这种方法的主要思想是，先分配一个很大的数组空间，而数组中的每一个元素都是一个链表的头部。随后，我们就可以根据哈希函数算出的哈希值（也叫哈希的 key），找到数组的某个元素及对应的链表，然后把数据添加到这个链表中。之所以要这样设计，是因为存在哈希冲突。所以，我们要尽量找到一个合理的哈希函数，减少冲突发生的机会，提升检索的效率。

接下来，我们来聊聊缓存淘汰的策略。这里我们使用 LRU 最久未用策略。在这种策略中，系统会根据数据最近一次的使用时间来排序，使用时间最久远的对象会被淘汰。考虑到这个特性，我们可以使用队列。我在讲解广度优先搜索策略时，谈到了队列。这是一种先进先出的数据结构，先进入队列的元素会优先得到处理。如果充分利用队列的特点，我们就很容易找到上一次使用时间最久的数据，具体的实现过程如下。

第一，根据缓存的大小，设置队列的最大值。通常的做法是使用缓存里所能存放数据记录量的上限，作为队列里结点的总数的上限，两者保持一致。

第二，每次访问一个数据后，查看是不是已经存在一个队列中的结点对应于这个数据。如果不是，创造一个对应于这个数据的队列结点，加入队列尾部。如果是，把这个数据所对应的队列结点重新放入队列的尾部。需要注意，这一点是至关重要的。因为这种操作可以保证上一次访问时间最久的数据，所对应的结点永远在队列的头部。

第三，如果队列已满，我们就需要淘汰一些缓存中的数据。由于队列里的结点和缓存中的数据记录量是一致的，所以队列里的结点数达到上限制，也就意味着缓存也已经满了。刚刚提到，由于第二点的操作，我们只需要移除队列头部的结点就可以了。

综合上述关于哈希表和队列的讨论，我们可以画出下面这张框架图。

从这张图可以看到，我们使用哈希表来存放需要被缓存的内容，然后使用队列来实现 LRU 策略。每当数据请求进来的时候，缓存系统首先检查数据记录是不是已经存在哈希表中。如果不存在，那么就返回没有查找到，不会对哈希表和队列进行任何的改变；如果已经存在，就直接从哈希表读取并返回。

与此同时，在队列中进行相应的操作，标记对应记录最后访问的次序。队列头部的结点，对应即将被淘汰的记录。如果缓存或者说队列已满，而我们又需要插入新的缓存数据，那么就需要移除队列头部的结点，以及它所对应的哈希表结点。

接下来，我们结合这张图，以请求数据记录 175 为例，详细看看在这个框架中，每一步是如何运作的。

这里的哈希表所使用的散列函数非常简单，是把数据的所有位数加起来，再对一个非常大的数值例如 10^6 求余。那么，175 的哈希值就是 (1+7+5)/10^6=13。通过哈希值 13 找到对应的链表，然后进行遍历找到记录 175。这个时候我们已经完成了从缓存中获取数据。

不过，由于使用了 LRU 策略来淘汰旧的数据，所以还需要对保存访问状态的队列进行必要的操作。检查队列，我们发现表示 175 的结点已经在队列之中了，说明最近这条数据已经被访问过，所以我们要把这个结点挪到队列的最后，让它远离被淘汰的命运。

我们再来看另一个例子，假设这次需要获取的是数据记录 1228，这条记录并不在缓存之中，因此除了从低速介质返回获取的记录，我们还要把这个数据记录放入缓存区，并更新保存访问状态的队列。和记录 175 不同的是，1228 最近没有被访问过，所以我们需要在队列的末尾增加一个表示 1201 的结点。这个时候，队列已经满了，我们需要让队列头部的结点 73 出队，然后把相应的记录 73 从哈希表中删除，最后把记录 1228 插入到哈希表中作为缓存。

总结

当今的计算机系统中，缓存扮演着非常重要的角色，小到 CPU，大到互联网站点，我们都需要使用缓存来提升系统性能。基于哈希的数据结构可以帮助我们快速的获取数据，所以非常适合运用在缓存系统之中。

不过，缓存都需要相对昂贵的硬件来实现，因此大小受到限制。所以，我们需要使用不同的策略来淘汰不经常使用的内容，取而代之一些更有可能被使用的内容，增加缓存的命中率，进而提升缓存的使用效果。为了实现这个目标，人们提出了多种淘汰的策略，包括 LRU 和 LFU。

综合上面两点，我们提出一种结合哈希表和队列的缓存设计方案。哈希表负责快速的存储和更新被缓存的内容，而队列负责管理最近被访问的状态，并告知系统哪些数据是要被淘汰并从哈希表中移除。

思考题

请根据今天所讲解的设计思想，尝试编码实现一个基于 LRU 淘汰策略的缓存。哈希表部分可以直接使用编程语言所提供的哈希类数据结构。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

47 | 搜索引擎（上）：如何通过倒排索引和向量空间模型，打造一个简单的搜索引擎？

你好，我是黄申。

上一节，我们充分利用了哈希表时间复杂度低的特点，设计了一个简单的缓存系统。在实际项目中，哈希表或者类似的哈希数据结构，有着更为广泛的运用。比如，搜索引擎中的倒排索引，也是基于哈希表结构来设计的。这种倒排索引可以大大提升数据对象的检索效率。

除了搜索的效率，搜索引擎另一个需要考虑的问题是相关性，也就是说，我们需要保证检索出来的信息是满足用户需求的。最简单的基于倒排索引的实现，属于一种布尔排序模型，它只考虑了单词是不是出现在文档之中，如果出现了就返回相应的文档，否则就不返回，对应于布尔模型中的真假值。在这种实现中，只要出现了相关搜索词的文档都会被检索出来，因此相关性比较差。对于这点，我们可以利用向量空间模型，来衡量文档和用户查询之间的相似程度，确保两者是相关的。不过，向量空间模型需要涉及两两之间的比较，时间复杂度比较高。

考虑到上述两点，今天，我们就以文档检索为例，参照倒排索引加向量空间模型的设计思路，设计一个简单的搜索引擎。

搜索引擎的设计框架

之前在讲解向量空间模型的时候，我们介绍了信息检索的基础知识，而我们平时经常使用的搜索引擎，就是一种典型的信息检索系统。在讲解如何结合倒排索引和向量空间模型之前，我们先来看，常见的文本搜索引擎都由哪些模块组成。

文本搜索系统的框架通常包括 2 个重要模块：离线的预处理和在线的查询。离线预处理也就是我们通常所说的“索引”阶段，包括数据获取、文本预处理、词典和倒排索引的构建、相关性模型的数据统计等。数据的获取和相关性模型的数据统计这两步，根据不同的应用场景，必要性和处理方式有所不同。可是，文本预处理和倒排索引构建这两个步骤，无论在何种应用场景之中都是必不可少的，所以它们是离线阶段的核心。之前我们讲过，常规的文本预处理是指针对文本进行分词、移除停用词、取词干、归一化、扩充同义词和近义词等操作。

在第 17 讲里，我讲解了如何使用倒排索引，把文档集转换为从关键词到文档的这种查找关系。有了这种“倒排”的关系，我们可以很高效地根据给定的单词，找到出现过这个单词的文档集合。

倒排索引是典型的牺牲空间来换取时间的方法。我们假设文章总数是 k，每篇文章的单词数量是 m，查询中平均的关键词数量是 l，那么倒排索引可以把时间复杂度从 O(k×logm) 降到 O(l)。但是，如果使用倒排索引，就意味着除了原始数据，我们还需要额外的存储空间来放置倒排索引。因此，如果我们的字典里，不同的词条总数为 n1n1，每个单词所对应的文章平均数为 n2n2，那么空间复杂度就是 O(n1n1×n2n2)。

在文本的离线处理完毕后，我们来看在线的文本查询。这个过程相对简单。

查询一般都会使用和离线模块一样的预处理，词典也是沿用离线处理的结果。当然，也可能会出现离线处理中未曾出现过的新词，我们一般会忽略或给予非常小的权重。在此基础上，系统根据用户输入的查询条件，在倒排索引中快速检出文档，并进行相关性的计算。

不同的相关性模型，有不同的计算方式。最简单的布尔模型只需要计算若干匹配条件的交集，向量空间模型 VSM，则需要计算查询向量和待查文档向量的余弦夹角，而语言模型需要计算匹配条件的贝叶斯概率等等。

综合上述的介绍，我使用下面这张图来展示搜索引擎的框架设计。

倒排索引的设计

我们之前已经把倒排索引的概念讲清楚了。不过到具体设计的时候，除了从关键词到文档这种“倒排”的关系，还有其它两个要点值得考虑：第一个是倒排索引里具体存储什么内容，第二个就是多个关键词的查询结果如何取交集。我们下面一个个来看。

首先我们来聊聊倒排索引里具体存放的内容。

从倒排索引的概念，我们很容易就想到使用哈希表、尤其是基于链式地址法的哈希表来实现倒排索引。哈希的键（key）就是文档词典的某一个词条，值（value）就是一个链表，链表是出现这个词条的所有文档之集合，而链表的每一个结点，就表示出现过这个词条的某一篇文档。这种最简单的设计能够帮助我们判断哪些文档出现过给定的词条，因此它可以用于布尔模型。但是，如果我们要实现向量空间模型，或者是基于概率的检索模型，就需要很多额外的信息，比如词频（tf）、词频 - 逆文档频率（tf-idf）、词条出现的条件概率等等。

另外，有些搜索引擎需要返回匹配到的信息摘要（nippet），因此还需要记住词条出现的位置。这个时候，最简单的倒排索引就无法满足我们的需求了。我们要在倒排索引中加入更多的信息。每个文档列表中，存储的不仅仅是文档的 ID，还有其他额外的信息。我使用下面这张图展示了一个示例，帮助你理解这种新的设计。

其中，ID 字段表示文档的 ID，tf 字段表示词频，tfidf 字段表示词频 - 逆文档频率，而 prob 表示这个词条在这篇文档中出现的条件概率。

好了，下面我们来看，如何确定出现所有多个关键词的文档。

由于倒排索引本身的特性，我们可以很快知道某一个词条对应的文档，也就是说查找出现某一个词条的所有文档是很容易的。可是，如果用户的查询包含多个关键词，那么该如何利用倒排索引，查找出现多个词条的所有文档呢？

还记得我讲解分治法时，所提到的归并排序吗？在这里，我们可以借鉴其中的合并步骤。假设有两个词条 a 和 b，a 对应的文档列表是 A，b 对应的文档列表是 B，而 A 和 B 这两个列表中的每一个元素都包含了文档的 ID。

首先，我们根据文档的 ID，分别对这两个列表进行从小到大的排序，然后依次比较两个列表的文档 ID，如果当前的两个 ID 相等，就表示这个 ID 所对应的文档同时包含了 a 和 b 两个关键词，所以是符合要求的，进行保留，然后两个列表都拿出下一个 ID 进行之后的对比。如果列表 A 的当前 ID 小于列表 B 的当前 ID，那么表明 A 中的这个 ID 一定不符合要求，跳过它，然后拿出 A 中的下一个 ID 和 B 进行比较。同样，如果是列表 B 的第一个 ID 更小，那么就跳过 B 中的这个 ID，拿出 B 中的下一个 ID 和 A 进行比较。依次类推，直到遍历完所有 A 和 B 中的 ID。

我画了张图来进一步解释这个过程。

基于这种两两比较的过程，我们可以推广到比较任意多的列表。此外，在构建倒排索引的时候，我们可以事先对每个词条的文档列表进行排序，从而避免了查询时候的排序过程，达到提升搜索效率的目的。

向量空间和倒排索引的结合

有了倒排索引的高效查询，向量空间的实现就不难了。还记得之前我们讲解的向量空间模型吗？这个模型假设所有的对象都可以转化为向量，然后使用向量间的距离（通常是欧氏距离）或者是向量间的夹角余弦来表示两个对象之间的相似程度。

在文本搜索引擎中，我们使用向量来表示每个文档以及用户的查询，而向量的每个分量由每个词条的 tf-idf 构成，最终用户查询和文档之间的相似度或者说相关性，由文档向量和查询向量的夹角余弦确定。如果能获取这个查询和所有文档之间的相关性得分，那么我们就能对文档进行排序，返回最相关的那些。不过，当文档集合很大的时候，这个操作的复杂度会很高。你可以观察一下这个夹角余弦的公式。

如果文档中词条的平均数量是 n，查询中词条的平均数量是 m，那么计算某个查询和某个文档之间的夹角余弦，时间复杂度是 O(n×m)。如果整个被索引的文档集合有 k 个文档，那么计算某个查询和所有文档之间的夹角余弦，时间复杂度就变为 O(n×m×k)。

实际上，很多文档并没有出现查询中的关键词条，所以计算出来的夹角余弦都是 0，而这些计算都是可以完全避免的，解决方案就是倒排索引。通过倒排索引，我们挑选出那些出现过查询关键词的文档，并仅仅针对这些文档进行夹角余弦的计算，那么计算量就会大大减少。

此外，我们之前设计的倒排索引也已经保存了 tf-idf 这种信息，因此可以直接利用从倒排索引中取出的 tf-idf 值计算夹角余弦公式的分子部分。至于分母部分，它包含了用户查询向量的和文档向量的 L2 范数。通常，查询向量所包含的非 0 分量很少，L2 范数计算是很快的。而每篇文档的 L2 范数，在文档没有更新的情况下是不变的，因此我们可以在索引阶段，就计算好并保持在额外的数据结构之中。

小结

目前，以搜索引擎为代表的信息检索技术已经相当成熟，无论是大型的互联网系统，还是小型的手机操作系统，都支持高效率的搜索。而搜索引擎最重要的核心就是及时性和相关性。及时性确保用户可以快速找到信息，而相关性确保所找到的信息是用户真正需要的。

在文本搜索中，倒排索引通过一种称为“索引”的过程，把文档到词条的关系，转化为词条到文档的逆关系，这样对于任何给定的关键词，我们可以很快的找到哪些文档包含这个关键词条。所以，倒排索引是搜索引擎提升及时性中非常关键的一步。倒排索引非常适合使用哈希表，特别是链地址型的哈希表来实现。

向量空间模型可以作为文本搜索的相关性模型。但是，它的计算需要把查询和所有的文档进行比较，时间复杂度太高，影响了及时性。这个时候，我们可以利用倒排索引，过滤掉绝大部分不包含查询关键词的文档。

思考题

请根据今天所讲解的设计思想，使用你熟悉的编程语言，来实现一个基于倒排索引和向量空间模型的文本搜索引擎。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

48 | 搜索引擎（下）：如何通过查询的分类，让电商平台的搜索结果更相关？

你好，我是黄申。

上一节，我给你阐述了如何使用哈希的数据结构设计倒排索引，并使用倒排索引加速向量空间模型的计算。倒排索引提升了搜索执行的速度，而向量空间提升了搜索结果的相关性。

可是，在不同的应用场景，搜索的相关性有不同的含义。无论是布尔模型、向量空间模型、概率语言模型还是其他任何更复杂的模型，都不可能“一招鲜，吃遍天”。今天，我就结合自己曾经碰到的一个真实案例，为你讲解如何利用分类技术，改善搜索引擎返回结果的相关性。

你可能会觉得奇怪，这分类技术，不是监督式机器学习中的算法吗？它和信息检索以及搜索技术有什么关系呢？且听我慢慢说来。

电商搜索的难题

我曾经参与过一个电商的商品搜索项目。有段时间，用户时常反馈这么一个问题，那就是关键词搜索的结果非常不精准。比如搜索“牛奶”，会出现很多牛奶巧克力，甚至连牛奶色的连衣裙，都跑到搜索结果的前排了，用户体验非常差。但是，巧克力和连衣裙这种商品标题里确实存在“牛奶”的字样，如果简单地把“牛奶”字眼从巧克力和服饰等商品标题里去除，又会导致搜索“牛奶巧克力”或者“牛奶连衣裙”时无法展示相关的商品，这肯定也是不行的。

这种搜索不精确的情况十分普遍，还有很多其他的例子，比如搜索“橄榄油”的时候会返回热门的“橄榄油发膜”或“橄榄油护手霜”，搜索“手机”的时候会返回热门的“手机壳”和“手机贴膜”。另外，商品的品类也在持续增加，因此也无法通过人工运营来解决。

为了解决这个问题，首先我们来分析一下产生问题的主要原因。目前多数的搜索引擎实现，所采用都是类似向量空间模型的相关性模型。所以在进行相关性排序的时候，系统主要考虑的因素都是关键词的 tf-idf、文档的长短、查询的长短等因素。这种方式非常适合普通的文本检索，在各大通用搜索引擎里也被证明是行之有效的方法之一。但是，经过我们的分析，这种方式并不适合电子商务的搜索平台，主要原因包括这样几点：

第一点，商品的标题都非常短。电商平台上的商品描述，包含的内容太多，有时还有不少广告宣传，这些不一定是针对产品特性的信息，如果进入了索引，不仅加大了系统计算的时间和空间复杂度，还会导致较低的相关性。所以，商品的标题、名称和主要的属性成为搜索索引关注的对象，而这些内容一般短小精悍，不需要考虑其长短对于相关性衡量的影响。

第二点，关键词出现的位置、词频对相关性意义不大。如上所述，正是由于商品搜索主要关注的是标题等信息浓缩的字段，因此某个关键词出现的位置、频率对于相关性的衡量影响非常小。如果考虑了这些，反而容易被别有用心的卖家利用，进行不合理的关键词搜索优化（SEO），导致最终结果的质量变差。

第三点，用户的查询普遍比较短。在电商平台上，顾客无需太多的关键词就能定位大概所需，因此查询的字数多少对于相关性衡量也没有太大意义。

因此，电商的搜索系统不能局限于关键词的词频、出现位置等基础特征，更应该从其他方面来考虑。

既然最传统的向量空间模型无法很好的解决商品的搜索，那么我们应该使用什么方法进行改进呢？回到我们之前所发现的问题，实际上主要纠结在一个“分类”的问题上。例如，顾客搜索“牛奶”字眼的时候，系统需要清楚用户是期望找到饮用的牛奶，还是牛奶味的巧克力或饼干。从这个角度出发考虑，我们很容易就考虑到了，是不是可以首先对用户的查询，进行一个基于商品目录的分类呢？如果可以，那么我们就能知道把哪些分类的商品排在前面，从而提高返回商品的相关性。

查询的分类

说到查询的分类，我们有两种方法可以尝试。第一种方法是在商品分类的数据上，运用朴素贝叶斯模型构建分类器。第二种方法是根据用户的搜索行为构建分类器。

在第一种方法中，商品分类数据和朴素贝叶斯模型是关键。电商平台通常会使用后台工具，让运营人员构建商品的类目，并在每个类目中发布相应的商品。这个商品的类目，就是我们分类所需的类别信息。由于这些商品属于哪个类目是经过人工干预和确认的，因此数据质量通常比较高。我们可以直接使用这些数据，构造朴素贝叶斯分类器。这里我们快速回顾一下朴素贝叶斯的公式。

之前我们提到过，商品文描中噪音比较多，因此通常我们只看商品的标题和重要属性。所以，上述公式中的 f1，f2，……，fkf1，f2，……，fk，表示来自商品标题和属性的关键词。

相对于第一种方法，第二种方法更加巧妙。它的核心思想是观察用户在搜词后的行为，包括点击进入的详情页、把商品加入收藏或者是添加到购物车，这样我们就能知道，顾客最为关心的是哪些类目。

举个例子，当用户输入关键词“咖啡”，如果经常浏览和购买的品类是国产冲饮咖啡、进口冲饮咖啡和咖啡饮料，那么这 3 个分类就应该排在更前面，然后将其它虽然包含咖啡字眼，但是并不太相关的分类统统排在后面。需要注意的是，这种方法可以直接获取 P(C|f)，而无需通过贝叶斯理论推导。

上述这两种方法各有优劣。第一种方法的优势在有很多的人工标注作为参考，因此不愁没有可用的数据。可是分类的结果受到商品分布的影响太大。假设服饰类商品的数量很多，而且有很多服饰都用到了“牛奶”的字眼，那么根据朴素贝叶斯分类模型的计算公式，“牛奶”这个词属于服饰分类的概率还是很高。第二种方法正好相反，它的优势在于经过用户行为的反馈，我们可以很精准地定位到每个查询所期望的分类，甚至在一定程度上解决查询季节性和个性化的问题。但是这种方法过度依赖用户的使用，面临一个“冷启动”的问题，也就说在搜索系统投入使用的初期，无法收集足够的数据。

考虑到这两个方法的特点，我们可以把它们综合起来使用，最简单的就是线性加和。

P(C|query)=w1⋅P1(C|query)+w2⋅P2(C|query)P(C|query)=w1·P1(C|query)+w2·P2(C|query)

其中，P1P1 和 P2P2 分别表示根据第一种方法和第二种方法获得的概率，而权重 w1w1 和 w2w2 分别表示第一种方法和第二种方法的权重，可以根据需要设置。通常在一个搜索系统刚刚起步的时候，可以让 w1w1 更大。随着用户不断的使用，我们就可以让 w2w2 更大，让用户的参与使得系统更智能。

查询分类和搜索引擎的结合

一旦我们可以对商品查询进行更加准确地分类，那么就可以把这个和普通的搜索引擎结合起来。我使用下面的框架图来展示整个流程。

从这张图可以看到，我们使用商品目录打造一个初始版本的查询分类器。随着用户不断的使用这个搜索引擎，我们收集用户的行为日志，并使用这个日志改善查询的分类器，让它变得更加精准，然后再进一步优化搜索引擎的相关性。

我以 Elasticsearch 为例，讲一下如何利用分类的结果改变搜索的排序。

Elasticsearch 是一个基于 Lucene 的搜索服务器，是流行的企业级搜索引擎之一，目前最新版已经更新到 6.6.x。Elasticsearch 是基于 Lucene 的架构，很多要素都是一脉相承的，例如文档和字段的概念、相关性的模型、各种模式的查询等。也正是这个原因，Elasticsearch 默认的排序也采取了类似向量空间模型的方式。如果这种默认排序并不适用于商品搜索，那么我们要如何修改呢？

为了充分利用查询分类的结果，首先要达到这样的目标：对于给定的查询，所有命中的结果的得分都是相同的。至少有两种做法：修改默认的 Similarity 类的实现，或者是使用过滤查询（Filter Query）。

统一了基本的排序得分之后，我们就可以充分利用用户的行为数据，指导搜索引擎进行有针对性的排序改变，最终提升相关性。这里需要注意的是，由于这里排序的改变依赖于用户每次输入的关键词，因此不能在索引的阶段完成。

例如，在搜索“牛奶巧克力”的时候，理想的是将巧克力排列在前，而搜索“巧克力牛奶”的时候，理想的是将牛奶排列在前，所以不能简单地在索引阶段就利用文档提升（Document Boosting）或字段提升（Field Boosting）。

对于 Elasticsearch 而言，它有个强大的 Boost 功能，这个功能可以在查询阶段，根据某个字段的值，动态地修改命中结果的得分。假设我们有一个用户查询“米”，根据分类结果，我们知道“米”属于“大米”分类的概率为 0.85，属于“饼干”和“巧克力”分类的概率都为 0.03。根据这个分类数据，下面我使用了一段伪代码，展示了加入查询分类后的 Elasticsearch 查询。

{

  "query": {

    "bool": {

      "must": {

        "match_all": {

        }

      },

      "should": [

        {

          "match": {

            "category_name": {

              "query": " 大米 ",

              "boost": 0.85

            }

          }

        },

        {

          "match": {

            "category_name": {

              "query": " 饼干 ",

              "boost": 0.03

            }

          }

        },

        {

          "match": {

            "category_name": {

              "query": " 巧克力 ",

              "boost": 0.03

            }

          }

        }

      ],

      "filter": {

        "term": {"listing_title" : " 米 "}

      }

    }

  }

}

其中最主要的部分是增加了 should 的查询，针对最主要的 3 个相关分类进行了 boost 操作。如果使用这个查询进行搜索，你就会发现属于“大米”分类的商品排到了前列，更符合用户的预期，而且这完全是在没有修改索引的前提下实现的。

小结

相关性模型是搜索引擎非常核心的模块，它直接影响了搜索结果是不是满足用户的需求。我们之前讲解的向量空间模型、概率语言模型等常见的模型，逐渐成为了主流的相关性模型。不过这些模型通常适用于普通的文本检索，并没有针对每个应用领域进行优化。

在电商平台上，搜索引擎是帮助用户查找商品的好帮手。可是，直接采用向量空间模型进行排序往往效果不好。这主要是因为索引的标题和属性都很短，我们无法充分利用关键词的词频、逆文档频率等信息。考虑到搜索商品的时候，商品的分类对于用户更为重要，所以我们在设计相关性排序的时候需要考虑这个信息。

为了识别用户对哪类商品更感兴趣，我们可以对用户输入的查询进行分类。用于构建分类器的数据，可以是运营人员发布的商品目录信息，也可以是用户使用之后的行为日志。我们可以根据搜索系统运行的情况，赋予它们不同的权重。

如果我们可以对查询做出更为准确的分类，那么就可以使用这个分类的结果，来对原有搜索结果进行重新排序。现在的开源搜索引擎，例如 Elasticsearch，都支持动态修改排序结果，为我们结合分类器和搜索引擎提供了很大的便利。

思考题

通过用户行为反馈的数据，构建查询分类的时候，我们把整个查询作为了一个单词或者词组来处理。也就是说直接获取了 P(C|f)P(C|f) 的值。如果我们把这个查询看做多个词的组合，也就是说获取的是 P(C|f1,f2,…,fn)P(C|f1,f2,…,fn)，那我们可以如何改进这个基于用户行为反馈的分类模型呢？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

49 | 推荐系统（上）：如何实现基于相似度的协同过滤？

你好，我是黄申。

个性化推荐这种技术在各大互联网站点已经普遍使用了，系统会根据用户的使用习惯，主动提出一些建议，帮助他们发现一些可能感兴趣的电影、书籍或者是商品等等。在这方面，最经典的案例应该是美国的亚马逊电子商务网站，它是全球最大的 B2C 电商网站之一。在公司创立之初，最为出名的就是其丰富的图书品类，以及相应的推荐技术。亚马逊的推荐销售占比可以达到整体销售的 30% 左右。可见，对于公司来说，推荐系统也是销售的绝好机会。因此，接下来的两节，我会使用一个经典的数据集，带你进行推荐系统核心模块的设计和实现。

MovieLens 数据集

在开始之前，我们先来认识一个知名的数据集，MovieLens。你可以在它的主页查看详细的信息。这个数据集最核心的内容是多位用户对不同电影的评分，此外，它也包括了一些电影和用户的属性信息，便于我们研究推荐结果是不是合理。因此，这个数据集经常用来做推荐系统、或者其他机器学习算法的测试集。

时至今日，这个数据集已经延伸出几个不同的版本，有不同的数据规模和更新日期。我这里使用的是一个最新的小规模数据集，包含了 600 位用户对于 9000 部电影的约 10 万条评分，最后更新于 2018 年 9 月。你可以在这里下载：http://files.grouplens.org/datasets/movielens/ml-latest-small.zip。

解压了这个 zip 压缩包之后，你会看到 readme 文件和四个 csv 文件（ratings、movies、links 和 tags）。其中最重要的是 ratings，它包含了 10 万条评分，每条记录有 4 个字段，包括 userId、movieId、rating、timestamp。userId 表示每位用户的 id，movieId 是每部电影的 ID，

rating 是这位用户对这部电影的评分，取值为 0-5 分。timestamp 是时间戳。而 movies 包含了电影的主要属性信息，title 和 genres 分别表示电影的标题和类型，一部电影可以属于多种类型。links 和 tags 则包含了电影的其他属性信息。我们的实验主要使用 ratings 和 movies 里的数据。

设计的整体思路

有了用于实验的数据，接下来就要开始考虑如何设计这个推荐系统。我在第 38 期讲解了什么是协同过滤推荐算法、基于用户的协同过滤和基于物品的协同过滤。这一节我们就以协同过滤为基础，分别实现基于用户和物品的过滤。

根据协同过滤算法的核心思想，整个系统可以分为三个大的步骤。

第一步，用户评分的标准化。因为有些用户的打分比较宽松，而有些用户打分则比较挑剔。所以，我们需要使用标准化或者归一化，让不同用户的打分具有可比性，这里我会使用z 分数标准化。

第二步，衡量和其他用户或者物品之间的相似度。我们这里的物品就是电影。在基于用户的过滤中，我们要找到相似的用户。在基于物品的过滤中，我们要找到相似的电影。我这里列出计算用户之间相似度 usus 和物品之间相似度 isis 的公式。之前我们讲过，这些都可以通过矩阵操作来实现。

我们以基于用户的过滤为例。假设我们使用夹角余弦来衡量相似度，那么我们就可以采用用户评分的矩阵点乘自身的转置来计算余弦夹角。用户评分的矩阵 XX 中，每一行是某位用户的行向量，每个分量表示这位用户对某部电影的打分。而矩阵 X′X′ 的每一列是某个用户的列向量，每个分量表示用户对某部电影的打分。

我们假设 XX′XX′ 的结果为矩阵 YY，那么 yi,jyi,j 就表示用户 ii 和用户 jj 这两者喜好度向量的点乘结果，它就是夹角余弦公式中的分子。如果 ii 等于 jj，那么这个计算值也是夹角余弦公式分母的一部分。从矩阵的角度来看，YY 中任何一个元素都可能用于夹角余弦公式的分子，而对角线上的值会用于夹角余弦公式的分母。因此，我们可以利用 YY 来计算任何两个用户之间的相似度。

之前我们使用了一个示例讲解过对于基于用户的协同过滤，如何计算矩阵 YY，以及如何使用 YY 来计算余弦夹角，我这里列出来给你参考。

第三步，根据相似的用户或物品，给出预测的得分 p。

之前我们也解释过如何使用矩阵操作来实现这一步。还是以基于用户的过滤为例。假设通过第二步，我们已经得到用户相似度矩阵 USUS，USUS 和评分矩阵 XX 的点乘结果为矩阵 USPUSP。沿用前面的示例，结果就是下面这样。

然后对 USPUSP 按行求和，获得矩阵 USRUSR。

最终，我们使用 USPUSP 和 USRUSR 的元素对应除法，就可以求得任意用户对任意电影的评分矩阵 PP。

有了这个设计的思路，下面我们就可以使用 Python 进行实践了。

核心 Python 代码

在实现上述设计的三个主要步骤之前，我们还需要把解压后的 csv 文件加载到数组，并转为矩阵。下面我列出了主要的步骤和注释。需要注意的是，由于这个数据集中的用户和电影 ID 都是从 1 开始而不是从 0 开始，所以需要减去 1，才能和 Python 数组中的索引一致。

import pandas as pd

from numpy import *

# 加载用户对电影的评分数据

df = pd.read_csv("/Users/shenhuang/Data/ml-latest-small/ratings.csv")

# 获取用户的数量和电影的数量

user_num = df["userId"].max()

movie_num = df["movieId"].max()

# 构造用户对电影的二元关系矩阵

user_rating = [[0.0] * movie_num for i in range(user_num)]

i = 0

for index, row in df.iterrows():   # 获取每行的 index、row

  # 由于用户和电影的 ID 都是从 1 开始，为了和 Python 的索引一致，减去 1

  userId = int(row["userId"]) - 1

  movieId = int(row["movieId"]) - 1

  # 设置用户对电影的评分

  user_rating[userId][movieId] = row["rating"]

  # 显示进度

  i += 1

  if i % 10000 == 0:

    print(i)

# 把二维数组转化为矩阵

x = mat(user_rating)

print(x)

加载了数据之后，第一步就是对矩阵中的数据，以行为维度，进行标准化。

# 标准化每位用户的评分数据

from sklearn.preprocessing import scale

# 对每一行的数据，进行标准化

x_s = scale(x, with_mean=True, with_std=True, axis=1)

print(" 标准化后的矩阵：", x_s)

第二步是计算表示用户之间相似度的矩阵 US。其中，y 变量保存了矩阵 X 左乘转置矩阵 X’的结果。而利用 y 变量中的元素，我们很容易就可以得到不同向量之间的夹角余弦。

# 获取 XX'

y = x_s.dot(x_s.transpose())

print("XX'的结果是'：", y)

# 获得用户相似度矩阵 US

us = [[0.0] * user_num for i in range(user_num)]

for userId1 in range(user_num):

    for userId2 in range(user_num):

        # 通过矩阵 Y 中的元素，计算夹角余弦

        us[userId1][userId2] = y[userId1][userId2] / sqrt((y[userId1][userId1] * y[userId2][userId2]))

在最后一步中，我们就可以进行基于用户的协同过滤推荐了。需要注意的是，我们还需要使用元素对应的除法来实现归一化。

# 通过用户之间的相似度，计算 USP 矩阵

usp = mat(us).dot(x_s)

# 求用于归一化的分母

usr = [0.0] * user_num

for userId in range(user_num):

    usr[userId] = sum(us[userId])

# 进行元素对应的除法，完成归一化

p = divide(usp, mat(usr).transpose()

我们可以来看一个展示推荐效果的例子。在原始的评分数据中，我们看到 ID 为 1 的用户并没有对 ID 为 2 的电影进行评分。而在最终的矩阵 P 中，我们可以看系统对用户 1 给电影 2 的评分做出了较高的预测，换句话说，系统认为用户 1 很可能会喜好电影 2。进一步研究电影的标题和类型，我们会发现用户 1 对《玩具总动员》（1995 年）这类冒险类和动作类的题材更感兴趣，所以推荐电影 2《勇敢者的游戏》（1995 年）也是合理的。

总结

在今天的内容中，我通过一个常用的实验数据，设计并实现了最简单的基于用户的协同过滤。我们最关心的是这个数据中，用户对电影的评分。有了这种二元关系，我们就能构建矩阵，并通过矩阵的操作来发现用户或物品之间的相似度，并进行基于用户或者物品的协同过滤。对于最终的计算结果，你可以尝试分析针对不同用户的推荐，看看协同过滤推荐的效果是不是合理。

在你分析推荐结果的时候，可能会参考 movie.csv 这个文件中所描述的电影类型。这些电影类型都是一开始人工标注好的。那么，有没有可能在没有这种标注数据的情况下，在一定程度上自动分析哪些电影属于同一个或者近似的类型呢？如果可以，有没有可能在这种自动划分电影类型的基础之上，给出电影的推荐呢？下一节，我会通过 SVD 奇异值分解，来进行这个方向的尝试。

思考题

今天我使用 Python 代码实现了基于用户的协同过滤。类似地，我们也可以采用矩阵操作来实现基于物品的协同过滤，请使用你擅长的语言来实现试试。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

50 | 推荐系统（下）：如何通过SVD分析用户和物品的矩阵？

你好，我是黄申。

上一节，我们讲了如何使用矩阵操作，实现基于用户或者物品的协同过滤。实际上，推荐系统是个很大的课题，你可以尝试不同的想法。比如，对于用户给电影评分的案例，是不是可以使用 SVD 奇异值的分解，来分解用户评分的矩阵，并找到“潜在”的电影主题呢？如果在一定程度上实现这个目标，那么我们可以通过用户和主题，以及电影和主题之间的关系来进行推荐。今天，我们继续使用 MovieLens 中的一个数据集，尝试 Python 代码中的 SVD 分解，并分析一些结果所代表的含义。

SVD 回顾以及在推荐中的应用

在实现 SVD 分解之前，我们先来回顾一下 SVD 的主要概念和步骤。如果矩阵 XX 是对称的方阵，那么我们可以求得这个矩阵的特征值和特征向量，并把矩阵 XX 分解为特征值和特征向量的乘积。

假设我们求出了矩阵 XX 的 nn 个特征值 λ1，λ2，…，λnλ1，λ2，…，λn，以及这 nn 个特征值所对应的特征向量 v1，v2，…，vnv1，v2，…，vn，那么矩阵 XX 可以表示为：

X=VΣV−1X=VΣV−1

其中，VV 是这 nn 个特征向量所张成的 n×nn×n 维矩阵，而 ΣΣ 是这 nn 个特征值为主对角线的 n×nn×n 维矩阵。这个过程就是特征分解（Eigendecomposition）。

如果我们会把 VV 的这 nn 个特征向量进行标准化处理，那么对于每个特征向量 ViVi，就有 ||Vi||2=1||Vi||2=1，而这表示 V′iVi=1Vi′Vi=1，此时 VV 的 nn 个特征向量为标准正交基，满足 V′V=IV′V=I， 也就是说，VV 为酉矩阵，有 V′=V−1V′=V−1 。这样一来，我们就可以把特征分解表达式写作：

X=VΣV′X=VΣV′

可是，如果矩阵 XX 不是对称的方阵，那么我们不一定能得到有实数解的特征分解。但是，SVD 分解可以避免这个问题。

我们可以把 XX 的转置 X′X′ 和 XX 做矩阵乘法，得到一个 n×nn×n 维的对称方阵 X′XX′X，并对这个对称方阵进行特征分解。分解的时候，我们得到了矩阵 X′XX′X 的 nn 个特征值和对应的 nn 个特征向量 vv，其中所有的特征向量叫作 XX 的右奇异向量。通过所有右奇异向量我们可以构造一个 n×nn×n 维的矩阵 VV。

类似地，如果我们把 XX 和 X′X′ 做矩阵乘法，那么会得到一个 m×mm×m 维的对称方阵 XX′XX′。由于 XX′XX′ 也是方阵，因此我们同样可以对它进行特征分解，并得到矩阵 XX′XX′ 的 mm 个特征值和对应的 mm 个特征向量 uu，其中所有的特征向量向叫作 XX 的左奇异向量。通过所有左奇异向量我们可以构造一个 m×mm×m 的矩阵 UU。

现在，包含左右奇异向量的 UU 和 VV 都求解出来了，只剩下奇异值矩阵 ΣΣ 了。ΣΣ 除了对角线上是奇异值之外，其他位置的元素都是 0，所以我们只需要求出每个奇异值 σσ 就可以了。之前我们已经推导过，σσ 可以通过两种方式获得。第一种方式是计算下面这个式子：

σi=Xviuiσi=Xviui

其中 vivi 和 uiui 都是列向量。一旦我们求出了每个奇异值 σσ，那么就能得到奇异值矩阵 ΣΣ。

第二种方式是通过 X′XX′X 矩阵或者 XX′XX′ 矩阵的特征值之平方根，来求奇异值。计算出每个奇异值 σσ，那么就能得到奇异值矩阵 ΣΣ 了。

通过上述几个步骤，我们就能把一个 mxnmxn 维的实数矩阵，分解成 X=UΣV′X=UΣV′ 的形式。那么这种分解对于推荐系统来说，又有怎样的意义呢？

之前我讲过，在潜在语义分析 LSA 的应用场景下，分解之后所得到的奇异值 σσ，对应一个语义上的“概念”，而 σσ 值的大小表示这个概念在整个文档集合中的重要程度。UU 中的左奇异向量表示了每个文档和这些语义“概念”的关系强弱，VV 中的右奇异向量表示每个词条和这些语义“概念”的关系强弱。

最终，SVD 分解把原来的“词条 - 文档”关系，转换成了“词条 - 语义概念 - 文档”的关系。而在推荐系统的应用场景下，对用户评分矩阵的 SVD 分解，能够帮助我们找到电影中潜在的“主题”，比如科幻类、动作类、浪漫类、传记类等等。

分解之后所得到的奇异值 σσ 对应了一个“主题”，σσ 值的大小表示这个主题在整个电影集合中的重要程度。UU 中的左奇异向量表示了每位用户对这些“主题”的喜好程度，VV 中的右奇异向量表示每部电影和这些“主题”的关系强弱。

最终，SVD 分解把原来的“用户 - 电影”关系，转换成了“用户 - 主题 - 电影”的关系。有了这种新的关系，即使我们没有人工标注的电影类型，同样可以使用更多基于电影主题的推荐方法，比如通过用户对电影主题的评分矩阵，进行基于用户或者电影的协同过滤。

接下来，我会使用同样一个 MovieLens 的数据集，一步步展示如何通过 Python 语言，对用户评分的矩阵进行 SVD 分解，并分析一些结果的示例。

Python 中的 SVD 实现和结果分析

和上节的代码类似，首先我们需要加载用户对电影的评分。不过，由于非并行 SVD 分解的时间复杂度是 3 次方数量级，而空间复杂度是 2 次方数量级，所以对硬件资源要求很高。这里为了节省测试的时间，我增加了一些语句，只取大约十分之一的数据。

import pandas as pd

from numpy import *

# 加载用户对电影的评分数据

df_ratings = pd.read_csv("/Users/shenhuang/Data/ml-latest-small/ratings.csv")

# 获取用户的数量和电影的数量，这里我们只取前 1/10 来减小数据规模

user_num = int(df_ratings["userId"].max() / 10)

movie_num = int(df_ratings["movieId"].max() / 10)

# 构造用户对电影的二元关系矩阵

user_rating = [[0.0] * movie_num for i in range(user_num)]

i = 0

for index, row in df_ratings.iterrows():   # 获取每行的 index、row

  # 由于用户和电影的 ID 都是从 1 开始，为了和 Python 的索引一致，减去 1

  userId = int(row["userId"]) - 1

  movieId = int(row["movieId"]) - 1

  # 我们只取前 1/10 来减小数据规模

  if (userId >= user_num) or (movieId >= movie_num):

    continue

  # 设置用户对电影的评分

  user_rating[userId][movieId] = row["rati

之后，二维数组转为矩阵，以及标准化矩阵的代码和之前是一致的。

# 把二维数组转化为矩阵

x = mat(user_rating)

# 标准化每位用户的评分数据

from sklearn.preprocessing import scale

# 对每一行的数据，进行标准化

x_s = scale(x, with_mean=True, with_std=True, axis=1)

print(" 标准化后的矩阵：", x_s

Python 的 numpy 库，已经实现了一种 SVD 分解，我们只调用一个函数就行了。

# 进行 SVD 分解

from numpy import linalg as LA

u,sigma,vt = LA.svd(x_s, full_matrices=False, compute_uv=True)

print("U 矩阵：", u)

print("Sigma 奇异值：", sigma)

print("V 矩阵：", vt)

最后输出的 Sigma 奇异值大概是这样的：

Sigma 奇异值： [416.56942602 285.42546812 202.25724866 ... 79.26188177  76.35167406 74.96719708]

最后几个奇异值不是 0，说明我们没有办法完全忽略它们，不过它们相比最大的几个奇异值还是很小的，我们可以去掉这些值来求得近似的解。

为了验证一下 SVD 的效果，我们还可以加载电影的元信息，包括电影的标题和类型等等。我在这里使用了一个基于哈希的 Python 字典结构来存储电影 ID 到标题和类型的映射。

# 加载电影元信息

df_movies = pd.read_csv("/Users/shenhuang/Data/ml-latest-small/movies.csv")

dict_movies = {}

for index, row in df_movies.iterrows():   # 获取每行的 index、row

  dict_movies[row["movieId"]] = "{0},{1}".format(row["title"], row["genres"])

print(dict_movies)

我刚刚提到，分解之后所得到的奇异值 σσ 对应了一个“主题”，σσ 值的大小表示这个主题在整个电影集合中的重要程度，而 V 中的右奇异向量表示每部电影和这些“主题”的关系强弱。所以，我们可以对分解后的每个奇异值，通过 VV 中的向量，找找看哪些电影和这个奇异值所对应的主题更相关，然后看看 SVD 分解所求得的电影主题是不是合理。比如，我们可以使用下面的代码，来查看和向量 Vt1Vt1, 相关的电影主要有哪些。

# 输出和某个奇异值高度相关的电影，这些电影代表了一个主题

print(max(vt[1,:]))

for i in range(movie_num):

    if (vt[1][i] > 0.1):

        print(i + 1, vt[1][i], dict_movies[i + 1])

需要注意的是，向量中的电影 ID 和原始的电影 ID 差 1，所以在读取 dict_movies 时需要使用 (i + 1)。这个向量中最大的分值大约是 0.173，所以我把阈值设置为 0.1，并输出了所有分值大于 0.1 的电影，电影列表如下：

0.17316444479201024

260 0.14287410901699643 Star Wars: Episode IV - A New Hope (1977),Action|Adventure|Sci-Fi

1196 0.1147295905497075 Star Wars: Episode V - The Empire Strikes Back (1980),Action|Adventure|Sci-Fi

1198 0.15453176747222075 Raiders of the Lost Ark (Indiana Jones and the Raiders of the Lost Ark) (1981),Action|Adventure

1210 0.10411193224648774 Star Wars: Episode VI - Return of the Jedi (1983),Action|Adventure|Sci-Fi

2571 0.17316444479201024 Matrix, The (1999),Action|Sci-Fi|Thriller

3578 0.1268370902126096 Gladiator (2000),Action|Adventure|Drama

4993 0.12445203514448012 Lord of the Rings: The Fellowship of the Ring, The (2001),Adventure|Fantasy

5952 0.12535012292041953 Lord of the Rings: The Two Towers, The (2002),Adventure|Fantasy

7153 0.10972312192709989 Lord of the Rings: The Return of the King, The (2003),Action|Adventure|Drama|Fantasy

从这个列表可以看出，这个主题是关于科幻或者奇幻类的动作冒险题材。

使用类似的代码和同样的阈值 0.1，我们来看看和向量 Vt5Vt5, 相关的电影主要有哪些。

# 输出和某个奇异值高度相关的电影，这些电影代表了一个主题

print(max(vt[5,:]))

for i in range(movie_num):

    if (vt[5][i] > 0.1):

        print(i + 1, vt[5][i], dict_movies[i + 1])

电影列表如下：

0.13594520920117012

21 0.13557812349701226 Get Shorty (1995),Comedy|Crime|Thriller

50 0.11870851441884082 Usual Suspects, The (1995),Crime|Mystery|Thriller

62 0.11407971751480048 Mr. Holland's Opus (1995),Drama

168 0.10295400456394468 First Knight (1995),Action|Drama|Romance

222 0.12587492482374366 Circle of Friends (1995),Drama|Romance

261 0.13594520920117012 Little Women (1994),Drama

339 0.10815473505804706 While You Were Sleeping (1995),Comedy|Romance

357 0.11108191756350501 Four Weddings and a Funeral (1994),Comedy|Romance

527 0.1305895737838763 Schindler's List (1993),Drama|War

595 0.11155774544755555 Beauty and the Beast (1991),Animation|Children|Fantasy|Musical|Romance|IMAX

从这个列表可以看出，这个主题更多的是关于剧情类题材。就目前所看的两个向量来说，SVD 在一定程度上区分了不同的电影主题，你也可以使用类似的方式查看更多的向量，以及对应的电影名称和类型。

总结

在今天的内容中，我们回顾了 SVD 奇异值分解的核心思想，解释了如何通过 XX′XX′ 和 X′XX′X 这两个对称矩阵的特征分解，求得分解后的 UU 矩阵、VV 矩阵和 ΣΣ 矩阵。另外，我们也解释了在用户对电影评分的应用场景下，SVD 分解后的 UU 矩阵、VV 矩阵和 ΣΣ 矩阵各自代表的意义，其中 ΣΣ 矩阵中的奇异值表示了 SVD 挖掘出来的电影主题，UU 矩阵中的奇异向量表示用户对这些电影主题的评分，而 VV 矩阵中的奇异向量表示了电影和这些主题的相关程度。

我们还通过 Python 代码，实践了这种思想在推荐算法中的运用。从结果的奇异值和奇异向量可以看出，SVD 分解找到了一些 MovieLens 数据集上的电影主题。这样我们就可以把用户针对电影的评分转化为用户针对主题的评分。由于主题通常远远小于电影，所以 SVD 的分解也帮助我们实现了降低特征维度的目的。

SVD 分解能够找到一些“潜在的“因素，例如语义上的概念、电影的主题等等。虽然这样操作可以降低特征维度，去掉一些噪音信息，但是由于 SVD 分解本身的计算量也很大，所以从单次的执行效率来看，SVD 往往无法起到优化的作用。在这种情况下，我们可以考虑把它和一些监督式的学习相结合，使用一次分解的结果构建分类器，提升日后的执行效率。

思考题

刚才 SVD 分解实验中得到的 UU 矩阵，是用户对不同电影主题的评分矩阵。请你使用这个 UU 矩阵，进行基于用户或者基于主题（物品）的协同过滤。

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

51 | 综合应用篇答疑和总结：如何进行个性化用户画像的设计？

你好，我是黄申。今天是综合应用篇的答疑和总结。

在这个模块中，我们讲述了不同数学思想在系统设计和实现中的综合运用。相对于前面几个模块，综合应用的内容更注重实践，也更加有趣。大家对这些内容也提出了很多值得思考的问题。今天，我会讲解其中一个问题，如何进行个性化用户画像的设计？。最后，我也会照例对整个应用篇进行一个总结。

个性化用户画像的设计

如今是个性化的时代，互联网和人工智能技术正在把这点推向极致。无论是主动搜索还是进行浏览，用户都希望看到针对自己的结果。

举个例子，A 品牌的奶瓶在全网是非常畅销的，可是对于一位 5 岁儿子的妈妈来说，儿子早已过了喝奶瓶的阶段，所以在她输入 A 品牌后，返回“奶瓶”肯定不合适。同时，如果她一直在购买 A 品牌的儿童洗衣液，那么返回 A 品牌的洗衣液就更合理，顾客体验也会更好，这就是品类的个性化。

从另一个场景来看，这位妈妈没有输入 A 品牌，而是输入了“儿童洗衣液”，如果是 A 品牌的洗衣液产品排在首页，而不是她所陌生的其他品牌，用户体验也会更好，这就是品牌的个性化。

在进行个性化设计之前，最关键的问题是，如何收集和运用顾客的行为数据。

第 48 节，我在讲解查询分类的时候，介绍了如何利用用户的搜索行为。而实践中，用户个人的行为涉及面更为广泛，需要更多细致的分析。通常我们将相应的工程称为“用户画像”。为了让你更好地理解，这里我给出一个较为全面的设计概述。

如何通过数据生成用户标签？

开发用户画像，首先要解决的问题是：哪些用户数据可以收集，以及如何通过这些数据生成用户标签。

最基本的原始数据包括网站浏览、购物、位置、气候、设备等信息。除了这些原始的数据，我们还可以结合人工的运营，生成一些包含语义的用户标签。这里的用户标签，或者说属性标签，是一个具有语义的标签，用于描述一组用户的行为特征。例如，“美食达人”“数码玩家”“白领丽人”“理财专家”等。对于标签的定义，按照概率统计篇和线性代数篇所介绍的机器学习方法论，既可以考虑采用监督式的分类方法，也可以采用非监督式的聚类方法。

分类的好处在于，可以让人工运营向计算机系统输入更多的先验知识，也可以让标签的制定和归类更为精准。从操作的层面考虑，又可以细分为基于人工规则和基于标注数据。人工规则是指由运营人员指定分类的主要规则。

例如，运营人员指定最近 1 个月，至少购买过 2 次以上母婴产品，消费额在 500 元以上的为“辣妈” 标签。这里规则就相当于直接产生类似决策树的分类模型，它的优势在于具有很强的可读性，便于人们的理解和沟通。但是，如果用户的行为特征过于繁多，运营人员往往很难甄别出哪些具有代表性。这时如果仍然使用规则，那么就不容易确定规则的覆盖面或者是精准度。

另一种方法是使用标注数据，通过训练样本来构建分类器。例如，通过运营人员挑选一些有代表性的用户，对他们的特征进行人工标注，然后输入给系统。之后，让系统根据分类技术来学习，模型可以使用决策树、朴素贝叶斯 NB（Naive Bayes）或支持向量机 SVM（Support Vector Machine）等等。

不过，除了决策树的模型，其余模型产生的人群分组可能会缺乏可读性内容，很难向业务方解释其结果。一种缓解的办法是让系统根据数据挖掘中的特征选择技术，包括我们之前讲解的信息增益 IG（Information Gain）、开方检验 CHI 等，来确定这组人群应该有怎样的特征，并将其作为标签。

除了分类，我们也可以使用非监督式的聚类。这种方法中，运营人员参与最少，完全利用用户之间的相似度来确定，相似度同样可以基于各种用户的特征和向量空间模型来衡量。其问题也在于结果缺乏解释性，只能通过特征选择等技术来挑选具有代表性的标签。

如果我们比较一下分类和聚类的方法，会发现分类的技术比较适合业务需求明确、运营人员充足、针对少量高端顾客的管理，其精准性可以提升 VIP 顾客服务的品质。而聚类更适合大规模用户群体的管理，甚至是进行在线的 AB 测试，其对精准性要求不高，但是数据的规模比较大，对系统的数据处理能力有一定要求。

无论是哪种方法，只要我们能获取比较准确的用户标签，那么我们就可以给出用户的画像，刻画他们的主要行为特征。下面我们来看看基于用户画像，可以进行哪些个性化的服务。首先是在搜索中增加个性化因素，相比普通的搜索，个性化的搜索可以投用户之所好，增加搜索结果的点击率、商品的购买转化率等等。具体来说，我们可以在下面这几点下功夫：

第一点，个性化的排序，根据用户经常浏览的品类和属性，对搜索结果中的项目进行个性化的排序，开头提到的 5 岁儿子妈妈的案例体现了这点的核心思想。

第二点，个性化的搜索词推荐。例如，一位体育迷搜索“足球”的时候，我们可以给出“足球新闻”“冠军杯”等相关搜索。而在一位彩票用户搜索“足球“的时候，我们可以给出”足球彩票“等相关搜索。

第三点，个性化的搜索下拉提示。例如，经常购买儿童洗衣液的用户，输入儿童用品的品牌后，在搜索下拉框中优先提示该品牌的儿童洗衣液。

除了搜索，个性化还可以运用在推荐系统、电子邮件营销 EDM（Email Direct Marketing）、移动 App 的推送等等。对于推荐系统来说，在用户画像完善的前提下，我们能更准确地找到相似的用户和物品，从而进行效果更好的基于用户或基于物品的协同过滤。相对于传统的线下营销，电子邮件营销不再受限于印刷和人力成本，完全可以做到因人而异的精准化定向投放。

比如，系统根据品类、品牌、节日或时令，分为不同的主题进行推送。运营人员甚至只用制定模板和规则，然后让系统根据用户画像的特征，自动的填充模板并最终生成电子邮件的内容。另外，随着移动端逐渐占据互联网市场的主导地位，掌上设备的 App 推送变成了另一个重要的营销渠道。从技术层面上看，它可以采用和电子邮件营销类似的解决方案。不过，内容的运营要考虑到移动设备屏幕尺寸和交互方式的特性，并进行有针对性的优化。

有了上述这些设计理念和模块，我们需要一个整体的框架来整合它们。我在这里画了一张框架图，供你参考。

这种架构包括行为数据的收集和分析、聚类、分类、构建画像、缓存等几个主要模块。随着数据规模的不断扩大，我们可以选择一些分布式系统来存储用户画像数据，并使用缓存系统来升数据查询的效率，为前端的搜索、推荐、EDM 和 App 推送等应用提供服务。当然，我们还可以利用行为数据的跟踪，进一步分析这套画像系统的质量和效果，形成一个螺旋式上升的优化闭环。

综合来看，用户画像也许概念上并不复杂，可是一旦落实到技术实施，我们需要综合很多不同领域的知识。从用户标签的角度来说，可能涉及的领域包括监督式和非监督式的机器学习算法，以及相关的特征选择。从系统集成的角度来说，可能涉及的领域包括分布式、缓存、信息检索和推荐系统。这些内容我们在之前的各个模块都有介绍，今天我通过用户画像的设计进行了知识的串联。当然，我这里讲解的方案也只是一种参考，你可以结合自身的需求来进一步的设计和实现。相信经过一定量的项目实践和经验积累，你对这些内容的综合性运用会更加得心应手。

综合应用篇总结

在综合应用篇之前，我们分别从基础模块、概率统计模块和线性代数模块出发，详细阐述了不同编程技术背后的数学的知识。在综合应用这个模块，我们又从几个非常实用的案例出发，讲解了如何结合不同的编程技术，设计并架构大型的系统，最终为商业需求提供解决方案。

如今的数据系统越来越庞大，系统设计时常常会用到缓存系统来提升记录查找的效率。对缓存系统的强烈需求也催生了很多开源的项目，例如 Memcached 和 Redis 等等，这些系统都已经相当成熟。而在这个模块，我们同时使用了哈希函数和队列，实现了一个最简单的缓存系统。哈希函数确保了查找的高效率，而队列则实现了 LRU 的淘汰策略。通过这两点，你就能理解缓存设计的基本原理和方法。

和缓存类似，搜索引擎的倒排索引也使用了哈希表结构来提高查询效率。当然，倒排索引的功能不仅限于数据对象的快速定位。它本身还能存放很多额外的信息，包括词频 tf、tfidf、关键词出现的位置等等。在这个模块中，我展示了如何利用这些信息，实现更为复杂的相关性模型，例如向量空间模型、概率语言模型等等。另外，倒排索引可以帮助我们过滤掉完全无关的数据，大大降低这些模型的计算量。

除了基本的及时性和相关性，搜索引擎还应该按照不同应用的需求进行优化。例如，电商平台的搜索，就和通用型的搜索不一样，对于电商搜索来说，用户更加关注的是商品的品类。我讲解了如何根据商品目录和用户行为反馈，构建查询的分类器。这样，当用户进行搜索的时候，系统首先对用户输入的关键词进行分类，弄清楚用户最感兴趣的品类是哪些，然后再优化商品的排序，最终增加商品搜索结果的相关性。

和搜索引擎同样重要的是推荐引擎。有的时候，用户自己不会输入想要查询的关键词，而是喜好不断地浏览网页。这个时候推荐技术起到了很关键的作用，它可以主动地为用户提供他们可能感兴趣的内容。在这个领域，协同过滤是非常经典的算法。我通过代码的实践，给你讲解了如何通过矩阵操作实现基于用户和基于物品的过滤。除此之外，我们还探讨了如何使用 SVD，对用户和物品之间的关系进行分解，帮助我们找到隐藏在用户和物品之间的潜在因素，比如电影的主题。

无论是设计搜索还是推荐系统，我们都可以加入个性化的元素，而这点往往是提升业务的关键。今天，我讲解了用户画像的原理、用户标签的设计和实现、以及如何使用用户画像来给搜索和推荐系统加入个性化。而这个整体方案涉及的技术面是相当广的，你可以结合之前的各期专栏，对每一个环节进行消化和理解。

思考题

对各种知识的综合应用对个人能力要求很高，却也是最重要的。我想听你说说，在平时的开发项目中，你有没有结合使用本专栏所讲的不同知识点的经历？能不能和我们说说你在这方面的心得体会？

欢迎留言和我分享，也欢迎你在留言区写下今天的学习笔记。你可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

数学专栏课外加餐（一） | 我们为什么需要反码和补码？

你好，我是黄申。欢迎来到第一次课外加餐时间。

专栏已经更新了几讲，看到这么多人在留言区写下自己的疑惑和观点，我非常开心。很多同学在留言里提出了很多非常好的问题，所以我决定每隔一段时间，对留言里的疑问、有代表性的问题做个集中的解答，也是对我们主线内容做一个补充，希望对你有帮助。

什么是符号位？为什么要有符号位？

在第 1 讲里，我介绍了十进制数转二进制数。这里面很多人对逻辑右移和算术右移中提到的符号位和补码有疑惑。这里面涉及了几个重要的概念，包括符号位、溢出、原码、反码和补码。我详细讲一下这几个点的来龙去脉。

首先我们来看，什么是符号位，为什么要有符号位？用一句话来概括就是，符号位是有符号二进制数中的最高位，我们需要它来表示负数。

在实际的硬件系统中，计算机 CPU 的运算器只实现了加法器，而没有实现减法器。那么计算机如何做减法呢？我们可以通过加上一个负数来达到这个目的。比如，3-2 可以看作 3+(-2)。因此，负数的表示对于计算机中的二进制减法至关重要。

那么，接下来的问题就是，如何让计算机理解哪些是正数，哪些是负数呢？为此，人们把二进制数分为有符号数（signed）和无符号数（unsigned）。

如果是有符号数，那么最高位就是符号位。当符号位为 0 时，表示该数值为正数；当符号位为 1 时，表示该数值为负数。例如一个 8 位的有符号位二进制数 10100010，最高位是 1，这就表示它是一个负数。

如果是无符号数，那么最高位就不是符号位，而是二进制数字的一部分，例如一个 8 位的无符号位二进制数 10100010，我们可以通过第 1 讲讲过的内容，换算出它所对应的十进制数是 162。由于没有表示负数的符号位，所有无符号位的二进制都代表正数。

有些编程语言，比如 Java，它所有和数字相关的数据类型都是有符号位的；而有些编程语言，比如 C 语言，它有诸如 unsigned int 这种无符号位的数据类型。

下面我们来看，什么是溢出？

在数学的理论中，数字可以有无穷大，也有无穷小。可是，现实中的计算机系统，总有一个物理上的极限（比如说晶体管的大小和数量），因此不可能表示无穷大或者无穷小的数字。对计算机而言，无论是何种数据类型，都有一个上限和下限。

在 Java 中，int 型是 32 位，它的最大值也就是上限是 2^31-1（最高位是符号位，所以是 2 的 31 次方而不是 32 次方），最小值也就是下限是 -2^31。而 long 型是 64 位，它的最大值，也就是上限是 2^63-1；最小值，也就是下限是 -2^63。

对于 n 位的数字类型，符号位是 1，后面 n-1 位全是 0，我们把这种情形表示为 -2^(n-1) ，而不是 2^(n-1)。一旦某个数字超过了这些限定，就会发生溢出。如果超出上限，就叫上溢出（overflow）。如果超出了下限，就叫下溢出（underflow）。

那么溢出之后会发生什么呢？我以上溢出为例来给你解释。

n 位数字的最大的正值，其符号位为 0，剩下的 n-1 位都为 1，再增大一个就变为了符号位为 1，剩下的 n-1 位都为 0。而符号位是 1，后面 n-1 位全是 0，我们已经说过这表示 -2^(n-1)。

那么就是说，上溢出之后，又从下限开始，最大的数值加 1，就变成了最小的数值，周而复始，这不就是余数和取模的概念吗？下面这个图可以帮助你的理解。

其中右半部分的虚线表示已经溢出的区间，而为了方便你理解，我将溢出后所对应的数字也标在了虚线的区间里。由此可以看到，所以说，计算机数据的溢出，就相当于取模。而用于取模的除数就是数据类型的上限减去下限的值，再加上 1，也就是 (2^(n-1)-1)-(-2^(n-1))+1=2x2^(n-1)-1+1=2^n-1+1。

你可能会好奇，这个除数为什么不直接写成 2^n 呢？这是因为 2^n 已经是 n+1 位了，已经超出了 n 位所能表示的范围。

二进制的原码、反码及补码

理解了符号位和溢出，我接下来说说，什么是二进制的原码、反码和补码，以及我们为什么需要它们。

原码就是我们看到的二进制的原始表示。对于有符号的二进制来说，原码的最高位是符号位，而其余的位用来表示该数字绝对值的二进制。所以 +2 的原码是 000…010，-2 的的原码是 100.…010。

那么我们是不是可以直接使用负数的原码来进行减法计算呢？答案是否定的。我还是以 3+(-2) 为例。

假设我们使用 Java 中的 32 位整型来表示 2，它的十进制是 000…010。最低的两位是 10，前面的高位都是 0。如果我们使用 -2 的原码，也就是 100…010，然后我们把 3 的二进制原码 000…011 和 -2 的二进制原码 100…010 相加，会得到 100…0101。具体计算你可以看我画的这幅图。

二进制编码上的加减法和十进制类似，只不过，在加法中，十进制是满 10 才进一位，二进制加法中只要满 2 就进位；同样，在减法中，二进制借位后相当于 2 而不是 10。

相加后的结果是二进制 100…0101，它的最高位是 1，表示负数，而最低的 3 位是 101，表示 5，所以结果就是 -5 的原码了，而 3+(-2) 应该等于 1，两者不符。

如果负数的原码并不适用于减法操作，那该怎么办呢？这个问题的解答还要依赖计算机的溢出机制。

我刚刚介绍了溢出以及取模的特性，我们可以充分利用这一点，对计算机里的减法进行变换。假设有 i-j，其中 j 为正数。如果 i-j 加上取模的除数，那么会形成溢出，并正好能够获得我们想要的 i-j 的运算结果。如果我说的还是不太好理解，你可以参考下面这张图。

我们把这个过程用表达式写出来就是 i-j=(i-j)+(2^n-1+1)=i+(2^n-1-j+1)。

其中 2^n-1 的二进制码在不考虑符号位的情况下是 n-1 位的 1，那么 2^n-1-2 的结果就是下面这样的：

从结果可以观察出来，所谓 2^n-1-j 相当于对正数 j 的二进制原码，除了符号位之外按位取反（0 变 1，1 变 0）。由于负数 -j 和正数 j 的原码，除了符号位之外都是相同的，所以，2^n-1-j 也相当于对负数 -j 的二进制原码，除了符号位之外按位取反。我们把 2^n-1-j 所对应的编码称为负数 -j 的反码。所以，-2 的反码就是 1111…1101。

有了反码的定义，那么就可以得出 i-j=i+(2^n-1-j+1)=i 的原码 +(-j 的反码)+1。

如果我们把 -j 的反码加上 1 定义为 -j 的补码，就可以得到 i-j=i 的原码 +(-j 的补码)。

由于正数的加法无需负数的加法这样的变换，因此正数的原码、反码和补码三者都是一样的。最终，我们可以得到 i-j=i 的补码 +(-j 的补码)。

换句话说，计算机可以通过补码，正确地运算二进制减法。我们再来用 3+(-2) 来验证一下。正数 3 的补码仍然是 0000…0011，-2 的补码是 1111…1110，两者相加，最后得到了正确的结果 1 的二进制。

可见，溢出本来是计算机数据类型的一种局限性，但在负数的加法上，它倒是可以帮我们大忙。

最后，给你留一道思考题吧。理解了负数的原码、反码和补码之后，你能算算看，8 位的有符号位二进制数 10100010，对应的是哪个十进制数吗？

好了，关于二进制的补充内容就到这里了。欢迎你继续留言给我。你也可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

数学专栏课外加餐（三）：程序员需要读哪些数学书？

你好，我是黄申。欢迎来到第三次加餐时间！之前很多同学问我能否推荐一些数学方面的书，今天我就来分享几本。

数学领域涉及的面很广，相关的书籍也很多。咱们这个专栏我从数学的三个主要方面，介绍程序员常用的数学知识，包括离散数学、概率和统计和线性代数。所以我还是围绕这个专栏的三大模块，来给你推荐相应的书籍。

基础思想篇推荐书籍：《离散数学及其应用》

第一模块是“基础思想篇”。这一模块，我尝试用实际项目中的案例，把不同的离散数学知识点串了起来，并加以解释。如果你对其中某些点，有更深的兴趣，可以参考 Kenneth H·Rosen 所著的《离散数学及其应用》，英文原名是 DiscreteDiscrete MathematicsMathematics andand ItsIts ApplicationsApplications。

这本书是国外高校的教材，对所有离散数学的知识点介绍的比较全面。咱们讲过的同余定理、数学归纳法、递归、分治算法、排列和组合、树和树的遍历、图和最短路径、逻辑以及集合等概念，这里面都有非常详细的介绍。我看很多人对这些内容很感兴趣，可以参考这本书的相关章节，深入学习。

除此之外，这本书还有几个特点，我觉得非常好。

第一，介绍了不少证明的方法。计算机算法的正确性是很重要的，专栏中我在不同的地方介绍并使用了数学归纳法，在解释 Dijkstra 算法时也用到了反证法和分情形证明的思想。数学中用于证明的方法其实还有很多，这本书涉及了穷举证明、存在性证明等。相信这些证明方法，可以让你更好地理解，为什么有些算法是可行的，有些算法是有问题的，并帮助你在理解算法、学习算法，甚至设计算法时保证它的正确性。

第二，介绍了不少逻辑和集合相关的知识。这些我在专栏里没有涉及太多。主要是因为程序员经常接触各种条件和查询语句，对这些内容已经很熟悉了，所以我没有花太多的篇幅。如果你想知道更多关于逻辑、集合和布尔代数这些基础内容的解释，也可以看看这本书。

第三，和编程结合得非常紧密。主要体现在两个方面：第一，它介绍了一些基于伪代码的算法，也对这些算法进行了时间和空间复杂度的分析，例如常见的排序、搜索算法。第二，它介绍了不少离散数学在计算机科学中的应用场景，例如关系型数据库和 SQL 查询语言是如何设计的。另外，它也提供了不少课后习题，可以加深你对这些知识点的理解。所以，当你读到这本书的某些章节时，会发现，怎么和计算机的数据结构和算法这么像啊？确实，离散数学和数据结构和基础算法有着紧密的联系，加上这本书使用了不少计算机的语言、例子和应用，自然有不少共同的内容了。

专栏的第二模块是“概率统计篇”，这本书也谈到了一些离散概率的内容。在学习第二个模块的时候，你也可以搭配这本书的内容来看，相信对你会很有帮助。

当然，这本书的某些内容讲得比较深，而且有些知识点在程序员日常编码中基本上用不到。你可以结合我专栏的主题和内容，并针对自己的日常工作，挑出一些重点来学习。

概率统计篇推荐书籍：《概率统计》

专栏第一模块已经结束了，接下来的“概率统计篇”我会着重介绍概率统计及其在计算机领域中的主要应用。你可以预先阅读一些相关的书籍，热热身。这里我推荐另一本国外高校的教材，Morris H．DeGroot 和 Mark J．Schervish 所著的《概率统计》，英文原名是 ProbabilityProbability andand StatisticsStatistics。本书的两位作者，DeGroot 和 Schervish 都是贝叶斯统计理论的重量级人物。

这本书包含了概率论常用的知识点，包括了随机变量及其分布、条件概率、期望值、贝叶斯理论、马尔科夫链等等。专栏的第二模块，我也会介绍这些知识点，以及它们在计算机领域，特别是机器学习中的应用。

我们再来说这本书的几个特色。

第一，这本书通常以列举非常实用的例子开始，然后详尽地讲解理论及其扩展应用。比如，一开篇解释“概率”的时候，作者使用了抛硬币的例子，分别从“频率”“经典”和“主观”的角度来解释概率，并又阐述了“概率理论”和“概率”有何不同。这样的写法会给你很多思路上的启发，让你获得更直观的认识。文章中也不乏很多来源于各个领域的案例，比如经济学和金融学等等。

第二，对概念的解释非常详细。比如“充分统计量（sufficient statistic）”这个概念，一般的书可能两句话就解释完了，然后就是大堆的公式，但是这本书用了差不多两页的篇幅来解释它。我觉得这点对自学者而言是非常有帮助的。

第三，这本书几乎没有任何涉及计算机算法和代码的部分，哪怕是伪代码也没有。我想作者是希望完全从概率和统计本身的角度来写，而避免过多的实现细节。不过，对于这点你也不用过于担心，因为在专栏中，我会结合一些具体的机器学习算法及其应用，给你展示这些理论知识是如何运用到实践中的。

总的来说，这是一本相当不错的概率和统计方面的专业书籍。如果你预先读读这本书的内容，对概念有了理解，再看我的专栏也会更有感触。

线性代数篇推荐书籍：《线性代数及其应用》

如今的机器学习模型，除了基于概率和统计，还会使用线性代数的知识，本专栏的第三个模块就是“线性代数”。介绍线性代数的书籍不少，我这里推荐一本 David C. Lay 和 Steven R. Lay 合著的《线性代数及其应用》，英文原名 LinearLinear AlgebraAlgebra andand ItsIts ApplicationsApplications。

这本书最大的特色在于：

第一，使用通俗易懂的口吻和大量的插图来阐述概念。而且在我看来，这些概念他解释得也相当清楚，比如线性方程、向量空间、特征向量、奇异值的分解等等，这些都是在机器学习算法中常用的模型或技术。

第二，写作的逻辑也相当清晰。这本书基本上都是先提出一个实际的问题，然后对这个问题进行分析，最终才进行定理式的归纳和证明。通俗易懂的同时，不乏数学的严谨性。和前面两本推荐的书一样，这本书中也结合了很多生动的案例，特别是经济学领域的。

第三，这本书还配套了一本优秀的学习指南 LinearLinear AlgebraAlgebra andand ItsIts ApplicationsApplications: StudyStudy GuideGuide。这本指南，加上原书课后的习题，对于自学的读者巩固知识很有帮助。不过我没有找到这本指南的中文翻译版。如果哪位同学有好的练习题推荐，也可以在留言区分享出来。

入门、通识类书籍推荐

除了上述三本重量级的专业书籍，我觉得还有几本通俗的入门书也是不错的。

一套是几位日本作家写的《程序员的数学》系列，包括《程序员的数学》《程序员的数学：概率统计》《程序员的数学：线性代数》。这套书也强调了和计算机领域紧密相连的三大模块。这几位作者使用朴实的语言，把最重要的一些概念给说明白了。相比前面三本，这套书所涵盖的内容可能没有那么全面、也没有那么深入，不过对于初学者来讲，是不错的入门书籍。

最后一本书是吴军老师的《数学之美》。这本书最大的特点是和计算机领域结合得非常紧密。所有的问题和解决方案，最后都联系到了计算机中的某个应用。可以说，作者更多的是从计算机从业者的角度出发，深入探讨了背后的数学思想和知识。除此之外，吴军老师广博的学识和深刻的见解，在这本书中也体现得淋漓尽致。这本书的写作风格对我写作这个专栏也是非常有启发的。

读书在精，不在多。我选的这些书，你可能多多少少见过，但是能静下心来读完一本的人可能寥寥无几。我相信，订阅这个专栏的你，一定有颗不甘于平庸的心。你一定有你的目标和追求。开卷有益，坚持下去，学下去、读下去，相信你一定会有所收获！

数学专栏课外加餐（二） | 位操作的三个应用实例

你好，我是黄申。欢迎来到第二次课外加餐时间。

位操作的应用实例

留言里很多同学对位操作比较感兴趣，我这里通过计算机中的位操作的几个应用，来帮你理解位操作。

1. 验证奇偶数

在第 2 节里，我提到了，奇偶数其实也是余数的应用。编程中，我们也可以用位运算来判断奇偶数。

仔细观察，你会发现偶数的二进制最后一位总是 0，而奇数的二进制最后一位总是 1，因此对于给定的某个数字，我们可以把它的二进制和数字 1 的二进制进行按位“与”的操作，取得这个数字的二进制最后一位，然后再进行判断。

我这里写了一段代码，比较了使用位运算和模运算的效率，我统计了进行 1 亿次奇偶数判断，使用这两种方法各花了多少毫秒。如果在你的机器上两者花费的时间差不多，你可以尝试增加统计的次数。在我的机器上测试下来，同样次数的奇偶判断，使用位运算的方法耗时明显更低。

public class Lesson1_append1 {

 public static void main(String[] args) {

  int even_cnt = 0, odd_cnt = 0;

  long start = 0, end = 0;

  start = System.currentTimeMillis();

  for (int i = 0; i < 100000000; i++) {

   if((i & 1) == 0){

       even_cnt ++;

   }else{

       odd_cnt ++;

   }

  }

  end = System.currentTimeMillis();

  System.out.println(end - start);

  System.out.println(even_cnt + " " + odd_cnt);

  even_cnt = 0;

  odd_cnt = 0;

  start = 0;

  end = 0;

  start = System.currentTimeMillis();

  for (int i = 0; i < 100000000; i++) {

   if((i % 2) == 0){

       even_cnt ++;

   }else{

       odd_cnt ++;

   }

  }

  end = System.currentTimeMillis();

  System.out.println(end - start);

  System.out.println(even_cnt + " " + odd_cnt);

 }

}

2. 交换两个数字

你应该知道，要想在计算机中交换两个变量的值，通常都需要一个中间变量，来临时存放被交换的值。不过，利用异或的特性，我们就可以避免这个中间变量。具体的代码如下：

x = (x ^ y);

y = x ^ y;

x = x ^ y;

把第一步代入第二步中, 可以得到：

y = (x ^ y) ^ y = x ^ (y ^ y) = x ^ 0 = x

把第一步和第二步的结果代入第三步中，可以得到：

x = (x ^ y) ^ x = (x ^ x) ^ y = 0 ^ y = y

这里用到异或的两个特性，第一个是两个相等的数的异或为 0，比如 x^x= 0；第二个是任何一个数和 0 异或之后，还是这个数不变，比如 0^y=y。

3. 集合操作

集合和逻辑的概念是紧密相连的，因此集合的操作也可以通过位的逻辑操作来实现。

假设我们有两个集合{1, 3, 8}和{4, 8}。我们先把这两个集合转为两个 8 位的二进制数，从右往左以 1 到 8 依次来编号。

如果某个数字在集合中，相应的位置 1，否则置 0。那么第一个集合就可以转换为 10000101，第二个集合可以转换为 10001000。那么这两个二进制数的按位与就是 10000000，只有第 8 位是 1，代表了两个集合的交为{8}。而这两个二进制数的按位或就是 10001101，第 8 位、第 4 位、第 3 位和第 1 位是 1，代表了两个集合的并为{1, 3, 4, 8}。

说到这里，不禁让我想起 Elasticsearch 的 BitSet。我曾经使用 Elasticsearch 这个开源的搜索引擎来实现电商平台的搜索。

当时为了提升查询的效率，我使用了 Elasticsearch 的 Filter 查询。我研究了一下这个 Filter 查询的原理，发现它并没有考虑各种文档的相关性得分，因此它可以把文档匹配关键字的情况，转换成了一个 BitSet。

你可以把 BitSet 想成一个巨大的位数组。每一位对应了某篇文档是否和给定的关键词匹配，如果匹配，这一位就置 1，否则就置 0。每个关键词都可以拥有一个 BitSet，用于表示哪些文档和这个关键词匹配。那么要查看同时命中多个关键词的文档有哪些，就是对多个 BitSet 求交集。利用上面介绍的按位与，这点是很容易实现的，而且效率相当之高。

二分查找时的两个细节

第 3 节我介绍了迭代法，并讲解了相关的代码实现。其中，有两个细节我在这里补充说明一下。

第一个是关于中间值的计算。我优化了两处代码，分别是 Lesson3_2 的第 16 行和 Lesson3_3 的第 22 行。

其中，Lesson3_2 的第 16 行由原来的：

double middle = (min + max) / 2;

改为：

double middle = min + (max - min) / 2;

Lesson3_3 的第 22 行由原来的：

int middle = (left + right) / 2;

改为：

int middle = left + (right - left) / 2;

这两处改动的初衷都是一样的，是为了避免溢出。在第一篇加餐中，介绍负数的加法时，我已经解释了什么是溢出。那这里为什么会发生溢出呢？我以第二处代码为例来讲解下。

从理论上来说，(left+right)/2=left+(right-left)/2。可是，我们之前说过，计算机系统有自身的局限性，无论是何种数据类型，都有一个上限或者下限。一旦某个数字超过了这些限定，就会发生溢出。

对于变量 left 和 right 而言，在定义的时候都指定了数据类型，因此不会超出范围。可是，left+right 的和就不一定了。从下图可以看出，当 left 和 right 都已经很接近某个数据类型的最大值时，两者的和就会超过这个最大值，发生上溢出。这也是为什么最好不用通过 (left+right)/2 来求两者的中间值。

那么为什么 left + (right -left)/2 就不会溢出呢？首先，right 是没有超过最大值的，那么 (right -left)/2 自然也就没有超过范围，即使 left 加上了 (right -left)/2，也不会超过 right 的值，所以运算的整个过程都不会产生溢出。

第二个是关于误差百分比和绝对误差。在 Lesson3_2 中有这么一行：

double delta = Math.abs((square / n) - 1);

这里我使用了误差的百分比，也就是误差值占输入值 n 的比例。其实绝对误差也是可以的，不过我在这里考虑了 n 的大小。比如，如果 n 是一个很小的正整数，比如个位数，那么误差可能要精确到 0.00001。但是如果 n 是一个很大的数呢？比如几个亿，那么精确到 0.00001 可能没有多大必要，也许精确到 0.1 也就可以了。所以，使用误差的百分比可以避免由于不同的 n，导致的迭代次数有过大差异。

由于这里 n 是大于 1 的正整数，所以可以直接拿平方值 square 去除以 n。否则，我们要单独判断 n 为 0 的情况，并使用绝对误差。

关于迭代法、数学归纳法和递归

从第 3 节到第 6 节，我连续介绍了迭代法、数学归纳法、递归。这些概念之间存在相互联系，又不完全一样，很多同学对此也有一些疑惑。所以，这里我来帮你梳理一下。

迭代法和递归都是通过不断反复的步骤，计算数值或进行操作的方法。迭代一般适合正向思维，而递归一般适合逆向思维。而递归回溯的时候，也体现了正向递推的思维。它们本身都是抽象的流程，可以有不同的编程实现。

对于某些重复性的计算，数学归纳法可以从理论上证明某个结论是否成立。如果成立，它可以大大节约迭代法中数值计算部分的时间。不过，在使用数学归纳法之前，我们需要通过一些数学知识，假设命题，并证明该命题成立。

对于那些无法使用数学归纳法来证明的迭代问题，我们可以通过编程实现。这里需要注意的是，广义上来说，递归也是迭代法的一种。不过，在计算机编程中，我们所提到的迭代是一种具体的编程实现，是指使用循环来实现的正向递推，而递归是指使用函数的嵌套调用来实现的逆向递推。当然，两种实现通常是可以相互转换的。

循环的实现很容易理解，对硬件资源的开销比较小。不过，循环更适合“单线剧情”，例如计算 2^n，n!，1+2+3+…+n 等等。而对于存在很多“分支剧情”的复杂案例而言，使用递归调用更加合适。

利用函数的嵌套调用，递归编程可以存储很多中间变量。我们可以很轻松地跟踪不同的分支，而所有这些对程序员基本是透明的。如果这时使用循环，我们不得不自己创建并保存很多中间变量。当然，正是由于这个特性，递归比较消耗硬件资源。

递归编程本身就体现了分治的思想，这个思想还可以延伸到集群的分布式架构中。最近几年比较主流的 MapReduce 框架也体现了这种思想。

综合上面说的几点，你可以大致遵循这样的原则：

如果一个问题可以被迭代法解决，而且是有关数值计算的，那你就看看是否可以假设命题，并优先考虑使用数学归纳法来证明；

如果需要借助计算机，那么优先考虑是否可以使用循环来实现。如果问题本身过于复杂，再考虑函数的嵌套调用，是否可以通过递归将问题逐级简化；

如果数据量过大，可以考虑采用分治思想的分布式系统来处理。

最后，给你留一道思考题吧。

在 1 到 n 的数字中，有且只有唯一的一个数字 m 重复出现了，其它的数字都只出现一次。请把这个数字找出来。提示：可以充分利用异或的两个特性。

好了，前面 6 讲的补充内容就到这里了。欢迎你留言给我。你也可以点击“请朋友读”，把今天的内容分享给你的好友，和他一起精进。

结束语 | 从数学到编程，本身就是一个很长的链条

你好，我是黄申。不知不觉，4 个多月就过去了，终于到了说再见的时候。

上周编辑对我说：“黄老师，专栏正文写完啦，要写结束语啦！”我当时第一反应是，“啥？已经写完啦？这么快！”。别看我现在“依依不舍”，回想写专栏之初，真的是一波三折。

我曾经出版过两本大数据相关的书籍，而且销量和口碑都还不错，所以刚开始的时候，我感觉写专栏应该是“得心应手”的事情。可是，试写了几篇之后，在和专栏编辑的沟通中，我逐渐意识到，写专栏和写书完全是两回事。

写书的时候，往往是作者主导，想把这本书写成什么样，给谁看，这些完全由自己说了算。但是，写专栏文章是不同的，它要有明确的受众，因此就要明确每一篇的知识点深度和密度，并把一个知识点深入浅出地说清楚，确保每个人看完之后能有所收获。

很快，我就进入了状态。可是，我又遇到了第二个“波折”。

虽然大家都知道数学和编程是紧密相关的，但是到具体的知识点的时候，就没有那么直观了。对于数学和编程之间的关系，每个人都有自己的理解。我很明白，如果无法厘清这两者的关系，很难写出一个对于程序员来说，非常实用的数学专栏。所以，在写作的同时，我反复地问自己：“数学和编程究竟是什么关系？如何把这种关系的本质通过文字和代码讲解出来？”。

我不断地去思考、和编辑讨论，慢慢发现，多数人对这两者关系不清楚的主要原因是，从数学的知识体系出发，一直到具体的编程应用，整个过程本身是一个很长的链条。

要把编程领域中的数学讲清楚，我们至少要经历“数学概念 - 数学模型 - 数据结构 - 基础算法 / 机器学习算法 - 编码实现”这几个关键步骤。

具体来说，首先你要充分理解一个数学的概念，然后是数学的模型。在这些基础之上，我们才能把它们转换成编程领域中对应的数据结构和算法，最终才能付诸于编码实现。

经历过这些深度思考，搞明白了专栏的交付目标。每天写稿、录音到深夜，对我来说，都不是什么事情了。因为写这个专栏的过程中，我也收获了很多。总的来说，这几个月的创作充满了艰辛，但是也充满了成就感。希望这个专栏能够帮到你收获知识，以及知识之外的一些对数学的认知，那就是这个专栏最大的意义和价值。

最后，感谢极客时间这个平台给了我一个机会，能够重新梳理自己这么多年的学习心得和工作经验，更要感谢订阅专栏的你，不断给我反馈和意见。专栏虽然结束了，但是学习应该是持续进行的。非常感谢你的支持，你可以继续在留言区留下的你的疑问，我也会持续不断地进行解答。

每一次结束都是另一段新旅途的开始，祝福你在工作、生活中都能取得不断的进步！