前端学习 数据结构与算法 快速入门 系列 —— 集合、字典和散列表

其他章节请看:

前端学习 数据结构与算法 快速入门 系列

集合、字典和散列表

集合

集合:由一组无序且唯一的项组成。

Tip集合 是数学中的概念,但应用在计算机科学的数据结构中。

创建集合类

通常集合有如下方法:

  • add(element): 给集合添加新元素
  • delete(element) 或 remove(): 从集合中移除一个元素
  • clear(): 移除集合中所有元素
  • has(element) 或 contains(): 如果元素在集合中,返回true
  • size(): 返回集合包含元素的数量
  • values() 或 iterator(): 返回一个包含集合中所有值的项

笔者实现如下:

class Set {
    constructor() {
        this.items = {}
    }
    has(item) {
        return this.items.hasOwnProperty(item)
    }
    add(item) {
        // 添加之前先检查是否已存在
        if (!this.has(item)) {
            this.items[item] = item;
            return true;
        }
        // 表示没有添加
        return false;
    }
    delete(item) {
        // 添加之前先检查是否已存在
        if (this.has(item)) {
            delete this.items[item];
            return true;
        }
        return false;
    }
    size() {
        // 返回自身可枚举属性组成的数组
        return Object.keys(this.items).length
    }
    values() {
        return Object.values(this.items)
    }
    clear() {
        this.item = {}
    }
}

测试:

let set = new Set()
console.log(set.add(5)) // true 
console.log(set.add("5")) // false 
set.add(6)
console.log(set.values()) // [5, 6] 
console.log(set.size()) // 2

这个实现有一个缺陷,数字 5 和字符串 "5" 被认为是同一个元素。因为属性的键名,即使传入的是数字,最后也会自动转化为字符串,所以 set[5]set["5"] 在 Set 看来是同一个。

Tip:有些同学可能会说,我用数组来表示集合(this.items = []),不就可以区分数字5和字符串"5"。就像这样:

class Set {
    constructor() {
        this.items = []
    }
    add(item) {
        if (!this.items.includes(item)) {
            this.items.push(item)
            return true
        }
        // 表示没有添加
        return false;
    }
}

最终,es6 还是新增了 Set 类型,允许存储任何类型的唯一值。当然,还附有其他特性,比如 Set 类型是可以迭代的...

使用 es6 中的 Set 类

es6 中的 Set 类的用法,核心和我们的 Set 类很相似。请看示例:

  • 通过 new Set() 创建集合
  • 调用 add() 方法给集合添加元素
  • 访问 size 属性可以获取集合中元素的数量
  • 通过调用 has() 方法可以检测 Set 集合中是否存在某个值
  • 通过调用 delete() 方法可以移除集合中的某个元素
let set = new Set()

set.add(2)
set.add("2")
console.log(set.size) // 2
console.log(set.has(2)) // true
set.delete(2)
console.log(set.values()) // [Set Iterator] { '2' }
set.clear()
console.log(set.size) // 0

es6 的 Set 和我们的实现有一些差异。比如:

  • es6 中 values() 方法返回的是 Iterator
  • es6 中 size 是属性

:Set 构造函数可以接受所有可迭代对象作为参数,例如数组、Set 集合、Map都是可迭代对象。

数组中的 forEach() 非常好用,所以 es6 也给 Set 集合添加了同样的方法:

new Set(["a", "b", "c"]).forEach(item => console.log(item))
// a
// b
// c

我们不能像数组一样通过索引访问集合中的元素,如果需要,可以将 Set 集合先转成一个数组:

const set = new Set(["a", "b", "c"])
const arr = [...set]
// arr:  [ 'a', 'b', 'c' ]
console.log('arr: ', arr)

Tip:更多用法请看 mdn Set

集合运算

我们可以对集合进行如下运算:

  • 并集:对于两个集合,返回一个包含两个集合中所有元素的新集合
  • 交集:对于两个集合,返回一个包含两个集合中共有元素的新集合
  • 差集:对于两个集合,返回一个包含所有存在于第一个集合且不存在于第二个集合的元素的新集合
  • 子集:如果一个集合(S1)中的每个元素都在另一个集合(S2)中,则 S1 是 S2 的子集
  • 超集:如果一个集合(S2)中的每个元素都在另一个集合(S1)中,而 S1 中可能包含 S2 中没有的元素,则 S1 就是 S2 的一个超集
// 并集
function union(setA, setB) {
    let _union = new Set(setA);
    for (let elem of setB) {
        _union.add(elem);
    }
    return _union;
}

// 交集
function intersection(setA, setB) {
    let _intersection = new Set();
    for (let elem of setB) {
        if (setA.has(elem)) {
            _intersection.add(elem);
        }
    }
    return _intersection;
}

// 差集
function difference(setA, setB) {
    let _difference = new Set(setA);
    for (let elem of setB) {
        _difference.delete(elem);
    }
    return _difference;
}

// 超集
function isSuperset(set, subset) {
    for (let elem of subset) {
        if (!set.has(elem)) {
            return false;
        }
    }
    return true;
}

测试:

const set1 = new Set([2, 3, 4])
const set2 = new Set([3, 4, 5])
const set3 = new Set([3, 4])

// Set(4) { 2, 3, 4, 5 }
console.log(union(set1, set2)) 

// Set(2) { 3, 4 }
console.log(intersection(set1, set2))

// Set(1) { 2 }
console.log(difference(set1, set2))

// true
console.log(isSuperset(set1, set3))
扩展运算符

有一种计算并集、交集、差集和超集的简便方法,就是使用 扩展运算符。重写以上方法:

function union(setA, setB) {
    return new Set([...set1, ...set2])
}

function intersection(setA, setB) {
    return new Set([...setA].filter(item => setB.has(item)))
}

function difference(setA, setB) {
    return new Set([...setA].filter(item => !setB.has(item)))
}

function isSuperset(set, subset) {
    return [...subset].every(item => set.has(item))
}

WeakSet

将对象存储在 Set 的实例中与存储在变量中完全一样,只要 Set 实例中的引用存在,垃圾回收机制就不能释放该对象的空间:

let set = new Set()
let key = {}
set.add(key)

key = null // {1}
console.log(set.size) // 1 {2}

但有时,我们希望当其他所有引用都不存在时(行{1}),Set 中的这些引用也随之消失(行{2} 输出为0),于是 es6 新增了 WeakSet 类型,与 Set 用法类似,最主要区别是 WeakSet 保存的是对象的弱引用。重写上面的示例:

let set = new WeakSet()
let key = {}
set.add(key)
// 移除对象 key 的最后一个强引用(Weak Set 中的引用也会自动移除)
key = null

测试难以进行下去,但你要相信 javascript 引擎。

WeakSet 集合与普通 Set 集合还有以下几个差异:

  • WeakSet 只能存储对象
  • 只支持3个方法:add()、has()和delete()
  • 不支持 forEach
  • 不可迭代,不暴露任何迭代器(例如keys()和values()方法)
  • 不支持size属性

字典

字典和集合很相似,集合以 [值,值] 的形式存储元素,字典则以 [键,值] 的形式存储元素。

集合中,我们感兴趣的是每个值本身,并将它当作主元素;而对于字典,我们通常通过键名来查询特定的元素。

现实生活中,这种数据结构的应用有:字典、通讯录等等

创建字典类

通常字典有如下方法:

  • set(key, value): 向字典添加新元素。如果 key 存在,则会覆盖已存在的 value
  • get(key): 返回对应的值
  • has(key): 检测指定键名在 Map 中是否已存在
  • remove(key): 移除指定键名及对应的值
  • values():将字典所包含的值以数组的形式返回
  • keys():将字典所包含的键名以数组的形式返回
  • keyValues():将字典中的 [键,值] 对以数组形式返回
  • isEmpty(): 字典是否为空。size 为 0 时返回 true
  • size(): 返回字典中包含成员的数量
  • clear(): 方法移除 Map 中所有键值对
class Dictionary {
    constructor() {
        this.table = {};
    }
    set(key, value) {
        if (key != null && value != null) {
            this.table[key] = value;
            return true;
        }
        return false;
    }
    get(key) {
        return this.table[key]
    }
    has(key) {
        return this.table.hasOwnProperty(key)
    }
    remove(key) {
        if (this.hasKey(key)) {
            delete this.table[key];
            return true;
        }
        return false;
    }
    values() {
        return Object.values(this.table)
    }
    keys() {
        return Object.keys(this.table)
    }
    keyValues() {
        // 可以在对象字面量中使用可计算属性名,语法是使用方括号([])
        return this.keys().map(key => ({ [key]: this.table[key] }))
    }
    isEmpty() {
        return this.size() === 0;
    }
    size() {
        return Object.keys(this.table).length;
    }
    clear() {
        this.table = {};
    }
}

测试:

const d = new Dictionary()
d.set('age', 18)
d.set('name', 'aaron')
console.log(d)                    // Dictionary { table: { age: 18, name: 'aaron' } }
console.log(d.hasKey('age'))      // true
console.log(d.size())             // 2
console.log(d.keys(), d.values()) // [ 'age', 'name' ] [ 18, 'aaron' ]
console.log(d.keyValues())        // [ { age: 18 }, { name: 'aaron' } ]

Tip:字典也叫关联数组,或许在 javascript 中允许我们使用方括号([])获取对象的属性。

使用 es6 中的 Map 类

es6 中的 Map 的用法和我们的 Dictionary 类很相似。请看示例:

  • 通过 new Map() 创建字典类
  • 调用 set() 方法传入键名和对应的值
  • 调用 get() 方法返回对应的值
  • 调用 has(key) 方法能检测指定键名在 Map 中是否已存在
  • 调用 delete(key) 方法移除指定键名及对应的值
  • 调用 clear() 方法移除 Map 中所有键值对
  • 调用 size 属性返回 Map 中成员数量
const map = new Map()
map.set('age', 18)
map.set('name', 'aaron')
console.log(map.get('age'))   // 18
console.log(map.has('age'))   // true
map.delete('name')
console.log(map.size)         // 1

