導師計劃--資料結構和演算法系列(上)

call_me_R發表於2019-03-25

structure-banner

導師計劃已經開始一個月了,自己的講解的課程選擇了資料結構和演算法。這個系列的講解分為上下兩章javascript語言輔助。本篇文章為上章,涉及的內容是基本的資料結構。在日本,晚上沒事安排@…@,時間還是充足的...,於是自己整理下本系列知識點的上章內容。

moiunt-Fuji

以下為正文:

資料結構是計算機儲存、組織資料的方式。資料結構是指相互直接存在一種或多種特殊關係的資料元素的集合。通常情況下,精心選擇資料結構可以帶來更高的執行或者儲存效率。作為一名程式猿,更需要了解下資料結構。AND WHY?可以參考這篇文章【譯】程式設計不容易中的效能和優化部分內容。

講到資料結構,我們都會談到線性結構和非線性結構。

1.線性結構是一個有序資料元素的集合。它應該滿足下面的特徵:

  • 集合中必存在唯一的一個“第一個元素”
  • 集合中必存在唯一的一個“最後的元素”
  • 除最後一元素之外,其它資料元素均有唯一的“後繼”
  • 除第一個元素之外,其它資料元素均有唯一的“前驅”

按照百度百科的定義,我們知道符合條件的資料結構就有棧、佇列和其它。

2.非線性結構其邏輯特徵是一個節點元素可以有多個直接前驅或多個直接後繼。

那麼,符合條件的資料結構就有圖、樹和其它。

嗯~瞭解一下就行。我們進入正題:

陣列

陣列是一種線性結構,以十二生肖(鼠、牛、虎、兔、龍、蛇、馬、羊、猴、雞、狗、豬)排序為例:

array_demo

我們來建立一個陣列並列印出結果就一目瞭然了:

let arr = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'];
arr.forEach((item, index) => {
	console.log(`[ ${index} ] => ${item}`);
});

// [ 0 ] => 鼠
// [ 1 ] => 牛
// [ 2 ] => 虎
// [ 3 ] => 兔
// [ 4 ] => 龍
// [ 5 ] => 蛇
// [ 6 ] => 馬
// [ 7 ] => 羊
// [ 8 ] => 猴
// [ 9 ] => 雞
// [ 10 ] => 狗
// [ 11 ] => 豬
複製程式碼

陣列中常用的屬性和一些方法如下,直接呼叫相關的方法即可。這裡不做演示~

常用的屬性

  • length : 表示陣列的長度

常用的方法

  • splice(index, howmany, item, ... itemx)

    splice方法自認為是陣列中最強大的方法。可以實現陣列元素的新增、刪除和替換。引數index為整數且必需,規定新增/刪除專案的位置,使用負數可從陣列結尾處規定位置;引數howmany為必需,為要刪除的專案數量,如果設定為 0,則不會刪除專案;item1, ... itemx為可選,向陣列新增新的專案。

  • indexOf(searchValue, fromIndex)

    indexOf方法返回某個指定字串值在陣列中的位置。searchValue是查詢的字串;fromIndex是查詢的開始位置,預設是0。如果查詢不到,會返回-1。

  • concat(array1, ... arrayn)

    concat方法用於連線兩個或者多個陣列。

  • push(newElement1, ... newElementN)

    push方法可向陣列的末尾新增一個或者多個元素。

  • unshift(newElement1, ... newElementN)

    unshift方法可向陣列的開頭新增一個或者多個元素。

  • pop()

    pop方法用於刪除並返回陣列的最後一個元素

  • shift()

    shift方法可以刪除陣列的第一個元素

  • reverse()

    reverse方法用於陣列的反轉

  • sort(sortFn)

    sort方法是對陣列的元素排序。引數sortFn可選,其規定排序順序,必須是函式。

let values = [0, 1, 5, 10, 15];
values.sort();
console.log(values); // [0, 1, 10, 15, 5]
// 為什麼會出現這種排序結果呢❓
// 因為在忽略sortFn的情況下,元素會按照轉換為字串的各個字元的Unicode位點進行排序,如下
let equalValues = ['0', '1', '5', '10', '15'];
equalValues.sort();
console.log(equalValues); //  ["0", "1", "10", "15", "5"]

let arr = [0, 10, 5, 1, 15];
function compare(el1, el2){
    return el1 - el2; // 升序排列
}
arr.sort(compare);
console.log(arr); // [0, 1, 5, 10, 15]

