數學之美1 - 離散篇

離散篇

程序員的數學基礎課

  • 二進制
  • 餘數
  • 迭代法
  • 歸納法
  • 遞歸
  • 排列 & 組合
  • 動態規劃
  • DPS & BFS
  • 時間複雜度 & 空間複雜度
  • 反碼 & 補碼
  • 位操作

開篇詞 | 作爲程序員,爲什麼你應該學好數學?

  • 數學它其實是一種思維模式,考驗的是一個人歸納總結抽象的能力
  • 如果編程語言是血肉,數學的思想和知識就是靈魂
  • 只做程序員需要學的數學知識

導讀:程序員應該怎麼學數學?

把握數學的工具屬性,學習具體方法時先溯因再求果,勤于思考解決相同問題的不同方法,與解決不同問題的相同方法之間的聯繫與區別。

程序員的數學應用地圖


01 | 二進制:不瞭解計算機的源頭,你學什麼編程

向左移位

使用’<<'表示向左移位

注意數字溢出

向右移位

需考慮高位補 1 還是補 0(符號位可能爲 1 或 0)

邏輯右移’>>>’

邏輯右移 1 位,左邊補 0 即可。

算術右移’>>’

算術右移時保持符號位不變,除符號位之外的右移一位並補符號位 1。補的 1 仍然在符號位之後。

位的“或”

邏輯“或”的意思是,參與操作的位中只要有一個位是 1,那麼最終結果就是 1,也就是“真”。

使用’|'表示按位的“或”

位的“與”

參與操作的位中必須全都是 1,那麼最終結果纔是 1(真),否則就爲 0(假)。

使用’&’ 表示按位“與”

位的“異或”

如果參與操作的位相同,那麼最終結果就爲 0(假),否則爲 1(真)。

使用’^’ 表示按位“異或”

“異或”操作的本質其實就是,所有數值和自身進行按位的“異或”操作之後都爲 0。作爲判斷兩個變量是否相等的條件

學習筆記


02 | 餘數:原來取餘操作本身就是個哈希函數

例子

  • 星期
  • 分頁

同餘定理

簡單來說,就是兩個整數 a 和 b,如果它們除以正整數 m 得到的餘數相等,我們就可以說 a 和 b 對於模 m 同餘。

同餘定理其實就是用來分類的

哈希

將任意長度的輸入,通過哈希算法,壓縮爲某一固定長度的輸出。

學習筆記


03 | 迭代法:不用編程語言的自帶函數,你會如何計算平方根?

例子

棋盤放麥粒,每次翻倍,放滿64個格子,求總的麥粒數量

到底什麼是迭代法?

迭代法,簡單來說,其實就是不斷地用舊的變量值,遞推計算新的變量值。

應用

  • 求數值的精確或者近似解。典型的方法包括二分法(Bisection method)和牛頓迭代法(Newton’s method)。
  • 在一定範圍內查找目標值。典型的方法包括二分查找。
  • 機器學習算法中的迭代。相關的算法或者模型有很多,比如 K- 均值算法(K-means clustering)、PageRank 的馬爾科夫鏈(Markov chain)、梯度下降法(Gradient descent)等等。迭代法之所以在機器學習中有廣泛的應用,是因爲很多時候機器學習的過程,就是根據已知的數據和一定的假設,求一個局部最優解。而迭代法可以幫助學習算法逐步搜索,直至發現這種解。

計算大於 1 的正整數之平方根

比如說,我們想計算某個給定正整數 n(n>1)的平方根,如果不使用編程語言自帶的函數,你會如何來實現呢?

假設有正整數 n,這個平方根一定小於 n 本身,並且大於 1。那麼這個問題就轉換成,在 1 到 n 之間,找一個數字等於n 的平方根。

我這裏採用迭代中常見的二分法。每次查看區間內的中間值,檢驗它是否符合標準。

class Solution1(object):
    def mySqrt(self, x):
        """

	計算大於 1 的正整數之平方根

        :type x: int
        :rtype: int

        迭代中的二分法
        """
        if x < 1:
            return x

        l, r = 1, x
        while l <= r:
            mid = l + (r - l) / 2
            sqrt = x / mid  # 技巧:通過取整避免解的精度問題
            if sqrt == mid:
                return mid
            elif sqrt < mid:
                r = mid - 1
            else:
                l = mid + 1

查找某個單詞是否在字典裏出現

def bin_search(arr, target):
  # 查找某個單詞是否在字典裏出現
  # O(logn)
  l, r = 0, len(arr)
  while l <= r:
    mid = (l+r)/2
    if target == arr[mid]:
      return mid
    elif target > arr[mid]:
      l = mid + 1
    else:
      r = mid - 1

學習筆記


04 | 數學歸納法:如何用數學歸納提升代碼的運行效率?

例子

棋盤放麥粒,每次翻倍,放滿64個格子,求總的麥粒數量

數學歸納法

數學歸納法的一般步驟是這樣的:

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

迭代法 & 歸納法

和使用迭代法的計算相比,數學歸納法最大的特點就在於“歸納”二字。它已經總結出了規律。只要我們能夠證明這個規律是正確的,就沒有必要進行逐步的推算,可以節省很多時間和資源。

迭代法是如何通過重複的步驟進行計算或者查詢的。與此不同的是,數學歸納法在理論上證明了命題是否成立,而無需迭代那樣反覆計算,因此可以幫助我們節約大量的資源,並大幅地提升系統的性能。

遞歸調用 & 數學歸納

  • 遞歸調用先逆向遞推再正向遞推的過程
  • 數據歸納的證明是由特殊到一般的過程
  • 遞歸把計算交給計算機,歸納把計算交給人

學習筆記


05 | 遞歸(上):泛化數學歸納,如何將複雜問題簡單化?

學習筆記

使用函數的遞歸(嵌套)調用,找出所有可能的獎賞組合

import copy

rewards = [1, 2, 5, 10]    # 四種面額的紙幣


def get_sum_combo(total_reword, result=[]):
    """ 使用函數的遞歸(嵌套)調用,找出所有可能的獎賞組合

    Args:
        total_reword: 獎賞總金額
        result: 保存當前的解

    Returns: void

    """
    if total_reword == 0:  # 證明它是滿足條件的解,結束嵌套調用,輸出解

        print(result)
        return
    elif total_reword < 0:  # 證明它不是滿足條件的解,不輸出
        return
    else:
        for i in range(len(rewards)):
            new_result = copy.copy(result)  # 由於有 4 種情況,需要 clone 當前的解並傳入被調用的函數

            new_result.append(rewards[i])  # 記錄當前的選擇,解決一點問題
            get_sum_combo(total_reword - rewards[i], new_result)  # 剩下的問題,留給嵌套調用去解決

思考題

