Node.js 自動猜單詞程序的實現

本文同步自個人 GitHubjavascript

過去,在文曲星等各類電子詞典中,常常會有一個叫作猜單詞的遊戲。給定一個單詞,告訴你這個單詞有幾個字母,而後你去猜。輸入一個字母,若是單詞中包含這個字母,則將單詞中全部的這個字母都顯示出來,若是猜錯,則扣生命值,在生命值扣光以前所有猜對則爲勝利。java

過去我很喜歡玩這個遊戲,由於它能讓背單詞顯得不那麼枯燥乏味,也能提升本身對單詞構詞規律的認識。可是這篇文章要說的,不是怎麼去玩好這個遊戲,而是怎麼藉助程序的力量去自動破解猜單詞的難題。
hangmangit

背景

假設如今存在這樣的一個接口http://hangman.com/game/on,它能夠接受 post 請求,合法的請求共有四種。第一種是開始遊戲,發送這樣的數據能夠從新開始一次新的遊戲:github

javascript{
    "playerId": "classicemi",
    "action": "startGame"
}

服務器會返回以下信息:算法

javascript{
    "message": "THE GAME IS ON",
    "sessionId": "xxxx",
    "data": {
        "numberOfWordsToGuess": 80,
        "numberOfGuessAllowedForEachWord": 10
    }
}

它告訴用戶遊戲已經開始,共有 80 個單詞要猜,每一個單詞有十次猜錯的機會。數據庫

用戶還能夠發送下一個單詞的請求:vim

javascript{
    "sessionId": "xxxx", //這是開始遊戲時服務器返回的sessionId,用於識別用戶
    "action": "nextWord"
}

服務器的返回信息以下:數組

javascript{
    "sessionId": "xxxx",
    "data": {
        "word": "*****",
        "totalWordCount": 1,
        "wrongGuessCountOfCurrentWord": 0
    }
}

從這樣的信息中能夠知道,要猜的單詞由 5 個字母組成,以及如今猜錯了幾回(固然如今是 0 次)。服務器

要進行猜想的話,則發送以下請求:網絡

javascript{
    "sessionId": "xxxx",
    "action": "guessWord",
    "guess": "t" //舉個栗子
}

若是猜想正確,服務器會返回以下數據:

javascript{
    "sessionId": "xxxx",
    "data": {
        "word": "***S*",
        "totalWordCount": 1,
        "wrongGuessCountOfCurrentWord": 0
    }
}

若是猜錯了,則返回以下數據:

javascript{
    "sessionId": "xxxx",
    "data": {
        "word": "*****",
        "totalWordCount": 1,
        "wrongGuessCountOfCurrentWord": 1
    }
}

若是猜錯超過十次還繼續猜,則會返回以下信息:

javascript{
    "message": "No more guess left."
}

這時,只能選擇跳轉至下一個單詞了,即再次發送nextWord請求。當用戶猜完了 80 個詞(固然也能夠是任什麼時候候),用戶能夠選擇提交成績結束遊戲,只要發送以下請求:

javascript{
    "sessionId": "xxxx",
    "action" : "submitResult"
}

服務器返回最終完成的信息:

javascript{
    "message": "GAME OVER",
    "sessionId": "xxxx",
    "data": {
        "playerId": "classicemi",
         "sessionId": "xxxx",
        "totalWordCount": 80,
        "correctWordCount": 77,
        "totalWrongGuessCount": 233,
        "score": 1307,
        "datetime": "2014-10-28 11:45:58"
    }
}

同時,在遊戲過程當中,用戶能夠隨時查看當前已有的成績,發送請求以下:

javascript{
    "sessionId": "xxxx",
    "action" : "getResult"
}

返回信息以下:

javascript{
    "sessionId": "xxxx",
    "data": {
        "totalWordCount": 40,
        "correctWordCount": 20,
        "totalWrongGuessCount": 100,
        "score": 300
    }
}

OK,關於接口已經介紹完了,下面就來玩這個遊戲吧。

思考

首先,因爲咱們要實現一個全自動的程序,不能借助人的力量,也就是說,用戶的單詞量的多少根本派不上用場。若是這個單詞只是一個隨機字符串的話,問題倒也簡單了,隨機猜字母便可。可是如今已經明確是英語單詞,雖然比起隨機字符串,範圍大大縮小,可是要準確去猜英語單詞,隨機猜字母確定是行不通了。

