数据结构—散列—PART1(散列的Java实现)

0. 前言

我是一名数据结构与算法的初学者,为了巩固知识点,与更多的IT朋友们交流,我将在CSDN社区发布数据结构与算法系列的学习总结。根据学习进度,内容大概1-2周一更,欢迎大家对相关知识点进行校正和补充,共同进步,谢谢~

1. 概念

这一章将介绍散列的基本知识以及一些核心思想。高亮部分为重要知识点或需要掌握的概念。1.1节介绍什么是散列表,1.2-1.4节介绍散列表的核心思想:散列函数、解决冲突、再散列

1.1 什么是散列表

散列表是一种以常数平均时间执行增、删、查的技术,但不支持排序。
散列表其实是包含一些项的具有固定大小的数组大小为TableSize,如下表。向数组中添加元素时,将项映射到 [0,TableSize)中的某个数index,然后把这个元素添加到数组中相应的位置。这就是散列表的基本想法,接下来就要解决如何映射的问题。

角标
0张三 18
1
2王五 18
3赵六 18
TableSize-1

1.2 散列函数h(x)

1.1节介绍了散列的基本工作原理,在这一节将解决映射的问题,给定一个项,我们可以根据这个项中的某个关键字进行映射,散列函数将关键字转换成散列表数组的角标。

  1. 当关键字Key为整数时,令散列函数h(x) = Key % TableSize,这种方法最为简单,但是需要考虑的是,若表的大小是10,而项的关键字个位都是0时,所有的项都被映射到0这个位置,就发生了冲突,为了避免冲突,最好的方法是将TableSize设置为质数,这样冲突就会减少,但还不能完全避免,比如TableSize = 11,而关键字是11的倍数。本文将在1.3中介绍解决冲突的方法,在第2章中给出散列的java代码实现。
  2. 当关键字是字符串时,散列函数见Code1
    //Code1 关键字是字符串时的散列函数
    //一个良好的散列函数,根据Horner法则计算
    public int hash(String key, int tableSize){
    	int hasVal = 0;
    
    	for (int i = 0; i < key.length(); i++) {
    		hasVal = 37 * hasVal + key.charAt(i);
    	}
    
    	hasVal %= tableSize;
    	if(hasVal < 0)//计算出来的hasVal可能会溢出,产生负数,因此需要进行判断
    		hasVal += tableSize;
    
    return hasVal;
    }
  3. 使用对象进行散列:散列函数并不是一成不变的,只是有些散列函数能把关键字均匀的分配,不易造成冲突,而有些散列函数映射出来的结果很差。其实Object的hashCode()方法可以为我们获取对象的哈希值,我们可以在此基础上写散列函数,需要时,也可以覆盖hashCode()方法。
    //基于对象哈希值的散列函数	
    public int hash(Object obj, int tableSize){
    int hasVal = obj.hashCode();
    
    hasVal %= tableSize;
    
    if(hasVal < 0)
    	hasVal += tableSize;
    return hasVal;
    }

1.2 解决冲突

本节解释发生冲突时如何解决冲突,但本节并不能解决全部的冲突,这个小问题留在1.3中解决。
将一个新的项存入散列表的某个位置时,这个位置可能已经被其他项占据,这称之为冲突。解决冲突的方法有几种,最简单的是分离链接法开放定址法,而开放定址法又分为线性探测、平方探测以及双散列。根据方法的不同,我们有不同的散列实现,见第2章。以下介绍这些方法的基本原理和基本概念。

  1. 分离链接法-分离散列表
    将散列到同一个值的所有元素保存到一个表(LinkedList)中,这样便解决了冲突。散列表数组存储的元素为LinkedList链表,而每个linkedList储存冲突的项。比如向链表中插入2时发现3占据了这个位置,但是没关系,用链表把3和2串起来一起放到这个位置即可。将散列到同一个值的所有元素保存到一个表(LinkedList)中,这样便解决了冲突。散列表数组存储的元素为LinkedList链表,而每个linkedList储存冲突的项。比如向链表中插入2时发现3占据了这个位置,但是没关系,用链表把3和2串起来一起放到这个位置即可。
    执行查找时,使用散列函数来确定需要遍历哪个链表;执行插入时,检查对应链表中是否存在这个元素,若不存在,则在链表的前端插入,这么做的原因是:新插入的元素最有可能不久后又被访问。
