Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 22.430 phút đọc

Máy tính biểu thức

Sau bài này bạn sẽ làm được

  • Hiểu độ ưu tiên và tính kết hợp của toán tử
  • Cài thuật toán shunting-yard đầy đủ
  • Tính giá trị một biểu thức hậu tố
  • Báo lỗi rõ ràng cho ngoặc lệch và chia cho không

Máy tính bỏ túi là bài tập ngăn xếp kinh điển vì nó cần đúng hai ngăn xếp: một cho toán tử lúc chuyển dạng, một cho toán hạng lúc tính. Cả hai thuật toán đều ngắn, và cái khó nằm ở độ ưu tiên với tính kết hợp.

#Ba dạng viết biểu thức

DạngVí dụCần ngoặc khôngMáy tính dễ không
Trung tố3 + 4 * 2CóKhó, phải biết độ ưu tiên
Hậu tố3 4 2 * +Không bao giờRất dễ, một ngăn xếp
Tiền tố+ 3 * 4 2Không bao giờDễ, đọc từ phải sang
Hậu tố
Còn gọi là ký pháp Ba Lan ngược. Toán tử viết sau các toán hạng của nó. Không cần ngoặc và không cần bảng độ ưu tiên, vì thứ tự tính đã nằm sẵn trong thứ tự các ký hiệu.
Trung tố                          Hậu tố
3 + 4 * 2                         3 4 2 * +
(3 + 4) * 2                       3 4 + 2 *
3 + 4 * 2 / (1 - 5) ^ 2           3 4 2 * 1 5 - 2 ^ / +
2 ^ 3 ^ 2                         2 3 2 ^ ^        (kết hợp phải: 2^(3^2) = 512)
(2 ^ 3) ^ 2                       2 3 ^ 2 ^        (= 64)

#Tính biểu thức hậu tố

  1. Gặp một số thì đẩy vào ngăn xếp

    Ngăn xếp lúc này chứa toán hạng, không chứa toán tử.

  2. Gặp toán tử hai ngôi thì lấy ra hai số

    Số lấy ra trước là toán hạng bên phải, vì nó vào sau. Đây là chỗ dễ sai nhất, và nó chỉ lộ ra với phép trừ và phép chia.

  3. Đẩy kết quả trở lại ngăn xếp

    Kết quả trở thành toán hạng cho phép tính tiếp theo.

  4. Hết chuỗi thì ngăn xếp phải còn đúng một số

    Còn nhiều hơn một nghĩa là thiếu toán tử. Rỗng nghĩa là biểu thức rỗng. Cả hai đều là lỗi cú pháp.

hau-to.c
#include <math.h>
#include <stdio.h>
#include <string.h>

#include "stack-so.h"      /* ngăn xếp double, cùng mẫu Bài 22.1 */

typedef enum {
    TINH_OK,
    TINH_THIEU_TOAN_HANG,
    TINH_THUA_TOAN_HANG,
    TINH_CHIA_KHONG,
    TINH_KY_HIEU_LA
} MaTinh;

/* Tính giá trị biểu thức hậu tố, các ký hiệu cách nhau bằng khoảng trắng.
   Kết quả ghi vào *ra khi trả về TINH_OK. */
MaTinh tinh_hau_to(const char *bt, double *ra)
{
    StackSo st;

    stack_khoi_tao(&st);

    const char *p = bt;

    while (*p != '\0') {
        while (*p == ' ' || *p == '\t') ++p;

        if (*p == '\0') break;

        /* Số: bắt đầu bằng chữ số hoặc dấu chấm */
        if ((*p >= '0' && *p <= '9') || *p == '.') {
            char  *het;
            double v = strtod(p, &het);

            if (het == p) return TINH_KY_HIEU_LA;

            if (stack_push(&st, v) != 0) return TINH_THUA_TOAN_HANG;

            p = het;

            continue;
        }

        /* Toán tử hai ngôi */
        char op = *p++;
        double a, b;

        if (stack_pop(&st, &b) != 0) return TINH_THIEU_TOAN_HANG;   /* PHẢI */
        if (stack_pop(&st, &a) != 0) return TINH_THIEU_TOAN_HANG;   /* TRÁI */

        double kq;

        switch (op) {
            case '+': kq = a + b; break;
            case '-': kq = a - b; break;
            case '*': kq = a * b; break;
            case '/':
                if (b == 0.0) return TINH_CHIA_KHONG;
                kq = a / b;
                break;
            case '^': kq = pow(a, b); break;
            default:  return TINH_KY_HIEU_LA;
        }

        if (stack_push(&st, kq) != 0) return TINH_THUA_TOAN_HANG;
    }

    if (stack_pop(&st, ra) != 0)  return TINH_THIEU_TOAN_HANG;
    if (!stack_rong(&st))         return TINH_THUA_TOAN_HANG;

    return TINH_OK;
}

