1. 程式人生 > 實用技巧 >學習js資料結構與演算法——字典和散列表

學習js資料結構與演算法——字典和散列表

字典

在字典中,儲存的是[鍵,值]對,其中鍵名是用來查詢特定元素的。字典和集合很相似,集合以[值,值]的形式儲存元素,字 典則是以[鍵,值]的形式來儲存元素。字典也稱作對映、符號表或關聯陣列。

import { defaultToString } from '../util';
import { ValuePair } from './models/value-pair';

export default class Dictionary {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;//在字典中,理想的情況是用字串作為鍵名,值可以是任何型別(從數、字串等原始型別,到複雜的物件)。但是,由於 JavaScript不是強型別的語言,我們不能保證鍵一定是字串。我們 需要把所有作為鍵名傳入的物件轉化為字串,使得從 Dictionary 類中搜索和獲取值更簡單 (同樣的邏輯也可以應用在上一章的 Set 類上)。要實現此功能,我們需要一個將 key 轉化為字 符串的函式
    this.table = {};
  }

    //向字典中新增新元素。如果 key 已經存在,那麼已存在的 value 會 被新的值覆蓋
  set(key, value) {
    if (key != null && value != null) {
      const tableKey = this.toStrFn(key);
      this.table[tableKey] = new ValuePair(key, value);
      return true;
    }
    return false;
  }

    //通過以鍵值作為引數查詢特定的數值並返回
  get(key) {
    const valuePair = this.table[this.toStrFn(key)];
    return valuePair == null ? undefined : valuePair.value;
  }
  //get的第二種實現方法
  get(key) {
    if (this.hasKey(key)) { 
        return this.table[this.toStrFn(key)];//會獲取兩次 key 的字串以及訪問兩次 table 物件,消耗較第一種方法更多
    }
    return undefined; 
}

    //如果某個鍵值存在於該字典中,返回 true,否則返回 false
  hasKey(key) {
    return this.table[this.toStrFn(key)] != null;//JavaScript只允許我們使用字串作為物件的鍵名或屬性名
  }

    //通過使用鍵值作為引數來從字典中移除鍵值對應的資料值
  remove(key) {
    if (this.hasKey(key)) {
      delete this.table[this.toStrFn(key)];
      return true;
    }
    return false;
  }

    //方法返回一個字典包含的所有值構成的陣列
  values() {
    return this.keyValues().map(valuePair => valuePair.value);
  }

    //將字典所包含的所有鍵名以陣列形式返回
  keys() {
    return this.keyValues().map(valuePair => valuePair.key);//用所建立的 keyValues 方法來返回一個包含 valuePair 例項的陣列,只對valuePair 的 key 屬性感興趣,就只返回它的 key
  }
  //也可以寫成如下形式,map更加簡潔,應該適應這種寫法。
  keys() {
    const keys = [];
    const valuePairs = this.keyValues(); 
    for (let i = 0; i < valuePairs.length; i++) {
        keys.push(valuePairs[i].key);
    }
    return keys;
  }

    //以陣列形式返回字典中的所有 valuePair 物件
  keyValues() {
    return Object.values(this.table);//行了 JavaScript的 Object 類內建的 values 方法,ECMAScript 2017中引入
  }
  
    //並非所有瀏覽器都支援 Object.values 方法,也可以用下面的程式碼來代替
  keyValues() {
    const valuePairs = [];
    for (const k in this.table) { //迭代了 table 物件的所有屬性
        if (this.hasKey(k)) {
            valuePairs.push(this.table[k]); //然後將table 物件中的valuePair 加入結果陣列
        } 
        return valuePairs; 
    };
}

    //迭代字典中所有的鍵值對。callbackFn 有兩個引數:key 和 value。該方法可以在回撥函式返回false 時被中止
  forEach(callbackFn) {
    const valuePairs = this.keyValues();
    for (let i = 0; i < valuePairs.length; i++) {
      const result = callbackFn(valuePairs[i].key, valuePairs[i].value);
      if (result === false) {
        break;
      }
    }
  }

  isEmpty() {
    return this.size() === 0;
  }

  size() {
    return Object.keys(this.table).length;//也可以呼叫keyValues 方法並返回它所返回的陣列長度(return this.keyValues().
length
  }

  clear() {
    this.table = {};
  }

  toString() {
    if (this.isEmpty()) {
      return '';
    }
    const valuePairs = this.keyValues();
    let objString = `${valuePairs[0].toString()}`;
    for (let i = 1; i < valuePairs.length; i++) {
      objString = `${objString},${valuePairs[i].toString()}`;//呼叫 valuePair 的 toString 方法來將它的第一個 valuePair 加入結果字串
    }
    return objString;
  }
}

