單鏈表及常見演算法題
技術標籤:演算法與資料結構單鏈表連結串列資料結構java演算法
文章目錄
一、連結串列概述
1.1 連結串列介紹
連結串列( Linked List
),別名鏈式儲存結構或單鏈表,是一種常見的基礎資料結構,用於儲存邏輯關係為 “一對一” 的資料。連結串列是線性表的一種,但是它並不像順序表一樣是連續儲存在記憶體中的。連結串列的各個結點散佈在各個記憶體區域,在每一個結點中都存放下一個結點的地址。
單鏈表在記憶體中是如下儲存的:
其中:data 域存放的是本結點的資料,next 域存放的是下一個結點的地址。
通過上面的圖,我們可以得到連結串列的幾個特性:
-
連結串列是以結點的方式來儲存的,是鏈式儲存;
-
每一個結點包含 data 域、next 域。其中 next 域存放的是下一個結點的地址;
-
連結串列各個結點並不一定是連續(所謂連續指的是地址連續)儲存的;
-
連結串列分為帶頭結點的連結串列和沒有頭結點的連結串列。
1.2 程式碼描述結點
從上面的定義可以知道,連結串列中的每個結點中存放的是本結點的資料以及下一個結點的地址。所以連結串列中的結點可以定義如下:
public ListNode{
public int age; // 本結點的資訊
public String name;
public ListNode next; // 下一個結點的地址
}
二、單鏈表的基本操作
為了便於下面的演示,定義一個結點 HeroNode
如下,該結點可以理解為儲存的是一個水滸英雄的資訊:
class HeroNode {
private int no; // 本結點資料
private String name;
private String nickName;
public HeroNode next; // 指向下一個結點
public HeroNode(int no, String name, String nickName) {
this.no = no;
this.name = name;
this.nickName = nickName;
}
// getter and setter and toString ......
}
假設我現在有個需求,要求使用連結串列實現一個水滸英雄榜管理系統。這個系統的具體功能如下:
- 可以增加水滸英雄(直接在連結串列末尾追加)
- 可以根據水滸英雄的編號順序來插入英雄
- 可以根據水滸英雄的編號刪除英雄
- 可以根據編號更新水滸英雄的資訊
根據以上的 4 個需求,我們下面將分 4 步來逐一解決。
2.1 增加結點
增加結點,對應的是需求 1,也就是直接在連結串列的末尾追加一個結點。
思路:
-
首先我們需要建立一個頭結點,該結點的作用就是表示單鏈表的頭,如果沒有頭結點,我們是無法知道連結串列的首個結點是誰、在哪;
-
單鏈表是單向的,所以我們需要從頭結點開始遍歷整個連結串列直到末尾,然後增加結點到連結串列的末尾;
-
需要注意的是,頭結點是萬萬不能亂動的,所以我們最好將頭結點複製到一個臨時結點變數中,對臨時變數進行遍歷。
程式碼實現:
// 頭結點中是不儲存資料的,因此資料無所謂。只要儲存下一個結點的地址即可
private static final HeroNode headNode = new HeroNode(0, "", "");
/**
* @Description 1. 插入結點到連結串列中(直接在末尾追加)
*/
private static void insertNode(HeroNode node) {
// 首先需要拷貝一份頭結點
HeroNode tempNode = headNode;
// 插入結點到連結串列之中,需要首先遍歷連結串列
while (true) {
// 判斷有沒有到達連結串列末尾
if (tempNode.next == null) {
// 如果當前結點的下一個結點為 null 時,說明當前結點是最後一個結點
tempNode.next = node;
node.next = null;
System.out.println("++++++++++++ 插入結點成功!");
break;
}
// 沒有到達連結串列末尾,那就後移一個位置
tempNode = tempNode.next;
}
}
2.2 按順序插入結點
按順序插入結點,對應的是需求 2,也就是根據編號從小到大的順序將英雄插入到連結串列中。
思路:
- 首先還是要建立一個頭結點,然後拷貝一個頭結點作為輔助變數,使用輔助變數來遍歷整個連結串列;
- 如果出現某個結點(假設是 A 結點)的下一個結點(假設是 B 結點)的編號大於待插入結點的情況,那麼就首先將 B 結點記錄在待插入的結點中,然後再將這個待插入結點插入到 A 結點之後;
- 如果遍歷到了連結串列末尾還沒找到編號更大的,就直接插入到末尾即可。
程式碼實現:
/**
* @Description 2. 根據編號從小到大順序插入結點
*/
public static void insertNodeByOrder(HeroNode node) {
// 頭結點是程式碼的柱石,不能動,複製一份作為輔助變數
HeroNode tempNode = headNode;
// 遍歷連結串列
while (true) {
if (tempNode.next == null) { // 如果到了連結串列末尾,就直接插入
tempNode.next = node;
node.next = null;
break;
}
if (tempNode.next.getNo() > node.getNo()) { // 如果當前結點的下一個結點的編號大於要插入的結點,就插入
// 先讓要插入的結點指向下一個結點
node.next = tempNode.next;
// 然後再讓當前結點指向要插入的結點
tempNode.next = node;
break;
} else if (tempNode.next.getNo() == node.getNo()) { // 說明該結點已存在
System.out.println("!!!!!!!!!! 該結點已存在");
break;
}
// 上述條件都不滿足,就繼續往後迭代
tempNode = tempNode.next;
}
}
2.3 刪除結點
刪除結點,就是根據編號去查詢結點,然後把結點刪除掉。
思路:
- 首先還是要建立一個頭結點,然後拷貝一個頭結點作為輔助變數,使用輔助變數來遍歷整個連結串列;
- 如果 遍歷到某個結點的編號與要查詢的給定的編號相同,那麼就找到了結點;
- 如果遍歷結束還沒找到,說明該編號不在連結串列的結點中。
程式碼實現:
/**
* @Description 3. 從連結串列中刪除結點
*/
private static void deleteNode(int no) {
// 首先獲取到頭結點
HeroNode tempNode = headNode;
// 遍歷結點,找到要刪除的結點的前一個結點
while (true) {
// 判斷當前結點是不是最後一個結點,如果到了末尾還沒有找到目標結點,報錯警告
if (tempNode.next == null) {
System.out.println("!!!!!!!!!! 目標結點不存在!");
break;
}
if (tempNode.next.getNo() == no) {
// 說明找到了要刪除的結點
// 所謂刪除結點,其實就是把要刪除的結點的前一個結點的 next 指向要刪除的結點的後一個結點
tempNode.next = tempNode.next.next;
System.out.println("------------ 更新結點成功!");
break;
}
// 沒有找到目標結點,就繼續往後移一個位置
tempNode = tempNode.next;
}
}
2.4 更新結點
更新結點,對應的是需求 4,也就是根據編號去查詢結點,然後更新結點的資訊(不包括編號,因為編號是唯一標識)。
思路:
- 首先還是要建立一個頭結點,然後拷貝一個頭結點作為輔助變數,使用輔助變數來遍歷整個連結串列;
- 遍歷過程中,比對每個結點的編號與要更新的結點的編號是否一致,如果一致則說明找到了要更新的結點。接著將找到的結點中的資料替換成要更新的資料即可;
- 如果遍歷結束還沒找到對應編號的結點,說明連結串列中不存在這個結點;
程式碼:
/**
* @Description 4. 更新結點資訊
*/
private static void updateNode(HeroNode node) {
// 首先獲取頭結點
HeroNode tempNode = headNode;
// 遍歷結點,找到要修改的結點的前一個結點
while (true) {
// 判斷當前結點是不是最後一個結點,如果到了末尾還沒有找到目標結點,報錯警告
if (tempNode.next == null) {
System.out.println("!!!!!!!!!! 目標結點不存在!");
break;
}
if (tempNode.next.getNo() == node.getNo()) {
// 更新結點資訊
tempNode.next.setName(node.getName());
tempNode.next.setNickName(node.getNickName());
System.out.println("*********** 更新結點成功!");
break;
}
tempNode = tempNode.next;
}
}
三、單鏈表筆試題
3.1 查詢倒數第 K 個元素
3.1.1 問題描述
需求:
實現一種演算法,找出單向連結串列中倒數第 k 個結點,返回該結點的值。
示例:
輸入: 1->2->3->4->5 和 k = 2
輸出: 4
3.1.2 問題解決
思路:
- 首先獲取單向連結串列中結點的共個數 size;
- 遍歷到正數第(size - k + 1)個結點,就是我們要找的倒數第 k 個結點,返回結點值即可。
程式碼實現:
/**
* @Description 根據連結串列的頭結點和 k 到連結串列中查詢倒數第 k 個節點
*/
public static int getItem(HeroNode head, int k){
// 1. 首先獲取連結串列總長度
int size = getSize(head);
// 2. 連結串列總長度減去 k 個節點,得到要遍歷的節點個數
int length = size - k;
// 3. 獲取頭結點
HeroNode temp = head;
// 4. 遍歷到倒數第 k 個節點
for (int i=0; i<length+1; i++){
temp = temp.next;
}
return temp.getNo();
}
/**
* @Description 獲取連結串列中的結點的總個數
*/
public static int getSize(HeroNode head){
// 1. 獲取頭結點
HeroNode temp = head;
int length = 0;
// 2. 遍歷連結串列,獲取節點個數
while (true){
if (temp.next == null){ // 如果到了末尾
break;
}
length++;
temp = temp.next;
}
return length;
}
3.2 反轉單鏈表
3.2.1 問題描述
需求:
反轉一個單鏈表。
示例:
輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1->NULL
3.2.2 問題解決
思路:
解決這個問題的核心就是頭插法。
- 首先建立一個臨時頭結點用於記錄反轉過程中的連結串列;
- 遍歷單鏈表,每遍歷到一個有效結點,就讓該有效結點指向臨時頭結點指向的結點;
- 臨時頭結點再指向該有效結點,
- 原單鏈表遍歷結束之後,再讓原頭結點指向臨時頭結點指向的結點。
程式碼實現:
/**
* @Description 反轉連結串列
* @Param [head] 頭結點
*/
public static void reverseList(HeroNode head){
// 1. 判斷連結串列是否為空或者只有一個結點
if (head.next == null || head.next.next == null){
return;
}
// 2. 定義一個輔助的指標變數,幫助我們遍歷原來的連結串列
HeroNode cur = head.next;
HeroNode next = null; // 指向當前結點[cur] 的下一個結點
HeroNode reverHead = new HeroNode(0, "", ""); // 用於臨時存放反轉過程中的連結串列
while (cur != null){
next = cur.next; // 先把當前結點的下一個結點儲存下來
cur.next = reverHead.next; // 接著讓當前結點指向臨時反轉連結串列的第一個結點
reverHead.next = cur; // 讓當前結點作為臨時反轉連結串列的第一結點
cur = next; // 移動到原來連結串列的下一個結點,繼續遍歷
}
head.next = reverHead.next; // 讓原頭結點指向反轉後的連結串列
}
3.3 逆序列印單鏈表
3.3.1 問題描述
需求:
逆序列印一個單鏈表。
示例:
輸入: 1->2->3->4->5->NULL
輸出: 5->4->3->2->1
3.3.2 問題解決
思路:
這個問題的解決有多種方案,下面將對各種方案分別進行介紹。
-
方案一:使用棧。根據棧的先進後出特點,可以先正向遍歷將連結串列中的結點入棧,然後讓結點出棧,在出棧的時候列印即可。
-
方案二:使用遞迴。首先判斷當前結點是否為最後一個結點,如果不是則遞迴呼叫,然後在判斷語句之外列印每個結點。
-
方案三:直接反轉單鏈表。這個方案就是 3.2 的方案,先將連結串列反轉,然後遍歷列印。但是這樣會破壞原連結串列的結構,不推薦使用。
程式碼實現:
-
方案一:使用棧
/** * @Description 方案一:使用棧來逆序列印結點 * @Param [head] 頭結點 */ public static void printReverse_2(HeroNode head){ Stack<HeroNode> nodeStack = new Stack<>(); // 去除頭結點 HeroNode cur = head.next; // 結點入棧 while (cur != null){ nodeStack.push(cur); cur = cur.next; } // 結點出棧並列印 while (nodeStack.size() > 0){ System.out.println(nodeStack.pop()); } }
-
方案二:使用遞迴
/** * @Description 方案二:使用遞迴列印結點 * @Param [node] 連結串列的第一個結點,即 head.next */ public static void printReverse_3(HeroNode node){ // 這裡一定要先遞迴呼叫再列印 if (node.next != null){ printReverse_3(node.next); } System.out.println(node); }
3.4 合併兩個連結串列
3.4.1 問題描述
需求:
合併兩個有序連結串列,並且要求合併後的連結串列依然是一個有序連結串列。
示例:
輸入:1->4->7->NULL
2->3->5->NULL
輸出:1->2->3->4->5->7->NULL
3.4.2 問題解決
思路:
這個問題的核心思想其實就是上面的 2.2 按順序插入結點到連結串列中。
- 給定兩個連結串列 A、B,假設將連結串列 A 合併到連結串列 B 中;
- 遍歷連結串列 A 的所有結點,將每個結點按照順序插入到連結串列 B 中即可。
程式碼:
/**
* @Description 將 head_1 指向的連結串列按照結點編號從小到大順序合併到 head_2 指向的連結串列中
* @Param [head_1, head_2]
* @return 合併後的有序連結串列
*/
public static HeroNode collectList(HeroNode head_1, HeroNode head_2){
HeroNode cur = head_1.next;
HeroNode next;
// 遍歷第一個連結串列,將每個結點插入到第二個連結串列中
while(cur != null){
next = cur.next;
insertNode(cur, head_2);
cur = next;
}
return head_2;
}
/**
* @Description 按照編號從小到大的順序插入一個結點到 head 指向的連結串列中
* @Param [node, head]
* @return void
*/
public static void insertNode(HeroNode node, HeroNode head){
HeroNode temp = head;
while (true){
if (temp.next == null){
// 說明遍歷到了連結串列的末尾,直接追加即可
temp.next = node;
// 需要注意的是,由於插入的結點可能還記錄著其它結點,所以必須要把這個結點記錄的地址清空
// 這樣才能順利記錄下一個結點
node.next = null;
break;
}
if (temp.next.getNo() > node.getNo()){
// 如果該結點小於要插入的連結串列的結點,那麼就把這個結點插入到第一個小於的結點的前一個
node.next = temp.next;
temp.next = node;
break;
}
temp = temp.next;
}
}