《數據結構與算法之美》 學習筆記


02 如何抓住重點,系統高效地學習數據結構與算法

什么是數據結構?什么是算法?

  • 從廣義上講,數據結構就是指一組數據的存儲結構算法就是操作數據的一組方法;
  • 從俠義上講,是指某些著名的數據結構和算法,比如隊列、棧、堆、二分查找、動態規划等;

數據結構和算法是相輔相成的,數據結構是為了算法服務的,算法要作用在特定的數據結構之上。因此,我們無法孤立數據結構來講算法,也無法孤立算法來講數據結構。

復雜度分析

  • 用於考量一效率和資源消耗的方法;

常用的數據結構和算法

  • 數組、鏈表、棧、隊列、散列表、二叉樹、堆、調表、圖、Trie 樹;
  • 遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態規划、字符串匹配算法;

事半功倍的學習技巧

  • 邊學邊練。適度刷題;
  • 多問、多思考、多互動;
  • 大概升級學習法
  • 知識需要沉淀,不要試圖一下子掌握所有;

03 & 04 復雜度分析

如何分析、統計算法的執行效率和資源消耗?

為什么需要復雜度分析?

通過實際的代碼運行來統計運行效率的方法叫做是事后統計法,這種方法存在如下如下問題:

  • 測試結構非常依賴測試環境;
  • 測試結構受數據規模的影響很大;

所以,我們需要一個不用具體的測試數據來測試,可以粗略地估計算法的執行效率的方法,這就是 時間、空間復雜度分析方法

大 O 復雜度表示法

公式:T(n) = O(f(n))

  • n:表示數據規模的大小;
  • T(n):表示代碼執行的時間;
  • f(n):表示每行代碼執行的次數總和;
  • O:表示代碼的執行時間 T(n) 與 f(n) 表達式成正比;

這種復雜度表示方法只是表示一種變化趨勢,當 n 很大時,公式中的低階、常量、系數三部分並不左右增長趨勢,所以可以忽略。

示例代碼 01

int cal(int n){
    int sum = 0
    int i = 1;
    for(;i<=n;i++){
        sum = sum + i;
    }
}

假設每行代碼執行的時間都一樣,為 unit_time,那么上述代碼總的執行時間為:(2n+2)*unit_time,大 O 表示法為:T(n) = O(2n+2),當 n 很大時,可記為 T(n) = O(n)

示例代碼 02

int cal(int n){
    int sum = 0;
    int i = 1;
    int j = 1;
    for(;i<=n;++i){
        j = 1;
        for(;<=n;++j){
            sum = sum + i*j
        }
    }
}

假設每行代碼執行的時間都一樣,為 unit_time,那么上述代碼總的執行時間為:(2n2+2n+3)*unit_time, 大 O 表示法為:T(n) = O(2n2+2n+3), 當 n 很大時,可記為 T(n) = O(n2)

時間復雜度分析

漸進時間復雜度

  • 只關注循環執行次數最多的一段代碼;
  • 加法法則:總復雜度等於量級最大的那段代碼的復雜度;(如果 T1(n) = O(f(n)),T2(n) = O(g(n)); 那么 T(n) = T1(n) + T2(n) = max(O(f(n)),O(g(n))) = O(max(f(n),g(n))))
  • 乘法法則:嵌套代碼的復雜度等於嵌套內外代碼復雜度的乘積;(如果 T1(n) = O(f(n)),T2(n) =O(g(n));那么 T(n) = T1(n) * T2(n) = O(f(n)) * O(g(n)) = O(f(n) * g(n)))

幾種常見時間復雜度實例分析

  • 復雜度量級(按數量級遞增)
  • 常量階 O(1)
  • 指數階 O(2n)
  • 對數階 O(logn)
  • 階乘階 O(n!)
  • 線性階 O(n)
  • 線性對數階 O(nlogn)
  • 平方階 O(n2)
  • 立方階 O(n3)
  • k次方階 O(nk)
  • ......

對於上述羅列的復雜度量級,可以粗略地分為兩類:多項式量級和非多項式量級。其中,非多項式量級只有兩個:O(2n) 和 O(n!)。當數據規模 n 越來越大時,非多項式量級算法的執行時間會急劇增加,求解問題的執行時間會無線增長。蘇歐陽,非多項式時間復雜度的算法其實是效率非常低的算法。

空間復雜度分析

漸進空間復雜度

