数据结构和算法(九)之哈希表实现

数据结构和算法(九)之哈希表实现

前面,我们使用了大量的篇幅来解析哈希表的理论知识。

现在,我们进入代码的实施阶段,但是实施之前,先来深入一个比较重要的话题:哈希函数。

一. 哈希函数

我们这里来探讨一下,设计好的哈希函数应该具备哪些优点。

快速的计算

  • 好的哈希函数应该尽可能让计算的过程变得简单,应该可以快速计算出结果。

    • 哈希表的主要优点是他的速度,所以在速度上不能满足,那么就达不到设计的目的了。
    • 提高速度的一个方法就是让哈希函数中尽量少的有乘法和除法。因为他们的性能是比较低的。
  • 在前面,我们计算哈希值得时候使用的方式

    • c a t s = 3 ∗ 2 7 3 + 1 ∗ 2 7 2 + 20 ∗ 27 + 17 = 60337 cats=3*27^3+1*27^2+20*27+17=60337 cats=3273+1272+2027+17=60337

    • 这种方式是直观的计算结果,那么这种计算方式会进行几次乘法几次加法呢?当然,我们可能不止4项,可能有更多项

    • 我们抽象一下,这个表达式其实是一个多项式: a ( n ) ∗ x n + a ( n − 1 ) x ∗ ( n − 1 ) + . . . + a ( 1 ) ∗ x + a ( 0 ) a(n)*x^{n+a(n-1)x}*(n-1)+...+a(1)*x+a(0) a(n)xn+a(n1)x(n1)+...+a(1)x+a(0)

    • 现在问题就变成了多项式有多少次乘法和加法:

      • 乘法次数: n + ( n − 1 ) + . . . + 1 = n ( n + 1 ) / 2 n+(n-1)+...+1=n(n+1)/2 n+(n1)+...+1=n(n+1)/2
      • 加法次数:n
  • 多项式的优化:霍纳法则

    • 解决这类求值问题的高效算法——霍纳法则。在中国,霍纳法则也被称为秦九韶算法。
    • 通过如下变换我们可以得到一种快得多的算法,即 P n ( x ) = a n x n + a ( n − 1 ) x ( n − 1 ) + … + a 1 x + a 0 = ( ( … ( ( ( a n x + a n - 1 ) x + a n - 2 ) x + a n - 3 ) … ) x + a 1 ) x + a 0 Pn(x)= anx^{n+a(n-1)x}(n-1)+…+a1x+a0=((…(((anx +an-1)x+an-2)x+ an-3)…)x+a1)x+a0 Pn(x)=anxn+a(n1)x(n1)++a1x+a0=(((((anx+an1)x+an2)x+an3))x+a1)x+a0,这种求值的安排我们称为霍纳法则。
    • 变换后,我们需要多少次乘法,多少次加法呢?
      • 乘法次数:N次
      • 加法次数:N次
    • 如果使用大O表示时间复杂度的话,我们直接从 O ( N 2 ) O(N^2) O(N2)降到了 O ( N ) O(N) O(N).

