2023NJU-ICS PA1.2表达式求值 思路详解 心得体会

前言

PA1.2的细节非常非常多,导致这几天花了大量的时间去调试bug,4.3晚上终于过了最后一关“如何测试你的代码”(花了两整天时间才调成功)。虽然耗时巨大,但确实学到了不少东西、训练了能力,于是抽几天时间来此记录一下遇到的问题以及解决和一些心得体会

本文仅提供详细的思路,因为时间有限和比较难整理,所以暂时不展示完整源码,谢谢理解

词法分析

词法分析的目的是把用户输入的表达式分解成一个一个的tokens保存起来,用于后续的运算,这个过程看似直接,但其实会遇到很多细节问题,比如连续的好几个数字字符怎么识别成一个整体的数字?连续的空格字符怎么全部忽略?如果直接用C语言编写,将会十分的复杂,所以使用一个强大的工具——正则表达式

将不同类型的正则表达式映射到不同类型的数字上,组成一个二元组集合,用结构体表示,比如:

struct rule {
    char *regex;
    int type;
}rules[]={
    {" +", TK_NOTYPE}, 
    //others...
}

 前面的" +"表示空格这个字符可以重复1或多次,需要注意的是前面的匹配规则要考虑C语言的转义符使用,比如要匹配除号,要写"\\/",第一个\是为了在C语言中转义/,第二个\是在正则表达式本身转义/。

后面的TK_NOTYPE表示空格,可以在结构体的上面用enum对每一个这种表示起一个编号,如:

enum {
    TK_NOTYPE = 256,
    //other...
}

想要使用上面的正则表达式匹配,需要重点关注两个C语言库函数:regcomp和regexec

regcomp可以放在init_regex函数中用于对正则表达式相关的规则初始化,然后init_regex可以放在init_sdb函数中,进行初始化

下面说make_token函数的思路:

make_token()函数的工作方式十分直接, 它用position变量来指示当前处理到的位置, 并且按顺序尝试用不同的规则来匹配当前位置的字符串. 当一条规则匹配成功, 并且匹配出的子串正好是position所在位置的时候, 我们就成功地识别出一个token,把识别到的token放入tokens数组中(具体可以查阅其它博客)

效果举例:

输入:1    +   666* (            6+5)

识别为:

tokens[0].type==NUM,tokens[0].str=="1"

tokens[1].type==TK_NOTYPE

tokens[2].type==ADD

tokens[3].type==TK_NOTYPE

tokens[4].type==NUM,tokens[4].str=="666"

tokens[5].type=='*'

tokens[6].type=='('

tokens[7].type==TK_NOTYPE

tokens[8].type==NUM,tokens[8].str=='6'

tokens[9].type==ADD

tokens[10].type==NUM,tokens[10].str=='5'

tokens[11].type==')'

学会调试

通过PA1.2的学习,我的调试能力有了一定提高,正如讲义所说,调试常见三法宝是:printf、assert、GDB

printf

在某些地方写printf,输入此时某些变量的值进行分析,这也是算法题中常用的技巧

assert

在写下面的eval函数时,assert让我高兴又痛苦,高兴的是每次编译的时候都能找到出错的地方,最常见的两个是:1.括号匹配函数没写好导致的报错 2.除数为0导致的报错(因为这个调bug调了一整天),所以说在合适的位置插入assert(0)可以帮助提高代码的健壮性

以防止除数为0为例,可以写:

 case '/':
        if(val2 == 0){
          printf("  val1 = %d p = %d q = %d\n",val1,p,q);
          assert(0);
          return 0;
        }
        return val1 / val2;

GDB调试器

如讲义所说,要掌握gdb常见命令,如next、step、until、info、file、run、list...写PA期间大量使用了GDB,帮了大忙

递归求值

括号检测函数

我觉得这个函数的目的以下几个:

1.判断一个表达式的括号是否合法,比如(1+3))或(1+((23-6))))就不合法,针对这种不合法的情况,应该直接终止计算

2.去掉两侧多余的函数,比如((1+1)+(2+3)),但(1+1)+(2+3)这种两边的括号当然是不能去掉的

综上,设置三种返回值类型:

return -1:表达式格式有误、终止计算

return 0 :表达式格式正确,且不需要进行去括号处理

return 1 :表达式格式正确,需要去掉两边的括号,进入eval(p+1,q-1)

这里有个问题,多余的括号非去不可吗?是的,在后续“主运算符的寻找”中,如果多这种多余的括号,将会有bug(至少是我的思路会出bug)

继续说这个函数,我们可以分为三个部分:

1.检测

遍历表达式判断格式是否正确,若true,则继续进行下面操作,否则,return -1

2.初步筛选

