你真的瞭解字典和集合?-day2

你真的瞭解字典和集合?

寫在前面

進階第二天!堅持打卡!

前面的博文,重新學習了 Python 中的列表和元組,瞭解了他們的基本操作和性能比較。今天,我們來看看兩個同樣很常見並且很有用的數據結構:字典(dict)和集合(set)。

字典和集合在 Python 被廣泛使用,並且性能進行了高度優化,其重要性不言而喻。

字典和集合基礎

那究竟什麼是字典,什麼是集合呢?字典是一系列由鍵(key)和值(value)配對組成的元素的集合,在 Python3.7+,字典被確定爲有序(注意:在 3.6 中,字典有序是一個 implementation detail,在 3.7 才正式成爲語言特性,因此 3.6 中無法 100% 確保其有序性),而 3.6 之前是無序的,其長度大小可變,元素可以任意地刪減和改變。

相比於列表和元組,字典的性能更優,特別是對於查找、添加和刪除操作,字典都能在常數時間複雜度內完成。而集合和字典基本相同,唯一的區別,就是集合沒有鍵和值的配對,是一系列無序的、唯一的元素組合。

首先我們來看字典和集合的創建,通常有下面這幾種方式:


d1 = {'name': 'jason', 'age': 20, 'gender': 'male'}
d2 = dict({'name': 'jason', 'age': 20, 'gender': 'male'})
d3 = dict([('name', 'jason'), ('age', 20), ('gender', 'male')])
d4 = dict(name='jason', age=20, gender='male') 
d1 == d2 == d3 ==d4
True

s1 = {1, 2, 3}
s2 = set([1, 2, 3])
s1 == s2
True

這裏注意,Python 中字典和集合,無論是鍵還是值,都可以是混合類型。比如下面這個例子,我創建了一個元素爲1,‘hello’,5.0的集合:


s = {1, 'hello', 5.0}

再來看元素訪問的問題。字典訪問可以直接索引鍵,如果不存在,就會拋出異常:


d = {'name': 'jason', 'age': 20}
d['name']
'jason'
d['location']
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'location'

也可以使用 get(key, default) 函數來進行索引。如果鍵不存在,調用 get() 函數可以返回一個默認值。比如下面這個示例,返回了’null’。


d = {'name': 'jason', 'age': 20}
d.get('name')
'jason'
d.get('location', 'null')
'null'

說完了字典的訪問,我們再來看集合。首先我要強調的是,集合並不支持索引操作,因爲集合本質上是一個哈希表,和列表不一樣。所以,下面這樣的操作是錯誤的,Python 會拋出異常:


s = {1, 2, 3}
s[0]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'set' object does not support indexing

想要判斷一個元素在不在字典或集合內,我們可以用 value in dict/set 來判斷。


s = {1, 2, 3}
1 in s
True
10 in s
False

d = {'name': 'jason', 'age': 20}
'name' in d
True
'location' in d
False

當然,除了創建和訪問,字典和集合也同樣支持增加、刪除、更新等操作。


d = {'name': 'jason', 'age': 20}
d['gender'] = 'male' # 增加元素對'gender': 'male'
d['dob'] = '1999-02-01' # 增加元素對'dob': '1999-02-01'
d
{'name': 'jason', 'age': 20, 'gender': 'male', 'dob': '1999-02-01'}
d['dob'] = '1998-01-01' # 更新鍵'dob'對應的值 
d.pop('dob') # 刪除鍵爲'dob'的元素對
'1998-01-01'
d
{'name': 'jason', 'age': 20, 'gender': 'male'}

s = {1, 2, 3}
s.add(4) # 增加元素4到集合
s
{1, 2, 3, 4}
s.remove(4) # 從集合中刪除元素4
s
{1, 2, 3}

不過要注意,集合的 pop() 操作是刪除集合中最後一個元素,可是集合本身是無序的,你無法知道會刪除哪個元素,因此這個操作得謹慎使用。實際應用中,很多情況下,我們需要對字典或集合進行排序,比如,取出值最大的 50 對。

對於字典,我們通常會根據鍵或值,進行升序或降序排序:


d = {'b': 1, 'a': 2, 'c': 10}
d_sorted_by_key = sorted(d.items(), key=lambda x: x[0]) # 根據字典鍵的升序排序
d_sorted_by_value = sorted(d.items(), key=lambda x: x[1]) # 根據字典值的升序排序
d_sorted_by_key
[('a', 2), ('b', 1), ('c', 10)]
d_sorted_by_value
[('b', 1), ('a', 2), ('c', 10)]