均匀的分布

  • 均匀的分布
    • 在设计哈希表时,我们已经有办法处理映射到相同下标值得情况:链地址法或者开放地址法。
    • 但是,为了提高效率,最好的情况还是让数据在哈希表中均匀分布。
    • 因此,我们需要在使用常量的地方,尽量使用质数。
    • 哪些地方我们会使用到常量呢?
  • 质数的使用:
    • 哈希表的长度。
    • N次幂的底数(我们之前使用的是27)
    • 下面我们简单来说一下为什么。
  • 哈希表的长度使用质数:
    • 这个在链地址法中事实上重要性不是特别明显,明显的是在开放地址中的再哈希法中。
    • 再哈希法中质数的重要性:
      • 假设表的容量不是质数,例如:表长为15(下标值0~14)
      • 有一个特定关键字映射到0,步长为5.探测序列是多少呢?
      • 0—5—10—0—5—10,依次类推,循环下去。
      • 算法只尝试着三个单元,如果这三个单元已经有了数据,那么会一直循环下去,直到程序崩溃。
      • 如果容量是一个质数,比如13探测序列是多少呢?
      • 0—5—10—2—7—12—4—9—1—6—11—3,一直这样下去。
      • 不仅不会产生循环,而且可以让数据在哈希表中更加均匀的分布。
    • 链地址法中质数没有那么重要,甚至在Java中故意是2的N次幂
      • Java中的哈希表采用的是链地址法。
      • HashMap的初始化长度是16,每次自动扩展(我们还没有聊到扩展的话题),长度必须是2的次幂。
      • 这是为了服务于从key映射到index的算法。
      • HashMap中为了提高效率,采用了位运算的方法。
        • HashMapindex的计算公式:index=HashCode(key) & (Length - 1)
        • 比如计算bookhashcode,结果为十进制的3029737,二进制的1011100011101011101001
        • 假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111
        • 把以上两个结果做与运算,101110001110101101001 & 1111 =1001,十进制为9,所以index=9
        • 这样的方式相对于取模来说性能是高的,因为计算机更运算容易计算二进制的数据。
      • 但是,我发现JavaScript中进行大数据的位运算时会出问题,所以我的代码实现中还是使用了取模。
  • N次幂的底数,使用质数:
    • 这里采用质数的原因是为了产生的数据不按照某种规律递增。
    • 比如我们这里有一组数据时按照4进行递增的:0 4 8 12 16,将其映射到长度为 8 的哈希表中。
    • 他么的位置是多少呢?0—4—0—4,以此类推。
    • 如果我们哈希表本身不是质数,而我们递增的数量可以使用质数,比如 5,那么 0 5 10 15 20
    • 它们的位置是多少呢?0—5—2—7—4.依次类推。也可以尽量让数据均匀的分布。
    • 我们之前使用的是27,这次可以使用一个接近的数,比如31/37/41等等。一个比较常用的树是37.

哈希函数实现

  • 现在,我们就给出哈希函数的实现:

    function hashFunc(str,max) {
    	// 1.初始化hashCode的值
        let hashCode = 0
        
        // 2.霍纳算法,来计算hashCode的数值
        for(let i=0;i<str.length;i++) {
            hashCode = 37 * hashCode + str.charCodeAt(i)
        }
        
        // 3.取模运算
        hashCode = hashCode % max
        return hashCode
    }
    
  • 代码解析:

    • 理解了前面所有的内容,其实代码就非常简单了。
  • 代码测试:

    alert(hashFunc("abc",7));  // 4
    alert(hashFunc("cba",7));  // 3
    alert(hashFunc("nba",7));  // 5
    alert(hashFunc("mba",7));  // 1
    

二. 哈希表

我们这里采用链地址法来实现哈希表:

实现哈希表(基于storage的数组)每个index对应的是一个数组(bucket).(当然基于链表也可以)

bucket中存放什么呢?我们最好将keyvalue都放进去,我们继续使用一个数组。(其实其他语言使用元组更好)

最终我们的哈希表的数据格式是这样:[[[k,v],[k,v],[k,v]],[[k,v],[k,v]],[[k,v]]]

创建哈希表

  • 我们像封装其他数据结构一样,先来创建一个哈希表的类:

  • HashTable

    // 创建 HashTable构造函数
    function HashTable() {
        // 定义属性
        this.storage = []
        this.count = 0
        this.limit = 8
        
        // 定义相关的方法
        // 哈希函数
        HashTable.prototype.hashFunc = function(str,max) {
            // 1.初始化hashCode的值
            let hashCode = 0
            
            // 2.霍纳算法,来计算hashCode的数值
            for(let i=0;i < str.length;i++) {
                hashCode = 37 *hashCode +str.charCodeAt(i)
            }
            
            // 3.取模运算
            hashCode = hashCode % max
            return hashCode 
        }
    }
    
  • 代码解析:

    • storage作为我们的数组,数组中存放相关的元素。
    • count表示当前已经存在了多少数据。
    • limit用于标记数组中一共可以存放多少个元素。
    • 另外,我们直接将哈希函数定义在了HashTable中。

