背景

简单的说,当我们在大部分语言中尝试使用“可变类型”作为键值,会报错。以Python为例子,这将会提示有“不可哈希(unhashable)”错误。

In [1]: la=[1,2]
In [2]: d={}
In [3]: d[la]=3
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-3-79b9de59ffa1> in <module>
----> 1 d[la]=3

TypeError: unhashable type: 'list'

为了解答这是为什么,我们要从可变类型和哈希表的大概工作方式入手,再探讨究竟为什么不能这样。

可变类型和不可变类型

大部分语言根据变量的语义不同,分为值类型引用类型。Python对其称呼为可变类型和不可变类型,我认为这是一个很好的比喻。

通常来说,可变类型和不可变类型最大的区别是语义的不同,其次是实现的不同。

对于不可变类型,通常是各种数字、字符串、固定数组。语义上这些类型被修改后,将不再视为和之前一样了。比如一个数字变量a=1,经过a=2后,就被视为和之前不相等了。

对于可变类型,通常是各种动态数组、集合、动态增长的数组。语义上这些类型被修改后,通常仍然视为和之前一样。比如一个Python列表la=[1,2],经过la.append(3)变成la=[1,2,3]后,通常仍然视为和之前一样,也就是还是同一个列表。同时,引用类型也包含多个对象可以共用一个物理数据的含义,可以一处修改影响全体对象。

哈希表的工作方式

哈希函数的意义

哈希函数在数学上的定义,是把任意长度的输入数据,通过哈希算法变换成固定长度的输出,该输出就是哈希值。如果同一个哈希函数输出了两个不同的哈希值,那么它们的输入也将会是不同的。但是两个不同的输入,有可能产生相同的输出,这种情况被成为哈希碰撞。

具体到软件工程中,哈希函数往往作为判断两个对象是否相同的一种方法,将一个对象的内的某些重要属性(值,内存地址等)进行哈希计算并获得一个固定长度的值,该值可被当作该对象的一种类似“指纹”、“摘要”的身份信息。因为哈希值数据结构和长度固定的原因,很容易进行比对。若是有两个对象哈希值相同,我们可以暂且认为它们是相同的对象,但是因为哈希冲突的存在,我们需要进一步判断两个对象的重要属性是否相同。

哈希表的作用和实现

哈希表即字典、散列表。是利用了哈希函数(hash function)特性的一种数据结构,可以实现最快时间复杂度为O(1)的查询操作。

一种常见的实现方式是,哈希表首先会开辟一个桶数组,对插入的键值对,对键进行取哈希值操作,再通过进一步操作,比如将哈希值和桶数组长度进行取余操作,最终决定将键值对放入哪个桶,桶可以是链表或数组等容器。因为哈希碰撞的存在,桶通常可以装下不只一个键值对。在进行查询操作时,我们对键执行插入时相同的计算,可以立刻在多个桶中找到存放目标数据的桶的下标(这也是哈希表高效的主要原因),再在桶中寻找键对应的值。

我们再把查询的过程细化,参考Python官方wiki并翻译,字典的查找过程可以用下面的lookup函数展示。当然,实际上字典的设计比这个复杂得多,我们在这主要了解概念就行。

def lookup(d, key):
    '''字典的查找分三步完成
       1. 使用哈希函数计算键的哈希值。

       2. 通过哈希值定位到字典桶数组(d.data)中某个位置,我们应该能
          获得一个被称作桶或冲突列表的数组。里面包含了一个个键值对
          ((key,value))。

       3. 遍历这个被称作桶或冲突列表的数组,如果发现桶中存在键和传入参数键
          (key)相等的键值对,则将该值(value)作为返回值返回。
    '''
    h = hash(key)                  # 步骤 1
    cl = d.data[h]                 # 步骤 2
    for pair in cl:                # 步骤 3
        if key == pair[0]:
            return pair[1]
    else:
        raise KeyError, "没有找到键 %s 。" % key

可以留意到,对一个键进行查询,重点需要获取它的哈希值,并通过哈希值在桶数组中寻址,再比对桶中是否存在和传入的键值相同的键值对,最后返回键值对的值。

我们以Python为例讨论,参考Python官方的说法:

To be used as a dictionary key, an object must support the hash function (e.g. through hash__), equality comparison (e.g. through __eq or __cmp__), and must satisfy the correctness condition above.

一个数据类型要用作字典键,对象必须同时支持取哈希值(例如通过__hash__方法)和判断是否相等(例如通过__eq____cmp__方法)这两个操作。

In [80]: hash(la)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-80-e4e0d0f14f47> in <module>
----> 1 hash(la)

TypeError: unhashable type: 'list'

而对于文章开头的问题,其实我们这里已经可以回答了。即Python列表类型没有实现__hash__方法,因此无法取哈希值,进而无法作为字典键。但是更加深入一点的话,我们想讨论为什么不实现__hash__方法。

允许可变类型取哈希值带来的问题

对于哈希表,如果我们将它的作用再抽象一下,可以描述为。将一个键值对插入哈希表后,我们希望能通过“同样的”键能将该值取出来。而如何定义两个键(或者说对象)相同,则是哈希函数的事情。

问题就出于这,因为可变类型和哈希函数的特性,我们很难对大部分可变类型的“相同”作出一个符合直觉、不让人困惑的定义。

继续以Python列表为例,你可能想说,两个列表的内容,即长度和元素顺序相同,则说明两个列表相同,这种定义不是很容易能给出吗?实际上,列表确实是支持判断相等操作的(实现了__eq__方法),但是对于作为哈希表键值来说,仍然具有很多问题。

