为什么在Python中不能将列表用作字典键?到底哪些可以使用,哪些不行?原因是什么?

138

我发现以下几种方式都是有效的:

>>> d = {}
>>> d[None] = 'foo'
>>> d[(1, 3)] = 'baz'

甚至模块也可以用作字典的键:

>>> import sys
>>> d[sys] = 'bar'
然而,列表本身以及包含列表的元组也都是不可哈希的:
>>> d[[2]] = 'spam'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
>>> d[(1, [3])] = 'qux'
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'list'
为什么把列表存储在元组中就意味着不能再作为字典键了?毕竟我可以同样地在模块中“隐藏”一个列表(例如,sys.path已经是一个列表了)。我有一些模糊的概念,即键必须是“可哈希”的,但我不太理解这意味着什么,或者为什么会有这样的限制。如果Python允许使用列表作为键,比如使用它们的内存位置作为哈希值,会出现什么问题?

1
这里有一个很好的讨论:https://dev59.com/oHE85IYBdhLWcg3wqVX5 - Hernan
75
看到你的变量名,我忍不住笑了。 - kindall
相关但不足以编辑到问题中:如何在Python中检查可变性? - Karl Knechtel
11个回答

54

Python wiki上有一篇关于这个主题的好文章:为什么列表不能作为字典键。正如其中所解释的:

如果Python允许使用列表作为键,比如使用它们的内存位置作为哈希值,会出现什么问题?

这将导致一些意外的行为。通常情况下,列表被视为其值是从其内容的值派生而来的,例如在检查(不)相等性时。许多人会合理地期望,您可以使用任何列表[1, 2]来获取相同的键,而您必须保留完全相同的列表对象。但是,一旦用作键的列表被修改,按值查找就会中断,按标识查找需要跟踪该确切的列表对象-这对于处理列表并不是一个普通的要求。

其他对象,例如模块和object,无论如何都更注重它们的对象标识(您上次调用名为sys的两个不同模块对象是什么时候?),并且也通过这种方式进行比较。因此,当它们用作字典键时,在这种情况下按标识比较是 lesssurprising-甚至是expected。


50
为什么在Python中无法使用列表作为字典键?
>>> d = {repr([1,2,3]): 'value'}
{'[1, 2, 3]': 'value'}

正如其他人在这里所解释的那样,确实不行。但是,如果您真的想使用列表,可以使用其字符串表示形式。


8
抱歉,我没能看出你的观点。这跟使用字符串字面量作为键值没有什么不同。 - wim
21
没错,我刚刚看到很多答案都解释了为什么不能使用列表,因为“键必须是可哈希的”,这是非常正确的。但是,我想提供一种解决方法,以防有人(新手)需要它... - Remi
7
为什么不直接将列表转换为元组?为什么要将它们转换为字符串?如果您使用元组,它将与具有自定义比较方法“__eq__”的类正确地工作。但是,如果将它们转换为字符串,所有内容都将按其字符串表达式进行比较。 - Aran-Fey
1
好观点 @Aran-Fey。只需确保元组中的任何元素本身是可哈希的。例如,将tuple([[1,2],[2,3]])作为键将无法工作,因为元组的元素仍然是列表。 - Remi

46
一个列表可以转换为元组,而元组可以用作字典的键:例如。
d = {tuple([1,2,3]): 'value'}

22
问题在于元组是不可变的,而列表则不是。考虑以下示例:
d = {}
li = [1,2,3]
d[li] = 5
li.append(4)

d[li]应该返回相同的列表。那么d[[1,2,3]]呢?它具有相同的值,但是否是另一个不同的列表呢?

最终,没有令人满意的答案:

  • 如果仅原始键起作用,则无法在保留对原始键引用(并具有对其的访问权限)的情况下访问值。

  • 如果仅等效内容的键起作用,则修改键会改变查找工作方式;并且任何具有对该列表的引用的其他代码都可以修改它,可能会在以后引起意外。

  • 如果两者都起作用,则将非常不同的键映射到相同的值,这就更加令人惊讶了。


