前几天终于成功的解决了服务器连不上codex的问题(什么),于是就有了这个网站:**Guess The Function / 猜这个函数**。
玩法很直接,页面上先给一条目标函数的曲线,玩家往输入框里猜。系统会把你的猜测和真正的目标函数做比较,算出一个匹配度分数;如果分数足够高,就算你猜中了。如果还差一点,也没关系,页面下面会留下你的猜测记录和分数变化,方便你一点点往正确答案逼近。目前使用的是信噪比(SNR):

这个网站现在有两个模式。练习模式适合随手玩,想换题就换题;每日挑战则会在每天中午切一题,大家面对的是同一个函数,猜出来之后还能上榜。

## 函数是怎么生成出来的
最开始做这个网站时,一个很自然的思路是手写几十个表达式放进题库里,随机抽一道就行。但这样很快会碰到两个问题:一是题目数量有限,玩几轮就熟了;二是函数的风格会越来越像出题人自己,变化不够大。
所以后来把它改成了“表达式树生成器”。简单说,系统不是先想好字符串,而是先递归地长出一棵语法树,最后再把这棵树重新拼成可读的表达式。
代码里先定了一个生成深度范围:
```js
const MIN_GENERATION_DEPTH = 4;
const MAX_GENERATION_DEPTH = 5;
function generationShouldStop(depth) {
if (depth < MIN_GENERATION_DEPTH) {
return false;
}
if (depth >= MAX_GENERATION_DEPTH) {
return true;
}
return randomSource() < Math.min(0.22 + depth * 0.1, 0.82);
}
```
如果当前这一层决定不再往下展开,系统就会生成一个“叶子节点”。叶子节点不是只有 `x`,而是会从几个基础形态里挑一个出来,比如 `x`、`-x`、`3*x`、`x/2`、`x+4` 这种:
```js
function buildLeafNode() {
const coefficient = randomCoefficient(5, 2);
const constant = randomCoefficient(5);
return randomChoice([
makeVariableNode(),
makeUnaryNode("-", makeVariableNode()),
makeNumberNode(constant),
makeBinaryNode("*", makeNumberNode(coefficient), makeVariableNode()),
makeBinaryNode("/", makeVariableNode(), makeNumberNode(coefficient)),
makeBinaryNode("+", makeVariableNode(), makeNumberNode(constant)),
makeBinaryNode("-", makeVariableNode(), makeNumberNode(constant)),
]);
}
```
这里系数和常数都限制在不超过 5 的整数范围里。这样生成出来的函数虽然是随机的,但还保留了“人能猜”的手感,不会动不动冒出一个特别离谱的系数。
再往上一层,系统会决定当前节点到底长成什么样。它有时候会长成一个函数节点,比如 `sin(...)`、`cos(...)`、`exp(...)`;有时候会长成一个二元运算,比如加减乘除;还有时候会做一点偏移或者缩放:
```js
function buildExpressionNode(depth) {
if (generationShouldStop(depth)) {
return buildLeafNode();
}
const shape = randomChoice([
"func",
"func",
"binary",
"binary",
"binary",
"scaled-func",
"offset",
]);
if (shape === "func") {
return makeFunctionNode(
randomChoice(["sin", "cos", "exp"]),
buildFunctionArgumentNode(depth + 1)
);
}
if (shape === "scaled-func") {
return makeBinaryNode(
"*",
makeNumberNode(randomCoefficient(5, 2)),
makeFunctionNode(randomChoice(["sin", "cos", "exp"]), buildFunctionArgumentNode(depth + 1))
);
}
if (shape === "offset") {
return makeBinaryNode(
randomChoice(["+", "-"]),
buildExpressionNode(depth + 1),
makeNumberNode(randomCoefficient(5))
);
}
return makeBinaryNode(
randomChoice(["+", "-", "*", "/"]),
buildExpressionNode(depth + 1),
buildExpressionNode(depth + 1)
);
}
```
这个网站现在对生成结果做了几层筛选。首先,它必须真的依赖 `x`,不能生成一个纯常数;其次,它至少要出现两个函数节点,也就是 `sin`、`cos`、`exp` 总次数不能太少。再往下,还有几条比较关键的限制:不允许函数互相嵌套,比如 `sin(cos(x))`;不允许函数参数退化成纯常数,比如 `sin(3)`;也不允许在分母里出现 `sin`、`cos`、`exp`。这些限制看起来有点“严格”,但它们的作用其实很直接,就是尽量避免题目长成一种虽然随机、却并不好猜的样子。
对应的校验大致是这样:
```js
function isUsableGeneratedExpression(expression) {
if (!expression.includes("x")) {
return false;
}
if (!/(sin|cos|exp)/.test(expression)) {
return false;
}
let ast;
try {
ast = parseExpression(expression);
} catch (error) {
return false;
}
ast = simplifyAst(ast);
const depth = nodeDepth(ast);
if (depth < MIN_GENERATION_DEPTH || depth > MAX_GENERATION_DEPTH) {
return false;
}
if (
countFunctions(ast) < 2 ||
hasNestedFunction(ast) ||
hasConstantOnlyFunctionArgument(ast) ||
hasFunctionInDenominator(ast)
) {
return false;
}
return true;
}
```
注意这里不是“生成时严格不准犯错”,而是“先生成,再淘汰”。这是一个很实用的思路。因为如果把所有规则都塞进生成阶段,代码会变得越来越拧巴;而先大胆生成,再统一过滤,整体会清楚很多。
另外,代码里还做了一步表达式化简。比如连续常数加减会被合并,`1*...`、`.../1` 这种写法会被消掉,双重负号也会被整理掉。这样最终展示给玩家的表达式更自然。
```js
function simplifyAst(node) {
if (node.type === "number" || node.type === "variable") {
return node;
}
if (node.type === "unary") {
const argument = simplifyAst(node.argument);
if (node.operator === "+") {
return argument;
}
if (isNumberNode(argument)) {
return makeNumberNode(-argument.value);
}
if (argument.type === "unary" && argument.operator === "-") {
return argument.argument;
}
return makeUnaryNode(node.operator, argument);
}
// 下面处理常数折叠、1*x、x/1、连续加减项合并等情况
}
```
最后,系统会反复尝试生成候选表达式,直到拿到一个通过校验的结果;如果连续很多次都不理想,就回退到一个保底函数:
```js
for (let attempt = 0; attempt < 500; attempt += 1) {
const expression = buildRandomCandidate();
if (isUsableGeneratedExpression(expression)) {
return expression;
}
}
return "sin(x)+cos(x)";
```
这段代码很朴素,但很重要。它让整个系统既有随机性,也有兜底,不会因为某一轮运气太差就直接出一题坏题。

评论
留下评论
邮箱仅用于识别,不会公开
$...$行内 ·$$...$$独立公式