mirror of
https://github.com/cheetahlou/CategoryResourceRepost.git
synced 2026-05-10 19:54:28 +08:00
mod
This commit is contained in:
305
极客时间专栏/程序员的数学基础课/基础思想篇/01 | 二进制:不了解计算机的源头,你学什么编程.md
Normal file
305
极客时间专栏/程序员的数学基础课/基础思想篇/01 | 二进制:不了解计算机的源头,你学什么编程.md
Normal file
@@ -0,0 +1,305 @@
|
||||
<audio id="audio" title="01 | 二进制:不了解计算机的源头,你学什么编程" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/a1/ec/a174431d815d5b6c46fa277e41114bec.mp3"></audio>
|
||||
|
||||
我们都知道,计算机的起源是数学中的二进制计数法。可以说,没有二进制,就没有如今的计算机系统。那什么是二进制呢?为什么计算机要使用二进制,而不是我们日常生活中的十进制呢?如何在代码中操作二进制呢?专栏开始,我们就从计算机认知的起源——二进制出发,讲讲它在计算机中的“玄机”。
|
||||
|
||||
## 什么是二进制计数法?
|
||||
|
||||
为了让你更好地理解二进制计数法,我们先来简单地回顾一下人类计数的发展史。
|
||||
|
||||
原始时代,人类用路边的小石子,来统计放牧归来的羊只数量,这表明我们很早就产生了计数的意识。后来,罗马人用手指作为计数的工具,并在羊皮上画出Ⅰ、Ⅱ、Ⅲ来代替手指的数量。表示一只手时,就写成“Ⅴ”形,表示两只手时,就画成“ⅤⅤ”形等等。
|
||||
|
||||
公元3世纪左右,印度数学家(也有说法是阿拉伯人)发明了阿拉伯数字。阿拉伯数字由从0到9这样10个计数符号组成,并采取**进位制法**,高位在左,低位在右,从左往右书写。由于阿拉伯数字本身笔画简单,演算便利,因此它们逐渐在各国流行起来,成为世界通用的数字。
|
||||
|
||||
日常生活中,我们广泛使用的十进制计数法,也是基于阿拉伯数字的。这也是十进制计数法的基础。因此,相对其他计数方法,十进制最容易被我们所理解。
|
||||
|
||||
让我们来观察一个数字:2871。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d9/3d/d99f094c432638924f8665a178162c3d.jpg" alt="">
|
||||
|
||||
其中^表示幂或次方运算。十进制的数位(千位、百位、十位等)全部都是10^n的形式。需要特别注意的是,任何非0数字的0次方均为1。在这个新的表示式里,10被称为十进制计数法的**基数**,也是十进制中“十”的由来。这个我想你应该好理解,因为这和我们日常生活的习惯是统一的。
|
||||
|
||||
明白了十进制,我们再试着用类似的思路来理解二进制的定义。我以二进制数字110101为例,解释给你听。我们先来看,这里110101究竟代表了十进制中的数字几呢?
|
||||
|
||||
刚才我们说了,十进制计数是使用10作为基数,那么二进制就是使用2作为基数,类比过来,**二进制的数位就是2^n的形式**。如果需要将这个数字转化为人们易于理解的十进制,我们就可以这样来计算:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/c0/c6ae1772d7bf369aa9939fc00ca7b5c0.jpg" alt="">
|
||||
|
||||
按照这个思路,我们还可以推导出八进制(以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位,左移后数字就会溢出,需要将溢出的位数去除。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cd/76/cdbeb658035f275aa941a0d3f6eac876.jpg" alt="">
|
||||
|
||||
在这个例子中,如果将1101010换算为十进制,就是106,你有没有发现,106正好是53的2倍。所以,我们可以得出一个结论:**二进制左移一位,其实就是将数字翻倍**。
|
||||
|
||||
### 向右移位
|
||||
|
||||
接下来我们来看向右移位。
|
||||
|
||||
二进制110101向右移一位,就是去除末尾的那一位,因此110101就变成了11010(最前面的0可以省略)。我们将11010换算为十进制,就是26,正好是53除以2的整数商。所以**二进制右移一位,就是将数字除以2并求整数商的操作**。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8d/34/8df282639b609d5269582c789796c334.jpg" alt="">
|
||||
|
||||
下面我们来看看,用代码如何进行移位操作。
|
||||
|
||||
```
|
||||
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,表示该数是正数,只是通常我们都将其省略。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/ef/831a21734048b1fc3e357609527175ef.jpg" alt="">
|
||||
|
||||
如果数字是-53呢?那么第32位就不是0,而是1。请注意我这里列出的是补码。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/1e/857248d65d7c4b746b3677d45b2a2c1e.jpg" alt="">
|
||||
|
||||
那么这个时候向右移位,就会产生一个问题:对于符号位(特别是符号位为1的时候),我们是否也需要将其右移呢?因此,Java里定义了两种右移,**逻辑右移**和**算术右移**。逻辑右移1位,左边补0即可。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/74/c7/745a4880417a4dcb62f88bad7be800c7.jpg" alt="">
|
||||
|
||||
算术右移时保持符号位不变,除符号位之外的右移一位并补符号位1。补的1仍然在符号位之后。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b0/07/b00b38ba0e8e2349a64b52905852e107.jpg" alt="">
|
||||
|
||||
逻辑右移在Java和Python语言中使用>>>表示,而算术右移使用>>表示。如果你有兴趣,可以自己编码尝试一下,看看这两种操作符输出的结果有何不同。
|
||||
|
||||
在C或C++语言中,逻辑右移和算数右移共享同一个运算符>>。那么,编译器是如何决定使用逻辑右移还是算数右移呢?答案是,取决于运算数的类型。如果运算数类型是unsigned,则采用逻辑右移;而是signed,则采用算数右移。如果你针对unsigned类型的数据使用算数右移,或者针对signed类型的数据使用逻辑右移,那么你首先需要进行类型的转换。
|
||||
|
||||
由于左移位无需考虑高位补1还是补0(符号位可能为1或0),所以不需要区分逻辑左移和算术左移。
|
||||
|
||||
### 位的“或”
|
||||
|
||||
我们刚才说了,二进制的“1”和“0”分别对应逻辑中的“真”和“假”,因此可以针对位进行逻辑操作。
|
||||
|
||||
逻辑“或”的意思是,参与操作的位中只要有一个位是1,那么最终结果就是1,也就是“真”。如果我们将二进制110101和100011的每一位对齐,进行按位的“或”操作,就会得到110111。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/15/8394b6daf1d9727069736506332c4915.jpg" alt="">
|
||||
|
||||
### 位的“与”
|
||||
|
||||
同理,我们也可以针对位进行逻辑“与”的操作。“与”的意思是,参与操作的位中必须全都是1,那么最终结果才是1(真),否则就为0(假)。如果我们将二进制110101和100011的每一位对齐,进行按位的“与”操作,就会得到100001。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b1/19/b1c520385f4e5a5b719c393f9e1d0019.jpg" alt="">
|
||||
|
||||
### 位的“异或”
|
||||
|
||||
逻辑“异或”和“或”有所不同,它具有排异性,也就是说如果参与操作的位相同,那么最终结果就为0(假),否则为 1(真)。所以,如果要得到1,参与操作的两个位必须不同,这就是此处“异”的含义。我们将二进制110101和100011的每一位对齐,进行按位的“异或”操作,可以得到结果是10110。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c7/87/c7ae84301b2742b6714c01a77e6a6b87.jpg" alt="">
|
||||
|
||||
我总结一下,“异或”操作的本质其实就是,所有数值和自身进行按位的“异或”操作之后都为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来表示。因此,理解了二进制,你就能更加容易地理解很多计算机的数据结构和算法,也为我们后面的学习打下基础。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/20/74/209f1b06efdf9a7413fb793571c7ed74.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
如果不使用Java语言自带的BigInteger类,我们还有什么方法来实现十进制到二进制的转换呢?(提示:可以使用二进制的移位和按位逻辑操作来实现。)
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
97
极客时间专栏/程序员的数学基础课/基础思想篇/02 | 余数:原来取余操作本身就是个哈希函数.md
Normal file
97
极客时间专栏/程序员的数学基础课/基础思想篇/02 | 余数:原来取余操作本身就是个哈希函数.md
Normal file
@@ -0,0 +1,97 @@
|
||||
<audio id="audio" title="02 | 余数:原来取余操作本身就是个哈希函数" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/bc/65d67ae903466c333dc56f420d6b46bc.mp3"></audio>
|
||||
|
||||
你好,我是黄申。今天我们来聊聊“余数”。
|
||||
|
||||
提起来余数,我想你肯定不陌生,因为我们生活中就有很多很多与余数相关的例子。
|
||||
|
||||
比如说,今天是星期三,你想知道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万条记录连续存放,那么我们就可以使用余数和同余定理来设计一个散列函数,并实现哈希表的结构。
|
||||
|
||||
那这个函数应该怎么设计呢?你可以先停下来思考思考,提醒你下,你可以再想想星期几的那个例子,因为这里面用的就是余数的思想。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f1/8d/f156cef76582b3ee77b038cd2347968d.jpg" alt="">
|
||||
|
||||
下面是我想到的一种方法:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b3/58/b32e791f822044f579b80ad2cfe48c58.jpg" alt="">
|
||||
|
||||
在这个公式中,x表示等待被转换的数值,而size表示有限存储空间的大小,mod表示取余操作。**通过余数,你就能将任何数值,转换为有限范围内的一个数值,然后根据这个新的数值,来确定将数据存放在何处。**
|
||||
|
||||
具体来说,我们可以通过记录标号模100的余数,指定某条记录存放在哪个空间。这个时候,我们的公式就变成了这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fe/ac/fe96e521ed9d0a574ddaeeb0f00bbaac.jpg" alt="">
|
||||
|
||||
假设有两条记录,它们的记录标号分别是1和101。我们把这些模100之后余数都是1的,存放到第1个可用空间里。以此类推,将余数为2的2、102、202等,存放到第2个可用空间,将100、200、300等存放到第100个可用空间里。
|
||||
|
||||
这样,我们就可以根据求余的快速数字变化,对数据进行分组,并把它们存放到不同的地址空间里。而求余操作本身非常简单,因此几乎不会增加寻址时间。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/37/2c/372f09d2ff666150fd2855506a84f02c.jpg" alt="">
|
||||
|
||||
除此之外,为了增加数据散列的随机程度,我们还可以在公式中加入一个较大的随机数MAX,于是,上面的公式就可以写成这样:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/c0/78a943e119d823d39cdcaf35a75a42c0.jpg" alt="">
|
||||
|
||||
我们假设随机数MAX是590199,那么我们针对标号为1的记录进行重新计算,最后的计算结果就是0,而针对标号101的记录,如果随机数MAX取627901,对应的结果应该是2。这样先前被分配到空间1的两条记录,在新的计算公式作用下,就会被分配到不同的可用空间中。
|
||||
|
||||
你可以尝试记录2和102,或者记录100和200,最后应该也是同样的情况。你会发现,使用了MAX这个随机数之后,被分配到同一个空间中的记录就更加“随机”,更适合需要将数据重新洗牌的应用场景,比如加密算法、MapReduce中的数据分发、记录的高速查询和定位等等。
|
||||
|
||||
让我以加密算法为例,在这里面引入MAX随机数就可以增强加密算法的保密程度,是不是很厉害?举个例子,比如说我们要加密一组三位数,那我们设定一个这样的加密规则:
|
||||
|
||||
<li>
|
||||
先对每个三位数的个、十和百位数,都加上一个较大的随机数。
|
||||
</li>
|
||||
<li>
|
||||
然后将每位上的数都除以7,用所得的余数代替原有的个、十、百位数;
|
||||
</li>
|
||||
<li>
|
||||
最后将第一位和第三位交换。
|
||||
</li>
|
||||
|
||||
这就是一个基本的加密变换过程。
|
||||
|
||||
假如说,我们要加密数字625,根据刚才的规则,我们来试试。假设随机数我选择590127。那百、十和个位分别加上这个随机数,就变成了590133,590129,590132。然后,三位分别除以7求余后得到5,1,4。最终,我们可以得到加密后的数字就是415。因为加密的人知道加密的规则、求余所用的除数7、除法的商、以及所引入的随机数590127,所以当拿到415的时候,加密者就可以算出原始的数据是625。是不是很有意思?
|
||||
|
||||
## 小结
|
||||
|
||||
到这里,余数的所有知识点我们都讲完了。我想在此之前,你肯定是知道余数,也明白怎么求余。但对于余数的应用不知道你之前是否有思考过呢?我们经常说,数学是计算机的基础,在余数这个小知识点里,我们就能找到很多的应用场景,比如我前面介绍的散列函数、加密算法,当然,也还有我们没有介绍到的,比如循环冗余校验等等。
|
||||
|
||||
余数只是数学知识中的沧海一粟。你在中学或者大学的时候,肯定接触过很多的数学知识和定理,编程的时候也会经常和数字、公式以及数据打交道,但是真正学懂数学的人却没几个。希望我们可以从余数这个小概念开始,让你认识到数学思想其实非常实用,用好这些知识,对你的编程,甚至生活都有意想不到的作用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/97/5a/97a4d55737df060e213a12da82963e5a.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
你可以想想,在生活和编程中,还有哪些地方用到了余数的思想呢?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
277
极客时间专栏/程序员的数学基础课/基础思想篇/03 | 迭代法:不用编程语言的自带函数,你会如何计算平方根?.md
Normal file
277
极客时间专栏/程序员的数学基础课/基础思想篇/03 | 迭代法:不用编程语言的自带函数,你会如何计算平方根?.md
Normal file
@@ -0,0 +1,277 @@
|
||||
<audio id="audio" title="03 | 迭代法:不用编程语言的自带函数,你会如何计算平方根?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e6/f0/e654ce126daadea93ac45a57f25c8bf0.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
今天我们来说一个和编程结合得非常紧密的数学概念。在解释这个重要的概念之前,我们先来看个有趣的小故事。
|
||||
|
||||
>
|
||||
古印度国王舍罕酷爱下棋,他打算重赏国际象棋的发明人宰相西萨·班·达依尔。这位聪明的大臣指着象棋盘对国王说:“陛下,我不要别的赏赐,请您在这张棋盘的第一个小格内放入一粒麦子,在第二个小格内放入两粒,第三小格内放入给四粒,以此类推,每一小格内都比前一小格加一倍的麦子,直至放满64个格子,然后将棋盘上所有的麦粒都赏给您的仆人我吧!”
|
||||
|
||||
|
||||
国王自以为小事一桩,痛快地答应了。可是,当开始放麦粒之后,国王发现,还没放到第二十格,一袋麦子已经空了。随着,一袋又一袋的麦子被放入棋盘的格子里,国王很快看出来,即便拿来全印度的粮食,也兑现不了对达依尔的诺言。
|
||||
|
||||
放满这64格到底需要多少粒麦子呢?这是个相当相当大的数字,想要手动算出结果并不容易。如果你觉得自己厉害,可以试着拿笔算算。其实,这整个算麦粒的过程,在数学上,是有对应方法的,这也正是我们今天要讲的概念:**迭代法**(Iterative Method)。
|
||||
|
||||
## 到底什么是迭代法?
|
||||
|
||||
**迭代法,简单来说,其实就是不断地用旧的变量值,递推计算新的变量值**。
|
||||
|
||||
我这么说可能还是比较抽象,不容易理解。我们还回到刚才的故事。大臣要求每一格的麦子都是前一格的两倍,那么前一格里麦子的数量就是旧的变量值,我们可以先记作$X_{n-1}$;而当前格子里麦子的数量就是新的变量值,我们记作$X_{n}$。这两个变量的递推关系就是这样的:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c8/0e/c82c80cbf7d766f77422c564418cc70e.jpg" alt="">
|
||||
|
||||
如果你稍微有点编程经验,应该能发现,迭代法的思想,很容易通过计算机语言中的**循环语言**来实现。你知道,计算机本身就适合做重复性的工作,我们可以通过循环语句,让计算机重复执行迭代中的递推步骤,然后推导出变量的最终值。
|
||||
|
||||
那接下来,我们就用循环语句来算算,填满格子到底需要多少粒麦子。我简单用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型的范围。
|
||||
|
||||
## 迭代法有什么具体应用?
|
||||
|
||||
看到这里,你可能大概已经理解迭代法的核心理念了。迭代法在无论是在数学,还是计算机领域都有很广泛的应用。大体上,迭代法可以运用在以下几个方面:
|
||||
|
||||
<li>
|
||||
**求数值的精确或者近似解**。典型的方法包括二分法(Bisection method)和牛顿迭代法(Newton’s method)。
|
||||
</li>
|
||||
<li>
|
||||
**在一定范围内查找目标值。**典型的方法包括二分查找。
|
||||
</li>
|
||||
<li>
|
||||
**机器学习算法中的迭代**。相关的算法或者模型有很多,比如K-均值算法(K-means clustering)、PageRank的马尔科夫链(Markov chain)、梯度下降法(Gradient descent)等等。迭代法之所以在机器学习中有广泛的应用,是因为**很多时候机器学习的过程,就是根据已知的数据和一定的假设,求一个局部最优解**。而迭代法可以帮助学习算法逐步搜索,直至发现这种解。
|
||||
</li>
|
||||
|
||||
这里,我详细讲解一下求数值的解和查找匹配记录这两个应用。
|
||||
|
||||
### 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。
|
||||
|
||||
我把具体的步骤画成了一张图,你可以看看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/7d/89c9c38113624288091cd65ff3d8957d.jpg" alt="">
|
||||
|
||||
我这里用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世纪提出的一种方法,用于求方程的近似解。这种方法以微分为基础,每次迭代的时候,它都会去找到比上一个值$x_{0}$更接近的方程的根,最终找到近似解。该方法及其延伸也被应用在机器学习的算法中,在之后机器学习中的应用中,我会具体介绍这个算法。
|
||||
|
||||
### 2.查找匹配记录
|
||||
|
||||
**二分法中的迭代式逼近,不仅可以帮我们求得近似解,还可以帮助我们查找匹配的记录。**我这里用一个查字典的案例来说明。
|
||||
|
||||
在自然语言处理中,我们经常要处理同义词或者近义词的扩展。这时,你手头上会有一个同义词/近义词的词典。对于一个待查找的单词,我们需要在字典中找出这个单词,以及它所对应的同义词和近义词,然后进行扩展。比如说,这个字典里有一个关于“西红柿”的词条,其同义词包括了“番茄”和“tomato”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2d/5a/2de8a4c2b934a86ef5e8b915b6926d5a.jpg" alt="">
|
||||
|
||||
那么,在处理文章的时候,当我们看到了“西红柿”这个词,就去字典里查一把,拿出“番茄”“tomato”等等,并添加到文章中作为同义词/近义词的扩展。这样的话,用户在搜索“西红柿”这个词的时候,我们就能确保出现“番茄”或者“tomato”的文章会被返回给用户。
|
||||
|
||||
乍一看到这个任务的时候,你也许想到了哈希表。没错,哈希表是个好方法。不过,如果不使用哈希表,你还有什么其他方法呢?这里,我来介绍一下,用二分查找法进行字典查询的思路。
|
||||
|
||||
第一步,将整个字典先进行排序(假设从小到大)。二分法中很关键的前提条件是,所查找的区间是有序的。这样才能在每次折半的时候,确定被查找的对象属于左半边还是右半边。
|
||||
|
||||
第二步,使用二分法逐步定位到被查找的单词。每次迭代的时候,都找到被搜索区间的中间点,看看这个点上的单词,是否和待查单词一致。如果一致就返回;如果不一致,要看被查单词比中间点上的单词是小还是大。如果小,那说明被查的单词如果存在字典中,那一定在左半边;否则就在右半边。
|
||||
|
||||
第三步,根据第二步的判断,选择左半边或者后半边,继续迭代式地查找,直到范围缩小到单个的词。如果到最终仍然无法找到,则返回不存在。
|
||||
|
||||
当然,你也可以对单词进行从大到小的排序,如果是那样,在第二步的判断就需要相应地修改一下。
|
||||
|
||||
我把在a到g的7个字符中查找f的过程,画成了一张图,你可以看看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d3/99/d39dfcea9385baef98846d2a5914a599.jpg" alt="">
|
||||
|
||||
这个方法的整体思路和二分法求解平方根是一致的,主要区别有两个方面:第一,每次判断是否终结迭代的条件不同。求平方根的时候,我们需要判断某个数的平方是否和输入的数据一致。而这里,我们需要判断字典中某个单词是否和待查的单词相同。第二,二分查找需要确保被搜索的空间是有序的。
|
||||
|
||||
我把具体的代码写出来了,你可以看一下。
|
||||
|
||||
```
|
||||
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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
说的这两个例子,都属于迭代法中的二分法,我在第一节的时候说过,二分法其实也体现了二进制的思想。
|
||||
|
||||
## 小结
|
||||
|
||||
到这里,我想你对迭代的核心思路有了比较深入的理解。
|
||||
|
||||
实际上,人类并不擅长重复性的劳动,而计算机却很适合做这种事。这也是为什么,以重复为特点的迭代法在编程中有着广泛的应用。不过,日常的实际项目可能并没有体现出明显的重复性,以至于让我们很容易就忽视了迭代法的使用。所以,你要多观察问题的现象,思考其本质,看看不断更新变量值或者缩小搜索的区间范围,是否可以获得最终的解(或近似解、局部最优解),如果是,那么你就可以尝试迭代法。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/cf/23/cff999fbe0e89b76736f41aacc944623.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在你曾经做过的项目中,是否使用过迭代法?如果有,你觉得迭代法最大的特点是什么?如果还没用过,你想想看现在的项目中是否有可以使用的地方?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
205
极客时间专栏/程序员的数学基础课/基础思想篇/04 | 数学归纳法:如何用数学归纳提升代码的运行效率?.md
Normal file
205
极客时间专栏/程序员的数学基础课/基础思想篇/04 | 数学归纳法:如何用数学归纳提升代码的运行效率?.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="04 | 数学归纳法:如何用数学归纳提升代码的运行效率?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/00/c6/00d02389a5e9fe1f498ad5cbe68888c6.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上次我们聊了迭代法及其应用,并用编程实现了几个小例子。不过你知道吗,对于某些迭代问题,我们其实可以避免一步步的计算,直接**从理论上证明某个结论**,节约大量的计算资源和时间,这就是我们今天要说的**数学归纳法**。
|
||||
|
||||
平时我们谈的“归纳”,是一种从经验事实中找出普遍特征的认知方法。比如,人们在观察了各种各样动物之后,通过它们的外观、行为特征、生活习性等得出某种结论,来区分哪些是鸟、哪些是猫等等。比如我这里列出的几个动物的例子。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f6/37/f606627d96040653c5eeca1541788a37.jpg" alt="">
|
||||
|
||||
通过上面的表格,我们可以进行归纳,并得出这样的结论:
|
||||
|
||||
<li>
|
||||
如果一个动物,身上长羽毛并且会飞,那么就是属于鸟;
|
||||
</li>
|
||||
<li>
|
||||
如果一个动物,身上长绒毛、不会飞、而且吃小鱼和老鼠,那么就属于猫。
|
||||
</li>
|
||||
|
||||
通过观察$5$个动物样本的$3$个特征,从而得到某种动物应该具有何种特征,这种方法就是我们平时所提到的归纳法。
|
||||
|
||||
我们日常生活中所说的这种归纳法和数学归纳法是不一样的,它们究竟有什么区别呢?具体数学归纳法可以做什么呢?我们接着上一节舍罕王赏麦的故事继续说。
|
||||
|
||||
## 什么是数学归纳法?
|
||||
|
||||
上节我们提到,在棋盘上放麦粒的规则是,第一格放一粒,第二格放两粒,以此类推,每一小格内都比前一小格多一倍的麦子,直至放满$64$个格子。
|
||||
|
||||
我们假想一下自己穿越到了古印度,正站在国王的身边,看着这个棋盘,你发现第$1$格到第$8$格的麦子数分别是:$1、2、4、8、16、32、64、128$。这个时候,国王想知道总共需要多少粒麦子。我们小时候都玩过“找规律”,于是,我发现了这么一个规律,你看看是不是这样?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8e/8c/8eba65a8d57d5cc84cb6ea4dd20ba68c.jpg" alt="">
|
||||
|
||||
根据这个观察,我们是不是可以大胆假设,前$n$个格子的麦粒总数就是$2^{n}-1$ 呢?如果这个假设成立,那么填满64格需要的麦粒总数,就是$1+2+2^{2}+2^{3}+2^{4}+……+ 2^{63}$<br>
|
||||
$=2^{64}-1=18446744073709551615$。
|
||||
|
||||
这个假设是否成立,我们还有待验证。但是对于类似这种无穷数列的问题,我们通常可以采用**数学归纳法**(Mathematical Induction)来证明。
|
||||
|
||||
在数论中,数学归纳法用来证明任意一个给定的情形都是正确的,也就是说,第一个、第二个、第三个,一直到所有情形,概不例外。
|
||||
|
||||
数学归纳法的一般步骤是这样的:
|
||||
|
||||
<li>
|
||||
**证明基本情况(通常是$n=1$的时候)是否成立****;**
|
||||
</li>
|
||||
<li>
|
||||
**假设$n=k-1$成立,再证明$n=k$也是成立的****($k$为任意大于$1$的自然数)**。
|
||||
</li>
|
||||
|
||||
只要学过数学,我想你对这个步骤都不陌生。但是,现在你需要牢记这个步骤,然后我们用这个步骤来证明下开头的例子。
|
||||
|
||||
为了让你更好地理解,我将原有的命题分为两个子命题来证明。第一个子命题是,第$n$个棋格放的麦粒数为$2^{n-1}$。第二个子命题是,前$n$个棋格放的麦粒数总和为$2^{n}-1$。
|
||||
|
||||
首先,我们来证明第一个子命题。
|
||||
|
||||
<li>
|
||||
基本情况:我们已经验证了$n=1$的时候,第一格内的麦粒数为$1$,和$2^{1-1}$相等。因此,命题在$k=1$的时候成立。
|
||||
</li>
|
||||
<li>
|
||||
假设第$k-1$格的麦粒数为$2^{k-2}$。那么第$k$格的麦粒数为第$k-1$格的$2$倍,也就是$2^{k - 2}*2=2^{k-1}$。因此,如果命题在$k=n-1$的时候成立,那么在$k=n$的时候也成立。
|
||||
</li>
|
||||
|
||||
所以,第一个子命题成立。在这个基础之上,我再来证明第二个子命题。
|
||||
|
||||
<li>
|
||||
基本情况:我们已经验证了$n=1$的时候,所有格子的麦粒总数为$1$。因此命题在$k=1$的时候成立。
|
||||
</li>
|
||||
<li>
|
||||
假设前$k-1$格的麦粒总数为$2^{k-1}-1$,基于前一个命题的结论,第k格的麦粒数为$2^{k-1}$。那么前$k$格的麦粒总数为$(2^{k-1}-1)+(2^{k-1})=2*2^{k-1}-1=2^{k}-1$。因此,如果命题在$k=n-1$的时候成立,那么在$k=n$的时候也成立。
|
||||
</li>
|
||||
|
||||
说到这里,我已经证明了这两个命题都是成立的。**和使用迭代法的计算相比,数学归纳法最大的特点就在于“归纳”二字。它已经总结出了规律。只要我们能够证明这个规律是正确的,就没有必要进行逐步的推算,可以节省很多时间和资源。**
|
||||
|
||||
说到这里,我们也可以看出,数学归纳法中的“归纳”是指的从第一步正确,第二步正确,第三步正确,一直推导到最后一步是正确的。这就像多米诺骨牌,只要确保第一张牌倒下,而每张牌的倒下又能导致下一张牌的倒下,那么所有的骨牌都会倒下。从这里,你也能看出来,这和开篇提到的广义归纳法是不同的。数学归纳法并不是通过经验或样本的观察,总结出事物的普遍特征和规律。
|
||||
|
||||
好了,对数学归纳法的概念,我想你现在已经理解了。这里,我对上一节中有关麦粒的代码稍作修改,增加了一点代码来使用数学归纳法的结论,并和迭代法的实现进行了比较,你可以看看哪种方法耗时更长。
|
||||
|
||||
```
|
||||
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)));
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
在我的电脑上,这段代码运行的结果是:舍罕王给了$9223372036854775807$粒,耗时$4$毫秒。舍罕王给了这么多粒:$9223372036854775806$,耗时$0$毫秒。
|
||||
|
||||
你可能已经发现,当grid=$63$时,结果差了$1$个。这个是由于Math.pow()函数计算精度导致的误差。正确的结果应该是$9223372036854775807$。不过,基于数学归纳结论的计算明显在耗时上占有优势。虽然在我的笔记本电脑上只有4毫秒的差距,但是在生产项目的实践中,这种点点滴滴的性能差距都有可能累积成明显的问题。
|
||||
|
||||
## 递归调用和数学归纳的逻辑是一样的?
|
||||
|
||||
我们不仅可以使用数学归纳法从理论上指导编程,还可以使用编程来模拟数学归纳法的证明。如果你仔细观察一下数学归纳法的证明过程,会不会觉得和函数的**递归调用**很像呢?
|
||||
|
||||
这里我通过总麦粒数的命题来示范一下。首先,我们要把这个命题的数学归纳证明,转换成一段伪代码,这个过程需要经过这样两步:
|
||||
|
||||
第一步,如果$n$为$1$,那么我们就判断麦粒总数是否为$2^{1-1}=1$。同时,返回当前棋格的麦粒数,以及从第$1$格到当前棋格的麦粒总数。
|
||||
|
||||
第二步,如果$n$为$k-1$的时候成立,那么判断$n$为$k$的时候是否也成立。此时的判断依赖于前一格$k-1$的麦粒数、第$1$格到$k-1$格的麦粒总数。这也是上一步我们所返回的两个值。
|
||||
|
||||
你应该看出来了,这两步分别对应了数学归纳法的两种情况。在数学归纳法的第二种情况下,我们只能假设$n=k-1$的时候命题成立。但是,在代码的实现中,我们可以将伪代码的第二步转为函数的递归(嵌套)调用,直到被调用的函数回退到$n=1$的情况。然后,被调用的函数逐步返回$k-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-1$的状态到$k$。这里,我使用类Result来实现这一点。
|
||||
|
||||
这里是一段测试的代码。
|
||||
|
||||
```
|
||||
public static void main(String[] args) {
|
||||
|
||||
int grid = 63;
|
||||
|
||||
Result result = new Result();
|
||||
System.out.println(Lesson4_2.prove(grid, result));
|
||||
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
我们最多测试到$63$。因为如果测试到第$64$格,麦粒总数就会溢出Java的long型数据。
|
||||
|
||||
你可以自己分析一下函数的调用和返回。我这里列出了一开始嵌套调用和到递归结束并开始返回值得的几个状态:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/02/2d/02dfa54d6968676b90ac757a0711342d.png" alt="">
|
||||
|
||||
从这个图可以看出,函数从$k=63$开始调用,然后调用$k-1$,也就是$62$,一直到$k=1$的时候,嵌套调用结束,$k=1$的函数体开始返回值给$k=2$的函数体,一直到$k=63$的函数体。从$k=63, 62, …, 2, 1$的嵌套调用过程,其实就是体现了数学归纳法的核心思想,我把它称为**逆向递推**。而从$k=1, 2, …, 62, 63$的值返回过程,和上一篇中基于循环的迭代是一致的,我把它称为**正向递推**。
|
||||
|
||||
## 小结
|
||||
|
||||
今天,我介绍了一个编程中非常重要的数学概念:数学归纳法。
|
||||
|
||||
上一节我讲了迭代法是如何通过重复的步骤进行计算或者查询的。与此不同的是,数学归纳法在理论上证明了命题是否成立,而无需迭代那样反复计算,因此可以帮助我们节约大量的资源,并大幅地提升系统的性能。
|
||||
|
||||
数学归纳法实现的运行时间几乎为$0$。不过,数学归纳法需要我们能做出合理的命题假设,然后才能进行证明。虽然很多时候要做这点比较难,确实也没什么捷径。你就是要多做题,多去看别人是怎么解题的,自己去积累经验。
|
||||
|
||||
最后,我通过函数的递归调用,模拟了数学归纳法的证明过程。如果你细心的话,会发现递归的函数值返回实现了从$k=1$开始到$k=n$的迭代。说到这里,你可能会好奇:既然递归最后返回值的过程和基于循环的迭代是一致,那为什么还需要使用递归的方法呢?下一节,我们继续聊这个问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/0d/81/0dc6eaf8597eccad3ee4411e14acf081.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在你日常工作的项目中,什么地方用到了数学归纳法来提升代码的运行效率?如果没有遇到过,你可以尝试做做实验,看看是否有提升?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
153
极客时间专栏/程序员的数学基础课/基础思想篇/05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?.md
Normal file
153
极客时间专栏/程序员的数学基础课/基础思想篇/05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?.md
Normal file
@@ -0,0 +1,153 @@
|
||||
<audio id="audio" title="05 | 递归(上):泛化数学归纳,如何将复杂问题简单化?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/76/0c/768ae51ddfc6d20f463fdb976cefa20c.mp3"></audio>
|
||||
|
||||
你好,我是黄申。上一节的结尾,我们用递归模拟了数学归纳法的证明。同时,我也留下了一个问题:**既然递归的函数值返回过程和基于循环的迭代法一致,我们直接用迭代法不就好了,为什么还要用递归的数学思想和编程方法呢**?这是因为,在某些场景下,递归的解法比基于循环的迭代法更容易实现。这是为什么呢?我们继续来看舍罕王赏麦的故事。
|
||||
|
||||
## 如何在限定总和的情况下,求所有可能的加和方式?
|
||||
|
||||
舍罕王和他的宰相西萨·班·达依尔现在来到了当代。这次国王学乖了,他对宰相说:“这次我不用麦子奖赏你了,我直接给你货币。另外,我也不用棋盘了,我直接给你一个固定数额的奖赏。”
|
||||
|
||||
宰相思考了一下,回答道:“没问题,陛下,就按照您的意愿。不过,我有个小小的要求。那就是您能否列出所有可能的奖赏方式,让我自己来选呢?假设有四种面额的钱币,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元的情况。我来画个图,从图中你就能发现这种可能的情况在快速地“膨胀”。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/76/61/761c7053947cc4340950200f8626e661.jpg" alt="">
|
||||
|
||||
你应该能看到,虽然迭代法的思想是可行的,但是如果用循环来实现,恐怕要保存好多中间状态及其对应的变量。说到这里,你是不是很容易就想到计算编程常用的**函数递归**?
|
||||
|
||||
在递归中,每次嵌套调用都会让函数体生成自己的局部变量,正好可以用来保存不同状态下的数值,为我们省去了大量中间变量的操作,极大地方便了设计和编程。
|
||||
|
||||
不过,这里又有新的问题了。之前用递归模拟数学归纳法还是非常直观的。可是,这里不是要计算一个最终的数值,而是要列举出所有的可能性。那应该如何使用递归来解决呢?上一节,我只是用递归编程体现了数学归纳法的思想,但是如果我们把这个思想泛化一下,那么递归就会有更多、更广阔的应用场景。
|
||||
|
||||
## 如何把复杂的问题简单化?
|
||||
|
||||
首先,我们来看,**如何将数学归纳法的思想泛化成更一般的情况**?数学归纳法考虑了两种情况:
|
||||
|
||||
<li>
|
||||
初始状态,也就是n=1的时候,命题是否成立;
|
||||
</li>
|
||||
<li>
|
||||
如果n=k-1的时候,命题成立。那么只要证明n=k的时候,命题也成立。其中k为大于1的自然数。
|
||||
</li>
|
||||
|
||||
将上述两点顺序更换一下,再抽象化一下,我写出了这样的递推关系:
|
||||
|
||||
<li>
|
||||
假设n=k-1的时候,问题已经解决(或者已经找到解)。那么只要求解n=k的时候,问题如何解决(或者解是多少);
|
||||
</li>
|
||||
<li>
|
||||
初始状态,就是n=1的时候,问题如何解决(或者解是多少)。
|
||||
</li>
|
||||
|
||||
我认为这种思想就是将**复杂的问题,每次都解决一点点,并将剩下的任务转化成为更简单的问题等待下次求解,如此反复,直到最简单的形式**。回到开头的例子,我们再将这种思想具体化。
|
||||
|
||||
<li>
|
||||
假设n=k-1的时候,我们已经知道如何去求所有奖赏的组合。那么只要求解n=k的时候,会有哪些金额的选择,以及每种选择后还剩下多少奖金需要支付就可以了。
|
||||
</li>
|
||||
<li>
|
||||
初始状态,就是n=1的时候,会有多少种奖赏。
|
||||
</li>
|
||||
|
||||
有了这个思路,就不难写出这个问题的递归实现。我这里列一个基本的实现。
|
||||
|
||||
```
|
||||
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.另一个更简单的问题**。经过上述步骤之后,问题就会变得更加简单一点。这里“简单一点”,指运算的结果离目标值更近(例如赏金的总额),或者是完成了更多的选择(例如纸币的选择)。而“更简单的问题”,又可以通过嵌套调用,进一步简化和求解,直至达到结束条件。
|
||||
|
||||
我们只需要保证递归编程能够体现这种将复杂问题逐步简化的思想,那么它就能帮助我们解决很多类似的问题。
|
||||
|
||||
第二,递归会使用计算机的函数嵌套调用。而函数的调用本身,就可以保存很多中间状态和变量值,因此极大的方便了编程的处理。
|
||||
|
||||
正是如此,递归在计算机编程领域中有着广泛的应用,而不仅仅局限在求和等运算操作上。在下一节中,我将介绍如何使用递归的思想,进行“分而治之”的处理。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c5/63/c5dfb38f4310af08eb6b3d05006dbf63.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
一个整数可以被分解为多个整数的乘积,例如,6可以分解为2x3。请使用递归编程的方法,为给定的整数n,找到所有可能的分解(1在解中最多只能出现1次)。例如,输入8,输出是可以是1x8, 8x1, 2x4, 4x2, 1x2x2x2, 1x2x4, ……
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
246
极客时间专栏/程序员的数学基础课/基础思想篇/06 | 递归(下):分而治之,从归并排序到MapReduce.md
Normal file
246
极客时间专栏/程序员的数学基础课/基础思想篇/06 | 递归(下):分而治之,从归并排序到MapReduce.md
Normal file
@@ -0,0 +1,246 @@
|
||||
<audio id="audio" title="06 | 递归(下):分而治之,从归并排序到MapReduce" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/80/e4/8077007c3aeca6af4227d358bce832e4.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上一节,我解释了如何使用递归,来处理迭代法中比较复杂的数值计算。说到这里,你可能会问了,有些迭代法并不是简单的数值计算,而要通过迭代的过程进行一定的操作,过程更加复杂,需要考虑很多中间数据的匹配或者保存。例如我们之前介绍的用二分查找进行数据匹配,或者我们今天将要介绍的归并排序中的数据排序等等。那么,这种情况下,还可以用递归吗?具体又该如何来实现呢?
|
||||
|
||||
我们可以先分析一下,这些看似很复杂的问题,是否可以简化为某些更小的、更简单的子问题来解决,这是一般思路。如果可以,那就意味着我们仍然可以使用递归的核心思想,将复杂的问题逐步简化成最基本的情况来求解。因此,今天我会从归并排序开始,延伸到多台机器的并行处理,详细讲讲递归思想在“分而治之”这个领域的应用。
|
||||
|
||||
## 归并排序中的分治思想
|
||||
|
||||
首先,我们来看,如何使用递归编程解决数字的排序问题。
|
||||
|
||||
对一堆杂乱无序的数字,按照从小到大或者从大到小的规则进行排序,这是计算机领域非常经典,也非常流行的问题。小到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}的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3c/49/3c27ae183127b1a9fa3aa2ac10538149.jpg" alt="">
|
||||
|
||||
为了保证得到有序的C数列,我们必须保证参与合并的A和B也是有序的。可是,等待排序的数组一开始都是乱序的,如果无法保证这点,那归并又有什么意义呢?
|
||||
|
||||
还记得上一篇说的递归吗?这里我们就可以利用递归的思想,把问题不断简化,也就是把数列不断简化,一直简化到只剩1个数。1个数本身就是有序的,对吧?
|
||||
|
||||
好了,现在剩下的疑惑就是,每一次如何简化问题呢?最简单的想法是,我们把将长度为n的数列,每次简化为长度为n-1的数列,直至长度为1。不过,这样的处理没有并行性,要进行n-1次的归并操作,效率就会很低。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/4d/6e/4dd0cfbf5827c9d075483c9499a66a6e.jpg" alt="">
|
||||
|
||||
所以,我们可以在归并排序中引入了**分而治之**(Divide and Conquer)的思想。**分而治之,我们通常简称为分治。它的思想就是,将一个复杂的问题,分解成两个甚至多个规模相同或类似的子问题,然后对这些子问题再进一步细分,直到最后的子问题变得很简单,很容易就能被求解出来,这样这个复杂的问题就求解出来了**。
|
||||
|
||||
归并排序通过分治的思想,把长度为n的数列,每次简化为两个长度为n/2的数列。这更有利于计算机的并行处理,只需要log<sub>2</sub>n次归并。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/23/ab/2395178b639c3314a427dd658ae924ab.jpg" alt="">
|
||||
|
||||
我们把归并和分治的思想结合起来,这其实就是归并排序算法。这种算法每次把数列进行二等分,直到唯一的数字,也就是最基本的有序数列。然后从这些最基本的有序数列开始,两两合并有序的数列,直到所有的数字都参与了归并排序。
|
||||
|
||||
我用一个包含0~9这10个数字的数组,给你详细讲解一下归并排序的过程。
|
||||
|
||||
<li>
|
||||
假设初始的数组为{7, 6, 2, 4, 1, 9, 3, 8, 0, 5},我们要对它进行从小到大的排序。
|
||||
</li>
|
||||
<li>
|
||||
第一次分解后,变成两个数组{7, 6, 2, 4, 1}和{9, 3, 8, 0, 5}。
|
||||
</li>
|
||||
<li>
|
||||
然后,我们将{7, 6, 2, 4, 1}分解为{7, 6}和{2, 4, 1},将{9, 3, 8, 0, 5}分解为{9, 3}和{8, 0, 5}。
|
||||
</li>
|
||||
<li>
|
||||
如果细分后的组仍然多于一个数字,我们就重复上述分解的步骤,直到每个组只包含一个数字。到这里,这些其实都是递归的嵌套调用过程。
|
||||
</li>
|
||||
<li>
|
||||
然后,我们要开始进行合并了。我们可以将{4, 1}分解为{4}和{1}。现在无法再细分了,我们开始合并。在合并的过程中进行排序,所以合并的结果为{1,4}。合并后的结果将返回当前函数的调用者,这就是函数返回的过程。
|
||||
</li>
|
||||
<li>
|
||||
重复上述合并的过程,直到完成整个数组的排序,得到{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}。
|
||||
</li>
|
||||
|
||||
为了方便你的理解,我画了张图,给你解释整个归并排序的过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/c5/53f4f60370f04796685f913f22e8c8c5.jpg" alt="">
|
||||
|
||||
说到这里,我想问你,这个归并排序、分治和递归到底是什么关系呢?用一句话简单地说就是,**归并排序使用了分治的思想,而这个过程需要使用递归来实现。**
|
||||
|
||||
归并排序算法用分治的思想把数列不断地简化,直到每个数列仅剩下一个单独的数,然后再使用归并逐步合并有序的数列,从而达到将整个数列进行排序的目的。而这个归并排序,正好可以使用递归的方式来实现。为什么这么说?首先,我们来看看这张图,分治的过程是不是和递归的过程一致呢?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/29/a8/2945ead8def203f95053852c5dbd57a8.jpg" alt="">
|
||||
|
||||
分治的过程可以通过递归来表达,因此,归并排序最直观的实现方式就是递归。所以,我们从递归的步骤出发,来看归并排序如何实现。
|
||||
|
||||
我们假设n=k-1的时候,我们已经对较小的两组数进行了排序。那我们只要在n=k的时候,将这两组数合并起来,并且保证合并后的数组仍然是有序的就行了。
|
||||
|
||||
所以,在递归的每次嵌套调用中,代码都将一组数分解成更小的两组,然后将这两个小组的排序交给下一次的嵌套调用。而本次调用只需要关心,如何将排好序的两个小组进行合并。
|
||||
|
||||
在初始状态,也就是n=1的时候,对于排序的案例而言,只包含单个数字的分组。由于分组里只有一个数字,所以它已经是排好序的了,之后就可以开始递归调用的返回阶段。我这里画了张图,便于你的理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/12/5410fb301ffce57355ad7ef074e8fd12.jpg" alt="">
|
||||
|
||||
你现在应该已经明白了归并排序的基本过程,最难的已经过去了,编写代码实现就不难了。我这里给出示范性代码,你可以参考看看。
|
||||
|
||||
```
|
||||
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或者更小),然后分配到多台机器,让它们并行地处理。
|
||||
|
||||
等所有机器处理完后,中央服务器再进行结果的合并。由于多个小任务间不会相互干扰,可以同时处理,这样会大大增加处理的速度,减少等待时间。
|
||||
|
||||
在单台机器上实现归并排序的时候,我们只需要在递归函数内,实现数据分组以及合并就行了。而在多个机器之间分配数据的时候,递归函数内除了分组及合并,还要负责把数据分发到某台机器上。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/78/31/78eefc6b61bad62f257f2b5e4972f031.jpg" alt="">
|
||||
|
||||
在这个框架图中,你应该可以看到,分布式集群中的数据切分和合并,同单台机器上归并排序的过程是一样的,因此也是使用了分治的思想。从理论的角度来看,上面这个图很容易理解。不过在实际运用中,有个地方需要注意一下。
|
||||
|
||||
上图中的父结点,例如机器1、2、3,它们都没有被分配排序的工作,只是在子结点的排序完成后进行有序数组的合并,因此集群的性能没有得到充分利用。那么,另一种可能的数据切分方式是,每台机器拿出一半的数据给另一台机器处理,而自己来完成剩下一半的数据。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1d/58/1d278b81c4bd3b6bc522f34cbe298c58.jpg" alt="">
|
||||
|
||||
如果分治的时候,只进行一次问题切分,那么上述层级型的分布式架构就可以转化为类似MapReduce的架构。我画出了MapReduce的主要步骤,你可以看看,这里面有哪些步骤体现了分治的思想?
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/08/5a/08155dd375f7b049424a6686bcb6475a.jpg" alt="">
|
||||
|
||||
这里面主要有三个步骤用到了分治的思想。
|
||||
|
||||
### 1. 数据分割和映射
|
||||
|
||||
分割是指将数据源进行切分,并将分片发送到Mapper上。映射是指Mapper根据应用的需求,将内容按照键-值的匹配,存储到哈希结构中。这两个步骤将大的数据集合切分为更小的数据集,降低了每台机器节点的负载,因此和分治中的问题分解类似。不过,MapReduce采用了哈希映射来分配数据,而普通的分治或递归不一定需要。
|
||||
|
||||
### 2.归约
|
||||
|
||||
归约是指接受到的一组键值配对,如果是键内容相同的配对,就将它们的值归并。这和本机的递归调用后返回结果的过程类似。不过,由于哈希映射的关系,MapReduce还需要洗牌的步骤,也就是将键-值的配对不断地发给对应的Reducer进行归约。普通的分治或递归不一定需要洗牌的步骤。
|
||||
|
||||
### 3.合并
|
||||
|
||||
为了提升洗牌阶段的效率,可以选择减少发送到归约阶段的键-值配对。具体做法是在数据映射和洗牌之间,加入合并的过程,在每个Mapper节点上先进行一次本地的归约。然后只将合并的结果发送到洗牌和归约阶段。这和本机的递归调用后返回结果的过程类似。
|
||||
|
||||
说了这么多,你现在对分治应该有比较深入的理解了。实际上,分治主要就是用在将复杂问题转化为若干个规模相当的小问题上。分治思想通常包括问题的细分和结果的合并,正好对应于递归编程的函数嵌套调用和函数结果的返回。细分后的问题交给嵌套调用的函数去解决,而结果合并之后交由函数进行返回。所以,分治问题适合使用递归来实现。同时,分治的思想也可以帮助我们设计分布式系统和并行计算,细分后的问题交给不同的机器来处理,而其中的某些机器专门负责收集来自不同机器的处理结果,完成结果的合并。
|
||||
|
||||
## 小结
|
||||
|
||||
这两节我们学习了递归法。递归采用了和数学归纳法类似的思想,但是它用的是逆向递推,化繁为简,把复杂的问题逐步简化。再加上分治原理,我们就可以更有效地把问题细分,进行并行化的处理。
|
||||
|
||||
而计算机编程中的函数嵌套调用,正好对应了数学中递归的逆向递推,所以你只要弄明白了数学递推式,就能非常容易的写出对应的递归编码。这是为什么递归在编程领域有着非常广泛的应用。不过,需要注意的是,递归编程在没有开始返回结果之前,保存了大量的中间结果,所以比较消耗系统资源。这也是一般的编程语言都会限制递归的深度(也就是嵌套的次数)的原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/09/fe/0908dbae238006c1b8bafb09f9839bfe.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
你有没有想过,在归并排序的时候,为什么每次都将原有的数组分解为两组,而不是更多组呢?如果分为更多组,是否可行?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
225
极客时间专栏/程序员的数学基础课/基础思想篇/07 | 排列:如何让计算机学会“田忌赛马”?.md
Normal file
225
极客时间专栏/程序员的数学基础课/基础思想篇/07 | 排列:如何让计算机学会“田忌赛马”?.md
Normal file
@@ -0,0 +1,225 @@
|
||||
<audio id="audio" title="07 | 排列:如何让计算机学会“田忌赛马”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/19/89/1944dea9b5c7d551f284024f65fa6a89.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
“田忌赛马”的故事我想你肯定听过吧?田忌是齐国有名的将领,他常常和齐王赛马,可是总是败下阵来,心中非常不悦。孙膑想帮田忌一把。他把这些马分为上、中、下三等。他让田忌用自己的下等马来应战齐王的上等马,用上等马应战齐王的中等马,用中等马应战齐王的下等马。三场比赛结束后,田忌只输了第一场,赢了后面两场,最终赢得与齐王的整场比赛。
|
||||
|
||||
孙膑每次都从田忌的马匹中挑选出一匹,一共进行三次,排列出战的顺序。是不是感觉这个过程很熟悉?这其实就是数学中的**排列**过程。
|
||||
|
||||
我们初高中的时候,都学过排列,它的概念是这么说的:从n个不同的元素中取出m(1≤m≤n)个不同的元素,按照一定的顺序排成一列,这个过程就叫**排列**(Permutation)。当m=n这种特殊情况出现的时候,比如说,在田忌赛马的故事中,田忌的三匹马必须全部出战,这就是**全排列**(All Permutation)。
|
||||
|
||||
如果选择出的这m个元素可以有重复的,这样的排列就是为**重复排列**(Permutation with Repetition),否则就是**不重复排列**(Permutation without Repetition)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/15/98df21876ad52195217709e298707515.jpg" alt="">
|
||||
|
||||
看出来没有?这其实是一个树状结构。从树的根结点到叶子结点,每种路径都是一种排列。有多少个叶子结点就有多少种全排列。从图中我们可以看出,最终叶子结点的数量是3x2x1=6,所以最终排列的数量为6。
|
||||
|
||||
```
|
||||
{上等,中等,下等}
|
||||
{上等,下等,中等}
|
||||
{中等,上等,下等}
|
||||
{中等,下等,上等}
|
||||
{下等,上等,中等}
|
||||
{下等,中等,上等}
|
||||
|
||||
```
|
||||
|
||||
我用t1,t2和t3分别表示田忌的上、中、下等马跑完全程所需的时间,用q1,q2和q3分别表示齐王的上、中、下等马跑全程所需的时间,因此,q1<t1<q2<t2<q3<t3。
|
||||
|
||||
如果你将这些可能的排列,仔细地和齐王的上等、中等和下等马进行对比,只有{下等,上等,中等}这一种可能战胜齐王,也就是t3>q1,t1<q2,t2<q3。
|
||||
|
||||
对于最终排列的数量,这里我再推广一下:
|
||||
|
||||
<li>
|
||||
对于n个元素的全排列,所有可能的排列数量就是nx(n-1)x(n-2)x…x2x1,也就是n!;
|
||||
</li>
|
||||
<li>
|
||||
对于n个元素里取出m(0<m≤n)个元素的不重复排列数量是nx(n-1)x(n-2)x…x(n - m + 1),也就是n!/(n-m)!。
|
||||
</li>
|
||||
|
||||
这两点都是可以用数学归纳法证明的,有兴趣的话你可以自己尝试一下。
|
||||
|
||||
## 如何让计算机为田忌安排赛马?
|
||||
|
||||
我们刚才讨论了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年左右的时间才能暴力破解这种密码。
|
||||
|
||||
## 小结
|
||||
|
||||
排列可以帮助我们生成很多可能性。由于这种特性,排列最多的用途就是穷举法,也就是,列出所有可能的情况,一个一个验证,然后看每种情况是否符合条件的解。
|
||||
|
||||
古代的孙膑利用排列的思想,穷举了田忌马匹的各种出战顺序,然后获得了战胜齐王的策略。现代的黑客,通过排列的方法,穷举了各种可能的密码,试图破坏系统的安全性。如果你所面临的问题,它的答案也是各种元素所组成的排列,那么你就可以考虑,有没有可能排列出所有的可能性,然后通过穷举的方式来获得最终的解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/84/45/84f9e15c857ca0dbc49837ff0e107945.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
假设有一个4位字母密码,每位密码是a~e之间的小写字母。你能否编写一段代码,来暴力破解该密码?(提示:根据可重复排列的规律,生成所有可能的4位密码。)
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
152
极客时间专栏/程序员的数学基础课/基础思想篇/08 | 组合:如何让计算机安排世界杯的赛程?.md
Normal file
152
极客时间专栏/程序员的数学基础课/基础思想篇/08 | 组合:如何让计算机安排世界杯的赛程?.md
Normal file
@@ -0,0 +1,152 @@
|
||||
<audio id="audio" title="08 | 组合:如何让计算机安排世界杯的赛程?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/f0/c6246cebe1d9040824beef742ba806f0.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
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)。基于此,我们可以扩展成以下两种情况。
|
||||
|
||||
<li>
|
||||
n个元素里取出m个的组合,可能性数量就是n个里取m个的排列数量,除以m个全排列的数量,也就是(n! / (n-m)!) / m!。
|
||||
</li>
|
||||
<li>
|
||||
对于全组合而言,可能性为2^n种。例如,当n=3的时候,全组合包括了8种情况。
|
||||
</li>
|
||||
|
||||
这两点都可以用数学归纳法证明,有兴趣的话你可以自己尝试一下。
|
||||
|
||||
## 如何让计算机来组合队伍?
|
||||
|
||||
上一节,我用递归实现了全排列。全组合就是将所有元素列出来,没有太大意义,所以我这里就带你看下,如何使用递归从3个元素中选取2个元素的组合。
|
||||
|
||||
我们假设有3个队伍,t1,t2和t3。我还是把递归的选择画成图,这样比较直观,你也好理解。从图中我们可以看出,对于组合而言,由于{t1, t2}已经出现了,因此就无需{t2, t1}。同理,出现{t1, t3},就无需{t3, t1}等等。对于重复的,我用叉划掉了。这样,最终只有3种组合了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/4b/827020c47c2c4b825dc5d51697f5cd4b.jpg" alt="">
|
||||
|
||||
那么,如何使用代码来实现呢?一种最简单粗暴的做法是:
|
||||
|
||||
<li>
|
||||
先实现排列的代码,输出所有的排列。例如{t1, t2}, {t2, t1};
|
||||
</li>
|
||||
<li>
|
||||
针对每种排列,对其中的元素按照一定的规则排序。那么上述两种排列经过排序后,就是{t1, t2}, {t1, t2};
|
||||
</li>
|
||||
<li>
|
||||
对排序后的排列,去掉重复的那些。上述两种排列最终只保留一个{t1, t2}。
|
||||
</li>
|
||||
|
||||
这样做效率就会比较低,很多排列生成之后,最终还是要被当做重复的结果去掉。
|
||||
|
||||
显然,还有更好的做法。从图中我们可以看出被划掉的那些,都是那些出现顺序和原有顺序颠倒的元素。
|
||||
|
||||
例如,在原有集合中,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个元素中取出若干个元素。不过,排列考虑了取出的元素它们之间的顺序,而组合无需考虑这种顺序。这是排列和组合最大的区别。因此,组合适合找到多个元素之间的联系而并不在意它们之间的先后顺序,例如多元文法中的多元组,这有利于避免不必要的数据保存或操作。
|
||||
|
||||
具体到编程,组合和排列两者的实现非常类似。区别在于,组合并不考虑挑选出来的元素之间,是如何排序的。所以,在递归的时候,传入下一个嵌套调用函数的剩余元素,只需要包含当前被选元素之后的那些,以避免重复的组合。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/ee/b2cbd776947f32b6a3e5e30f388e0eee.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
假设现在需要设计一个抽奖系统。需要依次从100个人中,抽取三等奖10名,二等奖3名和一等奖1名。请列出所有可能的组合,需要注意的每人最多只能被抽中1次。
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
87
极客时间专栏/程序员的数学基础课/基础思想篇/09 | 动态规划(上):如何实现基于编辑距离的查询推荐?.md
Normal file
87
极客时间专栏/程序员的数学基础课/基础思想篇/09 | 动态规划(上):如何实现基于编辑距离的查询推荐?.md
Normal file
@@ -0,0 +1,87 @@
|
||||
<audio id="audio" title="09 | 动态规划(上):如何实现基于编辑距离的查询推荐?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/ec/4e/ecd834442a14187012faa23330ba484e.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上一篇讲组合的时候,我最后提到了有关文本的关键字查询。今天我接着文本搜索的话题,来聊聊查询推荐(Query Suggestion)的实现过程,以及它所使用的数学思想,**动态规划**(Dynamic Programming)。
|
||||
|
||||
那什么是动态规划呢?在递归那一节,我说过,我们可以通过不断分解问题,将复杂的任务简化为最基本的小问题,比如基于递归实现的归并排序、排列和组合等。不过有时候,我们并不用处理所有可能的情况,只要找到满足条件的最优解就行了。在这种情况下,我们需要在各种可能的局部解中,找出那些可能达到最优的局部解,而放弃其他的局部解。这个寻找最优解的过程其实就是动态规划。
|
||||
|
||||
动态规划需要通过子问题的最优解,推导出最终问题的最优解,因此这种方法特别注重子问题之间的转移关系。我们通常把这些子问题之间的转移称为**状态转移**,并把用于刻画这些状态转移的表达式称为**状态转移方程**。很显然,找到合适的状态转移方程,是动态规划的关键。
|
||||
|
||||
因此,这两节我会通过实际的案例,给你详细解释如何使用动态规划法寻找最优解,包括如何分解问题、发现状态转移的规律,以及定义状态转移方程。
|
||||
|
||||
## 编辑距离
|
||||
|
||||
当你在搜索引擎的搜索框中输入单词的时候,有没有发现,搜索引擎会返回一系列相关的关键词,方便你直接点击。甚至,当你某个单词输入有误的时候,搜索引擎依旧会返回正确的搜索结果。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/1c/eb/1c337d39b48ef544ef811c926c70fbeb.png" alt="">
|
||||
|
||||
搜索下拉提示和关键词纠错,这两个功能其实就是**查询推荐**。查询推荐的核心思想其实就是,对于用户的输入,查找相似的关键词并进行返回。而测量拉丁文的文本相似度,最常用的指标是**编辑距离**(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的字符数组作为列,每列表示其中一个字母,这样就得到下面这个表格。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3f/cd/3f696455617c8a0da422df3cdb64d0cd.png" alt="">
|
||||
|
||||
这张表格里的不同状态之间的转移,就是**状态转移**。其中红色部分表示字符串演变(或者说状态转移)的方式以及相应的编辑距离计算。对于表格中其他空白的部分,我暂时不给出,你可以试着自己来推导。
|
||||
|
||||
编辑距离是具有对称性的,也就是说从字符串A到B的编辑距离,和从字符串B到A的编辑距离,两者一定是相等的。这个应该很好理解。
|
||||
|
||||
你可以把刚才那个状态转移表的行和列互换一下,再推导一下,看看得出的编辑距离是否还是1。我现在从理论上解释下这一点。这其实是由编辑距离的三种操作决定的。比如说,从字符串A演变到B的每一种操作,都可以转换为从字符串B演变到A的某一种操作。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/e8/1824ca86219e0f05591aa48fe7f6dee8.jpg" alt="">
|
||||
|
||||
所以说,从字符串A演变到B的每一种变化方式,都可以找到对应的从字符串B演变到A的某种方式,两者的操作次数一样。自然,代表最小操作次数的编辑距离也就一样了。
|
||||
|
||||
## 小结
|
||||
|
||||
我今天介绍了用于查询推荐的编辑距离。编辑距离的定义很好理解,不过,求任意两个字符串之间的编辑距离可不是一件容易的事情。我先尝试用排列来分析问题,发现这条路走不通,而后我们仍然使用了化繁为简的思路,把编辑距离的计算拆分为3种情况,并建立了子串之间的联系。
|
||||
|
||||
你不要觉得这样的分析过程比较繁琐,我想说的是,学数学固然是为了得到结果,但是学习的过程,是要学会解决问题的方法和思路。比如面对一个问题的时候,你可能不知道用什么方法来解决,但是你可以尝试用我们学过的这些基础思想去分析,去比对,在这个分析的过程中去总结这些方法的使用规律,久而久之,你就能摸索出自己解决问题的套路。
|
||||
|
||||
比如说,动态规划虽然也采用了把问题逐步简化的思想,但是它和基于递归的归并排序、排列组合等解法有所不同。能够使用动态规划解决的问题,通常只关心一个最优解,而这个最优解是单调改变的,例如最大值、最小值等等。因此,动态规划中的每种状态,通常只保留一个当前的最优解,这也是动态规划效率比较高的原因。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f3/94/f37da4a1ef98494dea70016b90922594.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
理解了动态规划法和状态转移之后,你觉得根据编辑距离来衡量字符串之间的相似程度有什么局限性?你有什么优化方案吗?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
168
极客时间专栏/程序员的数学基础课/基础思想篇/10 | 动态规划(下):如何求得状态转移方程并进行编程实现?.md
Normal file
168
极客时间专栏/程序员的数学基础课/基础思想篇/10 | 动态规划(下):如何求得状态转移方程并进行编程实现?.md
Normal file
@@ -0,0 +1,168 @@
|
||||
<audio id="audio" title="10 | 动态规划(下):如何求得状态转移方程并进行编程实现?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/f1/44/f17edfee45c537cf87b7989d030bcb44.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上一节,我从查询推荐的业务需求出发,介绍了编辑距离的概念,今天我们要基于此,来获得状态转移方程,然后才能进行实际的编码实现。
|
||||
|
||||
## 状态转移方程和编程实现
|
||||
|
||||
上一节我讲到了使用状态转移表来展示各个子串之间的关系,以及编辑距离的推导。不过,我没有完成那张表格。现在我把它补全,你可以和我的结果对照一下。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/8c/265fb5d134bfebb2fd2cf712f759468c.png" alt="">
|
||||
|
||||
这里面求最小值的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。
|
||||
|
||||
有了这些定义,下面我们用迭代来表达上述的推导过程。
|
||||
|
||||
<li>
|
||||
如果i为0,且j也为0,那么d[i, j]为0。
|
||||
</li>
|
||||
<li>
|
||||
如果i为0,且j大于0,那么d[i, j]为j。
|
||||
</li>
|
||||
<li>
|
||||
如果i大于0,且j为0,那么d[i, j]为i。
|
||||
</li>
|
||||
<li>
|
||||
如果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))。
|
||||
</li>
|
||||
|
||||
这里面最关键的一步是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元的最少币数是$x_{1}$,那么增加一枚2元后就是($x_{1}$ + 1)枚。
|
||||
|
||||
第二种,总和97元的钱币,加上1枚3元的钱币。如果凑到97元的最少币数是$x_{2}$,那么增加一枚3元后就是($x_{2}$ + 1)枚。
|
||||
|
||||
第三种,总和93元的钱币,加上1枚7元的钱币。如果凑到93元的最少币数是$x_{3}$,那么增加一枚7元后就是($x_{3}$ + 1)枚。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e5/d9/e5a9b9e6d931049bfae92ead29e37cd9.jpg" alt="">
|
||||
|
||||
比较一下以上三种情况的钱币总数,取最小的那个就是总额为100元时,最小的钱币数。换句话说,由于奖赏的总金额是固定的,所以最后选择的那枚钱币的面额,将决定到上一步为止的金额,同时也决定了上一步为止钱币的最少数量。根据这个,我们可以得出如下状态转移方程:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d8/27/d81e704031156e605b61610ef681c427.jpg" alt="">
|
||||
|
||||
其中,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种钱币的面额,最后一列记录最少的钱币数量。表中的“/”表示不可能,或者说无解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e7/58/e78354fe2f577d07649882fed69bd358.png" alt="">
|
||||
|
||||
这张状态转移表同样可以帮助你来理解状态转移方程的正确性。一旦状态转移方程确定了,要编写代码来实现就不难了。
|
||||
|
||||
## 小结
|
||||
|
||||
通过这两节的内容,我讲述了动态规划主要的思想和应用。如果仅仅看这两个案例,也许你觉得动态规划不难理解。不过,在实际应用中,你可能会产生这些疑问:什么时候该用动态规划?这个问题可以用动态规划解决啊,为什么我没想到?我这里就讲一些我个人的经验。
|
||||
|
||||
首先,如果一个问题有很多种可能,看上去需要使用排列或组合的思想,但是最终求的只是某种最优解(例如最小值、最大值、最短子串、最长子串等等),那么你不妨试试是否可以使用动态规划。
|
||||
|
||||
其次,状态转移方程是个关键。你可以用状态转移表来帮助自己理解整个过程。如果能找到准确的转移方程,那么离最终的代码实现就不远了。当然,最好的方式,还是结合工作中的项目,不断地实践,尝试,然后总结。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/5b/7e084bc699b4939b78226718756fd65b.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
对于总金额固定、找出最少钱币数的题目,用循环或者递归的方式该如何进行编码呢?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
114
极客时间专栏/程序员的数学基础课/基础思想篇/11 | 树的深度优先搜索(上):如何才能高效率地查字典?.md
Normal file
114
极客时间专栏/程序员的数学基础课/基础思想篇/11 | 树的深度优先搜索(上):如何才能高效率地查字典?.md
Normal file
@@ -0,0 +1,114 @@
|
||||
<audio id="audio" title="11 | 树的深度优先搜索(上):如何才能高效率地查字典?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/98/a1/98e911b9323e4b38d7ad8d07a75399a1.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
你还记得迭代法中的的二分查找吗?在那一讲中,我们讨论了一个查字典的例子。如果要使用二分查找,我们首先要把整个字典排个序,然后每次都通过二分的方法来缩小搜索范围。
|
||||
|
||||
不过在平时的生活中,咱们查字典并不是这么做的。我们都是从单词的最左边的字母开始,逐个去查找。比如查找“boy”这个单词,我们一般是这么查的。首先,在a~z这26个英文字母里找到单词的第一个字母b,然后在b开头的单词里找到字母o,最终在bo开头的单词里找到字母y。
|
||||
|
||||
你可以看我画的这种树状图,其实就是从树顶层的根结点一直遍历到最下层的叶子结点,最终逐步构成单词前缀的过程。对应的数据结构就是**前缀树**(prefix tree)**,或者叫字典树**(trie)。我个人更喜欢前缀树这个名称,因为看到这个名词,这个数据结构的特征就一目了然。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f4/34/f4cffbdefa0cd94eda18294c47bf8e34.jpg" alt="">
|
||||
|
||||
那前缀树究竟该如何构建呢?有了前缀树,我们又该如何查询呢?今天,我会从图论的基本概念出发,来给你讲一下什么样的结构是树,以及如何通过树的深度优先搜索,来实现前缀树的构建和查询。
|
||||
|
||||
## 图论的一些基本概念
|
||||
|
||||
前缀树是一种有向树。那什么是有向树?顾名思义,有向树就是一种树,特殊的就是,它的边是有方向的。而树是没有简单回路的连通图。
|
||||
|
||||
如果一个图里所有的边都是有向边,那么这个图就是有向图。如果一个图里所有的边都是无向边,那么这个图就是无向图。既含有向边,又含无向边的图,称为混合图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bb/7d/bb472743016ead750fc7a80d8fc6bf7d.jpg" alt="">
|
||||
|
||||
在有向图中,以结点$v$为出发点的边的数量,我们叫作$v$的**出度**。而以$v为$终点的边之数量,称为$v$的**入度**。在上图中,结点$v_{2}$的入度是1,出度是2。
|
||||
|
||||
还有两个和有向树有关的概念,回路和连通,我这里简单给你解释一下,你很容易就能明白了。
|
||||
|
||||
结点和边的交替序列组成的就是**通路**。所以,通路上的任意两个结点其实就是互为连通的。如果一条通路的起始点$v_{1}$和终止点$v_{n}$相同,这种特殊的通路我们就叫作**回路**。从起始点到终止点所经过的边之数量,就是通路的长度。这里我画了一张图,这里面有1条通路和1条回路,第一条非回路通路的长度是3,第二条回路的长度是4。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d6/7b/d6f3fc3ffa213ba714a25091485ff97b.jpeg" alt="">
|
||||
|
||||
理解了图的基本概念,我们再来看树和有向树。**树**是一种特殊的图,它是没有简单回路的连通无向图。这里的简单回路,其实就是指,除了第一个结点和最后一个结点相同外,其余结点不重复出现的回路。你可以看我画的这几幅图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e8/92/e8705553ef8e27adf0e2f5005671f492.jpg" alt="">
|
||||
|
||||
那么,什么是**有向树**呢?顾名思义,有向树是一种特殊的树,其中的边都是有向的,而且它满足以下几个条件:
|
||||
|
||||
<li>
|
||||
有且仅有一个结点的入度为0,这个结点被称为根;
|
||||
</li>
|
||||
<li>
|
||||
除根以外的所有结点,入度都为1。从树根到任一结点有且仅有一条有向通路。
|
||||
</li>
|
||||
|
||||
除了这些基本定义,有向树还有几个重要的概念,父结点、子结点、兄弟结点、先辈结点、后辈结点、叶子结点、结点的高度(或深度)、树的高度(或深度)。这些都不难理解,我画个图展示一下,你就能明白了。我把根结点的高度设置为0,根据需要你也可以设置为1。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/36/13/366d75b7566adfb09f71f3ae4ad7dd13.jpg" alt="">
|
||||
|
||||
## 前缀树的构建和查询
|
||||
|
||||
好了,说了这么些,你对有向树应该有了理解。接下来,我们来看,如何使用有向树来实现前缀树呢?这整个过程主要包括两个部分:构建前缀树和查询前缀树。
|
||||
|
||||
### 1. 构建前缀树
|
||||
|
||||
首先,我们把空字符串作为树的根。对于每个单词,其中每一个字符都代表了有向树的一个结点。而前一个字符就是后一个字符的父结点,后一个字符是前一个字符的子结点。这也意味着,每增加一个字符,其实就是在当前字符结点下面增加一个子结点,相应地,树的高度也增加了1。
|
||||
|
||||
我们以单词geek为例,从根结点开始,第一次我增加字符g,在根结点下增加一个“g”的结点。第二次,我在“g”结点下方增加一个“e”结点。以此类推,最终我们可以得到下面的树。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d0/26/d06e56e85242f66552672d0f7ada7b26.jpg" alt="">
|
||||
|
||||
那如果这个时候,再增加一个单词,geometry会怎样?我们继续重复这个过程,就能得到下面这个图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/95/8f/950f2b49edf271a36a5f96cf2187638f.jpg" alt="">
|
||||
|
||||
到这里为止,我们已经建立了包含两个单词的前缀树。在这棵树的两个叶子结点“k”和“y”上,我们可以加上额外的信息,比如单词的解释。那么在匹配成功之后,就可以直接返回这些信息,实现字典的功能了。假设我把牛津词典里所有的英文单词都按照上述的方法处理一遍,就能构造一棵包含这个字典里所有单词的前缀树,并实现常用单词的查找和解释。
|
||||
|
||||
### 2. 查询前缀树
|
||||
|
||||
假设我们已经使用牛津词典,构建完了一个完整的前缀树,现在我们就能按照开篇所说的那种方式,查找任何一个单词了。从前缀树的根开始,查找下一个结点,顺着这个通路走下去,一直走到到某个结点。如果这个结点及其前缀代表了一个存在的单词,而待查找的单词和这个结点及其前缀正好完全匹配,那就说明成功找到了一个单词。否则,就表示无法找到。
|
||||
|
||||
这里还有几种特殊情况,需要注意。
|
||||
|
||||
<li>
|
||||
如果还没到叶子结点的时候,待查的单词就结束了。这个时候要看最后匹配上的非叶子结点是否代表一个单词;如果不是,那说明被查单词并不在字典中。
|
||||
</li>
|
||||
<li>
|
||||
如果搜索到前缀树的叶子结点,但是被查单词仍有未处理的字母。由于叶子结点没有子结点,这时候,被查单词不可能在字典中。
|
||||
</li>
|
||||
<li>
|
||||
如果搜索到一半,还没到达叶子结点,被查单词也有尚未处理的字母,但是当前被处理的字母已经无法和结点上的字符匹配了。这时候,被查单词不可能在字典中。
|
||||
</li>
|
||||
|
||||
前缀树的构建和查询这两者在本质上其实是一致的。构建的时候,我们需要根据当前的前缀进行查询,然后才能找到合适的位置插入新的结点。而且,这两者都存在一个不断重复迭代的查找过程,我们把这种方式称为**深度优先搜索**(Depth First Search)。
|
||||
|
||||
所谓树的深度优先搜索,其实就是从树中的某个结点出发,沿着和这个结点相连的边向前走,找到下一个结点,然后以这种方式不断地发现新的结点和边,一直搜索下去,直到访问了所有和出发点连通的点、或者满足某个条件后停止。
|
||||
|
||||
如果到了某个点,发现和这个点直接相连的所有点都已经被访问过,那么就回退到在这个点的父结点,继续查看是否有新的点可以访问;如果没有就继续回退,一直到出发点。由于单棵树中所有的结点都是连通的,所以通过深度优先的策略可以遍历树中所有的结点,因此也被称为**深度优先遍历**。
|
||||
|
||||
为了让你更容易理解,我用下面这张图来展示在一棵有向树中进行深度优先搜索时,结点被访问的顺序。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/fd/f9/fdc74a1d4797eee2b397e7c6fe5992f9.jpg" alt="">
|
||||
|
||||
其中,结点上的数字表示结点的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连起来,就正好对应一个二进制数。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/56/89/564ee1181fe4b351a12f8af690311d89.jpg" alt="">
|
||||
|
||||
既然树是如此重要,那么我们该如何高效率地访问树中的结点呢?下一讲,我会继续前缀树的话题,讨论如何遍历树中所有结点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/09/79701ece6ee3d1a7efdcba51c5684e09.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
现在给你一个字典,请尝试实现其前缀树,包括树的构建和查询两个过程。这里,字典可以用字符串数组来表示,每个字符串代表一个单词。
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
205
极客时间专栏/程序员的数学基础课/基础思想篇/12 | 树的深度优先搜索(下):如何才能高效率地查字典?.md
Normal file
205
极客时间专栏/程序员的数学基础课/基础思想篇/12 | 树的深度优先搜索(下):如何才能高效率地查字典?.md
Normal file
@@ -0,0 +1,205 @@
|
||||
<audio id="audio" title="12 | 树的深度优先搜索(下):如何才能高效率地查字典?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/62/db/620a9d90a1b8433b9698a3a3faaf2cdb.mp3"></audio>
|
||||
|
||||
你好,我是黄申。今天咱们继续聊前缀树。
|
||||
|
||||
上节结尾我给你留了道思考题:如何实现前缀树的构建和查询?如果你动手尝试之后,你会发现,这个案例的实现没有我们前面讲的那些排列组合这么直观。
|
||||
|
||||
这是因为,从数学的思想,到最终的编程实现,其实需要一个比较长的流程。我们首先需要把问题转化成数学中的模型,然后使用数据结构和算法来刻画数学模型,最终才能落实到编码。
|
||||
|
||||
而在前缀树中,我们需要同时涉及树的结构、树的动态构建和深度优先搜索,这个实现过程相对比较复杂。所以,这节我就给你仔细讲解一下,这个实现过程中需要注意的点。只要掌握这些点,你就能轻而易举实现深度优先搜索。
|
||||
|
||||
## 如何使用数据结构表达树?
|
||||
|
||||
首先,我想问你一个问题,什么样的数据结构可以表示树?
|
||||
|
||||
我们知道,计算机中最基本的数据结构是数组和链表。数组适合快速地随机访问。不过,数组并不适合稀疏的数列或者矩阵,而且数组中元素的插入和删除操作也比较低效。相对于数组,链表的随机访问的效率更低,但是它的优势是,不必事先规定数据的数量,表示稀疏的数列或矩阵时,可以更有效地利用存储空间,同时也利于数据的动态插入和删除。
|
||||
|
||||
我们再来看树的特点。树的结点及其之间的边,和链表中的结点和链接在本质上是一样的,因此,我们可以模仿链表的结构,用编程语言中的指针或对象引用来构建树。
|
||||
|
||||
除此之外,我们其实还可以用二维数组。用数组的行或列元素表示树中的结点,而行和列共同确定了两个树结点之间是不是存在边。可是在树中,这种二维关系通常是非常稀疏的、非常动态的,所以用数组效率就比较低下。
|
||||
|
||||
基于上面这些考虑,我们可以设计一个TreeNode类,表示有向树的结点和边。这个类需要体现前缀树结点最重要的两个属性。
|
||||
|
||||
<li>
|
||||
这个结点所代表的字符,要用label变量表示。
|
||||
</li>
|
||||
<li>
|
||||
这个结点有哪些子结点,要用sons哈希映射表示。之所以用哈希,是为了便于查找某个子结点(或者说对应的字符)是否存在。
|
||||
</li>
|
||||
|
||||
另外,我们还可以用变量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),也就是,最先进入栈的元素最后才会得到处理。我画了一张元素入栈和出栈的过程图,你可以看看。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/53/c1/5391e9f266cb795cec532cc54928b8c1.jpg" alt="">
|
||||
|
||||
为什么栈可以进行深度优先搜索呢?你可以先回顾一下上一节,我解释深度优先搜索时候的例子。为了方便你回想,我把图放在这里了。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/90/b7/900c981c816375268eefdf274cb149b7.jpg" alt="">
|
||||
|
||||
然后,我们用栈来实现一下这个过程。
|
||||
|
||||
第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,没有发现新的结点,也没有更多待处理的结点,整个过程结束。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/89/45/893203ec268e095397966b58e1c71d45.jpg" alt="">
|
||||
|
||||
从上面的步骤来看,栈先进后出的特性,可以模拟函数的递归调用。实际上,计算机系统里的函数递归,在内部也是通过栈来实现的。如果我们不使用函数调用时自动生成的栈,而是手动使用栈的数据结构,就能始终保持数据的副本只有一个,大大节省内存的使用量。
|
||||
|
||||
用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把子结点入栈的顺序颠倒过来。
|
||||
|
||||
## 小结
|
||||
|
||||
这一节我们用递归来实现了深度优先搜索。说到这,你可能会想到,之前讨论的归并排序、排列组合等课题,也采用了递归来实现,那它们是不是也算深度优先搜索呢?
|
||||
|
||||
我把归并排序和排列的分解过程放在这里,它们是不是也可以用有向树来表示呢?
|
||||
|
||||
在归并排序的数据分解阶段,初始的数据集就是树的根结点,二分之前的数据集代表父节点,而二分之后的左半边的数据集和右半边的数据集都是父结点的子结点。分解过程一直持续到单个的数值,也就是最末端的叶子结点,很明显这个阶段可以用树来表示。如果使用递归编程来进行数据的切分,那么这种实现就是深度优先搜索的体现。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/54/12/5410fb301ffce57355ad7ef074e8fd12.jpg" alt="">
|
||||
|
||||
在排列中,我们可以把空集认为是树的根结点,如果把每次选择的元素作为父结点,那么剩下可选择的元素,就构成了这个父结点的子结点。而每多选择一个元素,就会把树的高度加1。因此,我们也可以使用递归和深度优先搜索,列举所有可能的排列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/98/15/98df21876ad52195217709e298707515.jpg" alt="">
|
||||
|
||||
从这两个例子,我们可以看出有些数学思想都是相通的,例如递归、排列和深度优先搜索等等。
|
||||
|
||||
我来总结一下,其实深度优先搜索的核心思想,就是按照当前的通路,不断地向前进,当遇到走不通的时候就回退到上一个结点,通过另一个新的边进行尝试。如果这一个点所有的方向都走不通的时候,就继续回退。这样一次一次循环下去,直到到达目标结点。树中的每个结点,既可以表示某个子问题和它所对应的抽象状态,也可以表示某个数据结构中一部分具体的值。
|
||||
|
||||
所以,我们需要做的是,观察问题是否可以使用递归的方式来逐步简化,或者是否需要像前缀树这样遍历,如果是,就可以尝试使用深度优先搜索来帮助我们思考并解决问题。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ae/c7/aeef1d5f6be9b5d09618a189520055c7.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
这两节我讲的是树的深度优先搜索。如果是在一般的图中进行深度优先搜索,会有什么不同呢?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
217
极客时间专栏/程序员的数学基础课/基础思想篇/13 | 树的广度优先搜索(上):人际关系的六度理论是真的吗?.md
Normal file
217
极客时间专栏/程序员的数学基础课/基础思想篇/13 | 树的广度优先搜索(上):人际关系的六度理论是真的吗?.md
Normal file
@@ -0,0 +1,217 @@
|
||||
<audio id="audio" title="13 | 树的广度优先搜索(上):人际关系的六度理论是真的吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/9a/69/9a94bae0bb713d034b98ff74429e1969.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上一节,我们探讨了如何在树的结构里进行深度优先搜索。说到这里,有一个问题,不知道你有没有思考过,树既然是两维的,我们为什么一定要朝着纵向去进行深度优先搜索呢?是不是也可以朝着横向来进行搜索呢?今天我们就来看另一种搜索机制,广度优先搜索。
|
||||
|
||||
## 社交网络中的好友问题
|
||||
|
||||
LinkedIn、Facebook、微信、QQ这些社交网络平台都有大量的用户。在这些社交网络中,非常重要的一部分就是人与人之间的“好友”关系。
|
||||
|
||||
在数学里,为了表示这种好友关系,我们通常使用图中的结点来表示一个人,而用图中的边来表示人和人之间的相识关系,那么社交网络就可以用图论来表示。而“相识关系”又可以分为单向和双向。
|
||||
|
||||
单向表示,两个人a和b,a认识b,但是b不认识a。如果是单向关系,我们就需要使用有向边来区分是a认识b,还是b认识a。如果是双向关系,双方相互认识,因此直接用无向边就够了。在今天的内容里,我们假设相识关系都是双向的,所以我们今天讨论的都是无向图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/26/52/26d0c89c9d6b968a9cd3fb889663f752.jpg" alt="">
|
||||
|
||||
从上面的例图可以看出,人与人之间的相识关系,可以有多条路径。比如,张三可以直接连接赵六,也可以通过王五来连接赵六。比较这两条通路,最短的通路长度是1,因此张三和赵六是一度好友。也就是说,这里我用两人之间最短通路的长度,来定义他们是几度好友。照此定义,在之前的社交关系示意图中,张三、王五和赵六互为一度好友,而李四和赵六、王五为二度好友。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/3d/cb/3da7b008f235bfde1ca29ff8d66415cb.jpg" alt="">
|
||||
|
||||
寻找两个人之间的最短通路,或者说找出两人是几度好友,在社交中有不少应用。例如,向你推荐新的好友、找出两人之间的关系的紧密程度、职场背景调查等等。在LinkedIn上,有个功能就是向你推荐了你可能感兴趣的人。下面这张图是我的LinkedIn主页里所显示的好友推荐。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/8f/61bfeb62a8d60da3a8aeb5c4b2a32d8f.png" alt="">
|
||||
|
||||
这些被推荐的候选人,和我都有不少的共同连接,也就是共同好友。所以他们都是我的二度好友。但是,他们和我之间还没有建立直接的联系,因此不是一度好友。也就是说,对于某个当前用户,LinkedIn是这么来选择好友推荐的:
|
||||
|
||||
<li>
|
||||
被推荐的人和当前用户不是一度好友;
|
||||
</li>
|
||||
<li>
|
||||
被推荐的人和当前用户是二度好友。
|
||||
</li>
|
||||
|
||||
那为什么我们不考虑“三度“甚至是“四度”好友呢?我前面已经说过,两人之间最短的通路长度,表示他们是几度好友。那么三度或者四度,就意味着两人间最短的通路也要经历2个或更多的中间人,他们的关系就比较疏远,互相添加好友的可能性就大大降低。
|
||||
|
||||
所以呢,总结一下,如果我们想进行好友推荐,那么就要优先考虑用户的“二度“好友,然后才是“三度”或者“四度”好友。那么,下一个紧接着要面临的问题就是:给定一个用户,如何优先找到他的二度好友呢?
|
||||
|
||||
## 深度优先搜索面临的问题
|
||||
|
||||
这种情况下,你可能会想到上一篇介绍的深度优先搜索。深度优先搜索不仅可以用在树里,还可以应用在图里。不过,我们要面临的问题是图中可能存在回路,这会增加通路的长度,这是我们在计算几度好友时所不希望的。所以在使用深度优选搜索的时候,一旦遇到产生回路的边,我们需要将它过滤。具体的操作是,判断新访问的点是不是已经在当前通路中出现过,如果出现过就不再访问。
|
||||
|
||||
如果过滤掉产生回路的边,从一个用户出发,我们确实可以使用深度优先的策略,搜索完他所有的n度好友,然后再根据关系的度数,从二度、三度再到四度进行排序。这是个解决方法,但是效率太低了。为什么呢?
|
||||
|
||||
你也许听说过社交关系的六度理论。这个理论神奇的地方在于,它说地球上任何两个人之间的社交关系不会超过六度。咋一听,感觉不太可能。仔细想想,假设每个人平均认识100个人(我真心不觉得100很多,不信你掰着指头数数看自己认识多少人),那么你的二度好友就是100^2,这个可以用我们前面讲的排列思想计算而来。
|
||||
|
||||
以此类推,三度好友是100^3,到五度好友就有100亿人了,已经超过了地球目前的总人口。即使存在一些好友重复的情况下,例如,你的一度好友可能也出现在你的三度好友中,那这也不可能改变结果的数量级。所以目前来看,地球上任何两个人之间的社会关系不会超过六度。
|
||||
|
||||
六度理论告诉我们,你的社会关系会随着关系的度数增加,而呈指数级的膨胀。这意味着,在深度搜索的时候,每增加一度关系,就会新增大量的好友。但是你仔细回想一下,当我们在用户推荐中查看可能的好友时,基本上不会看完所有推荐列表,最多也就看个几十个人,一般可能也就看看前几个人。所以,如果我们使用深度优先搜索,把所有可能的好友都找到再排序,那效率实在太低了。
|
||||
|
||||
## 什么是广度优先搜索?
|
||||
|
||||
更高效的做法是,我们只需要先找到所有二度的好友,如果二度好友不够了,再去找三度或者四度的好友。这种好友搜索的模式,其实就是我们今天要介绍的广度优先搜索。
|
||||
|
||||
**广度优先搜索**(Breadth First Search),也叫**宽度优先搜索**,是指从图中的某个结点出发,沿着和这个点相连的边向前走,去寻找和这个点距离为1的所有其他点。只有当和起始点距离为1的所有点都被搜索完毕,才开始搜索和起始点距离为2的点。当所有和起始点距离为2的点都被搜索完了,才开始搜索和起始点距离为3的点,如此类推。
|
||||
|
||||
我用上一节介绍深度优先搜索顺序的那棵树,带你看一下广度优先搜索和深度优先搜索,在结点访问的顺序上有什么不一样。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/8f/d4/8feed421333ad442eb8dec4e574eb4d4.jpg" alt="">
|
||||
|
||||
同样,我们用结点上的数字表示结点的ID,用虚线表示遍历前进的方向,用结点边上的数字表示该结点在广度优先搜索中被访问的顺序。从这个图中,你有没有发现,广度优先搜索其实就是横向搜索一颗树啊!
|
||||
|
||||
尽管广度优先和深度优先搜索的顺序是不一样的,它们也有两个共同点。
|
||||
|
||||
第一,在前进的过程中,我们不希望走重复的结点和边,所以会对已经被访问过的点做记号,而在之后的前进过程中,就只访问那些还没有被标记的点。这一点上,广度优先和深度优先是一致的。有所不同的是,在广度优先中,如果发现和某个结点直接相连的点都已经被访问过,那么下一步就会看和这个点的兄弟结点直接相连的那些点,从中看看是不是有新的点可以访问。
|
||||
|
||||
例如,在上图中,访问完结点945的两个子结点580和762之后,广度优先策略发现945没有其他的子结点了,因此就去查看945的兄弟结点131,看看它有哪些子结点可以访问,因此下一个被访问的点是906。而在深度优先中,如果到了某个点,发现和这个点直接相连的所有点都已经被访问过了,那么不会查看它的兄弟结点,而是回退到这个点的父节点,继续查看和父结点直接相连的点中是不是存在新的点。例如在上图中,访问完结点945的两个子结点之后,深度优先策略会回退到点110,然后访问110的子结点131。
|
||||
|
||||
第二,广度优先搜索也可以让我们访问所有和起始点相通的点,因此也被称为广度优先遍历。如果一个图包含多个互不连通的子图,那么从起始点开始的广度优先搜索只能涵盖其中一个子图。这时,我们就需要换一个还没有被访问过的起始点,继续广度优先遍历另一个子图。广度优先搜索可以使用同样的方式来遍历有多个连通子图的图,这也回答了上一讲的思考题。
|
||||
|
||||
## 如何实现社交好友推荐?
|
||||
|
||||
第12讲中我说深度优先是利用递归的嵌套调用、或者是栈的数据结构来实现的。然而,广度优先的访问顺序是不一样的,我们需要优先考虑和某个给定结点距离为1的所有其他结点。等距离为1的结点访问完,才会考虑距离为2的结点。等距离为2的结点访问完,才会考虑距离为3的结点等等。在这种情况下,我们无法不断地根据结点的边走下去,而是要先遍历所有距离为1的点。
|
||||
|
||||
那么,如何在记录所有已被发现的结点情况下,优先访问距离更短的点呢?仔细观察,你会发现和起始点更近的结点,会先更早地被发现。也就是说,越早被访问到的结点,越早地处理它,这是不是很像我们平时排队的情形?早到的人可以优先接受服务,而晚到的人需要等前面的人都离开,才能轮到。所以这里我们需要用到队列这种先进先出(First In First Out)的数据结构。
|
||||
|
||||
如果你不是很熟悉队列的数据结构,我这里简短地回顾一下。队列是一种线性表,要被访问的下一个元素来自队列的头部,而所有新来的元素都会加入队列的尾部。
|
||||
|
||||
我画了张图给你讲队列的工作过程。首先,读取已有元素的时候,都是从队列的头部来取,例如$x_{1}$,$x_{2}$等等。所有新的元素都加入队列的尾部,例如$x_{m}$,$x_{m+1}$。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b2/a1/b2962dffa28a5c2f3b51bd6039242da1.jpg" alt="">
|
||||
|
||||
那么在广度优先搜索中,队列是如何工作的呢?这主要分为以下几个步骤。
|
||||
|
||||
首先,把初始结点放入队列中。然后,每次从队列首位取出一个结点,搜索所有在它下一级的结点。接下来,把新发现的结点加入队列的末尾。重复上述的步骤,直到没有发现新的结点为止。
|
||||
|
||||
我以上面的树状图为例,并通过队列实现广度优先搜索。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/bf/90/bf8188bd3eccee22dcdda1b2a939c790.jpg" alt="">
|
||||
|
||||
第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,没有发现新的结点,也没有更多待处理的结点,整个过程结束。
|
||||
|
||||
理解了如何使用队列来实现广度优先搜索之后,我们就可以开始着手编写代码。我们现在没有现成的用户关系网络数据,所以我们需要先模拟生成一些用户结点及其间的相识关系,然后利用队列的数据结构进行广度优先的搜索。基于此,主要使用的数据结构包括:
|
||||
|
||||
<li>
|
||||
**用户结点Node**。这次设计的用户结点和前缀树结点TreeNode略有不同,包含了用户的ID user_id,以及这个用户的好友集合。我用HashSet实现,便于在生成用户关系图的时候,确认是否会有重复的好友。
|
||||
</li>
|
||||
<li>
|
||||
表示整个图的**结点数组Node[]**。由于每个用户使用user_id来表示,所以我可以使用连续的数组表示所有的用户。用户的user_id就是数组的下标。
|
||||
</li>
|
||||
<li>
|
||||
**队列Queue**。由于Java中Queue是一个接口,因此需要用一个拥有具体实现的LinkedList类。
|
||||
</li>
|
||||
|
||||
首先我们列出结点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));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
需要注意的是,这里用户结点之间的边是随机生成的,所以每次结果会有所不同。如果想重现固定的结果,可以从某个文件加载用户之间的关系。
|
||||
|
||||
## 小结
|
||||
|
||||
在遍历树或者图的时候,如果使用深度优先的策略,被发现的结点数量可能呈指数级增长。如果我们更关心的是最近的相连结点,比如社交关系中的二度好友,那么这种情况下,广度优先策略更高效。也正是由于这种特性,我们不能再使用递归编程或者栈的数据结构来实现广度优先,而是需要用到具有先进先出特点的队列。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/2b/40/2b985dc5dba11a41d968cca57254d640.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
在计算机的操作系统中,我们常常需要查看某个目录下的文件或子目录。现在给定一个目录的路径,请分别使用深度优先和广度优先搜索,列出该目录下所有的文件和子目录。对于子目录,需要进一步展示其下的文件和子目录,直到没有更多的子目录。
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
189
极客时间专栏/程序员的数学基础课/基础思想篇/14 | 树的广度优先搜索(下):为什么双向广度优先搜索的效率更高?.md
Normal file
189
极客时间专栏/程序员的数学基础课/基础思想篇/14 | 树的广度优先搜索(下):为什么双向广度优先搜索的效率更高?.md
Normal file
@@ -0,0 +1,189 @@
|
||||
<audio id="audio" title="14 | 树的广度优先搜索(下):为什么双向广度优先搜索的效率更高?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/70/52/70736331b267b11a5d746fb0ad6a8552.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
上一讲,我们通过社交好友的关系,介绍了为什么需要广度优先策略,以及如何通过队列来实现它。有了广度优先搜索,我们就可以知道某个用户的一度、二度、三度等好友是谁。不过,在社交网络中,还有一个经常碰到的问题,那就是给定两个用户,如何确定他们之间的关系有多紧密?
|
||||
|
||||
最直接的方法是,使用这两人是几度好友来衡量他们关系的紧密程度。今天,我就这个问题,来聊聊广度优先策略的一种扩展:双向广度优先搜索,以及这种策略在工程中的应用。
|
||||
|
||||
## 如何更高效地求两个用户间的最短路径?
|
||||
|
||||
基本的做法是,从其中一个人出发,进行广度优先搜索,看看另一个人是否在其中。如果不幸的话,两个人相距六度,那么即使是广度优先搜索,同样要达到万亿级的数量。
|
||||
|
||||
那究竟该如何更高效地求得两个用户的最短路径呢?我们先看看,影响效率的问题在哪里?很显然,随着社会关系的度数增加,好友数量是呈指数级增长的。所以,如果我们可以控制这种指数级的增长,那么就可以控制潜在好友的数量,达到提升效率的目的。
|
||||
|
||||
如何控制这种增长呢?我这里介绍一种“**双向广度优先搜索**”。它巧妙地运用了两个方向的广度优先搜索,大幅降低了搜索的度数。现在我就带你看下,这个方法的核心思想。
|
||||
|
||||
假设有两个人$a$、$b$。我们首先从$a$出发,进行广度优先搜索,记录$a$的所有一度好友$a_{1}$,然后看点$b$是否出现在集合$a_{1}$中。如果没有,就再从$b$出发,进行广度优先搜索,记录所有一度好友$b_{1}$,然后看$a$和$a_{1}$是否出现在$b$和$b_{1}$的并集中。如果没有,就回到$a$,继续从它出发的广度优先搜索,记录所有二度好友$a_{2}$,然后看$b$和$b_{1}$是否出现在$a$、$a_{1}$和$a_{2}$三者的并集中。如果没有,就回到$b$,继续从它出发的广度优先搜索。如此轮流下去,直到找到$a$的好友和$b$的好友的交集。
|
||||
|
||||
如果有交集,就表明这个交集里的点到$a$和$b$都是通路。我们假设$c$在这个交集中,那么把$a$到$c$的通路长度和$b$到$c$的通路长度相加,得到的就是从$a$到$b$的最短通路长(这个命题可以用反证法证明),也就是两者为几度好友。这个过程略有点复杂,我画了一张图帮助你来理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/b6/0e/b665fdbd81e7d0fb3245fbcd3b21230e.jpg" alt="">
|
||||
|
||||
思路你应该都清楚了,现在我们来看看如何用代码来实现。
|
||||
|
||||
要想实现双向广度优先搜索,首先我们要把结点类Node稍作修改,增加一个变量degrees。这个变量是HashMap类型,用于存放从不同用户出发,到当前用户是第几度结点。比如说,当前结点是4,从结点1到结点4是3度,结点2到结点4是2度,结点3到结点4是4度,那么结点4的degrees变量存放的就是如下映射:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/e2/47/e27d792a83dbe325ad5d0432910ffb47.png" alt="">
|
||||
|
||||
有了变量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,给你举例讲解。
|
||||
|
||||
左边的图表示从结点$a$单向搜索走2步,右边的图表示分别从结点$a$和$b$双向搜索各走1步。很明显,左边的结点有16个,明显多于右边的8个结点。而且,随着每人认识的好友数、搜索路径的增加,这种差距会更加明显。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/15/5b/1518aaa073b379b20ba3dca8dde08d5b.jpg" alt="">
|
||||
|
||||
我们假设每个地球人平均认识100个人,如果两个人相距六度,单向广度优先搜索要遍历100^6=1万亿左右的人。如果是双向广度优先搜索,那么两边各自搜索的人只有100^3=100万。
|
||||
|
||||
当然,你可能会说,单向广度优先搜索之后查找匹配用户的开销更小啊。的确如此,假设我们要知道结点$a$和$b$之间的最短路径,单向搜索意味着要在$a$的1万亿个好友中查找$b$。如果采用双向搜索的策略,从结点$a$和$b$出发进行广度优先搜索,每个方向会产生100万的好友,那么需要比较这两组100万的好友是否有交集。假设我们使用哈希表来存储$a$的1万亿个好友,并把搜索$b$是否存在其中的耗时记作x,而把判断两组100万好友是否有交集的耗时记为y,那么通常x<y。
|
||||
|
||||
不过,综合考虑广度优先搜索出来的好友数量,双向广度优先搜索还是更有效。为什么这么说呢?稍后介绍算法复杂度的概念和衡量方法时,我会具体来分析这个例子。
|
||||
|
||||
广度优先搜索的应用场景有很多,下面我来说说这种策略的一个应用。
|
||||
|
||||
## 如何实现更有效的嵌套型聚合?
|
||||
|
||||
广度优先策略可以帮助我们大幅优化数据分析中的聚合操作。聚合是数据分析中一个很常见的操作,它会根据一定的条件把记录聚集成不同的分组,以便我们统计每个分组里的信息。目前,SQL语言中的GROUP BY语句,Python和Spark语言中data frame的groupby函数,Solr的facet查询和Elasticsearch的aggregation查询,都可以实现聚合的功能。
|
||||
|
||||
我们可以嵌套使用不同的聚合,获得层级型的统计结果。但是,实际上,针对一个规模超大的数据集,聚合的嵌套可能会导致性能严重下降。这里我来谈谈如何利用广度优先的策略,对这种问题进行优化。
|
||||
|
||||
首先,我用一个具体的例子来给你讲讲,什么是多级嵌套的聚合,以及为什么它会产生严重的性能问题。
|
||||
|
||||
这里我列举了一个数据表,它描述了一个社交网络中,每个人的职业经历。字段包括项目的ID、用户ID、公司ID和同事的IDs。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/82/02/8216d39c6bdc3925e5ea139071a41202.png" alt="">
|
||||
|
||||
对于这张表,我们可以进行三层嵌套的聚集。第一级是根据用户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万计数器。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/61/d8/61dc9b211bf5f7e4f33bf24b1dba9cd8.jpg" alt="">
|
||||
|
||||
我们假设一个计数器是4个字节,那么280万个计数器就需要消耗超过10M的内存。对于高并发、低延迟的实时性服务,如果每个请求都要消耗10M内存,很容易就导致服务器崩溃。另外,实时性的服务,往往只需要前若干个结果就足以满足需求了。在这种情况下,完全基于排列的设计就有优化的空间了。
|
||||
|
||||
从刚才那张图中,其实我们就能想到一些优化的思路。
|
||||
|
||||
对于只需要返回前若干结果的应用场景,我们可以对图中的树状结构进行剪枝,去掉绝大部分不需要的结点和边,这样就能节省大量的内存和CPU计算。
|
||||
|
||||
比如,如果我们只需要返回前100个参与项目最多的用户,那么就没有必要按照深度优先的策略,去扩展树中高度为2和3的结点了,而是应该使用广度优先策略,首先找出所有高度为1的结点,根据项目数量进行排序,然后只取出前100个,把计数器的数量从5万个一下子降到100个。
|
||||
|
||||
以此类推,我们还可以控制高度为2和3的结点之数量。如果我们只要看前100位用户,每位用户只看排名第一的公司,而每家公司只看合作最多的3名同事,那么最终计数器数量就只有50000+100x5+100x1x10=51500。只有文字还是不太好懂,我画了一张图,帮你理解这个过程。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/81/d6/8183dff98d2f84b053ca103cb26566d6.jpg" alt=""><img src="https://static001.geekbang.org/resource/image/86/25/86e89d1df417e7e1d364e1416855b625.jpg" alt="">
|
||||
|
||||
如果一个项目用到排列组合的思想,我们需要在程序里使用大量的变量,来保存数据或者进行计算,这会导致内存和CPU使用量的急剧增加。在允许的情况下,我们可以考虑使用广度优先策略,对排列组合所生成的树进行优化。这样,我们就可以有效地缩减树中靠近根的结点数量,避免之后树的爆炸性生长。
|
||||
|
||||
## 小结
|
||||
|
||||
广度优先搜索,相对于深度优先搜索,没有函数的嵌套调用和回溯操作,所以运行速度比较快。但是,随着搜索过程的进行,广度优先需要在队列中存放新遇到的所有结点,因此占用的存储空间通常比深度优先搜索多。
|
||||
|
||||
相比之下,深度优先搜索法只保留用于回溯的结点,而扩展完的结点会从栈中弹出并被删除。所以深度优先搜索占用空间相对较少。不过,深度优先搜索的速度比较慢,而并不适合查找结点之间的最短路径这类的应用。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/d7/64/d735ed146cac3ca1f81df5acbe634664.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
今天所说的双向广度优先比单向广度优先更高效,其实是要基于一个前提条件的。你能否说出,在什么情况下,单向广度优先更高效呢?针对这种情况,又该如何优化双向广度优先呢?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
162
极客时间专栏/程序员的数学基础课/基础思想篇/15 | 从树到图:如何让计算机学会看地图?.md
Normal file
162
极客时间专栏/程序员的数学基础课/基础思想篇/15 | 从树到图:如何让计算机学会看地图?.md
Normal file
@@ -0,0 +1,162 @@
|
||||
<audio id="audio" title="15 | 从树到图:如何让计算机学会看地图?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/2f/c1/2fa2a7f2379a3c77895a9ed5210f4cc1.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
我们经常使用手机上的地图导航App,查找出行的路线。那计算机是如何在多个选择中找到最优解呢?换句话说,计算机是如何挑选出最佳路线的呢?
|
||||
|
||||
前几节,我们讲了数学中非常重要的图论中的概念,图,尤其是树中的广度优先搜索。在广度优先的策略中,因为社交网络中的关系是双向的,所以我们直接用无向边来求解图中任意两点的最短通路。
|
||||
|
||||
这里,我们依旧可以用图来解决这个问题,但是,影响到达最终目的地的因素有很多,比如出行的交通工具、行驶的距离、每条道路的交通状况等等,因此,我们需要赋予到达目的地的每条边不同的权重。而我们想求的最佳路线,其实就是各边权重之和最小的通路。
|
||||
|
||||
我们前面说了,广度优先搜索只测量通路的长度,而不考虑每条边上的权重。那么广度优先搜索就无法高效地完成这个任务了。那我们能否把它改造或者优化一下呢?
|
||||
|
||||
我们需要先把交通地图转为图的模型。图中的每个结点表示一个地点,每条边表示一条道路或者交通工具的路线。其中,边是有向的,表示单行道等情况。其次,边是有权重的。
|
||||
|
||||
假设你关心的是路上所花费的时间,那么权重就是从一点到另一点所花费的时间;如果你关心的是距离,那么权重就是两点之间的物理距离。这样,我们就把交通导航转换成图论中的一个问题:在边有权重的图中,如何让计算机查找最优通路?
|
||||
|
||||
## 基于广度优先或深度优先搜索的方法
|
||||
|
||||
我们以寻找耗时最短的路线为例来看看。
|
||||
|
||||
一旦我们把地图转换成了图的模型,就可以运用广度优先搜索,计算从某个出发点,到图中任意一个其他结点的总耗时。基本思路是,从出发点开始,广度优先遍历每个点,当遍历到某个点的时候,如果该点还没有耗时的记录,记下当前这条通路的耗时。如果该点之前已经有耗时记录了,那就比较当前这条通路的耗时是不是比之前少。如果是,那就用当前的替换掉之前的记录。
|
||||
|
||||
实际上,地图导航和之前社交网络最大的不同在于,每个结点被访问了一次还是多次。在之前的社交网络的案例中,使用广度优先策略时,对每个结点的首次访问就能获得最短通路,因此每个结点只需要被访问一次,这也是为什么广度优先比深度优先更有效。
|
||||
|
||||
而在地图导航的案例中,从出发点到某个目的地结点,可能有不同的通路,也就意味着耗时不同。而耗时是通路上每条边的权重决定的,而不是通路的长度。因此,为了获取达到某个点的最短时间,我们必须遍历所有可能的路线,来取得最小值。这也就是说,我们对某些结点的访问可能有多次。
|
||||
|
||||
我画了一张图,方便你理解多条通路对最终结果的影响。这张图中有A、B、C、D、E五个结点,分别表示不同的地点。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c1/b2/c155d6b1babe59e0c42928337686f9b2.jpg" alt="">
|
||||
|
||||
从这个图中可以看出,从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包含了图的所有结点,也就是说,没有结点需要处理了。
|
||||
|
||||
字面描述有些抽象,我用一个具体的例子来解释一下。你可以看我画的这个图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/79/aa/796ad4473319311570c64646875370aa.jpg" alt="">
|
||||
|
||||
我们把结点s放入集合F。同s直接相连的结点有a、b、c和d,我把它们的mw更新为w数组中的值,就可以得到如下结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/85/7e/85d93aaa912327053fa18157f02c067e.png" alt="">
|
||||
|
||||
然后,我们从mw选出最小的值0.2,把对应的结点c加入集合F,并更新和c直接相连的结点f、h的mw值,得到如下结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7a/bf/7abb384727aa14482c5c1a99e841fabf.png" alt="">
|
||||
|
||||
然后,我们从mw选出最小的值0.3,把对应的结点b加入集合F,并更新和b直接相连的结点a和f的mw值。以此逐步类推,可以得到如下的最终结果:
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ac/46/acfbc2ef21b572cb0b7d1a8ea248d346.png" alt="">
|
||||
|
||||
你可以试着自己从头到尾推导一下,看看结果是不是和我的一致。
|
||||
|
||||
说到这里,你可能会产生一个疑问:Dijkstra算法提前把一些结点排除在计算之外,而且没有遍历全部可能的路径,那么它是如何确保找到最优路径的呢?下面,我们就来看看这个问题的答案。Dijkstra算法的步骤看上去有点复杂,不过其中最关键的两步是:第一个是每次选择最小的mw;第二个是,假设被选中的最小mw,所对应的结点是x,那么查看和x直接相连的结点,并更新它们的mw。
|
||||
|
||||
### 2.为什么每次都要选择最小的mw?
|
||||
|
||||
最小的、非无穷大的mw值,对应的结点是还没有加入F集合的、且和s有通路的那些结点。假设当前mw数组中最小的值是mw[x],对应的结点是x。如果边的权重都是正值,那么通路上的权重之和是单调递增的,所以其他通路的权重之和一定大于当前的mw[x],因此即使存在其他的通路,其权重也会比mw[x]大。
|
||||
|
||||
你可以结合这个图,来理解我刚才这段话。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/c6/d3/c6d1365f398861d997a8b443a6fdfbd3.jpg" alt="">
|
||||
|
||||
图中的虚线表示省去了通路中间的若干结点。mw[x]是当前mw数组中的最小值,所以它小于等于任何一个mw[xn],其中xn不等于x。
|
||||
|
||||
我们假设存在另一个通路,通过$x_{n}$达到x,那么通路的权重总和为mw[$x_{n}$] + w[$x_{n}$, x] ≥ mw[$x_{n}$] ≥ mw[x]。所以我们可以得到一个结论:拥有最小mw值的结点x不可能再找到更小的mw值,可以把它放入“已完成“的集合F。
|
||||
|
||||
这就是为什么每次都要选择最小的mw值,并认为对应的结点已经完成了计算。和广度优先或者深度优先的搜索相比,Dijkstra算法可以避免对某些结点,重复而且无效的访问。因此,每次选择最小的mw,就可以提升了搜索的效率。
|
||||
|
||||
### 3.为什么每次都要看x直接相连的结点?
|
||||
|
||||
我们已经确定mw[x]是从点s到点x的最小权重,那么就可以把这个确定的值传播到和x直接相连、而且不在F中的结点。通过这一步,我们就可以获得从点s到这些点、而且经过x的通路中最小的那个权重。我画了张图帮助你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/ec/f2/ec5b72711eba1bf5c4e2f9c03beedaf2.jpg" alt="">
|
||||
|
||||
在这个图中,x直接相连$y_{1}$,$y_{2}$,…,$y_{n}$。从点s到点x的mw[x]已经确定了,那么对于从s到yn的所有通路,只有两种可能,经过x和不经过x。如果这条通路经过x,那么其权重的最小值就是mw’[$y_{i}$] = mw[x] + w[x, $y_{i}$]中的一个(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个结点中的一员。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/59/b1/59dfee4aefe052d5a7da2cea7bf20cb1.jpg" alt="">
|
||||
|
||||
我们先来看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,分别是$z_{1}$, $z_{2}$, …,$z_{m}$,从s到x的通路必定会经过这m个z结点中的一个。Dijkstra算法中找最小mw的步骤,一定会遍历mw[$z_{i}$](1<=i<=m),而更新权重的步骤,可以并保证从(mw[$z_{i}$] + w[$z_{i}$, x])中找出最小值,最终找到从s到x的最优通路。
|
||||
|
||||
有了详细的推导,想要写出代码就不难了。我这里只给你说几点需要注意的地方。
|
||||
|
||||
在自动生成图的函数中,你需要把广度优先搜索的相应代码做两处修改。第一,现在边是有向的了,所以生成的边只需要添加一次;第二,要给边赋予一个权重值,例如可以把边的权重设置为[0,1.0)之间的float型数值。
|
||||
|
||||
为了更好地模块化,你可以实现两个函数:findGeoWithMinWeight和updateWeight。它们分别对应于我之前提到的最重要的两步:每次选择最小的mw;更新和x直接相连的结点之mw。
|
||||
|
||||
每次查找最小mw的时候,我们需要跳过已经完成的结点,只考虑那些不在F集合中的点。这也是Dijkstra算法比较高效的原因。此外,如果你想输出最优路径上的每个结点,那么在updateWeight函数中就要记录每个结点的前驱结点。
|
||||
|
||||
如果你能跟着我进行一步步的推导,并且手写代码进行练习,相信你对Dijkstra算法会有更深刻的印象。
|
||||
|
||||
## 小结
|
||||
|
||||
我们使用Dijkstra算法来查找地图中两点之间的最短路径,而今天我所介绍的Dijkstra使用了更为抽象的“权重”。如果我们把结点作为地理位置,边的权重设置为路上所花费的时间,那么Dijkstra算法就能帮助我们找到,任意两个点之间耗时最短的路线。
|
||||
|
||||
除了时间之外,你也可以对图的边设置其他类型的权重,比如距离、价格,这样Dijkstra算法可以让用户找到地图任意两点之间的最短路线,或者出行的最低价格等等。有的时候,边的权重越大越好,比如观光车开过某条路线的车票收入。对于这种情况,Dijkstra算法就需要调整一下,每次找到最大的mw,更新邻近结点时也要找更大的值。所以,你只要掌握核心的思路就可以了,具体的实现可以根据情况去灵活调整。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/48/9f/48b3029c0e5310bf9b5f2e5564463a9f.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
今天的思考题和地图数据的特殊情况有关。
|
||||
|
||||
<li>
|
||||
如果边的权重是负数,我们还能用今天讲的Dijkstra算法吗?
|
||||
</li>
|
||||
<li>
|
||||
如果地图中存在多条最优路径,也就是说多条路径的权重和都是相等的,那么我刚刚介绍的Dijkstra算法应该如何修改呢?
|
||||
</li>
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
139
极客时间专栏/程序员的数学基础课/基础思想篇/16 | 时间和空间复杂度(上):优化性能是否只是“纸上谈兵”?.md
Normal file
139
极客时间专栏/程序员的数学基础课/基础思想篇/16 | 时间和空间复杂度(上):优化性能是否只是“纸上谈兵”?.md
Normal file
@@ -0,0 +1,139 @@
|
||||
<audio id="audio" title="16 | 时间和空间复杂度(上):优化性能是否只是“纸上谈兵”?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/d8/41/d873a9c62d68b3a006f3be3b43011641.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
作为程序员,你一定非常清楚复杂度分析对编码的重要性。计算机系统从最初的设计、开发到最终的部署,要经过很多的步骤,而影响系统性能的因素有很多。我把这些因素分为三大类:**算法理论上的计算复杂度**、**开发实现的方案**和**硬件设备的规格**。
|
||||
|
||||
如果将整个系统的构建比作生产汽车,那么计算复杂度相当于在蓝图设计阶段,对整个汽车的性能进行评估。如果我们能够进行准确的复杂度分析,那么就能从理论上预估汽车的各项指标,避免生产出一辆既耗油又开得很慢的汽车。
|
||||
|
||||
可是,你也常常会发现,要准确地分析复杂度并不容易。这一讲,我来说说如何使用数学的思维,来进行系统性的复杂度分析。
|
||||
|
||||
## 基本概念
|
||||
|
||||
我先带你简短回顾一下几个重要概念,便于你稍后更好地理解本节的内容。
|
||||
|
||||
**算法复杂度**是一个比较抽象的概念,通常只是一个估计值,它用于衡量程序在运行时所需要的资源,用于比较不同算法的性能好坏。同一段代码处理不同的输入数据所消耗的资源也可能不同,所以分析复杂度时,需要考虑三种情况,最差情况、最好情况和平均情况。
|
||||
|
||||
复杂度分析会考虑性能的各个方面,不过我们最关注的是两个部分,时间和空间。时间因素是指程序执行的耗时多少,空间因素是程序占用内存或磁盘存储的多少。因此,我们把复杂度进一步细分为时间复杂度和空间复杂度。
|
||||
|
||||
我们通常所说的时间复杂度是指**渐进时间复杂度**,表示程序运行时间随着问题复杂度增加而变化的规律。同理,空间复杂度是指**渐进空间复杂度**,表示程序所需要的存储空间随着问题复杂度增加而变化的规律。我们可以使用大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<sup>3</sup>,时间复杂度是O(n<sup>3</sup>)。对应加法和乘法,分别是减法和除法。如果去掉平行的代码,就减掉相应的时间复杂度。如果去掉嵌套内的循环或函数,就除去相应的时间复杂度。
|
||||
|
||||
对于空间复杂度,同样如此。需要注意的是,空间复杂度看的是对内存空间的使用,而不是计算的次数。如果语句中没有新开辟空间,那么无论是平行增加还是嵌套增加代码,都不会增加空间复杂度。
|
||||
|
||||
### 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<sup>2</sup>))简化为O(n<sup>2</sup>)。
|
||||
|
||||
注意,这个法则对于乘法或除法并不适用,因为乘法或除法会改变参与运算的复杂度的数量级。
|
||||
|
||||
### 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)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a3/20/a32499c23229ae55d9049e89e756a420.jpg" alt="">
|
||||
|
||||
在数据归并阶段,我们看二叉树的高度,为log<sub>2</sub>n,因此归并的次数为log<sub>2</sub>n。另外,无论数组被细分成多少个小的部分,每次归并都需要扫描整个长度为n的数组,因此归并阶段的时间复杂度为nlog<sub>2</sub>n。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/f2/9a/f2385f58fa03dba21cab1ba0e26f4d9a.jpg" alt="">
|
||||
|
||||
两个阶段加起来的时间复杂度为O(n-1)+nlog<sub>2</sub>n,最终简化为nlogn。是不是很直观?
|
||||
|
||||
我再放出我们之前讲二分查找所用的图,你可以结合这个例子进一步理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7e/90/7e0de8eefdaf43cbb62291e074c93e90.png" alt="">
|
||||
|
||||
当然,除了图论,很多简单的图表也能帮助到我们的分析。
|
||||
|
||||
例如,在使用动态规划法的时候,我们经常要画出状态转移的表格。看到这类表格,我们可以很容易地得出该算法的时间复杂度和空间复杂度。以编辑距离为例,参看下面这个示例的图表,我们可以发现每个单元格都对应了3次计算,以及一个存储单元,而总共的单元格数量为m*n,m为第一个字符串的长度,n为第二个字符串的长度。所以,我们很快就能得出这种算法的时间复杂度为O(3m*n),简写为O(m*n),空间复杂度为O(m*n)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/a4/fe/a42623209645217d04637f122d793efe.png" alt="">
|
||||
|
||||
### 6.时空互换法则
|
||||
|
||||
在给定的计算量下,通常时间复杂度和空间复杂度呈现数学中的反比关系。这就说明,如果我们没法降低整体的计算量,那么也许可以通过增加空间复杂度来达到降低时间复杂度的目的,或者反之,通过增加时间复杂度来降低空间复杂度。
|
||||
|
||||
对于这个规则最直观的例子就是缓存系统。在没有缓存系统的时候,每次请求都要服务器来处理,因此时间复杂度比较高。如果使用了缓存系统,那么我们会消耗更多的内存空间,但是降低了请求相应的时间。
|
||||
|
||||
说到这,你也许会问,在使用广度优先策略优化聚合操作的时候,无论是时间还是空间复杂度,都大幅降低了啊?请注意,这里时空互换法则有个前提条件,就是计算量固定。而聚合操作的优化,是利用了广度优先的特点,大幅减少了整体的计算量,因此可以保证时间和空间复杂度都得到降低。
|
||||
|
||||
## 小结
|
||||
|
||||
时间复杂度和空间复杂度的概念,你一定不陌生。可是,在实际运用中,你可能就会发现复杂度分析并不是那么简单。这一节我通过个人的一些经验,从数学思维的角度出发,总结了几条常用的法则,对你会有所帮助。
|
||||
|
||||
这些总结可能还是过于抽象,下一讲中,我会通过几个案例分析,来讲讲如何使用这些法则。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/af/b1/af160564e13906e8ed99e127df4830b1.jpg" alt="">
|
||||
|
||||
## 思考题
|
||||
|
||||
请尝试使用本次介绍的规则,分析一下双向广度优先搜索的时间和空间复杂度。
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
106
极客时间专栏/程序员的数学基础课/基础思想篇/17 | 时间和空间复杂度(下):如何使用六个法则进行复杂度分析?.md
Normal file
106
极客时间专栏/程序员的数学基础课/基础思想篇/17 | 时间和空间复杂度(下):如何使用六个法则进行复杂度分析?.md
Normal file
@@ -0,0 +1,106 @@
|
||||
<audio id="audio" title="17 | 时间和空间复杂度(下):如何使用六个法则进行复杂度分析?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/31/49/31819e46a836ab115b8dd00a9e92ce49.mp3"></audio>
|
||||
|
||||
你好,我是黄申,今天我们接着聊复杂度分析的实战。
|
||||
|
||||
上一讲,我从数学的角度出发,结合自身经验给你总结了几个分析复杂度的法则。但是在实际工作中我们会碰到很多复杂的问题,这个时候,正确地运用这些法则并不是件容易的事。今天,我就结合几个案例,教你一步步使用这几个法则。
|
||||
|
||||
## 案例分析一:广度优先搜索
|
||||
|
||||
在有关图遍历的专栏中,我介绍了单向广度优先和双向广度优先搜索。当时我提到了通常情况下,双向广度优先搜索性能更好。那么,我们应该如何从理论上分析,谁的效率更高呢?
|
||||
|
||||
首先我们来看单向广度优先搜索。我们先快速回顾一下搜索的主要步骤。
|
||||
|
||||
第一步,判断边界条件,时间和空间复杂度都是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之外的第二个关键因素。
|
||||
|
||||
如果你觉得还是不太好理解,可以使用**一图千言法则**,我画了一张图来帮助你理解。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/67/35/6752724f60af35508e1e71be50b91935.jpg" alt="">
|
||||
|
||||
我们再来看这个步骤的空间复杂度。通过代码你应该可以看出来,只有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)。基于这些,我们重新写一下双向广度优先搜索的时间复杂度。
|
||||
|
||||
假设我们分别从$a$点和$b$点出发。从$a$点出发,找到m个一度连接点$a_{1}$,时间复杂度O(m),然后查看$b$是否在这m个结点中,时间复杂度是O(1)。然后从$b$点出发,找到m个一度连接点$b_{1}$,时间复杂度O(m),然后查看$a$和$a_{1}$是不是在$b$和$b_{1}$中,时间复杂度是O(m+1),简写为O(m)。从$a$点继续推进到第二度的结点$a_{2}$,这个时候$a$、$a_{1}$和$a_{2}$的并集的数量已经有1+m+m^2,而$b$和$b_{1}$的并集数量只有1+m,因此,针对$b$和$b_{1}$的集合进行循环更高效一些,时间复杂度是O(m)。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/5e/81/5e1d92f9a34d267fba7139aff29ffa81.jpg" alt="">
|
||||
|
||||
逐步递推下去,我们可以得到下面这个式子:
|
||||
|
||||
```
|
||||
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),数量级一下子就增加了。
|
||||
|
||||
为了降低搜索引擎在查询时候的时间复杂度,我们要引入倒排索引(或逆向索引),这就是典型的牺牲空间来换取时间。如果你对倒排索引的概念不熟悉,我打个比方给你解释一下。
|
||||
|
||||
假设你是一个热爱读书的人,当你进入图书馆或书店的时候,怎样快速找到自己喜爱的书籍?没错,就是看书架上的标签。如果看到一个架子上标着“极客时间 - 数学专栏”,那么恭喜你,离程序员的数学书就不远了。而倒排索引做的就是“贴标签”的事情。
|
||||
|
||||
为了实现倒排索引,对于每篇文章我们都要先进行分词,然后将分好的词作为该篇的标签。让我们看看下面三篇样例文章和对应的分词,也就是标签。其中,分词之后,我也做了一些标准化的处理,例如全部转成小写、去掉时态等。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/18/33/188f28947e82254de3a6bb8e5b022a33.png" alt="">
|
||||
|
||||
上面这个表格看上去并没有什么特别。好,体现“倒排”的时刻来了。我们转换一下,不再从文章的角度出发,而是从标签的角度出发来看问题。也就是说,从每个标签,我们能找到哪些文章?通过这样的思考,我们可以得到下面这张表。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/7d/f8/7d4f5ee599916f96aad9910ab10a0bf8.png" alt="">
|
||||
|
||||
你看看,有了这张表格,想知道查找某个关键词在哪些文章中出现,是不是很容易了呢?整个过程就像在哈希表中查找一样,时间复杂度只有O(1)了。当然,我们所要付出的成本就是倒排索引这张表。假设有n个不同的单词,而每个单词所对应的文章平均数为m的话,那么这种索引的空间复杂度就是O(n*m)。好在n和m通常不会太大,对内存和磁盘的消耗都是可以接受的。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我分析了两个复杂度的案例,并在其中穿插了6个法则的运用和讲解。随着项目经验的累积,你会发现复杂度分析是个很有趣,也很有成就感的事情。更重要的是,它可以告诉我们哪些方法是可行的,哪些是不可行的,避免不必要的资源浪费。这里资源浪费可能是硬件资源的浪费,也有可能是开发资源的浪费。这些法则中的数学思想并不高深,却可以帮我们有效地分析复杂度,运筹帷幄于帐中,决胜于千里之外。
|
||||
|
||||
## 思考题
|
||||
|
||||
在你日常的工作中,有没有经历过性能分析相关的项目?如果有,你都使用了哪些方法来分析问题的症结?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
112
极客时间专栏/程序员的数学基础课/基础思想篇/18 | 总结课:数据结构、编程语句和基础算法体现了哪些数学思想?.md
Normal file
112
极客时间专栏/程序员的数学基础课/基础思想篇/18 | 总结课:数据结构、编程语句和基础算法体现了哪些数学思想?.md
Normal file
@@ -0,0 +1,112 @@
|
||||
<audio id="audio" title="18 | 总结课:数据结构、编程语句和基础算法体现了哪些数学思想?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/af/27/af5f63bac44f6858edae2f7fe5c77127.mp3"></audio>
|
||||
|
||||
你好,我是黄申。
|
||||
|
||||
之前的17讲,我们从小处着眼,介绍了离散数学中最常用的一些知识点。我讲到了很多**数据结构**、**编程语句**和**基础性算法**。这些知识点看似是孤立的,但是内部其实有很多联系。今天这一节,我们就来总结一下前面讲过的内容,把之前讲过的内容串联起来。
|
||||
|
||||
## 数据结构
|
||||
|
||||
首先,我们来看一些基本的数据结构,**你可别小看这些数据结构,它们其实就是一个个解决问题的“模型”****。**有了这些模型,你就能把一个个具体的问题抽象化,然后再来解决。
|
||||
|
||||
我们从最简单的数据结构数组开始说。自从你开始接触计算机编程,**数组**一定是你经常使用的数据结构。它的特点你应该很清楚。数组可以通过下标,直接定位到所需的数据,因此数组特别适合快速地随机访问。它常常和循环语句相结合,来实现迭代法,例如二分查找、斐波那契数列等等。
|
||||
|
||||
另外,我们将要在“线性代数篇”介绍的矩阵,也可以使用多维数组来表示。不过,数组只对稠密的数列更有效。如果数列非常稀疏,那么很多数组的元素就是无效值,浪费了存储空间。此外,数组中元素的插入和删除也比较麻烦,需要进行数据的批量移动。
|
||||
|
||||
那么对于稀疏的数列而言,什么样的数据结构更有效呢?答案是**链表**。链表中的结点存储了数据,而链表结点之间的相连关系,在C和C++语言中是通过指针来实现的,而在Java语言中是通过对象引用来实现的。
|
||||
|
||||
链表的特点是不能通过下标来直接访问数据,而是必须按照存储的结构逐个读取。这样做的优势在于,不必事先规定数据的数量,也不再需要保存无效的值,表示稀疏的数列时可以更有效的利用存储空间,同时也利于数据的动态插入和删除。但是,相对于数组而言,链表无法支持快速地随机访问,进行读写操作时就更耗时。
|
||||
|
||||
和数组一样,链表也可以是多维的。对于非常稀疏的矩阵,也可以用多维链表的结构来表达。此外,在链表结构中,点和点之间的连接,分别体现了图论中的顶点和边。因此,我们还可以使用指针、对象引用等来表示图结构中的顶点和边。常见的图模型,例如多叉树、无向图和有向图等,都可以用指针或引用来实现。
|
||||
|
||||
在数组和链表这些基础的数据结构之上,我们可以构建更复杂的数据结构,比如哈希表、队列和栈等等。这些数据结构,提供了逻辑更复杂的模型,可以通过数组、链表或两者的结合来实现。
|
||||
|
||||
[第2讲](https://time.geekbang.org/column/article/72163)我提到了哈希的概念,而哈希表就可以通过数组和链表来构造。在很多编程语言中,哈希表的实现采用的是**链地址哈希表**。这种方法的主要思想是,先分配一个很大的数组空间,而数组中的每一个元素都是一个链表的头部。随后,我们就可以根据哈希函数算出的哈希值(也叫哈希的key),找到数组的某个元素及对应的链表,然后把数据添加到这个链表中。
|
||||
|
||||
之所以要这样设计,是因为存在**哈希冲突**。对于不同的数据,哈希函数可能产生相同的哈希值,这就是哈希冲突。如果数组的每个元素都只能存放一个数据,那就无法解决冲突。如果每个元素对应了一个链表,那么当发生冲突的时候,我们就可以把多个数据添加到同一个链表中。可是,把多个数据存放在一个链表,就代表访问效率不高。所以,我们要尽量找到一个合理的哈希函数,减少冲突发生的机会,提升检索的效率。
|
||||
|
||||
在第2讲中,我还提到了使用求余相关的操作来实现哈希函数。我这里举个例子。你可以看我画的这幅图。
|
||||
|
||||
<img src="https://static001.geekbang.org/resource/image/83/c2/834ad2a0c5aefd05e0c57aa6d0528fc2.jpg" alt="">
|
||||
|
||||
我们把对100求余作为哈希函数。因此数组的长度是100。对于每一个数字,通过它对100求余,确定它在数组中的位置。如果多个数字的求余结果一样,就产生冲突,使用链表来解决。我们可以看到,表中位置98的链表没有冲突,而0、1、2、3和99位置的链表都有冲突。
|
||||
|
||||
说完了哈希,我们来看看栈这种数据结构。我在介绍树的深度优先搜索时讲到栈。它是先进后出的。在我们进行函数递归的时候,函数调用和返回的顺序,也是先进后出,所以,栈体现了递归的思想,可以实现基于递归的编程。实际上,计算机系统里的函数递归,在内部也是通过栈来实现的。虽然直接通过栈来实现递归不如函数递归调用那么直观,但是,由于栈可以避免过多的中间变量,它可以节省内存空间的使用。
|
||||
|
||||
我在介绍广度优先搜索策略时,谈到了队列。队列和栈最大的不同在于,它是一种先进先出的数据结构,先进入队列的元素会优先得到处理。队列模拟了日常生活中人们排队的现象,其思想已经延伸到很多大型的数据系统中,例如消息队列。
|
||||
|
||||
在消息系统中,生产者会源源不断地推送新的数据,而消费者会对这些消息进行处理。可是,有时消费者的处理速度会慢于生产者推送的速度,这会带来很多复杂的后续问题,因此我们可以通过队列实现消息的缓冲。新产生的数据会先进入队列,直到消费者处理它。经过这样的异步处理,消息的队列实现了生产者和消费者的松耦合,对消费者起到了保护作用,使它不容易被数据洪流冲垮。
|
||||
|
||||
比哈希表,队列和栈更为复杂的数据结构是基于图论中的各种模型,例如各种二叉树、多叉树、有向图和无向图等等。通常,这些模型表示了顶点和顶点之间的稀疏关系,所以它们常常是基于指针或者对象引用来实现的。我在讲前缀树、社交关系图和交通地图的案例中,都使用了这些模型。另外,树模型中的多叉树、特别是二叉树体现了递归的思想。之前的递归编程的案例中的图示也可以对应到多叉树的表示。
|
||||
|
||||
## 编程语句
|
||||
|
||||
在你刚刚开始学习编程的时候,肯定接触过条件语句、循环语句和函数调用这些基本的语句。
|
||||
|
||||
条件语句的一个关键元素是布尔表达式。它其实体现了逻辑代数中逻辑和集合的概念。逻辑代数,也被称为布尔代数,主要包括了逻辑表达式及其相关的逻辑运算,可以帮助我们消除自然语言所带来的歧义,并严格、准确地描述事物。在编程语言中,我们把逻辑表达式和控制语言结合起来,比如Java语言的If语句:
|
||||
|
||||
```
|
||||
if(表达式) {函数体1} else {函数体2}:若表达式为真,执行函数体1,否则执行函数体2。
|
||||
|
||||
```
|
||||
|
||||
当然,逻辑代数在计算机中的应用,远不止条件语句。例如SQL语言中的Select语句和布尔检索模型。Select是SQL查询语言中十分常用的语句。这个语句将根据指定的逻辑表达式,在一个数据库中进行查询并返回结果,而返回的结果就是满足条件的记录之集合。类似地,布尔检索模型利用逻辑表达式,确定哪些文档满足检索的条件并把它们作为结果返回。
|
||||
|
||||
这里顺便提一下,除了条件语句中的布尔表达式,逻辑代数还体现在编程中的其他地方。例如,SQL语言中的Join操作。Join有多种类型,每种类型其实都对应了一种集合的操作。
|
||||
|
||||
<li>
|
||||
内连接(inner join):假设被连接的两张数据表分别是左表和右表,那么内连接查询能将左表和右表中能关联起来的数据连接后返回,返回的结果就是两个表中所有相匹配的数据。如果认为左表是集合A,右表是集合B,那么从集合的角度来说,内连接产生的结果是A、B两个集合的交集。
|
||||
</li>
|
||||
<li>
|
||||
外连接(outer join):外连接可以保留左表,右表或全部表。根据这些行为的不同,可分为左外连接、右外连接和全连接。无论哪一种,都是对应于不同的集合操作。
|
||||
</li>
|
||||
|
||||
循环语句可以让我们进行有规律性的重复性操作,直到满足某个条件。这和迭代法中反复修改某个值的操作非常一致。所以循环常用于迭代法的实现,例如二分或者牛顿法求解方程的根。在之前的迭代法讲解中,我经常使用循环来实现编码。另外,循环语句也会经常和布尔表达式相结合。嵌套的多层循环,常常用于比较多个元素的大小,或者计算多个元素之间的相似度等等,这也体现了排列组合的思想。
|
||||
|
||||
至于函数的调用,一个函数既可以调用自己,也可以调用其他不同的函数。如果不断地调用自己,这就体现了递归的思想。同时,函数的递归调用也可以体现排列组合的思想。
|
||||
|
||||
## 基础算法
|
||||
|
||||
在前面的专栏中,我介绍了一些常见算法及其对应的数学思想。而这些思想,在算法中的体现无处不在。
|
||||
|
||||
介绍分治思想的时候,我谈及了MapReduce的数据切分。在分布式系统中,除了数据切分,我们还要经常处理的问题是:如何确定服务请求被分配到哪台机器上?这就引出了负载均衡算法。
|
||||
|
||||
常见的包括轮询或者源地址哈希算法。轮询算法把请求按顺序轮流地分配到后端服务器上,它并不关心每台服务器当前的负载。如果我们对每个请求标上一个自动增加的ID,我们可以认为轮询算法是对请求的ID进行求余操作(或者是求余的哈希函数),被除数就是可用服务器的数量,余数就是接受请求的服务器ID。而源地址哈希进一步扩展了这个思想,扩展主要体现在:
|
||||
|
||||
<li>
|
||||
它可以对请求的IP或其他唯一标识进行哈希,而不一定是请求的ID;
|
||||
</li>
|
||||
<li>
|
||||
哈希函数的变换操作不一定是求余。
|
||||
</li>
|
||||
|
||||
不管是对何种数据进行哈希变换,也不管是何种哈希函数,只要能为每个请求确定哈希key之后,我们就能为它查找对应的服务器。
|
||||
|
||||
另外,在[第9节](https://time.geekbang.org/column/article/75807)中,我谈到了字符串的编辑距离,但是没有涉及字符串匹配的算法。知名的RK(Rabin-Karp)匹配算法,在暴力匹配(Brute Force)基础之上,充分利用了迭代法和哈希,提升了算法的效率。
|
||||
|
||||
首先,RK算法可以根据两个字符串哈希后的值。来判断它们是不是相同。如果哈希值不同,则两个字符串肯定不同,不用再比较;此外,RK算法中的哈希设计非常巧妙,让相邻两个子字符串的哈希值产生了固定的联系,让我们可以通过前一个子串的哈希值,推导出后一个子串的哈希值,这样就能使用迭代法来计算每个子串的哈希值,大大减少了用于哈希函数的计算。
|
||||
|
||||
除了分治和动态规划,另一个常用的算法思想是回溯。我们可以使用回溯来解决的问题包括八皇后和0/1背包等等。回溯实际上体现了递归和排列的思想。不过,它对搜索空间做了一些优化,提前排除了不可能的情况,提升了算法整体的效率。当然,既然回溯体现了递归的思想,也可以把整个搜索状态表示成树,而对结果的搜索就是树的深度优先遍历。
|
||||
|
||||
在前两节讲述算法复杂度分析的时候,我已经从数学的角度出发,总结了几个常用的法则,包括四则运算、主次分明、齐头并进、排列组合、一图千言和时空互换。这些法则体现了数学中的运算优先级、数量级、多元变量、图论等思想。这些我们上两节刚刚讲过,我就不多说了,你可以参考之前的内容快速复习一下。
|
||||
|
||||
## 小结
|
||||
|
||||
这一讲,我对常用的数据结构、编程语句和算法中所体现的数学思想,做了一个大体的梳理。可以看到,**不同的数据结构,都是在编程中运用数学思维的产物。每种数据结构都有自身的特点,有利于我们更方便地实现某种特定的数学模型。**
|
||||
|
||||
从数据结构的角度来看,最基本的数组遍历体现了迭代的思想,而链表和树的结构可用于刻画图论中的模型。栈的先进后出、以及队列的先进先出,分别适用于图的深度优先和广度优先遍历。哈希表则充分利用了哈希函数的特点,大幅降低了查询的时间复杂度。
|
||||
|
||||
当然,仅仅使用数据结构来存储数据还不够,我们还需要操作这些数据。为了实现操作的流程,条件语句使用了布尔代数来控制编程逻辑,循环和函数嵌套使用迭代、递归和排列组合等思想来实现更精细的数学模型。
|
||||
|
||||
但是,有时候我们面对的问题太复杂了,除了数据结构和基本的编程语句,我们还需要发明一些算法。为了提升算法的效率,我们需要对其进行复杂度分析。通常,这些算法中的数学思想就更为明显,因为它们都是为了解决特定的问题,根据特定的数学模型而设计的。
|
||||
|
||||
有的时候,某个算法会体现多种数学思想,例如RK字符串匹配算法,同时使用了迭代法和哈希。此外,多种数学思维可能都是相通的。比如,递归的思想、排列的结果、二进制数的枚举都可以用树的结构来图示化,因此我们可以通过树来理解。
|
||||
|
||||
所以,在平时学习编程的时候,你可以多从数学的角度出发,思考其背后的数学模型。这样不仅有利于你对现有知识的融会贯通,还可以帮助你优化数据结构和算法。
|
||||
|
||||
## 思考题
|
||||
|
||||
在你日常的工作项目中,应该经常用到数据结构和算法,能不能列举一下,其中有哪些数学思想呢?
|
||||
|
||||
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user