这个题目其实源于很久之前的一次 Uber 面试,码工换工作无非就是刷 leetcode ,研究如何翻转二叉树之类的算法问题,所以头一次在电话里听到这道题的时候还是挺耳目一新的。当时顺利写出来了,也通过了电面,但觉得还是有不完善的地方,比如说代码不够 “Pythonic” 等,所以趁着周天晚上闲着无事,又拿出来写了写。
HashTable 本身没啥好说的,中文叫”哈希表“或者”散列表“,具体翻译看教材编写者的个人喜好。众所周知这个数据结构用来存储”键-值“结构的数据,可以做到常数级时间复杂度的查找,在日常搬砖中算是主力工具。实现一个 HashTable 其实就是实现两个部分
既然是自己实现,就根据 HashTable 的查找原理选择 List 作为数据存储结构,在每个位置放置一个子 List 用于解决 hash 冲突,因此对于构造函数来说,大概应该长这个样子
class MyDict(object):
def __init__(self, size=99999):
self.hash_list = [list() for _ in range(size)]
self.size = size
每次添加一个键值对时,将 key hash 后的整数对 List 长度取模,即得到该 key 在 List 中的位置。因为 List 的每个位置是一个子 List ,所以需要遍历该子 List ,如果已存在该键值对,则更新 value ;如果不存在,将该键值对存在尾部。
def __setitem__(self, key, value):
hashed_key = hash(key) % self.size
for item in self.hash_list[hashed_key]:
if item[0] == key:
item[1] = value
break
else:
self.hash_list[hashed_key].append([key, value])
同理,在 MyDict
类中取值时,首先定位到给定 key 的位置,然后遍历其中的子 List ,若存在,返回 value;若不存在,抛出 KeyError
。
def __getitem__(self, key):
for item in self.hash_list[hash(key) % self.size]:
if item[0] == key:
return item[1]
raise KeyError(key)
这样一个简单的字典类(HashTable)就写好了,没有太多的功能,仅仅支持存放键值对及取值,运行效率也不算高,但已经够用了,函数的时间复杂度也是 O(1) 级别的。在这里用 Python 的魔术方法实现了这两个函数,这样就能像操作 Python 自带的 Dict
那样来操作 MyDict
。简单的示例如下。
>>> from my_dict import MyDict
>>> d = MyDict()
>>> d["a"] = 1
>>> d["b"] = 2
>>> d["c"] = 3
>>> d["a"]
1
>>> d["d"]
Traceback (most recent call last):
File "", line 1, in
File "/path/to/my_dict.py", line 19, in __getitem__
raise KeyError(key)
KeyError: 'd'
至此 MyDict
的实现还不算结束,因为还不能判断一个 key 是否在字典中,无法遍历字典等,接下来的功能如下。
目前的 MyDict
已经有了基本的功能,但如果试图输出,就会出现如下所示的样子。
>>> from my_dict import MyDict
>>> d = MyDict()
>>> d["a"] = 1
>>> d["b"] = 2
>>> d["c"] = 3
>>> d
>>> print(d)
这时候,我们需要实现 Python 类中的 __repr__
和 __str__
方法。关于两者的区别,这里有个简洁明了的一句话解释
My rule of thumb:
__repr__
is for developers,__str__
is for customers.
在我们这里,单独在 shell 中执行一个 d
,解释器调用的是 __repr__
,用 print
函数输出时,调用的是 __str__
。同时,对于 dict
,这两个方法的输出是一致的,所以我们只需要实现一个 __repr__
,这个方法在 __str__
缺失时会替代其被调用。
def __repr__(self):
result = []
for sub_list in self.hash_list:
if not sub_list:
continue
for item in sub_list:
result.append(str(item[0]) + ": " + str(item[1]))
return "{" + ", ".join(result) + "}"
让我们继续尝试完善这个 MyDict
类。对于一个字典,除了以常数级的时间复杂度从中取值,我们经常做的另一个常数级操作是检查一个 key
是否在字典中,语法已经很熟悉了, key in dict
。实现 in
关键字的操作,需要在类中实现 __contains__
方法
def __contains__(self, key):
for item in self.hash_list[hash(key) % self.size]:
if item[0] == key:
return True
return False
很多时候,我们希望能够遍历一个字典,通过调用 .keys()
、 .values()
、 .items()
来分别遍历键、值、键值对,这就要求 MyDict
的内部结构是可迭代的,所幸之前简单粗暴的采用了 list
来存储数据,但这还不够,因为我们在遍历字典的时候并不希望把内部 list
中的空位也返回给调用者。这个时候我们需要首先实现一个迭代器,将 MyDict
中的键值对依次返回,然后用这个迭代器实现 __iter__
方法,让其仅仅返回 key
,这样就可以有一个比较符合直觉的 for key in my_dict
调用,至于本段开始提到的三个方法,则可以调用这个迭代器或者 __iter__
来实现
def __iterate_kv(self):
for sub_list in self.hash_list:
if not sub_list:
continue
for item in sub_list:
yield item
def __iter__(self):
for kv_pair in self.__iterate_kv():
yield kv_pair[0]
def keys(self):
return self.__iter__()
def values(self):
for kv_pair in self.__iterate_kv():
yield kv_pair[1]
def items(self):
return self.__iterate_kv()
我们还期望得知目前字典的大小,即调用 len(dict)
就可以很方便的返回字典里有多少个键值对,这就需要实现 __len__
方法。但每次调用这个方法时,从内部的 list
中一个个的去数有多少个键值对无疑是低效的,我们可以用一个变量来记录下当前的字典大小,每次新增一个键值对时自增,这样在调用 len
函数的时候就可以直接返回了。
class MyDict(object):
def __init__(self, size=99999):
...
self.length = 0
def __setitem__(self, key, value):
...
for item in self.hash_list[hashed_key]:
...
else:
self.hash_list[hashed_key].append([key, value])
self.length += 1
...
def __len__(self):
return self.length
到此为止, MyDict
的运行效果如下所示。完整版代码已经放到了 gist 上。
>>> from my_dict import MyDict
>>> d = MyDict()
>>> d["a"] = 1
>>> d["b"] = 2
>>> d["c"] = 3
>>> d
{c: 3, a: 1, b: 2}
>>> print(d)
{c: 3, a: 1, b: 2}
>>> "a" in d
True
>>> "no-exist" in d
False
>>> for k in d:
... print(k)
...
c
a
b
>>> for k in d.keys():
... print(k)
...
c
a
b
>>> for v in d.values():
... print(v)
...
3
1
2
>>> for k, v in d.items():
... print(k, v)
...
c 3
a 1
b 2
>>> len(d)
3