一個整數可以被分解爲多個整數的乘積,例如,6 可以分解爲 2x3。請使用遞歸編程的方法,爲給定的整數 n,找到所有可能的分解(1 在解中最多隻能出現 1 次)。例如,輸入 8,輸出是可以是 1x8, 8x1, 2x4, 4x2, 1x2x2x2, 1x2x4, ……


06 | 遞歸(下):分而治之,從歸併排序到MapReduce

學習筆記

思考題

你有沒有想過,在歸併排序的時候,爲什麼每次都將原有的數組分解爲兩組,而不是更多組呢?如果分爲更多組,是否可行?

老師講的是最經典的2路歸併排序算法,時間複雜度是O(NlogN)。如果將數組分解成更多組(假設分成K組),是K路歸併排序算法,當然是可以的,比如K=3時,是3路歸併排序,依次類推。3路歸併排序是經典的歸併排序(路歸併排序)的變體,通過遞歸樹方法計算等式T(n)= 3T(n/3)+ O(n)可以得到3路歸併排序的時間複雜度爲O(NlogN),其中logN以3爲底(不方便打出,只能這樣描述)。儘管3路合併排序與2路相比,時間複雜度看起來比較少,但實際上花費的時間會變得更高,因爲合併功能中的比較次數會增加。類似的問題還有二分查找比三分查找更受歡迎。


07 | 排列:如何讓計算機學會“田忌賽馬”?

舉例

  • 田忌賽馬
  • 黑客竊取系統密碼:暴力破解法

定義

  • 對於 n 個元素的全排列,所有可能的排列數量就是 nx(n-1)x(n-2)x…x2x1,也就是 n!;
  • 對於 n 個元素裏取出 m(0<m≤n) 個元素的不重複排列數量是 nx(n-1)x(n-2)x…x(n - m + 1),也就是 n!/(n-m)!。

學習筆記

思考題

假設有一個 4 位字母密碼,每位密碼是 a~e 之間的小寫字母。你能否編寫一段代碼,來暴力破解該密碼?(提示:根據可重複排列的規律,生成所有可能的 4 位密碼。)


08 | 組合:如何讓計算機安排世界盃的賽程?

舉例

  • 世界盃賽程
  • 多維度的數據分析
  • 自然語言處理的優化(多元文法)

定義

  • n 個元素裏取出 m 個的組合,可能性數量就是 n 個裏取 m 個的排列數量,除以 m 個全排列的數量,也就是 (n! / (n-m)!) / m!。
  • 對於全組合而言,可能性爲 2^n 種。例如,當 n=3 的時候,全組合包括了 8 種情況。

學習筆記


09 | 動態規劃(上):如何實現基於編輯距離的查詢推薦?


10 | 動態規劃(下):如何求得狀態轉移方程並進行編程實現?

什麼時候該用動態規劃?

首先,如果一個問題有很多種可能,看上去需要使用排列或組合的思想,但是最終求的只是某種最優解(例如最小值、最大值、最短子串、最長子串等等),那麼你不妨試試是否可以使用動態規劃。

其次,狀態轉移方程是個關鍵。你可以用狀態轉移表來幫助自己理解整個過程。如果能找到準確的轉移方程,那麼離最終的代碼實現就不遠了。

學習筆記


11 | 樹的深度優先搜索(上):如何才能高效率地查字典?

前綴樹(prefix tree)或者叫字典樹(trie)

圖論

前綴樹是一種有向樹。那什麼是有向樹?顧名思義,有向樹就是一種樹,特殊的就是,它的邊是有方向的。而樹是沒有簡單迴路的連通圖。

有向樹:顧名思義,有向樹是一種特殊的樹,其中的邊都是有向的,而且它滿足以下幾個條件:

  • 有且僅有一個結點的入度爲 0,這個結點被稱爲根;
  • 除根以外的所有結點,入度都爲 1。從樹根到任一結點有且僅有一條有向通路。

前綴樹的構建和查詢

  • 構建前綴樹

  • 查詢前綴樹

假設我們已經使用牛津詞典,構建完了一個完整的前綴樹,現在我們就能按照開篇所說的那種方式,查找任何一個單詞了。從前綴樹的根開始,查找下一個結點,順着這個通路走下去,一直走到到某個結點。如果這個結點及其前綴代表了一個存在的單詞,而待查找的單詞和這個結點及其前綴正好完全匹配,那就說明成功找到了一個單詞。否則,就表示無法找到。

這裏還有幾種特殊情況,需要注意。

  1. 如果還沒到葉子結點的時候,待查的單詞就結束了。這個時候要看最後匹配上的非葉子結點是否代表一個單詞;如果不是,那說明被查單詞並不在字典中。
  2. 如果搜索到前綴樹的葉子結點,但是被查單詞仍有未處理的字母。由於葉子結點沒有子結點,這時候,被查單詞不可能在字典中。
  3. 如果搜索到一半,還沒到達葉子結點,被查單詞也有尚未處理的字母,但是當前被處理的字母已經無法和結點上的字符匹配了。這時候,被查單詞不可能在字典中。

前綴樹的構建和查詢這兩者在本質上其實是一致的。構建的時候,我們需要根據當前的前綴進行查詢,然後才能找到合適的位置插入新的結點。而且,這兩者都存在一個不斷重複迭代的查找過程,我們把這種方式稱爲深度優先搜索(Depth First Search)。

  • 深度優先搜索(Depth First Search)

所謂樹的深度優先搜索,其實就是從樹中的某個結點出發,沿着和這個結點相連的邊向前走,找到下一個結點,然後以這種方式不斷地發現新的結點和邊,一直搜索下去,直到訪問了所有和出發點連通的點、或者滿足某個條件後停止。

如果到了某個點,發現和這個點直接相連的所有點都已經被訪問過,那麼就回退到在這個點的父結點,繼續查看是否有新的點可以訪問;如果沒有就繼續回退,一直到出發點。由於單棵樹中所有的結點都是連通的,所以通過深度優先的策略可以遍歷樹中所有的結點,因此也被稱爲深度優先搜索。

小結


12 | 樹的深度優先搜索(下):如何才能高效率地查字典?

如何使用數據結構表達樹?

  • 我們再來看的特點。樹的結點及其之間的邊,和鏈表中的結點和鏈接在本質上是一樣的,因此,我們可以模仿鏈表的結構,用編程語言中的指針或對象引用來構建樹。
  • 除此之外,我們其實還可以用二維數組。用數組的行或列元素表示樹中的結點,而行和列共同確定了兩個樹結點之間是不是存在邊。可是在樹中,這種二維關係通常是非常稀疏的、非常動態的,所以用數組效率就比較低下。

如何使用遞歸和棧實現深度優先搜索?

深度優先搜索的過程和遞歸調用在邏輯上是一致的