這裏返回了一個列表。列表中的每個元素,是由原字典的鍵和值組成的元組。而對於集合,其排序和前面講過的列表、元組很類似,直接調用 sorted(set) 即可,結果會返回一個排好序的列表。


s = {3, 4, 2, 1}
sorted(s) # 對集合的元素進行升序排序
[1, 2, 3, 4]

字典和集合性能

文章開頭我就說到了,字典和集合是進行過性能高度優化的數據結構,特別是對於查找、添加和刪除操作。那接下來,我們就來看看,它們在具體場景下的性能表現,以及與列表等其他數據結構的對比。比如電商企業的後臺,存儲了每件產品的 ID、名稱和價格。現在的需求是,給定某件商品的 ID,我們要找出其價格。

如果我們用列表來存儲這些數據結構,並進行查找,相應的代碼如下:


def find_product_price(products, product_id):
    for id, price in products:
        if id == product_id:
            return price
    return None 
     
products = [
    (143121312, 100), 
    (432314553, 30),
    (32421912367, 150) 
]

print('The price of product 432314553 is {}'.format(find_product_price(products, 432314553)))

# 輸出
The price of product 432314553 is 30

假設列表有 n 個元素,而查找的過程要遍歷列表,那麼時間複雜度就爲 O(n)。即使我們先對列表進行排序,然後使用二分查找,也會需要 O(logn) 的時間複雜度,更何況,列表的排序還需要 O(nlogn) 的時間。

但如果我們用字典來存儲這些數據,那麼查找就會非常便捷高效,只需 O(1) 的時間複雜度就可以完成。原因也很簡單,剛剛提到過的,字典的內部組成是一張哈希表,你可以直接通過鍵的哈希值,找到其對應的值。


products = {
  143121312: 100,
  432314553: 30,
  32421912367: 150
}
print('The price of product 432314553 is {}'.format(products[432314553])) 

# 輸出
The price of product 432314553 is 30

類似的,現在需求變成,要找出這些商品有多少種不同的價格。我們還用同樣的方法來比較一下。

如果還是選擇使用列表,對應的代碼如下,其中,A 和 B 是兩層循環。同樣假設原始列表有 n 個元素,那麼,在最差情況下,需要 O(n^2) 的時間複雜度。


# list version
def find_unique_price_using_list(products):
    unique_price_list = []
    for _, price in products: # A
        if price not in unique_price_list: #B
            unique_price_list.append(price)
    return len(unique_price_list)

products = [
    (143121312, 100), 
    (432314553, 30),
    (32421912367, 150),
    (937153201, 30)
]
print('number of unique price is: {}'.format(find_unique_price_using_list(products)))

# 輸出
number of unique price is: 3

但如果我們選擇使用集合這個數據結構,由於集合是高度優化的哈希表,裏面元素不能重複,並且其添加和查找操作只需 O(1) 的複雜度,那麼,總的時間複雜度就只有 O(n)。


# set version
def find_unique_price_using_set(products):
    unique_price_set = set()
    for _, price in products:
        unique_price_set.add(price)
    return len(unique_price_set)        

products = [
    (143121312, 100), 
    (432314553, 30),
    (32421912367, 150),
    (937153201, 30)
]
print('number of unique price is: {}'.format(find_unique_price_using_set(products)))

# 輸出
number of unique price is: 3

可能你對這些時間複雜度沒有直觀的認識,我可以舉一個實際工作場景中的例子,讓你來感受一下。

下面的代碼,初始化了含有 100,000 個元素的產品,並分別計算了使用列表和集合來統計產品價格數量的運行時間:


import time
id = [x for x in range(0, 100000)]
price = [x for x in range(200000, 300000)]
products = list(zip(id, price))

# 計算列表版本的時間
start_using_list = time.perf_counter()
find_unique_price_using_list(products)
end_using_list = time.perf_counter()
print("time elapse using list: {}".format(end_using_list - start_using_list))
## 輸出
time elapse using list: 41.61519479751587

# 計算集合版本的時間
start_using_set = time.perf_counter()
find_unique_price_using_set(products)
end_using_set = time.perf_counter()
print("time elapse using set: {}".format(end_using_set - start_using_set))
# 輸出
time elapse using set: 0.008238077163696289

你可以看到,僅僅十萬的數據量,兩者的速度差異就如此之大。事實上,大型企業的後臺數據往往有上億乃至十億數量級,如果使用了不合適的數據結構,就很容易造成服務器的崩潰,不但影響用戶體驗,並且會給公司帶來巨大的財產損失。

工作原理