#Shunting-yard: trung tố sang hậu tố

Thuật toán do Edsger Dijkstra đặt tên năm 1961, theo hình ảnh đường ray rẽ ở nhà ga xe lửa. Nó đọc biểu thức từ trái sang phải một lần duy nhất, dùng một ngăn xếp chứa toán tử đang chờ.

Toán tửĐộ ưu tiênKết hợp
^4Phải
* / %3Trái
+ -2Trái
shunting.c
#include <stdio.h>
#include <string.h>

#include "stack-char.h"

static int uu_tien(char op)
{
    switch (op) {
        case '^':            return 4;
        case '*': case '/':
        case '%':            return 3;
        case '+': case '-':  return 2;
        default:             return 0;
    }
}

static int ket_hop_phai(char op)
{
    return op == '^';
}

typedef enum { CH_OK, CH_NGOAC_LECH, CH_KY_HIEU_LA, CH_TRAN } MaChuyen;

/* Chuyển trung tố sang hậu tố. Ghi kết quả vào ra, tối đa co byte. */
MaChuyen sang_hau_to(const char *bt, char *ra, size_t co)
{
    StackChar st;
    size_t    k = 0;

    stack_khoi_tao(&st);

    for (const char *p = bt; *p != '\0'; ++p) {
        if (*p == ' ' || *p == '\t') continue;

        /* Số: chép nguyên cả cụm chữ số ra đầu ra */
        if ((*p >= '0' && *p <= '9') || *p == '.') {
            while ((*p >= '0' && *p <= '9') || *p == '.') {
                if (k + 2 >= co) return CH_TRAN;

                ra[k++] = *p++;
            }

            --p;                        /* bù cho ++p của vòng for */
            ra[k++] = ' ';

            continue;
        }

        if (*p == '(') {
            if (stack_push(&st, '(') != 0) return CH_TRAN;

            continue;
        }

        if (*p == ')') {
            int dinh;

            for (;;) {
                if (stack_pop(&st, &dinh) != 0) return CH_NGOAC_LECH;

                if ((char)dinh == '(') break;

                if (k + 2 >= co) return CH_TRAN;

                ra[k++] = (char)dinh;
                ra[k++] = ' ';
            }

            continue;
        }

        if (uu_tien(*p) == 0) return CH_KY_HIEU_LA;

        /* Toán tử: đẩy ra hết những toán tử đang chờ mà phải tính trước */
        int dinh;

        while (stack_peek(&st, &dinh) == 0 && (char)dinh != '(') {
            int u_dinh = uu_tien((char)dinh);
            int u_moi  = uu_tien(*p);

            int phai_lay = ket_hop_phai(*p)
                         ? (u_dinh >  u_moi)      /* kết hợp phải: chỉ lớn hơn */
                         : (u_dinh >= u_moi);     /* kết hợp trái: lớn hơn hoặc bằng */

            if (!phai_lay) break;

            stack_pop(&st, &dinh);

            if (k + 2 >= co) return CH_TRAN;

            ra[k++] = (char)dinh;
            ra[k++] = ' ';
        }

        if (stack_push(&st, *p) != 0) return CH_TRAN;
    }

    /* Đổ nốt ngăn xếp ra */
    int dinh;

    while (stack_pop(&st, &dinh) == 0) {
        if ((char)dinh == '(') return CH_NGOAC_LECH;

        if (k + 2 >= co) return CH_TRAN;

        ra[k++] = (char)dinh;
        ra[k++] = ' ';
    }

    if (k > 0 && ra[k - 1] == ' ') --k;      /* bỏ khoảng trắng thừa cuối */

    ra[k] = '\0';

    return CH_OK;
}

#Vết đầy đủ của một biểu thức

Theo dõi từng bước với biểu thức 3 + 4 * 2 / ( 1 - 5 ) ^ 2. Cột ngăn xếp ghi từ đáy lên đỉnh.

