算法-散列表

160 阅读7分钟

散列表

散列表(Hash table,也叫哈希表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。

  • 存储数据时:

    • 输入一个key,把key传递给散列函数处理,计算出一个散列地址
    • 散列地址作为数组索引,将数据存储在该索引对应的空间上
  • 查询数据时:

    • 输入key,通过散列函数计算出对应的数组下标(散列地址),通过索引获取数据

散列函数

key => hashFn(key) => value

必须满足以下条件:

  • 散列值(value)是一个非负数:常见的学号、内存寻址呀,都要求散列值不可能是负数
  • key 值相同,通过散列函数计算出来的散列值(value)一定相同
  • key 值不同,通过散列函数计算出来的散列值(value)不一定不相同

有时候不同值可能会计算出同样的值,这时就会出现冲突,有时候散列函数太复杂,计算耗时过长,这些都会影响性能,所以好的散列函数应该具有如下特点:

好的散列函数需要具有以下基本要求:

  • 易计算:它应该易于计算,并且不能成为算法本身。
  • 统一分布:它应该在哈希表中提供统一分布,不应导致群集。
  • 冲突少:当元素对映射到相同的哈希值时发生冲突。应该避免这些。

例子

试想你在写一个快餐店的点单程序,准备实现一个展示各种食物及相应价格的菜单。你可能会用数组来做(当然这没问题)。

menu = [ ["french fries", 0.75],
 ["hamburger", 2.5],
 ["hot dog", 1.5],
 ["soda", 0.6]
]

该数组由一些子数组构成,每个子数组包含两个元素。第一个元素是表示食物名称的字符串,第二个元素是该食物的价格。 如果现在需要查询hot dog的价格,需要遍历数组,取出每一个子数组,查询出食物名称为hot dog的,在获取价格,如果数组是无序的,需要O(N)步。有序数组则可以用二分查找,只需要O(log N)步。

使用散列表可以O(1)步查询到价格

常见散列函数

  • 直接寻址法:取关键字或关键字的某个线性函数值为散列地址。
  • 数字分析法:对数据的分析,发现数据中冲突较少的部分,并构造散列地址。
  • 平方取中法:先求出关键字的平方值,然后按需要取平方值的中间几位作为散列地址。
  • 取随机数法:使用一个随机函数,取关键字的随机值作为散列地址,这种方式通常用于关键字长度不同的场合。
  • 除留取余法:取关键字被某个不大于散列表的表长 n 的数 m 除后所得的余数 p 为散列地址。

解决冲突

在散列里,冲突是不可避免的,常见的解决冲突方法有:

  • 开放地址法(也叫开放寻址法):实际上就是当需要存储值时,对Key哈希之后,发现这个地址已经有值了,这时该怎么办?不能放在这个地址,不然之前的映射会被覆盖。这时对计算出来的地址进行一个探测再哈希
  • 链地址法:链地址法其实就是对Key通过哈希之后落在同一个地址上的值,做一个链表。
  • 再哈希法:在产生冲突之后,使用关键字的其他部分继续计算地址,如果还是有冲突,则继续使用其他部分再计算地址。这种方式的缺点是时间增加了。
  • 建立一个公共溢出区:这种方式是建立一个公共溢出区,当地址存在冲突时,把新的地址放在公共溢出区里。

常数时间插入,删除和获取随机元素

设计一个支持在平均时间复杂度 O(1) 下,执行以下操作的数据结构。

  • insert(val) :当元素 val 不存在时,向集合中插入该项。
  • remove(val) :元素 val 存在时,从集合中移除该项。
  • getRandom :随机返回现有集合中的一项。每个元素应该有 相同的概率 被返回。

示例

// 初始化一个空的集合。
RandomizedSet randomSet = new RandomizedSet();

// 向集合中插入 1 。返回 true 表示 1 被成功地插入。
randomSet.insert(1);

// 返回 false ,表示集合中不存在 2 。
randomSet.remove(2);

// 向集合中插入 2 。返回 true 。集合现在包含 [1,2] 。
randomSet.insert(2);

// getRandom 应随机返回 1 或 2 。
randomSet.getRandom();

// 从集合中移除 1 ,返回 true 。集合现在包含 [2] 。
randomSet.remove(1);

// 2 已在集合中,所以返回 false 。
randomSet.insert(2);

// 由于 2 是集合中唯一的数字,getRandom 总是返回 2 。
randomSet.getRandom();

方式一:暴力破解法

  • 插入val时
    • 判断数组中是否存在val,存在就直接返回false; 否则把val存储到数组中,并返回true
  • 移除val时
    • 查询数组中是否存在val,已存在,移除元素,返回true; 否则返回false
  • 获取随机一项
    • 获取0到数组长度-1范围内的随机数,使用随机数作为索引,查询一个元素返回
class RandomizedSet {
  constructor() {
    this.items = []
  }
  isEmpty() {
    return this.isEmpty.length
  }
  isExist(val) {
    return this.items.indexOf(val) !== -1
    // return this.items.includes(val)
  }
  size() {
    return this.items.length
  }
  insert(val) {
    // 已存在,返回false
    // 不存在,存储元素,返回true
    if(this.isExist(val)) {
      return false
    } else {
      this.items.push(val)
      return true
    }
  }

  remove(val) {
    // 已存在,移除元素,返回true
    // 不存在,返回false
    if(this.isExist(val)) {
      this.items = this.items.filter(item => item !== val)
      return true
    } else {
      return false
    }
  }

  getRandom() {
    // 相同概率返回集合中的一项数据
    const size = this.size()
    // 获取0到数组长度-1范围内的随机数
    const random = parseInt(Math.random() * size)
    return this.items[random]
  }
}

/**
 * Your RandomizedSet object will be instantiated and called as such:
 * var obj = new RandomizedSet()
 * var param_1 = obj.insert(val)
 * var param_2 = obj.remove(val)
 * var param_3 = obj.getRandom()
 */

方式二:map+数组

  • 数组items存储元素
  • map存储元素和对应数组下标
class RandomizedSet {
  constructor() {
    this.items = []
    this.map = new Map()
  }
  size() {
    return this.items.length
  }
  insert(val) {
    // 已存在,返回false
    if(this.map.has(val)) {
      return false
    }
    // 不存在,存储元素,返回true
    this.map.set(val, this.size())
    this.items.push(val)
    return true
  }
  remove(val) {
    // 不存在,返回false
    if(!this.map.has(val)) {
      return false
    }
    const index = this.map.get(val)
    // 是数组最后元素
    if(index === this.size() - 1) {
      this.items.pop()
      this.map.delete(val)
    } else {
      // 不是最后一个元素,移除最后一个元素
      const lastVal = this.items.pop()
      // 把最后元素覆盖掉当前位置的元素
      this.items[index] = lastVal
      // 更新map中最后一个元素的索引
      this.map.set(lastVal, index)
      // 移除map中的当前val
      this.map.delete(val)
    }
    return true
  }

  getRandom() {
    // 相同概率返回集合中的一项数据
    const size = this.size()
    // 获取0到数组长度-1范围内的随机数
    const random = parseInt(Math.random() * size)
    return this.items[random]
  }
}

方式三:使用set

class RandomizedSet {
  constructor() {
    this.set = new Set()
  }
  insert(val) {
    // 已存在,返回false
    if(this.set.has(val)) {
      return false
    }
    // 不存在,存储元素,返回true
    this.set.add(val)
    return true
  }
  remove(val) {
    // 不存在,返回false
    if(!this.set.has(val)) {
      return false
    }
    // 存在,移除val元素
    this.set.delete(val)
    return true
  }

  getRandom() {
    // 相同概率返回集合中的一项数据
    const size = this.set.size
    // 获取0到数组长度-1范围内的随机数
    const random = parseInt(Math.random() * size)
    return Array.from(this.set)[random]
  }
}

第一个只出现一次的字符

在字符串 s 中找出第一个只出现一次的字符。如果没有,返回一个单空格。 s 只包含小写字母。

示例:

s = "abaccdeff"
返回 "b"

s = "" 
返回 " "

思路

  • 遍历字符串,使用map记录字符和出现次数
  • 第二次遍历字符串,获取当前字符串,使用当前字符在map中获取出现次数,判断次数是否等于1
    • 如果等于1,返回当前字符
  • 如果字符串中没有满足条件的,返回单个空字符' '
const firstUniqChar = function(s) {
  const map = new Map()
  // 统计每个字符串出现的次数
  for(const ch of s) {
    map.set(ch, (map.get(ch) || 0)+1)
  }
  for(const ch of s) {
    // 当前字符只出现一次
    if(map.get(ch) === 1) {
      return ch
    }
  }
  return ' '
}