引言
本篇想总结的是python的三种字典格式:dict、defaultdict和ordereddict的使用方式,将通过lc题展现这三种方式具体使用场景,以及相应的一些原理与数据结构本身。
dict
在python中,字典也叫哈希表,自python3.7后,官宣了 Python 原生的 dict 就能保证 Key 的插入顺序,在写这篇文章的时候,我实验了一下,发现确实如此,具体的实验在orderdict中。另外,在3.7.5下尝试orderedDict与dict两种不同格式的结构对比如下:
import collections
if __name__ == '__main__':
dic = dict()
dic["name"] = "Tom"
dic["age"] = 12
dic["money"] = 100
dic["girl"] = "no"
dic["house"] = None
dic.update({"girl":"yes"})
dic.popitem()
for key, value in dic.items():
print(key, value)
print("=" * 30)
dic2 = collections.OrderedDict()
dic2["name"] = "Tom"
dic2["age"] = 12
dic2["money"] = 100
dic2["girl"] = "no"
dic2["house"] = None
dic2.update({"girl": "yes"})
# 为False时删除掉最开始插入的的那个键值对
dic2.popitem(last=False)
for key, value in dic2.items():
print(key, value)
"""
name Tom
age 12
money 100
girl yes
==============================
age 12
money 100
girl yes
house None
"""
这里的dict类中的popitem方法,是没有参数能加的,这也说明插入字典后依然没有对顺序有所记录,而不像orderedDict类,这种数据结构会在后面提到,其实它的底层是用了一个双向循环链表指向经过了哈希的键值,这样才能指定头和尾。dict和orderedDict两个类的关系就类似queue和dequeue,但又不全是,我们接着来看。
dict的使用场景为:
- 需要比较快速的查询复杂无序数据的,相当于一对多的关系,类比数组,如果list的层数很高,那么查找是相当麻烦的,但现在前后端分离后的json,就是dict的一个具体体现。
- 存放通过哈希函数 + 哈希碰撞得到,所以内存空间占用会比较高,算是空间换时间的一个范例。
dict原理:
- 可变哈希表,key通过hash函数映射为哈希值,哈希值进一步映射到哈希表大小size的位置index上,value的地址就存放在index所对应的空间上。所以这里就说明key必须是不可变类型,不然无法申请到内存空间
- dict数据的表现形式为:一行记录2个数据:key的引用和value的引用,因为python中没有指针的概念,一般都是说成相对引用。
- dict查询时,是将key哈希得到哈希值之后,用哈希值末尾几位计算哈希表存放的位置,在哈希表较拥挤触发哈希扩容之后,会增大哈希值的末尾几位。
- cpython的哈希函数一般为:hash(“key值”)&*(2^k-1)【等价于hash(“key值”)%(k+1)】
关于上述的定义,可以举个例子,拿上面的dic中的girl这个key作为哈希对象,因为它是个字符串,不可变类型,所以是可哈希的,而它存放的数据为:
print(bin(hash("girl")))
# 0b111001101011011100011110010101001110111001000110110001000101100
用散列值的最右边 3 位数字作为偏移量,即“100”,十进制是数字 4。我们查看偏移量 4,对应的 bucket 是否为空。如果为空,则将键值对放进去。如果不为空,则依次取右移 3 位作为偏移量,即“101”,十进制是数字5,循环此过程,直到找到为空的 bucket 将键值对放进去。python 会根据散列表的拥挤程度扩容。“扩容”指的是:创造更大的数组,将原有内容拷贝到新数组中。接近 2/3 时,数组就会扩容。扩容后,偏移量的数字个数增加,如数组长度扩容到16时,可以用最右边4位数字作为偏移量。
dict源码分析:
//Objects/dict-common.h
struct _dictkeysobject {
//引用计数
Py_ssize_t dk_refcnt;
/* Size of the hash table (dk_indices). It must be a power of 2. */
/* 哈希表的大小,必须是2的倍数 */
Py_ssize_t dk_size;
/* 与哈希表有关的函数 */
dict_lookup_func dk_lookup;
/* Number of usable entries in dk_entries. */
/* dk_entries中可用的entries数量 */
Py_ssize_t dk_usable;
/* Number of used entries in dk_entries. */
/* dk_entries中已经使用的entries数量 */
Py_ssize_t dk_nentries;
/* Actual hash table of dk_size entries. It holds indices in dk_entries,
or DKIX_EMPTY(-1) or DKIX_DUMMY(-2).
Indices must be: 0 <= indice < USABLE_FRACTION(dk_size).
The size in bytes of an indice depends on dk_size:
Dynamically sized, SIZEOF_VOID_P is minimum. */
//最终的哈希表,它存储了dk_entries的索引
//里面的类型是会随着dk_size的大小而变化的
/*
- 1 byte if dk_size <= 0xff (char*)
- 2 bytes if dk_size <= 0xffff (int16_t*)
- 4 bytes if dk_size <= 0xffffffff (int32_t*)
- 8 bytes otherwise (int64_t*)
*/
char dk_indices[]; /* char is required to avoid strict aliasing. */
/* "PyDictKeyEntry dk_entries[dk_usable];" array follows:
see the DK_ENTRIES() macro */
};
//我们一直提到了dk_entries,这又是个啥?
//dk_entries是一个数组,里面的元素类型是PyDictKeyEntry,就是一个一个的键值对
//所以我们把某个键值对称之为一个entry,它的大小可以用USABLE_FRACTION这个宏来获取
typedef struct {
/* me_key的哈希值,避免每次查询的时候都要重新建立 */
Py_hash_t me_hash;
//字典的key
PyObject *me_key;
//这个字段只对combined table有意义
/*
还记得ma_values吗?上面说了如果是combined table,那么key和value都会存在PyDictKeysObject *ma_keys里面,但如果是split table,那就只有key会存在PyDictKeysObject *ma_keys里面,也就是这里me_key,所以这里注释了:me_value这个字段只对combined table有意义。因为是split table的话,value都会存储在ma_values里面,而不是这里的me_value
*/
PyObject *me_value; /* This field is only meaningful for combined tables */
} PyDictKeyEntry;
上述源码是关于PyDictObject对象的,而引用自《python解释器源码剖析》第6章–python中的dict对象,后面还有关于PyDictObject对象的查询以及创建的源代码,解释了上面4点dict的原理,回到python的角度,其实dict可以看成是list实现的一种数据结构。我在找上面那篇源代码解读的同时,也找到了一个面试题:不用 Python 自带的 Dict 实现自己的 HashTable?,这里引出一篇参考文献中的代码,参考详情见最后,代码为:
class MyDict(object):
def __init__(self, size=10000):
# 用list初始化hashtable,并且每个位置都对应一个子list已解决hash冲突问题
self.hash_list = [list() for _ in range(size)]
self.size = size
def __setitem__(self, key, value):
# 利用python自带的hash函数,对key哈希并对size取模
# hased_key位置没有值就追加,否则覆盖
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])
def __getitem__(self, key):
# return: key所对应的value
# 没有key,就抛出keyError
for item in self.hash_list[hash(key) % self.size]:
if item[0] == key:
return item[1]
raise KeyError(key)
def __repr__(self):
# hashtable打印
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) + "}"
def __contains__(self, key):
# 是否包含key,实现in操作符的功能
for item in self.hash_list[hash(key) % self.size]:
if item[0] == key:
return True
return False
# 通过调用 keys() 、values() 、items() 来分别遍历键、值、键值对
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()
那么从上面的代码,我们便能发现,list转dict是很容易实现的,而dict转list同样,可以通过统计个数,得到排序后的list,同样也能使用内置的collections完全操作:
# 方式一
m_c = collections.Counter(tasks).most_common()
# 方式二
task_map = dict()
for task in tasks:
task_map[task] = task_map.get(task, 0) + 1
# 按任务出现的次数从大到小排序
task_sort = sorted(task_map.items(), key=lambda x: x[1], reverse=True)
这来源于今日的每日一题,参考高赞代码:
"""
输入:tasks = ["A","A","A","B","B","B"], n = 2
输出:8
"""
class Solution(object):
def leastInterval(self, tasks, n):
"""
:type tasks: List[str]
:type n: int
:rtype: int
"""
length = len(tasks)
if length <= 1:
return length
# 用于记录每个任务出现的次数
task_map = dict()
for task in tasks:
task_map[task] = task_map.get(task, 0) + 1
# 按任务出现的次数从大到小排序
task_sort = sorted(task_map.items(), key=lambda x: x[1], reverse=True)
# 出现最多次任务的次数
max_task_count = task_sort[0][1]
# 至少需要的最短时间
res = (max_task_count - 1) * (n + 1)
for sort in task_sort:
if sort[1] == max_task_count:
res += 1
# 如果结果比任务数量少,则返回总任务数
return res if res >= length else length
那其实能省略很多,直接得到结果:
class Solution:
def leastInterval(self, tasks: List[str], n: int) -> int:
import collections
m_c = collections.Counter(tasks).most_common()
maxCount = len([i for i in m_c if i[1]==m_c[0][1]])
return max((m_c[0][1]-1)*(n+1)+maxCount, len(tasks))
那么,Counter是一种什么数据结构?根据官方文档,Counter是dict用于计数可哈希对象的子类。它是一个集合,其中元素存储为字典键,其计数存储为字典值。计数可以是任何整数值,包括零或负计数。在版本3.7后,Counter 继承了记住插入顺序的功能。对对象的数学运算也保留顺序。根据在左操作数中首次遇到元素的时间,然后按照在右操作数中遇到的顺序,对结果进行排序。
defaultdict
返回一个新的类似字典的对象。 defaultdict是内置类的子dict类。它重写一种方法并添加一个可写的实例变量。其余功能与dict该类相同,它的接收参数为default_factory,该参数一般为带有默认值的类或者常量都行,比如int或者0,关于它能带来的遍历,我们可以通过一个例子来看:
from collections import defaultdict
d = defaultdict(list)
# d = dict()
d['python'].append("awesome")
d['something-else'].append("not relevant")
d['python'].append("language")
for i in d.items():
print(i)
"""
('python', ['awesome', 'language'])
('something-else', ['not relevant'])
"""
如果是将defaultdict换成下面的dict,会出现keyerror的问题,因为申明的字典是空的为{},而向python的键所对应值append是不行的,根据底层的源码,只有getitem和setitem,两个魔法方法都没有列表的append。
而defaultdict其实是在原先dict的基础上,加了一个默认值为[],这种方式dict也可以实现,就是dict.setfault(),那么为什么能这样加呢?
因为在defaultdict类中有一个missing的魔法方法:
我们可以用它来做一个demo:
>>> class Missing(dict):
... def __missing__(self, key):
... return 'missing'
...
>>> d = Missing()
>>> d
{}
>>> d['foo']
'missing'
>>> d
{}
我们发现了它的作用,并将defaultdict子类化,只需要根据上面的例子:
class MyDict(defaultdict):
def __missing__(self, key):
value = [None, None]
self[key] = value
return value
这里我查阅了一下官方说明,在python 2.5之前,python没有加入defaultdict这个模块,所以很多人自己去实现了一个这样的类,而后续的python应该也是这样写的:
class defaultdict(dict):
def __init__(self, default_factory=None, *a, **kw):
dict.__init__(self, *a, **kw)
self.default_factory = default_factory
def __getitem__(self, key):
try:
return dict.__getitem__(self, key)
except KeyError:
return self.__missing__(key)
def __missing__(self, key):
self[key] = value = self.default_factory()
return value
另外关于更详细的说明,可以看《cookbook》或者官方讲解,直接来看上个月的一道题:
class Solution(object):
def topKFrequent(self, nums, k):
"""
:type nums: List[int]
:type k: int
:rtype: List[int]
"""
n = len(nums)
cntDict = collections.defaultdict(int)
for i in nums:
cntDict[i] += 1
freqList = [[] for i in range(n + 1)]
for p in cntDict:
freqList[cntDict[p]] += p,
ans = []
#最多出现n次,从n...1
for p in range(n, 0, -1):
ans += freqList[p]
return ans[:k]
而这种,同样可以用Counter进行代码缩减:
from collections import Counter
class Solution:
def topKFrequent(self, nums: List[int], k: int) -> List[int]:
return [i[0] for i in Counter(nums).most_common(k)]
所以,总结一下__missing__方法:
- 如果default_factory类属性为None,则KeyError使用key作为参数会引发异常。
- 如果default_factory不是None,则在不带参数的情况下调用该函数,以提供给定键的默认值,并将此值插入键的字典中,然后返回。
- 如果调用default_factory引发异常,则此异常将不更改地传播。
- 当找不到请求的键时__getitem__(),由dict类的方法 调用此方法。无论它返回或引发的收益,都将由__getitem__()。
- 请注意,missing()是不要求任何操作之外 getitem()。这意味着get()它将像普通词典None一样默认返回,而不是使用 default_factory。
orderedDict
关于orderdict,也就是字面意思,是会排序的字典,因为之前的字典是
没有顺序的,具体的比如这种:
>>> d1 = {}
>>> d1['a'] = 1
>>> d1['b'] = 2
>>> d1['c'] = 3
>>> d1['d'] = 4
>>> d1['e'] = 5
>>> d1
{'b': 2, 'd': 4, 'c': 3, 'a': 1, 'e': 5}
但根据我现在的版本,好像现在上面的已经不成立了,那似乎ordereddict的作用变小了一些,但依然是dict类下的第二大子类了,相比于defaultdict,当我在看default资料的时候,就经常看到很多人觉得它已经没有存在的必要了,但事实上还是有存在的地方的,节省代码还是很方便的。。emmm
另外关于这个的源码,可以在GitHub上的cpython编译器的__init__上看到,因为它是使用了双向链表来确定这种顺序,所以从空间复杂度来讲,甚至比dict还要多接近两倍的内存空间,典型的空间换时间了:
class OrderedDict(dict):
def __setitem__(self, key, value,
dict_setitem=dict.__setitem__, proxy=_proxy, Link=_Link):
'od.__setitem__(i, y) <==> od[i]=y'
# Setting a new item creates a new link at the end of the linked list,
# and the inherited dictionary is updated with the new key/value pair.
if key not in self:
self.__map[key] = link = Link()
root = self.__root
last = root.prev
link.prev, link.next, link.key = last, root, key
last.next = link
root.prev = proxy(link)
dict_setitem(self, key, value)
def popitem(self, last=True):
'''Remove and return a (key, value) pair from the dictionary.
Pairs are returned in LIFO order if last is true or FIFO order if false.
'''
if not self:
raise KeyError('dictionary is empty')
root = self.__root
if last:
link = root.prev
link_prev = link.prev
link_prev.next = root
root.prev = link_prev
else:
link = root.next
link_next = link.next
root.next = link_next
link_next.prev = root
key = link.key
del self.__map[key]
value = dict.pop(self, key)
return key, value
def move_to_end(self, key, last=True):
'''Move an existing element to the end (or beginning if last is false).
Raise KeyError if the element does not exist.
'''
link = self.__map[key]
link_prev = link.prev
link_next = link.next
soft_link = link_next.prev
link_prev.next = link_next
link_next.prev = link_prev
root = self.__root
if last:
last = root.prev
link.prev = last
link.next = root
root.prev = soft_link
last.next = link
else:
first = root.next
link.prev = root
link.next = first
first.prev = soft_link
root.next = link
def __sizeof__(self):
sizeof = _sys.getsizeof
n = len(self) + 1 # number of links including root
size = sizeof(self.__dict__) # instance dictionary
size += sizeof(self.__map) * 2 # internal dict and inherited dict
size += sizeof(self.__hardroot) * n # link objects
size += sizeof(self.__root) * n # proxy objects
return size
......
上图引用自参考文献,关于ordereddict的详细说明我后续会回来补充,目前来讲,我看到过这种结构用得最多的地方,应该就是缓存cache了,好像目前主流的算法模块或者python内存机制,如果不考虑大数据集,而是有批量的小样本,比如说mmdetection,还有是用python写的数据库,叫啥我忘了,都是在底层封装了ordereddict并制作cache类,具体的可以看题,LRUCache:
class LRUCache:
def __init__(self, capacity: int):
self.dict=collections.OrderedDict()
self.remain=capacity
def get(self, key: int) -> int:
if key not in self.dict:
return -1
else:
v=self.dict.pop(key)
self.dict[key]=v
return v
def put(self, key: int, value: int) -> None:
if key in self.dict:
self.dict.pop(key)
else:
if self.remain>0:
self.remain-=1
else:# 这个若是满了,字典第一项是最后加进去的
self.dict.popitem(last=False)
self.dict[key]=value
这个能实现当缓存容量达到上限时,在写入新数据之前删除最近最少使用的数据值,从而为新的数据值留出空间。而我们可以再进一步完善这种逻辑,使其达到商用的目的:
from collections import OrderedDict
class Cache(OrderedDict):
def __init__(self, *args, **kwds):
self.size_limit = kwds.pop("size_limit", None)
OrderedDict.__init__(self, *args, **kwds)
self.val_sum = 0
self.hit = 0
self.num_evicted = 0
self.total_req = 0
self._check_size_limit()
def __contains__(self, key):
self.total_req += 1
if OrderedDict.__contains__(self, key):
self.hit += 1
value = OrderedDict.__getitem__ (self,key)
self.move_item_to_the_top(key, value)
return True
else:
return False
def move_item_to_the_top(self, key, value):
OrderedDict.__setitem__(self, key, value)
def __setitem__(self, key, value):
OrderedDict.__setitem__(self, key, value)
self.val_sum += value
self._check_size_limit()
def _check_size_limit(self):
if self.size_limit is not None:
while self.val_sum > self.size_limit:
key, value = self.popitem(last=False)
self.val_sum -= value
self.num_evicted += 1
def get_cache_size(self):
return self.val_sum
def get_number_evicted(self):
return self.num_evicted
def get_hit_ratio(self):
return 1.00 * self.hit / self.total_req
def get_total_req(self):
return self.total_req
def get_hits(self):
return self.hit
参考与推荐:
[1]. Built-in Types
[2]. collections — High-performance container datatypes
[3]. collections — Container datatypes
[4]. 理解 Python 语言中的 defaultdict
[5].https://github.com/python/cpython/blob/3.8/Lib/collections/init.py#L81
[6]. OrderedDict 是如何保证 Key 的插入顺序的
[7]. How does collections.defaultdict work?