[本專題會對常見的數據結構及相應算法進行分析與總結,並會在每個系列的博文中提供幾道相關的一線互聯網企業面試/筆試題來鞏固所學及幫助我們查漏補缺。項目地址:https://github.com/absfree/Algo。由於個人水平有限,敘述中難免存在不清晰准確的地方,希望大家可以指正,謝謝大家:)]
一、什么是鏈表
提到鏈表,我們大家都不陌生,在平時的編碼中我們也或多或少地使用過這個數據結構。算法(第4版) (豆瓣)一書中對鏈表的定義如下:
鏈表是一種遞歸的數據結構,它或者為空(null),或者是指向一個結點(node)的引用,該節點還有一個元素和一個指向另一條鏈表的引用。
把以上定義用Java語言來描述大概是這樣的:
public class LinkedList<Item> { private Node first; private class Node { Item data; Node next; } ... }
一個LinkedList類實例便代表了一個鏈表,它的一個實例域保存了指向鏈表中第一個結點的引用。如下圖所示:
當然,以上我們所介紹的鏈表是single linked list(單向鏈表),有時候我們更喜歡double linked list(雙向鏈表),double linked list就是每個node不僅包含指向下后一個結點的引用,還包含着指向前一個結點的引用。后文我們在介紹鏈表的具體實現是會對這兩種鏈表進行更加詳細地介紹。
通常來說,鏈表支持插入和刪除這兩種操作,並且刪除/插入鏈表頭部/尾部結點的時間復雜度通常都是常數級別的,鏈表的不足在於不支持高效的random access(隨機訪問)。
二、鏈表的實現
1. 單鏈表(Single-Linked List)
在上文中,我們已經簡單用用Java刻畫出了鏈表的部分結構,我們只需為以上的LinkedList類增加insert、delete等方法,便可以實現一個(單向)鏈表。下面我們來介紹如何向鏈表中插入及刪除結點。
(1)插入結點
由於我們的LinkedList類中維護了一個指向first node的引用,所以在表頭插入結點是很容易的,具體請看以下代碼:
public void insert(Item item) { Node oldFirst = first; first = new Node(); first.item = item; first.next = oldFirst; itemCount++; }
(2)刪除結點
在表頭刪除結點的代碼也很簡單,基本是自注釋的:
public Item delete() { if (first != null) { Item item = first.item; first = first.next; return item; } else { throw new NullPointerException("There's no Node in the linked list."); }
2. 雙向鏈表(Double-Linked List)
雙向鏈表相比與單鏈表的優勢在於它同時支持高效的正向及反向遍歷,並且可以方便的在鏈表尾部刪除結點(單鏈表可以方便的在尾部插入結點,但不支持高效的表尾刪除操作)。雙向鏈表的Java描述如下:
public class DoubleLinkedList<Item> { private Node first; private Node last; private int itemCount; private class Node { Node prev; Node next; Item item; } public void addFirst(Item item) { Node oldFirst = first; first = new Node(); first.item = item; first.next = oldFirst; if (oldFirst != null) { oldFirst.prev = first; } if (itemCount == 0) { last = first; } itemCount++; } public void addLast(Item item) { Node oldLast = last; last = new Node(); last.item = item; last.prev = oldLast; if (oldLast != null) { oldLast.next = last; } if (itemCount == 0) { first = last; } itemCount++; } public Item delFirst() { if (first == null) { throw new NullPointerException("No node in linked list."); } Item result = first.item; first = first.next; if (first != null) { first.prev = null; } if (itemCount == 1) { last = null; } itemCount--; return result; } public Item delLast() { if (last == null) { throw new NullPointerException("No node in linked list."); } Item result = last.item; last = last.prev; if (last != null) { last.next = null; } if (itemCount == 1) { first = null; } itemCount--; return result; } public void addBefore(Item targetItem, Item item) { //從頭開始遍歷尋找目標節點 Node target = first; if (target == null) { throw new NullPointerException("No node in linked list"); } while (target != null && target.item != targetItem) { //繼續向后尋找目標節點 target = target.next; } if (target == null) { throw new NullPointerException("Can't find target node."); } //現在target為指向目標結點的引用 if (target.prev == null) { //此時相當於在表頭插入結點 addFirst(item); } else { Node oldPrev = target.prev; Node newNode = new Node(); newNode.item = item; target.prev = newNode; newNode.next = target; newNode.prev = oldPrev; oldPrev.next = newNode; itemCount++; } } public void addAfter(Item targetItem, Item item) { Node target = first; if (target == null) { throw new NullPointerException("No node in linked list."); } while (target != null && target.item != targetItem) { target = target.next; } if (target == null) { throw new NullPointerException("Can't find target node."); } if (target.next == null) { addLast(item); } else { Node oldNext = target.next; Node newNode = new Node(); newNode.item = item; target.next = newNode; newNode.prev = target; newNode.next= oldNext; oldNext.prev = newNode; itemCount++; } } }
上面代碼的邏輯都很直接,不過剛接觸鏈表的小伙伴有時候可能容易感到有些迷糊,這時候一個好方法便是在拿出筆紙,畫出鏈表操作相關結點的prev、next指針等的指向變化情況,這樣鏈表相關的各類操作過程都能被非常直觀的展現出來。
有一點需要我們注意的是,我們上面實現鏈表使用的是pointer wrapper方式,這種方式的特點是prev/next指針包含在結點中,而數據由結點中的另一個指針(即item)所引用。采取這種方式,在獲取結點數據時,我們需要進行double-dereferencing,而且這種方式實現的鏈表不是一種[局部化結構],這意味着我們拿到鏈表的一個結點數據后,無法直接進行insert/delete操作。
另一種實現鏈表的方式是intrusive方式,這種方式實現的鏈表也就是intrusive linked list。這種鏈表的特點是data就是node,node就是data。使用這種鏈表,我們在獲取data時,無需double-dereferencing,並且intrusive linked list是一種局部結構。
三、鏈表的特性
1. 優點
鏈表的主要優勢有兩點:一是插入及刪除操作的時間復雜度為O(1);二是可以動態改變大小。
2. 缺點
由於其鏈式存儲的特性,鏈表不具備良好的空間局部性,也就是說,鏈表是一種緩存不友好的數據結構。
四、小試牛刀之經典面試題
下面我們從《劍指Offer》中挑出幾道關於鏈表的經典面試題來進一步鞏固我們對鏈表相關技術點的掌握。
1. 輸入一個鏈表,輸出該鏈表中倒數第k個結點。
這道題給我們的框架如下,我們要做的是在這個框架中編程來實現從頭到尾打印鏈表:
/* public class ListNode { int val; ListNode next = null; ListNode(int val) { this.val = val; } }*/ public class Solution { public ListNode FindKthToTail(ListNode head,int k) { } }
首先我們可以看到這里面表示鏈表的是ListNode類,這對應着我們上面的單鏈表實現中的Node類。實際上,這道題的難度要比我們上面實現的DoubleLinkedList中的addBefore/addAfter方法的難度要小。
我想到的一種直接解法如下:(如有問題希望大家可以指出)
public class Solution { public ListNode FindKthToTail(ListNode head,int k) { //先求得鏈表的尺寸,賦值給size int size = 0; ListNode current = head; while (current != null) { size++; current = current.next; } //獲取next實例域size-k次,即可得到倒數第k個結點(從1開始計數) for (int i = 0; i < size - k; i++) { head = head.next; } return head; } }
2. 反轉鏈表
本題的要求是輸入一個鏈表,反轉鏈表后,輸出鏈表的所有元素。這道題的實現也比較直接,如以下代碼所示:
public ListNode ReverseList(ListNode head) { if (head == null) { return null; } ListNode current = head; //原head的next node為null ListNode prevNode = null; ListNode newHead = null; while (current != null) { ListNode nextNode = current.next; current.next = prevNode; if (nextNode == null) { newHead = current; } prevNode = current; current = nextNode; } return newHead; }
這里只是從劍指Offer中找了兩道關於鏈表的題來練手,以后會陸續在上面提到的項目地址跟大家分享更多的經常被用來作為一線互聯網公司面試/筆試題的題目,這樣在鞏固自己算法基本功的同時,在面試/筆試時也能夠更加得心應手。