2021年11月8日 星期一

Python 學習筆記 : 串列, 字典, 與集合的生成式 (comprehension)

Python 的串列生成式 (list comprehension) 是有別於其他語言的語法, 它可使程式碼更簡潔與具有 Python 風格 (Pythonic), 如果不是要操作大量資料的話, 基本上可完全取代 map() 與 filter() 等高階函式的功能. 此外不僅串列有生成式, 字典與集合也有, 不過元組卻沒有生成式 (no tuple comprehension), 因為類似的語句稱為產生器表達式 (generator expression), 它傳回的是一個產生器物件, 而不是元組. 

關於產生器參考 :    



1. 串列生成式 (list comprehension) :  

串列生成式是利用迴圈走訪一個可迭代物件的元素, 透過運算式或函式來求值並將結果放在串列中產生新串列, 語法如下 : 

[運算式 for item in 可迭代物件]      

也可以加上 if 條件式 :

[運算式 for item in 可迭代物件 if 條件式]

其中串列元素是在走訪可迭代物件時由 item 組成的運算式產生 (條件式也是 item 的運算式), 運算式也可以用函式代替, 整個生成式語句都是在一個中括號中完成. 由於串列生成式是直接產生串列, 故稱為 list comprehension. 

另外若有兩層以上的 for 迴圈, 只要將迴圈串接即可 (內圈在後), 例如兩層迴圈 :

[運算式 for i in 可迭代物件 for j in 可迭代物件 if 條件式]

三層迴圈的生成式 : 

[運算式 for i in 可迭代物件 for j in 可迭代物件 for k in 可迭代物件 if 條件式]

例如 :

>>> list1=[i for i in range(10)]     # 串列生成式
>>> list1    
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]    

這其實就是下列程式碼的簡化 :

>>> list2=[]               # 空串列
>>> for i in range(10):      # 迴圈走訪 Range 可迭代物件
    list2.append(i)    
    
>>> list2  
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9] 

其實也可以不用串列生成式, 直接將 range() 傳回的 range 物件傳給 list() 即可 : 

>>> list(range(10))    
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]  

下面是用 i**2 運算式產生平方數串列 : 

>>> list3=[i**2 for i in range(10)]     # 字典生成式
>>> list3    
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

附加 if 條件式的範例如下, 此例附加了除以 2 餘數為 0 與為 1 的條件, 故產生的串列元素為偶數與奇數. : 

>>> new_list=[i for i in range(10) if i % 2 == 0]      # 附加偶數條件
>>> new_list   
[0, 2, 4, 6, 8]
>>> new_list=[i for i in range(10) if i % 2 == 1]      # 附加奇數條件
>>> new_list 
[1, 3, 5, 7, 9]

下面是用 zip() 產生的兩個變數序列的串列生成式範例 :

>>> list1=[x + y for x, y in zip([1, 2, 3], [4, 5, 6])]     # 運算式有兩個變數的生成式 
>>> list1   
[5, 7, 9]

關於 zip() 函式用法參考 : 


兩層迴圈的串列生成式例如 (參考 Oreilly "精通 Python" 第四章) :

>>> rows=range(1, 4)      
>>> cols=range(1, 3)   
>>> cells=[[row, col] for row in rows for col in cols]    
>>> cells   
[[1, 1], [1, 2], [2, 1], [2, 2], [3, 1], [3, 2]]

此例生成式中有兩個 for 迴圈, 前者為外迴圈, 後者為內迴圈, 輸出二維串列. 

下面範例使用兩層 for 迴圈產生元組串列 :  

>>> list1=[(x, y) for x in range(5) for y in range(x)]   
>>> list1       
[(1, 0), (2, 0), (2, 1), (3, 0), (3, 1), (3, 2), (4, 0), (4, 1), (4, 2), (4, 3)]

下面的範例是求直角三角形的三個邊長 a, b, c (斜邊), 使用三層 for 迴圈的串列生成式 : 

>>> triangle=[[a, b, c] for a in range(1, 20) for b in range(a, 20) for c in range(b, 20) if a**2 + b**2 == c**2]  
>>> tringle 
[[3, 4, 5], [5, 12, 13], [6, 8, 10], [8, 15, 17], [9, 12, 15]]

此例在三層迴圈內利用畢達哥拉斯條件求斜邊為 c, 兩直角邊為 a, b 的三角形邊長. 

產生器表達式與 map() 也可以產生串列, 但一般認為用串列生成式可讀性較佳, 例如 :

>>> gen=(str(i) for i in range(5))      
>>> list(gen)      
['0', '1', '2', '3', '4']  
>>> mapped=map(str, range(5))     
>>> list(mapped)
['0', '1', '2', '3', '4']
>>> [str(i) for i in range(5)]      
['0', '1', '2', '3', '4']

但如果要建立的串列很龐大, 則使用 map() 或產生器表達式較好, 因不會立即占用大量記憶體. 


2. 字典生成式 (dict comprehension) : 

字典生成式可以簡潔快速地利用可迭代物件來產生字典, 其語法如下 : 

