鏈表簡介
鏈表是很常見的數據結構,由一個個節點組成,每個節點中儲存着數據和指針(地址引用),指針負責節點間的連接。
它是一種線性表,線性表有兩種存儲方式:順序存儲和鏈式存儲。鏈表屬於鏈式存儲,順序由元素間的指針決定,元素在內存中非連續存放,且鏈表長度可以改變。數組是順序存儲的線性表,元素在內存中連續存放的,且數組創建時大小已固定。
鏈表可以用來實現棧和隊列數據結構(棧和隊列可理解為邏輯類數據結構,鏈表屬於存儲類數據結構),實現緩存LRU算法,Java類庫也使用了鏈表(如,LinkedList,LinkedHashMap)等。鏈表的形式有很多,常用的有單向鏈表、雙向鏈表、循環鏈表 ...
單向鏈表
單鏈表中的節點分兩部分,分別是數據(data)和指向下一個節點的地址(next),尾節點(tail)的next指向null。單向鏈表只能從頭到尾一個方向遍歷,查找節點時需要從頭節點(head)開始向下查找。
插入節點首先遍歷查找到插入的位置,然后將當前插入節點的next指向下一節點,上一節點的next指向當前插入節點。刪除節點同樣從頭遍歷找到要刪除的節點,然后將當前刪除節點的上一個節點next指向當前刪除節點的下一個節點。
節點的偽代碼:
class Node<E>{
private E item;
private Node<E> next; // 如果是尾節點,next指向null
Node(E data, Node<E> next) {
this.item = data;
this.next = next;
}
// ...
}
單向循環鏈表
循環鏈表和非循環鏈表基本一樣,區別是首尾節點連在了一起,最后一個節點的next指向頭節點,形成了一個閉環。
節點的偽代碼:
class Node<E>{
private E item;
// 如果是末尾節點,指向首節點的引用地址
private Node<E> next;
Node(E data, Node<E> next) {
this.item = data;
this.next = next;
}
// ...
}
雙向鏈表
顧名思義,與單向鏈表相比較,雙向鏈表可以從頭到尾或從尾到頭兩個方向來遍歷數據。雙向鏈表中的節點分三個部分,分別是指向上一個節點的地址(prev)和數據(data)以及指向下一個節點的地址(next),尾節點(tail)節點的next指向null,頭節點(head)的prev指向null。
增加和刪除節點和單向鏈表同理,只是增加了修改prev地址的操作。
節點的偽代碼:
class Node<E>{
private E item;
private Node<E> prev; // 頭節點prev指向null
private Node<E> next; // 尾節點next指向null
Node(Node<E> prev, E data, Node<E> next) {
this.item = data;
this.prev = prev;
this.next = next;
}
// ...
}
雙向循環鏈表
尾節點的next指向頭節點,頭結點的prev指向尾節點,首尾節點連在一起形成閉環。
節點的偽代碼:
class Node<E> {
private E item;
// 如果是第一個節點,其一用指向末尾節點
private Node<E> prev;
// 如果是末尾結點,指向第一個結點的引用地址,形成一個環形
private Node<E> next;
Node(Node<E> prev, E data, Node<E> next) {
this.item = data;
this.prev = prev;
this.next = next;
}
// ...
}
鏈表操作
鏈表的增刪改查操作。鏈表查找節點需要從頭或者尾部(單向鏈表只能從頭開始)開始查找,刪除或插入節點先查找到節點,然后改變相關節點的指針指向即可。
以雙向鏈表為例:
添加節點
- 頭部添加節點
偽代碼:
Node<E> head;
Node<E> tail;
int size;
// 頭部添加節點
void addHead(E e) {
Node<E> h = head;
Node<E> newNode = new Node<>(null, e, h); // (Node<E> prev, E element, Node<E> next)
head = newNode;
if(h == null) { // 空鏈表
tail = newNode;
} else {
h.prev = newNode;
}
size++; // 記錄長度
}
- 尾部添加節點
偽代碼:
void addTail(E e) {
Node<E> t = tail;
Node<E> newNode = new Node<>(t, e, null);
tail = newNode;
if(t == null) {
head = newNode;
} else {
t.next = newNode;
}
size++;
}
- 按位置插入節點
偽代碼:
void add(int index, E element) {
if (index == size) {
// 直接在尾部添加節點
} else {
// 查找的節點
Node<E> temp = null;
if (index < (size >> 1)) {//由於雙向鏈表,選擇從離index位置最近端查找
Node<E> x = head;
for (int i = 0; i < index; i++) {
x = x.next;
}
temp = x;
} else {
Node<E> x = tail;
for (int i = size - 1; i > index; i--) {
x = x.prev;
}
temp = x;
}
// 插入節點
Node<E> pred = temp.prev;
Node<E> newNode = new Node<>(pred, element, temp);
temp.prev = newNode;
if (pred == null) { // 查找到的節點為Head節點
head = newNode;
} else {
pred.next = newNode;
}
}
size++;
}
刪除節點
- 刪除頭部節點
偽代碼:
E removeHead() {
Node<E> h = head;
if (h != null){
E element = h.item;
Node<E> next = h.next;
head = next;
if (next == null) {
tail = null;
} else {
next.prev = null;
}
size--; // 減少長度
return element; // 返回刪除元素
}
return null;
}
- 刪除尾部節點
偽代碼:
E removeTail() {
Node<E> t = tail;
if (t != null) {
E element = t.item;
Node<E> prev = t.prev;
tail = prev;
if (prev == null) {
head = null;
} else {
prev.next = null;
}
size--;
return element;
}
return null;
}
- 按節點位置或值刪除
偽代碼-按位置刪除:
E remove(int index) {
// 根據index查找節點
Node<E> temp = null;
if (index < (size >> 1)) {
Node<E> x = head;
for (int i = 0; i < index; i++) {
x = x.next;
}
temp = x;
} else {
Node<E> x = tail;
for (int i = size - 1; i > index; i--) {
x = x.prev;
}
temp = x;
}
// 刪除節點
E element = temp.item;
Node<E> next = temp.next;
Node<E> prev = temp.prev;
if (prev == null) {
head = next;
} else {
prev.next = next;
temp.prev = null;
}
if (next == null) {
tail= prev;
} else {
next.prev = prev;
temp.next = null;
}
temp.item = null;
size--;
return element;
}
查找節點
- 按位置或值查找節點
偽代碼-按位置索引查找:
E get(int index) {
Node<E> temp = null;
if (index < (size >> 1)) { // 從近的一端開始查找
Node<E> x = first;
for (int i = 0; i < index; i++) {
x = x.next;
}
temp = x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--) {
x = x.prev;
}
temp = x;
}
return temp.item;
}
如果是單向鏈表只能從頭部開始向后查找。
更新節點
更新節點首先查找到節點,然后修改節點data的指針。
具體可參考LinkedList源碼
鏈表實現棧和隊列
棧和隊列是一種對數據存取有嚴格順序要求的線性數據結構,使用鏈表和數組都能實現。下面使用鏈表來實現棧和隊列。
棧
棧只能從一端存取數據,遵循后進先出(LIFO)原則。進出棧的一端稱為棧頂,另一封閉端稱為棧底,數據進入棧稱為入棧或壓棧,取出數據稱為出棧或彈棧。
偽代碼 - 基於雙向鏈表實現簡單的“棧”:
class Stack<E> {
// 返回棧頂元素值
public E peek() {
Node<E> h = head;
return (h == null) ? null : h.item;
}
// 入棧
public void push(E e) {
addHead(e); // 在頭部添加節點
}
// 出棧
public E pop() {
// 移除頭部節點並返回值
return removeHead();
}
// ...
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
}
隊列
隊列是從兩端存取數據,並且從一端進,從另一端出,遵循先進先出(FIFO)原則。隊列進數據一端稱為隊尾,出數據端稱為隊頭,數據進隊列稱為入隊,取出隊列稱為出隊。
偽代碼 - 基於鏈表實現“隊列”:
class Queue {
// 入隊
public boolean offer(E e) {
return addTail(e);
}
// 出隊
public E poll() {
return removeHead();
}
// 返回頭元素值
public E peek() {
Node<E> h = head;
return (h == null) ? null : h.item;
}
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
}
快慢指針
快慢指針是解決鏈表某些問題的常用方法,利用兩個不同步頻的指針fast指針和slow指針算法來解決很多問題,例如:
查找未知長度的單向鏈表倒數第N個值
由於鏈表長度未知,首先循環鏈表得到 length,然后再次循環鏈表到length-(N-1) 處得到元素。但是利用快慢指針來保持固定位置間隔,只需要循環一次鏈表即可查找到元素。
偽代碼:
public E getLastN(int n) {
Node<E> h = head;
if (h == null || n < 1) {
return null;
}
Node<E> fast = h; // 快
Node<E> slow = h; // 慢
int count = 1;
while ((fast = fast.next) != null) {
// 倒數第k個節點與倒數第1個節點相隔 n-1 個位置,因此fast先走 n-1 個位置
if (count++ > n - 1) {
slow = slow.next;
}
}
// 鏈表中的元素個數小於 n
if (count < n) {
return null;
}
return slow.item;
}
找到鏈表中間節點值
使快指針移動步頻是慢指針二倍,一次遍歷即可快速找到中間節點。
偽代碼:
public E getMiddle() {
Node<E> h = head;
if (h == null) {
return null;
}
Node<E> fast = h; // 快
Node<E> slow = h; // 慢
while (fast != null && fast.next != null) {
fast = fast.next.next;
// 鏈表長度為偶數會兩個中間節點,返回第一個
if (fast != null) {
slow = slow.next;
}
}
return slow.item;
}
源碼:https://github.com/newobjectcc/code-example/blob/master/basic/datastructure/Linked.java
除此之外,還可以判斷鏈表中是否有環等等問題,快慢指針在面試時可能會被問到,有興趣朋友可以到網上找些鏈表的算法題。