[python] [VI coding] 第十三章 案例研究:資料結構的選擇 - 教學區

[python] [VI coding] 第十三章 案例研究:資料結構的選擇

文章瀏覽次數 914

特種兵

特種兵圖像(預設)

2021-08-30 10:28:09

From:211.23.21.202

第十三章 案例研究:資料結構的選擇

一開始我們是從單一型態的變數學習起的,現在已經學會了混合的資料型態類別,這些也是資料結構的基礎。

不同資料型態有自己的函數與方法,每種資料型態亦有其獨特性。

在本章中,除了操作小練習外,更重要的是以實例為大家解說在不同情境下資料結構的選擇。

13-0 集合

還有一種複合資料型態叫做集合 (set),在原文講義中,後面章節才會提到,這裡稍微介紹一下,至少要知道這個型態的特性。

>>> y = set()
>>> type(y)
<class 'set'>

它的架構像列表,不過是把中括號改成大括號,但不像字典有鍵值對:

>>> x = {1, 2, 'string'}
>>> x
{1, 2, 'string'}

集合的特性是不能存入相同的值,相同的值會被捨棄。可以想成是字典的鍵集合。

>>> d = {'a': 123, 'b': 456}
>>> set(d)
{'b', 'a'}

因此,想去掉一個列表的重複元素,就可以使用集合:

>>> lst = [1, 2, 3, 2, 4, 1]
>>> lsts = set(lst)
>>> lsts
{1, 2, 3, 4}
>>> lst = list(lsts)
>>> lst
[1, 2, 3, 4]

集合一樣可以使用索引來取值,但它是不可變的型態,也就是不能對其新增、修改與刪除元素,這部分的特性比較像數組。

另外,它有幾個比較常用的方法是:

  1. intersection 取交集
  2. union 取聯集
  3. difference 取差集
>>> languages1 = {'js', 'python', 'go'}
>>> languages2 = {'python', 'go', 'java'}
>>> languages1.intersection(languages2) # 交集
{'python', 'go'}
>>> languages1.union(languages2) # 聯集
{'js', 'python', 'java', 'go'}
>>> languages1.difference(languages2) # 差集
{'js'}

到此,python 的四種複合式資料型態:列表、字典、數組與集合才算全部介紹完畢。

13-1 學生考試成績

假設一個班級共有3位學員,目前我們有這些學員的身高、體重與 2021 年國、英、數三科的分數。

資料整理如下:

姓名 身高 體重 國語 英文 數學
小慶 175 68 90 80 100
小達 170 65 82 70 90
小翰 180 80 85 88 86

依據這些資料,需要回答一些問題。

想建立這樣的資料讓程式使用,應該選擇哪一種資料結構呢?

首先,因為可能用到計算,顯然選擇一種較有彈性的資料型態會好一些,如列表或字典。

從資料中可以看出,使用列表雖然可以按序把資料存進去,但在取得資料時會比較不方便,因為列表只能用索引來取值,所以必須記得哪個索引是代表什麼資料,之後如果有更多科目或資訊,該怎麼辦?

最後,考慮選擇字典,因為索引可以很直觀地使用有意義的文字,甚至是中文也可以。

有意義的關鍵字,比較不會遺忘,在處理資料與整個程式碼時,看起來更直覺易懂。

雖然字典可能是無序的,但對我們來說,這些資料的順序並不重要,只要同一個人的資料可以歸納在一起,並有關鍵字來取得不同類別的資料即可。

確定選擇字典後,開始思考怎麼建立資料。

在同一個字典的鍵 (key) 必須是唯一值,也就是不能重複,顯然 d['姓名'] = '小慶' 這種架構較不理想,因為三個人的名字都是姓名,那 key 就重複了。使用姓名一、二、三也不是好主意,因為這樣又會落入死背號碼的窘況。

必須找出資料的相同與不同之處,加以歸納,雖然三個人都有名字,但三個人的名字都不一樣,把不同的名字直接拿來當作鍵,看起來比較合適。

那值呢?值就是屬於每個人的資料內容。但因為每個人的資料又有好幾個項目,所以值的部分也必須選擇一個比較好的資料型態。如果選擇列表,又會發生要記索引的問題,那還是考慮字典吧。

這個字典也需要鍵與值,很直觀的,鍵就是身高、體重、國語、英文和數學了。值就是資料內容本身。

雖然每個人都有一樣的鍵,但因為屬於不同人名下的字典,因此不會有問題。最後,我們決定使用字典的字典來初始化這些資料。

