[python] [VI coding] 第九章 案例研究:猜字遊戲 - 教學區

[python] [VI coding] 第九章 案例研究:猜字遊戲

文章瀏覽次數 633

特種兵

特種兵圖像(預設)

2020-12-19 16:10:26

From:1.161.131.9

第九章 案例研究:猜字遊戲

這是我們的第二個案例研究,透過本章的探討,我們將學會幾個處理字串的技巧,例如關於迴文與查詢字母順序大小等。利用這些案例,可以解決一些之前遇到的問題。

同時,我們盡量以讓各位多練習的方式來強化撰寫程式的感覺,以便習慣用程式的角度去思考問題。

9-1 讀取單字列表

在這章的練習中,我們需要使用到一份英文單字的列表,網路上可以找到很多像這樣的資料。

比較適合這次使用的是由 Grady Ward 公開發表的 the Moby lexicon project.

這裡面包含 113,809 個在填字遊戲當中被使用到的單字,檔名是 113809of.fic, 可以下載。

另外,這裡還有一個簡單的純文字版本,檔名為 words.txt.

因為是純文字,所以可以直接用一般的文字編輯器打開,或利用 python 的讀取檔案物件,也可以很輕易地讀取它的資訊。

假設我們已經下載,並將檔案命名為 words.txt, 可用以下的程式碼開啟:

>>> fin = open('words.txt')

open 函數使用唯讀的方式開啟檔案,fin 是一個普通的變數,用來接收檔案物件的輸入。

我們這邊省略了 open 的兩個參數,一個是指定用什麼模式來開檔,另一個是指定用什麼編碼來讀取檔案,這些等到探討檔案輸出、輸入時再來細說。

如果檔案不在相對路徑中,就必須填上絕對路徑,才能讓 python 順利找到檔案並開檔。

目前的重點是處理字串的部分,開檔讀取資訊只是一種方式與過程。

利用檔案物件提供的 readline 方法,可以一次讀取檔案的一行文字,並且回傳其字串。

>>> fin.readline()
'aa\n'

這個單字列表,也就是這個檔案的第一行是 aa 這個單字,而 \n 是換行符號,

readline 方法遇到 \n ,就表示讀取一行的結束。

檔案物件會保持讀取的檔案位置,也就是說,再呼叫一次 readline ,你將得到第二行,也就是第二個單字。它一次只讀取一行,讀取到換行字符為止。

>>> fin.readline()
'aah\n'

別懷疑,單字 aah 是一個真正存在的單字,如果你覺得換行符號 \n 很多餘且不是想要的,那麼我們可以使用 strip 方法來去除它。

>>> line = fin.readline()
>>> word = line.strip()
>>> word
'aahed'

你也可以將檔案物件當作迴圈的遍歷對象,以下讀取整個檔案的內容,一行印出一個單字:

fin = open('words.txt')
for line in fin:
	word = line.strip()
	print(word)
fin.close()

提醒大家,有使用 open 開檔,記得要使用 close 關檔喔。

9-2 練習

這些練習的解答會在下一節列出,在看解決方案前,希望你已經認真地嘗試過每個練習了。

練習1:

寫一支程式,印出 words.txt 中超過 20 個字母的所有單字。(空白不算)

練習2:

西元 1939 年 Ernest Vincent Wright 發表了一本五萬字的小說,裡面用到的單字都不含 e 這個字母,這是很不容易的,因為含有 e 的單字非常普遍。

例如 are 就有 e 了。這次請你寫一個函數名為 hasNoE, 如果單字不含 e 這個字母,就回傳 True ,否則回傳 False.

然後修改上一題練習的程式,改成只顯示不含 e 字母的單字,並且計算比例。

練習3:

寫一個函數名為 avoids, 接受兩個參數,一個是傳入的單字,另一個是不想顯示的字母串。如果該單字沒有使用到不想顯示的字母就回傳 True ,否則回傳 False.