在查詢的過程中,至少有三種情況是無法在字典裏找到被查的單詞的。於是,我們需要在遞歸的代碼中做相應的處理。

  • 第一種情況:被查單詞所有字母都被處理完畢,但是我們仍然無法在字典裏找到相應的詞條。
  • 第二種情況:搜索到前綴樹的葉子結點,但是被查單詞仍有未處理的字母,就返回查找失敗。
  • 第三種情況:搜索到中途,還沒到達葉子結點,被查單詞也有尚未處理的字母,但是當前被處理的字母已經無法和結點上的 label 匹配,返回查找失敗。是不是葉子仍然通過結點對象的 sons 變量來判斷。

儘管函數遞歸調用非常直觀,可是也有它自身的弱點。函數的每次嵌套,都可能產生新的變量來保存中間結果,這可能會消耗大量的內存。所以這裏我們可以用一個更節省內存的數據結構,棧(Stack)

深度優先搜索時候的例子

然後,我們用棧來實現一下這個過程。

從上面的步驟來看,棧先進後出的特性,可以模擬函數的遞歸調用。實際上,計算機系統裏的函數遞歸,在內部也是通過棧來實現的。如果我們不使用函數調用時自動生成的棧,而是手動使用棧的數據結構,就能始終保持數據的副本只有一個,大大節省內存的使用量。

小結

在歸併排序的數據分解階段,初始的數據集就是樹的根結點,二分之前的數據集代表父節點,而二分之後的左半邊的數據集和右半邊的數據集都是父結點的子結點。分解過程一直持續到單個的數值,也就是最末端的葉子結點,很明顯這個階段可以用樹來表示。如果使用遞歸編程來進行數據的切分,那麼這種實現就是深度優先搜索的體現。

在排列中,我們可以把空集認爲是樹的根結點,如果把每次選擇的元素作爲父結點,那麼剩下可選擇的元素,就構成了這個父結點的子結點。而每多選擇一個元素,就會把樹的高度加 1。因此,我們也可以使用遞歸和深度優先搜索,列舉所有可能的排列。

從這兩個例子,我們可以看出有些數學思想都是相通的,例如遞歸、排列和深度優先搜索等等。

我來總結一下,其實深度優先搜索的核心思想,就是按照當前的通路,不斷地向前進,當遇到走不通的時候就回退到上一個結點,通過另一個新的邊進行嘗試。如果這一個點所有的方向都走不通的時候,就繼續回退。這樣一次一次循環下去,直到到達目標結點。樹中的每個結點,既可以表示某個子問題和它所對應的抽象狀態,也可以表示某個數據結構中一部分具體的值。


13 | 樹的廣度優先搜索(上):人際關係的六度理論是真的嗎?

社交網絡中的好友問題

LinkedIn、Facebook、微信、QQ 這些社交網絡平臺都有大量的用戶。在這些社交網絡中,非常重要的一部分就是人與人之間的“好友”關係。

在數學裏,爲了表示這種好友關係,我們通常使用圖中的結點來表示一個人,而用圖中的邊來表示人和人之間的相識關係,那麼社交網絡就可以用圖論來表示。而“相識關係”又可以分爲單向和雙向。

這些被推薦的候選人,和我都有不少的共同連接,也就是共同好友。所以他們都是我的二度好友。但是,他們和我之間還沒有建立直接的聯繫,因此不是一度好友。也就是說,對於某個當前用戶,LinkedIn 是這麼來選擇好友推薦的:

  • 被推薦的人和當前用戶不是一度好友;
  • 被推薦的人和當前用戶是二度好友。

那爲什麼我們不考慮“三度“甚至是“四度”好友呢?我前面已經說過,兩人之間最短的通路長度,表示他們是幾度好友。那麼三度或者四度,就意味着兩人間最短的通路也要經歷 2 個或更多的中間人,他們的關係就比較疏遠,互相添加好友的可能性就大大降低。

深度優先搜索面臨的問題

這種情況下,你可能會想到上一篇介紹的深度優先搜索。深度優先搜索不僅可以用在樹裏,還可以應用在圖裏。不過,我們要面臨的問題是圖中可能存在迴路,這會增加通路的長度,這是我們在計算幾度好友時所不希望的。所以在使用深度優選搜索的時候,一旦遇到產生迴路的邊,我們需要將它過濾。具體的操作是,判斷新訪問的點是不是已經在當前通路中出現過,如果出現過就不再訪問。

如果過濾掉產生迴路的邊,從一個用戶出發,我們確實可以使用深度優先的策略,搜索完他所有的 n 度好友,然後再根據關係的度數,從二度、三度再到四度進行排序。這是個解決方法,但是效率太低了。爲什麼呢?

六度理論告訴我們,你的社會關係會隨着關係的度數增加,而呈指數級的膨脹。這意味着,在深度搜索的時候,每增加一度關係,就會新增大量的好友。但是你仔細回想一下,當我們在用戶推薦中查看可能的好友時,基本上不會看完所有推薦列表,最多也就看個幾十個人,一般可能也就看看前幾個人。所以,如果我們使用深度優先搜索,把所有可能的好友都找到再排序,那效率實在太低了。

什麼是廣度優先搜索?

更高效的做法是,我們只需要先找到所有二度的好友,如果二度好友不夠了,再去找三度或者四度的好友。這種好友搜索的模式,其實就是我們今天要介紹的廣度優先搜索。

廣度優先搜索(Breadth First Search),也叫寬度優先搜索,是指從圖中的某個結點出發,沿着和這個點相連的邊向前走,去尋找和這個點距離爲 1 的所有其他點。只有當和起始點距離爲 1 的所有點都被搜索完畢,纔開始搜索和起始點距離爲 2 的點。當所有和起始點距離爲 2 的點都被搜索完了,纔開始搜索和起始點距離爲 3 的點,如此類推。

例子

廣度優先搜索其實就是橫向搜索一顆樹。

如何實現社交好友推薦?

需要用到隊列這種先進先出(First In First Out)的數據結構。

那麼在廣度優先搜索中,隊列是如何工作的呢?這主要分爲以下幾個步驟。

  • 首先,把初始結點放入隊列中。然後,每次從隊列首位取出一個結點,搜索所有在它下一級的結點。接下來,把新發現的結點加入隊列的末尾。重複上述的步驟,直到沒有發現新的結點爲止。

我以上面的樹狀圖爲例,並通過隊列實現廣度優先搜索。

小結


14 | 樹的廣度優先搜索(下):爲什麼雙向廣度優先搜索的效率更高?

如何更高效地求兩個用戶間的最短路徑?

雙向廣度優先搜索。它巧妙地運用了兩個方向的廣度優先搜索,大幅降低了搜索的度數。