插入 & 修改数据

  • 现在,我们来做向哈希表中插入数据

    // 插入数据方法
    hashTable.prototype.put = function (key,value) {
        // 1.获取key对应的index
        let index = this.hashFunc(key, this.limit)
        
        // 2.取出数组(也可以使用链表)
        let bucket = this.storage[index]
        
        // 3.判断这个数组是否存在
        if(bucket === undefined) {
            // 3.1创建桶
            bucket = []
            this.storage[index] = bucket
        }
        alert(bucket)
        
        // 4.判断是新增还是修改原来的值
        let averride = false
        for(let i=0;i<bucket.length;i++) {
            let tuple = bucket[i]
            if(tuple[0] === key){
                tuple[1] = value
                override = true
            }
        }
        
        // 5.如果是新增,前一步没有覆盖
        if(!override) {
            bucket.push([key,value])
            this.count++
        }
    }
    
  • 代码解析:

    • 步骤1:根据传入的key获取对应的hashCode,也就是数组的index
    • 步骤2:从哈希表的index位置中取出桶(另外一个数组)
    • 步骤3:查看上一步的bucket是否为null
      • null,表示之前在该位置没有放置过任何的内容,那么就新建一个数组[]
    • 步骤4:查看是否之前已经放置过key对应的value
      • 如果放置过,那么就是依次替换操作,而不是插入新的数据
      • 我们使用一个变量override来记录是否修改操作
    • 步骤5:如果不是修改操作,那么插入新的数据。
      • bucketpush新的[key,value]即可
      • 注意:这里需要将count+1,因为数据增加了一项。