修改你的程式,讓使用者輸入不想顯示的字母串,能輸出的單字共有多少個?排除字母最少的單字後,能找出5個這樣的組合嗎?

練習4:

寫一個函數名為 usesOnly, 接受兩個參數,一個是傳入的單字,另一個是字母串。如果這個單字只包含字母串中的字母,就回傳 True, 否則回傳 False.

你可以只用含有 acefhlo 這個字母串的單字來造句嗎?

練習5:

寫一個函數名為 usesAll, 接受兩個參數,一個是傳入的單字,另一個是必須的字母串。如果該單字至少使用一次這些合法的字母串,就回傳 True, 否則回傳 False.

你能統計出有多少單字同時含有 aeiou 這些母音字母嗎?如果換成 aeiouy 呢?

練習6:

寫一個函數名為 isAbecedarian, 如果單字的字母是按照字母順序來排列的就回傳 True, 否則回傳 False.(連續兩個相同字母是可以的)。在單字表中,有多少個像這樣的單字?

9-3 搜尋

在上一節中的練習都有一些共通點,就是可以利用之前 8-7 搜尋提到的方式或技巧來解題,明顯的例子如下:

def hasNoE(word):
	for letter in word:
		if letter == 'e':
			return False
	return True

遍歷整個單字,如果含有 e 這個字母就回傳 False, 否則繼續遍歷下一個字母。

直到整個迴圈順利跑完,沒有再發現任何 e 字母,回傳 True.

你可以直接使用 in 運算子來更簡明地確認該字串有沒有 e 這個字母,但我一開始沒這麼做,而是使用迴圈的版本,因為這樣可以更清楚整個搜尋的過程,並且釐清一些基本的概念。

avoids 函數是 hasNoE 函數的更通用版本,不過它們的架構都是相同的:

def avoids(word, forbidden):
	for letter in word:
		if letter in forbidden:
			return False
	return True

當我們發現不合法的字母包含該單字的字母,就回傳 False; 若迴圈可以順利跑完,就回傳 True.

usesOnly 和上面的函數功能類似,只是語法意義上相反而已:

def usesOnly(word, available):
	for letter in word: 
		if letter not in available:
			return False
	return True

我們使用合法的字母取代了不合法的字母,如果發現該單字的字母沒有出現在合法字母當中,就回傳 False.

usesAll 函數是類似的,但單字與字母串的關係是相反的:

def usesAll(word, required):
	for letter in required: 
		if letter not in word:
			return False
	return True

這次遍歷的是合法的字母串,合法的字母串沒有出現在單字中,就回傳 False.

仔細思考一下,你會發現 usesAll 是之前提到可以利用寫好的函數解決程式問題的一個例子,我們可以這樣寫:

def usesAll(word, required):
	return usesOnly(required, word)

這樣的例子稱為「簡化解決已知問題」,利用之前已經開發好的解決方案,套用到現在的專案來解決問題。

身為一個工程師需要有靈活的思考與過人的記憶力,燒腦的事只要經歷一次就好。

9-4 使用索引的迴圈

在上一節中我選擇使用 for 迴圈來完成函數的撰寫,因為只需要字串中的字母,並且不需要利用索引做任何事情。

在 isAbecedarian 的練習中,我們必須比較相鄰的字母關係,這對 for 迴圈來說,有一點難度:

def isAbecedarian(word):
	previous = word[0]
	for c in word:
		if c < previous:
			return False
		previous = c
	return True

另一個方法是使用遞迴:

def isAbecedarian(word):
	if len(word) <= 1:
		return True
	if word[0] > word[1]:
		return False
	return isAbecedarian(word[1:])

當然也可以使用 while 迴圈:

def isAbecedarian(word):
	i = 0
	while i < len(word) - 1:
		if word[i + 1] < word[i]:
			return False
		i = i + 1
	return True