你可以同時實現單向廣度優先搜索和雙向廣度優先搜索,然後通過實驗來比較兩者的執行時間,看看哪個更短。如果實驗的數據量足夠大(比如說結點在 1 萬以上,邊在 5 萬以上),你應該能發現,雙向的方法對時間和內存的消耗都更少。爲什麼雙向搜索的效率更高呢?我以平均好友度數爲 4,給你舉例講解。

左邊的圖表示從結點 a 單向搜索走 2 步,右邊的圖表示分別從結點 a 和 雙向搜索各走 1 步。很明顯,左邊的結點有 16 個,明顯多於右邊的 8 個結點。而且,隨着每人認識的好友數、搜索路徑的增加,這種差距會更加明顯。

我們假設每個地球人平均認識 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 查詢,都可以實現聚合的功能。

我們可以嵌套使用不同的聚合,獲得層級型的統計結果。但是,實際上,針對一個規模超大的數據集,聚合的嵌套可能會導致性能嚴重下降。這裏我來談談如何利用廣度優先的策略,對這種問題進行優化。

我們假設這個社交網有 5 萬用戶,每位用戶平均在 5 家公司工作過,而用戶在每家公司平均有 10 名共事的同事,那麼針對用戶的計數器有 5 萬個,針對“每個用戶 + 每個公司”的計數器有 25 萬個,而到了“每個用戶 + 每個公司 + 每位同事”的計數器,就已經達到 250 萬個了,三個層級總共需要 280 萬計數器。

我們假設一個計數器是 4 個字節,那麼 280 萬個計數器就需要消耗超過 10M 的內存。對於高併發、低延遲的實時性服務,如果每個請求都要消耗 10M 內存,很容易就導致服務器崩潰。另外,實時性的服務,往往只需要前若干個結果就足以滿足需求了。在這種情況下,完全基於排列的設計就有優化的空間了。

從剛纔那張圖中,其實我們就能想到一些優化的思路。

對於只需要返回前若干結果的應用場景,我們可以對圖中的樹狀結構進行剪枝,去掉絕大部分不需要的結點和邊,這樣就能節省大量的內存和 CPU 計算。

比如,如果我們只需要返回前 100 個參與項目最多的用戶,那麼就沒有必要按照深度優先的策略,去擴展樹中高度爲 2 和 3 的結點了,而是應該使用廣度優先策略,首先找出所有高度爲 1 的結點,根據項目數量進行排序,然後只取出前 100 個,把計數器的數量從 5 萬個一下子降到 100 個。

以此類推,我們還可以控制高度爲 2 和 3 的結點之數量。如果我們只要看前 100 位用戶,每位用戶只看排名第一的公司,而每家公司只看合作最多的 3 名同事,那麼最終計數器數量就只有 50000+100x5+100x1x10=51500。只有文字還是不太好懂,我畫了一張圖,幫你理解這個過程。


如果一個項目用到排列組合的思想,我們需要在程序裏使用大量的變量,來保存數據或者進行計算,這會導致內存和 CPU 使用量的急劇增加。在允許的情況下,我們可以考慮使用廣度優先策略,對排列組合所生成的樹進行優化。這樣,我們就可以有效地縮減樹中靠近根的結點數量,避免之後樹的爆炸性生長。

學習筆記


15 | 從樹到圖:如何讓計算機學會看地圖?

基於廣度優先或深度優先搜索的方法

我畫了一張圖,方便你理解多條通路對最終結果的影響。這張圖中有 A、B、C、D、E 五個結點,分別表示不同的地點。

從這個圖中可以看出,從 A 點出發到到目的地 B 點,一共有三條路線。如果你直接從 A 點到 B 點,度數爲 1,需要 50 分鐘。從 A 點到 C 點再到 B 點,雖然度數爲 2,但總共只要 40 分鐘。從 A 點到 D 點,到 E 點,再到最後的 B 點,雖然度數爲 3,但是總耗時只有 35 分鐘,比其他所有的路線更優。這種情形之下,使用廣度優先找到的最短通路,不一定是最優的路線。所以,對於在地圖上查找最優路線的問題,無論是廣度優先還是深度優先的策略,都需要遍歷所有可能的路線,然後取最優的解。

一個優化的版本:Dijkstra 算法

Dijkstra 算法的核心思想是,對於某個結點,如果我們已經發現了最優的通路,那麼就無需在將來的步驟中,再次考慮這個結點。Dijkstra 算法很巧妙地找到這種點,而且能確保已經爲它找到了最優路徑。

漫畫:圖的 “最短路徑” 問題

小結

我們使用 Dijkstra 算法來查找地圖中兩點之間的最短路徑,而今天我所介紹的 Dijkstra 使用了更爲抽象的“權重”。如果我們把結點作爲地理位置,邊的權重設置爲路上所花費的時間,那麼 Dijkstra 算法就能幫助我們找到,任意兩個點之間耗時最短的路線。

除了時間之外,你也可以對圖的邊設置其他類型的權重,比如距離、價格,這樣 Dijkstra 算法可以讓用戶找到地圖任意兩點之間的最短路線,或者出行的最低價格等。有的時候,邊的權重越大越好,比如觀光車開過某條路線的車票收入。對於這種情況,Dijkstra 算法就需要調整一下,每次找到最大的 mw,更新鄰近結點時也要找更大的值。所以,你只要掌握核心的思路就可以了,具體的實現可以根據情況去靈活調整。


16 | 時間和空間複雜度(上):優化性能是否只是“紙上談兵”?

算法複雜度

算法複雜度是一個比較抽象的概念,通常只是一個估計值,它用於衡量程序在運行時所需要的資源,用於比較不同算法的性能好壞。同一段代碼處理不同的輸入數據所消耗的資源也可能不同,所以分析複雜度時,需要考慮三種情況,最差情況、最好情況和平均情況。

複雜度分析會考慮性能的各個方面,不過我們最關注的是兩個部分,時間和空間。時間因素是指程序執行的耗時多少,空間因素是程序佔用內存或磁盤存儲的多少。因此,我們把複雜度進一步細分爲時間複雜度和空間複雜度。

漸進時間複雜度:表示程序運行時間隨着問題複雜度增加而變化的規律。

漸進空間複雜度:表示程序所需要的存儲空間隨着問題複雜度增加而變化的規律。

我們可以使用大 O 來表示這兩者。

6 個通用法則

  • 四則運算法則

對於時間複雜度,代碼的添加,意味着計算機操作的增加,也就是時間複雜度的增加。如果代碼是平行增加的,就是加法。如果是循環、嵌套或者函數的嵌套,那麼就是乘法。

比如二分查找的代碼中,第一步是對長度爲 n 的數組排序,第二步是在這個已排序的數組中進行查找。這兩個部分是平行的,所以計算時間複雜度時可以使用加法。第一步的時間複雜度是 O(nlogn),第二步的時間複雜度是 O(logn),所以時間複雜度是 O(nlogn)+O(logn)。