角标数组存储的链表
0linkedList0 2->3->4->1
1linkedList1 5->6->7->8
2linkedList2 12->10->11->9
3linkedList3 13->14->16->15
TableSize-1linkedList… …->…->…->…
  1. 开放定址法-探测散列表
    当储存元素发生冲突时,这种方法尝试另外的单元h(x),直到找出空单元位置。根据1.2节的散列函数hash(x)构造新的散列函数散列函数hi(x)
    hi(x) = ( hash( x ) + f( i ) ) % tableSize
    通过试探h0(x)、h1(x)、h2(x)……这些单元,找到空位。hash(x)是1.2节的散列函数,f(i)函数的作用是解决冲突,hi(x)是新的散列函数。
    根据f(i)的不同,开放定址法可分为线性探测法、平方探测法、双散列法
    总之,开放地址法的思想是,当发生冲突时,我们从冲突发生的位置按照一定的规则不断试探其他单元,直到找到空单元时把元素存入。但这样的努力并不能解决所有冲突,有时可能永远也找不到空位,这样的探测可能是失效的,对于这个问题,1.3给出解决办法。

1.3 再散列

考虑一种情况,当表的大小固定而存储的元素数不断增多时,对于分散链接法,链表长度将不断增加,导致查找和删除时运行时间的增加(因为要遍历链表);而对于开放定址法,元素不断增多时,可能导致元素找不到空位,从而引发死循环,造成致命错误。再散列指当元素数目过多时,增大散列表TableSize的大小。
因此,分离链接法通过在散列降低删除和查找操作的复杂度;开放定址法通过再散列降低运算复杂度,并解决元素找不到空位这种致命的错误,这回应了1.2节中遗留的问题。
那么元素过多怎么定义,具体什么时候需要进行再散列呢?
定义散列表的装填因子R为散列表中元素个数与该表大小的比,对于分离连接法,一般使得R = 1,如果 R > 1 则进行再散列;对于开放地址法,R > 0.5 的时候进行再散列
相关定理表明,如果使用平方探测,且表大小是质数时,那么当表至少有一半是空的时候,总能插入一个新的元素。这解决了开放地址法中平方探测法的致命错误。为了减少冲突以及解决致命错误,我们要把散列表的大小设置为质数

2. 散列的实现(Java代码)

本章使用java语言实现分离链接法以及开放定址中的平方探测法,线性探测法以及双散列仅做简单介绍。

2.1 分离链接法散列表的Java实现

import java.util.LinkedList;
import java.util.List;

/**
 * 分离链接法实现散列表
 * 表中维护着一个数组array、元素个数currentSize
 * 定义了散列表默认大小
 */
public class MySeparateChainingHashTable<T> {
    /**
     * 默认构造方法
     */
    public MySeparateChainingHashTable(){this(DEFAULT_TABLE_SIZE); }

    /**
     * 创建一个具有指定大小的散列表
     * @param tableSize 指定散列表的大小
     */
    public MySeparateChainingHashTable(int tableSize){
        array =  new LinkedList[nextPrime(tableSize)];
        for (int i = 0; i < array.length; i++) {
            array[i] = new LinkedList<T>();
        }
        currentSize = 0;
    }

    /**
     * 将表置空
     */
    public void makeEmpty(){
        for(List list : array){
            list.clear();
        }
        currentSize = 0;
    }

    /**
     * 存储一个元素,若元素存在,则无需存储
     * 若元素不存再则存储该元素
     * 若装填因子>1则进行再散列
     * @param t 要存储的元素
     */
    public void insert(T t){
        List<T> list = array[myhash(t)];  //找到对应的链表

        if(!list.contains(t)){
            list.add(t);
            //若装填因子大于1,则进行再散列操作
            if(++currentSize > array.length)
                rehash();
        }
    }

    /**
     * 判断散列表中是否存在元素t
     * @param t 要查找的元素
     * @return  是否存在该元素
     */
    public boolean contains(T t){
        List<T> list = array[myhash(t)];
        return list.contains(t);
    }

    /**
     * 从散列表中删除元素t
     * @param t 想要删除的元素
     */
    public void remove(T t){
        if(contains(t)){
            List<T> list = array[myhash(t)];
            list.remove(t);
            currentSize--;
        }
    }

    /**
     * 判断散列表是否为空
     * @return
     */
    public boolean isEmpty(){
        return  size() == 0;
    }

    /**
     * 获取散列表大小
     * @return
     */
    public int size(){
        return  currentSize;
    }