既不能借助用戶的單詞量,又不能使用隨機字母,那麼咱們就須要一個樣本總量足夠大的單詞表做爲咱們的數據庫。在 UNIX 系統中,/usr/share/dict目錄中,有一個words文件,用 vim 打開看一下,發現裏面有 20 多萬個單詞,這就是一個現成的單詞數據庫。不過根據後來的測試結果來看,20多萬的單詞量玩這個遊戲仍是有點不夠,因此,仍是去找開源的單詞列表數據吧,最後我找到一個 65w 單詞量的文件,正確率就比較高了。

流程

有了大量的單詞數據,只是打好了基礎,就像張無忌練了九陽神功,內力充沛,可是沒有招式仍是不行,充其量只是打不死,在這裏咱們須要的招式則是一個科學的算法。

不過在實現算法以前,先來把自動化程序的骨架搭起來,使流程控制可以跑通。我使用的是 Node.js 來執行程序,依賴的模塊有兩個,分別是inquirerrequest。前者用來構建交互式的命令行程序,便於必要時接受用戶的指令;後者用來方便地發送 post 請求。

程序的流程圖以下:

+-------+                                    
                  | start |                                    
                  +---+---+                                    
                      |                                        
                      v                                        
            +---------+-----------+               +-----------+
       +--->+ flow control center | <-------------+ next word |
       |    +---------+-----------+               +-------+---+
       |              |                                   ^    
       |        is the|guess finished?                    |no  
       |              |              is the game finished?|    
get the|result        +--------+yes+----------------------+    
       |              |no                              yes|    
       |              v                                   v    
       |       +------+-------+                      +----+---+
       +-------+ make a guess |                      | submit |
               +--------------+                      +--------+

根據流程圖能夠知道,咱們須要幾個函數來實現這個流程,圖中的一個方塊就對應一個函數,首先是流程的入口,程序最開始也是調用這個方法:

javascriptfunction startGame() {
    inquirer.prompt(
        [{
            type: "input",
            name: "startGame",
            message: "please enter 'y' to automatically play the game, or enter session id to continue: "
        }], function(answers) {
            if (answers.startGame.toLowerCase() != 'y') {
                sessionId = answers.startGame;
                nextWord();
                return;
            }
            setTimeout(function() {
                auto('start');
            }, 0);
        }
    );
}

這裏面有一個 if 語句用來接受用戶直接輸入sessionId的狀況,這是爲了處理一旦網絡中斷或是程序異常的狀況,便於用戶直接輸入sessionId來接着上次的進度繼續執行。能夠看到其中調用了auto方法,這個auto方法則是流程圖中的 flow control center,它會根據傳入的參數來決定下一步去調用哪一個方法(函數中的一些變量的做用後面會做解釋):

javascriptfunction auto(data, letterToGuess) {
    if (data == 'start') {
        options.body = {
            "playerId": playerId,
            "action": "startGame"
        };
        request(options, function(err, res, data) {
            if (!err && res.statusCode == 200) {
                console.log(data)
                console.log('game restarted,your sessionId is: ', data.sessionId);
                sessionId = data.sessionId;
                setTimeout(function() {
                    auto(data);
                }, 0);
            } else {
                console.log(err);
            }
        });
        return;
    }
    // game start
    if (data.message && data.message == 'THE GAME IS ON') {
        sessionId = data.sessionId;
        setTimeout(nextWord, 0);
        return;
    }
    if (data.message && data.message == 'No more word to guess.') {
        setTimeout(getResult, 0);
        return;
    }
    // unfinished situation
    if (data.data.word.indexOf('*') > -1
            && data.data.wrongGuessCountOfCurrentWord < 10
            && data.data.totalWordCount <= 80) {
        setTimeout(function() {
            guess(data.data.word, data.data.wrongGuessCountOfCurrentWord, letterToGuess);
        }, 0);
    } else if (data.data.word.indexOf('*') == -1
            || data.data.wrongGuessCountOfCurrentWord >= 10) { // guess finished
        // 猜詞完畢後,復原輔助變量
        wordsMatchLength = [];
        letterFrequency = {};
        wrongNum = 0;
        lettersGuessed = '';
        setTimeout(nextWord, 0);
    } else if (data.data.totalWordCount >= 80 && data.data.wrongGuessCountOfCurrentWord >= 10) {
        setTimeout(getResult, 0);
    }
}

