1. 程式人生 > 實用技巧 >程式設計師的數學基礎課 筆記5

程式設計師的數學基礎課 筆記5


你好,我是黃申。今天我們來說一個和程式設計結合得非常緊密的數學概念。在解釋這個重要的概念之前,我們先來看個有趣的小故事。

古印度國王舍罕酷愛下棋,他打算重賞國際象棋的發明人宰相西薩·班·達依爾。這位聰明的大臣指著象棋盤對國王說:“陛下,我不要別的賞賜,請您在這張棋盤的第一個小格內放入一粒麥子,在第二個小格內放入兩粒,第三小格內放入給四粒,以此類推,每一小格內都比前一小格加一倍的麥子,直至放滿 64 個格子,然後將棋盤上所有的麥粒都賞給您的僕人我吧!”
國王自以為小事一樁,痛快地答應了。可是,當開始放麥粒之後,國王發現,還沒放到第二十格,一袋麥子已經空了。隨著,一袋又一袋的麥子被放入棋盤的格子裡,國王很快看出來,即便拿來全印度的糧食,也兌現不了對達依爾的諾言。

放滿這 64 格到底需要多少粒麥子呢?這是個相當相當大的數字,想要手動算出結果並不容易。如果你覺得自己厲害,可以試著拿筆算算。其實,這整個算麥粒的過程,在數學上,是有對應方法的,這也正是我們今天要講的概念:迭代法(Iterative Method)。
到底什麼是迭代法?
迭代法,簡單來說,其實就是不斷地用舊的變數值,遞推計算新的變數值。
我這麼說可能還是比較抽象,不容易理解。我們還回到剛才的故事。大臣要求每一格的麥子都是前一格的兩倍,那麼前一格里麥子的數量就是舊的變數值,我們可以先記作 Xn−1​;而當前格子裡麥子的數量就是新的變數值,我們記作 Xn​。這兩個變數的遞推關係就是這樣的:

如果你稍微有點程式設計經驗,應該能發現,迭代法的思想,很容易通過計算機語言中的迴圈語言來實現。你知道,計算機本身就適合做重複性的工作,我們可以通過迴圈語句,讓計算機重複執行迭代中的遞推步驟,然後推匯出變數的最終值。
那接下來,我們就用迴圈語句來算算,填滿格子到底需要多少粒麥子。我簡單用 Java 語言寫了個程式,你可以看看。

public class Lesson3_1 {
    /**
    * @Description: 算算舍罕王給了多少粒麥子
    * @param grid-放到第幾格
    * @return long-麥粒的總數
    */

    public static long getNumberOfWheat(int grid) {
     
     long sum = 0;      // 麥粒總數
     long numberOfWheatInGrid = 0;  // 當前格子裡麥粒的數量
     
     numberOfWheatInGrid = 1;  // 第一個格子裡麥粒的數量
     sum += numberOfWheatInGrid;  
     
     for (int i = 2; i <= grid; i ++) {
      numberOfWheatInGrid *= 2;   // 當前格子裡麥粒的數量是前一格的2倍
      sum += numberOfWheatInGrid;   // 累計麥粒總數
     }
     
     return sum;

    }
}

下面是一段測試程式碼,它計算了到第 63 格時,總共需要多少麥粒。

  public static void main(String[] args) {
  System.out.println(String.format("舍罕王給了這麼多粒:%d",   Lesson3_1.getNumberOfWheat(63)));
  }

計算的結果是 9223372036854775807,多到數不清了。我大致估算了一下,一袋 50 斤的麥子估計有 130 萬粒麥子,那麼 9223372036854775807 相當於 70949 億袋 50 斤的麥子!
這段程式碼有兩個地方需要注意。首先,用於計算每格麥粒數的變數以及總麥粒數的變數都是 Java 中的 long 型,這是因為計算的結果實在是太大了,超出了 Java int 型的範圍;第二,我們只計算到了第 63 格,這是因為計算到第 64 格之後,總數已經超過 Java 中 long 型的範圍。
迭代法有什麼具體應用?
看到這裡,你可能大概已經理解迭代法的核心理念了。迭代法在無論是在數學,還是計算機領域都有很廣泛的應用。大體上,迭代法可以運用在以下幾個方面:

**求數值的精確或者近似解**。典型的方法包括二分法(Bisection method)和牛頓迭代法(Newton’s method)。

**在一定範圍內查詢目標值**。典型的方法包括二分查詢。