arr.sort((el1, el2) => {
    return el2 - el1; // 降序排列
}); 
console.log(arr); // [15, 10, 5, 1, 0]
複製程式碼
  • forEach(fn(currentValue, index, arr), thisValue)

    forEach方法用於呼叫陣列的每個元素,並將元素傳遞給回撥函式。引數function(currentValue, index, arr){}是一個回撥函式。thisValue可選,傳遞給函式的值一般用 "this" 值,如果這個引數為空, "undefined" 會傳遞給 "this" 值。

  • every(fn(currentValue, index, arr), thisValue)

    every方法用於檢測陣列中所有元素是否符合指定條件,如果陣列中檢測到有一個元素不滿足,則整個表示式返回false,且剩餘的元素不再檢查。如果所有的元素都滿足條件,則返回true

  • some(fn(currentValue,index,arr),thisValue)

    some方法用於檢測陣列中元素是否滿足指定條件。只要有一個符合就返回true,剩餘的元素不再檢查。如果所有元素都不符合條件,則返回false

  • reduce(fn(accumulator, currentValue, currentIndex, arr), initialValue)

    reduce方法接收一個函式作為累加器,陣列中的每個值(從左到右)開始縮減,最終為一個值。回撥函式的四個引數的意義如下:accumulator,必需,累計器累計回撥的返回值, 它是上一次呼叫回撥時返回的累積值,或initialValue;currentValue,必需,陣列中正在處理的元素;currentIndex,可選,陣列中正在處理的當前元素的索引,如果提供了initialValue,則起始索引號為0,否則為1;arr,可選,當前元素所屬的陣列物件。initialValue,可選,傳遞給函式的初始值。

let arr = [1, 2, 3, 4];
let reducer = (accumulator, currentValue) => accumulator + currentValue;

// 1 + 2 + 3 + 4
console.log(arr.reduce(reducer)); // 10

// 5 + 1 + 2 + 3 + 4
console.log(arr.reduce(reducer, 5)); // 15
複製程式碼

是一種後進先出(LIFO)線性表,是一種基於陣列的資料結構。(ps:其實後面講到的資料結構或多或少有陣列的影子)

  • LIFO(Last In First Out)表示後進先出,後進來的元素第一個彈出棧空間。類似於自動餐托盤,最後放上去的托盤,往往先被拿出來使用。
  • 僅允許在表的一端進行插入和移除元素。這一端被稱為棧頂,相對地,把另一端稱為棧底。如下圖的標識。
  • 向一個棧插入新元素稱作進棧、入棧或壓棧,這是將新元素放在棧頂元素上面,使之成為新的棧頂元素。
  • 從一個棧刪除元素又稱為出棧或退棧,它是把棧頂元素刪除掉,使其相鄰的元素成為新的棧頂元素。

stack_demo

我們程式碼寫下,熟悉下棧:

class Stack {
    constructor(){
        this.items = [];
    }
    // 入棧操作
    push(element = ''){
        if(!element) return;
        this.items.push(element);
        return this;
    }
    // 出棧操作
    pop(){
        this.items.pop();
        return this;
    }
    // 對棧一瞥,理論上只能看到棧頂或者說即將處理的元素
    peek(){
        return this.items[this.size() - 1];
    }
    // 列印棧資料
    print(){
        return this.items.join(' ');
    }
    // 棧是否為空
    isEmpty(){
        return this.items.length == 0;
    }
    // 返回棧的元素個數
    size(){
        return this.items.length;
    }
}
let stack = new Stack(),
    arr = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'];
arr.forEach(item => {
    stack.push(item);
});
console.log(stack.print()); // 鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬
console.log(stack.peek()); // 豬
stack.pop().pop().pop().pop();
console.log(stack.print()); // 鼠 牛 虎 兔 龍 蛇 馬 羊
console.log(stack.isEmpty()); // false
console.log(stack.size()); // 8
複製程式碼

⚠️ 注意:棧這裡的push和pop方法要和陣列方法的push和pop方法區分下。

說到,這也讓我想到了翻譯的一篇文章JS的執行上下文和環境棧是什麼?,感興趣的話可以戳進去看下。

佇列

佇列是一種先進先出(FIFO)受限的線性表。受限體現在於其允許在表的前端(front)進行刪除操作,在表的末尾(rear)進行插入【優先佇列這些排除在外】操作。

queue_demo

程式碼走一遍:

class Queue {
    constructor(){
        this.items = [];
    }
    // 入隊操作
    enqueue(element = ''){
        if(!element) return;
        this.items.push(element);
        return this;
    }
    // 出隊操作
    dequeue(){
        this.items.shift();
        return this;
    }
    // 檢視隊前元素或者說即將處理的元素
    front(){
        return this.items[0];
    }
    // 檢視佇列是否為空
    isEmpty(){
        return this.items.length == 0;
    }
    // 檢視佇列的長度
    len(){
        return this.items.length;
    }
    // 列印佇列資料
    print(){
        return this.items.join(' ');
    }
}

