之前我們學習了動態陣列,雖然比原始陣列的功能強大了不少,但還不是完全純動態的(基於靜態陣列實現的)。這回要講的連結串列則是正兒八經的動態結構,是一種非常靈活的資料結構。
連結串列的基本結構
連結串列由一系列單一的節點組成,將它們一個接一個地連結起來,就形成了連結串列。連結串列雖然沒有長度上的限制,但是節點之間需要儲存關聯關係。所以可以很自然地想到,你得知道前一個元素是啥,才能在它後面繼續接新的元素。如果後面沒元素可接,那麼就在連結串列尾部接一個空值,代表連結串列結束。
我們從一個空連結串列開始,依次往連結串列中新增元素:
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)級別,所以會比連結串列慢一些),所以效能會較優。
總結
至此,我們就學會了連結串列的基本概念和使用方式。相對於陣列來說,連結串列有著更加靈活的結構,但連結串列也不是萬能的。通常情況下,陣列適合索引有實際意義的場景,例如按照學號儲存成績,如果使用陣列,就可以直接使用學號進行訪問,而連結串列則沒有這一優勢。如果索引沒有實際意義,用連結串列就比較合適。