C#数据结构(3) 哈希表

导言

在实际生活中我们常常遇到这种情况:已知一个同学的姓名,要查他的成绩。假如我们将同学和成绩对应的信息储存在计算机里,需要使用什么样的方式存储,才能让这个查询过程变得高效?

之前我们已经研究过了顺序表和链表,我们可以定义一个结构,结构中有两个字段,分别是姓名和成绩,将结构一个一个存进顺序表和链表中。需要查找时,调用顺序表和链表的查找函数,逐个对比顺序表和链表中每个结构与目标姓名,发现目标姓名与结构中储存的姓名相同时,就返回结构中储存的成绩。

上述过程使用的方法是顺序查找。这种查找方式的时间复杂度是o(n),若从左到右查找,而元素在表的最右端时,查找次数就等于表的长度。当表中有大量元素时,顺序查找无疑是低效的。

人们又发现,二分法能让查找变得高效。假如一个序列已经从小到大排好序,(对于之前提到的姓名成绩对应的案例,就要根据姓名中的字符顺序进行排序),若我们取其最中间的元素,与目标元素进行大小对比,发现中间元素小于目标元素,则目标元素一定在中间元素的右边。再取右边的序列进行同样的操作,直到中间元素等于目标元素,或者查询已经结束而没有发现目标元素为止。

这种二分的查找,成功让查找的时间复杂度降低到了o(logn),但这依然不是一种完美的查找算法,因为它查找所需的操作次数依然与数据规模有关,而且必须要求序列有序。

最后人们从数据结构出发,成功设计出了一种支持具有常数级、或近似常数级时间复杂度的查找算法的数据结构。它基于哈希算法,因此被称为哈希表。

我们设有一对键值对,造表时,每次根据键生成一个唯一、或重复概率很小的整数,将值保存在一个数组下标为该整数的位置。再次查找的时候,只需要根据值计算出该整数,再到数组下标位置查询出对应的值即可。

假如我们可以根据每个同学的姓名生成一个键,将成绩保存在数组中键所对应的下标下,下次查找再根据姓名生成键,就能在常数时间内找到姓名对应的成绩。这个生成键的函数,就是哈希表中的哈希函数。

1.哈希函数

哈希函数的目的是将给出的数据进行一定操作,生成一个纵使原数据仅有微小的变动、也有极大不同的散列值,名为哈希值。这个值就是对于给出的数据(无论数据类型和规模)的一个摘要,它的应用至少有下列三种:

  1. 在哈希表中起作用
  2. 为可执行文件生成一个数字签名。由于原数据仅有微小的变动也可能导致哈希值极大的不同,通过在可执行文件执行时重新生成哈希值,和原来的数字签名对比,就能发现该可执行文件是否被篡改过。
  3. 加密。由于在哈希函数足够复杂时,很难通过哈希值推出原数据,因此常常被用于数据加密。如果在服务器上储存了密码的哈希值,用户上传用哈希函数加密过的密码和服务器中储存的哈希值一致时,就能通过验证,即使服务器被攻击、访问被拦截,攻击者也只能获得密码的哈希值,而难以获取密码的明文。

本文仅谈论哈希函数的第一种作用。下面给出一种迭代获取字符串哈希值的算法:
h a s h ( n ) = h a s h ( n 1 ) s e e d + n u t f 8 hash(n)=hash(n-1)*seed+字符串第n位的utf8值
由于这个值可能很大,最后我们还要将其对某个数X取模,才能得到适合作为数组下标的哈希值。

我们的目标是让字符串不同时,哈希值尽量分散,因此讨论seed和X的取值。

1.1seed的取值

在网上查了不少资料,都指出要使哈希值尽量分散,seed要取质数,但是很少能给出令人信服的理由,大部分都只是说“观察解空间可得”。所以这里我自己对seed的取值进行了一些研究。

在字符串只有一个字符时,hash值和seed无关。在字符串有两个字符时,设第一个字符utf-8值为x,第二个字符为utf-8值为y,哈希值为z,有 z = s e e d y + x z=seed*y+x ,可见这是一个三维坐标系下的、仅在第一卦限的平面方程,设该平面为S。当z为常数 z 0 z_0 时,平面 z = z 0 z=z_0 与S的交线如图所示。
在这里插入图片描述
当seed的值很大时,观察图形。
在这里插入图片描述
当seed的值很小时,观察图形。
在这里插入图片描述
可以直观地看出,seed的值越大,对于特定的z,S与 z = z 0 z=z_0 的交线越短,也就是说,越不可能取到两个不同的x、y,使得z相等。另外,z的值越大,S与 z = z 0 z=z_0 的交线越长,取到两个不同的x、y使得z相等的概率越大。

当字符串有三个字符时,设第三个字符为z,哈希值为a,有 a = s e e d 2 z + s e e d y + x a=seed^2*z+seed*y+x ,这是一个四维空间中的、仅存在于x、y、z均大于0的空间中的体的表达式,其与体 a = a 0 a=a_0 的交面在三维空间中如图所示。
在这里插入图片描述
由于 y 0 = a 0 s e e d y_0=\frac {a_0}{seed} z 0 = a 0 s e e d 2 z_0=\frac {a_0}{seed^2} ,观察可得seed越大时,该交面越小。

