结对项目

第二次编程作业

这个作业属于哪个课程 网工1934-软件工程
这个作业要求在哪里 作业要求
这个作业的目标 实现一个自动生成小学四则运算题目的命令行程序

1.相关代码已经上传到GitHub

2.PSP 表格

PSP2.1 Personal Software Process Stages 预估耗时(分钟) 实际耗时(分钟)
Planning 计划 30 30
· Estimate · 估计这个任务需要多少时间 5 5
Development 开发 150 90
· Analysis · 需求分析 (包括学习新技术) 200 240
· Design Spec · 生成设计文档 20 10
· Design Review · 设计复审 10 10
· Coding Standard · 代码规范 (为目前的开发制定合适的规范) 5 5
· Design · 具体设计 30 20
· Coding · 具体编码 120 240
· Code Review · 代码复审 20 10
· Test · 测试(自我测试,修改代码,提交修改) 40 80
Reporting 报告 60 70
· Test Repor · 测试报告 20 20
· Size Measurement · 计算工作量 5 5
· Postmortem & Process Improvement Plan · 事后总结, 并提出过程改进计划 10 10
· · 合计 725 845

3. 模块接口的设计以及实现过程

1. 主要实现方法
  • 主类: index入口,主要作用是接收命令行的参数,以及调用工具函数执行程序
  • 工具类:包括生成运算式、计算运算式、读写文件,commander.js(命令行编辑工具,需使用 yarn add commander 或 npm i commander 安装)
  • 单元测试:test方法,测试主函数以及工具函数是否可以正常使用

项目结构:

结对项目

项目流程以及函数间的关系:

结对项目

2. 关键函数的分析与实现

实现函数的需求分析以及解释:

  1. 将中缀表达式转化为逆波兰式(后缀表达式),代码如下:
// 运算符集合
const symbol = ['#', '(', '+', '-', '*', '÷', ')']
const symbolPriority = {
    '#': 0,
    '(': 1,
    '+': 2,
    '-': 2,
    '*': 3,
    '÷': 3,
    ')': 4
}

/**
 * 
 * @param {*} char 符号字符
 * @param {*} symArr 存储符号表达式
 * @param {*} resArr 记录后缀表达式元素
 * @returns 
 */
const operaSymbol = (char, symArr, resArr) => {
    let lastChar = symArr[symArr.length - 1]
    let curChar
    if (!lastChar) {
        symArr.push(char)
        return
    }
    // 如果遇到左括号则直接入栈
    if (char === '(') {
        symArr.push(char)
    }
    // 如果遇到右括号,则弹出栈内只到出现左括号为止
    else if (char === ')') {
        curChar = symArr.pop()
        while (symArr && curChar != '(') {
            resArr.push(curChar)
            curChar = symArr.pop()
        }
    }
    // 如果栈外操作符的优先级高于栈内的优先级则入栈
    else if (symbolPriority[char] > symbolPriority[lastChar]) {
        symArr.push(char)
    }
    // 如果栈外的操作符优先级低于或等于栈内的优先级,输出栈内的符号,并入栈外的符号
    else if (symbolPriority[char] <= symbolPriority[lastChar]) {
        while (lastChar && (symbolPriority[char] <= symbolPriority[lastChar])) {
            curChar = symArr.pop()
            resArr.push(curChar)
            lastChar = symArr[symArr.length - 1]
        }
        //      operaSymbol(char, symArr, resArr)
        symArr.push(char)
    } else {
        symArr.push(char)
    }
}

/**
 * 中缀表达式转化为后缀表达式(逆波兰表达式)
 * @param {*} str 中缀表达式字符串
 * @returns 
 */