參考程式碼如下:

students_data = {
	'小慶' :
	{'身高' : 175, '體重' : 68, '國語' : 90, '英文' : 80, '數學' : 100},
	'小達' :
	{'身高' : 170, '體重' : 65, '國語' : 82, '英文' : 70, '數學' : 90},
	'小翰' :
	{'身高' : 180, '體重' : 80, '國語' : 85, '英文' : 88, '數學' : 86},
}

換行是給工程師容易看的,對程式來說可以全部寫在同一行,分行寫清楚有助於除錯。

接著取值看看:

>>> students_data['小達']['英文']
70

接著我們來幫這三個學員計算他們的 BMI 是不是符合標準。

BMI 的計算方式是體重(公斤)除以身高(公尺)的平方。算出來的數值,標準參考如下:

  • 18.5 以下為過瘦
  • 18.5 到 24 為正常
  • 24 到 27 為過重
  • 27 到 30 為輕度肥胖
  • 30 到 35 為中度肥胖
  • 35 到 38 為過度肥胖
  • 38 以上為病態肥胖

寫一個計算每個學員 BMI 的函數,並將結果存到該學員的字典中。

此時,又要思考一個問題,就是應該把迴圈寫在函數中計算每個人的 BMI ,還是只寫單次計算的方式就好?

如果把迴圈寫在函數裡,就表示要帶的參數是一個列表或字典,通常這樣的做法比較偏向方法的撰寫。

現在是撰寫一個可以通用的函數,因此我會選擇只在函數中完成計算。至於怎麼呼叫,就由呼叫的人決定。

想要計算每一個人,就由呼叫端寫迴圈把數值帶進來計算,這樣或許可以保持函數的通用性。

所以我們寫成這樣:

# 給定公斤與公尺,計算 BMI
def bmi(m, k):
	return k / m ** 2

這邊注意三件事:

  1. 身高的單位是公尺
  2. 需要小數所以不能用地板除 //
  3. ** 的優先順序最高(由右往左結合)

我們還可以寫一個專門把身高公分轉成公尺的函數,這個函數很簡單,但需要注意每個微小的細節,才能寫好。

我們不需要一次到位的寫程式能力,但需要調整程式到位的功力。

所以這樣調用這個函數:

>>> bmi(students_data['小慶']['身高'] / 100, students_data['小慶']['體重'])
22.20408163265306

事情還沒完,要能算出每個學員的 BMI ,然後分別存入字典中。

問題又來了,如果是列表,我們可以很直覺地使用 for 迴圈來遍歷每個值,因為索引是按照順序的。

但字典可能是無序的,而且鍵是有意義的關鍵字,那要怎麼遍歷?

利用之前所學,可以遍歷字典的鍵,再利用這個鍵帶回去字典裡找我們要的值,像這樣:

for k in students_data.keys(): # 遍歷字典的鍵,也就是學員姓名
	students_data[k]['bmi'] = bmi(students_data[k]['身高'] / 100, students_data[k]['體重']) # 直接利用鍵找到所有的資訊帶入,並將計算結果存回字典

讓我們確認一下 BMI 資料有沒有存入:

>>> students_data['小慶']['bmi']
22.20408163265306

接下來,想要計算每個人的平均分數,並且保持擴充性,也就是將來加了美術、體育、化學等成績時,原本的程式不用大改。

如果免不了要改程式,請盡量降低修改程度,避免發生掛一漏萬的狀況。

我的做法是建立一個列表,將科目,也就是鍵,放到列表當中,之後都是由這個列表當作鍵來取值。新增科目除了原先的字典需加入資料外,也需要在該列表中加入科目,這些都是程式可以自動化的工作。

沒有使用數組或集合,是因為沒辦法增加值進去,比較不方便。

例如:

subject = ['國語', '英文', '數學'] # 初始基本款

想增加科目就用 append() 方法來把科目加到列表中。

只要遍歷這個列表,就可以得到所有的科目。同時,計算這個列表的長度,也可以知道目前有幾個科目。

類似這樣:

# 帶入所有科目與目前分數,計算總平均
def grade(sub, p):
	sum = 0 # 用來累加所有科目的總分
	length = len(sub) # 算出目前有幾個科目
	# 累加所有科目的總分
	for i in sub: # 遍歷科目列表可以得到所有科目
		sum += p[i] # 所有科目分數加總
	return sum / length # 回傳總平均

調用計算一個學員的平均,像這樣:

>>> grade(subject, students_data['小達'])
80.66666666666667

科目與數量都是動態的,這樣就可以隨時增減科目了。

值得注意的是,調用時是傳內層的字典進去,所以在函數中變成只有一層的字典在使用,沒用到的元素先不管它。

至於想帶入計算每個學員的平均,方式就跟前述討論的計算 BMI 一樣用迴圈了。

順便提醒,在函數中變更作為參數傳入的字典、列表的值,也會直接作用在全域的變數當中。

13-2 亂數

在之前的章節練習中,已經有請大家閱讀過亂數的使用參考文章,現在在這裡正式地介紹它。

亂數產生的方式,目前學到兩類,一類是數字形的亂數,像是亂數取得整數:

import random

num = random.randint(10, 20)
print(num)

重點是取出來的亂數也包含 10 和 20 這兩個數喔。

以下是亂數取得 0 到 1 之間的數:

import random

t = random.random()
print(t)

上面的小數呈現並非不實用,它的好處是想要幾位數就乘多少,指定兩個小數間的亂數也可以:

import random

c = random.uniform(0.0, 20.0)
print(c)

另外一類就是我們自己已經有樣本,然後希望用亂數的方式取得其中一個項目,像這樣:

import random

x = random.choice([1, 3, 5, 7, 9])
print(x)

還有可以從樣本中隨機產生固定數量的亂數:

import random

y = random.sample([1, 3, 5, 7, 9], 2)
print(y)

我想這些就非常夠用了,最後補一個隨機打亂原本排序樣本的方法,像是洗牌程式就會用到:

import random

data = [1, 3, 5, 7, 9]
random.shuffle(data)
print(data)

13-3 對對碰

這是一種考驗記憶力的紙牌遊戲,俗稱釣魚。玩法是將整副牌蓋住打散,全部任意排在桌上,雙方輪流,一次翻兩張牌,如果數字一樣就收走得分。

若翻出來的數字不同,則將牌蓋上,換下一個玩家翻牌。如果得分,還可以再翻一次,直到大家把牌翻完,得到最多分的玩家獲勝。

一個很簡單的遊戲,通常是比較小的孩子才會想玩,玩家的記憶力都不好的話,一場可能會玩很久。我們來思考這樣的紙牌遊戲應該怎麼初始化。

由規則中看出,這個遊戲不強調紙牌花色,所以只針對數字即可。

可以先考慮產生一個按照順序排列的紙牌列表,再用上一節介紹的方法把數字打亂,別忘了共有52張牌:

import random

special = ['A', 'J', 'Q', 'K']
pi = []
for i in range(2, 11):
	pi.append(i)
for i in special:
	pi.append(i)
pi *= 4
random.shuffle(pi)
print(pi)

為了貼近真實,必須使用 A, J, Q, K 來取代 1, 11, 12, 13 這些數字。因為這些牌的順序必須被打亂,所以不需要按照順序排列。

可以考慮一開始就建立一個正確順序的靜態列表,最後直接打亂順序就好:

import random

pi = ['A', 2, 3, 4, 5, 6, 7, 8, 9, 10, 'J', 'Q', 'K']
pi *= 4
random.shuffle(pi)
print(pi)

還可以建立一個13張牌的列表,用 choice 隨機挑選數字,要記得被挑出來的數字需從原列表刪除。

總之,可以想到的方式很多,自己比較一下哪一個較好,還有沒有更好的方式。

在不太影響效能的情況下,方法的選擇是很自由的,有人喜歡選速度快的、有人會選程式碼少的,也有人會想要選擇容易看懂的程式碼。

13-4 列表的表達式

列表也稱為串列,所以又稱為串列表達式。它是一種快速建立列表的方法,通常搭配 for 迴圈使用。

第一個例子是產生一個 1 到 10 的列表,之前是用純 for 迴圈完成,現在還有另一種做法:

num_list = [i for i in range(1, 11)]
print(num_list)

注意,此時的 for 是沒有冒號的,for 前面的 i 就是最後產生出來的列表元素。

第二個例子是產生 1 到 10 的偶數列表:

num_list = [i for i in range(1, 11) if i % 2 == 0]
print(num_list)

在上個例子中,使用 range() 的第三個參數就可以解決,不過在這裡主要是探討表達式的用法,故意使用 if 來完成。關鍵在於執行順序。

先進入 for 迴圈,產生 i 後再判斷迴圈內的 if 為真時,才是最後留下來的列表元素 i。