获取数据

  • 有插入和修改数据,就应该有根据key获取value

    // 获取存放的数据
    HashTable.prototype.get = function (key) {
    	// 1.获取key对应的index
        let index = this.hashFunc(key,this.limit)
        
        // 2.获取对应的bucket
        let bucket = this.storage[index]
        
        // 3.如果bucket为null,那么说明这个位置没有数据
        if(bucket == null) {
            return null
        }
        
        // 4.有bucket,判断是否有对应的key
        for(let i=0;i<bucket.length;i++){
            let tuple = bucket[i]
            if(tuple[0] === key) {
                return tuple[1]
            }
        }
        
        // 5.没有找到,return null
        return null
    }
    
  • 代码解析:

    • 步骤1:根据key获取hashCode(也就是index
    • 步骤2:根据index取出bucket
    • 步骤3:因为如果bucket都是null,那么说明这个位置之前并没有插入过数据
    • 步骤4:有了bucket,就遍历,并且如果找到,就将对应的value返回即可。
    • 步骤5:没有找到,返回null

删除数据

  • 我们根据对应的key,删除对应key/value

    // 删除数据
    HashTable.prototype.remove = function (key) {
    	// 1.获取 key 对应的 value
        let index = this.hashFunc(key,this.limit)
        
        // 2.获取对应的bucket
        let buckket = this.storage[index]
        
        // 3.判断桶是否为null,为null则说明没有对应的数据
        if(bucket === null) {
            return null
        }
        
        // 4.遍历bucket,寻找对应的数据
        for(let i=0;i<bucket.length;i++) {
            let tuple = bucket[i]
            if(tuple[0] === key){
                bucket.splice(i,1)
                this.count--
                return tuple[1]
            }
        }
        return null
    }
    

其他方法

  • 判断哈希表是否为空:isEmpty

    // isEmpty方法
    HashTable.prototype.isEmpty = function () {
        return this.count === 0
    }
    
  • 获取哈希表中数组的个数

    // size方法
    HashTable.prototype.size = function () {
    	return this.count
    }
    

哈希表测试

// 测试哈希表
// 1.创建哈希表
let ht = new HashTable()

// 2.插入数据
ht.put("abc","123")
ht.put("cba","321")
ht.put("nba","521")
ht.put("mba","520")

// 3.获取数据
alert(ht.get("abc"))
ht.put("abc","111")
alert(ht.get("abc"))

// 4.删除数据
alert(ht.remove("abc"))
alert(ht.get("abc"))

三. 哈希表扩容

哈希表扩容的思想

  • 为什么需要扩容?
    • 目前,我们是将所有的数据项放在长度为8的数组中的。
    • 因为我们使用的是链地址法,loadFactor可以大于1,所以这个哈希表可以无限制的插入新数据。
    • 但是,随着数据量的增多,每一个index对应的bucket会越来越长,也就造成效率的降低。
    • 所以,在合适的情况对数组进行扩容。比如扩容两倍。
  • 如何进行扩容?
    • 扩容可以简单的将容量增加大两倍(不是质数吗?质数的问题后面再讨论)
    • 但是这种情况下,所有的数据项一定要同时进行修改(重新哈希化,来获取到不同的位置)
    • 比如hashCode=12的数据项,在length=8的时候,index=4在长度为16的时候呢?index=12
    • 这是一个耗时的过程,但是如果数组需要扩容,那么这个过程是必要的。
  • 什么情况下扩容呢?
    • 比较常见的情况是loadFactor>0.75的时候进行扩容。
    • 比如java的哈希表就是在装填因子大于0.75的时候,对哈希表进行扩容

哈希表扩容的实现

  • 我们来实现扩容函数

    // 哈希表扩容
    HashTable.prototype.resize = function (newLimit) {
        // 1.保存旧的数组内容
        let oldStorage = this.storage
        
        // 2.重置属性
        this.limit = newLimit
        this.count = 0
        this.storage = []
        
        // 3.遍历旧数组中的所有数据项,并且重新插入到哈希表中
        oldStorage.forEach(function (bucket) {
             // 1.bucket为null,说明这里没有数据
            if(bucket == null) {
                return 
            }
            
            // 2.bucket 中有数据,那么将里面的数据重新哈希化插入
            for(let i=0; i<bucket.length;i++) {
                let tuple = bucket[i]
                this.put(tuple[0],tuple[1])
            }
        }.bind(this))
    }
    
  • 代码解析:

    • 步骤1:先将之前数组保存起来,因为我们待会儿会将storage=[]
    • 步骤2:之前的属性值需要重置。
    • 步骤3:遍历所有的数据项,重新插入到哈希表中
  • 在什么时候调用扩容方法呢?

    • 在每次添加完新的数据时,都进行判断。(也就是put方法中)
  • 修改put方法

    • 代码第5步中的内容
    // 插入数据方法
    HashTable.prototype.put = function (key,value) {
    	// 1.获取key对应的index
        let index = this.hashFunc(key,this.limit)
        
        // 2.取出数组(也可以使用链表)
        // 数组中放置数据的方式:[[ [k,v],[k,v],[k,v]],[ [k,v],[k,v]], [[k,v]]]
        let bucket =this.storage[index]
        
        // 3.判断这个数组是否存在
        if(bucket === undefined) {
            // 3.1创建桶
            bucket = []
            this.storage[inidex] = bucket
        }
        
        // 4.判断是新增还是修改原来的值
        let override = false
        for(let i=0;i<bucket.length;i++) {
            let tuple = bucket[i]
            if(tuple[0] === key) {
                tuple[1] = value
                override = true
            }
        }
        
        // 5.如果是新增,前一步没有覆盖
        if(!override) {
            bucket.push([key,value])
            this.count++
            // 数组扩容
            if(this.count > this.limit * 0.75) {
                this.resize(this.limit * 2)
            }
        }
    }
    
  • 如果我们不断地删除数据呢?

    • 如果不断地删除数据,当loadFactor < 0.25的时候,最好将数量限制在一半。
  • 修改remove方法

    • 修改代码第4步的内容
    // 删除数据
    HashTable.prototype.remove = function (key) {
    	// 1.获取 key 对应的 value
        let index = this.hashFunc(key,this.limit)
        
        // 2.获取对应的bucket
        let buckket = this.storage[index]
        
        // 3.判断桶是否为null,为null则说明没有对应的数据
        if(bucket === null) {
            return null
        }
        
        // 4.遍历bucket,寻找对应的数据
        for(let i=0;i<bucket.length;i++) {
            let tuple = bucket[i]
            if(tuple[0] === key){
                bucket.splice(i,1)
                this.count--
                
                // 缩小数组的容量
                if(this.limit > 8 && this.count < this.limit *0.25) {
                    this.resize(Math.floor(this.limit/2))
                }
            }
             return tuple[1]
        }
        return null
    }
    

四. 容量质数

我们前面提到过,容量最好是质数

虽然在链地址法中将容量设置为质数,没有在开放地址法中重要,但是其实链地址法中质数作为容量也更利于数据的均匀分布。所以,我们还是完成一下这个步骤。

判断质数

  • 我们这里先讨论一下常见的面试题,判断一个数是质数。

  • 质数的特点:

    • 质数也被称为素数
    • 质数表示大于1的自然数中,只能被1和自己整除的数
  • OK,了解了这个特点,就可以写出他的算法:

    function isPrime(num) {
    	for(let i=2;i<num;i++){
            if(num % i == 0){
                return false
            }
        }
        return true
    }
    
    // 测试
    alert(isPrime(3))     // true
    alert(isPrime(27))    // false
    alert(isPrime(37))    // true
    
  • 但是,这种做法的效率并不高。为什么呢?

    • 对于每个数n,其实并不需要从2判断到n-1
    • 一个数若可以进行因数分解,那么因数分解时得到的两个数一定是一个小于等于sqrt(n),一个大于等于sqrt(n).
    • 比如 16 可以被分解为 4 * 4 ,2 * 8
    • 所以其实我们遍历到等于sqrt(n)即可
    function isPrime(num) {
    	// 1.获取平方根
        let temp = parseInt(math.sqrt(num))
        
        // 2.循环判断
        for (let i=2;i<=temp;i++){
            if(num % i == 0){
                return false
            }
        }
        return true
    }
    

扩容的质数

  • 首先,将初始的limit8,改成 7

  • 前面,我们有对容量进行扩展,方式是:原来额容量 x 2

    • 比如之前的容量是 7 ,那么扩容后就是 14。14还是一个质数吗?
    • 显然不是,所以我们还需要一个方法,来是想一个新的容量为质数的算法。
  • 那么我们可以封装获取新的容量的代码(质数)

    // 判断一个数是否为质数
    HashTable.prototype.isPrime = function (num) {
        let temp = parseInt(Math.sqrt(num))
        // 2.循环判断
        for(let i=2;i<=temp;i++) {
            if(num % i == 0) {
                return false
            }
        }
        return true
    }
    
    // 获取质数
    HashTable.prototype.getPrime = function (num) {
        while (!isPrime) {
            num++
        }
        return num
    }
    
  • 插入数据的代码:

    // 扩容数组的数量
    if(this.count > this.limit * 0.75) {
        let primeNum = this.getPrime(this.limit * 2)
        this.resize(primeNum)
    }
    
  • 删除数据的代码:

    // 缩小数组的容量
    if(this.limit > 7 && this.count <this.limit * 0.25) {
        let primeNum = this.getPrime(Math.floor(this.limit / 2))
        this.resize(primeNum)
    }
    

五. 完整代码

// 创建HashTable构造函数
function HashTable() {
    // 定义属性
    this.storage = []
    this.count = 0
    this.limit = 8

    // 定义相关方法
    // 判断是否是质数
    HashTable.prototype.isPrime = function (num) {
        let temp = parseInt(Math.sqrt(num))
        // 2.循环判断
        for (let i = 2; i <= temp; i++) {
            if (num % i == 0) {
                return false
            }
        }
        return true
    }

    // 获取质数
    HashTable.prototype.getPrime = function (num) {
        while (!isPrime(num)) {
            num++
        }
        return num
    }

    // 哈希函数
    HashTable.prototype.hashFunc = function(str, max) {
        // 1.初始化hashCode的值
        let hashCode = 0

        // 2.霍纳算法, 来计算hashCode的数值
        for (let i = 0; i < str.length; i++) {
            hashCode = 37 * hashCode + str.charCodeAt(i)
        }

        // 3.取模运算
        hashCode = hashCode % max
        return hashCode
    }

    // 插入数据方法
    HashTable.prototype.put = function (key, value) {
        // 1.获取key对应的index
        let index = this.hashFunc(key, this.limit)

        // 2.取出数组(也可以使用链表)
        // 数组中放置数据的方式: [[ [k,v], [k,v], [k,v] ] , [ [k,v], [k,v] ]  [ [k,v] ] ]
        let bucket = this.storage[index]

        // 3.判断这个数组是否存在
        if (bucket === undefined) {
            // 3.1创建桶
            bucket = []
            this.storage[index] = bucket
        }

        // 4.判断是新增还是修改原来的值.
        let override = false
        for (let i = 0; i < bucket.length; i++) {
           let tuple = bucket[i]
            if (tuple[0] === key) {
                tuple[1] = value
                override = true
            }
        }

        // 5.如果是新增, 前一步没有覆盖
        if (!override) {
            bucket.push([key, value])
            this.count++

            if (this.count > this.limit * 0.75) {
                let primeNum = this.getPrime(this.limit * 2)
                this.resize(primeNum)
            }
        }
    }

    // 获取存放的数据
    HashTable.prototype.get = function (key) {
        // 1.获取key对应的index
        let index = this.hashFunc(key, this.limit)

        // 2.获取对应的bucket
        let bucket = this.storage[index]

        // 3.如果bucket为null, 那么说明这个位置没有数据
        if (bucket == null) {
            return null
        }

        // 4.有bucket, 判断是否有对应的key
        for (let i = 0; i < bucket.length; i++) {
            let tuple = bucket[i]
            if (tuple[0] === key) {
                return tuple[1]
            }
        }

        // 5.没有找到, return null
        return null
    }

    // 删除数据
    HashTable.prototype.remove = function (key) {
        // 1.获取key对应的index
        let index = this.hashFunc(key, this.limit)

        // 2.获取对应的bucket
       let bucket = this.storage[index]

        // 3.判断同是否为null, 为null则说明没有对应的数据
        if (bucket == null) {
            return null
        }

        // 4.遍历bucket, 寻找对应的数据
        for (let i = 0; i < bucket.length; i++) {
            let tuple = bucket[i]
            if (tuple[0] === key) {
                bucket.splice(i, 1)
                this.count--

                // 缩小数组的容量
                if (this.limit > 7 && this.count < this.limit * 0.25) {
                   let primeNum = this.getPrime(Math.floor(this.limit / 2))
                    this.resize(primeNum)
                }
            }
            return tuple[1]
        }

        // 5.来到该位置, 说明没有对应的数据, 那么返回null
        return null
    }

    // isEmpty方法
    HashTable.prototype.isEmpty = function () {
        return this.count == 0
    }

    // size方法
    HashTable.prototype.size = function () {
        return this.count
    }

    // 哈希表扩容
    HashTable.prototype.resize = function (newLimit) {
        // 1.保存旧的数组内容
        let oldStorage = this.storage

        // 2.重置属性
        this.limit = newLimit
        this.count = 0
        this.storage = []

        // 3.遍历旧数组中的所有数据项, 并且重新插入到哈希表中
        oldStorage.forEach(function (bucket) {
            // 1.bucket为null, 说明这里面没有数据
            if (bucket == null) {
                return
            }

            // 2.bucket中有数据, 那么将里面的数据重新哈希化插入
            for (let i = 0; i < bucket.length; i++) {
                let tuple = bucket[i]
                this.put(tuple[0], tuple[1])
            }
        }).bind(this)
    }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yige001

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值