表示算法的存儲空間與數據規模之間的增長關系,常見的空間復雜度如下:

  • O(1)
  • O(n)
  • O(n2)

淺析最好、最壞、平均、均攤時間復雜度

  • 最壞、最好情況時間復雜度
  • 平均情況時間復雜度
  • 均攤時間復雜度

05 數組

是一種線性表數據結構,用一組連續的內存空間來存儲一組具有相同類型的數據。

  • 支持隨機訪問;
  • 低效的 插入刪除,平均復雜度為 O(n);
  • 警惕數組的訪問越界問題;

使用建議:

  • 如果特別關注性能,或者希望使用基本類型,可以選用數組;
  • 如果數據大小事先已知,並且對數據的操作非常簡單,可以直接使用數組;
  • 當要表示多維數組時,用數組往往會更加直觀;
  • 對於業務開發,直接使用集合類型就足夠了,省時省力;如果時作一些非常底層的開發,這個時候數組就會優於集合;

為什么在大多數的編程語言中,數組要從 0 開發編號,而不是 1 ?

從數組存儲的內存模型上來看,下標 最確切的定義應該是 偏移(offset),這樣就能確保正確計算出每次隨機訪問的元素對於的內存地址,這樣就好理解了。

06 & 07 鏈表

是一種線性數據結構,用一組非連續的內存空間來存儲一組具有相同類型的數據。

  • 不存儲越界問題;
  • 相比數組,插入和刪除較為高效;

數組 VS 鏈表 時間復雜度比較:

數組 鏈表
插入、刪除 O(n) O(1)
隨機訪問 O(1) O(n)

常見的鏈表類型:

  • 單鏈表
  • 循環鏈表
  • 雙向鏈表
  • 雙向循環鏈表(以空間換時間)

緩存問題

緩存策略常有如下三種方式:

  • 先進先出策略 FIFO(First In,First Out)
  • 最少使用策略 LFU(Least Frequently Used)
  • 最近最少使用策略 LRU(Least Recently Used)

如何基於鏈表實現 LRU 緩存淘汰算法?

思路:維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問,當有一個新的數據被訪問時,從鏈表頭開始順序遍歷單鏈表。

  1. 如果此數據之前已經被緩存在鏈表中了,我們遍歷得到這個數據對應的結點,並將其從原來的位置刪除,然后再插入到鏈表的頭部。

  2. 如果此數據沒有在緩存鏈表中,又可以分為兩種情況:

    • 如果此時緩存未滿,則將此結點直接擦汗如到鏈表的頭部;
    • 如果此時緩存已滿,則鏈表尾結點刪除,將心的數據結點插入到鏈表頭部。

時間復雜度為:O(n)

如何輕松寫出正確的鏈表代碼?

  • 理解指針或引用的含義
  • 警惕指針丟失和內存泄漏
  • 利用哨兵簡化實現難度
  • 重點留意邊界條件處理
  • 舉例畫圖,輔助思考
  • 多寫多練,沒有捷徑

5 種常見的鏈表操作

  • 單鏈表反轉
  • 鏈表中環的檢測
  • 兩個有序鏈表合並
  • 刪除鏈表倒數第 n 個結點
  • 求鏈表的中間結點

08 棧

當某個數據集合只涉及在一端插入和刪除數據,並且滿足后進先出、先進后出的特性,我們就應該首選 這種數據結構

不管是順序棧還是鏈式棧,入棧、出棧只涉及棧頂個別數據的操作,所有時間復雜度都是 O(1)。棧是一種操作受限的數據結構,只支持入棧和出棧操作。后進先出是它最大的特點。棧既可以通過數組實現,也可以通過鏈表實現。

內存中的堆棧和數據結構中的堆棧不是一個概念,內存中的堆棧是真實存在的物理區,數據結構中的堆棧是抽象出來的數據存儲結構:

內存空間在邏輯上分為三部分:

  • 代碼區:存儲方法體的二級制代碼。高級調度(作業調度)、中級調度(內存調度)、低級調度(進程調度)控制代碼區執行代碼的卻換;
  • 靜態數據區:存儲全局變量、靜態變量、常量,由系統自動分配和回收;
  • 棧區:存儲運行方法的形參、局部變量、返回值,由系統自動分配和回收;
  • 堆區:new 一個對象的引用或地址存儲在棧區,執行該對象存儲在堆區中的真實數據。

09 隊列

先進者先出

