散列表(哈希表)知识详解

1. 散列表查找(哈希表)概述

  • 1.1 散列表查找定义

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系 f 使得每个关键字 key 对应一个存储位置 f(key)。 查找时,根据这个确定的关系找到给定值 key 的映射 f(key),若查找集合中存在这个记录,则必定在 f(key)位置上。

我们把这种对应关系 f 称为散列函数,有成为哈希(Hash)函数。按照这个思想,采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表(Hash Table)。那么关键字对应的记录存储位置我们称为散列地址。

  • 1.2 散列表查找步骤

整个散列过程其实就是两步。

  1. 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。不管什么记录,我们都需要用同一个散列函数计算出地址再存储。

  2. 当查找记录时,我们通过同样的散列函数计算记录的散列地址,按此散列地址访问该记录。说起来很简单,在哪存的,上哪去找,由于存取用的是同一个散列函数,因此结果当然也是相同的。

所以说,散列技术既是一种存储方法,也是一种查找方法。然而它与线性表、树、图等结构不同的是,前面几种结构,数据元素之间都存在某种逻辑关系,可以用连线图示表示出来,而散列技术的记录之间不存在什么逻辑关系,它只与关键字有关联。因此,散列主要是面向查找的存储结构。

散列技术最适合的求解问题是查找与给定值相等的记录。对于查找来说,简化了比较过程,效率就会大大提高。但万事有利就有弊,散列技术不具备很多常规数据结构的能力。

比如那种同样的关键字,它能对应很多记录的情况,就不适合用散列技术。一个班级几十个学生,他们的性别有男有女,你用关键字“男”去查找,对应的有许多学生的记录,这显然是不合适的。只有如用班级学生的学号或者身份证号来散列存储,此时一个号码唯一对应一个学生才比较合适。

同样散列表也不适合范围查找,比如查找一个班级18~22岁的同学,在散列表中没法进行。想获得表中记录的排序也不可能,像最大值、最小值等结果也都无法从散列表中计算出来。

我们说了这么多,散列函数应该如何设计?这个我们需要重点来讲解,总之设计一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。

另一个问题是冲突。在理想的情况下,每一个关键字,通过散列函数计算出来的地址都是不一样的,可现实中,这只是一个理想。我们时常会碰到两个关键字 key1 != key2,但是却有 f(key1) = f(key2),这种现象我们称为冲突(collision),并把key,和key,称为这个散列函数的同义词(synonym)。出现了冲突当然非常糟糕,这将造成数据查找错误。尽管我们可以通过精心设计的散列函数让冲突尽可能地少,但是不能完全避免。于是如何处理冲突就成了一个很重要的课题,这在我们后面也需要详细讲解。

2.散列函数的构造方法

好的散列函数的两个原则:

  1. 计算简单
  2. 散列地址分布均匀
  • 2.1 直接定址法

我们可以取关键字的某个线性函数值为散列地址,即f(key)=a*key+b(a、b为常数)这样的散列函数的优点就是简单、均匀,也不会产生冲突,但问题是这需要事先知道关键字的分布情况,适合查找表较小且连续的情况。由于这样的限制,在现实应用中,此方法虽然简单,但却并不常用。

  • 2.2 数字分析法

如果我们的关键字是位数较多的数字,比如我们的11位手机号“130xxx×1234”,其中前三位是接入号,一般对应不同运营商公司的子品牌,如130是联通如意通、136是移动神州行、153是电信等;中间四位是HLR识别号,表示用户号的归属地;后四位才是真正的用户号。

若我们现在要存储某家公司员工登记表,如果用手机号作为关键字,那么极有可能前7位都是相同的。那么我们选择后面的4位成为散列地址就是不错的选择。如果这样的抽取工作还是容易出现冲突问题,还可以对抽取出来的数字再进行反转(如1234改成4321)、右环位移(如1234改4123)、左环位移、甚至前两数与后两数叠加(如1234改成12+34=46)等方法。总的目的就是为了提供一个散列函数,能够合理地将关键字分配到散列表的各位置。

