模擬實現ES6的Map數據結構

你們都知道ES6中的Map是新增的一種數據結構。它相似對象,可是對象的鍵只能是字符串,Map的鍵不限定是字符串,Map的鍵能夠是一個
對象,能夠是布爾值等。Map提供"值-值"的對應關係,是一種Hash結構,但實際上ES6又比傳統Hash多了一些特性。javascript

經過下面這個小例子,咱們看一下Map比傳統Hash多的特性是什麼:java

let m = new Map()
    m.set(1,1).set(2,2).set(3,3) //Map(3) {1 => 1, 2 => 2, 3 => 3}
    m.delete(2) //true    Map(2) {1 => 1, 3 => 3}
   
    m.set(1,'1') //Map(2) {1 => "1", 3 => 3}
    m.set(4,4) //Map(3) {1 => "1", 3 => 3, 4 => 4}

我簡單羅列一下 Map 的api(但並非重點)node

  • Map.prototype.set(key,value),返回Map自己,因此能夠鏈式
  • Map.prototype.get(key) 若是找不到,返回undefined
  • Map.prototype.has(key)
  • Map.prototype.clear()

這篇文章的重點不是講解api的,而是透過Map提供的這些api,可以體會到Map底層的數據結構是什麼樣的c++

上面的例子,先初始化一個Map m,而後一次性set 3 個值,能夠看到此時的m是Map(3) {1 => 1, 2 => 2, 3 => 3}正好和咱們
set的順序是對應的,而後刪除key=2的鍵值對,而後m是Map(2) {1 => 1, 3 => 3},而後從新m.set(1,'1')
這時m是Map(2) {1 => "1", 3 => 3},再m.set(4,4),能夠看到m是Map(3) {1 => "1", 3 => 3, 4 => 4}。key=4的鍵值對是
在最後的,這標明瞭一種順序,在set新值的時候,保持着一種前後順序。es6

這個特性就是,Map的插入老是保持着前後順序(刪除了中間元素也會保持順序),這和傳統Hash並不同。傳統Hash是一種散列結構,元素並不具有順序性,而Map很明顯,
後插入的元素就在最後,保持着這種前後順序。算法

Hash本是哈希函數,表示爲index = fn(key),這裏的fn就是哈希函數,它表明一種生成地址的規則,通常有直接定製法、數組分析法、
平方取中法等等,這離本篇文章跑遠了,哈希算法哪一種好,已經在計算機算法中有所研究,能夠看https://blog.csdn.net/u011109...
咱們模擬Map的數據結構,就須要本身制定一個Hash函數。api

Hash是一段連續的有限的內存空間,根據散列函數H(key)和處理衝突的方法將一組關鍵字映射到一個有限的連續的地集(區間)上,並以關鍵字
在地址集中的"象"做爲記錄在表中的存儲位置,這種表就是散列表,這一映象過程稱爲散列造表或散列,所得的額存儲位置稱散列地址。

咱們能夠捋捋思路了,根據上文,Map底層使用了Hash毋庸置疑,但保持順序這個特性,我猜想Map的底層使用了鏈表數據結構(鏈表是前一個結點的next指針指向後一個結點),因此Map的底層數據結構,使用了Hash + 鏈表實現。
Map能夠保證順序,也可使用O(1)的時間複雜度來找到某個元素,因此咱們初步方案,Hash的散列表存儲Map中的數據,同時每一個結點存入的前後順序使用鏈表的形式表示。數組

我畫了兩張示意圖,表示數據結構的一種基本實現和刪除某個結點時的實現,以下:數據結構

插入數據

圖中,我爲了簡化默認散列表的長度是6,插入節點時,咱們使用Hash()函數,根據傳入的key,獲取一個應該插入的位置index,好比第一個結點①,
得出index=1,那麼放入位置,再放一個結點②,得出index,這裏要有一個指針(next),從①指向②,這樣才能保證Map中數據的順序,而後是插入結點③。插入結點④
的時候,經過Hash函數,得出和①結點相同的位置,這種現象叫作Hash碰撞,碰撞是不可避免的,好的Hash函數應該儘量少的出現碰撞,更加平均的使用散列表。
出現了碰撞,怎麼處理呢,這裏我選擇使用指針,鏈接①結點和④結點,這樣在同一個位置,會映射多個結點。(理論上應該儘量少的碰撞,不然影響查找速度)
,按照這樣的規律,依次建立告終點⑤和結點⑥dom

刪除數據

刪除某個結點時,傳入要刪除的key,經過Hash函數,很是快的找到所在位置,不須要遍歷。然而咱們說了Map的值的插入是有順序的,好比刪除結點②,
爲了保持這種順序性,咱們使用鏈表的特性,把結點②的前一個結點①的next指針指向結點③。這裏不須要移動結點什麼,只是改變一下指針的指向,因此操做很是快