ĐọcNgăn xếp toán tửĐầu ra hậu tố
33
++3
4+3 4
*+ *3 4
2+ *3 4 2
/+ /3 4 2 *
(+ / (3 4 2 *
1+ / (3 4 2 * 1
-+ / ( -3 4 2 * 1
5+ / ( -3 4 2 * 1 5
)+ /3 4 2 * 1 5 -
^+ / ^3 4 2 * 1 5 -
2+ / ^3 4 2 * 1 5 - 2
hết3 4 2 * 1 5 - 2 ^ / +

Rồi tính hậu tố

ĐọcNgăn xếp toán hạngPhép tính
33
43 4
23 4 2
*3 84 * 2 = 8
13 8 1
53 8 1 5
-3 8 -41 - 5 = -4
23 8 -4 2
^3 8 16(-4) ^ 2 = 16
/3 0.58 / 16 = 0.5
+3.53 + 0.5 = 3.5
terminal
gcc -std=c17 -Wall -Wextra -g may-tinh.c shunting.c hau-to.c stack-so.c stack-char.c -o mt -lm
./mt '3 + 4 * 2 / ( 1 - 5 ) ^ 2'
hau to : 3 4 2 * 1 5 - 2 ^ /  +
ket qua: 3.5
./mt '( 3 + 4 ) * 2'
hau to : 3 4 + 2 *
ket qua: 14
./mt '100 / 5 / 2'
hau to : 100 5 / 2 /
ket qua: 10

#Dấu âm một ngôi và hàm

Dấu trừ có hai nghĩa hoàn toàn khác nhau: a - b là phép trừ hai ngôi, còn -a là dấu âm một ngôi. Phân biệt chúng chỉ dựa vào ký hiệu đứng ngay trước.

Nhận diện dấu âm một ngôi
/* Dấu trừ là MỘT NGÔI khi nó đứng ở một trong ba chỗ:
     - đầu biểu thức
     - ngay sau một toán tử khác
     - ngay sau một ngoặc mở                                 */
static int la_mot_ngoi(const char *bt, const char *p)
{
    const char *q = p - 1;

    while (q >= bt && (*q == ' ' || *q == '\t')) --q;

    if (q < bt)      return 1;      /* đầu biểu thức */
    if (*q == '(')   return 1;      /* sau ngoặc mở */
    if (uu_tien(*q)) return 1;      /* sau một toán tử */

    return 0;
}

/* Dùng ký hiệu nội bộ 'u' cho dấu âm một ngôi, độ ưu tiên 5, kết hợp phải.
   Ưu tiên cao hơn ^ nên -2 ^ 2 thành (-2) ^ 2 = 4.
   Nếu muốn theo quy ước toán học -(2 ^ 2) = -4 thì đặt ưu tiên 3.5,
   tức thấp hơn ^ nhưng cao hơn *.                                     */

Hàm một tham số

Xử lý sin, cos, sqrt
/* Hàm được đối xử như toán tử một ngôi độ ưu tiên cao nhất.
   Khi gặp tên hàm thì đẩy một mã riêng vào ngăn xếp toán tử.
   Khi gặp ngoặc đóng và đỉnh là một hàm thì lấy nó ra luôn.   */

typedef enum {
    HAM_SIN = 128, HAM_COS, HAM_TAN,
    HAM_SQRT, HAM_LOG, HAM_LN, HAM_ABS
} MaHam;

static int doc_ham(const char **p)
{
    static const struct { const char *ten; int ma; } bang[] = {
        { "sqrt", HAM_SQRT }, { "sin", HAM_SIN }, { "cos", HAM_COS },
        { "tan",  HAM_TAN  }, { "log", HAM_LOG }, { "ln",  HAM_LN  },
        { "abs",  HAM_ABS  },
    };

    for (size_t i = 0; i < sizeof bang / sizeof bang[0]; ++i) {
        size_t n = strlen(bang[i].ten);

        if (strncmp(*p, bang[i].ten, n) == 0) {
            *p += n;

            return bang[i].ma;
        }
    }

    return 0;
}

#Báo lỗi cho ra hồn

Một máy tính chỉ in loi thì gần như vô dụng. Người dùng cần biết lỗi gì và ở đâu.

loi.c
typedef struct {
    MaChuyen ma;
    size_t   vi_tri;      /* chỉ số ký tự gây lỗi */
    char     chi_tiet[64];
} Loi;

static const char *mo_ta(MaChuyen m)
{
    switch (m) {
        case CH_OK:          return "khong loi";
        case CH_NGOAC_LECH:  return "ngoac khong khop";
        case CH_KY_HIEU_LA:  return "ky hieu khong hieu duoc";
        case CH_TRAN:        return "bieu thuc qua dai";
        default:             return "loi khong ro";
    }
}

void in_loi(const char *bt, const Loi *l)
{
    fprintf(stderr, "loi: %s\n", mo_ta(l->ma));
    fprintf(stderr, "  %s\n", bt);
    fprintf(stderr, "  %*s^\n", (int)l->vi_tri, "");

    if (l->chi_tiet[0] != '\0')
        fprintf(stderr, "  %s\n", l->chi_tiet);
}
terminal
./mt '3 + * 4'
loi: thieu toan hang
  3 + * 4
      ^
  toan tu '*' can hai toan hang, chi co mot
./mt '( 3 + 4'
loi: ngoac khong khop
  ( 3 + 4
        ^
  con 1 ngoac mo chua dong
./mt '3 + 4)'
loi: ngoac khong khop
  3 + 4)
       ^
  ngoac dong thua
./mt '10 / 0'
loi: chia cho khong
  10 / 0
       ^
./mt '3 @ 4'
loi: ky hieu khong hieu duoc
  3 @ 4
    ^
  ky tu '@' khong phai toan tu

Biến và chế độ hiện bước

Mở rộng cuối cùng
/* Bảng biến đơn giản, tìm tuyến tính là đủ với vài chục biến. */
typedef struct { char ten[16]; double gia_tri; } Bien;

typedef struct { Bien d[64]; size_t n; } BangBien;

int bb_dat(BangBien *b, const char *ten, double v)
{
    for (size_t i = 0; i < b->n; ++i)
        if (strcmp(b->d[i].ten, ten) == 0) { b->d[i].gia_tri = v; return 0; }

    if (b->n == sizeof b->d / sizeof b->d[0]) return -1;

    snprintf(b->d[b->n].ten, sizeof b->d[b->n].ten, "%s", ten);
    b->d[b->n].gia_tri = v;
    ++b->n;

    return 0;
}

int bb_lay(const BangBien *b, const char *ten, double *ra)
{
    for (size_t i = 0; i < b->n; ++i)
        if (strcmp(b->d[i].ten, ten) == 0) { *ra = b->d[i].gia_tri; return 0; }

    return -1;
}
terminal
./mt -i
> x = 5
x = 5
> y = x * 2 + 1
y = 11
> sqrt ( x * x + y * y )
12.083
> -buoc
che do hien buoc: bat
> 2 + 3 * 4
hau to : 2 3 4 * +
  day 2      -> [2]
  day 3      -> [2, 3]
  day 4      -> [2, 3, 4]
  3 * 4 = 12 -> [2, 12]
  2 + 12 = 14 -> [14]
14
> -thoat

Tự làm thử

  1. Cài tinh_hau_to trước, thử với 8 5 - và 100 5 / 2 / để chắc thứ tự hai toán hạng đúng.
  2. Cài sang_hau_to, rồi kiểm với 2 ^ 3 ^ 2 phải ra 512 và 8 - 3 - 2 phải ra 3.
  3. In vết từng bước của thuật toán shunting-yard cho biểu thức trong bài và so với bảng.
  4. Thêm dấu âm một ngôi. Chọn quy ước cho -2 ^ 2, ghi vào tài liệu, rồi viết phép thử cho lựa chọn đó.
  5. Thêm bảy hàm và bảng biến, rồi chạy toàn bộ dưới -fsanitize=address,undefined.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Hậu tố không cần ngoặc và tính được bằng một ngăn xếp toán hạng trong một lượt.
  • Khi lấy hai toán hạng ra, cái ra trước là toán hạng phải. Lỗi này chỉ lộ với trừ, chia và lũy thừa.
  • Shunting-yard dùng một ngăn xếp toán tử. Kết hợp trái so bằng >=, kết hợp phải so bằng >.
  • Ngoặc mở chỉ bị lấy ra bởi ngoặc đóng tương ứng, không bao giờ bởi độ ưu tiên.
  • Báo lỗi phải nói được lỗi gì và ở vị trí nào. Chia cho không với số thực cho ra inf chứ không sập, nên phải kiểm tường minh.