let queue = new Queue(),
    arr = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'];
arr.forEach(item => {
    queue.enqueue(item);
});
console.log(queue.print()); // 鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬
console.log(queue.isEmpty()); // false
console.log(queue.len()); // 12
queue.dequeue().dequeue();
console.log(queue.front()); // 虎
console.log(queue.print()); // 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬
複製程式碼

連結串列

在進入正題之前,我們先來聊聊陣列的優缺點。

優點:

  • 儲存多個元素,比較常用
  • 訪問便捷,使用下標[index]即可訪問

缺點:

  • 陣列的建立通常需要申請一段連續的記憶體空間,並且大小是固定的(大多數的程式語言陣列都是固定的),所以在進行擴容的時候難以掌控。(一般情況下,申請一個更大的陣列,會是之前陣列的倍數,比如兩倍。然後,再將原陣列中的元素複製過去)
  • 插入資料越是靠前,其成本很高,因為需要進行大量元素的位移。

相對陣列,連結串列亦可以儲存多個元素,而且儲存的元素在內容中不必是連續的空間;在插入和刪除資料時,時間複雜度可以達到O(1)。在查詢元素的時候,還是需要從頭開始遍歷的,比陣列在知道下表的情況下要快,但是陣列如果不確定下標的話,那就另說了...

我們使用十二生肖來了解下連結串列:

linklist_demo

連結串列是由一組節點組成的集合。每個節點都使用一個物件的引用指向它的後繼。如上圖。下面用程式碼實現下:

// 連結串列
class Node {
    constructor(element){
        this.element = element;
        this.next = null;
    }
}

class LinkedList {
    constructor(){
        this.length = 0; // 連結串列長度
        this.head = new Node('head'); // 表頭節點
    }
    /**
     * @method find 查詢元素的功能,找不到的情況下直接返回鏈尾節點
     * @param { String } item 要查詢的元素
     * @return { Object } 返回查詢到的節點 
     */
    find(item = ''){
        let currNode = this.head;
        while(currNode.element != item && currNode.next){
            currNode = currNode.next;
        }
        return currNode;
    }
    /**
    * @method findPrevious 查詢連結串列指定元素的前一個節點
    * @param { String } item 指定的元素
    * @return { Object } 返回查詢到的之前元素的前一個節點,找不到節點的話返回鏈尾節點
    */
    findPrevious(item){
        let currNode = this.head;
        while((currNode.next != null) && (currNode.next.element != item)){
            currNode = currNode.next;
        }
        return currNode;
    }
    /**
     * @method insert 插入功能
     * @param { String } newElement 要出入的元素
     * @param { String } item 想要追加在後的元素(此元素不一定存在)
     */
    insert(newElement = '', item){
        if(!newElement) return;
        let newNode = new Node(newElement),
            currNode = this.find(item);
        newNode.next = currNode.next;
        currNode.next = newNode;
        this.length++;
        return this;
    }
    // 展示連結串列元素
    display(){
        let currNode = this.head,
            arr = [];
        while(currNode.next != null){
            arr.push(currNode.next.element);
            currNode = currNode.next;
        }
        return arr.join(' ');
    }
    // 連結串列的長度
    size(){
        return this.length;
    }
    // 檢視連結串列是否為空
    isEmpty(){
        return this.length == 0;
    }
    /**
     * @method indexOf 檢視連結串列中元素的索引
     * @param { String } element 要查詢的元素
     */
    indexOf(element){
        let currNode = this.head,
            index = 0;
        while(currNode.next != null){
            index++;
            if(currNode.next.element == element){
                return index;
            }
            currNode = currNode.next;
        }
        return -1;
    }
    /**
     * @method removeEl 移除指定的元素
     * @param { String } element 
     */
    removeEl(element){
        let preNode = this.findPrevious(element);
        preNode.next = preNode.next != null ? preNode.next.next : null;
    }
}

let linkedlist = new LinkedList();
console.log(linkedlist.isEmpty()); // true
linkedlist.insert('鼠').insert('虎').insert('牛', '鼠');
console.log(linkedlist.display()); // 鼠 牛 虎
console.log(linkedlist.find('豬')); // Node { element: '虎', next: null }
console.log(linkedlist.find('鼠')); // Node { element: '鼠', next: Node { element: '牛', next: Node { element: '虎', next: null } } }
console.log(linkedlist.size()); // 3
console.log(linkedlist.indexOf('鼠')); // 1
console.log(linkedlist.indexOf('豬')); // -1
console.log(linkedlist.findPrevious('虎')); // Node { element: '牛', next: Node { element: '虎', next: null } }
linkedlist.removeEl('鼠');
console.log(linkedlist.display()); // 牛 虎
複製程式碼