事实上无论是在几维空间中,只要图形被限定在了所有变量均大于0的区域,seed的值越大,意味着除了x以外的所有变量的自由度都会减小,不同的变量产生同样的哈希值的概率越低。因此,我们不难得出seed越大,哈希值越离散的结论。至于取质数这个问题,比起和解决哈希值冲突问题有关,更多的是为了降低哈希值原数据被反推的几率,这和我们今天要讨论的哈希表暂时没有关系。

1.2X的取值

X被作为取模的除数,而取模运算的特性是:模的取值是0除数-1。由于utf-8值是均匀连续的,因此取模运算的可能结果也是在0除数-1之间连续的。所以,X取值的第一个条件就是:必须小于等于哈希表数组空间+1,防止下标访问错误。

由于取模操作实际上是对于空间的一种压缩:将原本分布在大小为n的空间上的哈希值压缩到大小为n%X的空间内,因此必定会降低原本哈希值的离散度。又由于n%X的取值范围是0~X-1,因此X越小,最终空间就越小,哈希值的离散度就越低。然而为了避免在储存少量数据时哈希表造成大量的空间浪费,压缩操作又是必不可少的。所以,X取值的第二个条件是:和自己要储存的数据总量正相关。

最后,取模前的哈希值为 h a s h = k s e e d + u t f 8 hash=k*seed+字符串最后一位的utf8值 ,k为任意正整数。我们会发现,假如X是seed的因数,那么k*seed这一项余X一定为0,实际上最终的哈希值就只与字符串最后一位的utf-8值有关,这是我们目前唯一可以确定的能使得哈希值的离散度大大降低的除数选择策略。因此,X取值的第三个条件是:不是seed的因数。

综上所述,我们最终选择了seed=65539,X=65537来完成我们的哈希表。

2.应对冲突的措施

无论怎样设法使哈希值分散,免不了会出现不同的键被映射到了数组的同一个位置的情况。那么为了使储存和查询正常进行,我们要在数组的每个位置进行“挂链”操作,也就是在数组的每个位置提供一个单向链表。

每有一个新值被储存进来,就在链表最后插入一个新的结点,里面储存有值和真实的键。查询时,键被映射到数组的某个位置时,先对比键与链表第一个结点真实的键,如果不一致,就去查询下一个结点,直到找到真实的值为止。

这种情况下,哈希表在局部依然需要用到顺序查找,但已经通过哈希值的方式,将顺序查找的次数大大降低至可以忽略的地步了。

3.1哈希表类的主体

哈希表类的主体就是一个数组和关键的哈希函数。在这里我们仅对字符串重载了新的哈希函数,而其他数据类型则使用C#内部提供的GetHashCode()来获取哈希值,并对65537取余以获得下标。

public class HashTable<T1,T2> where T1: IComparable where T2 : IComparable
{
        HashNode[] list;

        public HashTable()
        {
            list = new HashNode[65536];
        }

        private int hash(T1 tar)
        {
            if (tar is string)
            {
                int hashcode=0;
                string tarstr = tar.ToString();
                for (int i = 0; i < tarstr.Length; i++)
                    hashcode = hashcode * 65539 + tarstr[i];
                return hashcode % 65537;
            }
            else
                return Math.Abs(tar.GetHashCode()) % 65537; 
        }
}

3.2链表的实现

数组被定义为HashNode类型,其实现如下。

private class HashNode
{
    T1 key;
    T2 value;
    HashNode next;
    bool ocp;

    public HashNode()
    {
        ocp = false;
    }

    public void setValue(T1 m_key,T2 m_value)
    {
        if (ocp)
        {
            if (m_key.CompareTo(key) == 0)
            {
                value = m_value;
                return;
            }
            next.setValue(m_key, m_value);
            return;
        }
        key = m_key;
        value = m_value;
        ocp = true;
        next = new HashNode();
    }

    public T2 findValue(T1 m_key)
    {
        if (!ocp)
            return default(T2);
        if (m_key.CompareTo(key) == 0)
            return value;
        else
            return next.findValue(m_key);
    }
}

这是单向链表的头结点,一开始ocp属性为false,表示没有被占用。一旦被赋值,则ocp变为true并将下一个结点初始化。再次被赋值时,如果真实键和自身键一致,则更新自身value,否则将值传给下一个结点,以此类推实现递推的赋值。

而在查找时,根据传入的真实键和自身键值对比,如果不一致,则返回下一结点的查询结果,以此类推实现对链表递归的查找。

3.3插入和取出操作

实现类的索引器,将索引器的set属性作为插入的接口。get属性作为获取的接口。做法就是将索引器传入的值通过哈希函数进行运算获取数组下标,再对数组下标位置上的HashNode调用setValue方法或findValue方法。

public  T2 this[T1 key]
{
    get
    {
        if (list[hash(key)] != null)
            return list[hash(key)].findValue(key);
        else
            return default(T2);
    }
    set
    {
        if (list[hash(key)] == null)
            list[hash(key)] = new HashNode();
        list[hash(key)].setValue(key, value);
    }
}
发布了24 篇原创文章 · 获赞 11 · 访问量 3906

猜你喜欢

转载自blog.csdn.net/weixin_43441742/article/details/88859494