i 從 0 開始帶入 while 迴圈,迴圈終止的條件是 i 小於單字長度減一。從這裡我們可以推測,通常 i 這種當作迴圈判斷條件的變數用於索引,而有判斷的條件就表示迴圈內 i 的值會持續更新,而這個條件跟之前多數的判斷式不同,它只遍歷到整個單字的倒數第二個字為止。

在迴圈中,我們比較 i 的下一個字母,如果小於 i 這個字母的順序,就回傳 False 。否則 i 遞增一後,繼續遍歷單字中的下一個字母。如果順利跑完迴圈,就表示該單字通過測試,字母排序符合由小至大的順序排列。

覺得不容易憑空想像時,可以假設任意一個單字試試看,例如 flossy.它的長度是 6, 最後一圈的 i 值是 4, 它比較了這個字母與它的下一個字母,這樣就比對完了。

當 i 是 5 不小於 5, 迴圈條件為 False ,所以不會進入迴圈,這是我們想要的結果。

我們寫了三個版本,除了練習迴圈的使用外,也想告訴大家,思考問題不要被侷限,什麼方式是你認為最好的,就去使用它。

多思考、多寫程式,經驗越多,就會找到更好的方式,這也是程式要重構的原因。

接下來,這裡有一個雙索引版的 isPalindrome 函數,一個索引從頭開始遞增,另一個則是從尾開始遞減,應該還記得上一章的除錯單元範例吧?這是依照它修改的。

def isPalindrome(word):
	i = 0
	j = len(word)-1
	while i < j:
		if word[i] != word[j]:
			return False
		i = i + 1
		j = j - 1
	return True

或者我們利用「簡化解決已知問題」的技巧,改寫以上的函數:

def isPalindrome(word):
	return isReverse(word, word)

還記得 isReverse 這個函數的定義在哪裡嗎?它在第八章。

9-5 再談輸出

我們已經知道 print() 這個內建函數是用來顯示,也就是輸出內容的。但它還提供很多元化的形式,可以在不同時機或不同喜好下使用。

用逗號 (,) 分隔運算元:
a = 10
b = 20
print(a, '+', b, '=', a + b)
# 10 + 20 = 30

再次提醒,印出的結果中間會空格,是因為逗號後面本身會被空一格。可以試試在逗號後面不空格或空兩格,結果會是一樣的空一格。

用加號 (+) 分隔運算元:
a = '小明'
b = '小華'
print('你好: ' + a + ' 和 ' + b)
# 你好 小明 和 小華

用加號連接字串的方式就不會被自動空格,所以我們必須在字串裡自行空格。

傳統 C 風格的輸出格式:
a = '小明'
b = '小美'
print('%s 是我的朋友, %5s 是他的太太, 他們有 %d 元' % (a, b, 200))
# 小明 是我的朋友,    小美 是他的太太, 他們有 200 元

%5s 是利用五格靠右對齊來顯示,小美算兩格,所以前面也就是左邊還會空三格,通常這是用來對齊的設計。

s 代表字串,d 代表整數,百分比則是特殊符號,也就是格式化字串符號。

python 3 的 format 輸出格式:
a = '小明'
b = '小美'
print('a 是 {} 且 b 是 {}'.format(a, b))
# a 是 小明 且 b 是 小美
print('我的朋友是 {0}, 他的太太是 {1}'.format(a, b))
# 我的朋友是 小明, 他的太太是 小美
print('a 是 {aa} 且 b 是 {bb}'.format(aa = a, bb = b))
# a 是 小明 且 b 是 小美

結果差不多,但寫法有很大的不同。這是我們推薦的用法,也是比較靈活的新方式。

大括號裡面帶數字的可以指定變數置放順序,或者不要帶數字留空,就會直接根據 format 所帶的參數順序。

直接把變數名稱放進去的寫法也很好,看起來更直接清楚,其實還有很多格式上的寫法,像是數字補零或者對齊等等,都有相應的寫法,請看下去。

