純資料結構Java實現(3/11)(連結串列)
題外話: 篇幅停了一下,特意去看看其他人寫的類似的內容;然後發現類似博主喜歡畫圖,喜歡講解原理。
(於是我就在想了,理解資料結構的確需要畫圖,但我的文章寫給懂得人看,只配少量圖即可,省事兒)
下面正題開始。
一般性的,都能想到 dummy head 的技巧以及Java中LinkedList(底層是雙向(迴圈)連結串列)。
Leetcode 返回一個頭結點物件,就算返回整個連結串列了,而我們自己實現一般會 new 一個連結串列物件例項,然後呼叫該例項的各類方法來操作整個連結串列。
單鏈表
基本認識
之前寫的動態陣列並非真正動態,因為其內部封裝的是一個容量不可變的靜態陣列。
而這裡的連結串列則是真正的動態資料結構(不需要處理固定容量問題,即增刪效率高,但由於不知道實際地址/索引,所以也喪失了隨機訪能力)。
輔助其他資料結構:二分搜尋樹,AVL/紅黑樹,它們基於連結串列實現。
基本構成: 節點 + 指標。
class Node {
E e;
Node next;
}
- 最後一個節點一般指向 null
為了方便或者統一操作,一般會有 Node head,頭結點。
- 頭結點的存在一般是為了在頭部操作 (就像動態陣列的新元素索引始終是 size 位置)
- 一般直接用頭結點指向首個節點(第一個節點即 head,但它不儲存元素) dummy head
之所以用 dummy head 的原因,其實是為了操作簡便。(不用也可以,但實現上的寫法就...)
- 打個比方,你要刪除/增加某個節點時,一般情況而言,一定要知道刪除節點的前一個節點(在頭部則沒有必要);一般都是通過迴圈遍歷往後先找到特定節點,但是如果沒有 dummy head,那麼就要區分是在頭結點還是中間節點操作(在腦海中想一下就知道了)。
有了 dummy head,頭結點前面也有節點了,所以整個操作行為是統一的,一致的,不需要再做情況區分。
(下面有案例)
實現框架
先把實現的框架列一下,大致如下:
package linkedlist; public class LinkedList<E> { //定義一個內部類,作為節點類 private class Node { public E e; public Node next; //便於 LinkedList 訪問 public Node(E e, Node next) { this.e = e; this.next = next; } public Node(E e){ this(e, null); } public Node(){ this(null, null); } @Override public String toString() { return e.toString(); } } //操作連結串列的輔助變數 private int size; private Node head; //頭結點 //建構函式 public LinkedList() { head = null; size = 0; } public int getSize() { return size; } public boolean isEmpty() { return size == 0; } }
然後再來實現其中的增刪改查,此時先不設定虛擬頭節點。
新增操作
這裡實現的頭部新增 (後續再擴充套件其他新增):
public void addFirst(E e) {
/*
Node node = new Node(e);
node.next = head;
head = node;
*/
//簡寫
head = new Node(e, head);
//維護連結串列長度
size++;
}
在某個位置插入元素:
- 情況1: 連結串列中間的節點,先找到相應位置前一個節點,然後建立新節點,插入
- 情況2: 如果是第一個節點,那麼是不存在前一個節點的。直接用 addFirst 的方式
//指定的 index 位置新增元素 (先要找到 index 前一個位置)
// index 從 0 ~ size-1
public void add(int index, E e) {
// 索引有問題
if (index < 0 || index > size) { //當 index == size 時,表示在末尾新增
throw new IllegalArgumentException("Add Failed, Illegal index");
}
if (index == 0) {
addFirst(e);
} else {
Node prev = head;
//找到指定位置前一個節點
for (int i = 0; i < index - 1; i++) {
prev = prev.next;
}
//建立一個新節點
/*Node node = new Node(e);
node.next = prev.next;
prev.next = node;*/
//簡寫
prev = new Node(e, prev.next);
size++;
}
}
(可以看到上面確實是區分不同的情況了的)
此時在末尾新增元素,即 index = size 的位置新增,直接呼叫 addLast 即可:
//在末尾新增元素
public void addLast(E e){
add(size, e);
}
頭結點優化
不著急往後探索,這裡先把頭節點優化一下,即加入 dummy head,統一整個操作流程。
上面的操作 add
,由於連結串列頭結點 head 並沒有前面一個節點,所以插入的時候確實要特殊一些。(如果第一個節點之前有節點,那麼整個操作就統一了)
優化方法,在頭結點前面新增一個 虛擬節點
,即不儲存任意元素的節點。
內部機制,使用者(client) 不知道虛擬節點的存在。(只是為了方便邏輯操作)。
相關修改:
建構函式需要修改,初始化 LinkedList 的時候就要建立一個節點
public LinkedList1() {
dummyHead = new Node(null, null);
size = 0;
}
新增元素可以統一用 add,然後讓 addFirst 和 addLast 呼叫 add 方法即可。
//指定的 index 位置新增元素 (先要找到 index 前一個位置)
// index 從 0 ~ size-1
public void add(int index, E e) {
// 索引有問題
if (index < 0 || index > size) { //當 index == size 時,表示在末尾新增
throw new IllegalArgumentException("Add Failed, Illegal index");
}
//因為在實際 index 取值範圍內,總能找到相關節點的前一個節點
Node prev = dummyHead;
//找 index 之前的節點
for(int i = 0; i < index; i++){
prev = prev.next;
}
prev = new Node(e, prev.next);
size++;
}
//頭部插入
public void addFirst(E e) {
add(0, e);
}
//在末尾新增元素
public void addLast(E e){
add(size, e);
}
虛擬頭結點的引入,方便了其他許多連結串列的操作(只要涉及類似的遍歷查詢)。
獲取操作
//獲取某元素
public E get(int index) {
//先檢查索引的合法性
if(index<0 || index > size-1) {
throw new IllegalArgumentException("Get Failed, Illegal index");
}
// 和前面找 index 節點前一個節點不同(那裡是從第一個節點前面的虛擬節點開始)
// 這裡就要找 index 節點,索引從 dummyHead.next 開始,即真正的第一個節點開始
Node ret = dummyHead.next;
for(int i =0; i < index; i++) {
ret = ret.next;
}
return ret.e;
}
獲取第一個元素,最後一個:
//獲取第一個
public E getFirst() {
return get(0);
}
//獲取最後一個
public E getLast() {
return get(size -1);
}
修改元素
把 index 位置的元素修改為 E。
(找到節點,然後替換裡面的元素 e)
public void set(int index, E e) {
//先檢查索引的合法性
if (index < 0 || index > size - 1) {
throw new IllegalArgumentException("Get Failed, Illegal index");
}
//找到節點,然後替換裡面的元素
Node curr = dummyHead.next;
for (int i = 0; i < index; i++) {
curr = curr.next;
}
curr.e = e;
}
查詢元素
一直遍歷到元素末尾,然後尋找尾巴。
//查詢元素
public boolean contains(E e) {
Boolean ret = false;
//在 size 範圍內遍歷查詢
Node curr = dummyHead.next;
/*for(int i=0; i<size; i++){
if(curr.e.equals(e)){
ret = true;
break;
}
curr = curr.next;
}*/
//其實可以用 while 迴圈 (多判斷一次 size 位置)
while(curr != null) {
//當前節點是有效節點
if(curr.e.equals(e)){
ret = true;
break;
}
curr = curr.next;
}
return ret;
}
遍歷列印
多種迴圈的寫法:
//列印方法
@Override
public String toString() {
StringBuilder res = new StringBuilder();
//從頭遍歷到尾巴
/*Node curr = dummyHead.next;
while(curr != null) {
res.append(curr + "->");
curr = curr.next;
}*/
//簡寫
for(Node curr = dummyHead.next; curr != null; curr = curr.next) {
res.append(curr + "->");
}
res.append("null");
return res.toString();
}
簡單測試一下:
//測試元素
public static void main(String[] args) {
LinkedList1<Integer> linkedlist = new LinkedList1<>();
//放入元素 0, 1, 2, 3, 4
for(int i =0; i < 5; i++) {
linkedlist.addFirst(i); //O(1)
System.out.println(linkedlist);
}
System.out.println(linkedlist);
//嘗試插入一個元素
linkedlist.add(1, 100); // 4, 100, 2, 3, 1, 0, null
System.out.println(linkedlist);
}
列印結果:
0->null
1->0->null
2->1->0->null
3->2->1->0->null
4->3->2->1->0->null
4->3->2->1->0->null
4->100->3->2->1->0->null
刪除元素
還是要 先找到前一個節點 。(也就是說還是藉助虛擬頭結點)
簡單一句話,然 delNode 和原來的連結串列脫離。(delNode 置空非必須)
編碼實現:
//刪除元素
public E remove(int index){
if (index < 0 || index > size - 1) {
throw new IllegalArgumentException("Delete Failed, Illegal index");
}
//找到相關節點的前一個節點
Node curr = dummyHead;
for(int i = 0; i < index; i++) {
curr = curr.next;
}
Node delNode = curr.next;
//刪除
curr.next = delNode.next;
delNode.next = null;
//必須維護 size
size--;
return delNode.e;
}
//刪除第一個節點
public E removeFirst() {
return remove(0);
}
//刪除最後一個節點
public E removeLast() {
return remove(size-1);
}
//刪除指定元素
public void removeElem(E e) {
//從 dummyHead 開始找,找到就刪除,否則就不刪除
Node curr = dummyHead;
boolean found = false;
while (curr.next != null) {
if (curr.next.e.equals(e)) {
found = true;
//刪除操作
Node delNode = curr.next;
curr.next = delNode.next;
delNode.next = null;
size--;
break;
}
curr = curr.next;
}
if (!found) {
throw new RuntimeException("要刪除的元素不存在");
}
}
測試一下:
//測試元素
public static void main(String[] args) {
LinkedList1<Integer> linkedlist = new LinkedList1<>();
//放入元素 0, 1, 2, 3, 4
for(int i =0; i < 5; i++) {
linkedlist.addFirst(i); //O(1)
System.out.println(linkedlist);
}
System.out.println(linkedlist);
//嘗試插入一個元素
linkedlist.add(1, 100); // 4, 100, 2, 3, 1, 0, null
System.out.println(linkedlist);
//嘗試刪除 index = 1 位置的 100
linkedlist.remove(1);
System.out.println(linkedlist); //4->3->2->1->0->null
//刪除最後一個元素 0
linkedlist.removeLast();
System.out.println(linkedlist); //4->3->2->1->null
//刪除第一個元素
linkedlist.removeFirst();
System.out.println(linkedlist); //3->2->1->null
//刪除指定元素
linkedlist.removeElem(3);
linkedlist.removeElem(1);
//linkedlist.removeElem(null);
System.out.println(linkedlist);
}
時間複雜度
連結串列雖然不移動元素,但是涉及到從前往後找到(檢查)相應的位置/元素。
新增操作:
- addFirst(), O(1) 因為採用的是頭插法
- addLast(), O(n) 涉及迴圈遍歷到尾部,然後插入
- add(), O(n) 其實是 O(n/2) 即 O(n)
刪除操作:
同上。
修改操作: O(n)。
查詢操作:
get(), contains(), find() 一律 O(n),因為並不支援隨機訪問呀。
單鏈表應用
鏈棧
上面也說了,如果只在連結串列頭增刪時,它的整體複雜度是 O(1),這不正好用於棧麼?
- 簡單記憶一下,同側操作
- 棧的底層實現是連結串列,而不是動態陣列了
package stack;
import linkedlist.LinkedList1; //這是有 dummy head優化的連結串列實現
public class LinkedListStack<E> implements Stack<E>{
//鏈棧內部實際採用連結串列儲存
private LinkedList1<E> list;
public LinkedListStack(){
list = new LinkedList1<>();
}
@Override
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public int getSize() {
return list.getSize();
}
@Override
public E pop() {
return list.removeFirst();
}
@Override
public E peek() {
return list.getFirst();
}
@Override
public void push(E e) {
list.addFirst(e);
}
@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append("Stack: top [");
res.append(list);
res.append("]");
return res.toString();
}
public static void main(String[] args) {
LinkedListStack<Integer> stack = new LinkedListStack<>();
//放入元素 0, 1, 2, 3, 4
for(int i =0; i < 5; i++) {
stack.push(i); //O(1)
System.out.println(stack);
}
System.out.println(stack);
System.out.println(stack.peek());
//彈出一個元素
stack.pop();
System.out.println(stack);
}
}
測試結果:
Stack: top [0->null]
Stack: top [1->0->null]
Stack: top [2->1->0->null]
Stack: top [3->2->1->0->null]
Stack: top [4->3->2->1->0->null]
Stack: top [4->3->2->1->0->null]
4
Stack: top [3->2->1->0->null]
和陣列實現的棧的不同,陣列是在尾巴上插入,可能涉及動態擴容,均攤複雜度是 O(1),而鏈棧始終就是O(1)。
- 但是 linkedlist 的 new 操作時非常耗時的 (特別是大量物件建立)
- 真實執行結果是不確定的 (ArrayStack VS LinkedListStack),因為數量級一致
鏈佇列
因為佇列涉及頭和尾的操作,所以如果用連結串列,那一般要新增一個尾指標。
因為 head 和 tail 都是指標,所以入隊和出隊相當於改變指向那麼簡單,但誰做頭誰做尾巴?(相當於 head, tail 指標往哪個方向移動)
如果要刪除 tail 元素並不容易(無法做到O(1)),因為刪除元素要知道 tail 前面一個元素。但是 tail 增加,則可以直接新增。(head不用管, 它的增刪都比較容易)
所以結論顯而易見:
- tail 用作隊尾 (即用於增加元素, tail 指標右移)
- head 用作隊首 (刪除元素,出隊)
此時還需要 dummy head 麼,分析上面的 tail, head,顯然不需要操作統一了,所以不需要啞結點。
這裡就不復用 LinkedList 了,而是專門再在內部實現鏈式儲存。(Node 內部類還是需要的)
特別注意:
- 連結串列為空的情況
- 只有一個元素的情況,此時即便是出隊,也要 head = tail = null;
//內部採用鏈式儲存的佇列
public class LinkedQueue<E> implements Queue<E> {
//定義一個內部類,作為節點類
private class Node {
public E e;
public Node next; //便於 LinkedList 訪問
public Node(E e, Node next) {
this.e = e;
this.next = next;
}
public Node(E e) {
this(e, null);
}
public Node() {
this(null, null);
}
@Override
public String toString() {
return e.toString();
}
}
private Node head, tail;
private int size;
//構造器
public LinkedQueue() {
head = tail = null;
size = 0;
}
@Override
public boolean isEmpty() {
return size == 0;
}
@Override
public int getSize() {
return size;
}
@Override
public E dequeue() {
//出隊操作,在隊首
//沒有元素肯定就不能出隊
if (isEmpty()) {
//或者 head = null
throw new IllegalArgumentException("Cannot dequeue from an empty queue");
}
//正常出隊,提取 head
Node retNode = head; //tail,考慮只有一個元素的佇列
head = retNode.next;
retNode.next = null;//遊離物件
//僅在只有一個元素的佇列,需要維護 tail
if (head == null) {
tail = null;
}
size--;
return retNode.e;
}
@Override
public E getFront() {
if (isEmpty()) {
//或者 head = null
throw new IllegalArgumentException("Cannot dequeue from an empty queue");
}
return head.e; // 返回隊首即可
}
@Override
public void enqueue(E e) {
//入隊操作,在尾部操作
if (tail == null) { //說明此時佇列是空的,即 tail 和 head 都為空
tail = new Node(e);
head = tail;
} else {
tail.next = new Node(e);
tail = tail.next;
}
size++;
}
@Override
public String toString() {
StringBuilder res = new StringBuilder();
res.append("Queue: front[ ");
for(Node curr = head; curr != null; curr = curr.next){
res.append(curr.e + "->");
}
res.append("null ] tail");
return res.toString();
}
public static void main(String[] args) {
LinkedQueue<Integer> queue = new LinkedQueue<>();
//儲存 11 個元素看看
for(int i=0; i<11; i++){
queue.enqueue(i);
System.out.println(queue); // 在 10 個元素滿的時候回擴容
}
//出隊試試
System.out.println("------出隊");
queue.dequeue();
System.out.println(queue);
}
}
執行結果如下:
Queue: front[ 0->null ] tail
Queue: front[ 0->1->null ] tail
Queue: front[ 0->1->2->null ] tail
Queue: front[ 0->1->2->3->null ] tail
Queue: front[ 0->1->2->3->4->null ] tail
Queue: front[ 0->1->2->3->4->5->null ] tail
Queue: front[ 0->1->2->3->4->5->6->null ] tail
Queue: front[ 0->1->2->3->4->5->6->7->null ] tail
Queue: front[ 0->1->2->3->4->5->6->7->8->null ] tail
Queue: front[ 0->1->2->3->4->5->6->7->8->9->null ] tail
Queue: front[ 0->1->2->3->4->5->6->7->8->9->10->null ] tail
------出隊
Queue: front[ 1->2->3->4->5->6->7->8->9->10->null ] tail
到這裡,單鏈表基本探究完畢了。
其他連結串列
下面說的這些連結串列其實也很常用,但是個人要去實現的話,就費事兒啊
(除非你是大學教師,或者學生,或者自由作家,有的是時間耐得住寂寞,磨啊)
雙向連結串列
這個維護代價其實有點大,有點就是節點之間的聯絡更加方便了。(單鏈表時也會維護尾指標)
- 比如尾端刪除,不用從頭開始找尾端前一個元素了,避免了 O(n) 複雜度
沒有對比就沒有傷害,要找我前一個節點是吧,直接給你(不要迴圈了)。其他操作則沒有太多變化(需要頭結點優化)。由於有額外的變數需要維護,所以並不見得簡單。
class Node {
E e;
Node prev, next;
}
迴圈連結串列
jdk 中 linkedlist 貌似經過一陣子去環優化,可能,因為不要環效率也不差。
迴圈連結串列一般都是基於雙向連結串列的。
不用畫圖了,直接認為尾部元素直接指向 dummy head 即可。
此時不需要 tail,因為在 dummyHead 的前面新增一個元素,就相當於在結尾新增元素了。
(引入的環會導致操作有些許變化,比如遍歷)
陣列連結串列
- 陣列中除了儲存值,還儲存了下一個節點的索引,那麼就相當於陣列連結串列了。
- 不依賴陣列本身的 index,而依賴於自身儲存的數字索引。
有點兒類似於資料庫儲存設計中的無限級欄位,即某個元素要儲存其父元素位置(parentId)。
畢竟還是基礎資料結構,沒有太複雜;這種 link 的思想用於樹(二叉樹,多叉樹)很平常。
老規矩,程式碼參考的話,我放在了 gayhub, FYI。