字典

字典的主要特點是鍵值一一對應的關係。可以比喻成我們現實學習中查不同語言翻譯的字典。這裡字典的鍵(key)理論上是可以使用任意的內容,但還是建議語意化一點,比如下面的十二生肖圖:

dictionary_demo

class Dictionary {
    constructor(){
        this.items = {};
    }
    /**
     * @method set 設定字典的鍵值對
     * @param { String } key 鍵
     * @param {*} value 值
     */
    set(key = '', value = ''){
        this.items[key] = value;
        return this;
    }
    /**
     * @method get 獲取某個值
     * @param { String } key 鍵
     */
    get(key = ''){
        return this.has(key) ? this.items[key] : undefined;
    }
    /**
     * @method has 判斷是否含有某個鍵的值
     * @param { String } key 鍵
     */
    has(key = ''){
        return this.items.hasOwnProperty(key);
    }
    /**
     * @method remove 移除元素
     * @param { String } key 
     */
    remove(key){
        if(!this.has(key))  return false;
        delete this.items[key];
        return true;
    }
    // 展示字典的鍵
    keys(){
        return Object.keys(this.items).join(' ');
    }
    // 字典的大小
    size(){
        return Object.keys(this.items).length;
    }
    // 展示字典的值
    values(){
        return Object.values(this.items).join(' ');
    }
    // 清空字典
    clear(){
        this.items = {};
        return this;
    }
}

let dictionary = new Dictionary(),
    // 這裡需要修改
    arr = [{ key: 'mouse', value: '鼠'}, {key: 'ox', value: '牛'}, {key: 'tiger', value: '虎'}, {key: 'rabbit', value: '兔'}, {key: 'dragon', value: '龍'}, {key: 'snake', value: '蛇'}, {key: 'horse', value: '馬'}, {key: 'sheep', value: '羊'}, {key: 'monkey', value: '猴'}, {key: 'chicken', value: '雞'}, {key: 'dog', value: '狗'}, {key: 'pig', value: '豬'}];
    arr.forEach(item => {
        dictionary.set(item.key, item.value);
    });
console.log(dictionary.keys()); // mouse ox tiger rabbit dragon snake horse sheep monkey chicken dog pig
console.log(dictionary.values()); // 鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬
console.log(dictionary.has('dragon')); // true
console.log(dictionary.get('tiger')); // 虎
console.log(dictionary.remove('pig')); // true
console.log(dictionary.size()); // 11
console.log(dictionary.clear().size()); // 0
複製程式碼

集合

集合通常是由一組無序的,不能重複的元素構成。 一些常見的集合操作如圖:

set_demo

es6中已經封裝好了可用的Set類。我們手動來寫下相關的邏輯:

// 集合
class Set {
    constructor(){
        this.items = [];
    }
    /**
     * @method add 新增元素
     * @param { String } element 
     * @return { Boolean }
     */
    add(element = ''){
        if(this.items.indexOf(element) >= 0) return false;
        this.items.push(element);
        return true;
    }
    // 集合的大小
    size(){
        return this.items.length;
    }
    // 集合是否包含某指定元素
    has(element = ''){
        return this.items.indexOf(element) >= 0;
    }
    // 展示集合
    show(){
        return this.items.join(' ');
    }
    // 移除某個元素
    remove(element){
        let pos = this.items.indexOf(element);
        if(pos < 0) return false;
        this.items.splice(pos, 1);
        return true;
    }
    /**
     * @method union 並集
     * @param { Array } set 陣列集合
     * @return { Object } 返回並集的物件
     */
    union(set = []){
        let tempSet = new Set();
        for(let i = 0; i < this.items.length; i++){
            tempSet.add(this.items[i]);
        }
        for(let i = 0; i < set.items.length; i++){
            if(tempSet.has(set.items[i])) continue;
            tempSet.items.push(set.items[i]);
        }
        return tempSet;
    }
    /**
     * @method intersect 交集
     * @param { Array } set 陣列集合
     * @return { Object } 返回交集的物件
     */
    intersect(set = []){
        let tempSet = new Set();
        for(let i = 0; i < this.items.length; i++){
            if(set.has(this.items[i])){
                tempSet.add(this.items[i]);
            }
        }
        return tempSet;
    }
    /**
     * @method isSubsetOf 【A】是【B】的子集❓
     * @param { Array } set 陣列集合
     * @return { Boolean } 返回真假值
     */
    isSubsetOf(set = []){
        if(this.size() > set.size()) return false;
        this.items.forEach*(item => {
            if(!set.has(item)) return false;
        });
        return true;
    }
}

let set = new Set(),
    arr = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴'];
