從廣義上來講:數據結構就是
一組數據的存儲結構
, 算法就是操作數據的方法
數據結構是為算法服務的,算法是要作用在特定的數據結構上的。
10個最常用的數據結構:數組、鏈表、棧、隊列、散列表、二叉樹、堆、跳表、圖、Trie樹
10個最常用的算法:遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態規劃、字符串匹配算法
本文總結了20個最常用的數據結構和算法,不管是應付面試還是工作需要,只要集中精力攻克這20個知識點就足夠了。
數據結構和算法(三):二分查找、跳表、散列表、哈希算法的傳送門
數據結構和算法(四):二叉樹、紅黑樹、遞歸樹、堆和堆排序、堆的應用的傳送門
第六章 遞歸
一、如何理解遞歸?
- 遞歸是一種應用非常廣泛的算法,之后我們要講的很多數據結構和算法的編碼實現都要用到遞歸,例如DFS深度優先搜索、前中后序二叉樹遍歷等等,所以搞懂遞歸非常重要。
- 一個例子幫你搞懂遞歸:周末帶女朋友去看電影,女朋友問你咱們現在是第幾排?電影院里太黑沒法數,你該怎么辦?這個時候遞歸就派上用場了,于是,你問前邊一排的人他是第幾排,你想只要在他的數字上加1,就可以知道自己是第幾排,但是前邊的人也看不清啊,所以他也問前邊的人,就這樣一排一排的往前傳,直到問到第一排,告訴說我是第一排,然后再這樣一排一排的把數字傳遞回來,這樣你就知道答案了。
- 這就是一個標準的遞歸求解問題的分解過程,去的過程叫做“遞”,回來的過程叫做“歸”。解決遞歸問題,需要寫出遞推公示,剛才這個問題的遞推公示是這樣的:
剛才這個問題的遞推公示:
f(n) = f(n - 1) + 1 , 其中f(1) = 1 //f(n)表示你想知道自己在哪一排,f(n-1)代表你前邊的一排的排數
- 有了遞推公示,我們很方便可以寫出遞歸代碼:
func f(_ n: NSInteger) -> NSInteger{
if n == 1 {
return 1
}
return f(n - 1) + 1
}
二、什么情況下使用遞歸?
滿足以下三個條件的問題,就可以使用遞歸的方法求解
- 一個問題可以拆解成幾個子問題的解
- 這個問題和之后的子問題,除了數據規模以外,求解思路完全相同
- 存在遞歸終止條件
三、如何編寫遞歸代碼?
寫出遞歸代碼的關鍵在于:
- 寫出遞推公式
- 找出終止條件
總結一下:寫出遞歸代碼的關鍵在于找到將大問題分解成小問題的規律,并基于此寫出遞推公式,找出終止條件,最后將遞推公式和終止條件翻譯成實際代碼。
注意!! : 不要試圖用人腦去分解遞歸的每一個步驟,只需要搞清一層關系并保證后續規律一致即可。
四、寫遞歸代碼要注意的地方
- 遞歸代碼要警惕堆棧溢出,講第四章“棧”的時候講過,函數的調用是基于函數調用棧的,函數調用棧會保存函數的臨時變量,直到函數調用完畢。一旦遞歸調用層次很深,一直壓入棧,就會有堆棧溢出的風險。
-
-
遞歸代碼要警惕重復運算,如下圖,我們把看電影數排數的問題分解一下,就會發現其中f(3)被重復計算了很多次,這就是重復計算問題。
重復計算問題
為了避免重復計算,我們可以通過一個數據結構(例如散列表)來保存已經求解過的f(k),當遞歸調用f(k)時,先看下是否已經求解過了,如果是,直接從散列表中取值返回,不需要重復在計算了。
-
遞歸代碼要警惕重復運算,如下圖,我們把看電影數排數的問題分解一下,就會發現其中f(3)被重復計算了很多次,這就是重復計算問題。
- 遞歸代碼要警惕存在環而導致無限遞歸的問題,例如A依賴B,B依賴C,C又依賴A,一旦存在這種環,就會出現無限遞歸的問題,可以用限制遞歸深度來解決,不過還有更高級的方法,后續再說。
第七章 排序基礎知識 + 復雜度為O(n*n)的排序算法
一、如何衡量一個排序算法的好壞:
- 最好、最壞、平均時間復雜度,及最好、最好時間復雜度的原始數據 (針對小規模數據排序時,如果是同階時間復雜度,應該把系數、常數、低階也考慮進來)
- 空間復雜度 (空間復雜度為1的叫做原地排序)
- 穩定性 (排序的穩定性是指相等的對象,在排序之后,順序保持不變)
- 對基于比較的排序算法,應該把比較次數和交換次數也考慮進去
二、有序度、逆序度、滿有序度
- 有序度就是有順序關系的元素對個數,例如:1、3、4的有序度就是3,(1,3)、(1,4)、(3,4)
- 滿有序度就是指完全有序的數組,例如:1、2、3、4, 滿有序度 = n * (n-1) / 2
- 逆序度就是有逆序關系的元素個數,逆序度 = 滿有序度 - 有序度
- 我們排序的過程就是增加有序度,減少逆序度的過程,最后達到滿有序度就說明排序完成了。(交換次數就是逆序度)
三、冒泡排序
- 原理:從第一個開始,依次比較相鄰元素的大小然后進行交換操作,把大的往后交換,直到沒有交換操作為止。
- 最好時間復雜度:O(n),當數組剛好是順序的時候,只需要挨個比較一遍就行了,不需要做交換操作,所以時間復雜度為O(n)
- 最壞時間復雜度:O(n * n),當數組剛好是完全逆序的時候,需要挨個比較,并且重復n次,所以時間復雜度為O(n*n)
- 平均時間復雜度:O(n * n),當數組是一半的滿有序度n * (n-1)/4時,進行計算的話,交換次數就是n * (n-1)/4,比較次數肯定比交換次數多,所以得出來的不準確的平均時間復雜度為O(n * n)
- 冒泡的穩定性:元素相同時不做交換,所以冒泡是穩定的排序算法
四、插入排序
- 原理: 選取未排序的元素,插入到已排序區間的合適位置,止到未排序區間為空。
- 最好時間復雜度: O(n),當數組剛好是完全順序時,每次只用比較一次就能找到正確的位置,重復n次,就可以清空未排序區間,所以最好時間復雜度為O(n)
- 最壞時間復雜度:O(n * n),當數組剛好是完全逆序時,每次都要比較n次才能找到正確位置,重復n次,才能清空未排序區間,所以最壞時間復雜度為O(n * n)
- 平均時間復雜度:O(n * n),因為往數組中插入一個元素的平均時間復雜度為O(n),而插入排序可以理解為重復n次的數組插入操作,所以平均時間復雜度為O(n * n)
- 插入的穩定性:未出現的元素總會插入到已排序元素的前邊,所以插入排序是穩定的排序算法。
五、插入排序為何比冒泡排序更優?
- 相同點:插入排序和冒泡排序的平均時間復雜度都是O(n*n),都是穩定的排序算法,都是原地排序,元素交換的次數都是逆序度。
- 插入比冒泡的優勢:冒泡的交換操作需要三個賦值操作,而插入只需要一步賦值操作,而且插入排序還有很大的優化空間,所以插入更優選一點。
第八章 時間復雜度為O(nlogn)的排序算法
一、歸并排序
- 原理:將數組分成前后兩部分,對這兩部分分別進行排序,將排序好的兩部分再合并在一起,這樣整個數組就有序了。
- 方法:使用分治思想,將大問題可以拆解成子問題,子問題的解決思路與大問題一樣,所以可以使用遞歸解決,需要寫出遞推公式,并找到終止條件
- 遞推公式:merge_sort(p…r) = merge(merge_sort(p...q),merge_sort(q+1,r)) , p是數組的最小索引值,q是數組的中間索引值,r是數組的最大索引值
- 終止條件:p>=r時不在繼續分解,也就是分解到只剩下一個的時候就不分解了
- 最好、最壞、平均時間復雜度都是:O(nlogn),T(n) = 2T(n/2) + n = O(n*logn)(因為合并兩個有序數組的時間復雜度是O(n),所以需要加上n)
- 空間復雜度:每次合并操作都需要開辟臨時內存空間,所以空間復雜度為O(n),不是原地排序
- 歸并的穩定性:因為合并的時候相同元素的前后順序不變,所以歸并是穩定的排序算法
二、快速排序
- 原理:選取數組中任意一個數據作為pivot分區點,將小于它的放在它的左側,大于它的放在它的右側,利用分治思想,繼續分別對左右兩側進行同樣的操作,直至區間縮小為1。
- 方法:使用分治思想,遞歸的解決問題,需要寫出遞推公示,找出終止條件
- 遞歸公示: quick_sort(p...r) = quick_sort(p...q-1) + quick_sort(q+1,r),q是分區點,p到q-1的是小于q的,q+1到r時大于q的
- 終止條件:p>=r,也就是區間中只剩一個元素時終止
- 最好時間復雜度:如果每次選取分區點時,都能把數組等分成兩個,此時的時間復雜度和歸并一樣,都是O(n*logn)
- 最壞時間復雜度:如果每次分區都是不均等的,那么就需要n次分區操作,每次分區平均掃描n/2個元素,此時時間復雜度就退化為O(n*n)了
- 平均時間復雜度:大部分情況下的時間復雜度都是O(n*logn)
- 空間復雜度:使用交換法,使空間復雜度降低為O(1)
- 快排的穩定性:因為分區過程涉及交換操作,所以快排是不穩定的排序算法
三、如何在O(n)時間復雜度內,找出無序數組中的第K大元素
- 選取數組區間A[0...n-1]的最后一個元素A[n-1]作為分區點,進行原地分區(也就是利用快排的思想,小的放左邊,大的放右邊,組合在一起行程一個新的數組),數組就變成了三部分A[0...p-1]、A[p]、A[p+1...n-1]
- 如果p+1=K,那A[p]就是第K大元素
- 如果K>p+1,那么第K大元素就在A[p+1]到A[n-1]區間中,利用遞歸方法,在用1中的方法把A[p+1]到A[n-1]進行原地分區,
- 如果K<p+1,那么第K大元素就在A[0]到A[p-1]區間中,與3中的同理
- 時間復雜度:第一次分區,需要遍歷N個元素,第二次分區需要遍歷N/2個元素,第三次分區需要遍歷N/4,知道區間縮小為1,總共需要遍歷N+N/2+N/4+N/8+....+1=2N-1個元素,所以時間復雜度為O(2n-1),忽略常量之后,就是O(n)
- 空間復雜度:原理與快排一樣,所以空間復雜度為O(1)
第九章 時間復雜度為O(n)的排序算法
一、線性排序
- 線性排序算法包括:桶排序、計數排序、基數排序
- 線性排序由于不涉及元素間的比較,所以能做到線性時間復雜度O(n)
- 對要排序的數據要求很苛刻
二、桶排序
- 原理:將數據分到若干個有序的桶里,每個桶里單獨排序,之后再將每個桶里的數據順序取出,組成的序列就是有序的了。(分桶,每個桶內快排)
- 對數據的要求:首先,數據必須很容易就分成若干個桶,并且桶和桶之間存在天然的大小順序;其次,數據在各個桶的分布是比較均勻的。
- 時間復雜度:最好為O(n),最壞為O(nlogn),取決于桶的劃分,如果桶適量且數據分布均勻則為O(n);如果數據全在一個桶里,然后桶內部排序用了快排,則為O(nlogn)
- 空間復雜度:O(n/m),n為數據規模,m為桶的數量
- 穩定性:如果每個桶內部都用歸并的話,是穩定的,如果用快排的話,就是不穩定的
- 適用場景:例如:外部排序,也就是數據存儲在外部磁盤,且數據量較大,而內存有限,無法將數據全部加載到內存中。
三、計數排序
- 原理:計數排序其實就是特殊的桶排序,劃分若干個桶,讓每個桶內的數值都是相同的,省去桶內排序的時間 (按值分桶,每個桶內值相同,通過統計計數,實現排序)
- 對數據的要求:只能用在數據范圍不大的場景中,并且數據范圍要與數據規模相差不大,并且只能給非負整數排序
- 時間復雜度:O(n)
- 空間復雜度:O(n)
- 穩定性:穩定
四、基數排序
- 原理:按照位進行排序,從后往前一位一位的排序 (在每一位上桶排序)。例如對10萬個手機號進行排序,先按照最后一位來排序手機號,然后再按照倒數第二位排序手機號,以此類推,最后按照第一位排序手機號。
- 對數據的要求:數據必須可以分出獨立的位來,并且位之間有遞進關系,高位大則低位就不用比較了,并且每一位的數據范圍不能太大,要可以用線性算法排序。
- 時間復雜度:O(n)
- 空間復雜度:O(n)
- 穩定性: 穩定
第十章 如何實現一個通用的、高性能的排序函數?
一、選擇合適的排序算法
首先,回顧一下前邊講過的排序算法,如下圖所示:
排序算法
- 由于線性排序算法對數據要求比較苛刻,所以做通用的排序函數,不能使用線性排序
- 如果對數據規模比較小的數據進行排序,可以選擇時間復雜度為O(n * n)的排序算法
- 對數據規模比較大的數據進行排序,就需要選擇O(nlogn)的排序算法了
- O(nlogn)的算法有歸并排序、快速排序等。歸并排序的空間復雜度為O(n),也就意味著排序100M的數據,就需要200M的空間,所以歸并不適合;快速排序在平均時間復雜度為O(nlogn),但是如果分區點選擇不好的話,最壞時間復雜度為O(n * n),如果可以對分區點的選取進行優化,快排還是不錯的選擇對象。
二、快排的分區點如何選擇
- 三數取中法,我們從首、尾、中間各取一個數,選擇中間值作為分區點。如果要排序的數組比較大,那么三數取中可能就不太夠了,需要“五數取中”或者“十數取中”。
- 隨機法,每次從要排序的區間內隨機選擇一個元素作為分區點,從概率學的角度來說,不太可能每次選取的分區點都是很差的,所以平均情況下這么選也是可以的,時間復雜度退化到O(n * n)的概率不大。
三、以Glibc中的qsort()
函數為例,具體分析一下工業級的排序函數
- 如果你去看源碼,你就會知道
qsort()
會優先使用歸并排序來排序數據,歸并排序的空間復雜度為O(n),所以對小規模的數據,例如1KB、2KB而言,這個問題不大,現在計算機的內存都挺大的,這就是以空間換時間的一個典型應用。
- 如果你去看源碼,你就會知道
- 但是如果數據量比較大的話,再用歸并就不合適了,這時候,
qsort()
會改用快速排序來排序,qsort()
使用了三數取中法來選擇分區點。
- 但是如果數據量比較大的話,再用歸并就不合適了,這時候,
- 對于遞歸太深會導致堆棧溢出的問題,
qsort()
自己實現了一個堆上的棧,用手動模擬遞歸的辦法來解決的。
- 對于遞歸太深會導致堆棧溢出的問題,
- 實際上,
qsort()
不僅僅用了歸并和快排,還用到了插入排序,在快速排序過程中,當要排序的區間中的元素個數小于等于4時,qsort()
就退化為了插入排序,不再用遞歸做快排了,原因也很簡單,因為在小規模數據面前,O(n * n)時間復雜度的算法不一定比O(nlogn)的算法執行的時間長。
- 實際上,
- 我們在說復雜度分析的時候說過,算法的性能可以用時間復雜度來分析,使用O表示法的時候,會省略低階、系數、常數,但是對比小規模數據來說,低階、系數、常數就不能忽略了,也就是說O(nlogn)在沒有忽略這些前,可能是O(klogn+c),k和c可能是一個比較大的數,就可能比O(n^2)大了。
假設k = 1000,c=200時,klogn+c > n^2
knlogn+c = 1000 * 100 * log100 + 200 遠大于 10000
n^2 = 100*100 = 10000