不管是順序隊列還是鏈式隊列,主要的兩個操作是入隊和出隊,最大特點是先進先出。

幾種高級的隊列結構:

  • 阻塞隊列(生產者-消費者問題);
  • 並發隊列(多線程與原子鎖操作);

10 遞歸

遞歸需要滿足的三個條件:

  • 一個問題的解可以分解為幾個子問題的解;
  • 這個問題與分解之后的子問題,出來數據規模不同,求解思路完全一樣;
  • 存在遞歸終止條件;

如何編寫遞歸代碼?

  • 遞推公式
  • 終止條件

缺點:

  • 堆棧溢出
  • 重復計算
  • 函數調用耗時多
  • 空間復雜度高
  • ......

11&12 排序

常見排序算法:

排序算法 時間復雜度 是否基於比較
冒泡、插入、選擇 O(n2)
快排、歸並 O(nlogn)
桶、計數、基數 O(n)

如何分析一個 “排序算法”?

  • 執行效率
    • 最好、最壞、平均情況的時間復雜度;
    • 時間復雜度的系數、常數、低階;
    • 比較次數和交換(移動)次數;
  • 內存消耗
  • 穩定性

冒泡排序

冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關系要求。如果不滿足就讓它倆互換。一次冒泡會讓至少一 個元素移動到它應該在的位置,重復n次,就完成了n個數據的排序工作。

示例代碼:

class Solution():
    def bubbleSort(self, lis: list, n: int):
        if n <= 1:
            return
        for i in range(len(lis)):
            flag = False
            for j in range(len(lis)-i-1):
                if lis[j] > lis[j+1]:
                    lis[j], lis[j+1] = lis[j+1], lis[j]
                    flag = True
            if not flag:
                break

arr = [4, 5, 6, 3, 2, 1]
print(arr)
Solution().bubbleSort(arr, len(arr))
print(arr)
  • 冒泡的過程只涉及相鄰數據的交換操作,只需要常量級的臨時空間,所以它的空間復雜度為O(1),是一個原地排序算法。
  • 在冒泡排序中,只有交換才可以改變兩個元素的前后順序。為了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的 數據在排序前后不會改變順序,所以冒泡排序是穩定的排序算法。
  • 最好情況下,要排序的數據已經是有序的了,我們只需要進行一次冒泡操作,就可以結束了,所以最好情況時間復雜度是O(n)。而最壞的情況是,要排序的數據 剛好是倒序排列的,我們需要進行n次冒泡操作,所以最壞情況時間復雜度為O(n2)。

插入排序

插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。重復這個過程,直到未排序區間中元素為空,算法結束。

示例代碼:

class Solution():
    def insertionSort(self, lis: list, n: int):
        if n <= 1:
            return
        for i in range(1, len(lis)):
            val = lis[i]
            j = i-1
            while j >= 0:
                if lis[j] > val:
                    lis[j+1] = lis[j]
                j -= 1
            lis[j+1] = val


attr = [4, 5, 6, 3, 2, 1]
print(attr)
Solution().insertionSort(attr, len(attr))
print(attr)
  • 從實現過程可以很明顯地看出,插入排序算法的運行並不需要額外的存儲空間,所以空間復雜度是O(1),也就是說,這是一個原地排序算法。
  • 在插入排序中,對於值相同的元素,我們可以選擇將后面出現的元素,插入到前面出現元素的后面,這樣就可以保持原有的前后順序不變,所以插入排序是穩定 的排序算法。
  • 如果要排序的數據已經是有序的,我們並不需要搬移任何數據。如果我們從尾到頭在有序數據組里面查找插入位置,每次只需要比較一個數據就能確定插入的位 置。所以這種情況下,最好是時間復雜度為O(n)。注意,這里是從尾到頭遍歷已經有序的數據。 如果數組是倒序的,每次插入都相當於在數組的第一個位置插入新的數據,所以需要移動大量的數據,所以最壞情況時間復雜度為O(n2)。對於插入排序來說,每次插入操作都相當於在數組中插入一個數據,循環執行 n 次插入操作,所以平均時間復雜度為O(n2)。

選擇排序

選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末 尾。

示例代碼:

class Solution():
    def selectSort(self, lis: list, n: int):
        if n <= 1:
            return
        for i in range(0, len(lis) - 1):
            index = i
            for j in range(i+1, len(lis)):
                if lis[index] > lis[j]:
                    index = j
            lis[i], lis[index] = lis[index], lis[i]