第三個例子是產生一個從 1 到 10 的平方列表:

squares = [value**2 for value in range(1, 11)]
print(squares)

上例是在 for 前面的元素加入運算式,最後產生想要的結果。

第四個例子,從另外一個列表挑出及格的分數成為新列表(加入判斷式):

all_grades = [80, 70, 50, 100, 20, 40, 90, 10]
grades = [grade for grade in all_grades if grade > 60]
print(grades)

運作的順序與第二個例子完全相同,只是本例是遍歷一個分數列表。

第五個例子,使用兩個 for 迴圈產生一個九九乘法表的積列表:

result = [i * j for i in range(1, 10) for j in range(1, 10)]
print(result)

上例是雙層的迴圈,思路上可以把 i * j 的式子當作是在第二個迴圈裡面執行的敘述,會比較好理解。

第六個是產生雙層迴圈的例子:

result = [[j for j in range(10)] for i in range(3)]
print(result)

上例中,先產生0到9的列表,迴圈共跑三圈,最後會產生三次一樣的列表。加上最外面的列表中括號,成為列表中的列表。

最後,表達式不只是用在列表上,還可以這樣做:

[print(i) for i in range(10)]

上例將會印出0到9,每行一個數字。

如果夠熟悉的話,還可以創造出許多有趣的列表或利用簡短的程式碼,做到複雜的事情。

但也建議不要把表達式弄得太複雜難懂,否則將失去意義。

除錯

當你正在對程式除錯,特別是遇到很難抓出的問題時,請考慮這五點:

  1. 閱讀:檢查程式碼,一次次地讀它,直到確定程式碼所表達的確實是原本想要的意思。
  2. 執行:不斷地修改並使用不同的版本、方式去測試程式,通常在正確的地方顯示正確的結果,就表示程式沒問題。有時免不了要寫一些測試程式,或設計一下測試方案,才能發現更多潛在的問題。
  3. 反省:思考這方面的錯誤屬於哪一類,語法、語意還是執行錯誤,這有助於你把問題點縮小,再回溯問題出現前曾改了什麼、是不是確定看懂且閱讀完整錯誤訊息的內容。
  4. 橡皮鴨除錯法:卡關時,不要只思考自認為錯誤的地方,試著找個對象解釋你寫的程式,甚至像外國的Quack Overflow形容的,向橡皮鴨解釋也可以。這能幫助你冷靜下來,釐清思路,避免在一個圈圈裡繞不出來,發現不足之處。
  5. 後退:打掉重練也是一個辦法。將程式往回修改到相對簡單且沒有問題的版本,再重新開始寫。這會打破自己的預設,帶來不同的思維,並避開原本發生問題的設計。退回原點重新思考無須擔心誤刪程式碼,如果刪了程式碼卻寫不回來,表示還有地方不懂,那就必須再學習。

我有個經驗,跑去洗個澡、睡個覺或散散步,先做別的事再換個心情,或許就會豁然開朗了。

動動腦

如何初始化字典列表?

# 單次
d = {'姓名' : '王大毛', '性別' : '男'}
lst = []
lst.append(d)
print(lst)

# 迴圈
for i in range(10):
	lst.append(d)
print(lst)

# 表達式
length = 10
dl = []
dl = [d for i in range(length)]
print(dl)

習題

練習 1 檔名: 13-1.py

請完成「對對碰遊戲」,暫時以兩個玩家比賽來撰寫就好。

之前較大的習題都幫各位設計好流程並有操作範例,現在該是讓大家自己去思考發揮的時候了。我不希望間接限制了你們的創意與思維。

以下是必須思考的問題,想通了再來撰寫這個遊戲,會比較輕鬆。

  • 怎麼讓玩家使用鍵盤翻牌?
  • 翻牌後在什麼時間點蓋回去?
  • 在沒有圖形介面的情況下,該怎麼排列這 52 張牌?
  • 翻牌時,有哪些狀況是需要做判斷的,這些判斷的順序應該怎麼安排?
  • 這種一個人翻兩張牌的玩法該怎麼設計比較合理?
練習 2 檔名: 13-2.py

用一行程式取得 1 到 10 中,有哪些數的立方超過 300 的,並直接將該數的平方印出來,每行一個。

參考資料

python collections

python 綜合表達式

使用 for 迴圈產生列表

影片

第十三章 案例研究-資料結構的選擇

最後更新:2021-10-08 20:49:17

From: 111.249.165.250

By: 特種兵