defaultToString 函式宣告如下。

export function defaultToString(item) { 
    if (item === null) { 
        return 'NULL';
    } else if (item === undefined) { 
        return 'UNDEFINED';
    } else if (typeof item === 'string' || item instanceof String) { 
        return `${item}`;
    } 
    return item.toString(); // 
}

為了在字典中儲存 value,我們將 key 轉化為了字串,而為了儲存資訊的需要,我們同
樣要儲存原始的 key。因此,我們不是隻將 value 儲存在字典中,而是要儲存兩個值:原始的 key 和 value。為了字典能更簡單地通過 toString 方法輸出結果,我們同樣要為 ValuePair類建立 toString 方法。

ValuePair 類的定義如下。

class ValuePair {
    constructor(key, value) { 
        this.key = key; this.value = value;
    } 
    toString() { 
        return `[#${this.key}: ${this.value}]`; 
    }
}

散列表

HashTable 類,也叫 HashMap 類,它是Dictionary 類的一種散列表實現方式。

雜湊演算法的作用是儘可能快地在資料結構中找到一個值。
雜湊函式的作用是給定一個鍵值,然後 返回值在表中的地址。

import { defaultToString } from '../util';
import { ValuePair } from './models/value-pair';

export default class HashTable {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    this.table = {};
  }

    //建立雜湊函式
  loseloseHashCode(key) {
    if (typeof key === 'number') {
      return key;
    }
    const tableKey = this.toStrFn(key);
    let hash = 0;
    for (let i = 0; i < tableKey.length; i++) {
      hash += tableKey.charCodeAt(i);
    }
    return hash % 37;
  }
  
    //hashCode 方法簡單地呼叫了 loseloseHashCode 方法,將 key 作為引數傳入
    hashCode(key) { 
        return this.loseloseHashCode(key); 
   }

    // lose lose雜湊函式並不是一個表現良好的雜湊函式,因為它會產生太多的衝突。一個表現良好的雜湊函式是由幾個方面構成的:插入和檢索元素的時間(即效能),以及較低的 衝突可能性
    //比 lose lose更好的雜湊函式是 djb2
  /* djb2HashCode(key) {
    const tableKey = this.toStrFn(key);
    let hash = 5381;//初始化一個 hash 變數並賦值為一個質數
    for (let i = 0; i < tableKey.length; i++) {//迭代引數 key
      hash = (hash * 33) + tableKey.charCodeAt(i);//將 hash 與 33 相乘(用作一個幻數,幻數是指在程式設計中指直接使用的常數),並和當前迭代到的字元的 ASCII碼值相加,
    }
    return hash % 1013;
  } */
  hashCode(key) {
    return this.loseloseHashCode(key);
  }

    //向散列表增加一個新的項(也能更新散列表),put方法和 Dictionary 類中的 set 方法邏輯相似,但是大多數的程式語言會在 HashTable 資料結構中使用 put 方法
  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);//用所建立的 hashCode 函式在表中找到 一個位置
      this.table[position] = new ValuePair(key, value);
      return true;
    }
    return false;
  }

    //返回根據鍵值檢索到的特定的值
  get(key) {
    const valuePair = this.table[this.hashCode(key)];//用所建立的 hashCode 方法獲取 key 引數的位置
    return valuePair == null ? undefined : valuePair.value;
  }

    //根據鍵值從散列表中移除值
  remove(key) {
    const hash = this.hashCode(key);//因此我們使用 hashCode 函 數來獲取 hash
    const valuePair = this.table[hash];
    if (valuePair != null) {
      delete this.table[hash];//還可以將刪除的 hash 位置賦值為 null 或 undefined
      return true;
    }
    return false;
  }

  getTable() {
    return this.table;
  }

  isEmpty() {
    return this.size() === 0;
  }

  size() {
    return Object.keys(this.table).length;
  }

  clear() {
    this.table = {};
  }

  toString() {
    if (this.isEmpty()) {
      return '';
    }
    const keys = Object.keys(this.table);
    let objString = `{${keys[0]} => ${this.table[keys[0]].toString()}}`;
    for (let i = 1; i < keys.length; i++) {
      objString = `${objString},{${keys[i]} => ${this.table[keys[i]].toString()}}`;
    }
    return objString;
  }
}