python 3.6 開始支援的 f-string 輸出格式:
user = '小明'
print(f'我是 {user}')
# 我是 小明

這樣更方便,連 format 都可以省了。

我們可以使用 f-string 來讓顯示的數字更靈活:

a = 116.66
b = 118.99
print(f'a * b 是 ${a * b:,.1f} 美元')
# a * b 是 $13,881.4 美元

.1f 就是只顯示一位小數,還有很多細部的格式化處理,可參考 格式化輸出 補充。

除錯

我們知道測試程式是困難的,但這一章的功能相對容易測試,因為可以直接確認其結果,也就是返回值對不對。不過還有一些隱藏在其中的困難之處,例如無法使用所有單字來測試所有的可能性。

舉一個測試 hasNoE 函數的例子來說,有兩個明顯的狀況,有 e 字母的單字就回傳 False, 而沒有 e 的回傳 True.

這部分應該沒問題,可以很容易地想到。但其中還有些需要測試的狀況沒那麼明顯,有時我們會沒考慮到那麼多,尤其是在一開始的階段。

例如, e 可能在最開頭、中間或結尾。有些單字很短,有些單字很長,還有空字串也需要處理或考慮。

空字串是一個經常容易被忽略的特殊狀況。除了使用所想到的狀況來測試程式外,像本章的案例,可以使用含有一百多萬個單字的 words.txt 來進行大量測試。

通過輸出,或許你能抓到錯誤點,但要小心,錯誤的種類有很多種,像是應該包含的沒有包含,不應該含有的卻出現了。有時候修正一個錯誤,反而又帶來另一個新的錯誤。

最理想的狀況是,只要程式經過修改,整個測試的流程都應該再進行一次,以降低錯誤發生的可能。

一般來說,測試可以讓你找到錯誤點,但要設計出好的測試方案並不容易。即使找到錯誤點,也不能保證程式已經完全正確。

有一句工程師的名言是這樣說的:「測試程式時可以發現錯誤的存在,但你永遠無法保證該程式已經不存在任何錯誤。」

我個人認為檢測或測試的工作是一門大學問,需要相當的耐心,因為永遠無法預料使用者會給出什麼樣的資訊。

程式會一直更新與除錯是好事,代表有人維護,也有人使用。不需要維護及除錯的程式,表示它已經死掉了,或者被取代。

程式跟人一樣不可能完美,但設計時可以設立目標,例如完成什麼樣的功能,或者下載、點閱次數達到多少等等,盡可能靠近理想中的要求。

動動腦

有一種猜字遊戲是這樣玩的:

任選一個單字,告訴你開頭的字母,再提示總共有多少字母。像這樣:

g---

你有兩種猜法,一種是直接猜出這個單字,另一種是猜這個單字中有沒有某個字母。

猜整個單字猜錯時,提示猜錯;猜某個字母猜錯時,輸出保持原狀。

猜字母時,若該字母在該單字中,則將該字母顯示在該單字的正確位置,也就是把 - 換成正確字母。

如果要讓遊戲好玩一點,可以考慮設定最多猜幾次。

以下是猜字謎的過程:

你可以猜 7 次
d-------
你可以直接猜整個字,也可以只猜一個字母: e

你可以猜 7 次
d-----e-
你可以直接猜整個字,也可以只猜一個字母: a

你可以猜 7 次
da----e-
你可以直接猜整個字,也可以只猜一個字母: i

猜錯了,字母 i 並沒有出現在答案當中
你可以猜 6 次
da----e-
你可以直接猜整個字,也可以只猜一個字母: o

猜錯了,字母 o 並沒有出現在答案當中
你可以猜 5 次
da----e-
你可以直接猜整個字,也可以只猜一個字母: r

你可以猜 5 次
da----er
你可以直接猜整個字,也可以只猜一個字母: u

