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

导言

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

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

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

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

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

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

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

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

1.哈希函数

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

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

本文仅谈论哈希函数的第一种作用。下面给出一种迭代获取字符串哈希值的算法:
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=seed*y+x,可见这是一个三维坐标系下的、仅在第一卦限的平面方程,设该平面为S。当z为常数z_0时,平面z=z_0与S的交线如图所示。

在这里插入图片描述

当seed的值很大时,观察图形。
在这里插入图片描述

当seed的值很小时,观察图形。
在这里插入图片描述

可以直观地看出,seed的值越大,对于特定的z,S与的交线越短,也就是说,越不可能取到两个不同的x、y,使得z相等。另外,z的值越大,S与的交线越长,取到两个不同的x、y使得z相等的概率越大。

当字符串有三个字符时,设第三个字符为z,哈希值为a,有a=seed^2*z+seed*y+x,这是一个四维空间中的、仅存在于x、y、z均大于0的空间中的体的表达式,其与体a=a_0的交面在三维空间中如图所示。

在这里插入图片描述

由于且,观察可得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取值的第二个条件是:和自己要储存的数据总量正相关。

最后,取模前的哈希值为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);
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容

  • 如需转载, 请咨询作者, 并且注明出处.有任何问题, 可以关注我的微博: coderwhy, 或者添加我的微信: ...
    coderwhy阅读 11,521评论 19 121
  • 在讲解HashMap集合之前,我们先说说一个重要的数据结构---哈希表。哈希表是一种非常优秀数据结构,对哈希表进行...
    wo883721阅读 2,882评论 4 13
  • 一些概念 数据结构就是研究数据的逻辑结构和物理结构以及它们之间相互关系,并对这种结构定义相应的运算,而且确保经过这...
    Winterfell_Z阅读 5,705评论 0 13
  • 作者:July、wuliming、pkuoliver 说明:本文分为三部分内容,第一部分为一道百度面试题Top K...
    cyj_ya阅读 803评论 0 0
  • 家中堆积了儿子从小到大的各类杂志,每年都要收拾归类整理,工程越来越大。每年都要和儿子商量,咱们处理卖了吧!儿子不同...
    赵浥辰阅读 997评论 1 10