const toSuffixExpre = (str) => {
    const resArr = new Array() // 记录后缀表达式
    const symArr = new Array() // 记录符号表达式
        // 用于记录数字
    let substr = ''
    for (let i = 0, len = str.length; i < len; i++) {
        // 判断是否是符号
        if (symbol.includes(str[i])) {
            if (substr.length) resArr.push(substr)
            substr = ''
            operaSymbol(str[i], symArr, resArr)
        } else {
            //  收集符号间的数字元素
            substr += str[i]
        }
    }

    if (substr !== '') resArr.push(substr)

    // 如果符号栈中还有元素,需要再推入存储后缀表达式的栈中
    while (symArr.length > 0) {
        const curChar = symArr.pop()
        resArr.push(curChar)
    }
    return resArr
}
  1. 逆波兰式计算,代码如下:
/**
 * 逆波兰运算
 * @param {*} expression 运算式
 * @returns 
 */
const evalRPN = (expression) => {
    // 将运算式多余的空格删除
    let str = formatExpression(expression)

    //  将中缀运算式按后缀运算式推入栈中
    let tokens = toSuffixExpre(str)
    const stack = [];
    const n = tokens.length;
    for (let i = 0; i < n; i++) {
        const token = tokens[i];
        if (isNumber(token)) {
            if (isFraction(token)) stack.push(token);
            else stack.push(parseInt(token));
        } else {
            let num2 = stack.pop();
            let num1 = stack.pop();
            if (token === '+') {
                // 整数直接运算,分数则需要用另外的方法计算
                if (!isFraction(num1) && !isFraction(num2)) {
                    stack.push(num1 + num2);
                } else {
                    stack.push(evalFraction(num1, num2, '+'))
                }
            } else if (token === '-') {
                if (!compareNum(num1, num2)) return false
                if (!isFraction(num1) && !isFraction(num2)) {
                    stack.push(num1 - num2);
                } else {
                    stack.push(evalFraction(num1, num2, '-'))
                }
            } else if (token === '*') {
                if (!isFraction(num1) && !isFraction(num2)) {
                    stack.push(num1 * num2);
                } else {
                    stack.push(evalFraction(num1, num2, '*'))
                }
            } else if (token === '÷') {
                if (num2 === 0) return false
                stack.push(evalFraction(num1, num2, '÷'))
            }
        }
    }
    return stack.pop();
};
  1. 相关分数计算及转化,代码如下:

/**
 * 判断是否为分数
 * @param {*} token 
 * @returns 
 */
const isFraction = (token) => {
    return typeof token !== 'number' && (token.includes('/') || token.includes('`'))
}

/**
 * 判断是否为带分数
 * @param {*} fraction  分数
 */
const isMixNum = (fraction) => {
    return typeof fraction !== 'number' && fraction.includes('`')
}

/**
 * 将带分数化为假分数
 * @param {*} fraction 带分数
 * @returns 
 */
const turnImFraction = (fraction) => {
    const arr = fraction.split(/[\/`]/)
    let integer = parseInt(arr[0])
    let numerator = parseInt(arr[1])
    let denominator = parseInt(arr[2])
    return integer * denominator + numerator + '/' + denominator
}

/**
 * 分数式化简
 * @param {*} numerator  分子
 * @param {*} denominator   分母
 * @returns 
 */
const formatFraction = (numerator, denominator) => {
    // 如果分母为1,则直接返回分子
    if (denominator === 1) return numerator + ''

    // 获取整数部分
    let integerPart = Math.floor(numerator / denominator)
        // 判断是否为假分数,是则转化为带分数
    if (integerPart) {
        // 抽出带分数的整数部分
        numerator -= integerPart * denominator
        return integerPart + '`' + numerator + '/' + denominator
    }

    return numerator + '/' + denominator
}

/**
 * 分数式运算
 * @param {*} num1 
 * @param {*} num2 
 * @param {*} symbol 运算符
 */
const evalFraction = (num1, num2, symbol) => {
    if (isMixNum(num1)) num1 = turnImFraction(num1)
    if (isMixNum(num2)) num2 = turnImFraction(num2)
    const stackNum1 = resolveImFraction(num1)
    const stackNum2 = resolveImFraction(num2)
    let numerator = 0 // 分子
    let denominator = 0 // 分母
    let maxFac = 0 // 最大公约数
    let ans
    switch (symbol) {
        case '+':
            numerator = stackNum1[0] * stackNum2[1] + stackNum2[0] * stackNum1[1]
            denominator = stackNum1[1] * stackNum2[1]
            maxFac = getMaxFactor(numerator, denominator)
            ans = formatFraction(numerator / maxFac, denominator / maxFac)
            break;
        case '-':
            numerator = stackNum1[0] * stackNum2[1] - stackNum2[0] * stackNum1[1]
            denominator = stackNum1[1] * stackNum2[1]
            maxFac = getMaxFactor(numerator, denominator)
            ans = formatFraction(numerator / maxFac, denominator / maxFac)
            break;
        case '*':
            numerator = stackNum1[0] * stackNum2[0]
            denominator = stackNum1[1] * stackNum2[1]
            maxFac = getMaxFactor(numerator, denominator)
            ans = formatFraction(numerator / maxFac, denominator / maxFac)
            break;
        case '÷':
            numerator = stackNum1[0] * stackNum2[1]
            denominator = stackNum1[1] * stackNum2[0]
            maxFac = getMaxFactor(numerator, denominator)
            ans = formatFraction(numerator / maxFac, denominator / maxFac)
            break;
        default:
            throw new Error("this symbol is not an operator!")
            break;
    }
    return ans
}

/**
 * 分解数字
 * @param {*} num  
 * @returns 
 */
const resolveImFraction = (num) => {
    let stack = []

    // 分解假分数,若为整数则分母设为1
    if (isFraction(num)) {
        let strArr = num.split(/[\/]/)
        stack = strArr.map(val => {
            return +val
        })
    } else {
        stack[0] = parseInt(num)
        stack[1] = 1
    }
    return stack
}

算法流程:
结对项目

4. 性能分析

结对项目

5. 项目运行

// 常用命令行
node index.js -n 20 // 生成20道题目
node index.js -r 20 // 数字范围为1~20
node index.js -e <exercisefile> -a <answerfile>  // 对比两个文件得到正确和错误题号,文件名需加上拓展符

// 进入文件夹所在路径后,可使用以下命令行启动项目
node index.js -n 20 -r 20  // 生成20道题目,数字范围为1~20
  • 运行结果:
    生成运算式:
    结对项目

生成比对结果:
结对项目

同时,该程序允许生成10000道以上的式子:
结对项目

6.异常处理

代码展示

结对项目

如果找不到文件路径,就打印相关错误并结束程序

测试结果:

输入非法字符:
结对项目

源文件输入路径错误:
结对项目

7. 单元测试:

cd进入test文件后node test.js 即可:
结对项目
结对项目

8. 项目小结:

  • 林澍锴: 因为我和马炽阳同学都是加入工作室担任开发的,所以完成这个项目其实算是比较简单的,简单沟通和分工之后就开始了各自的模块开发。我负责的模块的是运算及文件读写,感觉其中逻辑比较复杂的地方是有关于分数的计算,可能花了比较多时间去理清楚可能发生的一系列情况,中缀转后缀以及逆波兰计算由于之前刷题遇到过所以不算很难。还有由于是第一次接触nodejs命令行插件,用的不是很熟悉,也花费了一些时间熟悉用法。这个项目我感觉还是很不错的,巩固了我对逆波兰算法的认识,以及对一些nodejs工具插件的使用,对我帮助很多
  • 马炽阳:这次我和澍锴打算用js开发项目,但由于我个人是后台开发,所以花了一部分时间熟悉前端语法,并完成了运算式生成的模块,运算式生成其实更多的是关于字符串的处理,真假分数判断、真假分数转换、分数化简、 对括号的处理,都比较考验我的大局观,经过多轮测试,最后才完成了比较精确的生成方案,这个项目对我的考验其实很大,毕竟是第一次使用前端语言,但是完成的还是比较满意的。我相信这对我之后成为一名全栈工程师有很大的帮助,也很高兴有这个机会接触前端知识。

谢谢观看!

上一篇:列表初始化和赋值初始化的使用注意事项


下一篇:用JavaScript判断是不是质数