判断表达式第一个token是否为'('且最后一个token是否为')',若不是,那么一定不用去括号啦,直接return 0

3.递归筛选

直接进入check_parentheses(p+1,q-1),根据返回值分类讨论:

1.若返回-1,说明,(p,q)不能去括号,return 0

2.若返回0或者1,要去括号,return 1

综上,此函数源码为:

static int check_parentheses(int p,int q){
  //return -1:stop calculate
  //return 0 :no need to remove parentheses
  //return 1 :need to remove parentheses
//part1
  int cnt = 0;
  for(int i = p; i <= q;i ++)
  {
    if(tokens[i].type == '(')cnt ++;
    else if(tokens[i].type == ')')cnt --;
    
    if(cnt < 0)return -1;
  }
  if(cnt != 0) return -1;
//part2
  if(tokens[p].type != '(' || tokens[q].type != ')')return 0;
//part3
  int ret = check_parentheses(p+1,q-1);
  if(ret == -1)return 0;
  else if(ret == 0||ret == 1)return 1;
  return 2;//algorithm_error,提高算法健壮性,防止上面考虑不全而出错
}

主运算符的寻找

解决了括号匹配问题,再看这个核心问题,找主运算符,实际上,满足下面条件即可:

1.一定不在括号里面

2.优先级很小

3.如果几个运算符优先级相同,那么主运算符是最后面那个

所以,整体思路为:

step1:跳过括号