是的,它是同一个列表,所以我期望 d[li] 仍然是5。d[[1,2,3]] 将引用不同的列表对象作为键,因此会出现 KeyError。我还没有看到任何问题...除了让一个键被垃圾回收可能会使一些字典值无法访问。但这是一个实际问题,而不是逻辑问题。 - wim
@wim:d [list(li)] 导致 KeyError 是问题的一部分。在几乎每个其他用例中,li 与具有相同内容的新列表无法区分。它可以工作,但对许多人来说很反直觉。此外,您上次真正必须使用列表作为字典键是什么时候?我唯一能想象的用例是当您无论如何都通过标识哈希所有内容时,在这种情况下,您应该这样做,而不是依赖于 __hash____eq__ 基于身份。 - user395760
2
@wim:后者。正如我在答案中所述,它并没有真正违反字典键的要求,但很可能会引入比解决更多的问题。 - user395760
@wim 我已更新我的回答,包括我认为最大的问题:丢失的键。(为了获取值,必须存在原始键。) - Eric Wilson
1
@delnan - 你的意思是“前者”。 - Jason
显示剩余3条评论

8
这里有一个答案: http://wiki.python.org/moin/DictionaryKeys 如果你试图将列表作为键,并使用其哈希值,例如内存位置,会发生什么问题呢?查找具有相同内容的不同列表将产生不同的结果,尽管比较具有相同内容的列表将表明它们等价。使用字典查找中的列表字面量又如何呢?

7
由于列表是可变的,所以字典键(和集合成员)需要是可哈希的。对于可变对象进行哈希是个坏主意,因为哈希值应该基于实例属性计算。
在这个答案中,我将提供一些具体的例子,希望在现有答案的基础上添加价值。每一个洞见也适用于集合数据结构的元素。
示例1: 对可变对象进行哈希,其中哈希值是基于对象可变特征的。
>>> class stupidlist(list):
...     def __hash__(self):
...         return len(self)
... 
>>> stupid = stupidlist([1, 2, 3])
>>> d = {stupid: 0}
>>> stupid.append(4)
>>> stupid
[1, 2, 3, 4]
>>> d
{[1, 2, 3, 4]: 0}
>>> stupid in d
False
>>> stupid in d.keys()
False
>>> stupid in list(d.keys())
True

在对 stupid 进行更改后,字典中将无法再找到它,因为哈希值已经改变。只有在字典键列表上进行线性扫描才能找到 stupid

示例2: ... 但为什么不使用一个恒定的哈希值呢?

>>> class stupidlist2(list):
...     def __hash__(self):
...         return id(self)
... 
>>> stupidA = stupidlist2([1, 2, 3])
>>> stupidB = stupidlist2([1, 2, 3])
>>> 
>>> stupidA == stupidB
True
>>> stupidA in {stupidB: 0}
False

这也不是一个好主意,因为相等的对象应该有相同的哈希值,这样你才能在字典(dict)或集合(set)中找到它们。

示例3: ... 好吧,那所有实例的哈希值都相同怎么样?!

>>> class stupidlist3(list):
...     def __hash__(self):
...         return 1
... 
>>> stupidC = stupidlist3([1, 2, 3])
>>> stupidD = stupidlist3([1, 2, 3])
>>> stupidE = stupidlist3([1, 2, 3, 4])
>>> 
>>> stupidC in {stupidD: 0}
True
>>> stupidC in {stupidE: 0}
False
>>> d = {stupidC: 0}
>>> stupidC.append(5)
>>> stupidC in d
True

事情似乎按预期工作,但请考虑正在发生的事情:当您的类的所有实例产生相同的哈希值时,只要在dict中有两个以上的实例作为键或者存在于一个set中,就会出现哈希冲突。
使用my_dict[key]key in my_dict(或者item in my_set)来查找正确的实例,需要执行与stupidlist3的实例数目相同的等式检查(在最坏情况下)。此时,字典的目的——O(1)查找——完全被击败。下面是一些定时(使用IPython进行)的示例3。
>>> lists_list = [[i]  for i in range(1000)]
>>> stupidlists_set = {stupidlist3([i]) for i in range(1000)}
>>> tuples_set = {(i,) for i in range(1000)}
>>> l = [999]
>>> s = stupidlist3([999])
>>> t = (999,)
>>> 
>>> %timeit l in lists_list
25.5 µs ± 442 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
>>> %timeit s in stupidlists_set
38.5 µs ± 61.2 ns per loop (mean ± std. dev. of 7 runs, 10000 loops each)
>>> %timeit t in tuples_set
77.6 ns ± 1.5 ns per loop (mean ± std. dev. of 7 runs, 10000000 loops each)