es6 的 Map 和我们的实现有一些差异。比如:

  • es6 中 values() 方法返回的是 Iterator
  • es6 中 size 是属性
  • es6 中 没有 isEmpty() 方法
  • keyValues() 方法在 es6 中叫做 entries()

:Map 的键名和值都可以是任意类型,而且可以给 Map 构造函数传入数组来初始化:

const map = new Map([['age', 18], ['name', 'aaron']])
map.set({}, 1)
map.set({}, null)
console.log(map.size) // 4

Map 的 forEach() 与 Set 和 Array 中的 forEach() 方法类似,回调函数都支持 3 个参数:

const map = new Map([['age', 18], ['name', 'aaron']])
map.forEach((value, key, aMap) => {
    console.log(key + ' ' + value)
})
// age 18
// name aaron

const set = new Set(['age', 'name'])
set.forEach((value, key, aMap) => {
    console.log(key + ' ' + value)
})
// age age
// name name

const arr = new Array('age', 'name')
arr.forEach((value, key, aMap) => {
    console.log(key + ' ' + value)
})
// 0 age
// 1 name

Tip:更多用法请看 mdn Map

WeakMap

WeakSet 是弱引用 Set 集合,相对的,WeakMap 是弱引用 Map,也用于存储对象的弱引用。请看示例:

let map = new WeakMap()
let key = {}
map.set(key, 'empty object')
key = null

// 此时 WeakMap 为空

WeakMap 与普通 Map 还有以下几个差异:

  • 键名只能是对象(但不能是null)
  • 只支持4个方法:set()、get()、has()和delete()
  • 不支持 forEach
  • 不可迭代,不暴露任何迭代器(例如keys()、values()、entries())
  • 不支持size属性

:WeakMap 和 一种特殊的 Map,同样的,WeakSet 也是一种特殊的 Set,存放的都是对象的弱引用,当该对象的强引用都被清除时,弱引用的键以及对应的值也会自动被垃圾回收。

散列表

散列表 和字典一样,也是以 [键,值] 对的形式存储数据。但是键名需要通过一个函数(称之为散列函数)转换一下。

Tip:通常要在一个数据结构中获取一个值,就需要迭代整个数据结构来查找,而如果通过散列函数,就能知道值的具体位置,因此能够快速的查找该值。

创建散列表

以下是一个简单的散列表,只包括添加、查找和删除:

  • hashCode(): 散列函数,用于将键名转成散列值
  • put(key, value): 向散列表添加一个新项(也能更新散列表)
  • remove(key): 根据键从散列表中移除值
  • get(key): 通过键检索特定的值
class HashTable {
    constructor() {
        this.table = {}
    }
    // 散列函数
    hashCode(key) {
        // 将每个字符的 Unicode 编码值相加
        const hash = [...String(key)].reduce((pre, curr) => curr.codePointAt(0) + pre, 0)
        return hash

        // 避免操作数超过变量最大范围
        // return hash % 100
    }
    put(key, value) {
        if (key != null && value != null) {
            const position = this.hashCode(key);
            this.table[position] = value
            return true;
        }
        return false;
    }
    get(key) {
        return this.table[this.hashCode(key)]
    }
    remove(key) {
        const hash = this.hashCode(key)
        if (this.table.hasOwnProperty(hash)) {
            delete this.table[hash]
            return true
        }
        return false
    }
}

测试:

let h = new HashTable()
h.put('abc', 'abc')
h.put('a', 'a')
console.log(h)              // HashTable { table: { '97': 'a', '294': 'abc' } }
console.log(h.remove('a'))  // true
console.log(h.get('abc'))   // abc

散列表中的冲突

有时候,一些键会返回相同的值:

let h = new HashTable()
h.put('abc', 'abc')         // {1}
h.put('cba', 'cba')         // {2}
console.log(h)              // HashTable { table: { '294': 'cba' } }

由于不同的键(abc 和 cba)都对应着 294,所以造成了数据的丢失。

显然,使用一个数据结构来保存数据的目的不是丢失这些数据,因此,我们需要解决冲突。

解决冲突最简单的方法是 分离链接 法,即散列表中不直接存储 value,而是存储一个链表数据结构,在将 value 存入链表中。示意如下:

class HashTableSeparateChaining {
  constructor(toStrFn = defaultToString) {
    this.table = {};
  }
  hashCode(key) {}
  put(key, value) {
    if (key != null && value != null) {
      const position = this.hashCode(key);
      if (this.table[position] == null) {
        // LinkedList 是链表数据结构
        this.table[position] = new LinkedList();
      }
      this.table[position].push(value);
      return true;
    }
    return false;
  }
  get(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 === value) {
          return current.element;
        }
        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 === value) {
          linkedList.remove(current.element);
          if (linkedList.isEmpty()) {
            delete this.table[position];
          }
          return true;
        }
        current = current.next;
      }
    }
    return false;
  }
}

Tip:一个良好的散列函数应该有较低冲突的可能性

其他章节请看:

前端学习 数据结构与算法 快速入门 系列

posted @ 2021-11-23 10:07  彭加李  阅读(143)  评论(0编辑  收藏  举报