这里我们提到了一个关键词——抽取。抽取方法是使用关键字的一部分来计算散列存储位置的方法,这在散列函数中是常常用到的手段。

数字分析法通常适合处理关键字位数比较多的情况,如果事先知道关键字的分布且关键字的若干位分布较均匀,就可以考虑用这个方法

  • 2.3 平方取中法

这个方法计算很简单,假设关键字是1234,那么它的平方就是1522756,再抽取中间的3位就是227,用做散列地址。再比如关键字是4321,那么它的平方就是18671041,抽取中间的3位就可以是671,也可以是710,用做散列地址。平方取中法比较适合不知道关键字的分布,而位数又不是很多的情况。

  • 2.4 折叠法

折叠法是将关键字从左到右分割成位数相等的几部分(注意最后一部分位数不够时可以短些),然后将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。

比如我们的关键字是9876543210,散列表表长为3位,我们将它分为4组,987 654 321 0,然后将它们叠加求和987+654+321+0=1962,再求后3位得到散列地址为962。

有时可能这还不能够保证分布均匀,不妨从一端向另一端来回折叠后对齐相加。比如我们将987和321反转,再与654和0相加,变成789+654+123+0=1566,此时散列地址为566。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

  • 2.5 除留余数法

此方法为最常用的构造散列函数的方法。对于散列表长为m的散列函数公式为:

f(key)=key mod p(p≤m)

mod是取模(求余数)的意思。事实上,这方法不仅可以对关键字直接取模,也可在折叠、平方取中后再取模。很显然,本方法的关键就在于选择合适的p,p如果选得不好,就可能会产生同义词。

根据前辈们的经验,若散列表表长为m,通常p小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。

  • 2.6 随机数法

选择一个随机数,取关键字的随机函数值为它的散列地址。也就是(key)=random(key)。这里random是随机函数。当关键字的长度不等时,采用这个方法构造散列数是比较合适的。

有同学问,那如果关键字是字符串如何处理?其实无论是英文字符,还是中文字符,也包括各种各样的符号,它们都可以转化为某种数字来对待,比如ASCII码或者Unicode码等,因此也就可以使用上面的这些方法。总之,现实中,应该视不同的情况采用不同的散列函数。我们只能给出一些考虑的因素来提供参考:

  1. 计算散列地址所需的时间。
  2. 关键字的长度。
  3. 散列表的大小。
  4. 关键字的分布情况。
  5. 记录查找的频率。综合这些因素,才能决策选择哪种散列函数更合适。

3.处理散列(哈希)冲突的方法

3.1开放定址法

所谓的 开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入

它的公式是:fi(key)=(f(key)+di) MOD m (di=1,2,3...,m-1)

比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。我们用散列函数(key)-key mod 12

当计算前5个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入,如下表所示。

下标01234567891011
关键字1225166756

计算key=37时,发现(37)=1,此时就与25所在的位置冲突。于是我们应用上面的公式(37)=((37)+1) mod 12=2。于是将37存入下标为2的位置。这其实就是房子被人买了于是买下一间的作法,如下表所示。

下标01234567891011
关键字122516675622

接下来22,29,15,47都没有冲突,正常存入,如下表所示。

下标01234567891011
关键字12253715162967562247

到了key=48,我们计算得到/(48)=0,与12所在的0位置冲突了,不要紧,我们{48)=({48)+1)mod 12=1,此时又与25所在的位置冲突。于是(48)=((48)+2) mod 12=2,还是冲突……一直到/{48)=-(f(48)+6)mod 12=6时,才有空位,机不可失,赶快存入,如下表所示。

下标01234567891011
关键字1225371516294867562247

我们把这种解决冲突的开放定址法称为线性探测法

从这个例子我们也看到,在解决冲突的时候,还会碰到如48和37 这种本来都不是同义词却需要争夺一个地址的情况,我们称这种现象为堆积。很显然,堆积的出现,使得我们需要不断处理冲突,无论是存入还是查找效率都会大大降低。

