前言
對於絕大多少程序員來說,數據結構與算法絕對是一門非常重要但又非常難以掌握的學科。最近自己系統學習了一套數據結構與算法的課程,也開始到Leetcode上刷題了。這里對課程中講到的一些數據結構與算法基礎做了一些回顧和總結,從宏觀上先來了解整個知識框架。
數據結構與算法總覽圖
1、數組(Array)
數組的底層硬件實現是,有一個叫內存控制器的結構,為數組分配一個段連續的內存空間,這些空間中存儲着數組中對應的值(值為基本數據類型)或者地址(值為引用類型)。當根據index訪問數組中的某個元素時,內存控制器直接定位到該index所在的地址,無論是第一個元素、中間元素還是最后一個元素,都能一次性定位到,時間復雜度為O(1)。
Java中ArrayList是對數組的一個典型使用,其內部維護着一個數組,ArrayList的增、刪、查等,都是對其中數組進行操作。所以根據index進行查找時比較快,時間復雜度為O(1);但增加和刪除元素時需要擴容或者移動數組元素的位置等操作,其中擴容時還會開辟更大容量的數組,將原數組的值復制到新數組中,並將新數組復制給原數組,所以此時時間復雜度和空間復雜度為O(n)。對於頻繁查找數據時,使用ArrayList效率比較高。
ArrayList源碼:http://developer.classpath.org/doc/java/util/ArrayList-source.html
2、鏈表(Linked List)
可以通過使用雙向鏈表或者設置頭尾指針的方式,讓操作鏈表更加方便。
Java中LinkedList是對鏈表的一個典型使用,其內部維護着一個雙向鏈表,對數據的增,刪、查、改操作實際上都是對鏈表的操作。增、刪、改非首位節點本身操作時間復雜度為O(1),但是要查找到對應操作的位置,實際上也要經過遍歷查找,而鏈表的時間復雜度為O(n)。
LinkedList源碼:http://developer.classpath.org/doc/java/util/LinkedList-source.html
參考閱讀:https://www.cnblogs.com/LiaHon/p/11107245.html
3、跳表(Skip List)
跳表是在一個有序鏈表的基礎上升維,添加多級索引,以空間換時間,其空間復雜度為O(n),用於存儲索引節點。其有序性對標的是平衡二叉樹,二叉搜索樹等數據結構。
數組、鏈表、跳表對增、刪、查時間復雜度比較:
數組 | 鏈表 | 跳表 | |
preppend | O(n) | O(1) | O(logn) |
append | O(1) | O(1) | O(logn) |
lookup | O(1) | O(n) | O(logn) |
insert | O(n) | O(1) | O(logn) |
delete | O(n) | O(1) | O(logn) |
4、棧(Stack)
Java中雖然提供了Stack類(內部維護的實際上也是一個數組)用於實現棧,但官方文檔 https://www.apiref.com/java11-zh/java.base/java/util/Stack.html中明確說明,應該優先使用Deque來實現:
Deque<Integer> stack = new ArrayDeque<Integer>();
Deque接口及其實現,提供了一套更完整和一致的LIFO(Last in first out ,后進先出)堆棧操作,這里列舉幾個用於棧的方法:
public E peek():檢索但不移除此雙端隊列表示的隊列的頭部(換句話說,此雙端隊列的第一個元素),如果此雙端隊列為空,則返回 null
。
public E pop:從此雙端隊列表示的堆棧中彈出一個元素。
public void push(E e):在不違反容量限制的情況下執行此操作, 可以添加元素到此雙端隊列表示的堆棧(換句話說,在此雙端隊列的頭部),如果當前沒有可用空間則拋出 IllegalStateException
。
ArrayDeque實現類中,實際上也是維護的一個數組,下面會對該類做進一步介紹。
5、隊列(Queue)
Java中提供了實現接口Queue,源碼為:http://fuseyism.com/classpath/doc/java/util/Queue-source.html ;參考文檔:https://www.apiref.com/java11-zh/java.base/java/util/Queue.html。Java還提供了很多實現類,比如ArrayDeque、LinkedList、PriorityQueue等,可以使用如下方式來使用Queue接口:
Queue<String> queue = new LinkedList<String>();
Queue接口針對入隊、出隊、查看隊尾操作提供了兩套API:
第一套為,boolean add(E e) 、E element()、E remove(),在超過容量限制或者得到元素為null時,會報異常。
第二套為,boolean offer(E e)、E peek()、E poll(),不會報異常,而是返回true/false/null,一般工程中使用這一套api。
實現類LinkedList中實際維護的是一個雙向鏈表,前面已經介紹過了。
6、雙端隊列(Deque)
Deque是Double end queue的縮寫,參考文檔:https://www.apiref.com/java11-zh/java.base/java/util/Deque.html。
Deque既提供了用於實現Stack LIFO的push、pop、peek,又提供了用於實現Queue FIFO(First In First Out:先進先出)的offer、poll、peek(add、remove、element等也有,這里僅列出推薦使用的),所以可以用於實現Stack,也可以用於實現Queue。同時,Deque還提供了全新的接口用於對應Stach和Queue的方法,如offerFirst/offerLast、peekFirst/peekLast,pollFirst/pollLast等,另外還提供了一個addAll(Collection c),批量插入數據。
前面講Stack的時候已經介紹過了,Deque是一個接口,一般在工程中的使用方式為:
Deque<Integer> deque = new ArrayDeque<Integer>();
ArrayDeque內部維護的是一個數組。
7、優先隊列(PriorityQueue)
Java中提供了PriorityQueue類來實現優先隊列,是接口Queue的實現類。和Queue的FIFO不同的是,PriorityQueue中的元素是按照一定的優先級排序的。默認情況下,其內部是通過一個小頂堆來實現排序的,也可以自定義排序的優先級。堆是一個完全二叉樹,可以用數組來表示,所以PriorityQueue內部實際上是維護的一個數組。
PriorityQueue提供了對隊列的基本操作:offer用於向堆中插入元素,插入后會堆會進行調整;peek用於查看但不刪除數組的第一個元素,也就是堆頂的元素,是優先級最高(最大或者最小)的元素;poll用於獲取並移除堆頂元素,堆會再進行調整。當然,對應的還有add/element/remove方法,這在前面Queue部分講過了。
官方文檔:https://docs.oracle.com/javase/10/docs/api/java/util/PriorityQueue.html
參考閱讀:https://blog.csdn.net/u010623927/article/details/87179364
8、哈希表(Hash Table)
哈希表也叫散列表,通過鍵值對key-value的方式直接進行存儲和訪問的數據結構。它通過一個映射函數將Key映射到表中的對應位置,從而可以一次性找到對應key-value結點的位置。
Java中提供了HashTable、HashMap、ConcurrentHashMap等類來實現哈希表,這三者也經常被拿來做比較,這里簡單介紹一下這三個類:
HashTable:
Jdk早期使用該容器較多,現在使用得不多了,和HashMap和ConcurrentHashMap相比,有很多地方明顯性能更差卻沒有在新版本中升級優化。
1)內部通過數組 + 單鏈表實現;
2)主要方法都加了Synchronized鎖,線程安全,但也是因為加了鎖,所以效率比其它兩個差;
3)Key和Value均不允許為null;
4)沒有指定初始容量時,調用空構造函數時,默認初始容量為11;
5)put新元素時,采用頭插法;
6)一般情況下,數組擴容時擴容為原來的1.5倍,且擴容時采用頭插法重新分配元素,所以擴容后新鏈表的順序倒置了。
7)求Key的hash值時,直接調用的Object的hashCode方法來得到hash值;
HashTable內部結構圖
HashMap(基於jdk1.8):
1)Jdk1.7及之前,內部通過數組 + 單鏈表實現;Jdk1.8開始,內部通過 數組 + 單鏈表 + 紅黑樹實現 ;
2)非線程安全,如果要保證線程安全,可以通過Map m = Collections.synchronizedMap(new HashMap(...))或者使用ConcurrentHashMap來實現,由於沒有加鎖,所以HashMap效率比較高;
3)允許一個Key為null,Value也可以為null。
4)沒有指定初始容量,調用空構造函數時,不會分配初始容量,等到第一次put元素時 ,默認分配初始容量為16;
5)put新元素時,采用尾插法;
6)一般情況下,數組擴容時擴容為原來的2倍,且擴容時也采用尾插法重新分配元素,所以擴容后新鏈表的順序不變。
7)求Key的hash值時,采用了特殊的分散處理,減少了hash碰撞。
HashMap內部結構圖
ConcurrentHashMap(jdk1.8):
1)Jdk1.7及之前,使用分段加鎖的方式保證線程安全,內部結構通過Segment + 數組 + 鏈表來實現。Jdk1.8之后,內部通數組 + 鏈表 + 紅黑樹來實現。
2)線程安全。Jdk1.7及之前,使用分段加鎖的方式保證線程安全;Jdk1.8之后,使用Unsafe類,cas,synchronized等方式結合,保證線程安全。加鎖的粒度變小了,而且采用了多種保證並行的收到,其效率比HashMap小,比HashTable大。
3)添加元素時,Key和Value均不能為空,否則會報NullPointException。
4)沒有指定初始容量,調用空構造函數時,不會分配初始容量,等到第一次put元素時 ,默認分配初始容量為16;
5)put新元素時,采用尾插法;
6)一般情況下,數組擴容時擴容為原來的2倍,且擴容時也采用尾插法重新分配元素,所以擴容后新鏈表的順序不變。
7)求Key的hash值時,采用了特殊的分散處理,減少了hash碰撞。
擴展:Java中字符串hashCode()重復問題:https://www.jb51.net/article/119885.htm
9、映射(Map)
映射中是以鍵值對Key-Value的形式存儲元素的,其中Key不允許重復,但Value可以重復。Java中提供了Map接口來定義映射,還提供了如HashMap、ConcurrentHashMap等實現類,這兩個類前面有簡單介紹過。
10、集合(Set)
集合中不允許有重復的元素,添加元素時如果有重復,會覆蓋掉原來的元素。Java中提供了Set接口來定義集合,也提供了HashSet實現類。HashSet類的內部實際上維護了一個HashMap,將添加的對象作為HashMap的key,Object對象作為value,以此來實現集合中的元素不重復。
1 //HashSet部分源碼 2 public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { 3 ...... 4 private transient HashMap<E,Object> map; 5 private static final Object PRESENT = new Object(); 6 public HashSet() { 7 map = new HashMap<>(); 8 } 9 ...... 10 public boolean add(E e) { 11 return map.put(e, PRESENT)==null; 12 } 13 ...... 14 public boolean remove(Object o) { 15 return map.remove(o)==PRESENT; 16 } 17 ...... 18 }
11、樹(Tree)
在單鏈表的基礎上,如果一個節點的next有一個或者多個,就構成了樹結構,所以單鏈表是一棵特殊的樹,其child只有一個。關於樹有很多特定的結構,用於平時的工程中,出現得比較多得就是二叉樹,而二叉樹根據用途和性質又有多種類型,常見的有:
完全二叉樹:若設二叉樹的深度為k,除第 k 層外,其它各層 (1~k-1) 的結點數都達到最大個數,第k 層所有的結點都連續集中在最左邊,這就是完全二叉樹。完全二叉樹可以按層存儲在數組中,如果某個結點的索引為i,那么該結點如果有左右結點,那么左右結點的索引分別為2i+1,2i+2;
滿二叉樹:一個二叉樹,如果每一個層的結點數都達到最大值,則這個二叉樹就是滿二叉樹。也就是說,如果一個二叉樹的層數為K,且結點總數是(2^k) -1 ,則它就是滿二叉樹。所以,滿二叉樹也是完全二叉樹。
二叉搜索樹(Binary Search Tree):又稱為二叉排序樹、有序二叉樹、排序二叉樹等。其特征為:任意一個結點的左子樹的值都小於/等於該結點的值,右子樹的值都大於/等於根結點的值;中序遍歷的結果是一個升序數列;任意一個結點的左右子樹也是二叉搜索樹。如下圖所示:
在極端的情況下,二叉搜索樹會呈一個單鏈表。
平衡二叉樹(AVL):它或者是一顆空樹,或它的左子樹和右子樹的深度之差(平衡因子)的絕對值不超過1,且它的左子樹和右子樹都是一顆平衡二叉樹。平衡二叉樹也是一棵二叉搜索樹,由於具有平衡性,所以整棵樹比較平衡,不會出現一長串單鏈表的結構,在查找時最壞的情況也是O(logn)。為了保持平衡性,每次插入的時候都需要調整以達到平衡。
如下圖所示,任意一個結點的左右子樹的深度差絕對值都不超過1,且符合二叉搜索樹的特點:
12、紅黑樹
紅黑樹是一顆平衡二叉搜索樹,具有平衡性和有序性,結點的顏色為紅色或者黑色。這里的“平衡”和平衡二叉樹的“平衡”粒度上不同,平衡二叉數更為嚴格,導致在插入或者刪除數據時調整樹結構的頻率太高了,這會導致一定的性能問題。而紅黑樹的平衡是任意一個結點的左右子樹,較高的子樹與較低子樹之間的高度差不超過兩倍,這樣就能從一定層度上避免過於頻繁調整結構。可以認為紅黑樹是對平衡二叉樹的一種變體。
13、圖(Graph)
單鏈表是特殊的樹,樹是特殊的圖。
14、堆(Heap)
堆是一種可以迅速找到最大值或者最小值的數據結構,內部維護着一棵樹(注意這里說的是樹,而不是限制於二叉樹,也可以是多叉)。如果該堆的根結點是最大值,則稱之為大頂堆(或大根堆);如果根結點是最小值,則稱為小頂堆(或小根堆)。堆的實現有很多,這里主要介紹一下二叉堆。
二叉堆,顧名思義,就是堆中的樹是一棵二叉樹,且是完全二叉樹(這里要注意區別於二叉搜索樹),所以可以用數組表示,前面介紹的PriorityQueue就是一個堆的實現。如果是大頂堆,任何一個結點的值都 >= 其子結點的值大;如果是小頂堆,則任何一個結點的值都 <= 其子節點的值。下圖展示了一個二叉大頂堆,其對應的一維數組為[110, 100, 90, 40, 80, 20, 60, 10, 30, 50, 70]:
對於大頂堆而言,一般常使用的操作是查找最大值、刪除最大值和插入一個值,其時間復雜度分別為:查找最大值的時間復雜度是O(1),因為最大值就是根結點的值,位於數組的第一個位置;刪除最大值,找到最大值的時間復雜度是O(1),但是刪除后該堆需要重新調整,將最底層最末尾的結點移到根結點,然后根節點再與子結點點比較,並與較大的結點交換,直到該結點不小於子結點為止,由於是從最末尾的結點直接升到根結點,所以該結點的值肯定是相對很小的,需要調整多次才能再次符合堆的定義,所以時間復雜度為O(logn);插入一個結點,其做法是在數組的最后插入,也就是二叉樹的最后一個層的末尾位置插入,然后再和其父結點比較,如果新結點大就和父結點交換位置,直到不大於根結點為止,所以插入新的結點可能一次到位,時間復雜度為O(1),也有可能還需要調整,最壞的時候比較和交換O(logn),即時間復雜度為O(logn)。同理,小頂堆也是如此。
堆的實現代碼參考:https://shimo.im/docs/Lw86vJzOGOMpWZz2/read
15、並查集(Disjoint Set)
並查集一般用於解決元素,組團或者配對的問題,即是否在一個集合的問題。它管理着一系列不相交的集合,主要提供如下三種基本操作:
(1)makeSet(s),創建並查集:創建一個新的並查集,其中包含s個單元素集合;
(2)unionSet(x,y),合並集合:將x元素和y元素所在的集合不相交,就將這兩個集合合並;如果這兩個結合相交,則不合並;
(3)find(x),查找代表:查找x元素所在集合的代表元素,該操作可以用於判斷兩個元素是否在同一個集合中,如果兩個元素的代表相同,表示在同一個集合;否則,不在同一個集合。
如果想避免並查集太高,還可以進行路徑壓縮。
實現並查集的基本代碼模板:
1 public class UnionFind { 2 private int count = 0; 3 private int[] parent; 4 5 //初始化並查集,用數組存儲每個元素的父節點,一開始將他們的父節點設為自己 6 public UnionFind(int n) { 7 count = n; 8 parent = new int[n]; 9 for (int i = 0; i < n; i++) { 10 parent[i] = i; 11 } 12 } 13 14 //找到元素x所在集合的代表元素 15 public int find(int x) { 16 while (x != parent[x]) { 17 x = parent[x]; 18 } 19 return x; 20 } 21 22 //合並x和y所在的集合 23 public void union(int x, int y) { 24 int rootX = find(x); 25 int rootY = find(y); 26 if (rootX == rootY) 27 return; 28 parent[rootX] = rootY; 29 count--; 30 } 31 }
這里推薦一篇寫不錯的文章:https://www.cnblogs.com/noKing/p/8018609.html
16、字典樹(Trie)
字典樹,即Trie樹,又稱為前綴樹、單詞查找樹或者鍵樹,是一種樹形結構。Trie的優點是最大限度地減少無畏的字符串比較,查詢效率比hash表高。其典型應用是統計字符串(不限於字符串)出現的頻次,查找具有相同前綴的字符串等,所以經常被搜索引擎用於統計單詞頻次,或者關鍵字提示,如下圖所示:
Trie樹具有如下特性:
(1)結點本身不存儲完整單詞;
(2)從根結點到某一結點,路徑上經過的字符串聯起來,對應的就是該結點表示的字符串;
(3)每個結點所有的子結點路徑代表的字符都不相同。
實際工程中,結點可以存儲一些額外的信息,如下圖就表示一棵Trie樹,每個結點存儲了其對應表示的字符串,以及該字符串被統計的頻次。
對於一個僅由26個小寫英文字母組成的字符串形成的Trie樹,其結點的內部結構為:
Trie樹的核心思想是以空間換時間,因為需要額外創建一棵Trie樹,它利用字符串的公共前綴來降低查詢的時間的開銷從而提升效率。
17、布隆過濾器(Bloom Filter)
布隆過濾器典型應用有,垃圾郵件/評論過濾、某個網址是否被訪問過等場景,它是由一個很長的二進制向量和一系列的hash函數實現的,其結構如下圖所示:
一個元素A經過多個hash函數(本例中是兩個)計算后得到多個hash code,在向量表中code對應的位置的值就設置為1。
其具有如下特點:
(1)存儲的信息是比較少的,不會存儲整個結點的信息,相比於HashMap/HashTable而言,節約了大量的空間;
(2)如果判斷某個元素不存在,則一定不存在;
(3)具有一定的誤判率,而且插入的元素越多,誤判率越過,如果判斷某個元素存在,那只能說可能存在,需要再做進一步的判斷,所以稱為過濾器;
所以,其優點是空間效率和查詢時間都遠遠優於一般的算法;缺點是具有一定的誤判率,且刪除元素比較困難(向量表中每一個位置可能對應着眾多元素)。
參考閱讀:https://baike.baidu.com/item/bloom%20filter/6630926?fr=aladdin
18、LRU Cache
LRU,即Least Recently Used,最近最少使用,應用非常廣泛,在Android的網絡圖片加載工具ImageLoader等中都具有使用。其思想為,由於空間資源有限,當緩存超過指定的Capacity時,那些最近最少使用的緩存就會被刪除掉,其工作機制如下圖所示:
不同的語言中都提供了相應的類來實現LRU Cache,Java中提供的類為LinkedHashMap,內部實現思想為HashMap + 雙向鏈表。我們也可以通過HashMap + 雙向鏈表自己實現一個LRU Cache。
1 //空間復雜度O(k),k表示容量 2 //小貼士:在雙向鏈表的實現中,使用一個偽頭部(dummy head)和偽尾部(dummy tail)標記界限,這樣在添加節點和刪除節點的時候就不需要檢查相鄰的節點是否存在。 3 class LRUCache { 4 HashMap<Integer, LNode> cache = new HashMap<>();//使用hashmap可以根據key一次定位到value 5 int capacity = 0;//容量 6 int size = 0; 7 //采用雙鏈表 8 LNode head; 9 LNode tail; 10 11 public LRUCache(int capacity) { 12 this.capacity = capacity; 13 //初始化雙鏈表 14 head = new LNode(); 15 tail = new LNode(); 16 head.next = tail; 17 tail.prev = head; 18 } 19 20 //時間復雜度:O(1) 21 public int get(int key) { 22 //先從緩存里面查,不存在返回-1;存在則將該節點移動到頭部,表示最近使用過,且返回該節點的value 23 LNode lNode = cache.get(key); 24 if (lNode == null) return -1; 25 moveToHead(lNode); 26 return lNode.value; 27 } 28 29 //時間復雜度O(1) 30 public void put(int key, int value) { 31 LNode lNode = cache.get(key); 32 //如果hashmap中不存在該key 33 if (lNode == null) { 34 size++; 35 //如果已經超過容量了,需要先刪除尾部節點,且從hashmap中刪除掉該元素 36 if (size > capacity) { 37 cache.remove(tail.prev.key); 38 removeNode(tail.prev); 39 size--; 40 } 41 //將新的節點存入hashmap,並添加到鏈表的頭部 42 lNode = new LNode(key, value); 43 cache.put(key, lNode); 44 addToHead(lNode); 45 } else { 46 //如果hashmap中存在該key,則修改該節點的value,且將該節點移動到頭部 47 lNode.value = value; 48 removeNode(lNode); 49 addToHead(lNode); 50 } 51 } 52 53 /** 54 * 將節點移動到頭部 55 */ 56 public void moveToHead(LNode lNode) { 57 removeNode(lNode); 58 addToHead(lNode); 59 } 60 61 /** 62 * 移除節點 63 */ 64 public void removeNode(LNode lNode) { 65 lNode.prev.next = lNode.next; 66 lNode.next.prev = lNode.prev; 67 lNode.next = null; 68 lNode.prev = null; 69 } 70 71 /** 72 * 在頭部添加節點 73 */ 74 private void addToHead(LNode lNode) { 75 head.next.prev = lNode; 76 lNode.next = head.next; 77 head.next = lNode; 78 lNode.prev = head; 79 } 80 } 81 82 class LNode { 83 int key; 84 int value; 85 LNode prev; 86 LNode next; 87 88 public LNode() { 89 } 90 91 public LNode(int key, int value) { 92 this.key = key; 93 this.value = value; 94 } 95 }
推薦閱讀:https://www.jianshu.com/p/b1ab4a170c3c
最后
最后附上一張常見數據結構的時間和空間復雜度表