attr = [4, 5, 6, 3, 2, 1]
print(attr)
Solution().selectSort(attr, len(attr))
print(attr)
  • 選擇排序空間復雜度為O(1),是一種原地排序算法。
  • 選擇排序的最好情況時間復雜度、最壞情況和平均情況時間復雜度都為O(n2)。
  • 選擇排序每次都要找剩余未排序元素中的最小值,並和前面的元素 交換位置,這樣破壞了穩定性。是一種不穩定的排序算法。
是否原地排序 是否穩定 最好 最壞 平均
冒泡 O(n) O(n2) O(n2)
插入 O(n) O(n2) O(n2)
選擇 O(n2) O(n2) O(n2)

歸並排序

核心思想:利用分而治之的思想,遞歸解決問題。如果要排序一個數組,我們先把數組從中間分成前后兩部分,然后對前后兩部分分別排序,再將排好序的兩部分合並在一 起,這樣整個數組就都有序了。

示例代碼:

class Solution():
    def mergeSort(self, arr):
        print("Splitting ", arr)
        if len(arr) > 1:
            mid = len(arr)//2
            lefthalf = arr[:mid]
            righthalf = arr[mid:]

            self.mergeSort(lefthalf)
            self.mergeSort(righthalf)

            i = 0
            j = 0
            k = 0
            while i < len(lefthalf) and j < len(righthalf):
                if lefthalf[i] < righthalf[j]:
                    arr[k] = lefthalf[i]
                    i = i+1
                else:
                    arr[k] = righthalf[j]
                    j = j+1
                k = k+1

            while i < len(lefthalf):
                arr[k] = lefthalf[i]
                i = i+1
                k = k+1

            while j < len(righthalf):
                arr[k] = righthalf[j]
                j = j+1
                k = k+1
            print("Merging ", arr)


arr = [4, 5, 6, 3, 2, 1]
print(arr)
Solution().mergeSort(arr)
print(arr)

性能分析:

  • 是一個穩定的排序算法。
  • 時間復雜度是O(nlogn)。
  • 空間復雜度是O(n)。

快速排序

快排核心思想就是分治和分區。如果要排序數組中下標從p到r之間的一組數據,我們選擇p到r之間的任意一個數據作為pivot(分區點)。 我們遍歷p到r之間的數據,將小於pivot的放到左邊,將大於pivot的放到右邊,將pivot放到中間。經過這一步驟之后,數組p到r之間的數據就被分成了三個部分,前 面p到q-1之間都是小於pivot的,中間是pivot,后面的q+1到r之間是大於pivot的。

示例代碼:

class Solution():
    def quickSort(self, arr: list):
        self.quickHelper(arr, 0, len(arr)-1)

    def quickHelper(self, arr: list, first: int, last: int):
        if first < last:
            splitpoint = self.partition(arr, first, last)
            self.quickHelper(arr, first, splitpoint-1)
            self.quickHelper(arr, splitpoint+1, last)

    def partition(self, arr: list, first: int, last: int):
        pivot = arr[first]
        left = first + 1
        right = last

        done = False
        while not done:
            while left <= right and arr[left] <= pivot:
                left = left + 1
            while arr[right] >= pivot and right >= left:
                right = right - 1
            if right < left:
                done = True
            else:
                temp = arr[left]
                arr[left] = arr[right]
                arr[right] = temp
        temp = arr[first]
        arr[first] = arr[right]
        arr[right] = temp

        return right


arr = [4, 5, 6, 3, 2, 1]
print(arr)
Solution().quickSort(arr)
print(arr)

性能分析:

  • 時間復雜度也是O(nlogn)。

但是,公式成立的前提是每次分區操作,我們選擇的pivot都很合適,正好能將大區間對等地一分為二。但實際上這種情況是很難實現的

13 線性排序

桶排序

核心思想是將要排序的數據分到幾個有序的桶里,每個桶里的數據再單獨進行排序。桶內排完序之 后,再把每個桶里的數據按照順序依次取出,組成的序列就是有序的了。

桶排序比較適合用在外部排序中。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。

計數排序

計數排序其實是桶排序的一種特殊情況。當要排序的n個數據,所處的范圍並不大的時候,比如最大值是k,我們就可以把數據划分成k個桶。每個桶 內的數據值都是相同的,省掉了桶內排序的時間。

示例代碼:

class Solution:
    def countingSort(self, arr: list, n: int):
        if n <= 1:
            return

        mv = arr[0]
        for v in arr:
            if mv < v:
                mv = v

        c = [0 for x in range(mv+1)]

        for i in range(n):
            c[arr[i]] += 1

        for i in range(1, mv+1):
            c[i] = c[i-1] + c[i]

        r = [0 for x in range(n)]
        i = n-1
        while i >= 0:
            index = c[arr[i]] - 1
            r[index] = arr[i]
            c[arr[i]] -= 1
            i -= 1

        for i in range(n):
            arr[i] = r[i]


arr = [4, 5, 6, 3, 2, 1]
print(arr)
Solution().countingSort(arr, len(arr))
print(arr)

計數排序只能用在數據范圍不大的場景中,如果數據范圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化為非負整數。

基數排序

基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關系,如果a數據的高位比b數據大,那剩下的低 位就不用比較了。除此之外,每一位的數據范圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間復雜度就無法做到O(n)了。

14 排序優化

時間復雜度 是否穩定排序 是否原地排序
冒泡排序 O(n2)
插入排序 O(n2)
選擇排序 O(n2)
快速排序 O(nlog2)
歸並排序 O(nlog2)
計數排序 O(n+k) k是數據范圍
桶排序 O(n)
基數排序 O(dn) d 是維度

如何優化快速排序?

  • 三數取中法
  • 隨機法

15&16 二分查找

二分查找(Binary Search)算法,也叫折半查找算法。時間復雜度為 O(longn)

示例代碼:

  • 遞歸實現
class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        return self.bsearchInternally(arr, 0, n-1, val)

    def bsearchInternally(self, arr: list, low: int, high: int, val: int):
        if low > high:
            return -1
        mid = low + ((high-low) >> 1)
        if arr[mid] == val:
            return mid
        elif arr[mid] < val:
            return self.bsearchInternally(arr, mid+1, high, val)
        else:
            return self.bsearchInternally(arr, low, mid-1, val)


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 4)
print(v)
  • 非遞歸實現
class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        low = 0
        high = n - 1
        while low <= high:
            mid = (low+high) // 2
            if arr[mid] == val:
                return mid
            elif arr[mid] < val:
                low = mid + 1
            else:
                high = mid - 1
        return -1


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 4)
print(v)

應用場景的局限性:

  • 二分查找只能用在數據是通過順序表來存儲的數據結構上;
  • 二分查找針對的是有序數據;
  • 數據量太小或太大不適合二分查找;

二分查找的變形問題:

  • 查找第一個值等於給定值的元素

示例代碼:

class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        low = 0
        high = n-1
        while low <= high:
            mid = low + ((high-low) >> 1)
            if arr[mid] > val:
                high = mid - 1
            elif arr[mid] < val:
                low = mid + 1
            else:
                if mid == 0 or arr[mid-1] != val:
                    return mid
                else:
                    high = mid - 1
        return -1


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 4)
print(v)
  • 查找最后一個值等於給定值的元素

示例代碼:

# 待修改
class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        low, high = 0, n-1
        while low <= high:
            mid = low + ((high-low) >> 1)
            if arr[mid] > val:
                high = mid - 1
            elif arr[mid] < val:
                low = mid + 1
            else:
                if mid == n-1 or arr[mid+1] != val:
                    return mid
                else:
                    low = mid + 1
        return -1


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 3)
print(v)
  • 查找第一個大於等於給定值的元素

示例代碼:

# 待修改
class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        low, high = 0, n-1
        while low <= high:
            mid = low + ((high-low) >> 1)
            if arr[mid] >= val:
                if mid == 0 or arr[mid - 1] < val:
                    return mid
                else:
                    high = mid-1
            else:
                low = mid + 1
        return -1


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 3)
print(v)
  • 查找最后一個小於等於給定值的元素

示例代碼:

# 待修改
class Solution:
    def bsearch(self, arr: list, n: int, val: int):
        low, high = 0, n-1
        while low <= high:
            mid = low + ((high-low) >> 1)
            if arr[mid] > val:
                high = mid - 1
            else:
                if mid == n - 1 or arr[mid + 1] > val:
                    return mid
                else:
                    low = mid + 1
        return -1


arr = [1, 2, 3, 4, 2, 2, 3, 5]
v = Solution().bsearch(arr, len(arr), 3)
print(v)

17 跳表

Redis 的有序集合就是使用跳表來實現的。

跳表使用空間換時間的設計思路,通過后見多級索引來提高查詢訂單效率,實現了基於鏈表的 “二分查找”。調表是一種動態結構,支持快速的插入、刪除、查找操作,時間復雜度都是 O(longn)

跳表的空間復雜度是 O(n),不過,跳表的實現非常靈活,可以通過改變索引構建策略,有效平衡執行效率和內存消耗。雖然跳表的代碼實現起來並不簡單,但是作為一種動態結構,比起紅黑樹來說,實現要簡單很多。所以很多時候,我們為了代碼的簡單、易讀,比起紅黑樹,我們更傾向用跳表。

18&19&20 散列表

Word 文檔中的單詞拼寫檢查功能

散列表是由數組演化而來的,借助散列函數堆數組進行擴展,利用的是數組支持按照下標隨機訪問元素的特性。

散列沖突的解決方法:

  • 開放尋址法
  • 鏈表法

散列表的查詢效率不能籠統地說成是 O(1),它跟散列函數、裝載因子、散列沖突等都有關系。如果散列函數涉及得不好,或者裝載因子過高,都可能導致散列沖突發生的概率升高,查詢效率下降。

如何設計散列函數?

直接尋址法、平方取中法、折疊法、隨機數法等

裝載因子過大怎么辦?

裝載因子閾值的設置要權衡時間、空間復雜度。如果內存空間不要緊,對執行效率要求很高,可以降低負載因子的閥值;相反,如果內存空間緊張,對執行效率要求又不高,可以增加負載因子的值,甚至可以大於 1。

如何避免低效地擴容?

通過均攤的方法,將一次性擴容的代價,均攤到多次插入操作中,就避免了一次性擴容耗時過多的情況。這種實現方式,任何情況下,插入一個數據的時間 復雜度都是O(1)。

工業級散列表分析要素:

  • 初始大小
  • 裝載因子和動態擴容
  • 散列沖突解決方法
  • 散列函數

工業級散列表特征:

  • 支持快速的查詢、插入、刪除操作;
  • 內存占用合理,不能浪費過多的內存空間;
  • 性能穩定,極端情況下,散列表的性能也不會退化到無法接受的情況;

工業級散列表設計思路:

  • 設計一個合適的散列函數;
  • 定義裝載因子閾值,並且設計動態擴容策略;
  • 選擇合適的散列沖突解決方法;

21&22 哈希算法

將任意長度的二進制值串映射為固定長度的二進制值串,這個映射的規則就是哈希算法,而 通過原始數據映射之后得到的二進制值串就是哈希值。

滿足如下幾點要求:

  • 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法);
  • 對輸入數據非常敏感,哪怕原始數據只修改了一個Bit,最后得到的哈希值也大不相同;
  • 散列沖突的概率要很小,對於不同的原始數據,哈希值相同的概率非常小;
  • 哈希算法的執行效率要盡量高效,針對較長的文本,也能快速地計算出哈希值。

應用場景:

  • 安全加密
  • 唯一標識
  • 數據校驗
  • 散列函數
  • 負載均衡
  • 數據切片
  • 分布式存儲

23&24 二叉樹

想要存儲一棵二叉樹,我們有兩種方法,一種是基於指針或者引用的二叉鏈式存儲法,一種是基於數組的順序存儲法。

二叉樹的遍歷:

  • 前序遍歷:對於樹中的任意節點來說,先打印這個節點,然后再打印它的左子樹,最后打印它的右子樹。
  • 中序遍歷:對於樹中的任意節點來說,先打印它的左子樹,然后再打印它本身,最后打印它的右子樹。
  • 后序遍歷:對於樹中的任意節點來說,先打印它的左子樹,然后再打印它的右子樹,最后打印這個節點本身。

實際上,二叉樹的前、中、后序遍歷就是一個遞歸的過程。

二叉查找樹

二叉查找樹是二叉樹中最常用的一種類型,也叫二叉搜索樹。顧名思義,二叉查找樹是為了實現快速查找而生的。不過,它不僅僅支持快速查找一個數據,還支 持快速插入、刪除一個數據。

二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大 於這個節點的值。

25&26 紅黑樹

滿足要求:

  • 根節點是黑色的;
  • 每個葉子結點都是黑色的空節點(NIL),也就是說,葉子節點不存儲數據;
  • 任何相鄰的節點都不能同時為紅色,也就是說,紅色節點是被黑色節點隔開的;
  • 每個節點,從該節點到達其可達葉子節點的所以路徑,都包含相同數目的黑色節點;

