八大数据结构——哈希表(五)

哈希表

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

举个简单例子来理解:查汉语字典,如果按数组遍历来找,就相当于你从第一页翻到最后一页。现实生活中我们会怎么干?比如我们要找一个 “我” 字,你是不是会去按拼音 “wo” 去翻。

在上述例子中,其实就哈希表的思想,定义说的关键码值中,key就是拼音 “wo”,value就是汉字 "我” 。我们存放数据,将存两个值,一个value是我们原本要放的数据,另一个key是这个数据的关键码,查找时,通过key,我们可以快速查找到想要的数据。

哈希函数

上面定义,只介绍了我们存放数据,要存key和value两个值,找的时候就通过key来找,但具体怎么通过key来快速找呢?这就需要哈希函数。

哈希函数,看他后面两个字,就是一函数,函数大家很早都学过吧,我一个 y = f(x)不就是一个函数嘛!哈希函数也是一样,key就是x,我们算出来的y就是存放数据的地址,通常这个地址是一个索引。

这个哈希函数的表达式具体怎么写呢?没有固定的写法,这不是数学公式,没有唯一答案,要我们自己定义一个表达式。

但是有几种常用的写法,前人总结的,通常能解决我们一般问题:

1.直接定址法。

我们用H(key)表示哈希函数,就和f(x)一样,就一标记,不要纠结。那么H(key) = akey+b;就是一线性函数。特点:地址集合的大小 = 关键字集合的大小。
举例,现在有一批苹果,价钱是不一样的,有3元/斤的,4元/斤的,5元/斤,要我们统计每种的有几个。那么我们就设H(key) = 1
key + (-3);

索引keyvalue(假设的)
001000
112000
223000

2.数字分析法。

就是去分析要记录数字的特征,比如我们要记录同学的学号,学号的组成是:前四位为入学年份,后四位为个人编号,比如 20180011 。而我们现在要存储的学号都是2018年入学的,所以前面四位都是2018,那么我们主要看后四位,设H(key) = key mod 10000;

索引keyvalue(假设的)
111120180011
131320180013
141420180014

3.平方取中法。

先把数据开平方,然后取中间的几位数。
比如x=111,x^2=12321,key=232;
比如y=121,y^2=14641,key=464;

4.折叠法。

数字有很多位时,可以通过求各部分的和,再取几位来作为它的key。
比如:188 3452 4523,那么1+8+8=17,3+4+5+2=14,4+5+2+3=14,最后是171414,取后三位,key = 414

当然,也可以是 188 + 3452 + 4523 = 8163,取后三位,key = 163 。

5.除留取余法。

将key除以一个值,然后取余数,H(key) = key mod p,像上面数字分析法举的例子也是一个除留取余法。这里的p通常可以取表长 。

6.随机数法

选择一个随机函数,取关键字的随机函数值为它的哈希地址,即
H(key)=random(key),其中random为随机函数。通常用于关键字长度不等时采用此法。

哈希函数设计原则

1.注意空间的利用,不能要你存10个数,结果你算出来的key值有1,有9999的。
2.哈希函数计算起来不要太复杂,本来就是为了查找块,结果你设计一个深奥的数学题,电脑算都算半天。
3.减少哈希冲突。

哈希冲突

比如我们哈希函数设计为:H(key) = key mod 10;那么当key = 10和key = 100时,算出来的结果都是0,那么此时就是哈希冲突了。

哈希冲突:key1≠key2,H(key1) = H(key2); 这就是哈希冲突的定义。

通常情况下,我们设计的哈希函数都是会有冲突的,这是难以避免的。当然,如果你设计的哈希函数是用直接定址法,一个线性函数,我们知道在坐标轴上,一个y,只有一个x与之对应,是不会出现哈希冲突的。但是线性函数y的值域是无穷大的,我们电脑有无穷大的空间吗?所以一个好的哈希函数是考虑各方面的,冲突也难以避免。

哈希冲突的解决办法:

开放地址法

开放地址法可以这么理解,比如key算出来等于1,结果去索引为1的地方一看,发现已经有人了,这个时候允许你去其他索引不为1的地方存储,但是这个其他地方是有规则的。

开放地址法有一个公式:Hi=(H(key)+di) MOD m i=1,2,…,k(k<=m-1)