接下來是實現nextWord功能和guessWord功能的函數:

javascriptfunction nextWord() {
    options.body = {
        "sessionId": sessionId,
        "action": "nextWord"
    };
    request(options, function(err, res, data) {
        if (err) {
            console.log(err);
        } else if(data.message) {
            console.log(data.message);
        } else {
            console.log('current word: ', data.data.word);
            console.log('current word count: ', data.data.totalWordCount);
            console.log('wrong guess: ', data.data.wrongGuessCountOfCurrentWord + ' times');
            index = 0;
        }
        auto(data);
    });
}

function guess(word, wrongNum, letter) {
    var letterToGuess = filter(word, wrongNum, letter);
    options.body = {
        "sessionId": sessionId,
        "action": "guessWord",
        "guess": letterToGuess.toUpperCase()
    };
    request(options, function(err, res, data) {
        if (err) {
            console.log(err);
        } else if(data.message) {
            console.log(message);
        } else {
            console.log('your guess: ', letterToGuess.toUpperCase());
            console.log('current word: ', data.data.word);
            console.log('current word count: ', data.data.totalWordCount);
            console.log('wrong guess: ', data.data.wrongGuessCountOfCurrentWord + ' times');
        }
        setTimeout(function() {
            auto(data, letterToGuess);
        }, 0);
    });
}

最後是獲取成績和提交成績的方法:

javascriptfunction getResult() {
    options.body = {
        "sessionId": sessionId,
        "action": "getResult"
    };
    function submitDicide() {
        inquirer.prompt(
            [{
                type: "input",
                name: "submitDicision",
                message: "enter 'y' to submit your score or enter 'n' to restart: "
            }], function(answers) {
                if (answers.submitDicision.toLowerCase() != 'y' && answers.submitDicision.toLowerCase() != 'n') {
                    console.log('illegal command, please reenter: ');
                    submitDicide();
                    return;
                }
                switch (answers.submitDicision.toLowerCase()) {
                    case 'y':
                        submit();
                        break;
                    case 'n':
                        startGame();
                        break;
                    default:
                        break;
                }
            }
        );
    }
    request(options, function(err, res, data) {
        if (err) {
            console.log(err);
        } else if(data.message) {
            console.log(message);
        } else {
            console.log(data);
            console.log('current word: ', data.data.word);
            console.log('current word count: ', data.data.totalWordCount);
            console.log('wrong guess: ', data.data.wrongGuessCountOfCurrentWord + ' times');
            console.log('current score: ', data.data.score);
            submitDicide();
        }
    });
}

function submit() {
    options.body = {
        "sessionId": sessionId,
        "action": "submitResult"
    };
    request(options, function(err, res, data) {
        if (err) {
            console.log(err);
        } else {
            console.log('player: ', data.data.playerId);
            console.log('session id: ', data.data.sessionId);
            console.log('total word count: ', data.data.totalWordCount);
            console.log('correct word count: ', data.data.correctWordCount);
            console.log('total wrong guess count: ', data.data.totalWrongGuessCount);
            console.log('total score: ', data.data.score);
            console.log('submit time: ', data.data.datetime);
        }
    });
}

因爲整個程序的方法之間會一直相互調用,爲了防止調用棧過深,全部的調用都用setTimeout改爲了異步的方式。

算法

與自動化流程相關的函數都已經準備好了,接下來須要實現的就是算法了。說是算法,其實就是充分利用已有的信息對詞典進行篩選的過程,首先要對現有的詞典文件進行一些預處理的工做,這些工做在執行程序的一開始就會完成:

javascript// 同步方式讀取字典文件
var dict = fs.readFileSync('words.txt', 'utf-8');
// 得到保存全部單詞的數組
var wordArr = dict.split('\r\n');

接下來就是核心函數filter,它位於guess方法中,用來分析數據,返回接下來應該猜哪一個字母,它的工做流程以下:

第一次調用時,根據要猜單詞的長度遍歷數組wordArr,篩選出長度符合條件的單詞並pushwordsMatchLength數組中:

javascriptif (!wordsMatchLength.length) {
    for (var i = 0, len = wordArr.length; i < len; i++) {
        if (wordArr[i].length === word.length) {
            wordsMatchLength.push(wordArr[i]);
        }
    }
}

wordsMatchLength數組進行雙循環遍歷,藉助一個空對象letterFrequency,選出這些單詞中出現頻率最高的字母,並返回。

javascriptfor (var i = 0, len = wordsMatchLength.length; i < len; i++) {
    for (var j = 0, innerLen = wordsMatchLength[i].length; j < innerLen; j++) {
        letterFrequency[wordsMatchLength[i][j].toLowerCase()] == undefined
                ? letterFrequency[wordsMatchLength[i][j].toLowerCase()] = 1
                : letterFrequency[wordsMatchLength[i][j].toLowerCase()]++;
    }
}
for (var key in letterFrequency) {
    if (letterFrequency[key] > frequency && lettersGuessed.indexOf(key) < 0) {
        frequency = letterFrequency[key];
        l = key;
    }
}

這是猜第一個字母的方法,後續的篩選將要依賴以前猜詞的結果來進行,filter方法在遞歸中會被重複調用,以前猜詞的結果會做爲參數傳入。

若是上一次猜對,那麼返回的信息大概會長這樣:

javascriptword: **t**u*

這顯然是一種模式,能夠將它轉化爲正則去篩選候選數組,我又實現了一個將此類字符串轉化爲正則的方法:

javascriptfunction generatePattern(word) {
    var patternStr = '';
    var starNum = 0;
    for (var i = 0, len = word.length; i < len; i++) {
        if (word[i] == '*') {
            starNum = starNum + 1;
        } else {
            patternStr = patternStr + (starNum ? '\\w{' + starNum + '}' : '') + word[i];
            starNum = 0;
        }
    }
    // 修正結尾的星號
    patternStr = patternStr + (starNum ? '\\w{' + starNum + '}' : '');
    return new RegExp(patternStr, 'i');
}

獲得正則後,用這個正則去過濾一下wordsMatchLength數組,刪掉不匹配的單詞:

javascriptfor (var i = 0, len = wordsMatchLength.length; i < len; i++) {
    if (wordsMatchLength[i] && !generatePattern(word).test(wordsMatchLength[i])) {
        wordsMatchLength.splice(i, 1);
        i--;
        len--;
    }
}

若是上一次猜錯了呢,那麼上一次猜了哪一個字母,就說明正確的單詞中不該該包含它,那麼遍歷一下wordsMatchLength數組,凡是包含這個字母的單詞統統幹掉:

javascriptfor (var i = 0, len = wordsMatchLength.length; i < len; i++) {
    if (wordsMatchLength[i] &&
            (wordsMatchLength[i].indexOf(letter.toLowerCase()) > -1 || wordsMatchLength[i].indexOf(letter.toUpperCase()) > -1)) {
        wordsMatchLength.splice(i, 1);
        i--;
        len--;
    }
}

過濾工做完成後,要作的就是再統計一次字母頻率,選擇最常出現的那個便可。

另外,還須要作一些修正工做,來應對所猜單詞過於偏門,沒有出如今單詞庫中的狀況,準備一個備用數組,裏面的單詞順序按照通常狀況下字母的出現頻率排列,一旦單詞庫被過濾完,就去遍歷這個數組,選出頻率最高,而以前尚未猜過的字母並返回。這時候就看運氣了。

同時也要記住在沒猜完一個單詞後要把候選數組清空,紀錄猜錯次數和已猜過字母的變量也要復原,不要影響後面的計算。

優化

以上方法還有一些優化的空間:

  1. 統計字母出現頻率的時候,同一個單詞中的同一個字母,不論出現幾回都只算一次,好比 e 或 s 這樣的字母,在一個單詞中可能出現不少次,可是沒有必要重複計數。
  2. 當候選數組被過濾完時,能夠不用備用數組,切換爲用戶手動輸入,這樣能夠利用用戶英語構詞法的知識進行有目的的猜想,但這種方法偏離了全自動程序的初衷。
  3. 最後就要藉助對構詞法的科學計算進行優化了,這種計算須要專業知識的支撐,普通開發者沒法勝任。

最後附上完整的源碼實現:
源碼

相關文章
相關標籤/搜索