從不浪費時間的人,沒有工夫抱怨時間不夠。 —— 傑弗遜node
線性表是由 n 個數據元素組成的有限序列,也是最基本、最簡單、最經常使用的一種數據結構。git
做者簡介:五月君,Nodejs Developer,熱愛技術、喜歡分享的 90 後青年,公衆號「Nodejs技術棧」,Github 開源項目 www.nodejs.redgithub
本篇文章歷時一週多,差很少花費了兩個週末的時間,在書寫的過程當中更多的仍是在思考每一種線性表的算法實現,鏈表的指針域部分對於不理解指針或者對象引用的童鞋,在閱讀代碼的時候可能會濛濛的,本篇文章代碼部分採用的 JavaScript 編程語言,可是實現思想是相通的,若是你用 Java、Python 等也可作參考,如文章有理解錯誤之處歡迎在下方評論區指正。算法
根據線性表的定義,可得出幾個關鍵詞:n 個數據元素、有限序列,也就是說它是有長度限制的且元素之間是有序的,在多個元素之間,第一個元素無前驅,最後一個元素無後繼,中間元素有且只有一個前驅和後繼。編程
舉一個與你們都息息相關的十二生肖例子,以「子(鼠)」 開頭,「亥(豬)」結尾,其中間的每一個生肖也都有其前驅和後繼,圖例以下所示:數組
下面再介紹一個複雜的線性表,其一個元素由多個數據項構成,例如,咱們的班級名單,含學生的學號、姓名、年齡、性別等信息,圖例以下所示:bash
線性表兩種存儲結構數據結構
線性表有兩種存儲結構,一種爲順序結構存儲,稱爲順序表;另外一種爲鏈式形式存儲,稱爲鏈表,鏈表根據指針域的不一樣,鏈表分爲單向鏈表、雙向鏈表、循環鏈表等。詳細的內容會在後面展開講解。編程語言
順序表是在計算機內存中以數組的形式保存的線性表,是指用一組地址連續的存儲單元依次存儲數據元素的線性結構。函數
在線性表裏順序表相對更容易些,所以也先從順序表講起,經過實現編碼的方式帶着你們從零開始實現一個順序表,網上不少教程大多都是以 C 語言爲例子,其實現思想都是相通的,這裏採用 JavaScript 編碼實現。
實現步驟
初始化順序表空間
在構造函數的 constructor 裏進行聲明,傳入 capacity 初始化順序表空間同時初始化順序表的元素長度(length)爲 0。
/** * * @param { Number } capacity 棧空間容量 */
constructor(capacity) {
if (!capacity) {
throw new Error('The capacity field is required!');
}
this.capacity = capacity;
this.list = new Array(capacity);
this.length = 0; // 初始化順序表元素長度
}
複製代碼
順序表是否爲空檢查
定義 isEmpty() 方法返回順序表是否爲空,根據 length 順序表元素進行判斷。
isEmpty() {
return this.length === 0 ? true : false;
}
複製代碼
順序表是否溢出檢查
定義 isOverflow() 方法返回順序表空間是否溢出,根據順序表元素長度和初始化的空間容量進行判斷。
isOverflow() {
return this.length === this.capacity;
}
複製代碼
查找指定位置元素
返回順序表中第 i 個數據元素的值
getElement(i) {
if (i < 0 || i > this.length) {
return false;
}
return this.list[i];
}
複製代碼
查找元素的第一個位置索引
返回順序表中第 1 個與 e 知足關係的元素,存在則返回其索引值;不存在,則返回值爲 -1
locateElement(e) {
for (let i=0; i<this.length; i++) {
if (this.list[i] === e) {
return i;
}
}
return -1;
}
複製代碼
在順序表中返回指定元素的前驅
這裏就用到了上面定義的 locateElement 函數,先找到元素對應的索引位置,若是前驅就取前一個位置,後繼就取後一個位置,在這以前先校驗當前元素的索引位置是否存在合法。
priorElement(e) {
const i = this.locateElement(e);
if (i === -1) {
return false;
}
if (i === 0) { // 沒有前驅
return false;
}
return this.list[i - 1]; // 返回前驅(即前一個元素)
}
複製代碼
在順序表中返回指定元素的後繼
nextElement(e) {
const i = this.locateElement(e);
if (i === -1) {
return false;
}
if (i === this.length - 1) { // 爲最後一個元素,沒有後繼
return false;
}
return this.list[i + 1]; // 返回後繼(即後 一個元素)
}
複製代碼
插入元素
在順序表中第 i 個位置以前插入新的數據元素 e,在插入以前先進行元素位置後移,插入以後順序表元素的長度要加 1。
舉個例子,咱們去火車站取票,恰逢人多你們都在排隊,忽然來一個美女或者帥哥對你說個人車次立刻要開車了,你可能贊成了,此時你的位置及你後面的童鞋就要後移一位了,也許你會聽到一些聲音,怎麼回事呀?怎麼插隊了呀,其實後面的人有的也不清楚什麼緣由 「233」,看一個圖
算法實現以下:
listInsert(i, e) {
if (i < 0 || i > this.length) {
return false; // 不合法的 i 值
}
for (let k=this.length; k>=i; k--) { // 元素位置後移 1 位
this.list[k + 1] = this.list[k];
}
this.list[i] = e;
this.length++;
return true;
}
複製代碼
刪除元素
刪除順序表的第 i 個數據元素,並返回其值,與插入相反,須要將刪除位置以後的元素進行前移,最後將順序表元素長度減 1。
一樣以火車站取票的例子說明,若是你們都正在排隊取票,忽然你前面一個妹子有急事臨時走了,那麼你及你後面的童鞋就要前進一步,圖例以下所示:
算法實現以下:
listDelete(i) {
if (i < 0 || i >= this.length) {
return false; // 不合法的 i 值
}
const e = this.list[i];
for (let j=i+1; j<this.length; j++) { // 元素位置前移 1 位
this.list[j - 1] = this.list[j];
}
this.length--;
return e;
}
複製代碼
清除順序表元素
這裏有幾種實現,你也能夠把順序表的空間進行初始化,或者把 length 棧位置設爲 0 也可。
clear() {
this.length = 0;
}
複製代碼
順序表銷燬
在一些高級語言中都會有垃圾回收機制,例如 JS 中只要當前對象再也不持有引用,下次垃圾回收來臨時將會被回收。不清楚的能夠看看我以前寫的 Node.js 內存管理和 V8 垃圾回收機制
destroy() {
this.list = null;
}
複製代碼
順序表元素遍歷
定義 traversing() 方法對順序表的元素進行遍歷輸出。
traversing(isBottom = false){
const arr = [];
for (let i=0; i < this.length; i++) {
arr.push(this.list[i])
}
console.log(arr.join('|'));
}
複製代碼
作一些測試
作下測試分別看下插入、刪除、遍歷等操做,其它的功能你們在練習的過程當中可自行實踐。
const [e1, e2, e3, e4, e5] = [3, 6, 1, 8, 7];
const list = new SequenceTable(10);
list.listInsert(0, e1);
list.listInsert(1, e2);
list.listInsert(2, e3);
list.listInsert(3, e4);
list.listInsert(1, e5);
list.traversing(); // 3|7|6|1|8
console.log(list.priorElement(3) ? '有前驅' : '無前驅'); // 無前驅
console.log(list.priorElement(6) ? '有前驅' : '無前驅'); // 有前驅
console.log(list.nextElement(3) ? '有後繼' : '無後繼'); // 有後繼
console.log(list.nextElement(8) ? '有後繼' : '無後繼'); // 無後繼
list.listDelete(0); // 3
list.traversing(); // 7|6|1|8
複製代碼
順序表的運行機制源碼地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/sequence-table.js
複製代碼
順序表優缺點總結
插入、刪除元素若是是在最後一個位置時間複雜度爲 O(1),若是是在第一個(或其它非最後一個)位置,此時時間複雜度爲 O(1),就要移動全部的元素向後或向前,時間複雜度爲 O(n),當順序表的長度越大,插入和刪除操做可能就須要大量的移動操做。
對於存取操做,能夠快速存取順序表中任意位置元素,時間複雜度爲 O(1)。
鏈表(Linked list)是一種常見的基礎數據結構,是一種線性表,可是並不會按線性的順序存儲數據,而是在每個節點裏存到下一個節點的指針(Pointer)。因爲沒必要須按順序存儲,鏈表在插入的時候能夠達到O(1)的複雜度,比另外一種線性表順序錶快得多,可是鏈表查找一個節點或者訪問特定編號的節點則須要O(n)的時間,而順序表相應的時間複雜度分別是O(logn)和O(1)。
使用鏈表結構能夠克服數組鏈表須要預先知道數據大小的缺點,鏈表結構能夠充分利用計算機內存空間,實現靈活的內存動態管理。可是鏈表失去了數組隨機讀取的優勢,同時鏈表因爲增長告終點的指針域,空間開銷比較大。
鏈表中最簡單的一種是單向鏈表,它包含兩個域,一個信息域和一個指針域。這個連接指向列表中的下一個節點,而最後一個節點則指向一個空值,圖例以下:
除了單向鏈表以外還有雙向鏈表、循環鏈表,在學習這些以前先從單向鏈表開始,所以,這裏會完整講解單向鏈表的實現,其它的幾種後續都會在這個基礎之上進行改造。
單向鏈表實現步驟
初始化鏈表
在構造函數的 constructor 裏進行聲明,無需傳入參數,分別對如下幾個屬性和方法作了聲明:
當咱們實例化一個 SingleList 對象時 head 指向爲 null 及 length 默認等於 0,代碼示例以下:
class SingleList {
constructor() {
this.node = function(element) {
return {
element,
next: null,
}
};
this.length = 0;
this.head = null;
}
}
複製代碼
鏈表是否爲空檢查
定義 isEmpty() 方法返回鏈表是否爲空,根據鏈表的 length 進行判斷。
isEmpty() {
return this.length === 0 ? true : false;
}
複製代碼
返回鏈表長度
一樣使用鏈表的 length 便可
length() {
return this.length;
}
複製代碼
鏈表尾部插入元素
鏈表 SingleList 尾部增長元素,須要考慮兩種狀況:一種是鏈表(head)爲空,直接賦值添加第一個元素,另外一種狀況就是鏈表不爲空,找到鏈表最後一個節點在其尾部增長新的節點(node)便可。
第一種狀況,假設咱們插入一個元素 1,此時因爲鏈表爲空,就會走到(行 {2})代碼處,示意圖以下:
第二種狀況,假設咱們再插入一個元素 2,此時鏈表頭部 head 指向不爲空,走到(行 {3})代碼處,經過 while 循環直到找到最後一個節點,也就是當 current.next = null 時說明已經達到鏈表尾部了,接下來咱們要作的就是將 current.next 指向想要添加到鏈表的節點,示意圖以下:
算法實現以下:
insertTail(e) {
let node = this.node(e); // {1}
let current;
if (this.head === null) { // 列表中尚未元素 {2}
this.head = node;
} else { // {3}
current = this.head;
while (current.next) { // 下個節點存在
current = current.next;
}
current.next = node;
}
this.length++;
}
複製代碼
鏈表指定位置插入元素
實現鏈表的 insert 方法,在任意位置插入數據,一樣分爲兩種狀況,如下一一進行介紹。
若是是鏈表的第一個位置,很簡單看代碼塊(行 {1})處,將 node.next 設置爲 current(鏈表中的第一個元素),此時的 node 就是咱們想要的值,接下來將 node 的引用改成 head(node、head 這兩個變量此時在堆內存中的地址是相同的),示意圖以下所示:
若是要插入的元素不是鏈表第一個位置,經過 for 循環,從鏈表的第一個位置開始循環,定位到要插入的目標位置,for 循環中的變量 previous(行 {3})是對想要插入新元素位置以前的一個對象引用,current(行 {4})是對想要插入新元素位置以後的一個對象引用,清楚這個關係以後開始連接,咱們本次要插入的節點 node.next 與 current(行 {5})進行連接,以後 previous.next 指向 node(行 {6})。
算法實現以下:
/** * 在任意位置插入元素 * @param { Number } i 插入的元素位置 * @param { * } e 插入的元素 */
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) { // {1}
node.next = current;
this.head = node;
} else { // {2}
for (let k=0; k<i; k++) {
previous = current; // {3}
current = current.next; // 保存當前節點的下一個節點 {4}
}
node.next = current; // {5}
previous.next = node; // 注意,這塊涉及到對象的引用關係 {6}
}
this.length++;
return true;
}
複製代碼
移除指定位置的元素
定義 delete(i) 方法實現移除任意位置的元素,一樣也有兩種狀況,第一種就是移除第一個元素(行 {1})處,第二種就是移除第一個元素之外的任一元素,經過 for 循環,從鏈表的第一個位置開始循環,定位到要刪除的目標位置,for 循環中的變量 previous(行 {2})是對想要刪除元素位置以前的一個對象引用,current(行 {3})是對想要刪除元素位置以後的一個對象引用,要從列表中移除元素,須要作的就是將 previous.next 與 current.next 進行連接,那麼當前元素會被丟棄於計算機內存中,等待垃圾回收器回收處理。
關於內存管理和垃圾回收機制的知識可參考文章 Node.js 內存管理和 V8 垃圾回收機制
經過一張圖,來看下刪除一個元素的過程:
算法實現以下:
delete(i) {
// 要刪除的元素位置不能超過鏈表的最後一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) { // {1}
this.head = current.next;
} else {
for (let k=0; k<i; k++) {
previous = current; // {2}
current = current.next; // {3}
}
previous.next = current.next;
}
this.length--;
return current.element;
}
複製代碼
獲取指定位置元素
定義 getElement(i) 方法獲取指定位置元素,相似於 delete 方法可作參考,在鎖定位置目標後,返回當前的元素便可 previous.element。
getElement(i) {
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
for (let k=0; k<=i; k++) {
previous = current
current = current.next;
}
return previous.element;
}
複製代碼
查找元素的第一個位置索引
返回鏈表中第 1 個與 e 知足關係的元素,存在則返回其索引值;不存在,則返回值爲 -1
locateElement(e) {
let current = this.head;
let index = 0;
while (current.next) { // 下個節點存在
if (index === 0) {
if (current.element === e) {
return index;
}
}
current = current.next;
index++;
if (current.element === e) {
return index;
}
}
return -1;
}
複製代碼
在鏈表中返回指定元素的前驅
若是是第一個元素,是沒有前驅的直接返回 false,不然的話,須要遍歷鏈表,定位到目標元素返回其前驅即當前元素的上一個元素,若是在鏈表中沒有找到,則返回 false。
priorElement(e) {
let current = this.head;
let previous;
if (current.element === e) { // 第 0 個節點
return false; // 沒有前驅
} else {
while (current.next) { // 下個節點存在
previous = current;
current = current.next;
if (current.element === e) {
return previous.element;
}
}
}
return false;
}
複製代碼
在鏈表中返回指定元素的後繼
nextElement(e) {
let current = this.head;
while (current.next) { // 下個節點存在
if (current.element === e) {
return current.next.element;
}
current = current.next;
}
return false;
}
複製代碼
鏈表元素遍歷
定義 traversing() 方法對鏈表的元素進行遍歷輸出,主要是將 elment 轉爲字符串拼接輸出。
traversing(){
//console.log(JSON.stringify(this.head));
let current = this.head,
string = '';
while (current) {
string += current.element + ' ';
current = current.next;
}
console.log(string);
return string;
}
複製代碼
單向鏈表與順序表優缺點比較
單向鏈表源碼地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/single-list.js
複製代碼
雙向鏈表也叫雙鏈表。與單向鏈表的區別是雙向鏈表中不只有指向後一個節點的指針,還有指向前一個節點的指針。這樣能夠從任何一個節點訪問前一個節點,固然也能夠訪問後一個節點,以致整個鏈表。
雙向鏈表是基於單向鏈表的擴展,不少操做與單向鏈表仍是相同的,在構造函數中咱們要增長 prev 指向前一個元素的指針和 tail 用來保存最後一個元素的引用,能夠從尾到頭反向查找,重點修改插入、刪除方法。
修改初始化鏈表
constructor() {
this.node = function(element) {
return {
element,
next: null,
prev: null, // 新增
}
};
this.length = 0;
this.head = null;
this.tail = null; // 新增
}
複製代碼
修改鏈表指定位置插入元素
在雙向鏈表中咱們須要控制 prev 和 next 兩個指針,比單向鏈表要複雜些,這裏可能會出現三種狀況:
狀況一:鏈表頭部添加
若是是在鏈表的第一個位置插入元素,當 head 頭部指針爲 null 時,將 head 和 tail 都指向 node 節點便可,若是 head 頭部節點不爲空,將 node.next 的下一個元素爲 current,那麼一樣 current 的上個元素就爲 node(current.prev = node),node 就爲第一個元素且 prev(node.prev = null)爲空,最後咱們將 head 指向 node。
假設咱們當前鏈表僅有一個元素 b,咱們要在第一個位置插入元素 a,圖例以下:
狀況二:鏈表尾部添加
這又是一種特殊的狀況鏈表尾部添加,這時候咱們要改變 current 的指向爲 tail(引用最後一個元素),開始連接把 current 的 next 指向咱們要添加的節點 node,一樣 node 的上個節點 prev 就爲 current,最後咱們將 tail 指向 node。
繼續上面的例子,咱們在鏈表尾部在增長一個元素 d
狀況三:非鏈表頭部、尾部的任意位置添加
這個和單向鏈表插入那塊是同樣的思路,不清楚的,在回頭去看下,只不過增長了節點的向前一個元素的引用,current.prev 指向 node,node.prev 指向 previous。
繼續上面的例子,在元素 d 的位置插入元素 c,那麼 d 就會變成 c 的下一個元素,圖例以下:
算法實現以下:
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) { // 有修改
if (current) {
node.next = current;
current.prev = node;
this.head = node;
} else {
this.head = this.tail = node;
}
} else if (i === this.length) { // 新增長
current = this.tail;
current.next = node;
node.prev = current;
this.tail = node;
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next; // 保存當前節點的下一個節點
}
node.next = current;
previous.next = node; // 注意,這塊涉及到對象的引用關係
current.prev = node; // 新增長
node.prev = previous; // 新增長
}
this.length++;
return true;
}
複製代碼
移除鏈表元素
雙向鏈表中移除元素同插入同樣,須要考慮三種狀況,下面分別看下各自實現:
狀況一:鏈表頭部移除
current 是鏈表中第一個元素的引用,對於移除第一個元素,咱們讓 head = current 的下一個元素,即 current.next,這在單向鏈表中就已經完成了,可是雙向鏈表咱們還要修改節點的上一個指針域,再次判斷當前鏈表長度是否等於 1,若是僅有一個元素,刪除以後鏈表就爲空了,那麼 tail 也要置爲 null,若是不是一個元素,將 head 的 prev 設置爲 null,圖例以下所示:
狀況二:鏈表尾部移除
改變 current 的指向爲 tail(引用最後一個元素),在這是 tail 的引用爲 current 的上個元素,即最後一個元素的前一個元素,最後再將 tail 的下一個元素 next 設置爲 null,圖例以下所示:
狀況三:鏈表尾部移除
這個和單向鏈表刪除那塊是同樣的思路,不清楚的,在回頭去看下,只增長了 current.next.prev = previous 當前節點的下一個節點的 prev 指針域等於當前節點的上一個節點 previous,圖例以下所示:
算法實現以下:
delete(i) {
// 要刪除的元素位置不能超過鏈表的最後一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) {
this.head = current.next;
if (this.length === 1) {
this.tail = null;
} else {
this.head.prev = null;
}
} else if (i === this.length -1) {
current = this.tail;
this.tail = current.prev;
this.tail.next = null;
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next;
}
previous.next = current.next;
current.next.prev = previous; // 新增長
}
this.length--;
return current.element;
}
複製代碼
雙向鏈表源碼地址以下:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/doubly-linked-list.js
複製代碼
在單向鏈表和雙向鏈表中,若是一個節點沒有前驅或後繼該節點的指針域就指向爲 null,循環鏈表中最後一個節點 tail.next 不會指向 null 而是指向第一個節點 head,一樣雙向引用中 head.prev 也會指向 tail 元素,以下圖所示:
能夠看出循環鏈表能夠將整個鏈表造成一個環,既能夠向單向鏈表那樣只有單向引用,也能夠向雙向鏈表那樣擁有雙向引用。
如下基於單向鏈表一節的代碼進行改造
尾部插入元素
對於環形鏈表的節點插入與單向鏈表的方式不一樣,若是當前節點爲空,當前節點的 next 值不指向爲 null,指向 head。若是頭部節點不爲空,遍歷到尾部節點,注意這裏不能在用 current.next 爲空進行判斷了,不然會進入死循環,咱們須要判斷當前節點的下個節點是否等於頭部節點,算法實現以下所示:
insertTail(e) {
let node = this.node(e);
let current;
if (this.head === null) { // 列表中尚未元素
this.head = node;
node.next = this.head; // 新增
} else {
current = this.head;
while (current.next !== this.head) { // 下個節點存在
current = current.next;
}
current.next = node;
node.next = this.head; // 新增,尾節點指向頭節點
}
this.length++;
}
複製代碼
鏈表任意位置插入元素
實現同鏈表尾部插入類似,注意:將新節點插入在原鏈表頭部以前,首先,要將新節點的指針指向原鏈表頭節點,並遍歷整個鏈表找到鏈表尾部,將鏈表尾部指針指向新增節點,圖例以下:
算法實現以下所示:
insert(i, e) {
if (i < 0 || i > this.length) {
return false;
}
let node = this.node(e);
let current = this.head;
let previous;
if (i === 0) {
if (this.head === null) { // 新增
this.head = node;
node.next = this.head;
} else {
node.next = current;
const lastElement = this.getNodeAt(this.length - 1);
this.head = node;
// 新增,更新最後一個元素的頭部引用
lastElement.next = this.head
}
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next; // 保存當前節點的下一個節
}
node.next = current;
previous.next = node; // 注意,這塊涉及到對象的引用關係
}
this.length++;
return true;
}
複製代碼
移除指定位置元素
與以前不一樣的是,若是刪除第一個節點,先判斷鏈表在僅有一個節點的狀況下直接將 head 置爲 null,不然不只僅只有一個節點的狀況下,首先將鏈表頭指針移動到下一個節點,同時將最後一個節點的指針指向新的鏈表頭部
算法實現以下所示:
delete(i) {
// 要刪除的元素位置不能超過鏈表的最後一位
if (i < 0 || i >= this.length) {
return false;
}
let current = this.head;
let previous;
if (i === 0) {
if (this.length === 1) {
this.head = null;
} else {
const lastElement = this.getNodeAt(this.length - 1);
this.head = current.next;
lastElement.next = this.head;
current = lastElement;
}
} else {
for (let k=0; k<i; k++) {
previous = current;
current = current.next;
}
previous.next = current.next;
}
this.length--;
return current.element;
}
複製代碼
最後在遍歷的時候也要注意,不能在根據 current.next 是否爲空來判斷鏈表是否結束,能夠根據鏈表元素長度或者 current.next 是否等於頭節點來判斷,本節源碼實現連接以下所示:
https://github.com/Q-Angelo/project-training/tree/master/algorithm/circular-linked-list.js
複製代碼
本節主要講解的是線性表,從順序表->單向鏈表->雙向鏈表->循環鏈表,這個過程也是按部就班的,前兩個講的很詳細,雙向鏈表與循環鏈表經過與前兩個不一樣的地方進行比較針對性的進行了講解,另外學習線性表也是學習其它數據結構的基礎,數據結構特別是涉及到一些實現算法的時候,有時候並非看一遍就能理解的,總之多實踐、多思考。