我們通過舉例以及與列表的對比,看到了字典和集合操作的高效性。不過,字典和集合爲什麼能夠如此高效,特別是查找、插入和刪除操作?

這當然和字典、集合內部的數據結構密不可分。不同於其他數據結構,字典和集合的內部結構都是一張哈希表。

  • 對於字典而言,這張表存儲了哈希值(hash)、鍵和值這 3 個元素。
  • 而對集合來說,區別就是哈希表內沒有鍵和值的配對,只有單一的元素了。

我們來看,老版本 Python 的哈希表結構如下所示:


--+-------------------------------+
  | 哈希值(hash)(key)(value)
--+-------------------------------+
0 |    hash0      key0    value0
--+-------------------------------+
1 |    hash1      key1    value1
--+-------------------------------+
2 |    hash2      key2    value2
--+-------------------------------+
. |           ...
__+_______________________________+

不難想象,隨着哈希表的擴張,它會變得越來越稀疏。舉個例子,比如我有這樣一個字典:

{‘name’: ‘mike’, ‘dob’: ‘1999-01-01’, ‘gender’: ‘male’}

那麼它會存儲爲類似下面的形式:


entries = [
['--', '--', '--']
[-230273521, 'dob', '1999-01-01'],
['--', '--', '--'],
['--', '--', '--'],
[1231236123, 'name', 'mike'],
['--', '--', '--'],
[9371539127, 'gender', 'male']
]

這樣的設計結構顯然非常浪費存儲空間。爲了提高存儲空間的利用率,現在的哈希表除了字典本身的結構,會把索引和哈希值、鍵、值單獨分開,也就是下面這樣新的結構:


Indices
----------------------------------------------------
None | index | None | None | index | None | index ...
----------------------------------------------------

Entries
--------------------
hash0   key0  value0
---------------------
hash1   key1  value1
---------------------
hash2   key2  value2
---------------------
        ...
---------------------

那麼,剛剛的這個例子,在新的哈希表結構下的存儲形式,就會變成下面這樣:


indices = [None, 1, None, None, 0, None, 2]
entries = [
[1231236123, 'name', 'mike'],
[-230273521, 'dob', '1999-01-01'],
[9371539127, 'gender', 'male']
]

我們可以很清晰地看到,空間利用率得到很大的提高。清楚了具體的設計結構,我們接着來看這幾個操作的工作原理。

插入操作

每次向字典或集合插入一個元素時,Python 會首先計算鍵的哈希值(hash(key)),再和 mask = PyDicMinSize - 1 做與操作,計算這個元素應該插入哈希表的位置 index = hash(key) & mask。如果哈希表中此位置是空的,那麼這個元素就會被插入其中。

而如果此位置已被佔用,Python 便會比較兩個元素的哈希值和鍵是否相等。

  • 若兩者都相等,則表明這個元素已經存在,如果值不同,則更新值。

  • 若兩者中有一個不相等,這種情況我們通常稱爲哈希衝突(hash collision),意思是兩個元素的鍵不相等,但是哈希值相等。這種情況下,Python 便會繼續尋找表中空餘的位置,直到找到位置爲止

值得一提的是,通常來說,遇到這種情況,最簡單的方式是線性尋找,即從這個位置開始,挨個往後尋找空位。當然,Python 內部對此進行了優化(這一點無需深入瞭解,你有興趣可以查看源碼,我就不再贅述),讓這個步驟更加高效。

查找操作

和前面的插入操作類似,Python 會根據哈希值,找到其應該處於的位置;然後,比較哈希表這個位置中元素的哈希值和鍵,與需要查找的元素是否相等。如果相等,則直接返回;如果不等,則繼續查找,直到找到空位或者拋出異常爲止。

刪除操作

對於刪除操作,Python 會暫時對這個位置的元素,賦於一個特殊的值,等到重新調整哈希表的大小時,再將其刪除。

不難理解,哈希衝突的發生,往往會降低字典和集合操作的速度。因此,爲了保證其高效性,字典和集合內的哈希表,通常會保證其至少留有 1/3 的剩餘空間。隨着元素的不停插入,當剩餘空間小於 1/3 時,Python 會重新獲取更大的內存空間,擴充哈希表。不過,這種情況下,表內所有的元素位置都會被重新排放。

雖然哈希衝突和哈希表大小的調整,都會導致速度減緩,但是這種情況發生的次數極少。所以,平均情況下,這仍能保證插入、查找和刪除的時間複雜度爲 O(1)。

寫在後面

Yes! 堅持打卡第二天
透漏一下,基礎系列可能會分爲十四個板塊
希望大家多多支持,來一波素質三連
公衆號:興趣路人甲

在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章