队友博客地址链接:Xie_mixue的博客
Github源码地址:源代码
目录
一、问题描述
二、PSP表格预估时间
三、Console阶段
四、GUI阶段
五、单元测试
六、重要优化
七、性能分析
八、最终成果展示
九、PSP表格实际耗时
十、总结
一、问题描述
1.一次生成一千个小学四则运算题目到一个文件里,保证合法、不重复。
注意:
a.运算符至多10个,其中括号数不在此限制内。
b.可以进行真分数运算。
c.可以设置对乘方的符号选择( ** 或 ^ )。
d.合法的注意事项:没有除0的操作、括号匹配、真分数运算可能会出现假分数结果,此时将结果表示为 整数+真分数。
2.一个图形界面,用户可以输入答案,系统判断对错,并设立一个20秒的倒计时,并创建用户答题历史记录。
二、PSP表格预估时间
PSP2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 20 | |
Estimate | 估计这个任务需要多少时间 | 20 | |
Development | 开发 | 2880 | |
Analysis | 需求分析(包括学习新技术) | 1440 | |
Design Spec | 生成设计文档 | 0 | |
Design Review | 设计复审(和同事审核设计文档) | 0 | |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 0 | |
Design | 具体设计 | 60 | |
Coding | 具体编码 | 5760 | |
Code Review | 代码复审 | 60 | |
Test | 测试(自我测试,修改代码,提交修改) | 1000 | |
Reporting | 报告 | 0 | |
Test Report | 测试报告 | 60 | |
Size Measurement | 计算工作量 | 60 | |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | |
合计 | 11360 |
三、Console阶段
本阶段的完整源代码地址:Console_Version
经过商量之后,决定暂时将任务划分为
1.创建题目
2.计算题目
我的任务为第二个,基本思路为,读取题目(一条字符串),分类,计算。
计算的难点在于运算优先级,基本规则可以概括为(从栈的角度看):
(1)当前运算符为加、减号时,之前的加、减、乘、除、乘方都要计算掉(乘、除、乘方优先级高于加减号);
(2)当前运算符为乘、除号时,之前的乘、除、乘方要计算掉(同一优先级自左向右计算);
(3)乘方的优先级最高,且乘方自右向左计算;
(4)左括号直接入栈,continue;
(5)遇到右括号时,一直计算直到弹出距离栈顶最近的一个左括号,右括号不入栈;
(6)要将分数的结果化简为最简式,并要考虑若分子为0导致最终结果为0、若结果为-1/1要表示为-1,若分子大于分母则表示为如2+2/3的格式;
(7)考虑连续7个以上9相乘的情况,设为long long int。
此外,因为设计题目的时候就考虑了题目的合法性,因此在计算除法时不用考虑除数为0的情况。
第一版的命令行界面示例:
经过计算器验证,答案正确。
以下为设计的参数和函数名称列表
stack<long long int> Data; //数字
stack<char> Operator;
stack<long long int> Numerator;//分子
stack<long long int> Denominator;//分母
string result;
int PowModel = 0;//默认乘方为 ^
string userAnswer;
#pragma region MainFunc
bool CheckQuestion(string Question);
void Clear();//清空栈
void Calculate(string Question);
void UserGUI();
string Transform(int data);//将数值转换为字符串
#pragma endregion
#pragma region Integer
void ReadInt(string Question);
void Integer();//+-*/^
void CalInt();//确保Operator栈为空
string ToPower(string Question);//将 ** 转换为 ^
#pragma endregion
#pragma region Fraction
void ReadFraction(string Question);
int gcd(int x, int y);//最大因约数
int lcm(int x, int y);//最小公倍数 = 两数相乘 / 最大因约数
void Fraction();//+-*/
void CalFraction();
void Simple();//分数最简化
#pragma endregion
1.整数计算核心函数 ReadInt()
void ReadInt(string Question) {
for (int i = 0; Question[i] != '='; i++) {
//------------空格 + 左括号-----------
if (Question[i] == ' ') {
continue;
}
if (Question[i] == '(') {
Operator.push('(');
continue;
}
//---------------数字部分---------------
int number = 0;
if (Question[i] >= '0' && Question[i] <= '9') {
while (Question[i] >= '0' && Question[i] <= '9') {
number = number * 10 + Question[i] - '0';
i++;
}
i--;
Data.push(number);
continue;
}
//----------------运算符 + 计算----------------
if ((Question[i] < '0' || Question[i] > '9') && Question[i] != ' ') {
if (Question[i] == ')') {//当前运算符为右括号
while (Operator.top() != '(') {
Integer();
}
Operator.pop();
}
else if (Question[i] == '^') {//当前运算符为乘方,直接放入栈中即可
Operator.push(Question[i]);
}
else if(Question[i] != '^'){//如果当前运算符不为乘方
if (Question[i] == '(') {
Operator.push(Question[i]);
continue;
}
while (!Operator.empty() && Operator.top() == '^') {//先计算之前运算符里的乘方,从右到左
Integer();
}
if (!Operator.empty() &&( Operator.top() == '*' || Operator.top() == '/' ) ) {//当栈顶元素不为乘方后,乘除号优先
while (!Operator.empty() && (Operator.top() != '+' && Operator.top() != '-' && Operator.top()!='(')) {
Integer();
}
}
if (!Operator.empty() && (Question[i] == '+' || Question[i] == '-')) {//同一优先级自左向右算
while (!Operator.empty() && Operator.top() != '(') {
Integer();
}
}
Operator.push(Question[i]);
}
}
}
}
2.分数计算核心函数ReadFraction()
void ReadFraction(string Question) {
for (int i = 0; Question[i] != '='; i++) {
//---------------------括号处理-------------
if (Question[i] == '(') { //左括号直接入栈
Operator.push('(');
i++;//跳过之后的空格
continue;
}
if (Question[i] == ')') {//右括号不入栈,并会计算直到弹出距离栈顶最近的一个左括号
while (!Operator.empty() && Operator.top() != '(') {
Fraction();
}
Operator.pop();//弹出左括号
i++;//跳过之后空格
if (Question[i] == '=') {
return;
}
continue;
}
//--------------------分子分母计算---------------------
int number = 0;
int flag = 0;
while (Question[i] >= '0' && Question[i] <= '9') {
flag = 1;
number = number * 10 + Question[i] - '0';
i++;
}
if (flag != 0 && Question[i] == '/') {//后一位为 / 则为分子
Numerator.push(number);
continue;
}
else if (flag != 0 && Question[i] == ' ') {
Denominator.push(number);
continue;
}
//--------------------运算符入栈 + 计算-------------------------
if (Question[i] == ' ') {
char before = Question[i - 1];
if (Operator.empty() || Operator.top() == '(') {//如果栈为空,或者栈顶元素为左括号,那么此运算符入栈,然后跳过
Operator.push(before);
continue;
}
else {
if (!Operator.empty() && (before == '+' || before == '-')) {
while (!Operator.empty() && Operator.top() != '(') {//加减号之前的乘除加减都要先算
Fraction();
}
}
else if (!Operator.empty() &&(before == '*' || before == '/')) {//乘除号之前的乘除号要先算
if (!Operator.empty() && Operator.top() != '+' && Operator.top() != '-') {
Fraction();
}
}
Operator.push(before);
}
}
}
}
其他函数(如simple()、Clear()等)会占用大量版面不予展示。
四、GUI阶段
本阶段完整代码地址:GUI_Version
经过讨论后我们选择C#语言中的winform来设计。
在C++版本的代码中,整数计算中除法为计算机的默认整数除法,即3/2=1,1/2=0,这样的结果误差结果较大,考虑设计一个函数,当整数题目中存在除法时将题目转换为分母为1的分数进行计算,即可避免除法误差。
但进一步讨论后,发现在创建题目的过程中,因为需要进行题目查重所以需要对题目进行计算,这时候已经可以直接计算出题目结果,并且可以将所有数字都按分数来计算(整数为分母为1的分数),于是设立string[] puzzle_str 来存储题目,string[] answer_str来存储每道题的运算结果。
在向RecordGUI窗口传递用户答题记录时,在PuzzleGUI窗口设立一个string[] History来保存用户的答题记录。
五、单元测试
大致分为分为题目查重测试和运算符重载测试两个部分。
本部分完整代码地址:单元测试代码
截取代码块注释如下:
//-------------------获取最大公因数函数测试---------
public void FracionGetGCDTest1()//正常情况
public void FracionGetGCDTest2()//最大公因数为1
public void FracionGetGCDTest3()//最大公因数为其中较小的数
//--------------------题目查重测试------------------
//加法交换+括号测试
public void CheckTest1() //测试1+2+3与3+(2+1)是否重复,预期结果为重复 -1
//加法顺序交换测试
public void CheckTest2()//测试1+2+3与3+2+1是否重复,预期结果为不重复 1
//乘法顺序交换测试
public void CheckTest3()//测试123与321是否重复,预期结果为不重复 1
//除法顺序交换测试
public void CheckTest4()//测试1/2/3与3/2/1是否重复,预期结果为不重复 1
//乘法交换+括号测试
public void CheckTest5()//测试(1+2)3与3(2+1)是否重复,预期结果为重复 -1
//^乘方测试
public void CheckTest6()//测试1+231与231 + 1是否重复,预期结果为重复 -1
//括号嵌套测试
public void CheckTest7()//测试 (1+(1+2)(3-2))+1 与 1+((3-2)(1+2))+1) 是否重复,预期结果为不重复 1
//括号嵌套测试+乘法顺序变换
public void CheckTest8()//测试 (1+(1+2)(3-2))+1 与 1+((1+2)(3-2))+1) 是否重复,预期结果为重复 -1
//乘方测试
public void CheckTest9()//测试1+231与23**1 + 1是否重复,预期结果为重复 -1
//分数的加法+括号测试
public void CheckTest10()//测试 1/2 + (1/3 + 1/4) 与 1/4 + 1/3 + 1/2 是否重复,预期结果为重复 -1
//分数的加法+乘法测试
public void CheckTest11() //测试 1/2 * (1/3 + 1/4) 与 (1/4 + 1/3) * 1/2 是否重复,预期结果为重复 -1
//--------------运算符重载测试-------------------------
public void CheckTest12()//分数类 ^ 运算符重载
public void CheckTest13()//分数类 * 运算符重载
public void CheckTest14()//分数类 / 运算符重载
public void CheckTest15()//分数类 + 运算符重载
public void CheckTest16()//分数类 - 运算符重载
运行测试后截图:
典型测试用例的代码摘取:
//--------------------题目查重测试------------------
//加法交换+括号测试
[TestMethod()]//测试1+2+3与3+(2+1)是否重复,预期结果为重复 -1
public void CheckTest1()
{
N_Puzzle.labels[0] = 0;
N_Puzzle.labels[1] = 0;
int[] puzzle1 = new int[] { 1, 100, 2, 100, 3 };
int[] puzzle2 = new int[] { 3, 100, 106, 2, 100, 1, 107 };
int puzzle_len1 = 5;
int puzzle_len2 = 7;
int puzzle_num1 = 0;
int puzzle_num2 = 1;
int num_type1 = 0;
int num_type2 = 0;
N_Puzzle.Check(puzzle1, puzzle_len1, puzzle_num1, num_type1);
Assert.AreEqual(N_Puzzle.Check(puzzle2, puzzle_len2, puzzle_num2, num_type2), -1);
}
//乘法顺序交换测试
[TestMethod()]//测试1*2*3与3*2*1是否重复,预期结果为不重复 1
public void CheckTest3()
{
N_Puzzle.labels[0] = 0;
N_Puzzle.labels[1] = 0;
int[] puzzle1 = new int[] { 1, 102, 2, 102, 3 };
int[] puzzle2 = new int[] { 3, 102, 2, 102, 1 };
int puzzle_len1 = 5;
int puzzle_len2 = 5;
int puzzle_num1 = 0;
int puzzle_num2 = 1;
int num_type1 = 0;
int num_type2 = 0;
N_Puzzle.Check(puzzle1, puzzle_len1, puzzle_num1, num_type1);
Assert.AreEqual(N_Puzzle.Check(puzzle2, puzzle_len2, puzzle_num2, num_type2), 1);
}
//括号嵌套测试+乘法顺序变换
[TestMethod()]//测试 (1+(1+2)*(3-2))+1 与 1+((1+2)*(3-2))+1) 是否重复,预期结果为重复 -1
public void CheckTest8()
{
N_Puzzle.labels[0] = 0;
N_Puzzle.labels[1] = 0;
int[] puzzle1 = new int[] { 106, 1, 100, 106, 1, 100, 2, 107, 102, 106, 3, 101, 2, 107, 107, 100, 1 };
int[] puzzle2 = new int[] { 1, 100, 106, 106, 1, 100, 2, 107, 102, 106, 3, 101, 2, 107, 107, 100, 1 };
int puzzle_len1 = 17;
int puzzle_len2 = 17;
int puzzle_num1 = 0;
int puzzle_num2 = 1;
int num_type1 = 0;
int num_type2 = 0;
N_Puzzle.Check(puzzle1, puzzle_len1, puzzle_num1, num_type1);
Assert.AreEqual(N_Puzzle.Check(puzzle2, puzzle_len2, puzzle_num2, num_type2), -1);
}
//分数的加法+乘法测试
[TestMethod()]//测试 1/2 * (1/3 + 1/4) 与 (1/4 + 1/3) * 1/2 是否重复,预期结果为重复 -1
public void CheckTest11()
{
N_Puzzle.labels[0] = 1;
N_Puzzle.labels[1] = 1;
int[] puzzle1 = new int[] { 1, 1030, 2, 102, 106, 1, 1030, 3, 100, 1, 1030, 4, 107 };
int[] puzzle2 = new int[] { 106, 1, 1030, 4, 100, 1, 1030, 3, 107, 102, 1, 1030, 2 };
int puzzle_len1 = 13;
int puzzle_len2 = 13;
int puzzle_num1 = 0;
int puzzle_num2 = 1;
int num_type1 = 1;
int num_type2 = 1;
N_Puzzle.Check(puzzle1, puzzle_len1, puzzle_num1, num_type1);
Assert.AreEqual(N_Puzzle.Check(puzzle2, puzzle_len2, puzzle_num2, num_type2), -1);
}
[TestMethod()] //分数类 ^ 运算符重载
public void CheckTest12()
{
bool z = false;
fraction A = new fraction (2,3);
fraction B = new fraction (0, 1);
fraction C = A ^ B;
fraction E = new fraction(1, 1);
if (C == E) z = true;
Assert.IsTrue(z);
}
六、重要优化
1.整数化为分母为1的分数,避免整数除法的误差;
2.在创建题目的查重过程中即进行计算,而不是读取题目的字符串后再进行计算,减少了额外的计算量。
3.修正了用户完成一次答题之后返回用户界面,再次点击Begin按钮时程序报错问题(索引超出数组范围),实现了用户重复点击Begin按钮后进行新一轮的答题的功能。
4.修正了超时后答题界面的题目更新问题。
5.加入查询用户历史记录时,若用户还未答题的提示。
6.当进入用户主界面(MainGUI)时主界面入口窗口(MainInterface)隐藏,进入答题界面(PuzzlesGUI)或历史记录界面(RecordGUI)时用户主界面隐藏,当关闭两个子界面后用户主界面才会再次显示,优化了视觉体验。
7.为最终的exe文件更换了图标,过程详见我的另一篇博客:exe文件图标更换。
8.修正了答题界面点击Cancel按钮之后倒计时没有立即停止而是进入下一轮计时的问题。
9.修正了重复点击Query按钮时历史记录重复生成的问题,改为Query按钮在点击一次后隐藏,直到下一次打开RecordGUI才会再次显示。
七、性能分析
引用队友博客中的性能分析:
由性能分析报告可知,题目生成和查重函数比较耗时,而题目生成的主要调用函数也是Check()函数。因此仅需要对查重函数Check()进行改进即可。
对其的改进为:设置一个题型标记数组Labels[],在判重时,优先判断题型是否一样,再判断解题过程的长度是否一样,最后再判断解题过程内容是否完全一样,这样可以减少判重时间。
八、最终成果运行展示
最终的exe可执行文件:Arithmetic.exe
1.主界面入口
2.用户主界面,不输入ID和生成题目数点击Begin和Record无效。
核心代码:Begin点击事件、Record点击事件
//--------------开始答题-----------
private void btn_begin_Click(object sender, EventArgs e)
{
//将上一次的历史记录清空
for (int i = 0; i< PuzzlesGUI.History.Length; i++)
{
PuzzlesGUI.History[i] = "";
}
PuzzlesGUI.score = 0;//清空上次答题分数
tb_num.Focus();//鼠标自动定位到输入题目数的文本框
num = 0;
string N;
user_name = tb_ID.Text;//获取用户输入
N = tb_num.Text;
//-------------检查输入是否合法 + 选择乘方模式-------------------
CheckInput(N);
ChooseModel();
if(user_name.Length == 0)
{
MessageBox.Show("Please enter your ID.");
}
else if(N.Length == 0 || N == "0")
{
MessageBox.Show("Please enter the number of puzzles.");
}
else if(N.Length >= 4 && N!= "1000")//保证题目数小于等于1000
{
MessageBox.Show("Number is too big!");
}
else if (CheckN == false)
{
MessageBox.Show("Error Input!\nPlease enter correct number.");
}
else if(CheckN == true)
{
num = Convert.ToInt32(N);
//---------生成num道题目-------------------
n_puzzle.PuzzleGenerate(num, type);
//----------弹出PuzzleGUI------------------
this.Visible = false;
PuzzlesGUI Puzzle = new PuzzlesGUI();
Puzzle.ShowDialog();
this.Visible = true;
}
}
//------------------查看历史记录-----------
private void btn_record_Click(object sender, EventArgs e)
{
if(tb_ID.Text == "")
{
MessageBox.Show("Please enter your ID.");
}
else
{
this.Visible = false;
tb_num.Focus();
user_name = tb_ID.Text;
RecordGUI Record = new RecordGUI();
Record.ShowDialog();
this.Visible = true;
tb_num.Text = null;
tb_num.Focus();
}
}
3.答题界面(点击begin之后弹出),右上角设有20秒的倒计时。
未在20秒内答完题目,弹出超时的题目,并更新到下一道题,未做的题不计入答题历史记录。
倒计时Code:
//-------------倒计时--------------------------------
private void timer1_Tick(object sender, EventArgs e)
{
lab_time_num.Text = (seconds--).ToString() + " seconds";
if (seconds == -1)//超时后弹出提示窗口,更新题目、题号、剩余题目数,,清空答案输入框,进入下一轮计时
{
timer1.Stop();
lab_time_num.Visible = false;//倒计时显示关闭
MessageBox.Show("Time Out!");
lab_puzzle.Text = MainGUI.puzzle_str[++current];
lab_count.Text = "No." + current;
lab_left_num.Text = lab_left_num.Text = Convert.ToString(MainGUI.num - current);
tb_answer.Text = null;
seconds = 20;
timer1.Start();
lab_time_num.Text = (seconds--).ToString() + " seconds";
lab_time_num.Visible = true; //倒计时显示打开
}
}
超时
更新到下一题,重新开始计时
核心代码:点击OK事件
private void btn_ok_Click(object sender, EventArgs e)
{
tb_answer.Focus();
timer1.Stop();//停止计时
userAnswer = this.tb_answer.Text;//获取用户的输入
ToTrueFraction(MainGUI.answer_str[current]);//调用存储的计算结果
CheckAnswer();//核对用户答案
this.tb_answer.Text = "";//将输入框清空
History[current] = "NO." + current + ": " + MainGUI.puzzle_str[current] + Environment.NewLine
+ Environment.NewLine + IsTrue
+ " UserAnswer: " + userAnswer
+ " CorrectAnswer: " + result
+ Environment.NewLine + Environment.NewLine;
//传递答题数据到历史记录
lab_puzzle.Text = MainGUI.puzzle_str[++current];//更新PuzzleGUI的题目显示
seconds = 20;//重置计时时间
timer1.Start();//进行下一轮计时
lab_count.Text = "No." + current;//更新题号
IsTrue = false;
lab_left_num.Text = Convert.ToString(MainGUI.num - current);
if (current == MainGUI .num)//题目做完后,退出答题界面
{
timer1.Stop();
current = 0; //重置
MainGUI.tb_num.Text = null;
MessageBox.Show("All questions are completed!");
this.Close();
}
}
答题错误,弹出错误提示
回答正确,弹出提示窗口,之后score+1。
问题全部答完后,剩余题目数为0,提示答题完毕并退出答题界面,返回到用户界面。
4.历史记录界面,点击Query查询上一次答题的历史记录,点击Quit退出历史记录界面,返回到用户界面。
核心代码:点击Query事件
private void btn_query_Click(object sender, EventArgs e)
{
lab_user_name.Text = MainGUI.user_name;
lab_score.Text = Convert.ToString(PuzzlesGUI.score);
if(PuzzlesGUI.History[0] == null)
{
MessageBox.Show("You hanven't done questions yet!");
}
for(int i = 0; i < PuzzlesGUI .History.Length ; i++)
{
tb_record.Text += PuzzlesGUI.History[i];
}
}
如果用户没有做一道题,那么点击查询之后提示用户未做题。
历史记录允许水平方向和竖直方向滚动。
九、PSP表格实际耗时
PSP2.1 | Personal Software Process Stages | 预估耗时(min) | 实际耗时(min) |
---|---|---|---|
Planning | 计划 | 20 | 20 |
Estimate | 估计这个任务需要多少时间 | 20 | 20 |
Development | 开发 | 2880 | 2160 |
Analysis | 需求分析(包括学习新技术) | 1440 | 1460 |
Design Spec | 生成设计文档 | 0 | 0 |
Design Review | 设计复审(和同事审核设计文档) | 0 | 0 |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 0 | 0 |
Design | 具体设计 | 60 | 60 |
Coding | 具体编码 | 5760 | 5760 |
Code Review | 代码复审 | 60 | 120 |
Test | 测试(自我测试,修改代码,提交修改) | 1000 | 2000 |
Reporting | 报告 | 0 | 0 |
Test Report | 测试报告 | 60 | 0 |
Size Measurement | 计算工作量 | 60 | 0 |
Postmortem & Process Improvement Plan | 事后总结,并提出过程改进计划 | 60 | 180 |
合计 | 11360 | 11900 |
十、总结
在本次结对项目中,我们一开始的分工是将项目划分为生成题目和计算题目两个模块,但随着项目推进,发现生成题目过程中若要保证题目不重复就要进行查重,即需要对题目进行计算,此时我已经写好了计算题目的代码,无疑这样就是将计算过程进行了两次,造成了大量的计算量重叠。
并且,我一开始将题目分为整数和分数运算时,没有考虑到整数题目的除法会造成误差,精确的结果应当用分数来表示。因而最优的计算应当是一开始就将数据封装为分数形式的类。
于是最终的结果是我的组伴完成了生成题目、计算题目的核心代码部分,我则主要负责C#界面和功能设计、优化以及部分单元测试。
总结之后,发现前两天的C++代码花费了我大量时间是非常不划算且完全没有意义。合理的分工应当是,一个人写C++题目生成和计算,另一个人去完成C#界面设计。
当然也有很多收获,比如从对C#从陌生到可以做出简单的exe可执行文件,亲眼看到一个简单软件在你手中成形是非常有成就感的。
1…心得:
进行C#(winform)界面设计时,在不同窗口之间传递值要将那个值设为public static,并且一定要注意button_Click()事件里的数据重置和清零的问题。
进行timer计时时,一定要注意start()和stop()d的时机,防止进入反复计时。
C#里只有int,没有long long int,因为C#里的int表示的范围和C++里的long long int一样大。
C++的栈里栈顶元素表示为stack.top(),是可以直接对其赋值的,允许这样的操作(stack.top() = 12;),但C#的栈顶元素stack.Peek()不允许为其直接赋值,要改变栈顶元素的值只能在Pop()之后再Push()。