像Map的其餘操做,好比get(key) 就很容易了,關鍵是理解上面的兩個圖模型。好了,接下來用Javascript原生來模擬一個Map數據結構,上代碼:

class ListNode {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.next = null;  //記錄插入順序
        this.ne = null; //記錄Hash碰撞後的結點
    }
}

function myMap() {
    this.init()
}
myMap.prototype.init = function(){
    this.collection = new Array(6)  //map底層用了hash算法。假如使用collection容器存放map中的數據
    for (let i = 0; i < this.collection.length; i++) {
        this.collection[i] = Object.create(null)
        this.collection[i].ne = null;
        this.collection[i].next = null;
    }
    this.size = 0
    this.head = null; //頭指針,老是指向第一個
    this.tail = null;  //尾指針,老是指向最後一個
}

//插入或更新key結點
myMap.prototype.set = function (key, value) {
    let index = this.hash(key)  //獲取容器中的位置
    let node = this.collection[index]  //得到index位置處的對象
    while (node.ne) {
        if (node.ne.key === key) {
            node.ne.value = value  //更新
            return this //注意返回當前對象this
        } else {
            node = node.ne
        }
    }
    //map中沒有該key,就在鏈表尾部插入
    let new_node = new ListNode(key, value)
    node.ne = new_node
    if (!this.tail) {
        this.tail = new_node
    }

    if (!this.head) {
        this.head = new_node  //若是是第一個結點,頭指針指向它
    }

    this.tail.next = new_node  //尾指針
    this.tail = new_node

    this.size++
     return this  //注意返回當前對象this
}

//獲取key結點的值
myMap.prototype.get = function (key) {
    let index = this.hash(key)
    let node = this.collection[index] //獲取容器相應位置處的對象
    while (node) {
        if (node.key === key) {
            return node.value
        } else {
            node = node.ne
        }
    }
    return undefined
}

//刪掉key結點
myMap.prototype.delete = function (key) {
    if (!this.head) {
        return false
    }

    //從容器中刪除
    let index = this.hash(key)
    let node = this.collection[index]
    let pre = null;
    while (node.ne) {
        if (node.ne.key === key) {
            let _prev = node; //從鏈表中刪除,須要前置結點
            let _node = node.ne //保存要刪除的結點
            _prev.ne = node.ne.ne//從容器中刪除,前置結點的指針指向要刪除結點的指針
            //從鏈表中刪除
            if (this.head === _node) { //若是要刪除的結點是頭結點
                this.head = _node.next;
            } else if (this.tail === _node) { //若是要刪除的結點時尾結點
                this.tail = _prev; //將尾指針指爲前置結點
                _prev.next = null; //將前置結點的指針置爲空
            } else {
                let cur = this.head;
                while (cur) {
                    if (cur.key !== key) {
                        pre = cur;
                        cur = cur.next;
                    } else {
                        break
                    }
                }
                pre.next = cur.next
            }
            this.size--
            return true;
        } else {
            node = node.ne
        }
    }
    return false
}

myMap.prototype.has = function (key) {
    let index = this.hash(key)
    let node = this.collection[index]
    while (node.ne) {
        if (node.ne.key === key) {
            return true
        } else {
            node = node.ne
        }
    }
    return false
}

//返回鍵名的遍歷器
myMap.prototype.keys = function* () {
    let head = this.head
    // 遍歷鏈表,把鏈表中全部Key放入生成器中
    while (head) {
        if (head.key) {
            yield head.key
        }
        head = head.next
    }
}

//返回鍵值的遍歷器
myMap.prototype.values = function* () {
    let head = this.head;
    while (head) {
        if (head.value) {
            yield head.value
        }
        head = head.next
    }
}

//返回全部成員 遍歷器
myMap.prototype.entries = function* () {
    let head = this.head;
    while (head) {
        if (head.key) {
            yield [head.key, head.value]
        }
        head = head.next
    }
}
myMap.prototype[Symbol.iterator] = myMap.prototype.entries  //默認遍歷器接口,for of使用

//返回Map的全部成員,接受一個函數做爲第一個參數,第二個參數是thisArg 若是省略了 thisArg 參數,或者其值爲 null 或 undefined,this 則指向全局對象。
myMap.prototype.forEach = function (callbackFn, thisArg) {
    let head = this.head
    while (head) {
        if (head.key) {
            callbackFn.call(thisArg, head.key, head.value, this)
            head = head.next
        }
    }
}

myMap.prototype.clear = function () {
    this.init()
}