    /*
    再散列,防止装填因子过大而增加运算复杂度
     */
    private void rehash() {
        List<T>[] oldArray = array;

        array = new LinkedList[nextPrime(oldArray.length * 2)];
        for (int i = 0; i < array.length; i++) {
            array[i] = new LinkedList<T>();
        }

        currentSize =0;
        for (List<T> list : oldArray){
            for(T e : list){
                insert(e);
            }
        }
    }

    /*
    散列函数,获取元素t对应散列表的数组角标
     */
    private int myhash(T t) {
        int hashVal = t.hashCode();

        hashVal %= array.length;
        if (hashVal < 0)
            hashVal += array.length;

        return  hashVal;
    }

    /*
    获取不小于tableSize的最小质数
     */
    private int nextPrime(int tableSize) {
        if(isPrime(tableSize)) return  tableSize;
        while(isPrime(tableSize))
            tableSize++;
        return tableSize;
    }

    /*
    判断一个数是否为质数
     */
    private boolean isPrime(int num) {
        if(num < 2) return false;
        for (int i = 2; i < num; i++) {
            if (num % i == 0)   return false;
        }
        return true;
    }

    //常量
    private static final int DEFAULT_TABLE_SIZE = 11;

    //维护的字段
    private List<T>[] array;
    private int currentSize;
}

下面对这个散列表进行一个简单的加载测试:

public static void  main(String[] args){
        MySeparateChainingHashTable t = new MySeparateChainingHashTable<String>();
        t.insert("aaa");
        t.insert("bbb");
        t.insert("ccc");
        System.out.println(t.contains("aaa"));  		//true
        System.out.println(t.contains("ddd"));  		//false
        t.remove("aaa");
        System.out.println(t.contains("aaa"));  		//false
        t.insert("ddd");
        System.out.println(t.contains("ddd"));  		//true
        System.out.println(t.size());             	//3
        System.out.println(t.isEmpty());          	//false
        t.makeEmpty();
        System.out.println(t.isEmpty());          	//true
        System.out.println(t.contains("ddd"));  		//false

        //加载测试
        for(int i =0; i < 1000; i++){
            t.insert("元素"+i);
        }
        System.out.println(t.contains("元素999"));	//true
        System.out.println(t.size());               //1000
        t.makeEmpty();
        System.out.println(t.contains("元素999"));	//false
    }

测试成功,证明散列表是可用的。

2.2 线性探测法简介

线性探测法是开放地址法的一种,只是规定其解决冲突的函数 f(i) = i。线性探测法容易造成表中元素的聚集,导致元素无法均匀的存入表中,增加了运算的复杂度(需要很多次探测才能解决冲突)。因此不推荐这种方法。

2.3 平方探测法散列表的Java实现

平方探测法解决了线性探测法的聚焦问题。平方探测法的f(i) = i2。值得强调的是,开放地址法的删除操作是懒惰删除。如果我们真的删除了某个元素,那么对跳过这个元素的其他元素执行contians()方法时将捕获到null,contains方法失效。我们写一个内部类HashEntry来保存元素信息。

/**
 * 开放地址法——平方探测实现散列表
 * 维护一个HashEntry<T>[]数组
 * 维护currentSize:当前元素个数
 * 定义了散列表的默认大小DEFAULT_TABLE_SIZE
 * @param <T>
 */
public class MyQuadraticProbingHashTable<T> {

    /**
     * 默认构造初始化,创建默认大小(11)的散列表
     */
    public MyQuadraticProbingHashTable(){
        this(DEFAULT_TABLE_SIZE);
    }

    /**
     * 创建指定大小的散列表
     * @param tableSize 接收一个散列表的大小
     */
    public MyQuadraticProbingHashTable(int tableSize){
        allocate(tableSize);
    }

    /**
     * 把元素插入到散列表中,若元素已存在,则什么也不做
     * 若装填因子过大则执行再散列
     * @param t 接收一个元素
     */
    public void insert(T t){
        int currentPost = findPos(t);
        if (!isActive(currentPost)){
            array[currentPost] = new HashEntry<T>(t,true);
            currentSize++;
            if (currentSize > array.length / 2)
                rehash();
        }
    }


    /**
     * 判断散列表中是否包含元素t
     * @return
     */
    public boolean contains(T t){
        return isActive(findPos(t));
    }

    /**
     * 将元素t从散列表中删除
     * @param t
     */
    public void remove(T t){
        if (contains(t)){
            array[findPos(t)].isActive = false;
            currentSize--;
        }
    }

    /**
     * 获得散列表中元素个数
     * @return
     */
    public int size(){
        return currentSize;
    }

