連結串列的基本操作
連結串列的基礎操作有查詢、刪除、新增。
查詢
先定義一下連結串列的資料結構:
class DataNode{
int key;
int value;
DataNode pre;
DataNode next;
public DataNode(){};
public DataNode (int key,int value){
this.key = key;
this.value = value;
}
}
其中的key和value就是節點實際儲存的值。pre和next分別指向前一個節點和下一個節點。一般的單向連結串列只有next,我這裡定義的是雙向連結串列。查詢操作就是從頭節點,一直遍歷next,直到找到目標節點為止。可以用while迴圈或者遞迴實現,找到目標節點就跳出或返回即可,時間複雜度為O(n)。
刪除
以上面的雙向連結串列為例,演示一下刪除操作。我們假設有三個節點A、B、C,現要刪除B節點。把A.next指向C,C.pre指向A。中間的B節點就不在鏈路上了,會被垃圾收回器給回收掉。
public void delNode(DataNode node){
node.pre.next = node.next;
node.next.pre = node.pre;
}
上面的程式碼初看可能有點繞,其實連結串列除了查詢操作,都有點繞,建議畫圖理解。其中node.pre.next
就是上一個節點的下一個節點,把它改成node.next
,就相當於讓上一個節點指向自己的下一個節點。第二行程式碼就是讓下一個節點的pre指向自己的上一個節點。刪除操作的時間複雜度為O(1)。
新增
假設有A、C兩個節點,現要往中間新增一個B節點。思路看圖都能想到,你的寫法不一定要和我一樣,只是注意別丟失節點了,程式碼如下:
//寫法1
public void addNode(DataNode pre,DataNode node){
//先記錄一下pre.next節點,否則下一步會丟失C節點
DataNode next = pre.next; // 記錄C
pre.next = node; //A->B
node.next = next; //B->C
next.pre = node; // B<-C
node.pre = pre; // A<-B
}
//寫法2,不用臨時變數
public void addNode(DataNode pre,DataNode node){
node.next = pre.next; //B->C
node.pre = pre; //A<-B
pre.next = node; // A->B
node.next.pre = node; //C<-B
}
演算法題
LRU快取
關於連結串列的演算法題中,我覺得最能訓練連結串列操作的就是LRU快取。即給出已給固定容量的容器,往裡put元素時,如果容量到達最大,就刪除最久未使用的元素。
題目描述:
實現 LRUCache 類:
LRUCache(int capacity) 以 正整數 作為容量 capacity 初始化 LRU 快取
int get(int key) 如果關鍵字 key 存在於快取中,則返回關鍵字的值,否則返回 -1 。
void put(int key, int value) 如果關鍵字 key 已經存在,則變更其資料值 value ;如果不存在,則向快取中插入該組 key-value 。如果插入操作導致關鍵字數量超過 capacity ,則應該 逐出 最久未使用的關鍵字。
函式 get 和 put 必須以 O(1) 的平均時間複雜度執行。
思路:根據key找到value,所以肯定要一個Hash表儲存值。元素數量超過capacity就要刪除最久未使用的關鍵字。我們就設計一個連結串列,每次get元素A時,就把A移到連結串列頭部。需要刪除元素時,直接刪除連結串列尾部的元素,尾部的就是最久沒使用的。
每次get時要把對應的元素移至頭部,為了避免遍歷連結串列,設計Hash表的value型別可以設定成DataNode,這樣就免去的連結串列查詢的時間。DataNode就複用開頭定義的資料結構。
class LRUCache {
int capacity;
HashMap<Integer,DataNode> map;
//定義一個虛擬的頭節點和尾節點,方便刪除尾節點和往頭節點新增元素
DataNode head;
DataNode tail;
//1.初始化相關屬性
public LRUCache(int capacity) {
this.capacity = capacity;
map = new HashMap<>();
head = new DataNode();
tail = new DataNode();
head.next = tail;
tail.pre = head;
}
//2.實現get邏輯,裡面的moveToHead可以先不實現
public int get(int key) {
DataNode node = map.get(key);
if(node==null){
return -1;
}
//把node節點移至頭部
moveToHead(node);
return node.value;
}
//3.實現put邏輯
public void put(int key, int value) {
if(map.containsKey(key)){
//如果當前key已經存在
DataNode node = map.get(key);
moveToHead(node);
node.value = value;
}else{
//不存在就新建一個node,如果超過capacity就刪除尾部節點
DataNode node = new DataNode(key,value);
map.put(key,node);
if(map.size()>capacity){
//因為還要從map中刪除元素,所以removeTail要有返回值
DataNode delNode = removeTail();
map.remove(delNode.key);
}
addHead(node);
}
}
//4.最後一步,實現上面所需的連結串列操作方法
private void moveToHead(DataNode node){
//先刪除,再移至頭部
removeNode(node);
addHead(node);
}
private void addHead(DataNode node){
node.next = head.next;
node.pre = head;
head.next = node;
node.next.pre = node;
}
private DataNode removeTail(){
DataNode delNode = tail.pre;
removeNode(delNode);
return delNode;
}
//removeTail和moveToHead都有刪除元素的操作,所以再提取一個刪除方法
private void removeNode(DataNode node){
node.pre.next = node.next;
node.next.pre = node.pre;
}
}
反轉連結串列
反轉連結串列也是面試中出現頻率比較高的,反轉連結串列是個單向連結串列,它的操作比雙向連結串列更簡單。
題目描述:
給你單連結串列的頭節點 head ,請你反轉連結串列,並返回反轉後的連結串列。
思路:定義兩個指標(變數),一個指向當前節點,一個指向前一個節點。每次反轉指標指向的兩個節點,然後指標往後移一位。
/**
* Definition for singly-linked list.
* public class ListNode {
* int val;
* ListNode next;
* ListNode() {}
* ListNode(int val) { this.val = val; }
* ListNode(int val, ListNode next) { this.val = val; this.next = next; }
* }
*/
class Solution {
public ListNode reverseList(ListNode head) {
if(head==null||head.next==null){
return head;
}
ListNode pre = null;
ListNode node = head;
while(node!=null){
ListNode temp = node.next;
//反轉node和pre
node.next = pre;
//node和pre往後移一位
pre = node;
node = temp;
}
//因為node最終會移到尾節點的next上,也就是null
//所以pre才是真正的尾節點,也就是反轉後的頭節點
return pre;
}
}
環形連結串列
題目描述:
出一個連結串列的head,判斷該連結串列是否是環形連結串列。如果是,就返回環形的入口。如果不是,就返回null。
如上圖,入口節點就是2。
思路1:要做出這個題不難,第一下就能想到:邊遍歷連結串列,邊往Hash表儲存節點,每次遍歷前判斷Hash表是否存在當前節點,如果存在,這個節點就是環形入口。如果遍歷完了,還沒有重複節點,就說明沒有環形。時間複雜度:O(n),空間複雜度:O(n)。
/**
* Definition for singly-linked list.
* class ListNode {
* int val;
* ListNode next;
* ListNode(int x) {
* val = x;
* next = null;
* }
* }
*/
public class Solution {
public ListNode detectCycle(ListNode head) {
HashSet<ListNode> set = new HashSet<>();
while(head!=null){
if(set.contains(head)){
return head;
}
set.add(head);
head = head.next;
}
return null;
}
}
思路2:優化連結串列的空間複雜度常用手段就是用指標,思路2就是定義兩個快慢指標,屬於數學邏輯範疇了。我們定義一個慢指標slow,一個快指標fast。slow一次移動一位,fast一次移動兩位。
-
如果fast移到了null節點,說明連結串列無環,直接返回null
-
fast和slow相遇
-
- 此時fast和slow一定在環形內,否則不可能相遇。我們假設head到環形入口(不含入口)的長度為x,環形長度為y。
- 然後假設slow走了s步,則fast走了2s步(fast是slow的兩倍速)
- fast和slow相遇時,fast在環內比slow多走了ny步(關鍵點,可以畫圖理解一下)
-
-
- 所以fast=2s=s+ny(s是slow走的步數,ny是fast比slow多走的步數)
- 所以s=ny
-
-
- 根據上面的推測s=ny,接著可以推算出,入口點就是x+ny。因為y是環形長度,n是正整數,所以ny實際上和y沒區別,無非就是多繞了幾圈。(關鍵點,也可以畫圖理解一下)。
- 此時slow已經走了ny步,所以再走x步就是入口點了。但是我們不知道x等於多少,那我們就讓一個指標從head再走一遍,一次走一步,和slow相遇點就是入口點。為了少建立一個變數,可以讓fast指標回到head節點重新走。
public class Solution {
public ListNode detectCycle(ListNode head) {
if(head==null||head.next==null){
return null;
}
ListNode slow = head;
ListNode fast = head;
while(true){
if(fast == null||fast.next == null){
return null;
}
slow = slow.next;
fast = fast.next.next;
//第一次相遇
if(fast==slow){
break;
}
}
fast = head;
while(fast!=slow){
fast = fast.next;
slow = slow.next;
}
return slow;
}
}
總結
連結串列必須要掌握它的刪除、新增、查詢三個基礎操作。連結串列的型別還分為:單向連結串列、迴圈連結串列(頭尾相連,或者帶環的)、雙向連結串列。只要掌握了雙向連結串列的基礎操作,其他連結串列都不在話下。
關於連結串列的演算法中,因為不能像陣列那樣,通過下標隨機訪問,所以一般會把節點存進Hash表。如果Hash表也不想存,想優化空間複雜度,一般的做法是定義指標。單向連結串列一般要定義雙指標,一個指向當前,一個指向前一個,如果是雙向連結串列,只用定義一個。但是在演算法題中,單純只考連結串列的題目比較少,很多都會帶一些其他知識點。比如連結串列的排序、連結串列的二分查詢等。只要熟練掌握連結串列的插入、刪除,只用考慮排序、查詢的邏輯就行了,跟陣列的排序、二分查詢沒啥區別。