数据结构思维 第十章 哈希

第十章 哈希

原文:Chapter 10 Hashing

译者:飞龙

协议:CC BY-NC-SA 4.0

自豪地采用谷歌翻译

在本章中,我定义了一个比MyLinearMap更好的Map接口实现,MyBetterMap,并引入哈希,这使得MyBetterMap效率更高。

10.1 哈希

为了提高MyLinearMap的性能,我们将编写一个新的类,它被称为MyBetterMap,它包含MyLinearMap对象的集合。它在内嵌的映射之间划分键,因此每个映射中的条目数量更小,这加快了findEntry,以及依赖于它的方法的速度。

这是类定义的开始:

public class MyBetterMap<K, V> implements Map<K, V> {
    
    protected List<MyLinearMap<K, V>> maps;
    
    public MyBetterMap(int k) {
        makeMaps(k);
    }

    protected void makeMaps(int k) {
        maps = new ArrayList<MyLinearMap<K, V>>(k);
        for (int i=0; i<k; i++) {
            maps.add(new MyLinearMap<K, V>());
        }
    }
}

实例变量maps是一组MyLinearMap对象。构造函数接受一个参数k,决定至少最开始,要使用多少个映射。然后makeMaps创建内嵌的映射并将其存储在一个ArrayList中。

现在,完成这项工作的关键是,我们需要一些方法来查看一个键,并决定应该进入哪个映射。当我们put一个新的键时,我们选择一个映射;当我们get同样的键时,我们必须记住我们把它放在哪里。

一种可能性是随机选择一个子映射,并跟踪我们把每个键放在哪里。但我们应该如何跟踪?看起来我们可以用一个Map来查找键,并找到正确的子映射,但是练习的整个一点是编写一个有效的实现Map。我们不能假设我们已经有了。

一个更好的方法是使用一个哈希函数,它接受一个Object,一个任意的Object,并返回一个称为哈希码的整数。重要的是,如果它不止一次看到相同的Object,它总是返回相同的哈希码。这样,如果我们使用哈希码来存储键,当我们查找时,我们将得到相同的哈希码。

在Java中,每个Object都提供了hashCode,一种计算哈希函数的方法。这种方法的实现对于不同的对象是不同的;我们会很快看到一个例子。

这是一个辅助方法,为一个给定的键选择正确的子映射:

protected MyLinearMap<K, V> chooseMap(Object key) {
    int index = 0;
    if (key != null) { 
        index = Math.abs(key.hashCode()) % maps.size();
    }
    return maps.get(index);
}

如果keynull,我们任意选择索引为0的子映射。否则,我们使用hashCode获取一个整数,调用Math.abs来确保它是非负数,然后使用余数运算符%,这保证结果在0maps.size()-1之间。所以index总是一个有效的maps索引。然后chooseMap返回为其所选的映射的引用。

我们使用chooseMapputget,所以当我们查询键的时候,我们得到添加时所选的相同映射,我们选择了相同的映射。至少应该是 - 稍后我会解释为什么这可能不起作用。

这是我的putget的实现:

public V put(K key, V value) {
  MyLinearMap<K, V> map = chooseMap(key);
    return map.put(key, value);
}

public V get(Object key) {
    MyLinearMap<K, V> map = chooseMap(key);
    return map.get(key);
}

很简单,对吧?在这两种方法中,我们使用chooseMap来找到正确的子映射,然后在子映射上调用一个方法。这就是它的工作原理。现在让我们考虑一下性能。

如果在k个子映射中分配了n个条目,则平均每个映射将有n/k个条目。当我们查找一个键时,我们必须计算其哈希码,这需要一些时间,然后我们搜索相应的子映射。

因为MyBetterMap中的条目列表,比MyLinearMap中的短k倍,我们的预期是ķ倍的搜索速度。但运行时间仍然与n成正比,所以MyBetterMap仍然是线性的。在下一个练习中,你将看到如何解决这个问题。

10.2 哈希如何工作?

哈希函数的基本要求是,每次相同的对象应该产生相同的哈希码。对于不变的对象,这是比较容易的。对于具有可变状态的对象,我们必须花费更多精力。

作为一个不可变对象的例子,我将定义一个SillyString类,它包含一个String

public class SillyString {
    private final String innerString;

    public SillyString(String innerString) {
        this.innerString = innerString;
    }

    public String toString() {
        return innerString;
    }

这个类不是很有用,所以它叫做SillyString。但是我会使用它来展示,一个类如何定义它自己的哈希函数:

    @Override
    public boolean equals(Object other) {
        return this.toString().equals(other.toString());
    }
    
