之前我們學習了動態數組,雖然比原始數組的功能強大了不少,但還不是完全純動態的(基於靜態數組實現的)。這回要講的鏈表則是正兒八經的動態結構,是一種非常靈活的數據結構。
鏈表的基本結構
鏈表由一系列單一的節點組成,將它們一個接一個地鏈接起來,就形成了鏈表。鏈表雖然沒有長度上的限制,但是節點之間需要儲存關聯關系。所以可以很自然地想到,你得知道前一個元素是啥,才能在它后面繼續接新的元素。如果后面沒元素可接,那么就在鏈表尾部接一個空值,代表鏈表結束。
我們從一個空鏈表開始,依次往鏈表中添加元素:
把整個過程拆分開來,可以分為以下幾步:
1.初始鏈表為空;
2.添加元素3后,3后面再無元素,所以要接一個空節點;
3.再依次添加元素4、6,結束后在末尾再接個空節點。
所以,如果用代碼表示鏈表的結構,就可以這樣描述:
public class LinkedList<E>{
// 節點類
private class Node {
public E element; // 節點儲存的元素值
public Node next; // 指向的下一個鏈接節點
public Node(E element, Node node) {
this.element = element;
this.next = node;
}
public Node(E element) {
this.element = element;
this.next = null;
}
public Node() {
}
}
private Node head; // 頭節點
private int size; // 鏈表長度
public LinkedList(){
this.head = null;
this.size = 0;
}
}
我們用 head 表示頭節點,即鏈表頭部的節點。容易知道,每個鏈表只有一個頭節點,且初始頭節點為空。
帶索引的鏈表
上面說了,如果要添加節點,需要知道前一個節點是啥。其實在一般情況下(只在鏈表尾部添加),前一個節點總是這個鏈表的最后一個節點。但是這里我們搞得稍微復雜一點,把鏈表也設計成可以根據索引,在鏈表的任何位置添加節點(雖然正常情況下鏈表無索引一說)。如果是這樣的話,要在 index 處添加節點,就得從 head 開始遍歷,知道 index-1 處(方便起見就叫 prev 節點)的節點是誰,然后再把 prev.next 指向新節點。這里有一個細節,就是如果 index 后面還有節點的話,就需要先把新節點的 next 指向 index節點(即prev.next),再把 prev 節點的next指向新節點。假如有一個長度為3的鏈表,現在要在index=1處添加新節點:
這里由於 head 節點正好就是 prev 節點,所以不用遍歷。
如果是往頭節點的位置添加元素的話,是沒有prev節點的,所以需要特殊處理:
對於刪除節點來說,也需要對頭節點做特殊處理。但是這種特殊處理意味着更多的代碼,而且每次都要進行條件判斷。如果能在 head 頭節點前面再增加一個節點,而這個節點本身又不參與存儲元素,應該就能解決我們的問題。
dummyHead - 頭節點的prev節點
dummyHead 就是我們為了方便添加頭節點而新增的節點,dummy 的意思是它不是真正的節點,對外也無法訪問。一個含有 dummyHead 的初始化鏈表如下:
轉換成代碼的話,就是這樣:
public class LinkedList<E>{
// 節點類
private class Node{
... ...
}
private Node dummyHead; // dummyHead節點
private int size; // 鏈表長度
public LinkedList(){
this.dummyHead= new Node(); // 生成dummyHead節點
this.size = 0;
}
}
可以看見,我們只聲明了 dummyHead,而沒有聲明 head 頭節點,因為 dummyHead 的下一個節點指向的就是 head 節點,如果想訪問 head 節點,直接調用 dummyHead.next 就可以了。
有了 dummyHead,無論是添加還是刪除節點,我們都可以遵循同一流程,而不必對誰特殊對待,影響整體性能。
節點添加流程:
節點刪除流程:
節點訪問流程:
我們之前一直着重在說節點增刪的問題,其實訪問節點比較簡單,只要從頭節點開始(dummyHead.next),遍歷到索引位置,即可訪問到目標節點。
代碼實現
基於以上邏輯,我們就可以實現鏈表了。
package com.algorithm.linkedlist;
import java.lang.String;
// 添加head元素和索引元素分情況處理
public class LinkedList<E> {
// 節點類
private class Node {
public E element; // 節點儲存的元素值
public Node next; // 指向的下一個鏈接節點
public Node(E element, Node node) {
this.element = element;
this.next = node;
}
public Node(E element) {
this.element = element;
this.next = null;
}
public Node() {
}
}
private Node dummyHead; // 鏈表dummy節點
private int size; // 鏈表長度
public LinkedList() {
this.dummyHead = new Node();
this.size = 0;
}
public int getSize() {
return size;
}
public boolean isEmpty() {
return getSize() == 0;
}
// 添加節點
public void add(int index, E element) {
if (index < 0 || index > getSize()) throw new IllegalArgumentException("index must > 0 and <= size!");
// prev節點的初始值為dummyHead
Node prev = dummyHead;
// 通過遍歷找到prev節點
for (int i = 0; i < index; i++) prev = prev.next;
// 將new Node的next節點指向prev.next,再把prev節點的next指向new Node
prev.next = new Node(element, prev.next);
size++;
}
public void addFirst(E element) {
add(0, element);
}
public void addLast(E element) {
add(getSize(), element);
}
// 移除節點
public E remove(int index) {
if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
if (getSize() == 0) throw new IllegalArgumentException("Empty Queue, please enqueue first!");
// prev節點的初始值為dummyHead
Node prev = dummyHead;
// 通過遍歷找到prev節點
for (int i = 0; i < index; i++) prev = prev.next;
// 儲存待刪除節點
Node delNode = prev.next;
// 跳過delNode
prev.next = delNode.next;
// 待刪除節點后接null
delNode.next = null;
size--;
return delNode.element;
}
public E removeFirst() {
return remove(0);
}
public E removeLast() {
return remove(getSize() - 1);
}
// 查找元素所在節點位置
public int search(E element) {
// 從頭節點開始遍歷
Node current = dummyHead.next;
for (int i = 0; i < getSize(); i++) {
if (element.equals(current.element)) return i;
current = current.next;
}
return -1;
}
// 判斷節點元素值
public boolean contains(E element) {
return search(element) != -1;
}
// 獲取指定位置元素值
public E get(int index) {
if (index < 0 || index >= getSize()) throw new IllegalArgumentException("index must > 0 and < size!");
// 從頭節點開始遍歷
Node current = dummyHead.next;
for (int i = 0; i < index; i++) current = current.next;
return current.element;
}
public E getFirst() {
return get(0);
}
public E getLast() {
return get(getSize() - 1);
}
// 設置節點元素值
public void set(int index, E element) {
// 從頭節點開始遍歷
Node current = dummyHead.next;
for (int i = 0; i < index; i++) current = current.next;
current.element = element;
}
@Override
public String toString() {
StringBuilder str = new StringBuilder();
str.append(String.format("LinkedList: size = %d\n", getSize()));
Node current = dummyHead.next;
for (int i = 0; i < getSize(); i++) {
str.append(current.element).append("->");
current = current.next;
}
str.append("null");
return str.toString();
}
// main函數測試
public static void main(String[] args) {
LinkedList<Integer> linkedList = new LinkedList<>();
for (int i = 0; i < 5; i++) {
linkedList.add(i, i);
System.out.println(linkedList);
}
// 刪除首尾節點
linkedList.removeFirst();
linkedList.removeLast();
System.out.println(linkedList);
}
}
/*
輸出內容:
LinkedList: size = 1
0->null
LinkedList: size = 2
0->1->null
LinkedList: size = 3
0->1->2->null
LinkedList: size = 4
0->1->2->3->null
LinkedList: size = 5
0->1->2->3->4->null
LinkedList: size = 3
1->2->3->null
*/
使用鏈表實現棧
實現了鏈表后,我們仿照之前的動態數組,也實現一下棧和隊列這兩種較基礎的數據結構。如果需要了解棧和隊列,可以看之前的這篇文章。
在寫代碼前,我們先來分析一下如何實現。棧是一種后進先出的結構,而通過上面對鏈表的學習,可以發現鏈表的 head 頭節點位置與棧的棧頂非常相似,節點可以通過頭節點直接進入鏈表,也可以直接從頭節點脫離鏈表,而且這兩種操作的時間復雜度都是 O(1) 級別的(prev 節點無需移動)。找到了這個特點,就可以很快地利用鏈表實現棧。
我們還是使用之前的接口實現棧:
package com.algorithm.stack;
public interface Stack <E> {
void push(E element); // 入棧
E pop(); // 出棧
E peek(); // 查看棧頂元素
int getSize(); // 獲取棧長度
boolean isEmpty(); // 判斷棧是否為空
}
具體實現:
package com.algorithm.stack;
import com.algorithm.linkedlist.LinkedList;
public class LinkedListStack<E> implements Stack<E>{
private LinkedList<E> linkedList; // 使用鏈表儲存棧元素
public LinkedListStack(){
linkedList = new LinkedList<>();
}
// 把鏈表頭作為棧頂,始終對鏈表頭進行操作
// 入棧
@Override
public void push(E element) {
linkedList.addFirst(element);
}
// 出棧
@Override
public E pop() {
return linkedList.removeFirst();
}
// 查看棧頂元素
@Override
public E peek() {
return linkedList.getFirst();
}
// 查看棧中元素個數
@Override
public int getSize() {
return linkedList.getSize();
}
// 查看棧是否為空
@Override
public boolean isEmpty() {
return linkedList.isEmpty();
}
@Override
public String toString() {
return "Stack: top [" + linkedList + "] tail";
}
// main函數測試
public static void main(String[] args) {
LinkedListStack<Integer> stack = new LinkedListStack<>();
for (int i=0;i<5;i++){
stack.push(i);
System.out.println(stack);
}
stack.pop();
System.out.println(stack);
}
}
/*
輸出結果:
Stack: top [0->null] tail
Stack: top [1->0->null] tail
Stack: top [2->1->0->null] tail
Stack: top [3->2->1->0->null] tail
Stack: top [4->3->2->1->0->null] tail
Stack: top [3->2->1->0->null] tail
*/
數組棧 VS 鏈表棧
截至目前,我們已經通過兩種方式實現了棧,接下來不妨對比一下兩種實現方式的性能孰高孰低。可以通過出棧和入棧兩種操作進行評估:
package com.algorithm.stack;
import java.util.Random;
public class PerformanceTest {
public static double testStack(Stack<Integer> stack, int testNum){
// 起始時間
long startTime = System.nanoTime();
// 使用隨機數測試
Random random = new Random();
// 入棧測試
for (int i=0;i<testNum;i++) stack.push(random.nextInt(Integer.MAX_VALUE));
// 出棧測試
for (int i=0;i<testNum;i++) stack.pop();
// 結束時間
long endTime = System.nanoTime();
// 返回測試時長
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
// 數組棧
ArrayStack<Integer> arrayStack = new ArrayStack<>();
double arrayTime = testStack(arrayStack, 1000000);
System.out.println("ArrayStack: " + arrayTime);
// 鏈表棧
LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
double linkedTIme = testStack(linkedListStack, 1000000);
System.out.println("LinkedListStack: " + linkedTIme);
}
}
/*
輸出結果:
// testNum = 10萬次的測試結果
ArrayStack: 0.0167257
LinkedListStack: 0.0120104
// testNum = 100萬次的測試結果
ArrayStack: 0.0509282
LinkedListStack: 0.2121052
*/
第一次使用10萬個隨機數進行測試時,兩者的性能差不多,鏈表似乎還有小小的優勢;而當使用100萬個數測試時,鏈表要明顯慢於數組。原因是鏈表在添加節點的過程中,需要不斷地 new 一個新的節點,而這個 new 的過程需要尋找新的地址,所以隨着次數的增大,耗時變得越來越明顯。而數組是先統一申請一批,滿了再繼續通過 resize 申請(個數取決於數組長度)。但是如果先執行鏈表,后執行數組,又會出現不同的結果:
public class PerformanceTest {
... ...
public static void main(String[] args) {
// 鏈表棧
LinkedListStack<Integer> linkedListStack = new LinkedListStack<>();
double linkedTime = testStack(linkedListStack, 1000000);
System.out.println("LinkedListStack: " + linkedTime);
// 數組棧
ArrayStack<Integer> arrayStack = new ArrayStack<>();
double arrayTime = testStack(arrayStack, 1000000);
System.out.println("ArrayStack: " + arrayTime);
}
}
/*
輸出結果:
LinkedListStack: 0.0368811
ArrayStack: 0.054051
*/
這下鏈表又比數組快了😂!猜想應該是先跑鏈表時,空閑空間比較多,找新地址的開銷還不大。但是如果在數組已經占用了100萬個地址的情況下,再尋找地址就沒那么容易了。
使用鏈表實現隊列
實現了棧,再來看隊列。隊列是一種先進先出的結構,對應到鏈表,可以使用鏈表的 head 頭節點模擬出隊操作(O(1)的時間復雜度)。如果知道了鏈表尾部的位置,就可以通過從鏈表尾部添加節點來模擬入隊操作,並且這個操作的時間復雜度也是 O(1)。所以我們要再多維護一個 tail 節點,意味着我們要對剛才的鏈表稍作調整。
同樣使用之前的隊列接口進行實現:
package com.algorithm.queue;
public interface Queue<E> {
void enqueue(E element); // 入隊
E dequeue(); // 出隊
E getFront(); // 獲取隊首元素
int getSize(); // 獲取隊列長度
boolean isEmpty(); // 判斷隊列是否為空
}
具體實現:
package com.algorithm.queue;
import java.lang.String;
public class LinkedListQueue<E> implements Queue<E>{
// 節點類
private class Node{
public E element; // 節點儲存的元素值
public Node next; // 指向的下一個鏈接節點
public Node(E element, Node node){
this.element = element;
this.next = node;
}
public Node(E element){
this.element = element;
this.next = null;
}
public Node(){
}
}
private Node head, tail; // 增加tail節點
private int size;
public LinkedListQueue(){
head = tail = null;
size = 0;
}
@Override
public int getSize(){
return size;
}
@Override
public boolean isEmpty(){
return getSize() == 0;
}
// tail入隊
@Override
public void enqueue(E element){
// 如果隊列為空,則將head和tail都置為入隊的第一個節點
if (tail == null){
head = tail = new Node(element);
}else{ // 其他情況下在tail處鏈接即可
tail.next = new Node(element);
tail = tail.next;
}
size++;
}
// head出隊
@Override
public E dequeue(){
if (isEmpty()) throw new IllegalArgumentException("Empty queue, enqueue first!");
// 將當前head標記為待出隊節點
Node delNode = head;
// head.next節點替代當前head
head = head.next;
// 將出隊節點置為空,脫離鏈表
delNode.next = null;
size--;
// 如果出隊后head為空,說明隊列為空,則將tail也置為null
if (head == null) tail = null;
return delNode.element;
}
// 獲取隊首元素
@Override
public E getFront(){
if (isEmpty()) throw new IllegalArgumentException("Empty queue, enqueue first!");
return head.element;
}
@Override
public String toString(){
StringBuilder str = new StringBuilder();
str.append("head [");
// 從head開始遍歷節點
Node current = head;
for (int i=0; i<getSize(); i++) {
str.append(current.element).append("->");
current = current.next;
}
str.append("null] tail");
return str.toString();
}
public static void main(String[] args) {
LinkedListQueue<Integer> queue = new LinkedListQueue<>();
for (int i=0; i<10;i++) {
queue.enqueue(2*i +1);
System.out.println("enqueue: " + queue);
if (i % 2 == 0 && i != 0){
queue.dequeue();
System.out.println("dequeue: " + queue);
}
}
}
}
/*
輸出結果:
enqueue: head [1->null] tail
enqueue: head [1->3->null] tail
enqueue: head [1->3->5->null] tail
dequeue: head [3->5->null] tail
enqueue: head [3->5->7->null] tail
enqueue: head [3->5->7->9->null] tail
dequeue: head [5->7->9->null] tail
enqueue: head [5->7->9->11->null] tail
enqueue: head [5->7->9->11->13->null] tail
dequeue: head [7->9->11->13->null] tail
enqueue: head [7->9->11->13->15->null] tail
enqueue: head [7->9->11->13->15->17->null] tail
dequeue: head [9->11->13->15->17->null] tail
enqueue: head [9->11->13->15->17->19->null] tail
*/
可以看到,我們沒有像之前一樣再去維護 dummyHead 節點,因為在模擬隊列時,無論是入隊還是出隊,時間復雜度都是 O(1),不需要遍歷所有節點。加入了 tail 節點后,有點像之前的循環隊列,需要考慮隊列為空時 head 和 tail 的取值問題。
數組隊列 VS 鏈表隊列 VS 循環隊列
我們把之前的循環隊列也加上,比較一下三種隊列在入隊和出隊方面的性能差異:
package com.algorithm.queue;
import java.util.Random;
public class PerformanceTest {
public static double testQueue(Queue<Integer> queue, int testNum){
// 起始時間
long startTime = System.nanoTime();
// 入棧測試
Random random = new Random();
for (int i=0; i< testNum;i++){
queue.enqueue(random.nextInt(Integer.MAX_VALUE));
}
// 出棧測試
for (int i=0; i< testNum;i++){
queue.dequeue();
}
// 結束時間
long endTime = System.nanoTime();
return (endTime - startTime) / 1000000000.0;
}
public static void main(String[] args) {
// 數組隊列
ArrayQueue<Integer> arrayQueue = new ArrayQueue<>();
double arrayTime = testQueue(arrayQueue, 100000);
System.out.println("ArrayQueue: " + arrayTime);
// 鏈表隊列
LinkedListQueue<Integer> linkedListQueue = new LinkedListQueue<>();
double linkedTime = testQueue(linkedListQueue, 100000);
System.out.println("LinkedQueue: " + linkedTime);
// 循環隊列
LoopQueue<Integer> loopQueue = new LoopQueue<>();
double loopTime = testQueue(loopQueue, 100000);
System.out.println("LoopQueue: " + loopTime);
}
}
/*
輸出結果:
ArrayQueue: 3.1286747
LinkedQueue: 0.0070489
LoopQueue: 0.018315
*/
這次鏈表的性能就遠遠大於數組了(試試把鏈表放在最后執行),循環隊列與鏈表的差異還不算太大。因為數組每次出隊都會觸發向左移動元素(數組頭部為隊首),時間復雜度是 O(n) 級別,而鏈表和循環隊列不需要移動,是 O(1) 級別的復雜度(上篇文章計算的循環隊列實際上是 O(2)級別,所以會比鏈表慢一些),所以性能會較優。
總結
至此,我們就學會了鏈表的基本概念和使用方式。相對於數組來說,鏈表有着更加靈活的結構,但鏈表也不是萬能的。通常情況下,數組適合索引有實際意義的場景,例如按照學號存儲成績,如果使用數組,就可以直接使用學號進行訪問,而鏈表則沒有這一優勢。如果索引沒有實際意義,用鏈表就比較合適。