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

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

特種兵

特種兵圖像(預設)

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-6 搜尋提到的方式或技巧來解題,明顯的例子如下:

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)
用加號 (+) 分隔運算元:
a = '小明'
b = '小華'
print('你好: ' + a + ' 和 ' + b)
傳統 C 風格的輸出格式:
a = '小明'
b = '小美'
print('%s 是我的朋友, %5s 是他的太太, 他們有 %d 元' % (a, b, 200))
python 3 的 format 輸出格式:
a = '小明'
b = '小美'
print('a 是 {} 且 b 是 {}'.format(a, b))
print('我的朋友是 {0}, 他的太太是 {1}'.format(a, b))
print('a 是 {aa} 且 b 是 {bb}'.format(aa = a, bb = b))
python 3.6 開始支援的 f-string 輸出格式:
user = '小明'
print(f'我是 {user}')

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

a = 116.66
b = 118.99
print(f'a * b 是 ${a * b:,.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
def initQuestionString():
	question_string = answer[0] # 第1個字母是已知的
	answer_len = len(answer) # 確認答案長度
	for i in range(1, answer_len): # 除了第1個字母其他都是未知的
		question_string += '-'
判斷使用者猜字迷的結果 checkGuess
guess = '' # 使用者猜的字迷
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 # 猜的次數加一
處理問題字串 processQuestionString
def processQuestionString(flag):
	if flag:
		answer_len = len(answer)
		for i in range(1, answer_len):
			if answer[i] == guess:
				question_string = question_string[:i] + guess + question_string[i+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-01-05 22:55:13

From: 1.161.140.230

By: 特種兵