    @Override
    public int hashCode() {
        int total = 0;
        for (int i=0; i<innerString.length(); i++) {
            total += innerString.charAt(i);
        }
        return total;
    }

注意SillyString重写了equalshashCode。这个很重要。为了正常工作,equals必须和hashCode,这意味着如果两个对象被认为是相等的 - 也就是说,equals返回true - 它们应该有相同的哈希码。但这个要求只是单向的;如果两个对象具有相同的哈希码,则它们不一定必须相等。

equals通过调用toString来工作,返回innerString。因此,如果两个SillyString对象的innerString实例变量相等,它们就相等。

hashCode的原理是,迭代String中的字符并将它们相加。当你向int添加一个字符时,Java 将使用其 Unicode 代码点,将字符转换为整数。你不需要了解 Unicode 的任何信息来弄清此示例,但如果你好奇,可以在 http://thinkdast.com/codepoint 上阅读更多内容。

该哈希函数满足要求:如果两个SillyString对象包含相等的内嵌字符串,则它们将获得相同的哈希码。

这可以正常工作,但它可能不会产生良好的性能,因为它为许多不同的字符串返回相同的哈希码。如果两个字符串以任何顺序包含相同的字母,它们将具有相同的哈希码。即使它们不包含相同的字母,它们可能会产生相同的总量,例如"ac""bb"

如果许多对象具有相同的哈希码,它们将在同一个子映射中。如果一些子映射比其他映射有更多的条目,那么当我们有k个映射时,加速比可能远远小于k。所以哈希函数的目的之一是统一;也就是说,以相等的可能性,在这个范围内产生任何值。你可以在 http://thinkdast.com/hash 上阅读更多设计完成的,散列函数的信息。

10.3 哈希和可变性

String是不可变的,SillyString也是不可变的,因为innerString定义为final。一旦你创建了一个SillyString,你不能使innerString引用不同的String,你不能修改所指向的String。因此,它将始终具有相同的哈希码。

但是让我们看看一个可变对象会发生什么。这是一个SillyArray定义,它与SillyString类似,除了它使用一个字符数组而不是一个String

public class SillyArray {
    private final char[] array;

    public SillyArray(char[] array) {
        this.array = array;
    }

    public String toString() {
        return Arrays.toString(array);
    }
    
    @Override
    public boolean equals(Object other) {
        return this.toString().equals(other.toString());
    }
    
    @Override
    public int hashCode() {
        int total = 0;
        for (int i=0; i<array.length; i++) {
            total += array[i];
        }
        System.out.println(total);
        return total;
    }

SillyArray也提供setChar,它能够修改修改数组内的字符。

public void setChar(int i, char c) {
    this.array[i] = c;
}

现在假设我们创建了一个SillyArray,并将其添加到map

SillyArray array1 = new SillyArray("Word1".toCharArray());
map.put(array1, 1);

这个数组的哈希码是461。现在如果我们修改了数组内容,之后尝试查询它,像这样:

array1.setChar(0, 'C');
Integer value = map.get(array1);

修改之后的哈希码是441。使用不同的哈希码,我们就很可能进入了错误的子映射。这就很糟糕了。

一般来说,使用可变对象作为散列数据结构中的键是很危险的,这包括MyBetterMapHashMap。如果你可以保证映射中的键不被修改,或者任何更改都不会影响哈希码,那么这可能是正确的。但是避免这样做可能是一个好主意。

10.4 练习 8

在这个练习中,你将完成MyBetterMap的实现。在本书的仓库中,你将找到此练习的源文件:

  • MyLinearMap.java包含我们在以前的练习中的解决方案,我们将在此练习中加以利用。
  • MyBetterMap.java包含上一章的代码,你将填充一些方法。
  • MyHashMap.java包含按需增长的哈希表的概要,你将完成它。
  • MyLinearMapTest.java包含MyLinearMap的单元测试。
  • MyBetterMapTest.java包含MyBetterMap的单元测试。
  • MyHashMapTest.java包含MyHashMap的单元测试。
  • Profiler.java包含用于测量和绘制运行时间与问题大小的代码。
  • ProfileMapPut.java包含配置该Map.put方法的代码 。

像往常一样,你应该运行ant build来编译源文件。然后运行ant MyBetterMapTest。几个测试应该失败,因为你有一些工作要做!

从以前的章节回顾putget的实现。然后填充containsKey的主体。提示:使用chooseMap。再次运行ant MyBetterMapTest并确认通过了testContainsKey

填充containsValue的主体。提示:不要使用chooseMapant MyBetterMapTest再次运行并确认通过了testContainsValue。请注意,比起找到一个键,我们必须做更多的操作才能找到一个值。

类似putget,这个实现的containsKey是线性的,因为它搜索了内嵌子映射之一。在下一章中,我们将看到如何进一步改进此实现。

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

推荐阅读更多精彩内容

  • 1. Java基础部分 基础部分的顺序:基本语法,类相关的语法,内部类的语法,继承相关的语法,异常的语法,线程的语...
    子非鱼_t_阅读 31,565评论 18 399
  • Java集合框架 Java平台提供了一个全新的集合框架。“集合框架”主要由一组用来操作对象的接口组成。不同接口描述...
    小石38阅读 356评论 0 0
  • 这个答顾东桥书是我最没感觉的。 那就说点我有感觉的吧。今天接到一个咨询电话,说自己最近很忙很累,而且到了新的部门,...
    姜天合阅读 380评论 1 4
  • 毫无疑问,战国时代重来! 英国脱欧,德国黄金回国,欧盟摇摇欲坠;特朗普商业变脸术梦幻登场,安倍跪地求安纳贡4500...
    张永刚阅读 489评论 0 2
  • 2014.5.26北京妇产科 柜台护士拿出一张病例说道:二位把这表格填上,我望了小容一眼,关系栏没有一丝犹豫自然填...
    幻影行者阅读 224评论 2 0