mod是取余的意思,m为表长,而di有三种取法:
1.线性探测再散列
di = 1,2,3,4…k(就是每次+1)
比如:
(1)(H(key)+1)mod m;
(2)(H(key)+2)mod m;
(3)(H(key)+3)mod m;
(4)(H(key)+4)mod m;

2.二次探测再散列
di = 12,-12,22,-22…k2,-k2(就是从1到k取平方,正负号交替)
比如:
(1)(H(key)+1)mod m;
(2)(H(key)-1)mod m;
(3)(H(key)+4)mod m;
(4)(H(key)-4)mod m;

3.伪随机探测再散列
di是一个伪随机数列(这是个复杂的命题,不研究

假设现在H(key) = key mod 5,现在有 [23,53,11,76,41] 这五个key值。

线性探测再散列:
比如53%5=3,但此时索引3已经存了23,那么(53 mod 5+1)mod5=4,而索引4此时无数据,就存到索引4 。(如果索引4还有数据了,那么(53 mod 5+2)mod5=0,一直到发现空位为止。)图中序号(1),(2)…代表尝试的次数。

索引01234
数据23
(1)53冲突53
11
(1)76冲突76
41(1)41冲突(2)41冲突(3)41冲突(4)41冲突
最终4111762353

二次探测再散列:
比如像上面的41,冲突了,那么第二次:(41 mod 5+1)mod5=2,第三次:(41 mod 5-1)mod5 = 0 。

索引01234
数据23
(1)53冲突53
11
(1)76冲突76
41(1)41冲突(2)41冲突
最终4111762353

上面演示了,我们存储数据时的情况,当我们需要查找数据时也是通过这种方法。比如像线性探测再散列,我们要查找key = 53的数据,先用53 mod 5 = 3,然后到索引为3的位置一看,发现key值不是53,那么就会在(53 mod 5 + 1)mod 5 = 4,再到索引为4的位置一看,就找到了。

注意: 开放地址法一般删除数据不是直接删除,而是放一个元素表示该位置已被删除,可以放新数据,比如-1 。因为我们查找元素,先看第一次算出来的位置是不是有元素,如果没有,那么有可能这个真的没有key值算出来是放这里的,后面也没有冲突元素,那我们应该以此为标识,结束查找。否则冲突算法可以一直算下去,难道一直找?

拉链法

拉链法就是 数组链表 的结合。用数组来存链表(就是定义一个链表类型的数组),发生冲突时,就在链表后面增加一个节点来存储。
拉链法
这种方法十分实用,且空间利用率也比较高,下面的实现会用到这种方法。且可以真正删除数据,不需要放置什么-1 ,且删除数据后查找的平均长度也会降低。

另外在Java8中:hashmap的实现原理就是 数组 + 链表 + 红黑树 (链表超过8则转为红黑树,小于6则变会链表) >> 加快查询。

平均查找长度分析(ASL)

查找过程中,关键码的比较次数,取决于产生冲突的多少,产生的冲突少,查找效率就高,产生的冲突多,查找效率就低。因此,影响产生冲突多少的因素,也就是影响查找效率的因素。影响产生冲突多少有以下三个因素:

  1. 哈希函数是否均匀(即你选取的哈希函数算出来的值是否均匀分布,空间利用率高,不会产生大量冲突);
  2. 处理冲突的方法;
  3. 哈希表的装填因子。散列表的装填因子定义为:α= 填入表中的元素个数 / 散列表的长度

α是散列表装满程度的标志因子。由于表长是定值,α与“填入表中的元素个数”成正比,所以,α越大,填入表中的元素较多,产生冲突的可能性就越大;α越小,填入表中的元素较少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是装填因子α的函数,只是不同处理冲突的方法有不同的函数。

下面是部分不同冲突解决方法的平均查找长度(ASL)
哈希表平均查找长度

通过α我们可以计算出哈希表的长度应该设计为多少。
比如我们用拉链法,希望平均查找长度小于等于4,
1+α/2<=4,
1+(n/m)/2<=4
m>=2*n/3
即表长m应该大于等于填入表中数据长度n的2/3 。如果n=9,就是我们要放入9个数据,那么表长m应该大于等于6 。

适用范围

哈希表是一种在时间和空间做出权衡的存储结构,面对不同的应用场景,我们可以通过调节哈希表的装载因子,来对时间和空间做出取舍。如果需要更快的查询时间,那么我们就让α变小,即牺牲更多的空间。如果需要更优的空间,可以让α变大,即缩小分配的空间。

对于要求高效查询速率和存储类似key-value这样的数据尤其适用。但一定要关注哈希冲突问题,尤其是使用开发地址法时,冲突频繁会浪费大量时间。

代码实现

基于Java,jdk1.8 。哈希函数使用除留取余法,冲突解决方法为拉链法。
1.首先,先把链表要用的节点写出来。要记录三个值,key,value和下一个节点的地址。

class HashNode<K, V> {
    //定义节点,存储key,数据和对下一个节点的引用
    public K key;
    public V val;
    public HashNode<K, V> nextNode;
}

2.定义要用的属性,就两个,一个数组,一个size用来纪录已存储元素个数。这里我们考虑随插入元素增多,而调整表长以维持α的情况。这里我没有将数组声明为链表类型数组,而是直接将头节点保存在数组里,因为不想再实现一个链表,既然是底层实现,那就不应该导包。

    //    定义存储容器,表长如果改变,原来的哈希算法也要调整,情况复杂,这里不予考虑,所以表长初始化就不给改变。
    private HashNode<K, V>[] array;
    //    记录已存储元素个数
    private int size;

3.构造方法,提供三个
(1)通过要存入的数据长度n和平均查找长度,计算出表长。
(2)通过要存入的数据长度n,α=0.75,计算出表长。
(3)默认表长为10 。

    //    size储存的数据量,speed平均查找长度,开放链地址法的平均查找长度Snc = 1 + a/2; 这里的a = n/m; 
    //    n就是size,m是表长。
    //    m表长满足,speed >= 1 + a / 2 = 1 + size / (2*m);
    public HashTable(int size, int speed) {
        if (speed <= 1) {
            //            平均查找长度不可能等于1,等于1是数组,小于1更不可能!
            throw new IllegalArgumentException("Speed Must More Than 1");
        }
        int m = (int) Math.ceil(size / (2 * (speed - 1)));
        array = new HashNode[m];
        size = 0;
    }

    //    默认a=0.75
    public HashTable(int size) {
        int m = (int) (size / 0.75);
        array = new HashNode[m];
        size = 0;
    }

    //    默认是m = 10;
    public HashTable() {
        array = new HashNode[10];
        size = 0;
    }

4.size()返回已纪录元素个数,isEmpty()返回当前哈希表是否为空。

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

5.定义哈希函数。这里我们使用除留取余法,并利用Java自带的hashCode()方法,这样能使非整数类型的key也能变成整数去参与运算,并返回整数作为索引。另外和十六进制编码0x7fffffff按位做与操作,确保返回的是一个正数。

    //    哈希函数,这里选用的是用java自带的hashCode()除以表长,取余数。
    private int hash(K key) {
        if (key == null) {
            throw new IllegalArgumentException("Key Can Not Be Null");
        }
        //        和0x7fffffff按位做与操作,保证得到的结果为正值,因为第一位0代表正,1代表负。
        return (key.hashCode() & 0x7fffffff) % array.length;
    }

6.插入数据操作,通过计算H(key),找到数组中对应的索引位置,并增加节点存储数据。

    //    插入,找到对应的链表,在链表末尾添加数据。
    public V put(K key, V val) {
        int hashCode = hash(key);
        if (array[hashCode] == null) {
            array[hashCode] = new HashNode<>();
            array[hashCode].key = key;
            array[hashCode].val = val;
            size++;
            return array[hashCode].val;
        } else {
            HashNode<K, V> node = array[hashCode];
            while (node.nextNode != null) {
                node = node.nextNode;
            }
            HashNode<K, V> newNode = new HashNode<>();
            newNode.key = key;
            newNode.val = val;
            node.nextNode = newNode;
            size++;
            return node.nextNode.val;
        }
    }

7.删除操作。要分情况处理,判断要删除的是不是头节点,删除节点的后面还有没有节点。

    //    删除等于key的数据。
    public V remove(K key) {
        int hashCode = hash(key);
        HashNode<K, V> node = array[hashCode];
        //        如果该位置起始无数据,返回false
        if (node == null) {
            return null;
        }
        if (node.key.equals(key)) {
            //            起始节点就是要删除的,那分两种情况进行删除操作
            //            1.起始节点后面还有节点,那就让第数组存储第二个节点,这样第一个节点就无法访问了。
            //            2.起始节点后面没有节点了,此时将起始节点=null。
            V val = node.val;
            if (node.nextNode != null) {
                array[hashCode] = node.nextNode;
            } else {
                array[hashCode] = null;
            }
            size--;
            return val;
        } else {
            while (node.nextNode != null) {
                if (node.nextNode.key.equals(key)) {
                    //                    如果目标元素后面还有节点,那就让目标元素的前一个节点和后一个节点相连,把目标元素给踢出了。
                    //                    如果目标元素后面无节点了,那就让目标元素的前一个节点的nextNode
                    //                    = null,不再指向目标元素。
                    V val = node.nextNode.val;
                    if (node.nextNode.nextNode != null) {
                        node.nextNode = node.nextNode.nextNode;
                    } else {
                        node.nextNode = null;
                    }
                    size--;
                    return val;
                }
                //                要把这个语句放在while块的最后,因为上面的判断,需要用到目标元素的前后节点,如果提前让指针下移,那目标元素的前一个节点就无法获得了。
                node = node.nextNode;
            }
            //            while循环中如果没有返回true,那说明已经把该链表找遍了,也还是没有目标元素,那就返回false。
            return null;
        }
    }

8.查询操作

    //    根据key值获取数据
    public V get(K key) {
        int hashCode = hash(key);
        HashNode<K, V> node = array[hashCode];
        //        如果该位置起始无数据,返回false
        if (node == null) {
            return null;
        }
        if (node.key.equals(key)) {
            return node.val;
        } else {
            while (node.nextNode != null) {
                node = node.nextNode;
                if (node.key.equals(key)) {
                    return node.val;
                }
            }
            return null;
        }
    }

9.最后再写两个方法,返回所有的key和value,返回的是一个枚举类,方便遍历,Enumeration这个类比较老,现在一般用 Iterator迭代器了。

    //    返回所有的key
    public Enumeration<K> keys() {
        return new Enumeration<K>() {
            int count = 0;
            int row = 0;
            HashNode<K, V> node = array[row];

            public boolean hasMoreElements() {
                return count < size;
            }

            public K nextElement() {
                if (count < size) {
                    while (node == null) {
                        row++;
                        node = array[row];
                    }
                    HashNode<K, V> temp = node;
                    node = node.nextNode;
                    count++;
                    return temp.key;
                }
                throw new NoSuchElementException("HashTable Enumeration");
            }
        };
    }

    //    返回所有的value
    public Enumeration<V> values() {
        return new Enumeration<V>() {
            int count = 0;
            int row = 0;
            HashNode<K, V> node = array[row];

            public boolean hasMoreElements() {
                return count < size;
            }

            public V nextElement() {
                if (count < size) {
                    while (node == null) {
                        row++;
                        node = array[row];
                    }
                    HashNode<K, V> temp = node;
                    node = node.nextNode;
                    count++;
                    return temp.val;
                }
                throw new NoSuchElementException("HashTable Enumeration");
            }
        };
    }

完整代码

import java.util.Enumeration;
import java.util.NoSuchElementException;

//哈希表,基于“开放链地址法”的冲突解决方法
public class HashTable<K, V> {
    //    定义存储容器,表长如果改变,原来的哈希算法也要调整,情况复杂,这里不予考虑,所以表长初始化就不给改变。
    private HashNode<K, V>[] array;
    //    记录已存储元素个数
    private int size;

    //    size储存的数据量,speed平均查找长度,开放链地址法的平均查找长度Snc = 1 + a/2; 这里的a = n/m; 
    //    n就是size,m是表长。
    //    m表长满足,speed >= 1 + a / 2 = 1 + size / (2*m);
    public HashTable(int size, int speed) {
        if (speed <= 1) {
            //            平均查找长度不可能等于1,等于1是数组,小于1更不可能!
            throw new IllegalArgumentException("Speed Must More Than 1");
        }
        int m = (int) Math.ceil(size / (2 * (speed - 1)));
        array = new HashNode[m];
        size = 0;
    }

    //    默认a=0.75
    public HashTable(int size) {
        int m = (int) (size / 0.75);
        array = new HashNode[m];
        size = 0;
    }

    //    默认是m = 10;
    public HashTable() {
        array = new HashNode[10];
        size = 0;
    }

    public int size() {
        return size;
    }

    public boolean isEmpty() {
        return size == 0;
    }

    //    哈希函数,这里选用的是用java自带的hashCode()除以表长,取余数。
    private int hash(K key) {
        if (key == null) {
            throw new IllegalArgumentException("Key Can Not Be Null");
        }
        //        和0x7fffffff按位做与操作,保证得到的结果为正值,因为第一位0代表正,1代表负。
        return (key.hashCode() & 0x7fffffff) % array.length;
    }

    //    插入,找到对应的链表,在链表末尾添加数据。
    public V put(K key, V val) {
        int hashCode = hash(key);
        if (array[hashCode] == null) {
            array[hashCode] = new HashNode<>();
            array[hashCode].key = key;
            array[hashCode].val = val;
            size++;
            return array[hashCode].val;
        } else {
            HashNode<K, V> node = array[hashCode];
            while (node.nextNode != null) {
                node = node.nextNode;
            }
            HashNode<K, V> newNode = new HashNode<>();
            newNode.key = key;
            newNode.val = val;
            node.nextNode = newNode;
            size++;
            return node.nextNode.val;
        }
    }

    //    删除等于key的数据。
    public V remove(K key) {
        int hashCode = hash(key);
        HashNode<K, V> node = array[hashCode];
        //        如果该位置起始无数据,返回false
        if (node == null) {
            return null;
        }
        if (node.key.equals(key)) {
            //            起始节点就是要删除的,那分两种情况进行删除操作
            //            1.起始节点后面还有节点,那就让第数组存储第二个节点,这样第一个节点就无法访问了。
            //            2.起始节点后面没有节点了,此时将起始节点=null。
            V val = node.val;
            if (node.nextNode != null) {
                array[hashCode] = node.nextNode;
            } else {
                array[hashCode] = null;
            }
            size--;
            return val;
        } else {
            while (node.nextNode != null) {
                if (node.nextNode.key.equals(key)) {
                    //                    如果目标元素后面还有节点,那就让目标元素的前一个节点和后一个节点相连,把目标元素给踢出了。
                    //                    如果目标元素后面无节点了,那就让目标元素的前一个节点的nextNode
                    //                    = null,不再指向目标元素。
                    V val = node.nextNode.val;
                    if (node.nextNode.nextNode != null) {
                        node.nextNode = node.nextNode.nextNode;
                    } else {
                        node.nextNode = null;
                    }
                    size--;
                    return val;
                }
                //                要把这个语句放在while块的最后,因为上面的判断,需要用到目标元素的前后节点,如果提前让指针下移,那目标元素的前一个节点就无法获得了。
                node = node.nextNode;
            }
            //            while循环中如果没有返回true,那说明已经把该链表找遍了,也还是没有目标元素,那就返回false。
            return null;
        }
    }

    //    根据key值获取数据
    public V get(K key) {
        int hashCode = hash(key);
        HashNode<K, V> node = array[hashCode];
        //        如果该位置起始无数据,返回false
        if (node == null) {
            return null;
        }
        if (node.key.equals(key)) {
            return node.val;
        } else {
            while (node.nextNode != null) {
                node = node.nextNode;
                if (node.key.equals(key)) {
                    return node.val;
                }
            }
            return null;
        }
    }

    //    返回所有的key
    public Enumeration<K> keys() {
        return new Enumeration<K>() {
            int count = 0;
            int row = 0;
            HashNode<K, V> node = array[row];

            public boolean hasMoreElements() {
                return count < size;
            }

            public K nextElement() {
                if (count < size) {
                    while (node == null) {
                        row++;
                        node = array[row];
                    }
                    HashNode<K, V> temp = node;
                    node = node.nextNode;
                    count++;
                    return temp.key;
                }
                throw new NoSuchElementException("HashTable Enumeration");
            }
        };
    }

    //    返回所有的value
    public Enumeration<V> values() {
        return new Enumeration<V>() {
            int count = 0;
            int row = 0;
            HashNode<K, V> node = array[row];

            public boolean hasMoreElements() {
                return count < size;
            }

            public V nextElement() {
                if (count < size) {
                    while (node == null) {
                        row++;
                        node = array[row];
                    }
                    HashNode<K, V> temp = node;
                    node = node.nextNode;
                    count++;
                    return temp.val;
                }
                throw new NoSuchElementException("HashTable Enumeration");
            }
        };
    }

}


class HashNode<K, V> {
    //定义节点,存储key,数据和对下一个节点的引用
    public K key;
    public V val;
    public HashNode<K, V> nextNode;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值