對於空間複雜度,同樣如此。需要注意的是,空間複雜度看的是對內存空間的使用,而不是計算的次數。如果語句中沒有新開闢空間,那麼無論是平行增加還是嵌套增加代碼,都不會增加空間複雜度。

  • 主次分明法則

這個法則主要是運用了數量級和運算法則優先級的概念。在剛剛介紹的第一個法則中,我們會對代碼不同部分所產生的複雜度進行相加或相乘。使用加法或減法時,你可能會遇到不同數量級的複雜度。這個時候,我們只需要看最高數量級的,而忽略掉常量、係數和較低數量級的複雜度。

  • 齊頭並進法則

這個法則主要是運用了多元變量的概念,其核心思想是複雜度可能受到多個因素的影響。在這種情況下,我們要同時考慮所有因素,並在複雜度公式中體現出來。

我在之前的文章中,介紹了使用動態規劃解決的編輯距離問題。從解決方案的推導和代碼可以看出,這個問題涉及兩個因素:參與比較的第一個字符串的長度 n 和第二個字符串的長度 m。代碼使用了兩次嵌套循環,第一層循環的長度是 n,第二層循環的長度爲 m,根據乘法法則,時間複雜度爲 O(nm)。而空間複雜度,很容易從推導結果的狀態轉移表得出,也是 O(nm)。

  • 排列組合法則

  • 一圖千言法則

歸併排序、二分查找、動態規劃(狀態轉移表)

  • 時空互換法則

對於這個規則最直觀的例子就是緩存系統。在沒有緩存系統的時候,每次請求都要服務器來處理,因此時間複雜度比較高。如果使用了緩存系統,那麼我們會消耗更多的內存空間,但是降低了請求相應的時間。

小結


17 | 時間和空間複雜度(下):如何使用六個法則進行復雜度分析?

案例分析一:廣度優先搜索

在有關圖遍歷的專欄中,我介紹了單向廣度優先和雙向廣度優先搜索。當時我提到了通常情況下,雙向廣度優先搜索性能更好。那麼,我們應該如何從理論上分析,誰的效率更高呢?

案例分析二:全文搜索

搜索引擎你一定用的很多了,它最基本的也最重要的功能,就是根據你輸入的關鍵詞,查找指定的數據對象。這裏,我以文本搜索爲例。要查找某個關鍵詞是不是出現在一篇文章裏,最基本的處理方式有兩種。

第一,把全文作爲一個很長的字符串,把用戶輸入的關鍵詞作爲一個子串,那這個搜索問題就變成了子串匹配的問題。假設字符串平均長度爲 n 個字符,關鍵詞平均長度爲 m 個字符,使用最簡單的暴力法,就是把代表全文的字符串的每個字符,和關鍵詞字符串的每個字符兩兩相比,那麼時間複雜度就是 O(n*m)。

第二,對全文進行分詞,把全文切分成一個個有意義的詞,那麼這個搜索問題就變成了把輸入關鍵詞和這些切分後的詞進行匹配的問題。

爲了降低搜索引擎在查詢時候的時間複雜度,我們要引入倒排索引(或逆向索引),這就是典型的犧牲空間來換取時間。如果你對倒排索引的概念不熟悉,我打個比方給你解釋一下。

假設你是一個熱愛讀書的人,當你進入圖書館或書店的時候,怎樣快速找到自己喜愛的書籍?沒錯,就是看書架上的標籤。如果看到一個架子上標着“極客時間 - 數學專欄”,那麼恭喜你,離程序員的數學書就不遠了。而倒排索引做的就是**“貼標籤”**的事情。

爲了實現倒排索引,對於每篇文章我們都要先進行分詞,然後將分好的詞作爲該篇的標籤。讓我們看看下面三篇樣例文章和對應的分詞,也就是標籤。其中,分詞之後,我也做了一些標準化的處理,例如全部轉成小寫、去掉時態等。

上面這個表格看上去並沒有什麼特別。好,體現“倒排”的時刻來了。我們轉換一下,不再從文章的角度出發,而是從標籤的角度出發來看問題。也就是說,從每個標籤,我們能找到哪些文章?通過這樣的思考,我們可以得到下面這張表。

你看看,有了這張表格,想知道查找某個關鍵詞在哪些文章中出現,是不是很容易了呢?整個過程就像在哈希表中查找一樣,時間複雜度只有 O(1) 了。當然,我們所要付出的成本就是倒排索引這張表。假設有 n 個不同的單詞,而每個單詞所對應的文章平均數爲 m 的話,那麼這種索引的空間複雜度就是 O(n*m)。好在 n 和 m 通常不會太大,對內存和磁盤的消耗都是可以接受的。


18 | 總結課:數據結構、編程語句和基礎算法體現了哪些數學思想?

數據結構

  • 數組

一定是你經常使用的數據結構。它的特點你應該很清楚。數組可以通過下標,直接定位到所需的數據,因此數組特別適合快速地隨機訪問。它常常和循環語句相結合,來實現迭代法,例如二分查找、斐波那契數列等等。

另外,我們將要在“線性代數篇”介紹的矩陣,也可以使用多維數組來表示。不過,數組只對稠密的數列更有效。如果數列非常稀疏,那麼很多數組的元素就是無效值,浪費了存儲空間。此外,數組中元素的插入和刪除也比較麻煩,需要進行數據的批量移動

  • 鏈表

那麼對於稀疏的數列而言,什麼樣的數據結構更有效呢?答案是鏈表。鏈表中的結點存儲了數據,而鏈表結點之間的相連關係,在 C 和 C++ 語言中是通過指針來實現的,而在 Java 語言中是通過對象引用來實現的。

鏈表的特點是不能通過下標來直接訪問數據,而是必須按照存儲的結構逐個讀取。這樣做的優勢在於,不必事先規定數據的數量,也不再需要保存無效的值,表示稀疏的數列時可以更有效的利用存儲空間,同時也利於數據的動態插入和刪除。但是,相對於數組而言,鏈表無法支持快速地隨機訪問,進行讀寫操作時就更耗時。

和數組一樣,鏈表也可以是多維的。對於非常稀疏的矩陣,也可以用多維鏈表的結構來表達。此外,在鏈表結構中,點和點之間的連接,分別體現了圖論中的頂點和邊。因此,我們還可以使用指針、對象引用等來表示圖結構中的頂點和邊。常見的圖模型,例如多叉樹、無向圖和有向圖等,都可以用指針或引用來實現。

  • 哈希

