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. 最后就要借助对构词法的科学计算进行优化了,这种计算须要专业知识的支撑,普通开发者没法胜任。

最后附上完整的源码实现:
源码

相关文章
相关标签/搜索