學好資料結構和演算法 —— 線性表
線性表
線性表表示一種線性結構的資料結構,顧名思義就是資料排成像一條線一樣的結構,每個線性表上的資料只有前和後兩個方向。比如:陣列、連結串列、棧和佇列都是線性表,今天我們分別來看看這些線性資料結構。
陣列
陣列是一種線性表資料結構,用一組連續的記憶體空間來儲存一組具有相同型別的資料。
記憶體分佈:
隨機訪問
連續記憶體空間儲存相同型別的資料,這個特性支援了陣列的隨機訪問特性,相同型別的資料佔用的空間是固定的假設為data_size,第n個元素的地址可以根據公式計算出來:
&a[n] = $a[0] + n * data_size
其中:
&a[n]:第n個元素的地址
a[0]:第0個元素的地址(陣列的地址)
data_size:陣列儲存的元素的型別大小
所以訪問數組裡指定下標的任何一個元素,都可以直接訪問對應的地址,不需要遍歷陣列,時間複雜度為O(1)。
插入/刪除低效
為了保證記憶體的連續性,插入或刪除資料時如果不是在陣列末尾操作,就需要做資料的搬移工作,資料搬移會使得陣列插入和刪除時候效率低下。
向數組裡插入一個元素有三種情況:
1、向末尾插入一個元素,此時的時間複雜度為O(1),對應最好時間複雜度。
2、向陣列開頭插入一個元素,需要將所有元素依次向後挪一個位置,然後將元素插入開頭位置,此時時間複雜度為O(n),對應最壞時間複雜度。
3、向陣列中間位置插入一個元素,此時每個位置插入元素的概率都是一樣的為1/n,平均複雜度為(1+2+3+…+n)/n = O(n)
如果陣列元素是沒有順序的(或不需要保證元素順序),向陣列中間位置插入一個元素x,只需要將插入位置的元素放到陣列的末尾,然後將x插入,這時候不需要搬移元素,時間複雜度仍為O(1),如:
同樣地,刪除資料的時候,從開頭刪除,需要將後面n-1個元素往前搬移,對應的時間複雜度為O(n);從末尾刪除時間複雜度為O(1);平均時間複雜度也是O(n)。
如果不需要保證資料的連續性,有兩種方法:
1、可以將末尾的資料搬移到刪除點插入,刪除末尾那個元素
2、刪除的時候做標記,並不正真刪除,等陣列空間不夠的時候,再進行批量刪除搬移操作
Java的ArrayList
很多程式語言都針對陣列進行了封裝,比如Java的ArrayList,可以將陣列的很多操作細節封裝起來(插入刪除的搬移資料或動態擴容),可以參考ArrayList的擴容資料搬移方法,ArrayList預設size是10,如果空間不夠了會先按1.5倍擴容(如果還不夠就可能會用到最大容量)。所以在使用的時候如果事先知道陣列的大小,可以一次性申請,這樣可以免去自動擴容的效能損耗。
什麼時候選擇使用程式語言幫我們封裝的陣列,什麼時候直接使用陣列呢?
1、Java ArrayList不支援基本資料型別,需要封裝為Integer、Long類才能使用。Autoboxing、Unboxing有一定的效能消耗。如果比較關注效能可以直接使用陣列
2、使用前已經能確認資料大小,並且操作比較簡單可以使用陣列
連結串列
比起陣列,連結串列不需要連續記憶體空間,它通過指標將一組分別獨立的記憶體空間串起來形成一條“鏈條”。連結串列有很多種,如:單鏈表、雙向連結串列、迴圈連結串列和雙向迴圈連結串列。
連結串列記憶體分佈:
單鏈表
如下圖所示,連結串列的每一項我們稱為結點(node),為了將所有結點連線起來形成連結串列,結點除了需要記錄資料之外,還需要記錄下一個結點的地址,記錄下一個結點地址的指標我們叫做後繼指標(next),第一個結點和最後一個結點比較特殊,第一個結點沒有next指標指向它,稱為頭結點,最後一個結點next指標沒有指向任何元素,稱為尾結點。頭結點用來記錄連結串列的基地址,有了它就可以順著鏈子往下搜尋每一個元素,如果遇到next指向null表示到達了連結串列的末尾。
在數組裡插入或刪除資料需要保證記憶體空間的連續性,需要做資料的搬移,但是連結串列裡資料記憶體空間是獨立的,插入刪除只需要改變指標指向即可,所以連結串列的插入刪除非常高效。如圖:
雙向連結串列
單向連結串列每個結點只知道自己下一個結點是誰,是一條單向的“鏈條”,而在雙向連結串列裡每個結點既知道下一個結點,還知道前一個結點,相比單鏈表,雙向連結串列每個結點多了一個前驅指標(prev)指向前一個結點的地址,如下圖所示:
因為每個結點要額外的空間來儲存前驅結點的地址,所以相同資料情況下,雙向連結串列比單鏈表佔用的空間更多。雙向連結串列在找前驅結點時間複雜度為O(1),插入刪除都比單鏈表高效,典型的空間換時間的例子。
迴圈連結串列
將一個單鏈表首尾相連就形成了一個環,這就是迴圈連結串列,迴圈連結串列裡尾結點不在是null,而是指向頭結點。當資料具有環形結構時候就可以使用迴圈連結串列。
雙向迴圈連結串列
與迴圈連結串列類似,尾結點指向頭結點,同時每個結點除了儲存自身資料,分別有一個前驅指標和後繼指標,就形成了雙向迴圈連結串列:
插入/刪除比較
在連結串列裡插入資料(new_node)
1、在P結點後插入資料
new_node->next = p->next;
p->next = new_node
此時時間複雜度為O(1)
2、在P結點前插入資料
需要找到P結點的前驅結點,然後轉化為在P的前驅結點之後插入資料。
- 單鏈表需要從頭遍歷,當滿足條件 pre->next = p時,轉化為再pre結點後插入資料,此時時間複雜度為O(n)遍;
- 雙鏈表只需要通過p->pre即可找到前驅結點,時間複雜度為O(1)
3、在值等於某個值的結點前/後插入資料
需要遍歷整個連結串列,找到這個值,然後在它前/後插入資料,此時時間複雜度為O(n)
在連結串列裡刪除資料
1、刪除P結點下一個結點
p->next = p->next->next;
直接將p後繼結點指標指向p下下一個結點。
2、刪除P結點前一個結點
找到P結點前驅結點的前驅結點N,然後轉化為刪除N的後繼結點
- 單鏈表需要遍歷找到結點N,遍歷時間複雜度為O(n),然後刪除N的一個後繼結點,時間複雜度為O(1),所以總的時間複雜度為O(n)
- 雙向連結串列直接找到結點N:p->pre->pre->next = p,時間複雜度為O(1)
3、在值等於某個值的結點前/後刪除資料
需要遍歷整個連結串列,找到這個值,然後在它前/後刪除資料,此時時間複雜度為O(n)
棧
棧是一種後進者先出,先進者後出的線性資料結構,只允許在一端插入/刪除資料。棧可以用陣列來實現,也可以用連結串列來實現,用陣列實現的叫順序棧,用連結串列來實現是叫鏈式棧。
棧的陣列實現
陣列來實現棧,插入和刪除都發生在陣列的末尾,所以不需要進行資料的搬移,但是如果發生記憶體不夠需要進行擴容的時候,仍然需要進行資料搬移
1 @Test 2 public void testStack() { 3 StringStack stack = new StringStack(10); 4 for (int i = 0; i < 10; i++) { 5 stack.push("hello" + i); 6 } 7 System.out.println(stack.push("dd")); 8 9 String item = null; 10 while ((item = stack.pop()) != null) { 11 System.out.println(item); 12 } 13 } 14 15 public class StringStack { 16 private String[] items; 17 private int count; 18 private int size; 19 20 public StringStack(int n) { 21 this.items = new String[n]; 22 this.count = 0; 23 this.size = n; 24 } 25 26 public boolean push(String item) { 27 if (this.count == this.size) { 28 return false; 29 } 30 this.items[count++] = item; 31 return true; 32 } 33 34 public String pop() { 35 if (this.count == 0) { 36 return null; 37 } 38 return this.items[--count]; 39 } 40 41 public int getCount() { 42 return count; 43 } 44 45 public int getSize() { 46 return size; 47 } 48 }View Code
棧的連結串列實現
連結串列的實現有個小技巧,倒著建立一個連結串列來模擬棧結構,最後新增到連結串列的元素作為連結串列的頭結點,如圖:
1 public class LinkStack { 2 private Node top; 3 4 public boolean push(String item) { 5 Node node = new Node(item); 6 if(top == null){ 7 top = node; 8 return true; 9 } 10 node.next = top; 11 top = node; 12 return true; 13 } 14 15 public String pop() { 16 if (top == null) { 17 return null; 18 } 19 String name = top.getName(); 20 top = top.next; 21 return name; 22 } 23 24 private static class Node { 25 private String name; 26 private Node next; 27 28 public Node(String name) { 29 this.name = name; 30 this.next = null; 31 } 32 33 public String getName() { 34 return name; 35 } 36 } 37 }View Code
陣列實現的是一個固定大小的棧,當記憶體不夠的時候,可以按照陣列擴容方式實現棧的擴容,或是依賴於動態擴容的封裝結構來實現棧的動態擴容。出棧的時間複雜度都是O(1),入棧會有不同,如果是陣列實現棧需要擴容,最好時間複雜度(不需要擴容的時候)是O(1),最壞時間複雜度是O(n),插入資料的時候,棧剛好滿了需要進行擴容,假設擴容為原來的兩倍,此時時間複雜度是O(n),每n次時間複雜度為O(1)夾雜著一次時間複雜度為O(n)的擴容,那麼均攤時間複雜度就是O(1)。
棧的應用
- 函式呼叫棧
- java的攔截器
- 表示式求解
佇列
佇列與棧類似,支援的操作也很相似,不過佇列是先進先出的線性資料結構。日常生活中常常需要進行的排隊就是佇列,排在前面的人優先。佇列支援兩個操作:入隊 enqueue 從隊尾新增一個元素;出隊 dequeue 從對列頭部取一個元素。
和棧一樣,佇列也有順序佇列和鏈式佇列分別對應陣列實現的佇列和連結串列實現的佇列。
陣列實現佇列
陣列實現的佇列是固定大小的佇列,當佇列記憶體不足時候,統一搬移資料整理記憶體。
1 public class ArrayQueue { 2 private String[] items; 3 private int capacity; 4 private int head = 0; 5 private int tail = 0; 6 7 public ArrayQueue(int n) { 8 this.items = new String[n]; 9 this.capacity = n; 10 } 11 12 /** 13 * 入佇列(從佇列尾部入) 14 * @param item 15 * @return 16 */ 17 public boolean enqueue(String item) { 18 //佇列滿了 19 if (this.tail == this.capacity) { 20 //對列頭部不在起始位置 21 if (this.head == 0) { 22 return false; 23 } 24 //搬移資料 25 for (int i = head; i < tail; i++) { 26 items[i - head] = items[i]; 27 } 28 this.tail = tail - head; 29 this.head = 0; 30 } 31 items[tail++] = item; 32 return true; 33 } 34 35 /** 36 * 出佇列(從佇列頭部出) 37 * @return 38 */ 39 public String dequeue() { 40 if (this.head == this.tail) { 41 return null; 42 } 43 44 return this.items[head++]; 45 } 46 }View Code
連結串列實現佇列
連結串列尾部入隊,從頭部出隊,如圖:
1 public class LinkQueue { 2 private Node head; 3 private Node tail; 4 5 public LinkQueue() { } 6 7 /** 8 * 入佇列 9 * @param item 10 * @return 11 */ 12 public boolean enqueue(String item) { 13 Node node = new Node(item); 14 //佇列為空 15 if(this.tail == null) { 16 this.tail = node; 17 this.head = node; 18 return true; 19 } 20 this.tail.next = node; 21 this.tail = node; 22 return true; 23 } 24 25 /** 26 * 出佇列 27 * @return 28 */ 29 public String dequeue() { 30 //佇列為空 31 if (this.head == null) { 32 return null; 33 } 34 String name = this.head.getName(); 35 this.head = this.head.next; 36 if (this.head == null) { 37 this.tail = null; 38 } 39 return name; 40 } 41 42 private static class Node { 43 private String name; 44 private Node next; 45 46 public Node(String name) { 47 this.name = name; 48 } 49 50 public String getName() { 51 return name; 52 } 53 } 54 }View Code
迴圈佇列
陣列實現佇列時,當佇列滿了,頭部出隊時候,會發生資料搬移,但是如果是一個首尾相連的環形結構,如下圖,頭部有空間,尾部到達7位置,再新增元素時候,tail到達環形的第一個位置(下標為0)不需要搬移資料。
為空的判定條件:
head = tail
佇列滿了的判定條件:
- 當head = 0,tail = 7
- 當head = 1,tail = 0
- 當head = 4,tail = 3
- head = (tail + 1)% 8
1 public class CircleQueue { 2 private String[] items; 3 private int capacity; 4 private int head = 0; 5 private int tail = 0; 6 7 public CircleQueue(int n) { 8 this.items = new String[n]; 9 this.capacity = n; 10 } 11 12 /** 13 * 入佇列(從佇列尾部入) 14 * @param item 15 * @return 16 */ 17 public boolean enqueue(String item) { 18 //佇列滿了 19 if (this.head == (this.tail + 1)% this.capacity) { 20 return false; 21 } 22 items[tail] = item; 23 tail = (tail + 1) % this.capacity; 24 return true; 25 } 26 27 /** 28 * 出佇列(從佇列頭部出) 29 * @return 30 */ 31 public String dequeue() { 32 //佇列為空 33 if (this.head == this.tail) { 34 return null; 35 } 36 String item = this.items[head]; 37 head = (head + 1) % this.capacity; 38 return item; 39 } 40 }View Code
1 public class CircleQueue { 2 private String[] items; 3 private int capacity; 4 private int head = 0; 5 private int tail = 0; 6 7 public CircleQueue(int n) { 8 this.items = new String[n]; 9 this.capacity = n; 10 } 11 12 /** 13 * 入佇列(從佇列尾部入) 14 * @param item 15 * @return 16 */ 17 public boolean enqueue(String item) { 18 //佇列滿了 19 if (this.head == (this.tail + 1)% this.capacity) { 20 return false; 21 } 22 items[tail++] = item; 23 return true; 24 } 25 26 /** 27 * 出佇列(從佇列頭部出) 28 * @return 29 */ 30 public String dequeue() { 31 //佇列為空 32 if (this.head == this.tail) { 33 return null; 34 } 35 36 return this.items[head++]; 37 } 3
阻塞佇列
阻塞佇列是指當頭部沒有元素的時候(對應佇列為空),出隊會阻塞直到有元素為止;或者佇列滿了,尾部不能再插入資料,直到有空閒位置了再插入。
併發佇列
執行緒安全的佇列叫併發佇列。dequeue和enqueue加鎖或者使用CAS實現高效的併發。