python中字典类型使用与说明

引言

本篇想总结的是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原理:

  1. 可变哈希表,key通过hash函数映射为哈希值,哈希值进一步映射到哈希表大小size的位置index上,value的地址就存放在index所对应的空间上。所以这里就说明key必须是不可变类型,不然无法申请到内存空间
  2. dict数据的表现形式为:一行记录2个数据:key的引用和value的引用,因为python中没有指针的概念,一般都是说成相对引用。
  3. dict查询时,是将key哈希得到哈希值之后,用哈希值末尾几位计算哈希表存放的位置,在哈希表较拥挤触发哈希扩容之后,会增大哈希值的末尾几位。
  4. 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?

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

submarineas

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值