紅黑樹是一種平衡二叉查找樹,它是為了解決普通二叉查找樹在數據更新的過程中,復雜度退化的問題而產生的,紅黑樹的高度近似 log2n,所以它是近似平衡,插入、刪除、查找操作的時間復雜度都是 O(logn)。

因為紅黑樹是一種性能非常穩定的二叉查找樹,所以,在工程中,但凡是用到動態插入、刪除、查找數據的場景,都可以用到它。不過,它實現起來比較復雜,如果自己寫代碼實現,難度會有些高,這個時候,我們其實更傾向用跳表來代替它。

27 遞歸樹

  • 實戰一:分析快速排序的時間復雜度
  • 實戰二:分析斐波那契數列的時間復雜度
  • 實戰三:分析全排列的時間復雜度

28&29 堆和堆排序

堆的特點:

  • 是一個完全二叉樹;
  • 隊中每一個節點的值都必須大於等於(或小於等於)其子樹中每個節點的值;

對於每個節點值都大於等於子樹中每個節點值的堆,我們叫做 “大頂堆”;對於每個節點的值都小於等於子樹中每個節點值的堆,我們叫做 “小頂堆”。

為什么快速排序要比堆排序性能好?

  • 堆排序數據訪問方式沒有快速排序友好;
  • 對於同樣的數據,在排序過程中,堆排序算法的數據交換次數要多於快速排序;

堆的應用:

  • 優先級隊列
    • 合並有序小文件
    • 高性能定時器
  • 利用堆求 Top K
  • 利用堆求中位數

30&31 圖

非線性數據結構

相關概念:

  • 頂點
  • 度(出度、入度)
  • 有向圖
  • 無向圖
  • 帶權無向圖(權重)

存儲方法:

  • 鄰接矩陣
  • 鄰接表
  • 外部存儲(數據庫等)

鄰接矩陣存儲方法的缺點是比較浪費空間,但是優點是查詢效率高,而且方便矩陣運算。鄰接表存儲方法中每個頂點都對應一個鏈表,存儲與其相連接的其他頂 點。盡管鄰接表的存儲方式比較節省存儲空間,但鏈表不方便查找,所以查詢效率沒有鄰接矩陣存儲方式高。針對這個問題,鄰接表還有改進升級版,即將鏈表換成更加高效的動態數據結構,比如平衡二叉查找樹、跳表、散列表等。

搜索方法:

  • 深度優先搜索(DFS)
  • 廣度優先搜索(BFS)

廣度優先搜索和深度優先搜索是圖上的兩種最常用、最基本的搜索算法,比起其他高級的搜索算法,比如A、IDA等,要簡單粗暴,沒有什么優化,所以,也被 叫作暴力搜索算法。所以,這兩種搜索算法僅適用於狀態空間不大,也就是說圖不大的搜索。 廣度優先搜索,通俗的理解就是,地毯式層層推進,從起始頂點開始,依次往外遍歷。廣度優先搜索需要借助隊列來實現,遍歷得到的路徑就是,起始頂點到終 止頂點的最短路徑。深度優先搜索用的是回溯思想,非常適合用遞歸實現。換種說法,深度優先搜索是借助棧來實現的。在執行效率方面,深度優先和廣度優先搜索的時間復雜度都是O(E),空間復雜度是O(V)。

32&33&34 字符串

匹配算法

BF 算法

全稱叫 Brute Force 算法,中文叫作暴力匹配算法,也叫朴素匹配算法。

RK 算法

全稱叫 Rabin-Karp 算法,是 BF 算法的改進版。

BM 算法

全稱叫 Boyer-Moore 算法。是一種非常搞笑的字符串匹配算法。

BM 算法核心思想是,利用模式串本身的特點,在模式串中某個字符與主串不能匹配的時候,將模式串往后多滑動幾位,以此來減少不必要的字符比較,提高匹配的效率。BM算法構建的規則有兩類,壞字符規則和好后綴規則。好后綴規則可以獨立於壞字符規則使用。因為壞字符規則的實現比較耗內存,為了節省內存,我們可以只用好后綴規則來實現 BM 算法。

MKP 算法

KMP算法的核心思想是:我們假設主串是a,模式串是b。在模式串與主串匹配的過程中,當遇到不可匹配的字符的時候,我們希望找到一些規律,可以將模式串往后多滑動幾位,跳過那些肯定不會匹配的情況。

