1. 程式人生 > >資料結構與演算法基礎總覽

資料結構與演算法基礎總覽

前言

       對於絕大多少程式設計師來說,資料結構與演算法絕對是一門非常重要但又非常難以掌握的學科。最近自己系統學習了一套資料結構與演算法的課程,也開始到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:1)內部通過陣列 + 單鏈表實現;2)主要方法都加了Synchronized鎖,執行緒安全,但也是因為加了鎖,所以效率比其它兩個差;3)Key和Value均不允許為null;

HashTable內部結構圖      

         HashMap:1)Jdk1.7及之前,內部通過陣列 + 單鏈表實現;Jdk1.8開始,內部通過 陣列 + 單鏈表 + 紅黑樹實現 ;2)非執行緒安全,如果要保證執行緒安全,一般通過 Map m = Collections.synchronizedMap(new HashMap(...));的方式來實現,由於沒有加鎖,所以HashMap效率比較高;3)允許一個Key為null,Value也可以為null。

       ConcurrentHashMap:分段加鎖,相比HashMap它是執行緒安全的,相比HashTable它效率高,可以看成是對HashMap和HashTable的綜合。

HashMap內部結構圖

 

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

 

最後

       最後附上一張常見資料結構的時間和空間複雜度表