最基礎的動態資料結構:連結串列
什麼是連結串列
連結串列是一種線性結構,也是最基礎的動態資料結構。我們在實現動態陣列、棧以及佇列時,底層都是依託的靜態陣列,靠resize來解決固定容量的問題,而連結串列是真正的動態資料結構。學習連結串列這種資料結構,能夠更深入的理解引用(或者指標)以及遞迴。其中連結串列分為單鏈連結串列和雙鏈連結串列,本文中所介紹的是單鏈連結串列。
連結串列中的資料是儲存在一個個的節點中,如下這是一個最基本的節點結構:
class Node {
E e;
Node next; // 節點中持有下一個節點的引用
}
我們可以將連結串列想象成火車,每一節車廂就是一個節點,乘客乘坐在火車的車廂中,就相當於元素儲存在連結串列的節點中。火車的每一節車廂都連線著下一節車廂,就像連結串列中的節點都會持有下一個節點的引用。火車的最後一節車廂沒有連線任何車廂,就像連結串列中末尾的節點指向null一樣:
連結串列優缺點:
- 優點:真正的動態結構,不需要處理固定容量的問題,從中間插入、刪除節點很方便,相較於陣列要靈活
- 缺點:喪失了隨機訪問的能力,不能像陣列那種直接通過索引訪問
廢話不多說,我們開始來編寫連結串列這個資料結構吧,首先來實現連結串列中的節點結構以及連結串列的一些簡單方法,程式碼如下:
/** * @program: Data-Structure * @description: 連結串列資料結構實現 * @author: 01 * @create: 2018-11-08 15:37 **/ public class LinkedList<E> { /** * 連結串列中的節點結構 */ private class Node { E e; Node next; public Node() { this(null, null); } public Node(E e) { this(e, null); } public Node(E e, Node next) { this.e = e; this.next = next; } @Override public String toString() { return e.toString(); } } /** * 頭節點 */ private Node head; /** * 連結串列中元素的個數 */ private int size; public LinkedList() { this.head = null; this.size = 0; } /** * 獲取連結串列中的元素個數 * * @return 元素個數 */ public int getSize() { return size; } /** * 連結串列是否為空 * * @return 為空返回true,否則返回false */ public boolean isEmpty() { return size == 0; } }
在連結串列中新增元素
我們在為陣列新增元素時,最方便的新增方式就是從陣列後面進行新增,因為size總是指向陣列最後一個元素+1的位置,所以利用size變數我們可以很輕易的完成元素的新增。
而在連結串列中則相反,我們在連結串列頭新增新的元素最方便,因為連結串列內維護了一個head變數,即連結串列的頭部,我們只需要將新的元素放入一個新的節點中,然後將新節點內的next變數指向head,最後把head指向這個新節點就完成了元素的新增:
我們來實現這個在連結串列頭新增新的元素的方法,程式碼如下:
/** * 在連結串列頭新增新的元素e * * @param e 新的元素 */ public void addFirst(E e) { Node node = new Node(e); node.next = head; head = node; // 以上三句程式碼完全可以直接使用以下一句程式碼完成, // 但為了讓邏輯更清晰所以這裡特地將程式碼分解了 // head = new Node(e, head); size++; }
然後我們來看看如何在連結串列中指定的位置插入新的節點,雖然這在連結串列中不是一個常用的操作,但是有些連結串列相關的題目會涉及到這種操作,所以我們還是得了解一下。例如我們現在要往“索引”為2的位置插入一個新的節點,該如何實現:
雖然連結串列中沒有真正的索引,但是為了實現在指定的位置插入新的節點,我們得引用索引這個概念。如上圖中,把連結串列頭看作是索引0,下一個節點看作索引1,以此類推。然後我們還需要有一個prev變數,通過迴圈移動這個變數去尋找指定的“索引” - 1 的位置,找到之後將新節點的next指向prev的next,prev的next再指向新的節點,即可完成這個插入節點的邏輯,所以關鍵點就是找到要新增的節點的前一個節點:
具體的實現程式碼如下:
/**
* 在連結串列的index(0-based)位置新增新的元素e
*
* @param index 元素新增的位置
* @param e 新的元素
*/
public void add(int index, E e) {
// 檢查索引是否合法
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Illegal index.");
}
// 連結串列頭新增需特殊處理
if (index == 0) {
addFirst(e);
} else {
Node prev = head;
// 移動prev到index - 1的位置
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 同樣,以上三句程式碼可以一句程式碼完成
// prev.next = new Node(e, prev.next);
size++;
}
}
基於以上這個方法,我們就可以輕易的實現在連結串列末尾新增新的元素:
/**
* 在連結串列末尾新增新的元素e
*
* @param e 新的元素
*/
public void addLast(E e) {
add(size, e);
}
使用連結串列的虛擬頭節點
在上一小節中,我們實現向指定位置插入元素的程式碼裡,需對連結串列頭的位置特殊處理,因為連結串列頭沒有上一個節點。很多時候使用連結串列的都需要進行類似的特殊處理,並不是很優雅,所以本小節就是介紹如何優雅的解決這個問題。
之所以要進行特殊處理,主要原因還是head沒有上一個節點,初始化prev的時候只能指向head,既然這樣我們就給它前面加一個節點好了,這個節點不儲存任何資料,僅作為一個虛擬節點。這也是編寫連結串列結構時經常使用到的技巧,新增這麼一個節點就可以統一連結串列的操作邏輯:
修改後的程式碼如下:
public class LinkedList<E> {
...
/**
* 虛擬頭節點
*/
private Node dummyHead;
/**
* 連結串列中元素的個數
*/
private int size;
public LinkedList() {
this.dummyHead = new Node(null, null);
this.size = 0;
}
/**
* 在連結串列的index(0-based)位置新增新的元素e
*
* @param index 元素新增的位置
* @param e 新的元素
*/
public void add(int index, E e) {
if (index < 0 || index > size) {
throw new IllegalArgumentException("Add failed. Illegal index.");
}
Node prev = dummyHead;
// 移動prev到index前一個節點的位置
for (int i = 0; i < index; i++) {
prev = prev.next;
}
Node node = new Node(e);
node.next = prev.next;
prev.next = node;
// 同樣,以上三句程式碼可以一句程式碼完成
// prev.next = new Node(e, prev.next);
size++;
}
/**
* 在連結串列頭新增新的元素e
*
* @param e 新的元素
*/
public void addFirst(E e) {
add(0, e);
}
/**
* 在連結串列末尾新增新的元素e
*
* @param e 新的元素
*/
public void addLast(E e) {
add(size, e);
}
}
連結串列的遍歷、查詢和修改
有了以上小節的基礎,接下來我們實現連結串列的遍歷、查詢和修改就很簡單了,程式碼如下:
/**
* 獲取連結串列的第index(0-based)個位置的元素
*
* @param index
* @return
*/
public E get(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Get failed. Illegal index.");
}
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
return cur.e;
}
/**
* 獲取連結串列中的第一個元素
*
* @return
*/
public E getFirst() {
return get(0);
}
/**
* 獲取連結串列中的最後一個元素
*
* @return
*/
public E getLast() {
return get(size - 1);
}
/**
* 修改連結串列的第index(0-based)個位置的元素為e
*
* @param index
* @param e
*/
public void set(int index, E e) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("Set failed. Illegal index.");
}
Node cur = dummyHead.next;
for (int i = 0; i < index; i++) {
cur = cur.next;
}
cur.e = e;
}
/**
* 查詢連結串列中是否包含元素e
*
* @param e
* @return
*/
public boolean contain(E e) {
Node cur = dummyHead.next;
// 第一種遍歷連結串列的方式
while (cur != null) {
if (cur.e.equals(e)) {
return true;
}
cur = cur.next;
}
return false;
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("LinkedList: size = %d\n", size));
sb.append("[");
Node cur = dummyHead.next;
// 第二種遍歷連結串列的方式
for (int i = 0; i < size; i++) {
sb.append(cur.e).append(" -> ");
cur = cur.next;
}
// 第三種遍歷連結串列的方式
// for (Node cur = dummyHead.next; cur != null; cur = cur.next) {
// sb.append(cur.e).append(" -> ");
// }
return sb.append("NULL]").toString();
}
從連結串列中刪除元素
最後我們要實現的連結串列操作就是從連結串列中刪除元素,刪除元素就相當於是刪除連結串列中的節點。例如我要刪除”索引“為2的節點,同樣的我們也需要使用一個prev變數迴圈移動到要刪除的節點的前一個節點上,此時把prev的next拿出來就是待刪除的節點。刪除節點也很簡單,拿出待刪除的節點後,將prev的next指向待刪除節點的next:
最後將待刪除的節點指向一個null,讓其脫離連結串列,這樣就能夠快速被垃圾回收,如此一來就完成了節點的刪除:
具體的實現程式碼如下:
/**
* 從連結串列中刪除第index(0-based)個位置的元素,並返回刪除的元素
*
* @param index
* @return 被刪除的節點所儲存的元素
*/
public E remove(int index) {
if (index < 0 || index >= size) {
throw new IllegalArgumentException("remove failed. Illegal index.");
}
Node prev = dummyHead;
for (int i = 0; i < index; i++) {
prev = prev.next;
}
Node delNode = prev.next;
// 把引用改變一下就完成了刪除
prev.next = delNode.next;
delNode.next = null;
size--;
return delNode.e;
}
基於以上這個方法,我們就可以很簡單的實現如下兩個方法:
/**
* 刪除連結串列中第一個元素
*
* @return 被刪除的元素
*/
public E removeFirst() {
return remove(0);
}
/**
* 刪除連結串列中最後一個元素
*
* @return 被刪除的元素
*/
public E removeLast() {
return remove(size - 1);
}
最後我們來看一下我們實現的這個連結串列增刪查改操作的時間複雜度:
addLast(e) // O(n)
addFirst(e) // O(1)
add(index, e) // O(n)
removeLast() // O(n)
removeFirst() // O(1)
remove(index) // O(n)
set(index, e) // O(n)
get(index) // O(n)
contain(e) // O(n)
使用連結串列實現棧
從連結串列的addFirst和removeFirst方法的時間複雜度可以看到,如果只對連結串列頭進行增、刪操作的複雜度是O(1)的,只查詢連結串列頭的元素複雜度也是O(1)的。這時我們就可以想到使用連結串列來實現棧,用連結串列實現的棧其入棧出棧等操作時間複雜度也都是O(1)的,具體的實現程式碼如下:
/**
* @program: Data-Structure
* @description: 基於連結串列實現棧資料結構
* @author: 01
* @create: 2018-11-08 23:38
**/
public class LinkedListStack<E> implements Stack<E> {
private LinkedList<E> linkedList;
public LinkedListStack() {
this.linkedList = new LinkedList<>();
}
@Override
public int getSize() {
return linkedList.getSize();
}
@Override
public boolean isEmpty() {
return linkedList.isEmpty();
}
@Override
public void push(E e) {
linkedList.addFirst(e);
}
@Override
public E pop() {
return linkedList.removeFirst();
}
@Override
public E peek() {
return linkedList.getFirst();
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("LinkedListStack: size = %d\n", getSize()));
sb.append("top [");
for (int i = 0; i < getSize(); i++) {
sb.append(linkedList.get(i));
if (i != getSize() - 1) {
sb.append(", ");
}
}
return sb.append("]").toString();
}
// 測試
public static void main(String[] args) {
Stack<Integer> stack = new LinkedListStack<>();
for (int i = 0; i < 5; i++) {
stack.push(i);
System.out.println(stack);
}
stack.pop();
System.out.println(stack);
}
}
帶有尾指標的連結串列:使用連結串列實現佇列
上一小節我們基於連結串列很輕易的就實現了一個棧結構,本小節我們來看看如何使用連結串列實現佇列結構,看看需要對連結串列進行哪些改進。
在編寫程式碼之前,我們需要考慮到一個問題,在之前連結串列結構的實現程式碼中,只有一個head變數指向頭節點,若我們直接使用這個連結串列實現佇列的話,需要操作鏈尾的元素時,複雜度是O(n)的,因為需要遍歷整個連結串列直到尾節點的位置。那麼該如何避開遍歷,在O(1)的複雜度下快速的找到尾節點呢?答案就是增加一個tail變數,讓這個變數始終指向尾節點即可,這樣我們操作尾節點的複雜度就是O(1)了。
除此之外,使用連結串列實現佇列還有一個問題需要考慮,那就是從哪邊入隊元素,從哪邊出隊元素。我們之前編寫的連結串列程式碼中,在鏈首新增元素是O(1)的,也是最簡單方便的,所以我們要將鏈首作為入隊的一端嗎?答案是相反的,應該將鏈首作為出隊的一端,鏈尾作為入隊的一端。
因為我們實現的連結串列是單鏈結構,在這種情況下鏈首無論是作為入隊還是出隊的一端都是可以的,但是鏈尾不可以,鏈尾只能作為入隊的一端。如果將鏈尾作為出隊的一端,那麼出隊的複雜度將是O(n)的,需要遍歷連結串列找到尾節點的上一個節點,然後將該節點的next指向null才能完成出隊的操作。若是雙鏈結構倒是無所謂,只需要通過tail變數就可以獲取到上一個節點,不需要遍歷連結串列去尋找。因此,我們需要將鏈首作為入隊的一端,鏈尾作為出隊的一端,這樣無論是出隊還是入隊的時間複雜度都是O(1)。
具體的實現程式碼如下:
/**
* @program: Data-Structure
* @description: 基於連結串列實現的佇列資料結構
* @author: 01
* @create: 2018-11-09 17:00
**/
public class LinkedListQueue<E> implements Queue<E> {
private class Node {
E e;
Node next;
public Node() {
this(null, null);
}
public Node(E e) {
this(e, null);
}
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
@Override
public String toString() {
return e.toString();
}
}
/**
* 頭節點
*/
private Node head;
/**
* 尾節點
*/
private Node tail;
/**
* 表示佇列中的元素個數
*/
private int size;
@Override
public void enqueue(E e) {
if (tail == null) {
// 連結串列沒有元素
tail = new Node(e);
head = tail;
} else {
// 鏈尾入隊元素
tail.next = new Node(e);
tail = tail.next;
}
size++;
}
@Override
public E dequeue() {
if (isEmpty()) {
throw new IllegalArgumentException("Can't dequeue from an empty queue.");
}
// 鏈首出隊元素
Node retNode = head;
head = head.next;
retNode.next = null;
if (head == null) {
// 佇列裡沒元素的話,尾節點需要置空
tail = null;
}
size--;
return retNode.e;
}
@Override
public E getFront() {
if (isEmpty()) {
throw new IllegalArgumentException("Queue is empty.");
}
return head.e;
}
@Override
public int getSize() {
return size;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public String toString() {
if (isEmpty()) {
return "[]";
}
StringBuilder sb = new StringBuilder();
sb.append(String.format("LinkedListQueue: size = %d\n", getSize()));
sb.append("front [");
Node cur = head;
while (cur != null) {
sb.append(cur.e).append(", ");
cur = cur.next;
}
return sb.append("NULL] tail").toString();
}
}