JavaScript 資料結構與演算法3(連結串列)
學習資料結構的 git 程式碼地址: https://gitee.com/zhangning187/js-data-structure-study
1、連結串列
本章學習如何實現和使用連結串列這種動態的資料結構。在這種結構裡面可以從中隨意新增或移除項,可以按需進行擴容。
該章節內容包括一下內容:
- 連結串列資料結構
- 向連結串列新增元素
- 從連結串列移除元素
- 使用 LinkedList 類
- 雙向連結串列
- 迴圈連結串列
- 排序連結串列
- 通過連結串列實現棧
1.1 認識連結串列結構
連結串列和陣列一樣,可以用於儲存一系列的元素,但是連結串列和陣列的實現機制完全不同。
陣列的特點:
要儲存多個元素,陣列(列表)是最常用的資料結構。
幾乎每一種程式語言都實現了陣列結構。
缺點:
陣列的建立需要申請一段連續的記憶體空間,並且大小是固定的,噹噹前陣列不能滿足需求時需要擴容(擴容很耗效能)。
而且在陣列的開頭或中間位置插入資料的成本很高,需要進行大量元素的位移。
要儲存多個元素,另外一個選擇就是連結串列。
不同於陣列,連結串列中的元素在記憶體中不必是連續的空間。
連結串列的每個元素由一個儲存元素本身的節點和一個指向下一個元素的引用(也叫指標或連線)組成。
(最後一個節點的next指向 null,如果一個節點都沒有,head 直接指向null就可以了,head 是指向連結串列裡面所有節點的第一個節點)
相對於陣列,連結串列的一些優點
- 記憶體空間不是必須連續的,可以充分利用計算機的記憶體,實現靈活的記憶體動態管理。
- 連結串列不必在建立時就確定大小,並且大小可以無限的延伸下去。
- 連結串列在插入和刪除資料時,時間複雜度可以達到O(1).(大歐表示法)相對陣列高效很多。
相對於陣列,連結串列的一些缺點:
- 連結串列訪問任何一個位置的元素時,都需要從頭開始訪問。(無法跳過第一個元素訪問任何一個元素)
- 無法通過下標直接訪問元素,需要從頭一個個訪問,直到找到對應的元素。(在訪問的元素時候相對於陣列效能較低)
頻繁的刪除或新增元素選擇連結串列。通過下標查詢元素選擇數組合適。
連結串列相對於傳統陣列的一個好處在於,新增或移除元素的時候不需要移動其他元素。然而,連結串列需要使用指標,因此實現連結串列時需要額外注意。在陣列中,可以直接訪問任何位置的任何元素,而要想訪問連結串列中的一個元素,則需要從起點(表頭)開始迭代連結串列直到找到所需元素。
連結串列類似於火車:有一個火車頭,火車頭會連線一個節點,節點上有乘客(類似於資料),並且這個節點會連線下一個節點,以此類推。
1.2封裝連結串列結構
建立一個連結串列類:
首先連結串列裡面有個head屬性,指向連結串列裡所有節點的第一個節點,每個節點有兩部分組成,一個是data,一個是next指向下一個節點的引用。
// 封裝連結串列節點類,方便複用 export class Node { constructor(element) { // element: 當前資料 this.element = element; // next: 下一個節點的引用 this.next = undefined; } }
// 比較兩個值是否相等的預設方法 export function defaultEquals(a, b) { return a === b; }
// 連結串列類 import {Node} from '../models/index.js'; import {defaultEquals} from '../util.js'; export default class LinkedList { constructor(equalsFn = defaultEquals) { // length 記錄連結串列長度 this.length = 0; // head 預設執行undefined,即沒有一個元素 this.head = undefined; // 用於判斷 元素 是否相等,(可以自定義) // 在要實現 indexOf 方法的時候,要比較連結串列中的元素是否相等,需要使用一個內部呼叫的函式,equalsFn。 // 可以傳入一個自定義函式用於比較兩個 js 物件或值是否相等。 this.equalsFn = equalsFn; } }
1.3連結串列的常見操作(增刪改查)
- append(element): 向列表尾部新增一個新的項
- insert(position, element): 向列表特定位置插入一個新的項
- get(position): 獲取對應位置的元素
- indexOf(element): 返回元素在列表中的索引。如果列表中沒有該元素返回 -1
- update(position, data): 修改某個位置的元素
- removeAt(position): 刪除列表的特定位置項
- remove(element): 從列表中移除一項
- isEmpty():連結串列中沒有任何元素返回true,否則返回 false
- size(): 返回連結串列包含的元素個數。與陣列的length 屬性類似
- toString(): 由於列表項使用了 Node 類,就需要重寫繼承自 JavaScript 物件預設的 toString 方法,讓其只輸出元素的值
以上的操作和陣列非常相似,因為連結串列本身就是可以代替陣列的結構。
1.3.1 push(element) 向列表尾部新增一個新的項
// 追加方法 push(element) { // 建立新的資料節點 const newNode = new Node(element); // 連結串列為空,新增第一個元素,連結串列不為空,追加元素 if (this.length === 0) { this.head = newNode; } else { // 找到連結串列中的最後一個節點,讓最後一個節點的 next 等於 newNode let current = this.head; while (current.next) {// 如果next為空表示current是連結串列的最後一個元素 current = current.next; } // 這時 current 為連結串列最後一個節點,next 指向新的節點 current.next = newNode; } this.length++; };
1.3.2 insert(element, index): 向列表特定位置插入一個新的項
// 指定位置插入 insert(element, index) { // 檢查是否越界 if (index >= 0 && index <= this.length) { const newNode = new Node(element); let current = this.head; // 插入元素分為兩種情況,移除第一個元素,移除其他元素 if (index == 0) { newNode.next = current; this.head = newNode; } else { let lastNode; // 得到當前需要移除的節點 current,上一個節點 lastNode for (let i = 0; i < index; i++) { lastNode = current; current = current.next; } newNode.next = current; lastNode.next = newNode; } this.length++; // 返回刪除的節點 return true; } return undefined; };
1.3.3 get(index): 獲取對應位置的元素
// 獲取對應位置的元素 get(index) { // 檢查是否越界 if (index >= 0 && index <= this.length) { let current = this.head; for (let i = 0; i < index && current != null; i++) { current = current.next; } return current; } return undefined; };
1.3.4 indexOf(element): 返回元素在列表中的索引。如果列表中沒有該元素返回 -1
// indexOf(element): 返回元素在列表中的索引。如果列表中沒有該元素返回 -1 indexOf(element) { let current = this.head; for (let i = 0; i < this.length; i++) { if (this.equalsFn(current.element, element)) { return i; } else { current = current.next; } } return -1; };
1.3.5 update(index, element): 修改某個位置的元素
// update(index, element): 修改某個位置的元素 update(index, element) { if (index >= 0 && index <= this.length) { let current = this.head; for (let i = 0; i < index; i++) { current = current.next; } current.element = element; return true; } return false; };
1.3.6 toString() 轉換為字串
// 轉換字串 tostring() { let current = this.head; let listString = ''; // 迴圈每一個節點 while (current) { listString += current.element + ' '; // 每次指向下一個 current = current.next; } return listString; };
1.3.7 removeAt(index) 移除指定位置項
// 移除元素removeAt() removeAt(index) { // 檢查是否越界 if (index >= 0 && index <= this.length) { let current = this.head; // 移除元素分為兩種情況,移除第一個元素,移除其他元素 if (index == 0) { this.head = undefined; } else { let lastNode; // 得到當前需要移除的節點 current,上一個節點 lastNode for (let i = 0; i < index; i++) { lastNode = current; current = current.next; } lastNode.next = current.next; this.length--; // 返回刪除的節點 return current.element; } } return undefined; };
1.3.8 remove(element): 從列表中移除一項
// 移除元素remove() remove(element) { let current = this.head; let lastNode; for (let i = 0; i < this.length; i++) { lastNode = current; current = current.next; if (current && current.element == element) { lastNode.next = current.next; this.length--; return true; } } return false; };
1.3.9 isEmpty():連結串列中沒有任何元素返回true,否則返回 false
// isEmpty():連結串列中沒有任何元素返回true,否則返回 false isEmpty() { return this.head ? false : true; };
1.4 雙向連結串列
雙向連結串列與普通連結串列的區別:
在連結串列中一個節點只有鏈向下一個節點的連結;
在雙向連結串列中,連結是雙向的:一個鏈向下一個元素,另一個鏈向前一個元素
建立雙向連結串列類
import {Node} from '../models/index.js'; import LinkedList from '../1.封裝連結串列/LinkedList'; import {defaultEquals} from '../util'; class DoublyNode extends Node { constructor(element, next, prev) { super(element, next); // prev: 指向前一個節點 this.prev = prev; } } class DoublyLinkedList extends LinkedList { constructor(equalsFn = defaultEquals) { // 呼叫 LinkedList 的建構函式,它會初始化 equalsFn、length、head 屬性 super(equalsFn); // 儲存對連結串列最後一個元素的引用 this.tail = undefined; } }
雙向連結串列提供了兩種迭代的方法:從頭到尾,或者從尾到頭。還可以訪問一個特定節點的下一個或前一個元素。
1.4.1 實現雙向連結串列的新增方法 push(element)
// 追加方法 push(element) { const newNode = new DoublyNode(element); let current = this.tail; // 在頭部插入 if (this.length == 0) { this.head = newNode; this.tail = newNode; } else { // 當前節點 current.next = newNode; newNode.prev = current; this.tail = newNode; } this.length++; return true; };
1.4.2 實現指定位置插入方法 insert(index, element)
// 在指定位置插入新元素,單向連結串列只要控制一個 next 指標,而雙向連結串列則要同時控制 next 和 prev 這兩個指標 // 重寫 insert 方法,表示我們會使用一個和 LinkedList 類中的方法行為不同的方法 insert(element, index) { if (index >= 0 && index <= this.length) { const newNode = new DoublyNode(element); let current = this.head; // 在頭部插入 if (index == 0) { // 當沒有一條資料的時候 if (!this.head) { this.head = newNode; this.tail = newNode; } else { newNode.next = this.head; current.prev = newNode; this.head = newNode; } // 在尾部插入 } else if (index == this.length) { current = this.tail; current.next = newNode; newNode.prev = current; this.tail = newNode; } else { // 獲取 index 位置的前一個元素 const previous = this.get(index - 1); // 當前節點 current = previous.next; // 當前節點的 prev 為要插入的節點 current.prev = newNode; // 要插入的節點的 next 為 current 節點 newNode.next = current; newNode.prev = previous; previous.next = newNode; } this.length++; return true; } return false; };
1.4.3 實現移除指定位置元素
// 從任意位置移除元素 removeAt(index) { // 檢查是否越界 if (index >= 0 && index < this.length) { // 移除元素分為兩種情況,移除第一個元素,移除其他元素 let current = this.head; if (index == 0) { if (this.length == 1) { this.tail = undefined; } else { current.prev = undefined; } this.head = current.next; } else if (index === this.length - 1) {// 判斷是否是最後一個元素 current = this.tail; this.tail = current.prev; this.tail.next = undefined; } else { current = this.get(index); const previous = current.prev; previous.next = current.next; current.next.prev = previous; } this.length--; return current.element; } return undefined; };
1.5 迴圈連結串列
迴圈連結串列可以像連結串列一樣只有單項引用,也可以像雙向連結串列一樣有雙向引用。迴圈連結串列與連結串列之間唯一的區別在於,最後一個元素指向下一個元素的指標不是 undefined,而是第一個元素(head)。
雙向迴圈連結串列指向head元素的 tail.next 和 指向 tail 元素的 head.prev。
1.5.1 封裝迴圈連結串列結構
import LinkedList from '../1.封裝連結串列/LinkedList.js'; import {Node} from '../models/index.js'; import {defaultEquals} from '../util.js'; // CircularLinkedList 類不需要任何額外的屬性,直接擴充套件 LinkedList 類並覆蓋需要改寫的方法即可。 class CircularLinkedList extends LinkedList { constructor(equalsFn = defaultEquals) { super(equalsFn); } }
1.5.2 新增節點方法 push(element)
// 新增節點 push(element) { const newNode = new Node(element); // 先判斷是否存在節點 if (!this.head) { this.head = newNode; newNode.next = newNode; } else { let current = this.head; // 找到最後一個元素 while (current.next != this.head) { current = current.next; } current.next = newNode; newNode.next = this.head; } this.length++; return true; }
1.5.3 指定位置插入新元素 insert(index, element)
// 在指定位置插入新元素 // 這裡插入邏輯和普通連結串列插入邏輯是一樣的,不同之處在於我們需要將迴圈連結串列尾部節點的 next 引用指向頭部節點。 insert(element, index) { // 檢查是否越界 if (index >= 0 && index <= this.length) { const newNode = new Node(element); if (index == 0) {// 判斷頭部插入 if (this.length == 0) {// 沒有資料的時候插入 this.head = newNode; newNode.next = newNode; } else { newNode.next = this.head; this.head = newNode; } } else if (index == this.length) {// 判斷尾部插入 this.push(element); } else { // 得到插入的上一個節點 const lastNode = this.get(index - 1); newNode.next = lastNode.next; lastNode.next = newNode; } this.length++; return true; } return false; }
1.5.4 從指定位置移除元素 removeAt(index)
// 從指定位置移除元素 removeAt(index) { if (index >= 0 && index < this.length) { let current = this.head; if (index == 0) { if (this.length == 1) { this.head = undefined; } else { // 得到最後一個元素 const endNode = this.get(this.length - 1); this.head = current.next; endNode.next = current.next; } } else { // 得到需要刪除的元素的前一個元素 const previous = this.get(index - 1); previous.next = previous.next.next; } this.length--; } return undefined; }
1.6 有序連結串列
保持元素有序的連結串列結構。除了使用連結串列演算法之外,還可以將元素插入到正確的位置來保證連結串列的有序性。
1.6.1 有序連結串列結構封裝
/* * @author: zhangning * @date: 2022/2/11 17:55 * @Description: 封裝有序連結串列 **/ import LinkedList from '../1.封裝連結串列/LinkedList.js'; import {defaultEquals} from '../util.js'; // 比較狀態的返回值,為了程式碼好看通過宣告常量表示兩個值 const Compare = { LESS_THAN: -1, BIGGER_THEN: 1 }; // 比較資料大小 function defaultCompare(a, b) { if (a === b) { return 0; } return a < b ? Compare.LESS_THAN : Compare.BIGGER_THEN; } // 宣告有序列表類,繼承 LinkedList 類中所有的屬性和方法, // 這個類比較特別,需要一個用來比較 元素的函式 compareFn 預設使用 defaultCompare,支援自定義 class SortedLinkedList extends LinkedList { constructor(equalsFn = defaultEquals, compareFn = defaultCompare) { super(equalsFn); this.compareFn = compareFn; } // 覆蓋 insert 方法 insert(element, index = 0) { debugger if (this.isEmpty()) { return super.push(element); } // 得到要插入的位置 const pos = this.getIndexNextSortedElement(element); return super.insert(element, pos); } // 獲取插入的位置 getIndexNextSortedElement(element) { debugger let current = this.head; let i = 0; for (; i < this.length; i++) { const comp = this.compareFn(element, current.element); if (comp === Compare.LESS_THAN) { return i; } current = current.next; } return i; } } const sList = new SortedLinkedList(); sList.insert(8); sList.insert(2); sList.insert(1); sList.insert(6); sList.insert(3); console.log(sList);
1.7 建立 StackLinkedList 棧資料結構
除了上面的資料結構,還可以使用 LinkedList 類及其變種作為內部的資料結構來建立其他資料結構,如:棧、佇列、雙向佇列...等
1.7.1 建立棧資料結構(先進後出,後進先出)
/* * @author: zhangning * @date: 2022/2/11 19:42 * @Description: 建立棧資料結構(先進後出,後進先出) **/ import DoublyLinkedList from '../2.雙向連結串列/DoublyLinkedList.js'; class StackLinkedList { constructor() { // 使用雙向連結串列進行儲存資料,對於棧來說,會像連結串列尾部新增元素,也會從連結串列尾部移除元素,雙向連結串列類中有最後一個元素 tail 的引用,不需要迭代整個連結串列就能夠獲取到它。 // 雙向連結串列可以直接獲取頭尾的元素,減少過程消耗,它的時間複雜度和原始的 Stack 實現相同為O(1). // 當然也可以對 LinkedList 類進行優化,儲存一個指向尾部元素的引用 this.item = new DoublyLinkedList(); } // 新增元素 push(element) { this.item.push(element); } // 移除元素 pop() { if (this.item.isEmpty()) { return undefined; } return this.item.removeAt(this.item.length - 1); } // 獲取最頂層元素的值 peek() { if (this.item.isEmpty()) { return undefined; } return this.item.get(this.item.length - 1).element; } // 獲取棧的長度 size() { return this.item.length; } } const stackList = new StackLinkedList(); stackList.push(100); stackList.push(111); stackList.pop(); stackList.push(222); console.log(stackList.peek()); stackList.push(333); console.log(stackList); console.log(stackList.size());