arr.forEach(item => {
    set.add(item);
});
console.log(set.show()); // 鼠 牛 虎 兔 龍 蛇 馬 羊 猴
console.log(set.has('豬')); // false
console.log(set.size()); // 9
set.remove('鼠');
console.log(set.show()); // 牛 虎 兔 龍 蛇 馬 羊 猴
let setAnother = new Set(),
    anotherArr = ['馬', '羊', '猴', '雞', '狗', '豬'];
anotherArr.forEach(item => {
    setAnother.add(item);
});
console.log(set.union(setAnother).show()); // 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬
console.log(set.intersect(setAnother).show()); // 馬 羊 猴
console.log(set.isSubsetOf(setAnother)); // false
複製程式碼

雜湊表/雜湊表

雜湊是一種常用的儲存技術,雜湊使用的資料結構叫做雜湊表/雜湊表。在雜湊表上插入、刪除和取用資料都非常快,但是對於查詢操作來說卻效率低下,比如查詢一組資料中的最大值和最小值。查詢的這些操作得求助其它資料結構,比如下面要講的二叉樹。

切入個案例感受下雜湊表:

假如一家公司有1000個員工, 現在我們需要將這些員工的資訊使用某種資料結構來儲存起來。你會採用什麼資料結構呢?

  • 方案一:陣列

    • 按照順序將所有員工資訊依次存入一個長度為1000的陣列中。每個員工的資訊都儲存在該陣列的某個位置上。
    • 但是我們要檢視某個員工的資訊怎麼辦呢?一個個查詢嗎?不太好找。
    • 陣列最大的優勢是什麼?通過下標值獲取資訊。
    • 所以為了可以通過陣列快速定位到某個員工,最好給員工資訊中新增一個員工編號,而編號對應的就是員工的下標值
    • 當查詢某個員工資訊時,通過員工號可以快速定位到員工的資訊位置。
  • 方案二:連結串列

    • 連結串列對應插入和刪除資料有一定的優勢。
    • 但是對於獲取員工的資訊,每次都必須從頭遍歷到尾,這種方式顯然不是特別適合我們這裡。
  • 最終方案:

    • 這麼看最終方案似乎就是陣列了,但是陣列還是有缺點,什麼缺點呢?
    • 假如我們想檢視下張三這位員工的資訊,但是我們不知道張三的員工編號,怎麼辦呢?
    • 當然,我們可以問他的員工編號。但是我們每查詢一個員工都是要問一下這個員工的編號嗎?不合適。【那我們還不如直接問他的資訊嘞】
    • 能不能有一種辦法,讓張三的名字和他的員工編號產生直接的關係呢?
    • 也就是通過張三這個名字,我們就能獲取到他的索引值,而再通過索引值我們就能獲取張三的資訊呢?
    • 這樣的方案已經存在了,就是使用雜湊函式,讓某個key的資訊和索引值對應起來。

那麼雜湊表的原理和實現又是怎樣的呢,我們來聊聊。

我們的雜湊表是基於陣列完成的,我們從陣列這裡切入解析下。陣列可以通過下標直接定位到相應的空間,雜湊表的做法就是類似的實現。雜湊表把key(鍵)通過一個固定的演算法函式(此函式稱為雜湊函式/雜湊函式)轉換成一個整型數字,然後就將該數字對陣列長度進行取餘,取餘結果就當作陣列的下標,將value(值)儲存在以該數字為下標的陣列空間裡,而當使用雜湊表進行查詢的時候,就是再次使用雜湊函式將key轉換為對應的陣列下標,並定位到該空間獲取value

結合下面的程式碼,也許你會更容易理解:

// 雜湊表
class HashTable {
    constructor(){
        this.table = new Array(137);
    }
    /**
     * @method hashFn 雜湊函式
     * @param { String } data 傳入的字串
     * @return { Number } 返回取餘的數字
     */
    hashFn(data){
        let total = 0;
        for(let i = 0; i < data.length; i++){
            total += data.charCodeAt(i);
        }
        return total % this.table.length;
    }
    /**
     * 
     * @param { String } data 傳入的字串
     */
    put(data){
        let pos = this.hashFn(data);
        this.table[pos] = data;
        return this;
    }
    // 展示
    show(){
        this.table && this.table.forEach((item, index) => {
            if(item != undefined){
                console.log(index + ' => ' + item);
            }
        })
    }
    // ...獲取值get函式等看官感興趣的話自己補充測試啦
}

let hashtable = new HashTable(),
    arr = ['mouse', 'ox', 'tiger', 'rabbit', 'dragon', 'snake', 'horse', 'sheep', 'monkey', 'chicken', 'dog', 'pig'];
