You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

271 lines
14 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 03 | 迭代法:不用编程语言的自带函数,你会如何计算平方根?
你好,我是黄申。
今天我们来说一个和编程结合得非常紧密的数学概念。在解释这个重要的概念之前,我们先来看个有趣的小故事。
> 古印度国王舍罕酷爱下棋他打算重赏国际象棋的发明人宰相西萨·班·达依尔。这位聪明的大臣指着象棋盘对国王说“陛下我不要别的赏赐请您在这张棋盘的第一个小格内放入一粒麦子在第二个小格内放入两粒第三小格内放入四粒以此类推每一小格内都比前一小格加一倍的麦子直至放满64个格子然后将棋盘上所有的麦粒都赏给您的仆人我吧
国王自以为小事一桩,痛快地答应了。可是,当开始放麦粒之后,国王发现,还没放到第二十格,一袋麦子已经空了。随着,一袋又一袋的麦子被放入棋盘的格子里,国王很快看出来,即便拿来全印度的粮食,也兑现不了对达依尔的诺言。
放满这64格到底需要多少粒麦子呢这是个相当相当大的数字想要手动算出结果并不容易。如果你觉得自己非常厉害可以试着拿笔算算。其实这整个算麦粒的过程在数学上是有对应方法的这也正是我们今天要讲的概念**迭代法**Iterative Method
## 到底什么是迭代法?
**迭代法,简单来说,其实就是不断地用旧的变量值,递推计算新的变量值**。
我这么说可能还是有一点抽象,不容易理解。我们还回到刚才的故事。大臣要求每一格的麦子都是前一格的两倍,那么前一格里麦子的数量就是旧的变量值,我们可以先记作$X\_{n-1}$;而当前格子里麦子的数量就是新的变量值,我们记作$X\_{n}$。这两个变量的递推关系就是这样的:
![](https://static001.geekbang.org/resource/image/c8/0e/c82c80cbf7d766f77422c564418cc70e.jpg?wh=1142*648)
如果你稍微有点编程经验,应该能发现,迭代法的思想,很容易通过计算机语言中的**循环语言**来实现。你知道,计算机本身就适合做重复性的工作,我们可以通过循环语句,让计算机重复执行迭代中的递推步骤,然后推导出变量的最终值。
那接下来我们就用循环语句来算算填满格子到底需要多少粒麦子。我简单用Java语言写了个程序你可以看看。
```
public class Lesson3_1 {
/**
* @Description: 算算舍罕王给了多少粒麦子
* @param grid-放到第几格
* @return long-麦粒的总数
*/
public static long getNumberOfWheat(int grid) {
long sum = 0; // 麦粒总数
long numberOfWheatInGrid = 0; // 当前格子里麦粒的数量
numberOfWheatInGrid = 1; // 第一个格子里麦粒的数量
sum += numberOfWheatInGrid;
for (int i = 2; i <= grid; i ++) {
numberOfWheatInGrid *= 2; // 当前格子里麦粒的数量是前一格的2倍
sum += numberOfWheatInGrid; // 累计麦粒总数
}
return sum;
}
}
```
下面是一段测试代码它计算了到第63格时总共需要多少麦粒。
```
public static void main(String[] args) {
System.out.println(String.format("舍罕王给了这么多粒:%d", Lesson3_1.getNumberOfWheat(63)));
}
```
计算的结果是9223372036854775807多到数不清了。我大致估算了一下一袋50斤的麦子估计有130万粒麦子那么9223372036854775807相当于70949亿袋50斤的麦子
这段代码有两个地方需要注意。首先用于计算每格麦粒数的变量以及总麦粒数的变量都是Java中的long型这是因为计算的结果实在是太大了超出了Java int型的范围第二我们只计算到了第63格这是因为计算到第64格之后总数已经超过Java中long型的范围。
## 迭代法有什么具体应用?
看到这里,你可能大概已经理解迭代法的核心理念了。迭代法无论是在数学,还是计算机领域都有很广泛的应用。大体上,迭代法可以运用在以下几个方面:
* **求数值的精确或者近似解**。典型的方法包括二分法Bisection method和牛顿迭代法Newtons method
* **在一定范围内查找目标值。**典型的方法包括二分查找。
* **机器学习算法中的迭代**。相关的算法或者模型有很多比如K-均值算法K-means clustering、PageRank的马尔科夫链Markov chain、梯度下降法Gradient descent等等。迭代法之所以在机器学习中有广泛的应用是因为**很多时候机器学习的过程,就是根据已知的数据和一定的假设,求一个局部最优解**。而迭代法可以帮助学习算法逐步搜索,直至发现这种解。
这里,我详细讲解一下求数值的解和查找匹配记录这两个应用。
### 1.求方程的精确或者近似解
迭代法在数学和编程的应用有很多,如果只能用来计算庞大的数字,那就太“暴殄天物”了。迭代还可以帮助我们进行无穷次地逼近,求得方程的精确或者近似解。
比如说我们想计算某个给定正整数nn>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。
我把具体的步骤画成了一张图,你可以看看。
![](https://static001.geekbang.org/resource/image/89/7d/89c9c38113624288091cd65ff3d8957d.jpg?wh=1142*597)
我这里用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”。
![](https://static001.geekbang.org/resource/image/2d/5a/2de8a4c2b934a86ef5e8b915b6926d5a.jpg?wh=1142*366)
那么在处理文章的时候当我们看到了“西红柿”这个词就去字典里查一把拿出“番茄”“tomato”等等并添加到文章中作为同义词/近义词的扩展。这样的话用户在搜索“西红柿”这个词的时候我们就能确保出现“番茄”或者“tomato”的文章会被返回给用户。
乍一看到这个任务的时候,你也许想到了哈希表。没错,哈希表是个好方法。不过,如果不使用哈希表,你还有什么其他方法呢?这里,我来介绍一下,用二分查找法进行字典查询的思路。
第一步,将整个字典先进行排序(假设从小到大)。二分法中很关键的前提条件是,所查找的区间是有序的。这样才能在每次折半的时候,确定被查找的对象属于左半边还是右半边。
第二步,使用二分法逐步定位到被查找的单词。每次迭代的时候,都找到被搜索区间的中间点,看看这个点上的单词,是否和待查单词一致。如果一致就返回;如果不一致,要看被查单词比中间点上的单词是小还是大。如果小,那说明被查的单词如果存在字典中,那一定在左半边;否则就在右半边。
第三步,根据第二步的判断,选择左半边或者后半边,继续迭代式地查找,直到范围缩小到单个的词。如果到最终仍然无法找到,则返回不存在。
当然,你也可以对单词进行从大到小的排序,如果是那样,在第二步的判断就需要相应地修改一下。
我把在a到g的7个字符中查找f的过程画成了一张图你可以看看。
![](https://static001.geekbang.org/resource/image/d3/99/d39dfcea9385baef98846d2a5914a599.jpg?wh=1142*456)
这个方法的整体思路和二分法求解平方根是一致的,主要区别有两个方面:第一,每次判断是否终结迭代的条件不同。求平方根的时候,我们需要判断某个数的平方是否和输入的数据一致。而这里,我们需要判断字典中某个单词是否和待查的单词相同。第二,二分查找需要确保被搜索的空间是有序的。
我把具体的代码写出来了,你可以看一下。
```
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));
}
}
```
说的这两个例子,都属于迭代法中的二分法,我在第一节的时候说过,二分法其实也体现了二进制的思想。
## 小结
到这里,我想你对迭代的核心思路有了比较深入的理解。
实际上,人类并不擅长重复性的劳动,而计算机却很适合做这种事。这也是为什么,以重复为特点的迭代法在编程中有着广泛的应用。不过,日常的实际项目可能并没有体现出明显的重复性,以至于让我们很容易就忽视了迭代法的使用。所以,你要多观察问题的现象,思考其本质,看看不断更新变量值或者缩小搜索的区间范围,是否可以获得最终的解(或近似解、局部最优解),如果是,那么你就可以尝试迭代法。
![](https://static001.geekbang.org/resource/image/cf/23/cff999fbe0e89b76736f41aacc944623.jpg?wh=1242*1625)
## 思考题
在你曾经做过的项目中,是否使用过迭代法?如果有,你觉得迭代法最大的特点是什么?如果还没用过,你想想看现在的项目中是否有可以使用的地方?
欢迎在留言区交作业,并写下你今天的学习笔记。你可以点击“请朋友读”,把今天的内容分享给你的好友,和他一起精进。