GitHub地址:https://github.com/acromema/CPGenerator
本项目为结对项目,两人各写一部分各自做的工作记录。传送门:马同学的博客
此程序用C#完成
讨论阶段:
第一阶段:
分析阶段
概要设计
详细设计
第二阶段
需求分析
设计阶段
第三阶段
需求分析
设计阶段
测试阶段
总结
经过两人探讨,估计的时间如下:
经过两人讨论研究,决定选四则运算题目生成该题目。由于真分数处理和运算与整数的有些不同,所以决定一人写一个类,由我负责写整数运算类,马同学写真分数运算类,最后再由我整合,写UI界面。
第一阶段需求可以概括为:
所以,需要有出题功能和算题功能,两个主要功能。
首先,我们要有一个保存题目的字段,统一命名为Problem,以及保存结果的字段。
public string Problem { get; set; }
public string Result { get; set;}
而且要有一个生成算式的方法和一个生成解的方法。生成算式需要外部直接调用,生成算式的同时可以直接生成解存到Result中,所以一个是public一个是private。
public void GenerateProblem()
private void CalculateValue()
同时为了保证生成的题不重复,所以采用随机的生成方法,由于整数除法要保证必须除尽,所以我们采用先随机生成运算符个数,然后随机生成运算符,最后根据运算符再生成数字,整体构成一个算式。
private void GetOperaNum()
private void GetOperaList()
private void GetNumList()
首先要生成运算符的数目,直接随机1-10之间的整数即可。
private int operaNum;
private void GetOperaNum()
{
operaNum = rand.Next(10) + 1;
}
接着根据这个数字,来生成运算符,为了处理方便,约定不会出现连续的除号,为了让数字不会太大,约定乘号最多出现两次。其中Mod为选择模式,默认为0,即不带乘方,为后续扩展做准备。
private string[] operaList = new string[10];
opera[0] = new string[4] { "+", "-", "*", "/" };
public int Mod { get; set; } = 0;
private void GetOperaList()
{
//GetOperaNum();
int multNum = 0;
for (int i = 0; i < operaNum; i++)
{
string temp = opera[Mod][rand.Next(opera[Mod].Length)];
if (multNum == 2 && temp == "*")
{
i--;
continue;
}
if (temp == "*")
{
multNum++;
}
if (i > 0 && operaList[i - 1] == "/" && temp == "/")
{
i--;
continue;
}
operaList[i] = temp;
}
}
根据不同符号生成不同范围内的随机数,其中遇到除号,先生成除数,然后根据除数按一定范围的随机倍数生成被除数,无论之前被除数的位置生成了什么,都会被已生成的被除数覆盖,从而保证整除。其余符号如果不是第一个,则只生成符号后面的数,否则生成第一个数和符号后面的数。random的数字范围决定了算数的难度(决定因素之一)。
private int[] numList = new int[11];
private void GetNumList()
{
//GetOperaList();
int tempNum;
for (int i = 0; i < operaNum; i++)
{
if (operaList[i] == "/")
{
tempNum = rand.Next(1, 31);
numList[i + 1] = tempNum;
if (tempNum >= 25)
{
numList[i] = rand.Next(5) * tempNum;
}
else if (tempNum >= 15)
{
numList[i] = rand.Next(7) * tempNum;
}
else if (tempNum >= 10)
{
numList[i] = rand.Next(9) * tempNum;
}
else
{
numList[i] = rand.Next(11) * tempNum;
}
}
else if (operaList[i] == "*")
{
if (i == 0)
{
numList[i] = rand.Next(31);
numList[i + 1] = rand.Next(31);
}
else
{
if (operaList[i - 1] == "*")
{
numList[i + 1] = rand.Next(10);
}
else
{
numList[i + 1] = rand.Next(31);
}
}
while (numList[i] * numList[i + 1] > 100)
{
numList[i + 1] /= 2;
}
}
else if (operaList[i] == "+" || operaList[i] == "-")
{
if (i == 0)
{
numList[i] = rand.Next(31);
numList[i + 1] = rand.Next(31);
}
else
{
numList[i + 1] = rand.Next(31);
}
}
}
}
然后将生成的运算符和数字组合成一个表达式,并计算结果。
public void GenerateProblem()
{
GetOperaNum();
GetOperaList();
GetNumList();
Problem = numList[0].ToString() + " ";
for (int i = 0; i < operaNum; i++)
{
Problem += operaList[i] + " " + numList[i + 1].ToString() + " ";
}
CalculateValue();
}
计算表达式值采用现将表达式转化成逆波兰表达式,然后再用栈辅助求值。此处不详细叙述,其中用到了栈和队列。
private void TransRpn()
{
string[] symbols = Problem.Split(' ');
for (int i = 0; i < symbols.Length; i++)
{
if (IsInt(symbols[i]))
{
valueQueue.Enqueue(symbols[i]);
}
else if (IsOperator(symbols[i]))
{
while (operaStack.Count != 0&&GetOptionLevel(operaStack.Peek())>=GetOptionLevel(symbols[i]))
{
valueQueue.Enqueue(operaStack.Pop());
}
operaStack.Push(symbols[i]);
}
else if(symbols[i]=="(")
{
operaStack.Push(symbols[i]);
}
else if(symbols[i] == ")")
{
while (operaStack.Count!=0&&operaStack.Peek()!="(")
{
valueQueue.Enqueue(operaStack.Pop());
}
operaStack.Pop();
}
}
while (operaStack.Count != 0)
{
valueQueue.Enqueue(operaStack.Pop());
}
}
private void CalculateValue()
{
TransRpn();
Stack numStack = new Stack();
while(valueQueue.Count!=0)
{
if(IsInt(valueQueue.Peek()))
{
numStack.Push(int.Parse(valueQueue.Dequeue()));
}
else
{
string operation = valueQueue.Dequeue();
numStack.Push(GetOptionValue(operation, numStack.Pop(), numStack.Pop()));
}
}
Result = numStack.Pop().ToString();
}
private int GetOptionLevel(string operation)
{
int result = 0;
switch (operation)
{
case "+":
result = 1;
break;
case "-":
result = 1;
break;
case "*":
result = 2;
break;
case "/":
result = 2;
break;
}
return result;
}
private int GetOptionValue(string operation, int b, int a)
{
int result = 0;
switch (operation)
{
case "+":
result = a + b;
break;
case "-":
result = a - b;
break;
case "*":
result = a * b;
break;
case "/":
result = a / b;
break;
}
return result;
}
private bool IsOperator(string str)
{
if (str == "+" || str == "-" || str == "*" || str == "/")
{
return true;
}
else
{
return false;
}
}
private bool IsInt(string value)
{
return Regex.IsMatch(value, @"^\d+$");
}
这样一个四则运算出题器的原型就做好了。
再加一个输入输出,假设只有五道题。
static void Main(string[] args)
{
IntGen problem = new IntGen
{
Mod = 0
};
Console.WriteLine("欢迎进入四则运算出题系统");
Console.WriteLine("请选择所要进行的操作:(输入相应数字选择)");
Console.WriteLine("1.输出试题1000道,2.进入答题系统");
Console.Write("我选择:");
string str = Console.ReadLine();
if(str == "1")
{
string fileName = "小学生难题1000道.txt";
string newPath = AppDomain.CurrentDomain.BaseDirectory + fileName;
StreamWriter streamWriter = new StreamWriter(newPath, false, Encoding.Default);
for (int i = 0; i < 1000; i++)
{
problem.GenerateProblem();
streamWriter.WriteLine(problem.Problem);
//Console.WriteLine(problem.Result);
}
Console.WriteLine("导出成功!");
}
else if (str == "2")
{
int right = 0;
Console.WriteLine("一共五道题,每题20分");
System.Diagnostics.Stopwatch sw = new System.Diagnostics.Stopwatch();
sw.Start();
for (int i = 0; i < 5; i++)
{
problem.GenerateProblem();
Console.WriteLine(problem.Problem);
Console.Write("你的答案是:");
string ans = Console.ReadLine();
if(ans == problem.Result)
{
right++;
Console.WriteLine("你答对了!");
}
else
{
Console.WriteLine("你答错了!");
}
}
sw.Stop();
TimeSpan ts2 = sw.Elapsed;
Console.WriteLine("答题结束,你答对了{0}题,总分{1}/100", right, right * 20);
Console.WriteLine("花费{0}s", ts2.TotalSeconds);
}
else
{
Console.WriteLine("你个撒子,你输入有误!请进入系统重新来过");
}
Console.ReadKey();
}
运行画面如下:
注:若选1,生成的文件与运行程序在同文件夹。
第一阶段结束。
第二阶段主要是增加了乘方运算,并且有两种表示方式,可以通过设置来选择。
增加乘方运算,意味着运算符集合增加一个,而且要有两种乘方运算,所以最开始设计的运算符集合要增加到三个,即:
private readonly string[][] opera = new string[3][];
opera[0] = new string[4] { "+", "-", "*", "/" };
opera[1] = new string[5] { "+", "-", "*", "/" ,"**"};
opera[2] = new string[5] { "+", "-", "*", "/" ,"^"};
所以之前的Mod属性可以派上用场了,Mod取0就是不加乘方运算,取1即用**作为乘方符号,取2即用^作为乘方符号。
函数部分,涉及到运算符的地方都要增加相应的处理。考虑到运算难度,其中约定,一个运算式中最多只出现一次乘方,并且出现乘方符号之后不出现乘除号。故GetOperaList()改为:
private void GetOperaList()
{
//GetOperaNum();
int multNum = 0;
int powNum = 0;
for (int i = 0; i < operaNum; i++)
{
string temp = opera[Mod][rand.Next(opera[Mod].Length)];
if(powNum == 1 && (temp == "**" || temp == "^"))
{
i--;
continue;
}
if(temp == "**" || temp == "^")
{
powNum++;
}
if (multNum == 2 && temp == "*")
{
i--;
continue;
}
if (temp == "*")
{
multNum++;
}
if (i > 0 && operaList[i - 1] == "/" && temp == "/")
{
i--;
continue;
}
operaList[i] = temp;
}
if(powNum ==1)
{
for (int i = 0; i < operaNum; i++)
{
if(operaList[i] == "*")
{
operaList[i] = "+";
}
if (operaList[i] == "/")
{
operaList[i] = "-";
}
}
}
}
考虑到乘方算起来很复杂,所以约定乘方的底数不超过25,大于5的底数的指数不超过2,小于5的底数的指数不超过5,0的指数不为0,1的指数不超过100。
else if(operaList[i] == "**"||operaList[i]=="^")
{
numList[i] = rand.Next(21);
if(numList[i]==0)
{
numList[i+1] = rand.Next(1,101);
}
else if (numList[i]==1)
{
numList[i + 1] = rand.Next(101);
}
else if(numList[i]<=5)
{
numList[i + 1] = rand.Next(6);
}
else
{
numList[i + 1] = rand.Next(3);
}
}
同时增加乘方的优先级(设为3),运算中加入乘方运算,比较简单故不贴代码。
在主函数界面增加乘方选项:
Console.WriteLine("欢迎进入四则运算出题系统");
Console.WriteLine("请选择是否带乘方及乘方的表示形式:");
Console.WriteLine("0.不带乘方,1.乘方用**表示,2.乘方用^表示");
Console.Write("我选择:");
string str = Console.ReadLine();
if(str == "0")
{
problem.Mod = 0;
}
else if(str == "1")
{
problem.Mod = 1;
}
else if(str == "2")
{
problem.Mod = 2;
}
else
{
Console.WriteLine("模式选择有误,默认不带乘方。");
}
运行结果如下:
第二阶段结束。
根据讨论,第三阶段我们选择了第一项:“把程序变成一个Windows电脑图形界面的程序,同时增加“倒计时”功能,每个题目必须在20秒钟完成,如果完不成,则得0分并进入下一题。增加“历史记录”功能,把用户做题的成绩记录下来并可以展现历史记录。 ”
即:
由于浏览历史记录功能说的比较宽泛,而且个人认为如果只是查看成绩记录的话有些无用,所以此处的查看历史记录功能的意思是能浏览本次答过的所有题的历史记录(含正确答案)。
UI界面状态图如下:
依据上述状态图设计界面部件,搭建界面和各个部件,基本步骤比较机械,所以不详述,此处说明几点细节。因为真分数不涉及乘方运算,所以无法选择 真分数运算和乘方运算,当出现这一组合时,应将乘方运算的选项强制变成不带乘方。
限定语句为:
private void Cmb_SelectMod_SelectedIndexChanged(object sender, EventArgs e)
{
if (this.cmb_Erabu.SelectedIndex == 1)
{
if (this.cmb_SelectMod.SelectedIndex == 1 || this.cmb_SelectMod.SelectedIndex == 2)
{
this.cmb_SelectMod.SelectedIndex = this.cmb_SelectMod.Items.IndexOf("不带乘方");
}
problem1.Mod = 0;
}
else
{
if (this.cmb_SelectMod.SelectedIndex == 0)
{
problem1.Mod = 0;
}
else if (this.cmb_SelectMod.SelectedIndex == 1)
{
problem1.Mod = 1;
}
else
{
problem1.Mod = 2;
}
}
}
private void Cmb_Erabu_SelectedIndexChanged(object sender, EventArgs e)
{
if (this.cmb_Erabu.SelectedIndex == 1)
{
this.cmb_SelectMod.SelectedIndex = this.cmb_SelectMod.Items.IndexOf("不带乘方");
}
}
答题系统UI需要注意的逻辑是只有点完开始键才能点确认键,点完开始键后不能再次点击开始键,文本框内只能输入数字、减号(负号)和除号(分数线),最长只能输入十个字符(无线输入无意义),点完确认才可提交答案,或者等到20s结束后自动提交答案,因为逻辑较简单,故不在此粘贴代码。
第三阶段结束。
因为我们两人一人写一个类,所以我们两人各自建立了一个控制台程序,用其中的主函数来测试我们各自写的模块。因为生成算式采用的是随机生成,所以只能生成后直观检验生成的算式是否合法,经检验生成的算式完全合法(不会出现连续运算符,**除外,其可表示乘方),计算算式结果的函数我们只需要准备一些合法的算式进去跑结果就可以了(因为已经保证随机生成的算式一定合法),因为要求有用户输入并且要判分的功能,所以这两个测试可以放到一起测试(毕竟自己准备用例的预期结果也是需要人为算的),即系统自动随机出题,自己来输入答案,看看与函数输出结果是否相同即可。
UI部分的测试,在运行过程中想到用户可能的一切操作,在设计阶段的这些限定“答题系统UI需要注意的逻辑是只有点完开始键才能点确认键,点完开始键后不能再次点击开始键,文本框内只能输入数字、减号(负号)和除号(分数线),最长只能输入十个字符(无线输入无意义),点完确认才可提交答案,或者等到20s结束后自动提交答案”都是在测试之后经过深思熟虑加上的限定功能。
消耗最多的函数(不算系统内置的消息弹窗函数)为导出功能的函数,主要是读写耗时间,无法改进。
private void Btn_Out_Click(object sender, EventArgs e)
{
string fileName = "小学生难题1000道.txt";
string newPath = AppDomain.CurrentDomain.BaseDirectory + fileName;
StreamWriter streamWriter = new StreamWriter(newPath, false, Encoding.Default);
if (this.cmb_Erabu.SelectedIndex==0)
{
for (int i = 0; i < 1000; i++)
{
problem1.GenerateProblem();
streamWriter.WriteLine(problem1.Problem);
//streamWriter.WriteLine(i);
}
}
else if(this.cmb_Erabu.SelectedIndex == 1)
{
Problem problem = new Problem();
for (int i = 0; i < 1000; i++)
{
problem.GenerateProblem();
streamWriter.WriteLine(problem.problem);
//streamWriter.WriteLine(i);
}
}
else
{
//IntGen problem1 = new IntGen();
Problem problem2 = new Problem();
for (int i = 0; i < 1000; i++)
{
Random rand = new Random(GetRandomSeed());
int a = rand.Next(2);
if(a == 1)
{
problem1.GenerateProblem();
streamWriter.WriteLine(problem1.Problem);
}
else
{
problem2.GenerateProblem();
streamWriter.WriteLine(problem2.problem);
}
//streamWriter.WriteLine(i);
}
}
streamWriter.Flush();
streamWriter.Close();
MessageBox.Show("导出成功!");
this.Close();
Form1.f.Show();
}
结对项目到此就告一段落了,总结了一些经验。
最后附上PSP表: