遞歸的含義:一種很是簡潔、高效的編碼技巧,方法/函數調用自身的方式稱之爲遞歸,調用爲「遞」,返回爲「歸」。算法
全部的遞歸問題均可以用遞推公式來表達。數據庫
優勢:代碼表達能力強,編碼簡潔。編程
缺點:(1)空間複雜度高,存在棧溢出風險(策略:能夠設置遞歸次數強行終止條件);(2)存在重複計算,針對這一點能夠(策略:能夠額外增長哈希表來快速查找結果而減小重複計算);(3)過多函數調用耗時較長。數組
針對上述缺陷,任何一個遞歸問題(籠統來說)均可以轉化爲非遞歸實現方式。方法:抽象出遞推公式、初始值、邊界條件,而後用循環來實現。緩存
有序度:在一個數列中,符合順序排列的數據對個數。(滿有序度:所有數據均有序的數據對個數)安全
逆序度:在一個數列中,不符合順序排列的數據對個數。服務器
有序度 + 逆序度 = 滿有序度。 負載均衡
冒泡排序和插入排序都是穩定算法(選擇排序是不穩定算法),不論怎麼優化,二者的元素移動的次數都等於數列的逆序度。可是,冒泡排序的賦值操做是3個,而插入排序是1個。因此理論上來說,冒泡排序時間複雜度是3K,而插入排序是1K,若是但願性能更好確定要首選插入而不是冒泡。基礎的插入排序還有很大優化空間,進一步的改進可參考:希爾排序。編程語言
這三種基礎排序算法實際的用途不大,但插入排序的思想在某些編程語言的實現原理中仍是有可能用到。分佈式
歸併排序:將數列分紅先後兩部分分別進行排序,而後將兩個有序數列合併起來。(優勢:時間複雜度低且穩定排序;缺陷:因爲合併函數沒法原地執行,因此是非原地排序算法,空間複雜度較高O(n)不如快排)
快速排序:與歸併排序看起來有點相似,都是藉助「分治」思想。(優勢:時間複雜度低,空間複雜度僅O(n);缺點:不穩定排序)
快速排序的時間複雜度依賴於Partition選擇的合理性,在極端狀況下分區不合理會致使時間複雜度退化爲O(n*n)。
選擇第K大的思路,每一次分區後,看看分區較小的大小是否符合K-1?若是是那麼Guard元素就是了,若是不是,那麼繼續在分區內查找便可。
(1)桶排序
將n個數均勻分到m個桶內,每一個桶內有k=n/m個元素,桶內用快速排序O(k*logk),整個桶排序的時間複雜度就是O(m*k*logk)=O(n*logk),當m接近n時,該時間複雜度接近O(n)。
看似很棒,實際有不少限制約束條件:<1>須要數列可劃分且桶之間自然有序;<2>各桶內數據分佈均勻;<3>數據範圍不能太大,不然桶太多了。
適用場景:外部排序(內存較小,將數據劃分到外部大空間「桶」)。
(2)計數排序
能夠理解爲桶排序的一種特殊狀況:按照n個數據的數據範圍[0, k] 劃分k 個桶,省去桶內排序時間。其餘步驟跟桶排序同樣。
最巧妙的關鍵點:對數列A進行統計獲得C,C中每個位置表示該數值的不大於(<=)的元素個數,經過還原掃描C中每個位置的數據(每一次計數減一)來獲得排序結果R。
約束條件與桶排序一致,數據量不能太大。
(3)基數排序
對於數據位數一致(例如手機號碼)的排序,可能數據量很是大到不能用桶排序和計數排序。這裏有一個很巧妙的排序能夠接近達到O(n):對每一位從低到高依次進行穩定O(n)排序。例如:
若是數據位數不等,能夠經過補齊空位的方式來對齊排序。每一位的排序能夠用上面的桶排序或者計數排序來逼近O(n),而後每一位順序操做下來就是k*O(n)。總之,當位數k不太大時,時間複雜度就是O(k*n),接近O(n)。
綜上,基數排序約束條件爲:<1>數據要可以劃分出獨立的「位」;<2>位之間有遞進關係,例如高位對比以後低位能夠不用比較了;<3>每一位數據範圍不能太大,不然不能使用O(n)的桶排序/計數排序。總時間複雜度沒法達到O(n)。
優化要點:
(1)讓快速排序儘量接近O(n*logn) 的時間複雜度而不是O(n*n):分區點的選擇最重要,能夠用「三數取中法」、「隨機法」來優化;
(2)對於數據量不大的狀況,能夠選擇O(n)算法來排序,消耗O(n)的空間來換取時間是個很好的優化點;
(3)通常數據量採用快速排序O(n);
(4)對於快速排序中區間小於4個數的狀況,退化採用插入排序,雖然是O(n*n)複雜度但實際操做更快。從理論上來解釋就是O(n*logn)實際上剪去了一些常量係數,若是加上常量係數後則實際比O(n*n)更慢;
(5)利用哨兵技巧減小一次對比判斷,實際效果很好;
二分查找時間複雜度O(logn),但有約束條件:(1)連續內存空間支持隨機訪問;(2)數據有序;(3)不適合處理小規模數據,順利遍歷就OK了,也不適合太大規模數據由於連續內存申請不到;(4)不適合處理動態變化的數據,由於頻繁插入刪除影響效率;
二分查找算法實現的注意點:(1)(high + low) / 2 有可能由於序號太大而加法溢出,應改爲 low + (high - low) >> 1,這裏 >> 操做效率比 / 更高。(2)下標移動的時候注意 low = mid + 1,high = mid - 1;
二分查找算法的難點在於如何編寫正確又能處理變種狀況的代碼,例如:
(1)查找第一個值等於給定值的元素
一個精簡的「漂亮」的寫法:
1 int bsearch(int a[], int n, int val) { 2 int low = 0; 3 int high = n - 1; 4 while (low <= high) { 5 int mid = low + ((high - low) >> 1); 6 if (a[mid] >= val) { 7 high = mid - 1; 8 } 9 else { 10 low = mid + 1; 11 } 12 } 13 if (a[low] == val) { 14 return low; 15 } 16 else { 17 return -1; 18 } 19 }
一個更容易理解的寫法:
1 int bsearch(int a[], int n, int val) { 2 int low = 0; 3 int high = n - 1; 4 while (low <= high) { 5 int mid = low + ((high - low) >> 1); 6 if (a[mid] > val) { 7 high = mid - 1; 8 } 9 else if (a[mid] < val) { 10 low = mid + 1; 11 } 12 else { 13 if ((mid == 0) || (a[mid - 1] != val)) { 14 return mid; 15 } 16 else { 17 high = mid - 1; 18 } 19 } 20 } 21 return -1; 22 }
能夠看到第二種寫法只是更直觀一點,但整體思路都是:不斷縮短二分查找的範圍。讓mid==val的時候不是二分折半逼近,而是採起單步逼近,最後選擇範圍內知足數值相等的最低位便可。
(2)查找最後一個值等於給定值的元素(在上面「更容易理解的寫法」代碼中稍稍變動便可)
1 int bsearch(int a[], int n, int val) { 2 int low = 0; 3 int high = n - 1; 4 while (low <= high) { 5 int mid = low + ((high - low) >> 1); 6 if (a[mid] > val) { 7 high = mid - 1; 8 } 9 else if (a[mid] < val) { 10 low = mid + 1; 11 } 12 else { 13 if ((mid == n - 1) || (a[mid + 1] != val)) { 14 return mid; // 邊界檢查 15 } 16 else { 17 low = mid + 1; // 縮減範圍 18 } 19 } 20 } 21 return -1; 22 }
(3)找到第一個大於等於給定值的元素(想一想怎麼縮減二分查找的範圍邊界便可)
1 int bsearch(int a[], int n, int val) { 2 int low = 0; 3 int high = n - 1; 4 while (low <= high) { 5 int mid = low + ((high - low) >> 1); 6 if (a[mid] >= val) { 7 if ((mid == 0) || (a[mid - 1] < val)) { 8 return mid; 9 } 10 else { 11 high = mid - 1; 12 } 13 } 14 else { 15 low = mid + 1; 16 } 17 } 18 return -1; 19 }
(4)找到最後一個小於等於給定值的元素(同上,想一想怎麼縮減二分查找的範圍邊界便可)
1 int bsearch(int a[], int n, int val) { 2 int low = 0; 3 int high = n - 1; 4 while (low <= high) { 5 int mid = low + ((high - low) >> 1); 6 if (a[mid] <= val) { 7 if ((mid == n - 1) || (a[mid + 1] > val)) { 8 return mid; 9 } 10 else { 11 low = mid + 1; 12 } 13 } 14 else { 15 high = mid - 1; 16 } 17 } 18 return -1; 19 }
我以爲最後的思考題更值得思考研究:若是是一個循環有序數組(如:4,5,6,1,2,3)那麼如何實現上述的二分查找?網友給出來三種思路,第三種很棒:
(1)找到分界點(時間複雜度O(n)),判斷目標元素在哪一個區間,而後在區間內進行普通二分查找;
(2)找到分界點(時間複雜度O(n))下標x,全部元素下標+x偏移量,超出數組範圍則取模。對找到的元素點再作下標-x處理。
(3)將數組mid折半劃分爲一個有序部分和循環有序部分(時間複雜度O(logN)):若是a[0] < a[mid] 則證實前半部分有序然後半部分爲循環有序(反之亦然),若是目標元素在有序範圍內則普通二分查找;反之則對循環有序部分繼續作上一步查找。
跳錶基本結構以下(對原始鏈表創建索引層,加快查找速度):
這裏以每N=2個元素創建索引爲例(若是N>2也是同樣的):
(1)查找效率 O(logN):索引層高度logN,每一層訪問不超過3個,所以是3 * logN
(2)空間複雜度O(N):索引空間和爲 N/2 + N/4 + ... + 2 = N-2
(3)插入時間複雜度O(logN):查找的時間複雜度就是上面的O(logN),實際插入操做時間複雜度O(1)
(4)跳錶索引更新:採起「隨機函數」的方式來維護平衡性。若是隨機函數返回值K,就表示從1~K級插入索引數據。這個函數很講究,要保證性能不至於過分退化。
爲何Redis採用跳錶而不是紅黑樹呢?可能有如下緣由:(1)按照區間來查找數據,跳錶能夠在O(logN)找到起點再順序遍歷,比紅黑樹快;(2)跳錶比紅黑樹更易實現,可讀性好不易出錯;(3)跳錶更靈活,能夠經過改變索引構建策略有效地平衡效率和內存消耗。
跳錶相比紅黑樹的不足:(1)紅黑樹出現的更早,有現成庫支持。跳錶只能本身動手實現;
散列表來源於數據,利用散列函數對數據進行改造,利用的是數組支持隨機訪問的特色。核心兩個問題:(1)散列函數設計;(2)衝突解決方法 --> 經常使用兩種:開放尋址法、鏈表法。散列函數是好壞決定了散列表的衝突機率(即性能)。
「散列表碰撞攻擊」:攻擊者利用精心構造的數據所有散列到同一個槽內,讓查找效率從O(1)退化爲O(n),達到DDOS效果。
如何設計好的散列函數?(1)不能態複雜,不然計算量大會下降效率;(2)裝載因子不能太大。
裝載因子太大怎麼辦?擴容下降因子。一次性擴容會形成瞬間耗時巨大,能夠改爲分批次擴容,將擴容動做分攤到每個插入動做中去。查找的時候能夠先查新表,再查舊錶。
衝突解決辦法的優缺點對比:(1)開放尋址法:易序列化,且內存集中可藉助CPU緩存加速。但衝突代價太大,耗內存(適用於小數據低裝載因子的場景);(2)鏈表法:內存利用率高,當存儲大數據時指針的消耗可忽略,內存不連續對CPU緩存不友好(適用於大數據量大對象場景)。鏈表法能夠進一步優化成「跳錶」、「紅黑樹」達到O(logN)的效果,有效抵禦「散列表碰撞攻擊」。
原來的LRU淘汰算法用鏈表實現,時間複雜度是O(n),若是加上哈希表就會好不少,雖然操做看起來複雜了一點,可是時間複雜度能夠是O(1)。散列表負責快速定位,而後增長一個雙向鏈表能夠快速連接到隊列頭(尾),實現淘汰。
另外,若是將雙向鏈表換成跳錶,就具備了跳錶的能力了,能夠快速定位區間數據。
哈希算法四個應用場景:(1)安全加密,如MD五、SHA、DES、AES;(2)惟一標識,做爲ID快速定位再仔細對比確認;(3)數據校驗;(4)散列函數。
哈希算法在分佈式系統中應用場景:(5)負載均衡,利用哈希算法把訪問者IP+ID散列到固定值%服務器個數,就能夠算出目標服務器而不須要映射表了;(6)數據分片,將同類數據計算哈希並分配到指定機器。藉助分片的思路,能夠突破單機內存、CPU等資源限制;(7)分佈式存儲,如何解決分佈式系統擴容、縮容致使大量數據搬遷的問題?此時要用到「一致性哈希」:將原有的分隔區間進行進一步細分,把新增節點插入進來轉移部分子區間數據便可,不影響原有散列位置。
樹的基本概念,滿二叉樹是所有節點都是二叉的,主要是記住「徹底二叉樹」的概念就行。爲什麼單獨把葉子節點所有靠左的稱之爲「徹底二叉樹」呢?又爲什麼把這個概念單獨拿出來說呢?看看按數組的二叉樹存儲方式就知道了——空間利用率很高!(堆其實就是徹底二叉樹,最經常使用的是數組存儲)
思考題有點難度:(1)怎麼實現按層遍歷?藉助棧或者層數控制?(2)給定一個數列怎麼算出能夠構建多少種樹?
二叉排序樹的查找、插入、刪除操做,其中刪除略複雜一點點,注意刪除包含左右子節點的方式(將右子數最小節點替換刪除節點)就好了。更取巧的作法是:僅標記節點DEL而並不真正操做節點刪除。若是是支持重複節點的二叉排序樹呢,插入的方案能夠是單節點存儲多個,也能夠採用一種巧妙方案(將重複節點繼續插入右子樹),查找和刪除重複節點的二叉樹就須要一直找到葉子節點爲止。
爲了有了O(n)的散列表方案還須要搞一個二叉排序樹呢?緣由有:(1)散列表不利於輸出有序數據,而二叉排序樹採用中序遍歷就能O(n)輸出有序數列;(2)散列表擴容耗時長且衝突時性能不穩定,而採用「平衡二叉排序樹」很是穩定在O(logN);(3)哈希衝突的查找時間與哈希函數計算的時間累加,可能並不必定比平衡二叉排序樹的效率高;(4)散列表的構造比二叉查找樹複雜,要考慮效率、衝突、擴容等諸多問題,平衡二叉查找樹只須要考慮平衡一個問題。
平衡二叉查找樹中「平衡」的定義是任意節點的左右子樹高度差不大於1。但究其本質「平衡」的目的是但願二叉排序樹不要由於頻繁動態更新而致使性能退化,實際上不必定要嚴格遵循高度差不大於1的定義要求。例如紅黑樹,就是一種非嚴格的平衡二叉排序樹屬於「近似平衡」。
爲何工程中都喜歡紅黑樹這種非嚴格的平衡二叉排序樹呢?由於Treap、Splay Tree儘管絕大多數狀況下效率很高,但沒法避免極端狀況下時間退化,所以對於單次操做比較敏感的場景並不適用;AVL樹是一種高度平衡的二叉查找樹,朝趙很高效,可是維護成本很是高昂,不適用於頻繁插入刪除操做的場合。因此,爲了支撐複雜的工業應用場景,更傾向於使用性能穩定的近似平衡的二叉查找樹(紅黑樹)。
插入刪除的操做會對樹的平衡產生影響,先來複習下基礎概念「左旋」「右旋」。
紅黑色的調整步驟很是複雜,下面分別來區分CASE對照說明便可:
全部調整的方法就是兩種基本操做「左右旋轉」和「改變顏色」。兩種特殊狀況:(1)插入節點的父節點是黑色的,那麼什麼都不用作;(2)插入節點自身就是根節點,那麼把顏色變成黑色便可。
其餘狀況,劃分爲三種固定CASE來處理便可:
紅黑樹的刪除操做比插入要難不少。分爲兩步走:(1)第一步對刪除節點初步調整,目標是確保知足最後一條定義約束:即整個樹在刪除節點後,每一個節點到達葉子節點的全部路徑都包含相同數量的黑色節點;(2)針對關注節點作二次調整,目標是確保知足第三條定義約束:即不存在相鄰紅色節點。