處理衝突的幾種方法
  • 分離連結
  • 線性探查
  • 雙雜湊法

對於分離連結和線性探查來說,只需要重寫三個方法:put、get 和 remove。

分離連結法包括為散列表的每一個位置建立一個連結串列並將元素儲存在裡面。它是解決衝突的最簡單的方法,但是在 HashTable 例項之外還需要額外的儲存空間

HashTableSeparateChaining類 :
import { defaultToString } from '../util';
import LinkedList from './linked-list';
import { ValuePair } from './models/value-pair';

export default class HashTableSeparateChaining {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    this.table = {};
  }

  loseloseHashCode(key) {
    if (typeof key === 'number') {
      return key;
    }
    const tableKey = this.toStrFn(key);
    let hash = 0;
    for (let i = 0; i < tableKey.length; i++) {
      hash += tableKey.charCodeAt(i);
    }
    return hash % 37;
  }

  hashCode(key) {
    return this.loseloseHashCode(key);
  }

  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);
      if (this.table[position] == null) {
        this.table[position] = new LinkedList();
      }
      this.table[position].push(new ValuePair(key, value));//使用push 方法向 LinkedList 例項中新增一個 ValuePair 例項(鍵和值)
      return true;
    }
    return false;
  }

  get(key) {
    const position = this.hashCode(key);
    const linkedList = this.table[position];
    if (linkedList != null && !linkedList.isEmpty()) {//如果該位置上有值存在,我們知道這是一個 LinkedList 例項。現在要做的是迭代這個連結串列來尋找我們需要的元素
      let current = linkedList.getHead();//在迭代之前先要獲取鏈 表表頭的引用
      while (current != null) {
        if (current.element.key === key) {//可以通過current.element.key 來獲得Node連結串列的key屬性
          return current.element.value;
        }
        current = current.next;
      }
    }
    return undefined;
  }

  remove(key) {
    const position = this.hashCode(key);
    const linkedList = this.table[position];
    if (linkedList != null && !linkedList.isEmpty()) {
      let current = linkedList.getHead();
      while (current != null) {
        if (current.element.key === key) {
          linkedList.remove(current.element);
          if (linkedList.isEmpty()) {
            delete this.table[position];
          }
          return true;
        }
        current = current.next;
      }
    }
    return false;
  }

  isEmpty() {
    return this.size() === 0;
  }

  size() {
    let count = 0;
    Object.values(this.table).forEach(linkedList => {
      count += linkedList.size();
    });
    return count;
  }

  clear() {
    this.table = {};
  }

  getTable() {
    return this.table;
  }

  toString() {
    if (this.isEmpty()) {
      return '';
    }
    const keys = Object.keys(this.table);
    let objString = `{${keys[0]} => ${this.table[keys[0]].toString()}}`;
    for (let i = 1; i < keys.length; i++) {
      objString = `${objString},{${keys[i]} => ${this.table[
        keys[i]
      ].toString()}}`;
    }
    return objString;
  }
}

線性探查

線性,是因為它處理衝突的方法是將元素直
接儲存到表中,而不是在單獨的資料結構中。即當想向表中某個位置新增一個新元素的時候,如果索引為 position 的位置已經被佔據了,就嘗試 position+1 的位置。如果 position+1 的位置也被佔據了,就嘗試 position+2 的位 置,以此類推,直到在散列表中找到一個空閒的位置。