**機器學習演算法中的迭代**。相關的演算法或者模型有很多,比如 K- 均值演算法(K-means clustering)、PageRank 的馬爾科夫鏈(Markov chain)、梯度下降法(Gradient descent)等等。迭代法之所以在機器學習中有廣泛的應用,是因為**很多時候機器學習的過程,就是根據已知的資料和一定的假設,求一個區域性最優解**。而迭代法可以幫助學習演算法逐步搜尋,直至發現這種解。

這裡,我詳細講解一下求數值的解和查詢匹配記錄這兩個應用。
1.求方程的精確或者近似解
迭代法在數學和程式設計的應用有很多,如果只能用來計算龐大的數字,那就太“暴殄天物”了。迭代還可以幫助我們進行無窮次地逼近,求得方程的精確或者近似解。
比如說,我們想計算某個給定正整數 n(n>1)的平方根,如果不使用程式語言自帶的函式,你會如何來實現呢?
假設有正整數 n,這個平方根一定小於 n 本身,並且大於 1。那麼這個問題就轉換成,在 1 到 n 之間,找一個數字等於 n 的平方根。
我這裡採用迭代中常見的二分法。每次檢視區間內的中間值,檢驗它是否符合標準。
舉個例子,假如我們要找到 10 的平方根。我們需要先看 1 到 10 的中間數值,也就是 11/2=5.5。5.5 的平方是大於 10 的,所以我們要一個更小的數值,就看 5.5 和 1 之間的 3.25。由於 3.25 的平方也是大於 10 的,繼續檢視 3.25 和 1 之間的數值,也就是 2.125。這時,2.125 的平方小於 10 了,所以看 2.125 和 3.25 之間的值,一直繼續下去,直到發現某個數的平方正好是 10。
我把具體的步驟畫成了一張圖,你可以看看。

我這裡用 Java 程式碼演示一下效果,你可以結合上面的講解,來理解迭代的過程。

public class Lesson3_2 {
 
 /**
    * @Description: 計算大於1的正整數之平方根
    * @param n-待求的數, deltaThreshold-誤差的閾值, maxTry-二分查詢的最大次數
    * @return double-平方根的解
    */
    public static double getSqureRoot(int n, double deltaThreshold, int maxTry) {
     
     if (n <= 1) {
      return -1.0;
     }
     
     double min = 1.0, max = (double)n;
     for (int i = 0; i < maxTry; i++) {
      double middle = (min + max) / 2;
      double square = middle * middle;
      double delta = Math.abs((square / n) - 1);
      if (delta <= deltaThreshold) {
       return middle;
      } else {
       if (square > n) {
        max = middle;
       } else {
        min = middle;
       }
      }
     }
     
     return -2.0;

    }
}

這是一段測試程式碼,我們用它來找正整數 10 的平方根。如果找不到精確解,我們就返回一個近似解。

public static void main(String[] args) {
  
  int number = 10;
  double squareRoot = Lesson3_2.getSqureRoot(number, 0.000001, 10000);
  if (squareRoot == -1.0) {
   System.out.println("請輸入大於1的整數");
  } else if (squareRoot == -2.0) {
   System.out.println("未能找到解");
  } else {
   System.out.println(String.format("%d的平方根是%f", number, squareRoot));
  }
  
 }

這段程式碼的實現思想就是我前面講的迭代過程,這裡面有兩個小細節我解釋下。
第一,我使用了 deltaThreshold 來控制解的精度。雖然理論上來說,可以通過二分的無限次迭代求得精確解,但是考慮到實際應用中耗費的大量時間和計算資源,絕大部分情況下,我們並不需要完全精確的資料。
第二,我使用了 maxTry 來控制迴圈的次數。之所以沒有使用 while(true) 迴圈,是為了避免死迴圈。雖然,在這裡使用 deltaThreshold,理論上是不會陷入死迴圈的,但是出於良好的程式設計習慣,我們還是儘量避免產生的可能性。
說完了二分迭代法,我這裡再簡單提一下牛頓迭代法。這是牛頓在 17 世紀提出的一種方法,用於求方程的近似解。這種方法以微分為基礎,每次迭代的時候,它都會去找到比上一個值 x0​ 更接近的方程的根,最終找到近似解。該方法及其延伸也被應用在機器學習的演算法中,在之後機器學習中的應用中,我會具體介紹這個演算法。
2.查詢匹配記錄
二分法中的迭代式逼近,不僅可以幫我們求得近似解,還可以幫助我們查詢匹配的記錄。我這裡用一個查字典的案例來說明。
在自然語言處理中,我們經常要處理同義詞或者近義詞的擴充套件。這時,你手頭上會有一個同義詞 / 近義詞的詞典。對於一個待查詢的單詞,我們需要在字典中找出這個單詞,以及它所對應的同義詞和近義詞,然後進行擴充套件。比如說,這個字典裡有一個關於“西紅柿”的詞條,其同義詞包括了“番茄”和“tomato”。

那麼,在處理文章的時候,當我們看到了“西紅柿”這個詞,就去字典裡查一把,拿出“番茄”“tomato”等等,並新增到文章中作為同義詞 / 近義詞的擴充套件。這樣的話,使用者在搜尋“西紅柿”這個詞的時候,我們就能確保出現“番茄”或者“tomato”的文章會被返回給使用者。
乍一看到這個任務的時候,你也許想到了雜湊表。沒錯,雜湊表是個好方法。不過,如果不使用雜湊表,你還有什麼其他方法呢?這裡,我來介紹一下,用二分查詢法進行字典查詢的思路。
第一步,將整個字典先進行排序(假設從小到大)。二分法中很關鍵的前提條件是,所查詢的區間是有序的。這樣才能在每次折半的時候,確定被查詢的物件屬於左半邊還是右半邊。
第二步,使用二分法逐步定位到被查詢的單詞。每次迭代的時候,都找到被搜尋區間的中間點,看看這個點上的單詞,是否和待查單詞一致。如果一致就返回;如果不一致,要看被查單詞比中間點上的單詞是小還是大。如果小,那說明被查的單詞如果存在字典中,那一定在左半邊;否則就在右半邊。
第三步,根據第二步的判斷,選擇左半邊或者後半邊,繼續迭代式地查詢,直到範圍縮小到單個的詞。如果到最終仍然無法找到,則返回不存在。
當然,你也可以對單詞進行從大到小的排序,如果是那樣,在第二步的判斷就需要相應地修改一下。
我把在 a 到 g 的 7 個字元中查詢 f 的過程,畫成了一張圖,你可以看看。

這個方法的整體思路和二分法求解平方根是一致的,主要區別有兩個方面:第一,每次判斷是否終結迭代的條件不同。求平方根的時候,我們需要判斷某個數的平方是否和輸入的資料一致。而這裡,我們需要判斷字典中某個單詞是否和待查的單詞相同。第二,二分查詢需要確保被搜尋的空間是有序的。我把具體的程式碼寫出來了,你可以看一下。


import java.util.Arrays;

public class Lesson3_3 {
 
 /**
    * @Description: 查詢某個單詞是否在字典裡出現
    * @param dictionary-排序後的字典, wordToFind-待查的單詞
    * @return boolean-是否發現待查的單詞
    */
    public static boolean search(String[] dictionary, String wordToFind) {
     
     if (dictionary == null) {
      return false;
     }
     
     if (dictionary.length == 0) {
      return false;
     }
     
     int left = 0, right = dictionary.length - 1;
     while (left <= right) {
      int middle = (left + right) / 2;
      if (dictionary[middle].equals(wordToFind)) {
       return true;
      } else {
       if (dictionary[middle].compareTo(wordToFind) > 0) {
        right = middle - 1;
       } else {
        left = middle + 1;
       }
      }
     }
     
     return false;

    }

}


我測試程式碼首先建立了一個非常簡單的字典,然後使用二分查詢法在這個字典中查詢單詞“i”。


public static void main(String[] args) {
  
  
  String[] dictionary = {"i", "am", "one", "of", "the", "authors", "in", "geekbang"};
  
  Arrays.sort(dictionary);

  String wordToFind = "i";
  
  boolean found = Lesson3_3.search(dictionary, wordToFind);
  if (found) {
   System.out.println(String.format("找到了單詞%s", wordToFind));
  } else {
   System.out.println(String.format("未能找到單詞%s", wordToFind));
  }
  
 }

說的這兩個例子,都屬於迭代法中的二分法,我在第一節的時候說過,二分法其實也體現了二進位制的思想。

小結
到這裡,我想你對迭代的核心思路有了比較深入的理解。實際上,人類並不擅長重複性的勞動,而計算機卻很適合做這種事。這也是為什麼,以重複為特點的迭代法在程式設計中有著廣泛的應用。不過,日常的實際專案可能並沒有體現出明顯的重複性,以至於讓我們很容易就忽視了迭代法的使用。所以,你要多觀察問題的現象,思考其本質,看看不斷更新變數值或者縮小搜尋的區間範圍,是否可以獲得最終的解(或近似解、區域性最優解),如果是,那麼你就可以嘗試迭代法。