10個數據結構:數組、鏈表、棧、隊列、散列表、二叉樹、堆、跳錶、圖、Trie 樹;
10個算法:遞歸、排序、二分查找、搜索、哈希算法、貪心算法、分治算法、回溯算法、動態 規劃、字符串匹配算法。算法
【複雜度分析】
1、什麼是複雜度分析?
1.數據結構和算法解決是「如何讓計算機更快時間、更省空間的解決問題」。
2.所以需從執行時間和佔用空間兩個維度來評估數據結構和算法的性能。
3.分別用時間複雜度和空間複雜度兩個概念來描述性能問題,兩者統稱爲複雜度。
4.複雜度描述的是算法執行時間(或佔用空間)與數據規模的增加關係。數據庫
2、爲何要進行復雜度分析?
1.和性能測試相比,複雜度分析有不依賴執行環境、成本低、效率高、易操做、指導性強的特色。
2.掌握複雜度分析,將能編寫出性能更優的代碼,有利於下降系統開發和維護成本。編程
3、如何進行復雜度分析?
1.大O表示法
(1)來源
算法的執行時間與每行代碼的執行次數成正比,用T(n) = O(f(n))表示,其中T(n)表示算法執
行總時間,f(n)表示每行代碼執行總次數,而n每每表示數據的規模。
(2)特色
以時間複雜度爲例,因爲時間複雜度描述的是算法執行時間與數據規模的增加變化趨勢,所
以常量階、低階以及係數實際上對這種增加趨勢不產決定性影響,因此在作時間複雜度分析
時忽略這些項。
2.複雜度分析法則
1)單段代碼看高頻:好比循環。
2)多段代碼取最大:好比一段代碼中有單循環和多重循環,那麼取多重循環的複雜度。
3)嵌套代碼求乘積:好比遞歸、多重循環等
4)多個規模求加法:好比方法有兩個參數控制兩個循環的次數,那麼這時就取兩者複雜度相加。數組
4、經常使用的複雜度級別?
多項式階:隨着數據規模的增加,算法的執行時間和空間佔用,按照多項式的比例增加。包括:
O(1)(常數階)、O(logn)(對數階)、O(n)(線性階)、O(nlogn)(線性對數階)、O(n^2)(平方階)、O(n^3)(立方階)
非多項式階:隨着數據規模的增加,算法的執行時間和空間佔用暴增,這類算法性能極差。包括:
O(2^n)(指數階)、O(n!)(階乘階)瀏覽器
5、複雜度分析的4個概念
1.最壞狀況時間複雜度:代碼在最理想狀況下執行的時間複雜度。
2.最好狀況時間複雜度:代碼在最壞狀況下執行的時間複雜度。
3.平均時間複雜度:用代碼在全部狀況下執行的次數的加權平均值表示。
4.均攤時間複雜度:在代碼執行的全部複雜度狀況中絕大部分是低級別的複雜度,個別狀況是高級別複雜度且發生具備時序關係時,能夠將個別高級別複雜度均攤到低級別複雜度上。基
本上均攤結果就等於低級別複雜度。緩存
6、爲何要引入這4個概念?
1.同一段代碼在不一樣狀況下時間複雜度會出現量級差別,爲了更全面,更準確的描述代碼的時間複雜度,因此引入這4個概念。
2.代碼複雜度在不一樣狀況下出現量級差異時才須要區別這四種複雜度。大多數狀況下,是不須要區別分析它們的。安全
7、如何分析平均、均攤時間複雜度?
1.平均時間複雜度
代碼在不一樣狀況下複雜度出現量級差異,則用代碼全部可能狀況下執行次數的加權平均值表示。
2.均攤時間複雜度
兩個條件知足時使用:1)代碼在絕大多數狀況下是低級別複雜度,只有極少數狀況是高級別
複雜度;2)低級別和高級別複雜度出現具備時序規律。均攤結果通常都等於低級別複雜度。性能優化
數組(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 個結點
* 求鏈表的中間結點
【棧】
後進先出,先進後出,這就是典型的「棧」結構。
任何數據結構都是對特定應用場景的抽象,數組和鏈表雖然使用起來更加靈活,但卻暴露了幾乎全部的操做,不免會引起錯誤操做的風險。
當某個數據集合只涉及在一端插入和刪除數據,而且知足後進先出、先進後出的特性,咱們就應該首選「棧」這種數據結構。
棧主要包含兩個操做,入棧和出棧。
實際上,棧既能夠用數組來實現,也能夠用鏈表來實現。用數組實現的棧,咱們叫做順序棧,用鏈表實現的棧,咱們叫做鏈式棧。
對於出棧操做來講,咱們不會涉及內存的從新申請和數據的搬移,因此出棧的時間複雜度仍然是O(1)。可是,對於入棧操做來講,狀況就不同了。當棧中有空閒空間時,入棧操做的時間複雜度爲 O(1)。但當空間不夠時,就須要從新申請內存和數據搬移,因此時間複雜度就變成了O(n)。
【隊列】
先進者先出,這就是典型的「隊列」。
最基本的兩個操做:入隊enqueue(),放一個數據到隊列尾部;出隊dequeue(),從隊列頭部取一個元素。隊列能夠用數組或者鏈表實現,用數組實現的隊列叫做順序隊列,用鏈表實現的隊列叫做鏈式隊列。
隊列須要兩個指針:一個是 head 指針,指向隊頭;一個是 tail 指針,指向隊尾。
在數組實現隊列的時候,會有數據搬移操做,要想解決數據搬移的問題,咱們就須要像環同樣的循環隊列。
阻塞隊列就是在隊列爲空的時候,從隊頭取數據會被阻塞,由於此時尚未數據可取,直到隊列中有了數據才能返回;若是隊列已經滿了,那麼插入數據的操做就會被阻塞,直到隊列中有空閒位置後再插入數據,而後在返回。
在多線程的狀況下,會有多個線程同時操做隊列,這時就會存在線程安全問題。可以有效解決線程安全問題的隊列就稱爲併發隊列。
基於鏈表的實現方式,能夠實現一個支持無限排隊的無界隊列(unbounded queue),可是可能會致使過多的請求排隊等待,請求處理的響應時間過長。因此,針對響應時間比較敏感的系統,基於鏈表實現的無限排隊的線程池是不合適的。
而基於數組實現的有界隊列(bounded queue),隊列的大小有限,因此線程池中排隊的請求超過隊列大小時,接下來的請求就會被拒絕,這種方式對響應時間敏感的系統來講,就相對更加合理。不過,設置一個合理的隊列大小,也是很是有講究的。隊列太大致使等待的請求太多,隊列過小會致使沒法充分利用系統資源、發揮最大性能。
實際上,對於大部分資源有限的場景,當沒有空閒資源時,基本上均可以經過「隊列」這種數據結構來實現請求排隊。
【遞歸】
遞歸須要知足的三個條件:
1. 一個問題的解能夠分解爲幾個子問題的解
2. 這個問題與分解以後的子問題,除了數據規模不一樣,求解思路徹底同樣
3. 存在遞歸終止條件
寫遞歸代碼的關鍵就是找到如何將大問題分解爲小問題的規律,而且基於此寫出遞推公式,而後再推敲終止條件,最後將遞推公式和終止條件翻譯成代碼。遞歸代碼雖然簡潔高效,可是,遞歸代碼也有不少弊端。好比,堆棧溢出、重複計算、函數調用耗時多、空間複雜度高等,因此,在編寫遞歸代碼的時候,必定要控制好這些反作用。
遞歸的優缺點?
1.優勢:代碼的表達力很強,寫起來簡潔。
2.缺點:空間複雜度高、有堆棧溢出風險、存在重複計算、過多的函數調用會耗時較多等問題。
遞歸常見問題及解決方案
1.警戒堆棧溢出:能夠聲明一個全局變量來控制遞歸的深度,從而避免堆棧溢出。
2.警戒重複計算:經過某種數據結構來保存已經求解過的值,從而避免重複計算。
幾種最經典、最經常使用的排序方法:冒泡排序、插入排序、選擇排序、歸併排序、快速排序、計數排序、基數排序、桶排序。
對於排序算法執行效率的分析,咱們通常會從這幾個方面來衡量:
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.用哨兵簡化代碼,每次排序都減小一次判斷,儘量把性能優化到極致
【二分查找】
二分查找針對的是一個有序的數據集合,查找思想有點相似分治思想。每次都經過跟區間的中間元素對比,將待查找的區間縮小爲以前的一半,直到找到要查找的元素,或者區間被縮小爲 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)。
哈希算法的定義:將任意長度的二進制值串映射爲固定長度的二進制值串,這個映射的規則就是哈希算法,而經過原始數據映射以後獲得的二進制值串就是哈希值。常見的例如:MD五、SHA。
設計一個優秀的哈希算法須要知足的幾點要求:
* 從哈希值不能反向推導出原始數據(因此哈希算法也叫單向哈希算法);
* 對輸入數據很是敏感,哪怕原始數據只修改了一個 Bit,最後獲得的哈希值也大不相同;
* 散列衝突的機率要很小,對於不一樣的原始數據,哈希值相同的機率很是小;
* 哈希算法的執行效率要儘可能高效,針對較長的文本,也能快速地計算出哈希值。
哈希算法的七個常見應用:
* 安全加密:MD五、SHA、DES、AES。很難根據哈希值反向推導出原始數據;散列衝突的機率要很小(由於沒法作到零衝突)。
* 惟一標識:哈希算法能夠對大數據作信息摘要,經過一個較短的二進制編碼來表示很大的數據。
(1)海量的圖庫中,搜索一張圖是否存在
* 數據校驗:校驗數據的完整性和正確性。
* 散列函數:對哈希算法的要求很是特別,更加看重的是散列的平均性和哈希算法的執行效率。
* 負載均衡:利用哈希算法替代映射表,能夠實現一個會話粘滯的負載均衡策略。
(1)在同一個客戶端上,在一次會話中的全部請求都路由到同一個服務器上。
* 數據分片:經過哈希算法對處理的海量數據進行分片,多機分佈式處理,能夠突破單機資源的限制。
(1)如何統計「搜索關鍵詞」出現的次數?
(2)如何快速判斷圖片是否在圖庫中?
* 分佈式存儲:利用一致性哈希算法,能夠解決緩存等分佈式系統的擴容、縮容致使數據大量搬移的難題。
(1)如何決定將哪一個數據放到哪一個機器上?
(2)一致性哈希算法
以前說的棧和隊列都是線性表結構,樹是非線性表結構。
關於樹的經常使用概念:根節點、葉子節點、父節點、子節點、兄弟節點,還有節點的高度、深度、層數,以及樹的高度。
最經常使用的樹就是二叉樹(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. 爲了不過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,否則會浪費必定的存儲空間。綜合這幾點,平衡二叉查找樹在某些方面仍是優於散列表的,因此,這二者的存在並不衝突。