比如(1+1)+(2+3),在遍历token时,遇见第一个'('直接跳到')',但如果没有上面去多余括号的一步,就有:((1+1)+(1+1)),遇见(直接会跳到最右边的')',那么都遍历结束了,如何找主运算符?

step2:遍历token,根据优先级锁定主运算符

这个不多说了,直接上源码、请自行理解 (注意,越往下的优先级越高,可以照着C语言优先级等级表写这个flag大小顺序!)

 int op = -1;
    int flag = -1 ;
    for(int i = p; i <= q;i ++)
    {
      if(tokens[i].type == '(')
      {
        int cnt = 1;
        while(cnt != 0)
        {
          i ++;
          if(tokens[i].type == '(')cnt ++;
          else if(tokens[i].type == ')')cnt--;
        }
      }
      
      if(flag<=7 && tokens[i].type == OR){
        flag = 7;
        op = max(op,i);
      }
      if(flag<=6 && tokens[i].type == AND){
        flag = 6;
        op = max(op,i);
      }
      if(flag<=5 && tokens[i].type == NOTEQ){
        flag = 5;
        op = max(op,i);
      }
      if(flag<=4 && tokens[i].type == EQ){
        flag = 4;
        op = max(op,i);
      }
      if(flag<=3 && tokens[i].type == LEQ){
        flag = 3;
        op = max(op,i);
      }
      if(flag<=2 && (tokens[i].type == '+' || tokens[i].type == '-')){
        flag = 2;
        op = max(op,i);
      }
      if(flag<=1 && (tokens[i].type == '*' || tokens[i].type == '/') ){
        flag =1;
        op = max(op,i);
      }
    }
    int op_type = tokens[op].type;
    

负号和减号的区分

对于更复杂的表达式,比如1 + -2 如何知道后面的"-"是一个负号而不是减号呢?

可以在正式计算的函数(word_t expr(char *e, bool *success))中,在调用eval函数之前,做一些预处理,就以负号判断为例,可以写:

for(int i = 0;i < tokens_len; i ++)
  {
    if((tokens[i].type == '-' && i == 0)||(tokens[i].type == '-' && i > 0 &&tokens[i-1].type != NUM && tokens[i+1].type == NUM &&tokens[i-1].type != ')'))
    
  {
    tokens[i].type = TK_NOTYPE;
    for(int j = 31;j >= 0;j --)
    {
      tokens[i+1].str[j] = tokens[i+1].str[j-1];
    }
    tokens[i+1].str[0] = '-';
    for(int j = 0;j < tokens_len; j ++){ 
    if(tokens[j].type == TK_NOTYPE)
    {
      for(int k = j+1; k < tokens_len;k ++){
        tokens[k - 1] = tokens[k];
      }
      tokens_len --;
    }
  }
  }
  }

即如果这个'-'在开头或者不在开头但是前面的一个token不是数字、右括号、右边的token是数字,就可以让这个'-'先和后面的数字直接结合,然后调整tokens的位置('-'与后面的数字结合之后就会变成空格)

如何测试你的代码

生成测试用例

这一部分将采用“随机测试”的方法,生成大量的随机表达式,然后放入上面自己写的expr函数中进行测试,以前感觉OJ题目的评判很神奇,能够直接知道自己的哪个数据过不了,做完这个之后,感觉可能和这个测试代码的思路有点相似吧。

生成随机表达式的框架代码如下

static void gen_rand_expr(){
//  buf[0] = '\0' ;
  if(index_buf > 655){
//    printf("overSize\n");
    index_buf = 0;}
  
  switch(choose(3)){
    case 0: 
      gen_num();

      break;
    case 1:
      gen('(');
      gen_rand_expr();
      gen(')');
      break;
    default:
      gen_rand_expr();
      gen_rand_op();
      gen_rand_expr();
      break;
    }
}

使用测试用例计算调试器运算准确率

补充gen、choose等函数的代码之后,还需要考虑一些问题,如果生成的表达式格式不对怎么办?如果里面有除数为0的情况又怎么办,我的方法是把报错信息重定向到一个文件中,如果文件为空,则没问题,或者直接根据system函数的返回值确定,如果有问题,则重新生成一次表达式,main源码如下:

int main(int argc, char *argv[]) {
  int seed = time(0);
  srand(seed);
  int loop = 1;
  if (argc > 1) {
    sscanf(argv[1], "%d", &loop);
  }
  int i;
  for (i = 0; i < loop; i ++) {
    index_buf = 0;
    gen_rand_expr();
    buf[index_buf] = '\0';
    long size = 0;
    sprintf(code_buf, code_format, buf);

    FILE *fp = fopen("/tmp/.code.c", "w");
    assert(fp != NULL);
    fputs(code_buf, fp);
    fclose(fp);
    
    FILE *fp_err = fopen("/tmp/.err_message","w");
    assert(fp_err != NULL);

    int ret = system("gcc /tmp/.code.c -o /tmp/.expr 2>/tmp/.err_message");
    
    fseek(fp_err, 0, SEEK_END);

    // 获取文件大小
    size = ftell(fp_err);
    fclose(fp_err);
    if (ret != 0 ||size != 0){index_buf = 0; i--; continue;}
    
    fp = popen("/tmp/.expr", "r");
    assert(fp != NULL);

    uint32_t result;
    ret = fscanf(fp, "%u", &result);
    pclose(fp);

    printf("%u %s\n", result, buf);
    index_buf = 0;
  }
  return 0;
}

然后在命令行用gcc编译、运行gen-expr.c把输出重定向到input中,用于后序测试调试器运算的准确率

gcc -c -g gen-expr.c 
gcc gen-expr.o -o gen-expr
./gen-expr 100 > input

在sdb.c中增加一条命令test,用于测试准确率

static int cmd_test(char *args){
  int right_ans = 0;
  FILE *input_file = fopen("/home/dmz/ics2023/nemu/tools/gen-expr/input", "r");
    if (input_file == NULL) {
        perror("Error opening input file");
        return 1;
    }

    char record[1024];
    unsigned real_val;
    char buf[1024];

    // 循环读取每一条记录
    for (int i = 0; i < 100; i++) {
        // 读取一行记录
        if (fgets(record, sizeof(record), input_file) == NULL) {
            perror("Error reading input file");
            break;
        }

        // 分割记录,获取数字和表达式
        char *token = strtok(record, " ");
        if (token == NULL) {
            printf("Invalid record format\n");
            continue;
        }
        real_val = atoi(token); // 将数字部分转换为整数

        // 处理表达式部分,可能跨越多行
        strcpy(buf, ""); // 清空buf
        while ((token = strtok(NULL, "\n")) != NULL) {
            strcat(buf, token);
            strcat(buf, " "); // 拼接换行后的部分,注意添加空格以分隔多行内容
        }

        // 输出结果
        printf("Real Value: %u, Expression: %s\n", real_val, buf);
        bool flag = false;
        unsigned res = expr(buf,&flag);
        if(res == real_val)right_ans ++;

    }
    printf("test 100 expressions,the accuracy is %d/100\n",right_ans);
    fclose(input_file);
    return 0;
}

然后进入nemu,输入test,发现很多表达式依旧发生报错(除数为0的现象),而且是我写的sdb里面发生了除数为0,但是当我把这个表达式放入devC++直接运算,并没有报错现象,经过一番探索,我尝试了如下代码:

#include <stdio.h>
int main() { 
  unsigned result = 1000/(-120);
  printf("%u", result); 
  return 0; 
}

结果是一个很大的数,但用我自己写的sdb的p命令来算,算出的是0,最终知道了:对于一个表达式在devC++计算的过程中,先是当成有符号数处理了,比如上面的算出来是-8,然后再转换成了无符号数,所以很大,但我写的eval的返回类型就是uint32_t,就会先把-120变成很大的数,然后相除后得0!

当把此返回类型改成int,仅在最后的expr函数中转成uint32_t之后:

进入nemu、输入test,成功!

  • 9
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值