考虑深一步,如果发生这样的情况,当最后一个key=34,(key)=10,与22所在的位置冲突,可是22后面没有空位置了,反而它的前面有一个空位置,尽管可以不断地求余数后得到结果,但效率很差。因此我们可以改进di=1^2 ,-1^2 ,2^2 ,-2^2 ,…,q^2 ,-q^2(q<=m/2)这样就等于是可以双向寻找到可能的空位置。对于34来说,我们取d=-1即可找到空位置了。另外增加平方运算的目的是为了不让关键字都聚集在某一块区域。我们称这种方法为二次探测法。

fi(key)=(f(key)+di) MOD m
(di=1^2 ,-1^2 ,2^2 ,-2^2 ,...,q^2 ,-q^2(q<=m/2))

还有一种方法是,在冲突时,对于位移量d采用随机函数计算得到,我们称之为随机探测法

此时一定有人问,既然是随机,那么查找的时候不也随机生成di吗?如何可以获得相同的地址呢?这是个问题。这里的随机其实是伪随机数。伪随机数是说,如果我们设置随机种子相同,则不断调用随机函数可以生成不会重复的数列,我们在查找时,用同样的随机种子,它每次得到的数列是相同的,相同的d,当然可以得到相同的散列地址。

嗯?随机种子又不知道?罢了罢了,不懂的还是去查阅资料吧,我不能在课上没完没了地介绍这些基础知识呀。

fi(key)=(f(key) +di)MOD m (di是一个随机数列)

总之,开放定址法只要在散列表未填满时,总是能找到不发生冲突的地址,是我们常用的解决冲突的办法。

3.2 再散列函数法

我们继续用买房子来举例,如果你看房时的选择标准总是以市中心、交通便利、价格适中为指标,这样的房子凤毛麟角,基本上当你看到时,都已经被人买去了。我们不妨换一种思维,选择市郊的房子,交通尽管要差一些,但价格便宜很多,也许房子还可以买得大一些、质量好一些,并且由于更换了选房的想法,很快就找到了你需要的房子了。

对于我们的散列表来说,我们事先准备多个散列函数。

fi(key)=RHi(key)(i=1,2...,k)

这里RHi就是不同的散列函数,你可以把我们前面说的什么除留余数、折叠、平方取中全部用上。每当发生散列地址冲突时,就换一个散列函数计算,相信总会有一个可以把冲突解决掉。这种方法能够使得关键字不产生聚集,当然,相应地也增加了计算的时间。

3.3 链地址法

思路还可以再换一换,为什么有冲突就要换地方呢,我们直接就在原地想办法不可以吗?于是我们就有了链地址法。

将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针。对于关键字集合{12,67,56,16,25,37,22,29,15,47,48,34},我们用前面同样的12为除数,进行除留余数法,可得到如下图结构,此时,已经不存在什么冲突换址的问题,无论有多少个冲突,都只是在当前位置给单链表增加结点的问题。

在这里插入图片描述

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝不会出现找不到地址的保障。当然,这也就带来了查找时需要遍历单链表的性能损耗。

3.4 公共溢出区法

这个方法其实就更好理解了,你不是冲突吗?好吧,凡是冲突的都跟我走,我给你们这些冲突找个地儿待着。这就如同孤儿院收留所有无家可归的孩子一样,我们为所有冲突的关键字建立了一个公共的溢出区来存放。

就前面的例子而言,我们共有三个关键字{37,48,34}与之前的关键字位置有冲突,那么就将它们存储到溢出表中,如下图所示。

在这里插入图片描述

在查找时,对给定值通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表去进行顺序查找。如果相对于基本表而言,有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的。

4. 散列表查找的实现

哈希表的应用

  1. 使用哈希表进行快速的查找,哈希表因为特殊的结构,所以查找速度很快
  2. 在web开发中可以作为缓存来减少对数据库的压力,可以先从数据库中取出数据存入哈希表,之后的查找操作可以通过查找哈希表来完成。

4.1 散列表查找的算法实现

此处采用尚硅谷中的内容进行代码实现
视频连接

google公司的一个上机题:
有一个公司,当有新的员工来报道时要求将该员工的信息加入(id,性别,年龄,住址…),当输入该员工的id时,要求查找到该员工的所有信息,

要求:

不使用数据库,速度越快越好=>哈希表(散列)
添加时,保证按照id从低到高插入[课后思考:如果id不是从低到高插入,但要求各条链表仍是从低到高,怎么解决?]

  1. 使用链表来实现哈希表,该链表不带表头
    [即:链表的第-一个结点就存放雇员信息]

  2. 思路分析并画出示意图

  3. 代码实现[增删改查(显示所有员工,按id查询)]

public class HashTabDemo {
    public static void main(String[] args) {
        //创建哈希表
        HashTab hashTab = new HashTab(7);
 
        //写一个简单菜单
        String key = "";
        Scanner scanner = new Scanner(System.in);
        while(true){
            System.out.println("************");
            System.out.println("add:添加雇员");
            System.out.println("list:显示雇员");
            System.out.println("find:查找雇员");
            System.out.println("delete:删除雇员");
            System.out.println("exit:退出");
 
 
            key = scanner.next();
            switch (key){
                case "add":
                    System.out.println("输入id:");
                    int id = scanner.nextInt();
                    System.out.println("输入名字:");
                    String name = scanner.next();
                    //创建雇员
                    Emp emp = new Emp(id, name);
                    hashTab.add(emp);
                    break;
                case "list":
                    hashTab.list();
                    break;
                case "find":
                    System.out.println("请输入要查找的id:");
                    id = scanner.nextInt();
                    hashTab.findEmpId(id);
                    break;
                case "delete":
                    System.out.println("输入id:");
                    id = scanner.nextInt();
                    hashTab.deleteEmpId(id);
                    break;
                case "exit":
                    scanner.close();
                    System.exit(0);
                default:
                    break;
            }
        }
    }
}
 
//创建哈希表,用来管理多条链表
class HashTab{
    private EmpLinkedList[] empLinkedListArray;
    private int size;
 
    //构造器
    public HashTab(int size){
        this.size = size;
        //初始化empLinkedListArray
        empLinkedListArray = new EmpLinkedList[size];
 
        for (int i = 0; i < size; i++) {
            empLinkedListArray[i] = new EmpLinkedList();
        }
    }
 
    //添加雇员
    public void add(Emp emp){
        //根据员工的id得到该员工应当添加到哪条链表
        int empLinkedListNo = hashFun(emp.id);
        //将emp添加到对应的链表中
        empLinkedListArray[empLinkedListNo].add(emp);
    }
 
    //遍历所有的链表
    public void list(){
        for (int i = 0; i < size; i++) {
            empLinkedListArray[i].list(i);
        }
    }
    //根据输入的id,查找雇员
    public void findEmpId(int id){
        //使用散列函数确定到哪条链表查找
        int empLinkeId = hashFun(id);
        Emp emp = empLinkedListArray[empLinkeId].findEmpById(id);
        if(emp != null){
            System.out.printf("在第%d条链表找到该雇员id = %d\n",(empLinkeId + 1),id);
        }else{
            System.out.println("没有该雇员!");
        }
    }
    //删除雇员
    public void deleteEmpId(int id){
        int empLinkedId = hashFun(id);
        Emp emp = empLinkedListArray[empLinkedId].deleteEmpById(id);
        if(emp != null){
            System.out.printf("删除的员工处于第%d条链表,id为:%d\n",(empLinkedId +1),id);
        }else{
            System.out.println("无法找到指定的员工");
        }
    }
    //编写散列函数,使用一个简单取模法
    public int hashFun(int id){
        return id % size;
    }
}
//表示一个雇员
class Emp {
    public int id;
    public String name;
    public Emp next; //默认为空
 