    /**
     * 判断散列表是否为空
     * @return
     */
    public boolean isEmpty(){
        return size() ==0;
    }

    /**
     * 将散列表置空
     */
    public void makeEmpty(){
        allocate(array.length);
        currentSize = 0;
    }

    /*
    当装填因子 > 0.5时进行再散列,避免运算复杂度过大,避免出现致命的错误。
     */
    private void rehash() {
        HashEntry<T>[] oldArray = array;
        allocate(nextPrime(2 * oldArray.length));
        currentSize = 0;

        for(HashEntry<T> entry : oldArray){
            if (entry != null && entry.isActive)
            insert(entry.eletment);
        }
    }

    /*
    判断指定位置是否存在active的元素
     */
    private boolean isActive(int currentPost){
        return (array[currentPost] != null && array[currentPost].isActive);
    }

    /*
    返回元素t所在的位置,若散列表中没有元素t,则返回t可以添加的位置
    这个函数其实就是再平方法的散列函数
    */
    private int findPos(T t) {
        int currentPos = myhash(t);
        int offset = 1;

        while (array[currentPos] != null &&
                !array[currentPos].eletment.equals(t)){
            currentPos += offset;
            offset +=2;
            if(currentPos >= array.length)
                currentPos -= array.length;
        }

        return  currentPos;
    }

    /*
    散列函数,获取元素t对应散列表的数组角标
     */
    private int myhash(T t) {
        int hashVal = t.hashCode();

        hashVal %= array.length;
        if (hashVal < 0)
            hashVal += array.length;

        return  hashVal;
    }

    /*
    为数组HashEntry[]初始化
     */
    private void allocate(int size) {
        array = new HashEntry[nextPrime(size)];
    }

    /*
     获取不小于tableSize的最小质数
     */
    private int nextPrime(int tableSize) {
        if(isPrime(tableSize)) return  tableSize;
        while(isPrime(tableSize))
            tableSize++;
        return tableSize;
    }

    /*
    判断一个数是否为质数
     */
    private boolean isPrime(int num) {
        if(num < 2) return false;
        for (int i = 2; i < num; i++) {
            if (num % i == 0)   return false;
        }
        return true;
    }

    /*
    私有类,保存元素以及元素的状态,便于惰性删除
     */
    private static class HashEntry<T>{
        public T eletment;
        public boolean isActive;

        public HashEntry(T t){
            this(t, true);
        }

        public HashEntry(T t, boolean isActive){
            eletment = t;
            this.isActive = isActive;
        }
    }

    private static  final  int DEFAULT_TABLE_SIZE = 11;

    //维护的私有变量
    private HashEntry<T>[] array;
    private int currentSize;
}

下面对这个散列表进行一个简单的加载测试:

public static void  main(String[] args){
        MyQuadraticProbingHashTable<String> t = new MyQuadraticProbingHashTable<String>();
        t.insert("aaa");
        t.insert("bbb");
        t.insert("ccc");
        System.out.println(t.contains("aaa"));  		//true
        System.out.println(t.contains("ddd"));  		//false
        t.remove("aaa");
        System.out.println(t.contains("aaa"));  		//false
        t.insert("ddd");
        System.out.println(t.contains("ddd"));  		//true
        System.out.println(t.size());             	//3
        System.out.println(t.isEmpty());          	//false
        t.makeEmpty();
        System.out.println(t.isEmpty());          	//true
        System.out.println(t.contains("ddd"));  		//false

        //加载测试
        for(int i =0; i < 1000; i++){
            t.insert("元素"+i);
        }
        System.out.println(t.contains("元素999"));	//true
        System.out.println(t.size());               //1000
        t.makeEmpty();
        System.out.println(t.contains("元素999"));	//false
    }

测试通过!!

2.3 分离链表法与平方探测法的简要对比

  1. 很容易看出,分离链表法引入了链表数据结构LinkedList,而平方探测法没有用到多余的数据结构
  2. 分离链表法要分配链表的地址,这回消耗一定的资源
  3. 平方探测法实现的散列表比分离链表法大(装填因子的选择不同)

2.4 双散列法简介

双散列不提供代码实现,双散列即令冲突的函数f(i) = i*hash2(x)。双散列使用到了两个散列函数,hash2(x)的选择是非常重要的。

2.5 平方探测法与双散列法的对比

双散列需要用到第二个散列函数,这对于字符串这样的关键字,散列函数计算起来相当耗时。因此在实践中平方散列可能更快。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值