第一種方法需要檢驗是否有必要將一個或多個元素移動到之前的位置。當搜尋一個鍵的時候,這種方法可以避免找到一個空位置。如果移動元素是必要的,我們就需要在散列表中挪動鍵 值對

import { defaultToString } from '../util';
import { ValuePair } from './models/value-pair';

export default class HashTableLinearProbing {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    this.table = {};
  }

  loseloseHashCode(key) {
    if (typeof key === 'number') {
      return key;
    }
    const tableKey = this.toStrFn(key);
    let hash = 0;
    for (let i = 0; i < tableKey.length; i++) {
      hash += tableKey.charCodeAt(i);
    }
    return hash % 37;
  }

  hashCode(key) {
    return this.loseloseHashCode(key);
  }

  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);
      if (this.table[position] == null) {
        this.table[position] = new ValuePair(key, value);
      } else {
        let index = position + 1;//宣告一個 index 變數並賦值為 position+1
        while (this.table[index] != null) {
          index++;
        }
        this.table[index] = new ValuePair(key, value);
      }
      return true;
    }
    return false;
  }

  get(key) {
    const position = this.hashCode(key);
    if (this.table[position] != null) {
      if (this.table[position].key === key) {
        return this.table[position].value;
      }
      let index = position + 1;
      while (this.table[index] != null && this.table[index].key !== key) {
        index++;
      }
      if (this.table[index] != null && this.table[index].key === key) {
        return this.table[position].value;
      }
    }
    return undefined;
  }

  remove(key) {
    const position = this.hashCode(key);
    if (this.table[position] != null) {
      if (this.table[position].key === key) {
        delete this.table[position];
        this.verifyRemoveSideEffect(key, position);
        return true;
      }
      let index = position + 1;
      while (this.table[index] != null && this.table[index].key !== key) {
        index++;
      }
      if (this.table[index] != null && this.table[index].key === key) {
        delete this.table[index];
        this.verifyRemoveSideEffect(key, index);
        return true;
      }
    }
    return false;
  }

    //接收兩個引數:被刪除的 key 和該 key 被刪除的位置。
  verifyRemoveSideEffect(key, removedPosition) {
    const hash = this.hashCode(key);
    let index = removedPosition + 1;
    while (this.table[index] != null) {
      const posHash = this.hashCode(this.table[index].key);
      if (posHash <= hash || posHash <= removedPosition) {
        this.table[removedPosition] = this.table[index];
        delete this.table[index];
        removedPosition = index;
      }
      index++;
    }
  }

  isEmpty() {
    return this.size() === 0;
  }

  size() {
    return Object.keys(this.table).length;
  }

  clear() {
    this.table = {};
  }

  getTable() {
    return this.table;
  }

  toString() {
    if (this.isEmpty()) {
      return '';
    }
    const keys = Object.keys(this.table);
    let objString = `{${keys[0]} => ${this.table[keys[0]].toString()}}`;
    for (let i = 1; i < keys.length; i++) {
      objString = `${objString},{${keys[i]} => ${this.table[
        keys[i]
      ].toString()}}`;
    }
    return objString;
  }
}

第二種方法:軟刪除方法
我們使用一個特殊的值(標記)來表示鍵值對被刪除了(惰性刪除或軟刪除),而不是真的刪除它。經過一段時間,散列表被操作過後, 我們會得到一個標記了若干刪除位置的散列表。這會逐漸降低散列表的效率,因為搜尋鍵值會 隨時間變得更慢。能快速訪問並找到一個鍵是我們使用散列表的一個重要原因。

import { defaultToString } from '../util';
import { ValuePairLazy } from './models/value-pair-lazy';

export default class HashTableLinearProbingLazy {
  constructor(toStrFn = defaultToString) {
    this.toStrFn = toStrFn;
    this.table = {};
  }

  loseloseHashCode(key) {
    if (typeof key === 'number') {
      return key;
    }
    const tableKey = this.toStrFn(key);
    let hash = 0;
    for (let i = 0; i < tableKey.length; i++) {
      hash += tableKey.charCodeAt(i);
    }
    return hash % 37;
  }