哈希表就可以通過數組和鏈表來構造。在很多編程語言中,哈希表的實現採用的是鏈地址哈希表。這種方法的主要思想是,先分配一個很大的數組空間,而數組中的每一個元素都是一個鏈表的頭部。隨後,我們就可以根據哈希函數算出的哈希值(也叫哈希的 key),找到數組的某個元素及對應的鏈表,然後把數據添加到這個鏈表中。

之所以要這樣設計,是因爲存在哈希衝突。對於不同的數據,哈希函數可能產生相同的哈希值,這就是哈希衝突。如果數組的每個元素都只能存放一個數據,那就無法解決衝突。如果每個元素對應了一個鏈表,那麼當發生衝突的時候,我們就可以把多個數據添加到同一個鏈表中。可是,把多個數據存放在一個鏈表,就代表訪問效率不高。所以,我們要儘量找到一個合理的哈希函數,減少衝突發生的機會,提升檢索的效率。

在第 2 講中,我還提到了使用求餘相關的操作來實現哈希函數。我這裏舉個例子。你可以看我畫的這幅圖。

我們把對 100 求餘作爲哈希函數。因此數組的長度是 100。對於每一個數字,通過它對 100 求餘,確定它在數組中的位置。如果多個數字的求餘結果一樣,就產生衝突,使用鏈表來解決。我們可以看到,表中位置 98 的鏈表沒有衝突,而 0、1、2、3 和 99 位置的鏈表都有衝突。

說完了哈希,我們來看看棧這種數據結構。我在介紹樹的深度優先搜索時講到棧。它是先進後出的。在我們進行函數遞歸的時候,函數調用和返回的順序,也是先進後出,所以,棧體現了遞歸的思想,可以實現基於遞歸的編程。實際上,計算機系統裏的函數遞歸,在內部也是通過棧來實現的。雖然直接通過棧來實現遞歸不如函數遞歸調用那麼直觀,但是,由於棧可以避免過多的中間變量,它可以節省內存空間的使用

  • 隊列

我在介紹廣度優先搜索策略時,談到了隊列。隊列和棧最大的不同在於,它是一種先進先出的數據結構,先進入隊列的元素會優先得到處理。隊列模擬了日常生活中人們排隊的現象,其思想已經延伸到很多大型的數據系統中,例如消息隊列。

在消息系統中,生產者會源源不斷地推送新的數據,而消費者會對這些消息進行處理。可是,有時消費者的處理速度會慢於生產者推送的速度,這會帶來很多複雜的後續問題,因此我們可以通過隊列實現消息的緩衝。新產生的數據會先進入隊列,直到消費者處理它。經過這樣的異步處理,消息的隊列實現了生產者和消費者的松耦合,對消費者起到了保護作用,使它不容易被數據洪流沖垮。

比哈希表,隊列和棧更爲複雜的數據結構是基於圖論中的各種模型,例如各種二叉樹、多叉樹、有向圖和無向圖等等。通常,這些模型表示了頂點和頂點之間的稀疏關係,所以它們常常是基於指針或者對象引用來實現的。我在講前綴樹、社交關係圖和交通地圖的案例中,都使用了這些模型。另外,樹模型中的多叉樹、特別是二叉樹體現了遞歸的思想。之前的遞歸編程的案例中的圖示也可以對應到多叉樹的表示。

編程語句

  • 條件語句

條件語句的一個關鍵元素是布爾表達式。它其實體現了邏輯代數中邏輯和集合的概念。邏輯代數,也被稱爲布爾代數,主要包括了邏輯表達式及其相關的邏輯運算,可以幫助我們消除自然語言所帶來的歧義,並嚴格、準確地描述事物。

當然,邏輯代數在計算機中的應用,遠不止條件語句。例如 SQL 語言中的 Select 語句和布爾檢索模型。Select 是 SQL 查詢語言中十分常用的語句。這個語句將根據指定的邏輯表達式,在一個數據庫中進行查詢並返回結果,而返回的結果就是滿足條件的記錄之集合。類似地,布爾檢索模型利用邏輯表達式,確定哪些文檔滿足檢索的條件並把它們作爲結果返回。

這裏順便提一下,除了條件語句中的布爾表達式,邏輯代數還體現在編程中的其他地方。例如,SQL 語言中的 Join 操作。Join 有多種類型,每種類型其實都對應了一種集合的操作。

  1. 內連接(inner join):假設被連接的兩張數據表分別是左表和右表,那麼內連接查詢能將左表和右表中能關聯起來的數據連接後返回,返回的結果就是兩個表中所有相匹配的數據。如果認爲左表是集合 A,右表是集合 B,那麼從集合的角度來說,內連接產生的結果是 A、B 兩個集合的交集。
  2. 外連接(outer join):外連接可以保留左表,右表或全部表。根據這些行爲的不同,可分爲左外連接、右外連接和全連接。無論哪一種,都是對應於不同的集合操作。
  • 循環語句

循環語句可以讓我們進行有規律性的重複性操作,直到滿足某個條件。這和迭代法中反覆修改某個值的操作非常一致。所以循環常用於迭代法的實現,例如二分或者牛頓法求解方程的根。在之前的迭代法講解中,我經常使用循環來實現編碼。另外,循環語句也會經常和布爾表達式相結合。嵌套的多層循環,常常用於比較多個元素的大小,或者計算多個元素之間的相似度等等,這也體現了排列組合的思想。

  • 函數的調用

至於函數的調用,一個函數既可以調用自己,也可以調用其他不同的函數。如果不斷地調用自己,這就體現了遞歸的思想。同時,函數的遞歸調用也可以體現排列組合的思想。

基礎算法

  • 分治與哈希

介紹分治思想的時候,我談及了 MapReduce 的數據切分。在分佈式系統中,除了數據切分,我們還要經常處理的問題是:如何確定服務請求被分配到哪臺機器上?這就引出了負載均衡算法。

常見的包括輪詢或者源地址哈希算法。輪詢算法把請求按順序輪流地分配到後端服務器上,它並不關心每臺服務器當前的負載。如果我們對每個請求標上一個自動增加的 ID,我們可以認爲輪詢算法是對請求的 ID 進行求餘操作(或者是求餘的哈希函數),被除數就是可用服務器的數量,餘數就是接受請求的服務器 ID。而源地址哈希進一步擴展了這個思想,擴展主要體現在:

  1. 它可以對請求的 IP 或其他唯一標識進行哈希,而不一定是請求的 ID;
  2. 哈希函數的變換操作不一定是求餘。

不管是對何種數據進行哈希變換,也不管是何種哈希函數,只要能爲每個請求確定哈希 key 之後,我們就能爲它查找對應的服務器。

  • 迭代法和哈希

字符串的編輯距離,但是沒有涉及字符串匹配的算法。知名的 RK(Rabin-Karp)匹配算法,在暴力匹配(Brute Force)基礎之上,充分利用了迭代法和哈希,提升了算法的效率。