BM算法有兩個規則,壞字符和好后綴。KMP算法借鑒BM算法的思想,可以總結成好前綴規則。這里面最難懂的就是next數組的計算。如果用最笨的方法來計 算,確實不難,但是效率會比較低。所以,我講了一種類似動態規划的方法,按照下標i從小到大,依次計算next[i],並且next[i]的計算通過前面已經計算出來 的next[0],next[1],……,next[i-1]來推導。 KMP算法的時間復雜度是O(n+m)。

35 Trie 樹

Trie樹,也叫“字典樹”。顧名思義,它是一個樹形結構。它是一種專門處理字符串匹配的數據結構,用來解決在一組字符串集合中快速查找某個字符串的問題。

如果用來構建Trie樹的這一組字符串中,前綴重復的情況不是很多,那Trie樹這種數 據結構總體上來講是比較費內存的,是一種空間換時間的解決問題思路。

盡管比較耗費內存,但是對內存不敏感或者內存消耗在接受范圍內的情況下,在Trie樹中做字符串匹配還是非常高效的,時間復雜度是O(k),k表示要匹配的字符串的長度。 但是,Trie樹的優勢並不在於,用它來做動態集合數據的查找,因為,這個工作完全可以用更加合適的散列表或者紅黑樹來替代。Trie樹最有優勢的是查找前綴匹配的字符 串,比如搜索引擎中的關鍵詞提示功能這個場景,就比較適合用它來解決,也是Trie樹比較經典的應用場景。

36 AC 自動機

AC自動機是基於Trie樹的一種改進算法,它跟Trie樹的關系,就像單模式串中,KMP算法與BF算法的關系一樣。KMP算法中有一個非常關鍵的next數組,類比 到AC自動機中就是失敗指針。而且,AC自動機失敗指針的構建過程,跟KMP算法中計算next數組極其相似。所以,要理解AC自動機,最好先掌握KMP算法,因為AC自動機其實就是KMP算法在多模式串上的改造。

整個AC自動機算法包含兩個部分,第一部分是將多個模式串構建成AC自動機,第二部分是在AC自動機中匹配主串。第一部分又分為兩個小的步驟,一個是將模 式串構建成Trie樹,另一個是在Trie樹上構建失敗指針。

37 貪心算法

貪心算法有很多經典的應用,比如霍夫曼編碼(Huffman Coding)、Prim和Kruskal最小生成樹算法、還 有Dijkstra單源最短路徑算法。

實際上,貪心算法適用的場景比較有限。這種算法思想更多的是指導設計基礎算法。比如最小生成樹算法、單源最短路徑算法,這些算法都用到了貪心算法。

38 分治算法

分治算法(divide and conquer)的核心思想其實就是四個字,分而治之 ,也就是將原問題划分成n個規模較小,並且結構與原問題相似的子問題,遞歸地解決這些 子問題,然后再合並其結果,就得到原問題的解。

分治算法是一種處理問題的思想,遞歸是一種編程技巧。實際上,分治算法一般都比較適合用遞歸來實現。分治算法的遞歸實現中,每一層遞歸都會涉及這樣三個操作:

  • 分解:將原問題分解成一系列子問題;
  • 解決:遞歸地求解各個子問題,若子問題足夠小,則直接求解;
  • 合並:將子問題的結果合並成原問題。

分治算法能解決的問題,一般需要滿足下面這幾個條件:

  • 原問題與分解成的小問題具有相同的模式;
  • 原問題分解成的子問題可以獨立求解,子問題之間沒有相關性,這一點是分治算法跟動態規划的明顯區別,等我們講到動態規划的時候,會詳細對比這兩種算法;
  • 具有分解終止條件,也就是說,當問題足夠小時,可以直接求解;
  • 可以將子問題合並成原問題,而這個合並操作的復雜度不能太高,否則就起不到減小算法總體復雜度的效果了。

39 回溯算法

回溯算法的思想非常簡單,大部分情況下,都是用來解決廣義的搜索問題,也就是,從一組可能的解中,選擇出一個滿足要求的解。回溯算法非常適合用遞歸來 實現,在實現的過程中,剪枝操作是提高回溯效率的一種技巧。利用剪枝,我們並不需要窮舉搜索所有的情況,從而提高搜索效率。

40 動態規划


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM