当前位置: 代码迷 >> 综合 >> 四则运算——软工结对项目
  详细解决方案

四则运算——软工结对项目

热度:77   发布时间:2023-10-31 22:48:19.0

队友博客地址链接: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+2
31与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)是否重复,预期结果为重复 -1public 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是否重复,预期结果为不重复 1public 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) 是否重复,预期结果为重复 -1public 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 是否重复,预期结果为重复 -1public 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()。