{鍵運算式: 值運算式 for item in 可迭代物件 if 條件式}

與串列生成式不一樣的地方是運算式分成 key 與 value 兩部分, 可以有多層迴圈, 運算式也可以改用函式求值, 例如 :

>>> str='Hello World!'    
>>> dict1={c: str.count(c) for c in str}       
>>> dict1       
{'H': 1, 'e': 1, 'l': 3, 'o': 2, ' ': 1, 'W': 1, 'r': 1, 'd': 1, '!': 1}      
>>> dict1['l']       # 字元 i 出現的次數
3
>>> dict1['o']      # 字元 o 出現的次數
2  

此例利用字串物件的 count() 方法計算字元在字串中出現的次數作為字典的值, 以每一個字元當作鍵, 所以空格 ' ' 與 '!' 都出現一次, 也可以加上 if 判斷式過濾英文字母, 例如 : 

>>> dict1={c: str.count(c) for c in str if c.isalpha()}     # 加上 if 條件式過濾 
>>> dict1      
{'H': 1, 'e': 1, 'l': 3, 'o': 2, 'W': 1, 'r': 1, 'd': 1}      

可見經過字串物件方法 isalpha() 過濾, 非字母的空格 ' ' 與 '!' 都被濾掉了.  

下面範例是利用字典生成式將兩個串列組合成一個字典 :

>>> students=['Peter', 'Amy', 'Kelly']      # 序列 1
>>> grades=['A', 'A', 'B']                           # 序列 2
>>> dict1={students[i]: grades[i] for i in range(len(students))}      
>>> dict1     
{'Peter': 'A', 'Amy': 'A', 'Kelly': 'B'}

但更 Pythonic 的寫法則是利用 zip() 函式, 例如 : 

 >>> students=['Peter', 'Amy', 'Kelly']      # 序列 1
>>> grades=['A', 'A', 'B']                            # 序列 2
>>> dict1={k: v for k, v in zip(students, grades)}      # 使用 zip() 使程式更簡潔
>>> dict1     
{'Peter': 'A', 'Amy': 'A', 'Kelly': 'B'}

此處 zip() 會將兩個串列的對應元素組成一群 (k, v) 的元組, 這樣就可以在迭代 zip 物件元素時取出 k, v 來生成字典. 關於 zip() 函式用法參考 : 


下面的範例則是利用解包字典物件的 items() 傳回的鍵值, 將 k, v 對調生成新字典 :

>>> weeks_1={'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6, 'Sun': 7}    
>>> weeks_2={num: name for name, num in weeks_1.items()}      # k, v 對調
>>> weeks_2   
{1: 'Mon', 2: 'Tue', 3: 'Wed', 4: 'Thu', 5: 'Fri', 6: 'Sat', 7: 'Sun'}

可見以星期名為 key, 星期數為 value 的原字典經過生成式對調後, 新字典變成以星期數為 key, 以星期名為 value 了. 


3. 集合生成式 (set comprehension) : 

集合與字典一樣都是使用大括號 {} 包圍所有元素, 但集合沒有 key, 只有 value, 且這些值不會重複, 集合的元素必須是不可變的資料型態, 例如數值, 字串, 元組等; 而串列, 字典, 集合這些可變型態資料都不能作為集合的元素, 因為它們無法計算雜湊值, 將它們傳入 hash() 會出現錯誤, 例如 :

>>> hash([1, 2, 3])                                  # 串列無法計算雜湊值
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
TypeError: unhashable type: 'list'    
>>> set1={1, 2, 3, [1, 2, 3]}                     # 串列不可以作為集合的元素
Traceback (most recent call last):
  File "<pyshell>", line 1, in <module>
TypeError: unhashable type: 'list'    

集合生成式的語法與字典的類似, 只是沒有 Key 而已 : 

{值運算式 for item in 可迭代物件 if 條件式}

若運算式的值重複, 則該結果會自動被忽略 (但也不會報錯), 因為集合的元素都是唯一的 (可雜湊的). 例如 :

>>> set1={i*i for i in (-2, -2, 0, 2, 5)}    
>>> set1  
{0, 25, 4}

此例的運算式 i*i 對於元素 -2, -2, 與 2 都得到 4 這個結果, 但只會保留 1 個. 

下面是添加 if 條件過濾的範例 : 

>>> list1=[0, 2, 2, 3, 4, 7, 8, 11, 16, 16, 19, 20]     # 串列
>>> set1={i for i in list1 if i%2 ==0}                    # 過濾偶數元素組成集合
>>> set1   
{0, 2, 4, 8, 16, 20}

可見重複的串列元素 2 與 16 只有一個會列入集合中, 因重複者會自動被忽略. 

從以上範例可知, 生成式可使程式碼簡潔易懂, 許多原本要用傳統迴圈語法處理的操作若改用生成式通常變成一列程式碼就解決了. 但要注意的是, 生成式並非萬靈丹, 若運算式過於複雜反而會使程式碼可讀性下降. 

沒有留言 :