/**
 * @param  any key
 * @return {number}
 */

//Hash 方法,Hash的速算法我本身模擬一個,真正實踐中的Hash算法確定十分複雜
myMap.prototype.hash = function (key) {
    let index = 0;
    if (typeof key === 'string') {
        //字符串的話取前10位,也不必所有遍歷完字符串,會影響性能、計算時間
        for (let i = 0; i < 10; i++) {
            index += isNaN(key.charCodeAt(i)) ? 0 : key.charCodeAt(i)
        }
    } else if (typeof key === 'number') {
        index = isNaN(key) ? this.collection.length - 1 : key % this.collection.length
    } else if (typeof key === 'object') {
        //    若是傳入的是一個對象做爲鍵,es6中的Map,底層Hash算法必定跟它的內存地址有關,由於取值時,比較的是是不是同一個引用。就算給了一個字面量相同的值,也不能取到值,必須試試引用相同的值才能取到
        //    這裏我只能模擬key爲對象時,經Hash算法獲得的都是index=0了
        index = 0

    } else if (typeof key === 'undefined') {
        index = 1
    } else if (typeof key === 'boolean') {
        index = 2
    }
    return index % this.collection.length
}

因爲要注意的細節太多,因此我加上了註釋,必定要好好看註釋啊~

先說一句前提Javascript底層是用c/c++寫的,爲何這麼說呢?由於ES6的Map能夠用對象做爲鍵,那麼在計算機底層,確定是和傳入對象的內存地址有關係的,
Map取值時,若是是引用類型,必須是相同的內存地址才能夠取到。

綜上,模擬的主要思路是,用Hash思想來存儲數據,達到O(1)的查找時間,用鏈表思想來維持插入數據的前後順序。細節點是,Hash的碰撞處理,我是本身模擬Hash函數,由於Javascript底層是用
c/c++寫的,因此用js寫只能是叫"模擬"。我還用了單鏈表來處理碰撞,經過ne指針,能夠取到碰撞的後續值,這樣有個隱藏問題是,當碰撞多了,Map的查找速度會慢,
其實也能夠用數組來處理碰撞,好處是Map查找速度會快那麼點。

多說一句,在計算機領域Hash碰撞算是一個課題,若是要深刻研究均可以寫篇論文了。Hash函數也有不少種,但以哪一種算法爲準,則要看使用場景,不過最終目標是同樣的,就是減小碰撞,均勻使用每一個存儲地址。

來驗證一下結果吧:

let m = new myMap();
    m.set('a', 1).set('b', 2)
    console.log(m.get('a'), m.get('b'))  //1 2

    let obj = {name: 'lolita'}
    let obj2 = {name: 'obj2'}
    m.set(obj, 'obj')
    m.set(obj2, 'obj2')

    console.log(m.get(obj)) //obj
    console.log(m.size)  //4
    console.log(m.delete('a'))  //true
    console.log(m.delete('a'))  //false,由於'a'已經被刪除過了

    console.log(m.delete(obj)) //true
    console.log(m.get(obj2))  //  obj2

    for (let key of m.keys()) {
        console.log('key:', key)  //key: b   key: { name: 'obj2' }
    }
    
    for (let value of m.values()) {
        console.log('value:', value) //value: 2  value: obj2
    }
    for (let item of m.entries()) {
        console.log(item[0], item[1]) //b 2    { name: 'obj2' } 'obj2'      
    }

    for (let [key, value] of m) {
        console.log(key, value); //b 2    { name: 'obj2' } 'obj2'
    }
    
    let ooo = {name: 'Jack'}
    m.forEach(function (key, value, map) {
        console.log("key: %s, value: %s", key, value)
        console.log(this)  //forEach第二個參數不寫或者null或者undefined,this都會是全局對象
    }, ooo)
    //key: b, value: 2  { name: 'Jack' }
    //   key: [object Object], value: obj2  { name: 'Jack' }


    console.log(m.clear()) //undefined

通過驗證,徹底符合預期結果,能夠說模擬是到位的,歐耶~

Question:

  1. 爲何底層用Hash表示,用對象不行嗎?

不行,對象雖然也能夠達到get set的效果,可是在遍歷時,使用 for ... in 或者Object.keys()都會按升序自動排序問題

  1. 怎麼模擬內存地址生成過程呢?
let generator = function* RandomRefFun() {
    const random = Math.random().toString(16).slice(2,8)
    yield `0x${random}`;
}
       
 let RandomRefFun = generator()
 let randomRef =  RandomRefFun().next().value

參考連接:
https://es6.ruanyifeng.com/#d...
https://dev.to/arthurbiensur/...

相關文章
相關標籤/搜索