arr.forEach(item => {
    hashtable.put(item);
});
hashtable.show();
// 5 => mouse
// 40 => dog
// 46 => pig
// 80 => rabbit
// 87 => dragon
// 94 => ox
// 111 => monkey
// 119 => snake
// 122 => sheep
// 128 => tiger
// 134 => horse

// 那麼問題來了,十二生肖裡面的_小雞_去哪裡了呢❓
// 被_小萌狗_給覆蓋了,因為其位置都是40(這個可以自己證明下)
// 問題又來了,那麼應該如何解決這種被覆蓋的衝突呢❓
複製程式碼

hashtable_demo

針對上面的問題,我們儲存資料的時候,產生衝突的話我們可以像下面這樣解決:

1. 線性探測法

當發生碰撞(衝突)時,線性探測法檢查雜湊表中的下一個位置【有可能非順序查詢位置,不一定是下一個位置】是否為空。如果為空,就將資料存入該位置;如果不為空,則繼續檢查下一個位置,直到找到一個空的位置為止。該技術是基於一個事實:每個雜湊表都有很多空的單元格,可以使用它們儲存資料。

2. 開鏈法

但是,當發生碰撞時,我們任然希望將key(鍵)儲存到通過雜湊函式產生的索引位置上,那麼我們可以使用開鏈法開鏈法是指實現雜湊表底層的陣列中,每個陣列元素又是一個新的資料結構,比如另一個陣列(這樣結合起來就是二位陣列了),連結串列等,這樣就能儲存多個鍵了。使用這種技術,即使兩個key(鍵)雜湊後的值相同,依然是被儲存在同樣的位置,只不過它們是被儲存在另一個資料結構上而已。以另一個資料結構是陣列為例,儲存的資料如下:

open_link_method

二叉查詢樹

  • 樹的定義:

    • 樹(Tree):n(n >= 0)個節點構成的有限集合。

      • n = 0時,稱為空樹;
      • 對任意一棵空樹(n > 0),它具備以下性質:
      • 樹中有一個稱為**根(Root)**的特殊節點,用r(root)表示;
      • 其餘節點可分為m(m > 0)個互不相交的有限集T1,T2,...Tm,其中每個集合本省又是一棵樹,稱為原來樹的子樹(SubTree)
    • 注意:

      • 子樹之間不可以相交
      • 除了根節點外,每個節點有且僅有一個父節點;
      • 一個N個節點的樹有N-1條邊。
  • 樹的術語:

    • 節點的度(Degree):節點的子樹個數。
    • 樹的度:樹的所有節點中最大的度數(樹的度通常為節點個數的N-1)。
    • 葉節點(Leaf):度為0的節點(也稱葉子節點)。
    • 父節點(Parent):有子樹的節點是其子樹的父節點。
    • 子節點(Child):若A節點是B節點的父節點,則稱B節點是A節點的子節點。
    • 兄弟節點(Sibling):具有同一個父節點的各節點彼此是兄弟節點。
    • 路徑和路徑長度:從節點n1nk的路徑為一個節點序列n1,n2,n3,...,nknini+1的父節點。路徑所包含邊的個數為路徑長度。
    • 節點的層次(Level):規定根節點在第0層,它的子節點是第1層,子節點的子節點是第2層,以此類推。
    • 樹的深度(Depth):樹中所有節點中的最大層次是這棵樹的深度(因為上面是從第0層開始,深度 = 第最大層數 + 1)

如下圖:

tree_intro

  • 二叉樹的定義:

    • 二叉樹可以為空,也就是沒有節點
    • 二叉樹若不為空,則它是由根節點和稱為其左子樹TL和右子樹RT的兩個不相交的二叉樹組成
    • 二叉樹每個節點的子節點不允許超過兩個
  • 二叉樹的五種形態:

    • 只有根節點
    • 只有左子樹
    • 只有右子樹
    • 左右子樹均有

對應下圖(從左至右):

five_style_binary_tree

我們接下來要講的是二叉查詢樹(BST,Binary Search Tree)二叉查詢樹,也稱二叉搜尋樹或二叉排序樹,是一種特殊的二叉樹,相對值較的值儲存在節點中,較的值儲存在節點中。二叉查詢樹特殊的結構使它能夠快速的進行查詢、插入和刪除資料。下面我們來實現下:

// 二叉查詢樹
// 輔助節點類
class Node {
    constructor(data, left, right){
        this.data = data;
        this.left = left;
        this.right = right;
    }
    // 展示節點資訊
    show(){
        return this.data;
    }
}
class BST {
    constructor(){
        this.root = null;
    }
    // 插入資料
    insert(data){
        let n = new Node(data, null, null);
        if(this.root == null){
            this.root = n;
        }else{
            let current = this.root,
                parent = null;
            while(true){
                parent = current;
                if(data < current.data){
                    current = current.left;
                    if(current == null){
                        parent.left = n;
                        break;
                    }
                }else{
                    current = current.right;
                    if(current == null){
                        parent.right = n;
                        break;
                    }
                }
            }
        }
        return this;
    }
    // 中序遍歷
    inOrder(node){
        if(!(node == null)){
            this.inOrder(node.left);
            console.log(node.show());
            this.inOrder(node.right);
        }
    }
    //   先序遍歷
    preOrder(node){
        if(!(node == null)){
            console.log(node.show());
            this.preOrder(node.left);
            this.preOrder(node.right);
        }
    }
    // 後序遍歷
    postOrder(node){
        if(!(node == null)){
            this.postOrder(node.left);
            this.postOrder(node.right);
            console.log(node.show());
        }
    }
    // 獲取最小值
    getMin(){
        let current = this.root;
        while(!(current.left == null)){
            current = current.left;
        }
        return current.data;
    }
    // 獲取最大值
    getMax(){
        let current = this.root;
        while(!(current.right == null)){
            current = current.right;
        }
        return current.data;
    }
    // 查詢給定的值
    find(data){
        let current = this.root;
        while(current != null){
            if(current.data == data){
                return current;
            }else if(data < current.data){
                current = current.left;
            }else{
                current = current.right;
            }
        }
        return null;
    }
    // 移除給定的值
    remove(data){
        root = this.removeNode(this.root, data);
        return this;
    }
    // 移除給定值的輔助函式
    removeNode(node, data){
        if(node == null){
            return null;
        }
        if(data == node.data){
            // 葉子節點
            if(node.left == null && node.right == null){
                return null; // 此節點置空
            }
            // 沒有左子樹
            if(node.left == null){
                return node.right;
            }
            // 沒有右子樹
            if(node.right == null){
                return node.left;
            }
            // 有兩個子節點的情況
            let tempNode = this.getSmallest(node.right); // 獲取右子樹
            node.data = tempNode.data; // 將其右子樹的最小值賦值給刪除的那個節點值
            node.right = this.removeNode(node.right, tempNode.data); // 刪除指定節點的下的最小值,也就是置其為空
            return node;
        }else if(data < node.data){
            node.left = this.removeNode(node.left, data);
            return node;
        }else{
            node.right = this.removeNode(node.right, data);
            return node;
        }
    }
    // 獲取給定節點下的二叉樹最小值的輔助函式
    getSmallest(node){
        if(node.left == null){
            return node;
        }else{
            return this.getSmallest(node.left);
        }
    }
}

let bst = new BST();
bst.insert(56).insert(22).insert(10).insert(30).insert(81).insert(77).insert(92);
bst.inOrder(bst.root); // 10, 22, 30, 56, 77, 81, 92
console.log('--中序和先序遍歷分割線--');
bst.preOrder(bst.root); // 56, 22, 10, 30, 81, 77, 92
console.log('--先序和後序遍歷分割線--');
bst.postOrder(bst.root); // 10, 30, 22, 77, 92, 81, 56
console.log('--後序遍歷和獲取最小值分割線--');
console.log(bst.getMin()); // 10
console.log(bst.getMax()); // 92
console.log(bst.find(22)); // Node { data: 22, left: Node { data: 10, left: null, right: null }, right: Node { data: 30, left: null, right: null } }
// 我們刪除節點值為22,然後用先序的方法遍歷,如下
console.log('--移除22的分割線--')
console.log(bst.remove(22).inOrder(bst.root)); // 10, 30, 56, 77, 81, 92
複製程式碼

看了上面的程式碼之後,你是否有些懵圈呢?我們藉助幾張圖來了解下,或許你就豁然開朗了。

在遍歷的時候,我們分為三種遍歷方法--先序遍歷,中序遍歷和後序遍歷:

travel_tree

刪除節點是一個比較複雜的操作,考慮的情況比較多:

  • 該節點沒有葉子節點的時候,直接將該節點置空;
  • 該節點只有左子樹,直接將該節點賦予左子樹
  • 該節點只有右子樹,直接將該節點賦予右子樹
  • 該節點左右子樹都有,有兩種方法可以處理
    • 方案一:從待刪除節點的子樹找節點值最大的節點A,替換待刪除節點值,並刪除節點A
    • 方案二:從待刪除節點的子樹找節點值最小的節點A,替換待刪除節點值,並刪除節點A【?上面的示例程式碼中就是這種方案】

刪除兩個節點的圖解如下:

remove_tree_node

由邊的集合及頂點的集合組成。