首先,RK 算法可以根據兩個字符串哈希後的值。來判斷它們是不是相同。如果哈希值不同,則兩個字符串肯定不同,不用再比較;此外,RK 算法中的哈希設計非常巧妙,讓相鄰兩個子字符串的哈希值產生了固定的聯繫,讓我們可以通過前一個子串的哈希值,推導出後一個子串的哈希值,這樣就能使用迭代法來計算每個子串的哈希值,大大減少了用於哈希函數的計算。

  • 回溯

除了分治和動態規劃,另一個常用的算法思想是回溯。我們可以使用回溯來解決的問題包括八皇后和 0/1 揹包等等。回溯實際上體現了遞歸和排列的思想。不過,它對搜索空間做了一些優化,提前排除了不可能的情況,提升了算法整體的效率。當然,既然回溯體現了遞歸的思想,也可以把整個搜索狀態表示成樹,而對結果的搜索就是樹的深度優先遍歷。

小結

不同的數據結構,都是在編程中運用數學思維的產物。每種數據結構都有自身的特點,有利於我們更方便地實現某種特定的數學模型。


數學專欄課外加餐(一) | 我們爲什麼需要反碼和補碼?

什麼是符號位?爲什麼要有符號位?

符號位是有符號二進制數中的最高位,我們需要它來表示負數。

如何讓計算機理解哪些是正數,哪些是負數呢?

爲此,人們把二進制數分爲有符號數(signed)和無符號數(unsigned)。

有些編程語言,比如 Java,它所有和數字相關的數據類型都是有符號位的;而有些編程語言,比如 C 語言,它有諸如 unsigned int 這種無符號位的數據類型。

什麼是溢出?

對於 n 位的數字類型,符號位是 1,後面 n-1 位全是 0,我們把這種情形表示爲 -2^(n-1) ,而不是 2^(n-1)。一旦某個數字超過了這些限定,就會發生溢出。如果超出上限,就叫上溢出(overflow)。如果超出了下限,就叫下溢出(underflow)。

溢出之後會發生什麼呢?

n 位數字的最大的正值,其符號位爲 0,剩下的 n-1 位都爲 1,再增大一個就變爲了符號位爲 1,剩下的 n-1 位都爲 0。而符號位是 1,後面 n-1 位全是 0,我們已經說過這表示 -2^(n-1)。

那麼就是說,上溢出之後,又從下限開始,最大的數值加 1,就變成了最小的數值,週而復始,這不就是餘數和取模的概念嗎?下面這個圖可以幫助你的理解。

其中右半部分的虛線表示已經溢出的區間,而爲了方便你理解,我將溢出後所對應的數字也標在了虛線的區間裏。由此可以看到,所以說,**計算機數據的溢出,就相當於取模。**而用於取模的除數就是數據類型的上限減去下限的值,再加上 1,也就是 (2(n-1)-1)-(-2(n-1))+1=2x2(n-1)-1+1=2n-1+1。

你可能會好奇,這個除數爲什麼不直接寫成 2^n 呢?這是因爲 2^n 已經是 n+1 位了,已經超出了 n 位所能表示的範圍。

二進制的原碼、反碼及補碼

原碼就是我們看到的二進制的原始表示。對於有符號的二進制來說,原碼的最高位是符號位,而其餘的位用來表示該數字絕對值的二進制。所以 +2 的原碼是 000…010,-2 的的原碼是 100.…010。
*
那麼我們是不是可以直接使用負數的原碼來進行減法計算呢?答案是否定的。

如果負數的原碼並不適用於減法操作,那該怎麼辦呢?這個問題的解答還要依賴計算機的溢出機制。

我剛剛介紹了溢出以及取模的特性,我們可以充分利用這一點,對計算機裏的減法進行變換。假設有 i-j,其中 j 爲正數。如果 i-j 加上取模的除數,那麼會形成溢出,並正好能夠獲得我們想要的 i-j 的運算結果。如果我說的還是不太好理解,你可以參考下面這張圖。

我們把這個過程用表達式寫出來就是 i-j=(i-j)+(2n-1+1)=i+(2n-1-j+1)。

其中 2^n-1 的二進制碼在不考慮符號位的情況下是 n-1 位的 1,那麼 2^n-1-2 的結果就是下面這樣的:

從結果可以觀察出來,所謂 2^n-1-j 相當於對正數 j 的二進制原碼,除了符號位之外按位取反(0 變 1,1 變 0)。由於負數 -j 和正數 j 的原碼,除了符號位之外都是相同的,所以,2^n-1-j 也相當於對負數 -j 的二進制原碼,除了符號位之外按位取反。我們把 2^n-1-j 所對應的編碼稱爲負數 -j 的反碼。所以,-2 的反碼就是 1111…1101。

有了反碼的定義,那麼就可以得出** i-j=i+(2^n-1-j+1)=i 的原碼 +(-j 的反碼)+1**。

如果我們把 -j 的反碼加上 1 定義爲 -j 的補碼,就可以得到 i-j=i 的原碼 +(-j 的補碼)

由於正數的加法無需負數的加法這樣的變換,因此正數的原碼、反碼和補碼三者都是一樣的。最終,我們可以得到 i-j=i 的補碼 +(-j 的補碼)。

換句話說,計算機可以通過補碼,正確地運算二進制減法。


數學專欄課外加餐(二) | 位操作的三個應用實例

位操作的應用實例

  • 驗證奇偶數

仔細觀察,你會發現偶數的二進制最後一位總是 0,而奇數的二進制最後一位總是 1,因此對於給定的某個數字,我們可以把它的二進制和數字 1 的二進制進行按位“與”的操作,取得這個數字的二進制最後一位,然後再進行判斷。

  • 交換兩個數字

你應該知道,要想在計算機中交換兩個變量的值,通常都需要一箇中間變量,來臨時存放被交換的值。不過,利用異或的特性,我們就可以避免這個中間變量。具體的代碼如下:

x = 1
y = 2
x = (x ^ y)
y = x ^ y
x = x ^ y

print x,y
  • 集合操作

集合和邏輯的概念是緊密相連的,因此集合的操作也可以通過位的邏輯操作來實現。

假設我們有兩個集合{1, 3, 8}和{4, 8}。我們先把這兩個集合轉爲兩個 8 位的二進制數,從右往左以 1 到 8 依次來編號。

如果某個數字在集合中,相應的位置 1,否則置 0。那麼第一個集合就可以轉換爲 10000101,第二個集合可以轉換爲 10001000。那麼這兩個二進制數的按位與就是 10000000,只有第 8 位是 1,代表了兩個集合的交爲{8}。而這兩個二進制數的按位或就是 10001101,第 8 位、第 4 位、第 3 位和第 1 位是 1,代表了兩個集合的併爲{1, 3, 4, 8}。

