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 緩存淘汰算法?
思路:維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問,當有一個新的數據被訪問時,從鏈表頭開始順序遍歷單鏈表。
-
如果此數據之前已經被緩存在鏈表中了,我們遍歷得到這個數據對應的結點,並將其從原來的位置刪除,然后再插入到鏈表的頭部。
-
如果此數據沒有在緩存鏈表中,又可以分為兩種情況:
- 如果此時緩存未滿,則將此結點直接擦汗如到鏈表的頭部;
- 如果此時緩存已滿,則鏈表尾結點刪除,將心的數據結點插入到鏈表頭部。
時間復雜度為: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 回溯算法
回溯算法的思想非常簡單,大部分情況下,都是用來解決廣義的搜索問題,也就是,從一組可能的解中,選擇出一個滿足要求的解。回溯算法非常適合用遞歸來 實現,在實現的過程中,剪枝操作是提高回溯效率的一種技巧。利用剪枝,我們並不需要窮舉搜索所有的情況,從而提高搜索效率。