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

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


你好,我是黃申。上次我們聊了迭代法及其應用,並用程式設計實現了幾個小例子。不過你知道嗎,對於某些迭代問題,我們其實可以避免一步步的計算,直接從理論上證明某個結論,節約大量的計算資源和時間,這就是我們今天要說的數學歸納法。平時我們談的“歸納”,是一種從經驗事實中找出普遍特徵的認知方法。比如,人們在觀察了各種各樣動物之後,通過它們的外觀、行為特徵、生活習性等得出某種結論,來區分哪些是鳥、哪些是貓等等。比如我這裡列出的幾個動物的例子。

通過上面的表格,我們可以進行歸納,並得出這樣的結論:

如果一個動物,身上長羽毛並且會飛,那麼就是屬於鳥;
如果一個動物,身上長絨毛、不會飛、而且吃小魚和老鼠,那麼就屬於貓。

通過觀察 5 個動物樣本的 3 個特徵,從而得到某種動物應該具有何種特徵,這種方法就是我們平時所提到的歸納法。我們日常生活中所說的這種歸納法和數學歸納法是不一樣的,它們究竟有什麼區別呢?具體數學歸納法可以做什麼呢?我們接著上一節舍罕王賞麥的故事繼續說。
什麼是數學歸納法?
上節我們提到,在棋盤上放麥粒的規則是,第一格放一粒,第二格放兩粒,以此類推,每一小格內都比前一小格多一倍的麥子,直至放滿 64 個格子。我們假想一下自己穿越到了古印度,正站在國王的身邊,看著這個棋盤,你發現第 1 格到第 8 格的麥子數分別是:1、2、4、8、16、32、64、128。這個時候,國王想知道總共需要多少粒麥子。我們小時候都玩過“找規律”,於是,我發現了這麼一個規律,你看看是不是這樣?

根據這個觀察,我們是不是可以大膽假設,前 n 個格子的麥粒總數就是 2n−1 呢?如果這個假設成立,那麼填滿 64 格需要的麥粒總數,就是 1+2+2^2+2^3+2^4+……+2^63
=2^64−1=18446744073709551615。
這個假設是否成立,我們還有待驗證。但是對於類似這種無窮數列的問題,我們通常可以採用數學歸納法(Mathematical Induction)來證明。
在數論中,數學歸納法用來證明任意一個給定的情形都是正確的,也就是說,第一個、第二個、第三個,一直到所有情形,概不例外。
數學歸納法的一般步驟是這樣的:

證明基本情況(通常是 n=1 的時候)是否成立;
假設 n=k−1 成立,再證明 n=k 也是成立的(k 為任意大於 1 的自然數)。

只要學過數學,我想你對這個步驟都不陌生。但是,現在你需要牢記這個步驟,然後我們用這個步驟來證明下開頭的例子。為了讓你更好地理解,我將原有的命題分為兩個子命題來證明。第一個子命題是,第 n 個棋格放的麥粒數為 2^n−1。第二個子命題是,前 n 個棋格放的麥粒數總和為 2^n−1。
首先,我們來證明第一個子命題。

基本情況:我們已經驗證了 n=1 的時候,第一格內的麥粒數為 1,和 2^1−1 相等。因此,命題在 k=1 的時候成立。
假設第 k−1 格的麥粒數為 2^k−2。那麼第 k 格的麥粒數為第 k−1 格的 2 倍,也就是 2^k−2∗2=2^k−1。因此,如果命題在 k=n−1 的時候成立,那麼在 k=n 的時候也成立。

所以,第一個子命題成立。在這個基礎之上,我再來證明第二個子命題。

說到這裡,我已經證明了這兩個命題都是成立的。和使用迭代法的計算相比,數學歸納法最大的特點就在於“歸納”二字。它已經總結出了規律。只要我們能夠證明這個規律是正確的,就沒有必要進行逐步的推算,可以節省很多時間和資源。
說到這裡,我們也可以看出,數學歸納法中的“歸納”是指的從第一步正確,第二步正確,第三步正確,一直推導到最後一步是正確的。這就像多米諾骨牌,只要確保第一張牌倒下,而每張牌的倒下又能導致下一張牌的倒下,那麼所有的骨牌都會倒下。從這裡,你也能看出來,這和開篇提到的廣義歸納法是不同的。數學歸納法並不是通過經驗或樣本的觀察,總結出事物的普遍特徵和規律。
好了,對數學歸納法的概念,我想你現在已經理解了。這裡,我對上一節中有關麥粒的程式碼稍作修改,增加了一點程式碼來使用數學歸納法的結論,並和迭代法的實現進行了比較,你可以看看哪種方法耗時更長。


public static void main(String[] args) {
  
  int grid = 63;
  long start, end = 0;
  start = System.currentTimeMillis();
  System.out.println(String.format("舍罕王給了這麼多粒:%d", Lesson3_1.getNumberOfWheat(grid)));
  end = System.currentTimeMillis();
  System.out.println(String.format("耗時%d毫秒", (end - start)));
  
  start = System.currentTimeMillis();
  System.out.println(String.format("舍罕王給了這麼多粒:%d", (long)(Math.pow(2, grid)) - 1));
  end = System.currentTimeMillis();
  System.out.println(String.format("耗時%d毫秒", (end - start)));
  
 }

在我的電腦上,這段程式碼執行的結果是:舍罕王給了 9223372036854775807 粒,耗時 4 毫秒。舍罕王給了這麼多粒:9223372036854775806,耗時 0 毫秒。你可能已經發現,當 grid=63 時,結果差了 1 個。這個是由於 Math.pow() 函式計算精度導致的誤差。正確的結果應該是 9223372036854775807。不過,基於數學歸納結論的計算明顯在耗時上佔有優勢。雖然在我的膝上型電腦上只有 4 毫秒的差距,但是在生產專案的實踐中,這種點點滴滴的效能差距都有可能累積成明顯的問題。
遞迴呼叫和數學歸納的邏輯是一樣的?
我們不僅可以使用數學歸納法從理論上指導程式設計,還可以使用程式設計來模擬數學歸納法的證明。如果你仔細觀察一下數學歸納法的證明過程,會不會覺得和函式的遞迴呼叫很像呢?這裡我通過總麥粒數的命題來示範一下。首先,我們要把這個命題的數學歸納證明,轉換成一段虛擬碼,這個過程需要經過這樣兩步:

你應該看出來了,這兩步分別對應了數學歸納法的兩種情況。在數學歸納法的第二種情況下,我們只能假設 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 型資料。你可以自己分析一下函式的呼叫和返回。我這裡列出了一開始巢狀呼叫和到遞迴結束並開始返回值得的幾個狀態:

從這個圖可以看出,函式從 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 的迭代。說到這裡,你可能會好奇:既然遞迴最後返回值的過程和基於迴圈的迭代是一致,那為什麼還需要使用遞迴的方法呢?下一節,我們繼續聊這個問題。