  hashCode(key) {
    return this.loseloseHashCode(key);
  }

  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);
      if (
        this.table[position] == null
        || (this.table[position] != null && this.table[position].isDeleted)
      ) {
        this.table[position] = new ValuePairLazy(key, value);
      } else {
        let index = position + 1;
        while (this.table[index] != null && !this.table[position].isDeleted) {
          index++;
        }
        this.table[index] = new ValuePairLazy(key, value);
      }
      return true;
    }
    return false;
  }

  get(key) {
    const position = this.hashCode(key);
    if (this.table[position] != null) {
      if (this.table[position].key === key && !this.table[position].isDeleted) {
        return this.table[position].value;
      }
      let index = position + 1;
      while (
        this.table[index] != null
        && (this.table[index].key !== key || this.table[index].isDeleted)
      ) {
        if (this.table[index].key === key && this.table[index].isDeleted) {
          return undefined;
        }
        index++;
      }
      if (
        this.table[index] != null
        && this.table[index].key === key
        && !this.table[index].isDeleted
      ) {
        return this.table[position].value;
      }
    }
    return undefined;
  }

  remove(key) {
    const position = this.hashCode(key);
    if (this.table[position] != null) {
      if (this.table[position].key === key && !this.table[position].isDeleted) {
        this.table[position].isDeleted = true;
        return true;
      }
      let index = position + 1;
      while (
        this.table[index] != null
        && (this.table[index].key !== key || this.table[index].isDeleted)
      ) {
        index++;
      }
      if (
        this.table[index] != null
        && this.table[index].key === key
        && !this.table[index].isDeleted
      ) {
        this.table[index].isDeleted = true;
        return true;
      }
    }
    return false;
  }

  isEmpty() {
    return this.size() === 0;
  }

  size() {
    let count = 0;
    Object.values(this.table).forEach(valuePair => {
      count += valuePair.isDeleted === true ? 0 : 1;
    });
    return count;
  }

  clear() {
    this.table = {};
  }

  getTable() {
    return this.table;
  }

  toString() {
    if (this.isEmpty()) {
      return '';
    }
    const keys = Object.keys(this.table);
    let objString = `{${keys[0]} => ${this.table[keys[0]].toString()}}`;
    for (let i = 1; i < keys.length; i++) {
      objString = `${objString},{${keys[i]} => ${this.table[
        keys[i]
      ].toString()}}`;
    }
    return objString;
  }
}

ES2015 Map 類

可以基於 ES2015的 Map 類開發Dictionary 類。

原生的 Map 類使用:

const map = new Map();
map.set('Gandalf', '[email protected]'); map.set('John', '[email protected]'); map.set('Tyrion', '[email protected]');
console.log(map.has('Gandalf')); // true console.log(map.size); // 3 console.log(map.keys()); // 輸出{"Gandalf", "John", "Tyrion"} console.log(map.values()); // 輸出{"[email protected]", "[email protected]", "[email protected]"}
console.log(map.get('Tyrion')); // [email protected]

區別:

  • ES2015 的 Map 類的 values 方法和 keys 方法都返回Iterator(第 3章提到過),而不是值或鍵構成的陣列。
  • 我們實現的 size 方法 返回字典中儲存的值的個數,而 ES2015的 Map 類則有一個 size 屬性。
  • 刪除 map 中的元素可以用 delete 方法。 map.delete('John');

ES2105 WeakMap 類和 WeakSet 類

區別:

  • WeakSet 或 WeakMap 類相較於Map 和 Set沒有 entries、keys 和 values 等方法;
  • WeakSet 或 WeakMap 類只能用物件作為鍵。

優點:

  • 效能。WeakSet 和 WeakMap 是弱化的(用物件作為鍵),沒有強引用的鍵。這使得 JavaScript的垃圾回收器可以從中清除整個入口。
  • 必須用鍵才可以取出值。這些類沒有 entries、keys 和 values 等迭代器方法,因此,除非你知道鍵,否則沒有辦法取出值。即使用WeakMap 類封裝 ES2015類的私有屬性。

WeakMap 類也可以用 set 方法,但不能使用數、字串、布林值等基本資料型別,
需要將名字轉換為物件