如您所见,在我们的stupidlists_set中进行成员测试甚至比整个lists_list的线性扫描还要慢,而在没有大量哈希碰撞的集合中,您可以期望有超快的查找时间(500倍因子)。
简而言之:您可以使用tuple(yourlist)作为dict键,因为元组是不可变和可哈希的。

x = (1, 2, 3321321321321,) id(x) 139936535758888 z = (1, 2, 3321321321321,) id(z) 139936535760544 id((1, 2, 3321321321321,)) 139936535810768
这3个元组具有相同的值但不同的ID。所以一个以x为键的字典不会为z键提供任何值?
- Ashwani
@Ashwani,你试过了吗? - timgeb
是的,它按预期工作。我的疑问是所有具有相同值的元组都具有不同的ID。那么这个哈希是基于什么计算的? - Ashwani
@Ashwani,xz的哈希值相同。如果有不清楚的地方,请提出新问题。 - timgeb
你是如何计算 x 和 z 的哈希值的? - Ashwani
1
@Ashwani hash(x)hash(z) - timgeb

3
简单回答你的问题是,类列表没有实现方法hash,而任何想要在字典中用作键的对象都需要这个方法。然而,与元组类(基于容器内容)中的实现方式不同,hash之所以没有实现同样的方式,是因为列表是可变的,因此编辑列表将需要重新计算哈希值,这可能意味着列表现在位于底层哈希表中错误的桶中。请注意,由于您无法修改元组(不可变),因此不会遇到此问题。

补充一点,dictobjects的实际实现基于Knuth Vol. 3第6.4节中的D算法。如果您有该书可用,阅读一下可能是值得的。此外,如果您真的非常感兴趣,您可以瞥一眼实际的dictobject实现开发者评论。它详细介绍了它的工作原理。还有一个Python讲座关于字典的实现,您可能会感兴趣。他们在前几分钟讲解了键的定义以及哈希是什么。


2

你的答案可以在这里找到:

为什么列表不能作为字典键

Python新手经常会想知道,虽然语言包括元组和列表类型,但元组可用作字典键,而列表则不行。这是一个有意的设计决策,并且最好先了解Python字典的工作原理来解释。

来源和更多信息:http://wiki.python.org/moin/DictionaryKeys


-2
一个字典是一个哈希表,它存储了映射到哈希新键和值映射的关键字和值的地图。
类似于(伪代码):
{key : val}  
hash(key) = val

如果您想知道可用作字典键的选项,则

任何可以进行哈希转换并保持静态值(即不可变),以便像上面所述创建哈希键的内容都是合格的,但由于列表或集合对象可能会变化,因此哈希(key)也需要随之变化才能与列表或集合同步。

您可以尝试:

hash(<your key here>)

如果它能正常工作,它可以用作字典的键,否则将其转换为可哈希的内容。

简而言之:

  1. 将该列表转换为 tuple(<your list>)
  2. 将该列表转换为 str(<your list>)

-2
根据Python 2.7.2文档:
如果一个对象具有永远不会更改的哈希值(它需要一个__hash__()方法),并且可以与其他对象进行比较(它需要一个__eq__()或__cmp__()方法),则该对象是可哈希的。比较相等的可哈希对象必须具有相同的哈希值。哈希性使对象可用作字典键和集合成员,因为这些数据结构在内部使用哈希值。所有Python的不可变内置对象都是可哈希的,而没有可变容器(例如列表或字典)是可哈希的。默认情况下,用户定义类的实例都是可哈希的;它们都不相等,并且它们的哈希值是它们的id()。
元组在不能添加、删除或替换其元素的意义上是不可变的,但元素本身可能是可变的。列表的哈希值取决于其元素的哈希值,因此当您更改元素时,它会发生变化。使用列表哈希的id将意味着所有列表都比较不同,这将是令人惊讶和不方便的。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接