說到這裏,不禁讓我想起 Elasticsearch 的 BitSet。我曾經使用 Elasticsearch 這個開源的搜索引擎來實現電商平臺的搜索。

當時爲了提升查詢的效率,我使用了 Elasticsearch 的 Filter 查詢。我研究了一下這個 Filter 查詢的原理,發現它並沒有考慮各種文檔的相關性得分,因此它可以把文檔匹配關鍵字的情況,轉換成了一個 BitSet。

你可以把 BitSet 想成一個巨大的位數組。每一位對應了某篇文檔是否和給定的關鍵詞匹配,如果匹配,這一位就置 1,否則就置 0。每個關鍵詞都可以擁有一個 BitSet,用於表示哪些文檔和這個關鍵詞匹配。那麼要查看同時命中多個關鍵詞的文檔有哪些,就是對多個 BitSet 求交集。利用上面介紹的按位與,這點是很容易實現的,而且效率相當之高。

二分查找時的兩個細節

  • 第一個是關於中間值的計算
int middle = left + (right - left) / 2;

// 這兩處改動的初衷都是一樣的,是爲了避免溢出。

從理論上來說,(left+right)/2=left+(right-left)/2。可是,我們之前說過,計算機系統有自身的侷限性,無論是何種數據類型,都有一個上限或者下限。一旦某個數字超過了這些限定,就會發生溢出。

對於變量 left 和 right 而言,在定義的時候都指定了數據類型,因此不會超出範圍。可是,left+right 的和就不一定了。從下圖可以看出,當 left 和 right 都已經很接近某個數據類型的最大值時,兩者的和就會超過這個最大值,發生上溢出。這也是爲什麼最好不用通過 (left+right)/2 來求兩者的中間值。

  • 第二個是關於誤差百分比和絕對誤差。在 Lesson3_2 中有這麼一行:
double delta = Math.abs((square / n) - 1);

這裏我使用了誤差的百分比,也就是誤差值佔輸入值 n 的比例。其實絕對誤差也是可以的,不過我在這裏考慮了 n 的大小。比如,如果 n 是一個很小的正整數,比如個位數,那麼誤差可能要精確到 0.00001。但是如果 n 是一個很大的數呢?比如幾個億,那麼精確到 0.00001 可能沒有多大必要,也許精確到 0.1 也就可以了。所以,使用誤差的百分比可以避免由於不同的 n,導致的迭代次數有過大差異。

由於這裏 n 是大於 1 的正整數,所以可以直接拿平方值 square 去除以 n。否則,我們要單獨判斷 n 爲 0 的情況,並使用絕對誤差。

關於迭代法、數學歸納法和遞歸

迭代法和遞歸都是通過不斷反覆的步驟,計算數值或進行操作的方法。迭代一般適合正向思維,而遞歸一般適合逆向思維。而遞歸回溯的時候,也體現了正向遞推的思維。它們本身都是抽象的流程,可以有不同的編程實現。

對**於某些重複性的計算,數學歸納法可以從理論上證明某個結論是否成立。如果成立,它可以大大節約迭代法中數值計算部分的時間。**不過,在使用數學歸納法之前,我們需要通過一些數學知識,假設命題,並證明該命題成立。

對於那些無法使用數學歸納法來證明的迭代問題,我們可以通過編程實現。這裏需要注意的是,廣義上來說,遞歸也是迭代法的一種。不過,在計算機編程中,我們所提到的迭代是一種具體的編程實現,是指使用循環來實現的正向遞推,而遞歸是指使用函數的嵌套調用來實現的逆向遞推。當然,兩種實現通常是可以相互轉換的。

循環的實現很容易理解,對硬件資源的開銷比較小。不過,循環更適合“單線劇情”,例如計算 2^n,n!,1+2+3+…+n 等等。而對於存在很多“分支劇情”的複雜案例而言,使用遞歸調用更加合適。

利用函數的嵌套調用,遞歸編程可以存儲很多中間變量。我們可以很輕鬆地跟蹤不同的分支,而所有這些對程序員基本是透明的。如果這時使用循環,我們不得不自己創建並保存很多中間變量。當然,正是由於這個特性,遞歸比較消耗硬件資源。

遞歸編程本身就體現了分治的思想,這個思想還可以延伸到集羣的分佈式架構中。最近幾年比較主流的 MapReduce 框架也體現了這種思想。

綜合上面說的幾點,你可以大致遵循這樣的原則:

  • 如果一個問題可以被迭代法解決,而且是有關數值計算的,那你就看看是否可以假設命題,並優先考慮使用數學歸納法來證明;
  • 如果需要藉助計算機,那麼優先考慮是否可以使用循環來實現。如果問題本身過於複雜,再考慮函數的嵌套調用,是否可以通過遞歸將問題逐級簡化;
  • 如果數據量過大,可以考慮採用分治思想的分佈式系統來處理。

在 1 到 n 的數字中,有且只有唯一的一個數字 m 重複出現了,其它的數字都只出現一次。請把這個數字找出來。提示:可以充分利用異或的兩個特性。

方法1:暴力查找,兩層循環遍歷,時間複雜度爲O(n^2),空間複雜度爲O(1)

方法2:用快排先進行排序,然後遍歷一次,比較前一個數和後一個數,若相等,則查找完成,時間複雜度O(nlogn),空間複雜度爲O(1)

方法3:利用hash表(或set),進行一次遍歷,同時將遍歷到的數放入hash表,放入之前判斷hash表是否存在,若存在,則找到了重複的數,時間複雜度爲O(n),空間複雜度爲O(n)

方法4:使用位向量,遍歷給到的n個數,對於出現的數,將對應位標記爲1,如果已經是1則查找成功,時間複雜度爲O(n),空間複雜度爲(n),這種方法類似方法3,雖然漸進的空間複雜度和方法3相同,但是其實小很多很多,畢竟只要用1bit就能表示有或無

方法5:利用異或的兩個特性

原始數據: 1,2...m,m,...n (是否有序對此題不重要)

所有數字: 1,2,...m,...n

因爲 x^x = 0

令a = 1^2...^m...^n

   b = 1^2...^m^m...^n

則有: a^b = (1^2...^m...^n)^(1^2...^m...^n)^m = 0^m = m

數學專欄課外加餐(三):程序員需要讀哪些數學書?

數學領域涉及的面很廣,相關的書籍也很多。程序員常用的數學知識,包括離散數學、概率和統計和線性代數。

  • 基礎思想篇推薦書籍:《離散數學及其應用》
  • 概率統計篇推薦書籍:《概率統計》
  • 線性代數篇推薦書籍:《線性代數及其應用》

入門、通識類書籍推薦

  • 《程序員的數學》
  • 《程序員的數學:概率統計》
  • 《程序員的數學:線性代數》
  • 《數學之美》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章