你可以猜 5 次
dau---er
你可以直接猜整個字,也可以只猜一個字母: g

你可以猜 5 次
daug--er
你可以直接猜整個字,也可以只猜一個字母: daughier

猜錯了,答案不是 daughier
你可以猜 4 次
daug--er
你可以直接猜整個字,也可以只猜一個字母: h

你可以猜 4 次
daugh-er
你可以直接猜整個字,也可以只猜一個字母: daughter

恭喜你答對了
答案就是 daughter
感謝你,再見

因為最後只剩下一個字母還沒猜到,所以也可以只輸入一個字母來猜,這邊是直接猜出整個單字。

你可以隨時猜整個單字,或者試看看某個字母有沒有在答案當中。猜中了次數不會遞減。

我們來寫幾個像這樣的猜字謎遊戲會用到的函數,先用固定的單字來當作答案,待日後學習亂數時,再完成這個程式。

字謎的初始化樣式 initQuestionString
# answer = 'python' 字謎答案
def initQuestionString():
	question_string = answer[0] # 第1個字母是已知的
	answer_len = len(answer) # 確認答案長度
	for i in range(1, answer_len): # 除了第1個字母,其他都是未知的
		question_string += '-'
判斷使用者猜字謎的結果 checkGuess
# guess = 'paying' # 使用者猜的字謎
def checkGuess():
	change_question_string = True # 問題字串是否需要調整
	guess_len = len(guess) # 取得所猜字謎的長度
	# 猜整個單字
	if guess_len > 1:
		# 猜對了
		if guess == answer and guess_len == len(answer):
			guessRight() # 顯示猜中的訊息
			showAnswer() # 顯示答案
			gameOver() # 遊戲結束
		# 猜錯了
		else:
			change_question_string = False # 字謎題目不需要調整
			getWrong() # 顯示猜錯的訊息
			processQuestionString(change_question_string) # 處理問題字串
			self.now_times += 1 # 猜的次數加一
	# 只猜一個字母	
	else:
		# 猜對了
		if guess in answer:
			processQuestionString(change_question_string) # 處理問題字串
			# 剛好猜出整個單字
			if '-' not in question_string: 
				guessRight() # 顯示猜中的訊息
				showAnswer() # 顯示答案
				gameOver() # 遊戲結束
		# 猜錯了
		else:
			change_question_string = False # 字謎題目不需要調整
			getWrong() # 顯示猜錯的訊息
			processQuestionString(change_question_string) # 處理問題字串
			self.now_times += 1 # 猜的次數加一

針對需求先規劃好整個遊戲的流程,將重複利用到的功能以函數來替代。

一一撰寫相應的函數,有一個中控函數串起所有的流程,最後測試並修改程式直到完成。

完成之後可以先給身邊的人試看看,因為自己經常會有盲點,或許有什麼沒考慮到的地方,依據回應再修改程式,最後公開發布。

練習

第1題 檔名: 9-1.py

有一個 猜字遊戲,說明如下:

條件是從 words.txt 中找出單字裡有三組兩個連續字母的例子,並印出來,例如:

mmttee: committee
sssspp: Mississippi

上面的例子雖然不是很完美,因為中間都夾雜了其他字母,但事實上會有完全符合條件的單字嗎?

寫一支程式,使用上述的規則在 words.txt 挑出完全符合的單字,中間或前後不能有其他字母,把結果印出來看看。

第2題 檔名: 9-2.py

利用 python 求解二元一次聯立方程式:

2x+6y-12 = 0
8y-4x = -4

提示:使用內建的 numpy 函式庫來求解。

目前只要求寫出來的程式可以解相同格式的聯立就行了,像是:

3x+9y-21 = 0
8y-4x = 12

參考資料

讀寫檔案

python解聯立的三種方法

影片

第九章 案例研究-猜字遊戲

最後更新:2021-10-08 21:03:18

From: 111.249.165.250

By: 特種兵