【01-概述】
10個數據結構:數組、鏈表、棧、隊列、散列表、二叉樹、堆、跳表、圖、Trie 樹;
10個算法:遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態 規划、字符串匹配算法。
【復雜度分析】
一、什么是復雜度分析?
1.數據結構和算法解決是“如何讓計算機更快時間、更省空間的解決問題”。
2.因此需從執行時間和占用空間兩個維度來評估數據結構和算法的性能。
3.分別用時間復雜度和空間復雜度兩個概念來描述性能問題,二者統稱為復雜度。
4.復雜度描述的是算法執行時間(或占用空間)與數據規模的增長關系。
二、為什么要進行復雜度分析?
1.和性能測試相比,復雜度分析有不依賴執行環境、成本低、效率高、易操作、指導性強的特點。
2.掌握復雜度分析,將能編寫出性能更優的代碼,有利於降低系統開發和維護成本。
三、如何進行復雜度分析?
1.大O表示法
(1)來源
算法的執行時間與每行代碼的執行次數成正比,用T(n) = O(f(n))表示,其中T(n)表示算法執
行總時間,f(n)表示每行代碼執行總次數,而n往往表示數據的規模。
(2)特點
以時間復雜度為例,由於時間復雜度描述的是算法執行時間與數據規模的增長變化趨勢,所
以常量階、低階以及系數實際上對這種增長趨勢不產決定性影響,所以在做時間復雜度分析
時忽略這些項。
2.復雜度分析法則
1)單段代碼看高頻:比如循環。
2)多段代碼取最大:比如一段代碼中有單循環和多重循環,那么取多重循環的復雜度。
3)嵌套代碼求乘積:比如遞歸、多重循環等
4)多個規模求加法:比如方法有兩個參數控制兩個循環的次數,那么這時就取二者復雜度相加。
四、常用的復雜度級別?
多項式階:隨着數據規模的增長,算法的執行時間和空間占用,按照多項式的比例增長。包括:
O(1)(常數階)、O(logn)(對數階)、O(n)(線性階)、O(nlogn)(線性對數階)、O(n^2)(平方階)、O(n^3)(立方階)
非多項式階:隨着數據規模的增長,算法的執行時間和空間占用暴增,這類算法性能極差。包括:
O(2^n)(指數階)、O(n!)(階乘階)
五、復雜度分析的4個概念
1.最壞情況時間復雜度:代碼在最理想情況下執行的時間復雜度。
2.最好情況時間復雜度:代碼在最壞情況下執行的時間復雜度。
3.平均時間復雜度:用代碼在所有情況下執行的次數的加權平均值表示。
4.均攤時間復雜度:在代碼執行的所有復雜度情況中絕大部分是低級別的復雜度,個別情況是高級別復雜度且發生具有時序關系時,可以將個別高級別復雜度均攤到低級別復雜度上。基
本上均攤結果就等於低級別復雜度。
六、為什么要引入這4個概念?
1.同一段代碼在不同情況下時間復雜度會出現量級差異,為了更全面,更准確的描述代碼的時間復雜度,所以引入這4個概念。
2.代碼復雜度在不同情況下出現量級差別時才需要區別這四種復雜度。大多數情況下,是不需要區別分析它們的。
七、如何分析平均、均攤時間復雜度?
1.平均時間復雜度
代碼在不同情況下復雜度出現量級差別,則用代碼所有可能情況下執行次數的加權平均值表示。
2.均攤時間復雜度
兩個條件滿足時使用:1)代碼在絕大多數情況下是低級別復雜度,只有極少數情況是高級別
復雜度;2)低級別和高級別復雜度出現具有時序規律。均攤結果一般都等於低級別復雜度。
【02-數組和鏈表】
數組(Array)是一種線性表數據結構。它用一組連續的內存空間,來存儲一組具有相同類型的數據。
數組、鏈表、隊列、棧等都是線性表結構。
與它相對立的概念是非線性表,比如二叉樹、堆、圖等。之所以叫非線性,是因為,在非線性表中,數據之間並不是簡單的前后關系。
1.線性表
線性表就是數據排成像一條線一樣的結構。常見的線性表結構:數組,鏈表、隊列、棧等。
2. 連續的內存空間和相同類型的數據
優點:兩限制使得具有隨機訪問的特性
缺點:刪除,插入數據效率低(為何數組插入和刪除低效?)
【插入】
若有一元素想往int[n]的第k個位置插入數據,需要在k-n的位置往后移。
最好情況時間復雜度 O(1),最壞情況復雜度為O(n),平均負責度為O(n)
如果數組中的數據不是有序的,也就是無規律的情況下,可以直接把第k個位置上的數據移到
最后,然后將插入的數據直接放在第k個位置上。
這樣時間復雜度就將為 O(1)了。
【刪除】
與插入類似,為了保持內存的連續性。
最好情況時間復雜度 O(1),最壞情況復雜度為O(n),平均復雜度為O(n)
提高效率:將多次刪除操作中集中在一起執行,可以先記錄已經刪除的數據,但是不進行數據遷移,而僅僅是記錄,當發現沒有更多空間存儲時,再執行真正的刪除操作。
這也是 JVM標記清除垃圾回收算法的核心思想。
用數組還是容器?
數組先指定了空間大小,容器如ArrayList可以動態擴容。
1.希望存儲基本類型數據,可以用數組
2.事先知道數據大小,並且操作簡單,可以用數組
3.直觀表示多維,可以用數組
4.業務開發,使用容器足夠,開發框架,追求性能,首先數組。
為什么數組要從 0 開始編號?
由於數組是通過尋址公式,計算出該元素存儲的內存地址:a[i]_address = base_address + i * data_type_size
如果數組是從 1 開始計數,那么就會變成:a[i]_address = base_address + (i-1)* data_type_size
對於CPU來說,多了一次減法的指令。當然,還有一定的歷史原因。
緩存 是一種提高數據讀取性能的技術,在硬件設計、軟件開發中都有着非常廣泛的應用,比如常見的 CPU 緩存、數據庫緩存、瀏覽器緩存等等。
緩存的大小有限,當緩存被用滿時,哪些數據應該被清理出去,哪些數據應該被保留?這就需要緩存淘汰策略來決定。
常見的策略有三種:先進先出策略 FIFO(First In,First Out)、最少使用策略 LFU(Least Frequently Used)、最近最少使用策略 LRU(Least Recently Used)。
緩存實際上就是利用了空間換時間的設計思想。
對於執行較慢的程序,可以通過消耗更多的內存(空間換時間)來進行優化;
而消耗過多內存的程序,可以通過消耗更多的時間(時間換空間)來降低內存的消耗。
如何用鏈表來實現 LRU 緩存淘汰策略呢?
三種最常見的鏈表結構,它們分別是:單鏈表、雙向鏈表、循環鏈表、雙向循環鏈表。
1.單鏈表
(1)每個節點只包含一個指針,即后繼指針。
(2)單鏈表有兩個特殊的節點,即首節點和尾節點。為什么特殊?用首節點地址表示整條鏈表,尾節點的后繼指針指向空地址null。
(3)性能特點:插入和刪除節點的時間復雜度為O(1),查找的時間復雜度為O(n)。
2.循環鏈表
(1)除了尾節點的后繼指針指向首節點的地址外均與單鏈表一致。
(2)適用於存儲有循環特點的數據,比如約瑟夫問題。
3.雙向鏈表
(1)節點除了存儲數據外,還有兩個指針分別指向前一個節點地址(前驅指針prev)和下一個節點地址(后繼指針next)。
(2)首節點的前驅指針prev和尾節點的后繼指針均指向空地址。
與數組一樣,鏈表也支持數據的查找、插入和刪除操作。
數組和鏈表是兩種截然不同的內存組織方式。正是因為內存存儲的區別,它們插入、刪除、隨機訪問操作的時間復雜度正好相反。
選擇數組還是鏈表?
1.插入、刪除和隨機訪問的時間復雜度
數組:插入、刪除的時間復雜度是O(n),隨機訪問的時間復雜度是O(1)。
鏈表:插入、刪除的時間復雜度是O(1),隨機訪問的時間復雜端是O(n)。
2.數組缺點
(1)若申請內存空間很大,比如100M,但若內存空間沒有100M的連續空間時,則會申請失敗,盡管內存可用空間超過100M。
(2)大小固定,若存儲空間不足,需進行擴容,一旦擴容就要進行數據復制,而這時非常費時的。
3.鏈表缺點
(1)內存空間消耗更大,因為需要額外的空間存儲指針信息。
(2)對鏈表進行頻繁的插入和刪除操作,會導致頻繁的內存申請和釋放,容易造成內存碎片,如果是Java語言,還可能會造成頻繁的GC(自動垃圾回收器)操作。
4.如何選擇?
數組簡單易用,在實現上使用連續的內存空間,可以借助CPU的緩沖機制預讀數組中的數據,所以訪問效率更高,而鏈表在內存中並不是連續存儲,所以對CPU緩存不友好,沒辦法預讀。如果代碼對內存的使用非常苛刻,那數組就更適合。
1.對於指針(或者引用)的理解:
將某個變量賦值給指針,實際上就是將這個變量的地址賦值給指針,或者反過來說,指針中存儲了這個變量的內存地址,指向了這個變量,通過指針就能找到這個變量。
2.我們插入結點時,一定要注意操作的順序;刪除鏈表結點時,也一定要記得手動釋放內存空間,否則,也會出現內存泄漏的問題。
3. 利用哨兵簡化難度
鏈表的插入、刪除操作,需要對插入第一個結點和刪除最后一個節點做特殊處理。利用哨兵對象可以不用邊界判斷,鏈表的哨兵對象是只存指針不存數據的頭結點。
4. 重點留意邊界條件處理
操作鏈表時要考慮鏈表為空、一個結點、兩個結點、頭結點、尾結點的情況。學習數據結構和算法主要是掌握一系列思想,能在其它的編碼中也養成考慮邊界的習慣。
經常用來檢查鏈表代碼是否正確的邊界條件有這樣幾個:
如果鏈表為空時,代碼是否能正常工作?
如果鏈表只包含一個結點時,代碼是否能正常工作?
如果鏈表只包含兩個結點時,代碼是否能正常工作?
代碼邏輯在處理頭結點和尾結點的時候,是否能正常工作
經典鏈表操作案例:
* 單鏈表反轉
* 鏈表中環的檢測
* 兩個有序的鏈表合並
* 刪除鏈表倒數第 n 個結點
* 求鏈表的中間結點
【03-棧&隊列&遞歸】
【棧】
后進先出,先進后出,這就是典型的“棧”結構。
任何數據結構都是對特定應用場景的抽象,數組和鏈表雖然使用起來更加靈活,但卻暴露了幾乎所有的操作,難免會引發錯誤操作的風險。
當某個數據集合只涉及在一端插入和刪除數據,並且滿足后進先出、先進后出的特性,我們就應該首選“棧”這種數據結構。
棧主要包含兩個操作,入棧和出棧。
實際上,棧既可以用數組來實現,也可以用鏈表來實現。用數組實現的棧,我們叫作順序棧,用鏈表實現的棧,我們叫作鏈式棧。
對於出棧操作來說,我們不會涉及內存的重新申請和數據的搬移,所以出棧的時間復雜度仍然是O(1)。但是,對於入棧操作來說,情況就不一樣了。當棧中有空閑空間時,入棧操作的時間復雜度為 O(1)。但當空間不夠時,就需要重新申請內存和數據搬移,所以時間復雜度就變成了O(n)。
【隊列】
先進者先出,這就是典型的“隊列”。
最基本的兩個操作:入隊enqueue(),放一個數據到隊列尾部;出隊dequeue(),從隊列頭部取一個元素。隊列可以用數組或者鏈表實現,用數組實現的隊列叫作順序隊列,用鏈表實現的隊列叫作鏈式隊列。
隊列需要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。
在數組實現隊列的時候,會有數據搬移操作,要想解決數據搬移的問題,我們就需要像環一樣的循環隊列。
阻塞隊列就是在隊列為空的時候,從隊頭取數據會被阻塞,因為此時還沒有數據可取,直到隊列中有了數據才能返回;如果隊列已經滿了,那么插入數據的操作就會被阻塞,直到隊列中有空閑位置后再插入數據,然后在返回。
在多線程的情況下,會有多個線程同時操作隊列,這時就會存在線程安全問題。能夠有效解決線程安全問題的隊列就稱為並發隊列。
基於鏈表的實現方式,可以實現一個支持無限排隊的無界隊列(unbounded queue),但是可能會導致過多的請求排隊等待,請求處理的響應時間過長。所以,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊的線程池是不合適的。
而基於數組實現的有界隊列(bounded queue),隊列的大小有限,所以線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來說,就相對更加合理。不過,設置一個合理的隊列大小,也是非常有講究的。隊列太大導致等待的請求太多,隊列太小會導致無法充分利用系統資源、發揮最大性能。
實際上,對於大部分資源有限的場景,當沒有空閑資源時,基本上都可以通過“隊列”這種數據結構來實現請求排隊。
【遞歸】
遞歸需要滿足的三個條件:
1. 一個問題的解可以分解為幾個子問題的解
2. 這個問題與分解之后的子問題,除了數據規模不同,求解思路完全一樣
3. 存在遞歸終止條件
寫遞歸代碼的關鍵就是找到如何將大問題分解為小問題的規律,並且基於此寫出遞推公式,然后再推敲終止條件,最后將遞推公式和終止條件翻譯成代碼。遞歸代碼雖然簡潔高效,但是,遞歸代碼也有很多弊端。比如,堆棧溢出、重復計算、函數調用耗時多、空間復雜度高等,所以,在編寫遞歸代碼的時候,一定要控制好這些副作用。
遞歸的優缺點?
1.優點:代碼的表達力很強,寫起來簡潔。
2.缺點:空間復雜度高、有堆棧溢出風險、存在重復計算、過多的函數調用會耗時較多等問題。
遞歸常見問題及解決方案
1.警惕堆棧溢出:可以聲明一個全局變量來控制遞歸的深度,從而避免堆棧溢出。
2.警惕重復計算:通過某種數據結構來保存已經求解過的值,從而避免重復計算。
【04-排序】
幾種最經典、最常用的排序方法:冒泡排序、插入排序、選擇排序、歸並排序、快速排序、計數排序、基數排序、桶排序。
對於排序算法執行效率的分析,我們一般會從這幾個方面來衡量:
1. 最好情況、最壞情況、平均情況時間復雜度
2. 時間復雜度的系數、常數 、低階
3. 比較次數和交換(或移動)次數
排序算法的穩定性:如果待排序的序列中存在值相等的元素,經過排序之后,相等元素之間原有的先后順序不變。
【冒泡排序(Bubble Sort)】
冒泡排序只會操作相鄰的兩個數據。每次冒泡操作都會對相鄰的兩個元素進行比較,看是否滿足大小關系要求。
如果不滿足就讓它倆互換。一次冒泡會讓至少一個元素移動到它應該在的位置,重復 n 次,就完成了 n 個數據的排序工作。
* Q:第一,冒泡排序是原地排序算法嗎?
A:冒泡的過程只涉及相鄰數據的交換操作,只需要常量級的臨時空間,所以它的空間復雜度為O(1),是一個原地排序算法。
* Q:第二,冒泡排序是穩定的排序算法嗎?
A:在冒泡排序中,只有交換才可以改變兩個元素的前后順序。為了保證冒泡排序算法的穩定性,當有相鄰的兩個元素大小相等的時候,我們不做交換,相同大小的數據在排序前后不會改變順序,所以冒泡排序是穩定的排序算法。
* Q:第三,冒泡排序的時間復雜度是多少?
最好情況下,要排序的數據已經是有序的了,我們只需要進行一次冒泡操作,就可以結束了,所以最好情況時間復雜度是 O(n)。而最壞的情況是,要排序的數據剛好是倒序排列的,我們需要進行 n 次冒泡操作,所以最壞情況時間復雜度為 O(n²)。
【插入排序(Insertion Sort)】
我們將數組中的數據分為兩個區間,已排序區間和未排序區間。初始已排序區間只有一個元素,就是數組的第一個元素。
插入算法的核心思想是取未排序區間中的元素,在已排序區間中找到合適的插入位置將其插入,並保證已排序區間數據一直有序。
重復這個過程,直到未排序區間中元素為空,算法結束。
* 空間復雜度:插入排序是原地排序算法。
* 時間復雜度:1. 最好情況:O(n)。2. 最壞情況:O(n^2)。3. 平均情況:O(n^2)
* 穩定性:插入排序是穩定的排序算法。
【選擇排序(Selection Sort)】
選擇排序算法的實現思路有點類似插入排序,也分已排序區間和未排序區間。但是選擇排序每次會從未排序區間中找到最小的元素,將其放到已排序區間的末尾。
* 選擇排序空間復雜度為 O(1),是一種原地排序算法。
* 選擇排序的最好情況時間復雜度、最壞情況和平均情況時間復雜度都為O(n²)。
* 選擇排序是一種不穩定的排序算法。
冒泡排序和插入排序的時間復雜度都是 O(n²),都是原地排序算法,為什么插入排序要比冒泡排序更受歡迎呢?
從代碼實現上來看,冒泡排序的數據交換要比插入排序的數據移動要復雜,冒泡排序需要3 個賦值操作,而插入排序只需要 1 個。
如何在 O(n) 的時間復雜度內查找一個無序數組中的第 K 大元素?需要用到兩種時間復雜度為 O(nlogn) 的排序算法:歸並排序和快速排序。這兩種排序算法適合大規模的數據排序。
【歸並排序(Merge Sort)】
如果要排序一個數組,我們先把數組從中間分成前后兩部分,然后對前后兩部分分別排序,再將排好序的兩部分合並在一起,這樣整個數組就都有序了。
歸並排序使用的就是分治思想。分治算法一般都是用遞歸來實現的。(分治是一種解決問題的處理思想,遞歸是一種編程技巧)
* 歸並排序是一個穩定的排序算法。
* 歸並排序的時間復雜度是非常穩定的,不管是最好情況、最壞情況,還是平均情況,時間復雜度都是 O(nlogn)。
* 但是,歸並排序不是原地排序算法,歸並排序的空間復雜度是 O(n)。(因為歸並排序的合並函數,在合並兩個有序數組為一個有序數組時,需要借助額外的存儲空間)
【快速排序(Quicksort)】
快排的思想是這樣的:如果要排序數組中下標從 p 到 r 之間的一組數據,我們選擇 p 到 r 之間的任意一個數據作為 pivot(分區點)。
我們遍歷 p 到 r 之間的數據,將小於 pivot 的放到左邊,將大於 pivot 的放到右邊,將 pivot放到中間。
經過這一步驟之后,數組 p 到 r 之間的數據就被分成了三個部分,前面 p 到 q-1 之間都是小於 pivot 的,中間是 pivot,后面的 q+1 到 r 之間是大於 pivot 的。
根據分治、遞歸的處理思想,我們可以用遞歸排序下標從 p 到 q-1 之間的數據和下標從 q+1 到r 之間的數據,直到區間縮小為 1,就說明所有的數據都有序了。
* 快排是一種原地、不穩定的排序算法。
* 快排的時間復雜度也是 O(nlogn)
歸並排序和快速排序是兩種稍微復雜的排序算法,它們用的都是分治的思想,代碼都通過遞歸來實現,過程非常相似。
歸並排序算法是一種在任何情況下時間復雜度都比較穩定的排序算法,這也使它存在致命的缺點,即歸並排序不是原地排序算法,空間復雜度比較高,是 O(n)。正因為此,它也沒有快排應用廣泛。
快速排序算法雖然最壞情況下的時間復雜度是 O(n ),但是平均情況下時間復雜度都是O(nlogn)。
不僅如此,快速排序算法時間復雜度退化到 O(n ) 的概率非常小,我們可以通過合理地選擇 pivot 來避免這種情況。
三種時間復雜度是 O(n) 的排序算法:桶排序、計數排序、基數排序。因為這些排序算法的時間復雜度是線性的,所以我們把這類排序算法叫作線性排序(Linear sort)。
【桶排序(Bucket sort)】
將要排序的數據分到幾個有序的桶里,每個桶里的數據再單獨進行排序。桶內排完序之后,再把每個桶里的數據按照順序依次取出,組成的序列就是有序的了。
桶排序對要排序數據的要求是非常苛刻的。首先,要排序的數據需要很容易就能划分成 m 個桶,並且,桶與桶之間有着天然的大小順序。這樣每個桶內的數據都排序完之后,桶與桶之間的數據不需要再進行排序。其次,數據在各個桶之間的分布是比較均勻的。如果數據經過桶的划分之后,有些桶里的數據非常多,有些非常少,很不平均,那桶內數據排序的時間復雜度就不是常量級了。在極端情況下,如果數據都被划分到一個桶里,那就退化為 O(nlogn) 的排序算法了。
桶排序比較適合用在外部排序中。所謂的外部排序就是數據存儲在外部磁盤中,數據量比較大,內存有限,無法將數據全部加載到內存中。
【計數排序(Counting sort)】—— 其實是桶排序的一種特殊情況
當要排序的 n 個數據,所處的范圍並不大的時候,比如最大值是 k,我們就可以把數據划分成 k 個桶。每個桶內的數據值都是相同的,省掉了桶內排序的時間
計數排序只能用在數據范圍不大的場景中,如果數據范圍 k 比要排序的數據 n 大很多,就不適合用計數排序了。而且,計數排序只能給非負整數排序,如果要排序的數據是其他類型的,要將其在不改變相對大小的情況下,轉化為非負整數。
問題:如何根據年齡給100萬用戶數據排序?
我們假設年齡的范圍最小 1 歲,最大不超過 120 歲。我們可以遍歷這 100 萬用戶,根據年齡將其划分到這 120個桶里,然后依次順序遍歷這 120 個桶中的元素。這樣就得到了按照年齡排序的 100 萬用戶數據。
【基數排序(Radix sort)】
假設有 10 萬個手機號碼,希望將這 10 萬個手機號碼從小到大排序,你有什么比較快速的排序方法呢?
有這樣的規律:假設要比較兩個手機號碼 a,b 的大小,如果在前面幾位中,a手機號碼已經比 b 手機號碼大了,那后面的幾位就不用看了。
基數排序對要排序的數據是有要求的,需要可以分割出獨立的“位”來比較,而且位之間有遞進的關系,如果 a 數據的高位比 b 數據大,那剩下的低位就不用比較了。除此之外,每一位的數據范圍不能太大,要可以用線性排序算法來排序,否則,基數排序的時間復雜度就無法做到 O(n) 了。
如何實現一個通用的、高性能的排序函數?
快速排序比較適合來實現排序函數,如何優化快速排序?最理想的分區點是:被分區點分開的兩個分區中,數據的數量差不多。為了提高排序算法的性能,要盡可能地讓每次分區都比較平均。
* 1. 三數取中法
①從區間的首、中、尾分別取一個數,然后比較大小,取中間值作為分區點。
②如果要排序的數組比較大,那“三數取中”可能就不夠用了,可能要“5數取中”或者“10數取中”。
* 2.隨機法:每次從要排序的區間中,隨機選擇一個元素作為分區點。
* 3.警惕快排的遞歸發生堆棧溢出,有2種解決方法,如下:
①限制遞歸深度,一旦遞歸超過了設置的閾值就停止遞歸。
②在堆上模擬實現一個函數調用棧,手動模擬遞歸壓棧、出棧過程,這樣就沒有系統棧大小的限制。
通用排序函數實現技巧
1.數據量不大時,可以采取用時間換空間的思路
2.數據量大時,優化快排分區點的選擇
3.防止堆棧溢出,可以選擇在堆上手動模擬調用棧解決
4.在排序區間中,當元素個數小於某個常數是,可以考慮使用O(n^2)級別的插入排序
5.用哨兵簡化代碼,每次排序都減少一次判斷,盡可能把性能優化到極致
【05-查找&跳表&散列表】
【二分查找】
二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小為之前的一半,直到找到要查找的元素,或者區間被縮小為 0。
二分查找是一種非常高效的查找算法,時間復雜度是 O(logn)。O(logn) 這種對數時間復雜度,是一種極其高效的時間復雜度,有的時候甚至比時間復雜度是常量級O(1) 的算法還要高效。二分查找更適合處理靜態數據,也就是沒有頻繁的數據插入、刪除操作。
使用循環和遞歸都可以實現二分查找。
二分查找應用場景的局限性:
* 二分查找依賴的是順序表結構,簡單點說就是數組。(鏈表不可以)
* 二分查找針對的是有序數據。(如果數據沒有序,我們需要先排序。)
* 數據量太大不適合二分查找。
四種常見的二分查找變形問題
1.查找第一個值等於給定值的元素
2.查找最后一個值等於給定值的元素
3.查找第一個大於等於給定值的元素
4.查找最后一個小於等於給定值的元素
適用性分析
1.凡事能用二分查找解決的,絕大部分我們更傾向於用散列表或者二叉查找樹,即便二分查找在內存上更節省,但是畢竟內存如此緊缺的情況並不多。
2.求“值等於給定值”的二分查找確實不怎么用到,二分查找更適合用在”近似“查找問題上。比如上面講幾種變體。
【跳表】
跳表是一種動態數據結構,可以支持快速的插入、刪除、查找操作,寫起來也不復雜,甚至可以替代紅黑樹(Red-black tree)。Redis 中的有序集合(Sorted Set)就是用跳表來實現的。
鏈表加多級索引的結構,就是跳表。
在一個單鏈表中查詢某個數據的時間復雜度是 O(n)。那在一個具有多級索引的跳表中查詢任意數據的時間復雜度是 O(logn)。這
個查找的時間復雜度跟二分查找是一樣的。換句話說,我們其實是基於單鏈表實現了二分查找。(這種查詢效率的提升,前提是建立了很多級索引,也就是空間換時間的設計思路。)
跳表的空間復雜度是O(n)。也就是說,如果將包含 n 個結點的單鏈表構造成跳表,我們需要額外再用接近 n 個結點的存儲空間。
在實際的軟件開發中,原始鏈表中存儲的有可能是很大的對象,而索引結點只需要存儲關鍵值和幾個指針,並不需要存儲對象,所以當對象比索引結點大很多時,那索引占用的額外空間就可以忽略了。
跳表這個動態數據結構,不僅支持查找操作,還支持動態的插入、刪除操作,而且插入、刪除操作的時間復雜度也是 O(logn)。
作為一種動態數據結構,我們需要某種手段來維護索引與原始鏈表大小之間的平衡,也就是說,如果鏈表中結點多了,索引結點就相應地增加一些,避免復雜度退化,以及查找、插入、刪除操作性能下降。
跳表是通過隨機函數來維護“平衡性”,當我們往跳表中插入數據的時候,我們可以選擇同時將這個數據插入到部分索引層中。
為什么 Redis 要用跳表來實現有序集合,而不是紅黑樹?
Redis 中的有序集合支持的核心操作主要有下面這幾個:
* 插入一個數據;
* 刪除一個數據;
* 查找一個數據;
* 按照區間查找數據(比如查找值在 [100, 356] 之間的數據);
* 迭代輸出有序序列。
對於按照區間查找數據這個操作,跳表可以做到 O(logn) 的時間復雜度定位區間的起點,然后在原始鏈表中順序往后遍歷就可以了。這樣做非常高效。
【散列表】
用的是數組支持按照下標隨機訪問數據的特性,所以散列表其實就是數組的一種擴展,由數組演化而來。可以說,如果沒有數組,就沒有散列表。
散列函數,可以把它定義成hash(key),其中 key 表示元素的鍵值,hash(key) 的值表示經過散列函數計算得到的散列值。
散列函數設計的基本要求:
1. 散列函數計算得到的散列值是一個非負整數;
2. 如果 key1 = key2,那 hash(key1) == hash(key2);
3. 如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。
散列沖突
再好的散列函數也無法避免散列沖突。常用的散列沖突解決方法有兩類,開放尋址法(open addressing)和鏈表法(chaining)。
開放尋址法的核心思想是,如果出現了散列沖突,我們就重新探測一個空閑位置,將其插入。三種探測方法是:線性探測(Linear Probing)、二次探測(Quadratic probing)、雙重散列(Double hashing)。
【06-哈希】
哈希算法的定義:將任意長度的二進制值串映射為固定長度的二進制值串,這個映射的規則就是哈希算法,而通過原始數據映射之后得到的二進制值串就是哈希值。常見的例如:MD5、SHA。
設計一個優秀的哈希算法需要滿足的幾點要求:
* 從哈希值不能反向推導出原始數據(所以哈希算法也叫單向哈希算法);
* 對輸入數據非常敏感,哪怕原始數據只修改了一個 Bit,最后得到的哈希值也大不相同;
* 散列沖突的概率要很小,對於不同的原始數據,哈希值相同的概率非常小;
* 哈希算法的執行效率要盡量高效,針對較長的文本,也能快速地計算出哈希值。
哈希算法的七個常見應用:
* 安全加密:MD5、SHA、DES、AES。很難根據哈希值反向推導出原始數據;散列沖突的概率要很小(因為無法做到零沖突)。
* 唯一標識:哈希算法可以對大數據做信息摘要,通過一個較短的二進制編碼來表示很大的數據。
(1)海量的圖庫中,搜索一張圖是否存在
* 數據校驗:校驗數據的完整性和正確性。
* 散列函數:對哈希算法的要求非常特別,更加看重的是散列的平均性和哈希算法的執行效率。
* 負載均衡:利用哈希算法替代映射表,可以實現一個會話粘滯的負載均衡策略。
(1)在同一個客戶端上,在一次會話中的所有請求都路由到同一個服務器上。
* 數據分片:通過哈希算法對處理的海量數據進行分片,多機分布式處理,可以突破單機資源的限制。
(1)如何統計“搜索關鍵詞”出現的次數?
(2)如何快速判斷圖片是否在圖庫中?
* 分布式存儲:利用一致性哈希算法,可以解決緩存等分布式系統的擴容、縮容導致數據大量搬移的難題。
(1)如何決定將哪個數據放到哪個機器上?
(2)一致性哈希算法
【07-二叉樹】
之前說的棧和隊列都是線性表結構,樹是非線性表結構。
關於樹的常用概念:根節點、葉子節點、父節點、子節點、兄弟節點,還有節點的高度、深度、層數,以及樹的高度。
最常用的樹就是二叉樹(Binary Tree)。二叉樹的每個節點最多有兩個子節點,分別是左子節點和右子節點。
二叉樹中,有兩種比較特殊的樹,分別是滿二叉樹和完全二叉樹。滿二叉樹又是完全二叉樹的一種特殊情況。
二叉樹的兩種存儲方式:
(1)用鏈式存儲:
* 每個節點有三個字段,其中一個存儲數據,另外兩個是指向左右子節點的指針。
* 我們只要拎住根節點,就可以通過左右子節點的指針,把整棵樹都串起來。
* 這種存儲方式我們比較常用。大部分二叉樹代碼都是通過這種結構來實現的。
(2)用數組順序存儲:
* 如果節點 X 存儲在數組中下標為 i 的位置,下標為 2 * i 的位置存儲的就是左子節點,下標為 2 * i + 1 的位置存儲的就是右子節點。
* 反過來,下標為 i/2 的位置存儲就是它的父節點。
* 通過這種方式,我們只要知道根節點存儲的位置(一般情況下,為了方便計算子節點,根節點會存儲在下標為 1 的位置),這樣就可以通過下標計算,把整棵樹都串起來。
* 數組順序存儲的方式比較適合 完全二叉樹,其他類型的二叉樹用數組存儲會比較浪費存儲空間。
如果某棵二叉樹是一棵完全二叉樹,那用數組存儲是最節省內存的一種方式。因為數組的存儲方式並不需要像鏈式存儲法那樣,要存儲額外的左右子節點的指針。(這也是為什么完全二叉樹會單獨拎出來的原因,也是為什么完全二叉樹要求最后一層的子節點都靠左的原因。)堆 就是一種完全二叉樹,最常用的存儲方式就是數組。
【二叉樹的遍歷】
二叉樹里非常重要的操作就是前序遍歷、中序遍歷、后序遍歷,用遞歸代碼來實現遍歷的時間復雜度是 O(n)。其中,前、中、后序,表示的是節點與它的左右子樹節點遍歷打印的先后順序。
(1)前序遍歷是指,對於樹中的任意節點來說,先打印這個節點,然后再打印它的左子樹,最后打印它的右子樹。
* preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)
(2)中序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然后再打印它本身,最后打印它的右子樹。
* inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)
(3)后序遍歷是指,對於樹中的任意節點來說,先打印它的左子樹,然后再打印它的右子樹,最后打印這個節點本身。
* postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r
【二叉查找樹(Binary Search Tree)】
二叉查找樹是為了實現快速查找而生的,它不僅僅支持快速查找一個數據,還支持快速插入、刪除一個數據。
二叉查找樹要求,在樹中的任意一個節點,其左子樹中的每個節點的值,都要小於這個節點的值,而右子樹節點的值都大於這個節點的值。
1. 二叉查找樹的查找操作
先取根節點,如果它等於我們要查找的數據,那就返回。如果要查找的數據比根節點的值小,那就在左子樹中遞歸查找;如果要查找的數據比根節點的值大,那就在右子樹中遞歸查找。(感覺有點像 二分查找)
2. 二叉查找樹的插入操作
二叉查找樹的插入過程有點類似查找操作。新插入的數據一般都是在葉子節點上,所以我們只需要從根節點開始,依次比較要插入的數據和節點的大小關系。如果要插入的數據比節點的數據大,並且節點的右子樹為空,就將新數據直接插到右子節點的位置;如果不為空,就再遞歸遍歷右子樹,查找插入位置。同理,如果要插入的數據比節點數值小,並且節點的左子樹為空,就將新數據插入到左子節點的位置;如果不為空,就再遞歸遍歷左子樹,查找插入位置。
3. 二叉查找樹的刪除操作
針對要刪除節點的子節點個數的不同,需要分三種情況來處理:
* 如果要刪除的節點沒有子節點,我們只需要直接將父節點中,指向要刪除節點的指針置為 null。
* 如果要刪除的節點只有一個子節點(只有左子節點或者右子節點),我們只需要更新父節點中,指向要刪除節點的指針,讓它指向要刪除節點的子節點就可以了。
* 如果要刪除的節點有兩個子節點,需要找到這個節點的右子樹中的最小節點,把它替換到要刪除的節點上。然后再刪除掉這個最小節點,因為最小節點肯定沒有左子節點(如果有左子結點,那就不是最小節點了),所以,我們可以應用上面兩條規則來刪除這個最小節點。
4. 二叉查找樹的其他操作
二叉查找樹中還可以支持快速地查找最大節點和最小節點、前驅節點和后繼節點。
二叉查找樹還有一個重要的特性,就是中序遍歷二叉查找樹,可以輸出有序的數據序列,時間復雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。
支持重復數據的二叉查找樹:如果存儲的兩個對象鍵值相同,有兩種解決方法。
* 第一種方法:二叉查找樹中每一個節點不僅會存儲一個數據,因此我們通過鏈表和支持動態擴容的數組等數據結構,把值相同的數據都存儲在同一個節點上。
* 第二種方法:每個節點仍然只存儲一個數據。在查找插入位置的過程中,如果碰到一個節點的值,與要插入數據的值相同,我們就將這個要插入的數據放到這個節點的右子樹,也就是說,把這個新插入的數據當作大於這個節點的值來處理。當要查找數據的時候,遇到值相同的節點,我們並不停止查找操作,而是繼續在右子樹中查找,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查找值的所有節點都找出來。對於刪除操作,我們也需要先查找到每個要刪除的節點,然后再按前面講的刪除操作的方法,依次刪除。
二叉查找樹的時間復雜度分析:
完全二叉樹(或滿二叉樹),不管操作是插入、刪除還是查找,時間復雜度其實都跟樹的高度成正比,也就是 O(height)。二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時間復雜度是O(logn)。
* 有了高效的散列表(時間復雜度是 O(1)),為什么還需要二叉查找樹?
1. 散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間復雜度內,輸出有序的數據序列。
2. 散列表擴容耗時很多,而且當遇到散列沖突時,性能不穩定,盡管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間復雜度穩定在O(logn)。
3. 籠統地來說,盡管散列表的查找等操作的時間復雜度是常量級的,但因為哈希沖突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高。
4. 散列表的構造比二叉查找樹要復雜,需要考慮的東西很多。比如散列函數的設計、沖突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。
5. 為了避免過多的散列沖突,散列表裝載因子不能太大,特別是基於開放尋址法解決沖突的散列表,不然會浪費一定的存儲空間。
綜合這幾點,平衡二叉查找樹在某些方面還是優於散列表的,所以,這兩者的存在並不沖突。