還記得連線猜數字是我在暨大六年來,老俞上 Java 程式設計時一定要教到的東西,在我們那屆甚至是回家作業。如果我沒記錯,老俞在這個例子中,想要強調的是電腦的解題方式和人腦有時會有很大的差別。
雖然那時作業我有寫出來,之後研所當程設助教,好像也有教過學弟妺這題,但有一點我須承認:這個程式對我而言並不簡單。想通之後我可以了解整個解題的邏輯(並不難),但我很難去掌握要如何處理一些細節上的問題,特別是當老俞的標準版本裡甚至出現了四層迴圈時,我就開始頭痛了……
而早上嚐試了用 Scala 以及 Functional Programming 的方式寫比對幾 A 幾 B 後,我就開始想,如果用 Function Programming 的方式來解這題,是不是會比較漂亮,比較簡單呢?!
所以,這篇文章就是試著實作這個作業,不過為了一些原因,我並沒有把他做成連線版的,而是單機跑的 Multi-Thread 版本,一個 Thread 當 Server,一個 Thread 當 Client。
以下是這個作業(?)的規格:
- 一個 Server Thread 來模擬猜數字的伺服器
- 一個 Client Thread 來模擬猜數字的客戶端
- 客戶端要嚐試在最少次數內猜中 Server 端上的數字
- 客戶端可以丟一個類似 Guess("3215") 的訊息給 Server 端
- Server 端則回傳 Status("3215", 2, 1) // 3215 的結果為 2A1B 給 Client 端
- 伺服端和客戶端均在猜中正確答案時中止
在這個範例裡,我們會用 Scala 的 Actor 來模擬伺服器的行為,基本上 Actor 就類似伺服器--監聽訊息並做出反應。
首先,我們定義一些訊息物件,你可以把他想像成是網路上的封包,並且 import 一些之後需要用到的東西。
import scala.actors.Actor import scala.actors.Actor._ import scala.util.Random /** * 猜數字伺服器回傳的結果 * * @param x 使用者輸入的答案 * @param a 猜數字遊戲裡的A(數字對,位置也對) * @param b 猜數字遊戲裡的B(數字對,但位置錯) */ case class Status(x: String, a: Int, b: Int) /** * 要送給猜數字伺器的猜測 * * @param x 使用者猜的數字 */ case class Guess(x: String) /** * 用這個訊息告訴猜數字 AI 開始猜數字 */ case object StartGuess
接著,定義以下兩個猜數字的輔助函式:
- 產生猜數字裡所有的可能解
- 比對兩個數字是幾 A 幾 B
/**
* 猜數字的輔助函式
*/
trait GuessNumberFunctions
{
// 產生猜數字遊戲裡所有可能答案的組合,共 5040 種
//
// 產生方式:
//
// 1. 先產生 0 到 9999 所有數字
// 2. 將這些數字補零至四位數並轉成字串
// 3. 過濾掉所有出現重覆數字的字串
//
def generateAllPossible = {
// 重覆的定義:
//
// 將字串轉成字元集合後,如果大小不為四,就是有重覆的字元
//
// 例: "1234" -> Set(1, 2, 3, 4) -> 不重覆 (return false)
// "1224" -> Set(1, 2, 4) -> 有重覆 (return true)
def hasRepeatNumber (x: String) = x.toSet.size != 4
def convertToPaddingString(x: Int) = "%04d" format (x)
(0 to 9999). // 先產生 0 到 9999 的所有整數
map(convertToPaddingString). // 將這些整數全轉成補零成四位數的字串
filterNot(hasRepeatNumber) // 濾出所有不含重覆數字的部份
}
/**
* 比對兩個猜數字字串後,回覆幾 A 幾 B
*
* 詳細的說明請參考[前一篇文章][1]的講解。
*
* [1]: http://brianhsu.moe/blog/archives/1787
*
* @param number1 第一個數字
* @param number2 第二個數字
*/
def compare(number1: String, number2: String) = {
def pairIsSame(x: (Char, Char)) = x._1 == x._2
val sumA = (number1 zip number2) filter (pairIsSame)
val (remainNumber1, remainNumber2) =
(number1 zip number2).
filterNot (sumA contains _).
unzip
val sumB = remainNumber1 intersect remainNumber2
(sumA.length, sumB.length)
}
}
伺服器的部份很簡單:不斷監聽封包,如果傳進來的是猜測就進行比對並回傳結果,不然就略過該訊息。
/**
* 猜數字伺服器
*
* 這裡使用 Scala 的 Actor 來模擬老俞的作業裡的猜數字伺服器,
* 而會使用這個方法的原因有兩個:
*
* 1. 練習 Scala 的 Actor 的概念和寫法
* 2. 用這種方式實作比較簡單,不用處理 Socket
*
* 這個伺服器啟動後會做下面的事情:
*
* 1. 亂數產生一個猜數字正確解答
* 2. 持續監聽別人丟過來的訊息
* - 當這個訊息是一個猜數字的猜測訊息時(Guess)物件,
* 伺服器就會將其與正確答案比對,並回傳給猜數字客戶端
*
* - 如果是其他訊息就略過並印出
*
* 這個伺服器會在以下情況結束:
*
* - 客戶端猜中了正確數字
*
*/
class GuessNumberServer extends Actor with GuessNumberFunctions
{
// 亂數選擇正確答案
val answer =
Random.shuffle((0 to 9).toList) // 產生一個 List(0, 1, 2..., 9) 然後打亂之後
.take(4) // 取前四個數字,例:List(3,4,1,5)
.mkString // 再結合成字串,例:"3415"
// 印出正確答案
println ("[debug] answer:" + answer)
// 持續 (loop) 地監聽事件 (react)
def act () = loop {
react {
// 當進來的是一個猜數字的訊息(可以把他當做是封包之類的)
case Guess(x) =>
// 將傳進來的猜測與正確答比對
val (statusA, statusB) = compare (answer, x)
// 印出比對後是『幾 A 幾 B』
println ("%s => %dA%dB" format(x, statusA, statusB))
// 回傳給對方此次猜測的結果
// x => 這次他猜的數字
// statusA => 數字對,位置也對的數量
// statusB => 數字對,位置錯的數量
sender ! Status(x, statusA, statusB)
// 當 4A 時遊戲結束
if (statusA == 4) Actor.exit()
// 收到其他我不懂的訊息
case otherwise =>
println ("Server 收到不明訊息:" + otherwise)
}
}
}
接下來是重頭戲的部份,猜數字的 AI,其實概念很簡單:
- 先把所有可能的解列出
- 隨便挑一個來猜
- 如果對方告訴你答案是 2A1B
- 那把你的解答列表中,和你的答案相比是 2A1B 的東西全部找出來,正解一定在裡頭!而且此時可能解的範圍已經縮小了
- 從你在上一步中產生的新的可能解範圍裡,再挑一個出來猜
- 重覆以上的動作,直到猜中 4A 為止
/**
* 猜數字 AI
*
* 猜數字的 AI 其實很簡單,但套句老俞的說法:『不要用人腦的思維來想這
* 個問題!電腦和人腦的優點是不一樣的!』
*
* 記得:
* 1. 電腦的處理速度很快
* 2. 電腦的記憶量非常大
*
* 所以從電腦的角度來想,你可以用消去法來解猜數字的問題:
*
* 1. 把所有可能的解列出(一開始會有 5040 個)
* 2. 當你猜一個數字,對方告訴你是 2A0B 的時候
* 3. 把你所有可能解裡,和你猜的數字比對為 2A0B 的部份列出(假設此時已縮減到 1000 個可能解)
* 4. 因為正解和你的解比對是 2A0B,所以正解一定在這 1000 個可能解裡
* 5. 從這 1000 個可能解中,隨便找一個猜
* 6. 4A 的話結束,不然的話依照 2 ~ 5 的方式繼續進行
*
* 這個 AI Client 做的事情也一模一樣,他可以處理以下的訊息:
*
* - StartGame
* 開始向 Server 丟一個數字
*
* - Status(myGuess, 4, 0)
* 4A,所有數字和位置都對,遊戲結束
*
* - Status(myGuess, a, b)
* 過濾出可能解裡,和 myGuess 比對後為 aAbB 的解答之後繼續猜
*
* @param server 要連線到哪個 Server 猜數字
*/
class GuessNumberClient(server: GuessNumberServer) extends Actor with GuessNumberFunctions
{
// 目前的可能解,一開始是全部的數字
var possibleAnswer = generateAllPossible
// 檢測 number1 和 number2 的結果是不是 nAnB
def isSameStatus (number1: String, number2: String, nA: Int, nB: Int) = {
compare(number1, number2) == (nA, nB)
}
// 持續 (loop) 地監聽 (react) 事件
def act () = loop {
react {
// 如果開始玩猜數字遊戲
case StartGuess =>
// 從第一個數字猜
println ("開始猜數字")
server ! Guess(possibleAnswer.head)
// 如果伺服器告訴我們猜中了,就印出答案並結束
case Status(myGuess, 4, 0) =>
println ("答案是:" + answer)
Actor.exit()
// 如果沒中的話,利用消去法,濾出剩下的所有組合理,
// 同樣也是 nAnB 的部份
case Status(myGuess, a, b) =>
// 濾出剩下的可能答案
possibleAnswer = possibleAnswer.filter(isSameStatus(_, myGuess, a, b))
println ("現在的可能答案數:" + possibleAnswer.length)
// 拿剩下的答案中的第一個繼續猜
server ! Guess(possibleAnswer.head)
// 如果收到不明訊息
case otherwise =>
println ("Client 收到不明訊息:" + otherwise)
}
}
}
最後是主程式的部份,很單純的就是建立 Client / Server 物件,然後開始猜數字。
object Main
{
def main (args: Array[String]) {
val server = new GuessNumberServer
val client = new GuessNumberClient(server)
server.start() // 啟動 Server
client.start() // 啟動 AI Client
client ! StartGuess // 開始玩猜數字!
}
}
最後附上執行結果,從執行結果可以看出來,用這種方式猜測,大概 5 ~ 6 次之內就可以猜出正確的解答。
brianhsu@NBGentoo ~/GuessNumber $ scala Main [debug] answer:0951 開始猜數字 0123 => 1A1B 現在的可能答案數:720 0245 => 1A1B 現在的可能答案數:110 0356 => 2A0B 現在的可能答案數:7 0374 => 1A0B 現在的可能答案數:2 0851 => 3A0B 現在的可能答案數:1 0951 => 4A0B 答案是:0951
brianhsu@NBGentoo ~/GuessNumber $ scala Main [debug] answer:4183 開始猜數字 0123 => 2A0B 現在的可能答案數:180 0145 => 1A1B 現在的可能答案數:48 0426 => 0A1B 現在的可能答案數:5 4173 => 3A0B 現在的可能答案數:2 4183 => 4A0B 答案是:4183
以上,是不是覺得 Scala 和 Functional Programming 很神奇有趣呢?快點一起來玩 Scala 吧~:p
註一:如果你算一下,你會發現註解比程式碼還長,可見 Scala 在短短的幾行裡幫你做了多少事。XDD
註二:老俞的版本在這邊,和這個版本最大的差別在如何處理幾 A 幾 B,以及他是網路程式。
註三:不知道你有沒有發現,這隻程式裡除了監聽事件的 loop 外沒有迴圈。^_^

回響