我们直接一点,考虑设计一个这样的列表,它支持和原生列表一样的判断相等操作,而它的取哈希方式,也是对内容进行计算的。相同内容的两个列表将拥有相同的哈希,就像我们想像中的那样。

class MyList(object):
    def __init__(self, value):
        self.value = value

    def __repr__(self):
        return str(self.value)

    def __str__(self):
        return str(self.value)

    def __eq__(self, other):
        return self.value == other.value

    def __hash__(self):
        # 将列表转化为字符串,再将字符串每个字符转为数字
        # 例如:[1,2] -> "[1, 2]" -> 914944325093
        hashValue = ""
        for c in str(self.value):
            hashValue += str(ord(c))
        return int(hashValue)

使用情况如下

In [47]: ml1=mylist.MyList([1,2])

In [48]: ml1
Out[48]: [1, 2]

In [49]: d1={}

In [50]: d1[ml1]=999

In [51]: print(d1)
{[1, 2]: 999}

In [52]: ml2=mylist.MyList([1,2])

In [53]: d1[ml2]
Out[53]: 999

看起来这非常符合我们想象中的样子,但是假如我们修改一下ml1呢?

In [54]: ml1.value.append(3)

In [55]: ml1
Out[55]: [1, 2, 3]

In [56]: ml2
Out[56]: [1, 2]

In [57]: print(d1)
{[1, 2, 3]: 999}

In [58]: d1[ml1]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-58-1e9c8efcdfed> in <module>
----> 1 d1[ml1]

KeyError: [1, 2, 3]

In [59]: d1[ml2]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-59-aff57dc87ac2> in <module>
----> 1 d1[ml2]

KeyError: [1, 2]

我们可以发现,假如我们对ml1进行了修改,字典d1中的键值也跟着发生了变化,这是因为列表是可变对象,多个引用共同使用一份内容,一处修改影响所有引用。如果这个时候,我们再想通过ml1取出值,会提示错误,原因是修改过后的ml1内容变了,哈希值也变了,字典无法通过新的哈希值定位到原来存放键值对的位置上。此外,如果我们通过内容和哈希值和变化之前的ml1都一样的ml2尝试取出d1的值,也会错误。因为字典查询除了要计算哈希找出桶的下标外,还要因为避免哈希碰撞而再一次核对查询键和桶里的键是否真的相等,而此时字典中的键因为ml1的修改而跟着变化了,因此ml2的值和桶中的键值不相等,也无法取出值。

我们也可以思考其他的哈希方式,比如通过对象的id即内存地址来计算哈希,但是这样会有更加显而易见的问题。对字典进行查询的键,只能是基于初始插入对象的引用,相同值的对象和字面量将永远无法查出想要的结果,因为他们的内存地址不同。虽然实际上,对用户自定义类的对象内存地址取哈希是默认操作,但是那是在面向对象的思想中比较好用,对于内置列表这种基础类型,使用此方案是反直觉的。

In [98]: ml1=mylist.MyList([1,2])

In [99]: ml2=mylist.MyList([1,2])

In [100]: ml3=ml1

In [101]: print(id(ml1),id(ml2),id(ml3))
140172863009408 140172873557376 140172863009408

In [102]: ml1==ml2==ml3
Out[102]: True

In [103]: d1={}

In [104]: d1[ml1]=999

In [105]: print(d1)
{[1, 2]: 999}

In [106]: d1[ml1]
Out[106]: 999

In [107]: d1[ml3]
Out[107]: 999

In [108]: d1[ml2]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-108-aff57dc87ac2> in <module>
----> 1 d1[ml2]

KeyError: [1, 2]

In [109]: d1[mylist.MyList([1,2])]
---------------------------------------------------------------------------
KeyError                                  Traceback (most recent call last)
<ipython-input-109-bcd49cc44bab> in <module>
----> 1 d1[mylist.MyList([1,2])]

KeyError: [1, 2]

究其原因,用于索引的哈希值是基于对象某个时刻的某个属性生成的,是静态的。而在找到对应的桶后,从桶中找到和传入键相匹配的键值对,是基于对象本身的。对于不可变类型,他们的哈希值和桶中键值对都是不变的。而对于可变类型,他们变化将会导致桶中键值对的动态变化,但哈希索引仍然为静态,这就产生了矛盾。

那有其他解决办法吗? 比如对于可变类型,在不作为哈希表键值时,遵从其本身的特性,一旦作为哈希表键,就以其数值复制一份副本,类似一份快照,存入到哈希表中作为键,之后只允许拥有相同数值的可变类型对象才能进行读写。而因为键是一份副本,原有的值被修改后不会影响其本身。

实际上这种思路是可行的,原理和使用不可变类型作为键很相似。因为虽然全文都在讨论可变和不可变两种类型,但是归根结底其实区别并没有这么大。在允许使用指针的语言中,数字等类型也可以多个对象共享,但是只要被作为键值,就会被复制一份副本进入到哈希表。不过据我所知大部分编程语言并不会这么做,而是会单独出一个和可变类型很像,但平常并不会有很多人用的不可变类型。比如Python元组之于列表,Go数组之于切片。说到底还是如上文所说,允许使用可变类型作为哈希表键会有很多思维的反直觉和实现上的困难。

参考

[1] Why Lists Can't Be Dictionary Keys
[2] Why can't I use a list as a dict key in python?


rwxe
91 声望5 粉丝

no tengo trabajo.