    public Emp(int id, String name) {
        super();
        this.id = id;
        this.name = name;
    }
}
//创建一个EmpLinkedList,表示链表
class EmpLinkedList {
    //头指针,第一个雇员Emp,因此我们这个链表的head,是直接指向第一个Emp
    private Emp head = new Emp(0,null);
    //添加员工
    public void add(Emp emp) {
        Emp curEmp = head;
        boolean flag = false;
        //如果是添加第一个雇员
        if (head.next == null) {
            head.next = emp;
            return;
        }
        //如果不是第一个雇员,定义一个辅助指针,帮助定位到最后
        while (true) {
            if (curEmp.next == null) { //已经找到链表尾
                break;
            }
            if (emp.id < curEmp.next.id) { //待插入的值在curEmp后面
                break;
            } else if (curEmp.next.id == emp.id) {
                flag = true;
                break;
            }
            curEmp = curEmp.next;
        }
        if (flag) {
            System.out.printf("待插入的员工编号%d已经存在\n", emp.id);
        } else {
            emp.next = curEmp.next;
            curEmp.next = emp;
        }
 
    }
    //遍历
    public void list(int no) {
        Emp curEmp = head;
        if (head.next == null) { //说明链表为空
            System.out.println("第 " + (no + 1) + " 链表为空");
            return;
        }
        System.out.print("第 " + (no + 1) + " 链表的信息为:");
 
        curEmp = curEmp.next;
        while (true) {
            System.out.printf(" ---》 id=%d name=%s\t", curEmp.id, curEmp.name);
            if (curEmp.next == null) {//说明curEmp已经是最后结点
                break;
            }
            curEmp = curEmp.next; //后移,遍历
        }
        System.out.println();
    }
    //根据id查找雇员
    public Emp findEmpById(int id) {
        if (head.next == null) {
            System.out.println("链表为空!");
            return null;
        }
        Emp curEmp = head.next;
        while (true) {
            if (curEmp.id == id) {
                break;
            }
            //退出
            if (curEmp.next == null) {
                curEmp = null;
                break;
            }
            curEmp = curEmp.next;
        }
        return curEmp;
    }
    //删除雇员
    public Emp deleteEmpById(int id) {
//        Emp curEmp_pro = null;
        Emp curEmp = head;
        boolean flag = false;
        if (curEmp.next == null) {
            System.out.println("没有雇员可开除");
            return null;
        }
 
        while (true) {
            if (curEmp.next.id == id) {
                flag = true;
                break;
            }
            curEmp = curEmp.next;
        }
        if (flag) {
            curEmp.next = curEmp.next.next;
        } else {
            System.out.printf("要删除的%d号员工不存在", id);
        }
        return curEmp;
    }
}

4.2 散列表查找的性能分析

最后,我们对散列表查找的性能作一个简单分析。如果没有冲突,散列查找是我们本章介绍的所有查找中效率最高的,因为它的时间复杂度为O(1)。可惜,我说的只是“如果”,没有冲突的散列只是一种理想,在实际的应用中,冲突是不可避免的。那么散列查找的平均查找长度取决于哪些因素呢?

1.散列函数是否均匀

散列函数的好坏直接影响着出现冲突的频繁程度,不过,由于不同的散列函数对同一组随机的关键字,产生冲突的可能性是相同的,因此我们可以不考虑它对平均查找长度的影响。

2.处理冲突的方法

相同的关键字、相同的散列函数,但处理冲突的方法不同,会使得平均查找长度不同。比如线性探测处理冲突可能会产生堆积,显然就没有二次探测法好,而链地址法处理冲突不会产生任何堆积,因而具有更佳的平均查找性能。

3.散列表的装填因子

所谓的装填因子a=填入表中的记录个数/散列表长度。a标志着散列表的装满程度。填入表中的记录越多,a就越大,产生冲突的可能性就越大。比如我们前面的例子,8.1.3小节链地址法的图所示,如果你的散列表长度是12,而填入表中的记录个数为11,那么此时的装填因子a=11/12-0.9167,再填入最后一个关键字产生冲突的可能性就非常之大。也就是说,散列表的平均查找长度取决于装填因子,而不是取决于查找集合中的记录个数。

不管记录个数n有多大,我们总可以选择一个合适的装填因子以便将平均查找长度限定在一个范围之内,此时我们散列查找的时间复杂度就真的是O(1)了。为了做到这一点,通常我们都是将散列表的空间设置得比查找集合大,此时虽然是浪费了一定的空间,但换来的是查找效率的大大提升,总的来说,还是非常值得的。

参考资料:《大话数据结构》

  • 25
    点赞
  • 118
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值