我們來了解下圖的相關術語:

  • 頂點:圖中的一個節點。
  • 邊:表示頂點和頂點之間的連線。
  • 相鄰頂點:由一條邊連線在一起的頂點稱為相鄰頂點。
  • 度:一個頂點的度是相鄰頂點的數量。比如0頂點和其它兩個頂點相連,0頂點的度就是2
  • 路徑:路徑是頂點v1,v2...,vn的一個連續序列。
    • 簡單路徑:簡單路徑要求不包含重複的頂點。
    • 迴路:第一個頂點和最後一個頂點相同的路徑稱為迴路。
  • 有向圖和無向圖
    • 有向圖表示圖中的方向的。
    • 無向圖表示圖中的方向的。
  • 帶權圖和無權圖
    • 帶權圖表示圖中的邊有權重
    • 無權圖表示圖中的邊無權重

如下圖:

graph_concept_intro

圖可以用於現實中的很多系統建模,比如:

  • 對交通流量建模
    • 頂點可以表示街道的十字路口, 邊可以表示街道.
    • 加權的邊可以表示限速或者車道的數量或者街道的距離.
    • 建模人員可以用這個系統來判定最佳路線以及最可能堵車的街道.

圖既然這麼方便,我們來用程式碼實現下:

// 圖
class Graph{
    constructor(v){
        this.vertices = v; // 頂點個數
        this.edges = 0; // 邊的個數
        this.adj = []; // 鄰接表或鄰接表陣列
        this.marked = []; // 儲存頂點是否被訪問過的標識
        this.init();
    }
    init(){
        for(let i = 0; i < this.vertices; i++){
            this.adj[i] = [];
            this.marked[i] = false;
        }
    }
    // 新增邊
    addEdge(v, w){
        this.adj[v].push(w);
        this.adj[w].push(v);
        this.edges++;
        return this;
    }
    // 展示圖
    showGraph(){
        for(let i = 0; i < this.vertices; i++){
            for(let j = 0; j < this.vertices; j++){
                if(this.adj[i][j] != undefined){
                    console.log(i +' => ' + this.adj[i][j]);
                }
            }
        }
    }
    // 深度優先搜尋
    dfs(v){
        this.marked[v] = true;
        if(this.adj[v] != undefined){
            console.log("visited vertex: " + v);
        }
        this.adj[v].forEach(w => {
            if(!this.marked[w]){
                this.dfs(w);
            }
        })
    }
    // 廣度優先搜尋
    bfs(v){
        let queue = [];
        this.marked[v] = true;
        queue.push(v); // 新增到隊尾
        while(queue.length > 0){
            let v = queue.shift(); // 從對首移除
            if(v != undefined){
                console.log("visited vertex: " + v);
            }
            this.adj[v].forEach(w => {
                if(!this.marked[w]){
                    this.marked[w] = true;
                    queue.push(w);
                }
            })
        }
    }
}

let graphFirstInstance = new Graph(5);
graphFirstInstance.addEdge(0, 1).addEdge(0, 2).addEdge(1, 3).addEdge(2, 4);
graphFirstInstance.showGraph();
// 0 => 1
// 0 => 2
// 1 => 0
// 1 => 3
// 2 => 0
// 2 => 4
// 3 => 1
// 4 => 2
// ❓為什麼會出現這種資料呢?它對應的圖是什麼呢?可以思考?下,動手畫畫圖什麼的
console.log('--展示圖和深度優先搜尋的分隔線--');
graphFirstInstance.dfs(0); // 從頂點 0 開始的深度搜尋
// visited vertex: 0
// visited vertex: 1
// visited vertex: 3
// visited vertex: 2
// visited vertex: 4
console.log('--深度優先搜尋和廣度優先搜尋的分隔線--');
let graphSecondInstance = new Graph(5);
graphSecondInstance.addEdge(0, 1).addEdge(0, 2).addEdge(1, 3).addEdge(2, 4);
graphSecondInstance.bfs(0); // 從頂點 0 開始的廣度搜尋
// visited vertex: 0
// visited vertex: 1
// visited vertex: 2
// visited vertex: 3
// visited vertex: 4
複製程式碼

對於搜尋圖,在上面我們介紹了深度優先搜尋 - DFS(Depth First Search)廣度優先搜尋 - BFS(Breadth First Search),結合下面的圖再回頭看下上面的程式碼,你會更加容易理解這兩種搜尋圖的方式。

graph_search

後話

文章中的一些案例來自coderwhy的資料結構和演算法系列文章,感謝其授權

author_wechat_permission

繪圖軟體 Numbers,本篇文章用到的圖片繪圖稿感興趣可以下載。

演示程式碼存放地址 -- 資料結構資料夾 進入structure目錄可以直接 node + filename 執行

文章首發 github.com/reng99/blog…

更多內容 github.com/reng99/blog…

參考

相關文章