Ký pháp nghịch đảo Ba Lan

💡
Ký pháp nghịch đảo Ba Lan (Reversed Polish Notation), còn được gọi là ký pháp Łukasiewicz, hoặc đơn giản là ký pháp hậu tố, là một ký pháp toán học trong đó các toán tử đứng sau toán hạng của chúng. Điểm đặc biệt của ký pháp này là không cần bất kỳ dấu ngoặc đơn nào miễn là mỗi toán tử có số toán hạng cố định.

Nếu như một ngày nào đó bạn cần viết một phần mềm vẽ đồ thị một hàm số và hàm số ấy được nhập từ người dùng thông qua một input box, dạng chuỗi. Ví dụ, bạn phải viết một function kiểu như evaluate(‘3+4*(2-1)‘), và function này trả về giá trị của biểu thức bên trong là 7. Bạn sẽ phải đối mặt với vấn đề tính giá trị của một biểu thức là một chuỗi (string). Rõ ràng, bạn sẽ phải đối mặt với khá nhiều vấn đề nếu xử lý trực tiếp chuỗi nhập vào:

  • Xử lý thứ tự ưu tiên tính toán: nhân chia trước, cộng trừ sau

  • Xử lý thứ tự ưu tiên các dấu ngoặc

Ký pháp nghịch đảo Ba Lan sẽ là câu trả lời cho bạn.

Ký pháp nghịch đảo

Ký pháp nghịch đảo là một dạng biến đổi của cách biểu diễn biểu thức dạng thông thường hay dạng trung tố (infix notation) thành dạng hậu tố (postfix notation). Biểu thức dạng hậu tố có các tính chất:

  • Phép toán hay toán tử (operator) đứng sau toán hạng (operand)

  • Không có các dấu ngoặc ( )

Ví dụ: Biểu thức “1+2x(3-4) + 5” chuyển sang dạng hậu tố sẽ trở thành “1 2 3 4 - x + 5 +

Ở ví dụ trên, biểu thức sau khi biến đổi đã không còn dấu ngoặc và các toán tử đã bị đổi chỗ. Để tính toán biểu thức hậu tố, chúng ta đơn giản chỉ cần dùng một cấu trúc dữ liệu stack và thực hiện thuật toán đơn giản như sau:

Thuật toán 1: Tính giá trị biểu thức từ chuỗi hậu tố

  • Gọi s={} là một cấu trúc stack

  • Duyệt từ trái sang phải từng phần tử của biểu thức hậu tố

    • Nếu là toán hạng thì push vào stack

    • Nếu là toán tử op thì pop 2 phần tử từ stack ra: a=s.pop(), b=s.pop(). Sau đó, ta thực hiện phép tính x = b op a . Ví dụ, như biểu thức ở trên, khi duyệt lần lượt tới phần tử phép trừ “-”, ta pop từ stack ra 2 phần tử a=s.pop()=4, và b=s.pop()=3, rồi thực hiện phép tính x=b-a=3-4=-1. Rồi đưa x vào stack.

    • Return s[0]

Minh họa cho biểu thức “1 2 3 4 - x + 5 +“, duyệt từ trái sang phải:

  • Gặp các số 1,2,3,4 là các toán hạng nên đưa vào stack s={1,2,3,4}

  • Gặp dấu trừ “-”, s={1,2,3,4} => pop 2 phần tử: a=4, b=3, s={1,2} => tính: 3-4=-1, đưa vào stack s={1,2,-1}

  • Gặp dấu nhân “x“, s={1,2,-1} => pop 2 phần tử: a=-1, b=2, s={1} \=> tính: 2x-1=-2, đưa vào stack s={1,-2}

  • Gặp dấu cộng “+“, s={1,-2} => pop 2 phần tử a=-2, b=1 => tính: 1+-2=-1, đưa vào stack s={-1}

  • Gặp số 5, đưa vào stack s={-1,5}

  • Gặp dấu cộng “+“, s={-1,5} => pop 2 phần tử a=5, b=-1 => tính: -1+5=4, đưa vào stack s={4}

  • Trả về 4 là kết quả. Bạn có thể kiểm tra bằng cách tính lại biểu thức gốc “1+2x(3-4) + 5”.

Code minh họa

function evalPostfix(postfix) {
    const s = [];
    for (let i = 0; i < postfix.length; i++) {
        const token = postfix[i];
        if ('+-*/^'.indexOf(token) !== -1) {
            const a = s.pop() * 1; // convert to number
            const b = s.pop() * 1;
            switch (token) {
                case '+':
                    s.push(b + a);
                    break;
                case '-':
                    s.push(b - a);
                    break;
                case '*':
                    s.push(b * a);
                    break;
                case '/':
                    s.push(b / a);
                    break;
                case '^':
                    s.push(b ** a);
                    break;
            }            
        } else {
            s.push(token);
        }
    }
    return s.pop();
}

Câu hỏi tiếp theo là làm cách nào để chuyển đổi một biểu thức trung tố thành biểu thức hậu tố. Một lần nữa, cấu trúc dữ liệu stack lại được sử dụng cho thuật toán này. Trước tiên, chúng ta sẽ có một vài chi tiết nhỏ để đi vào thuật toán chính.

Thứ tự ưu tiên của các toán tử

Để đánh giá độ ưu tiên khác nhau chúng ta gán cho mỗi phép toán một số điểm. Theo quy ước, chúng ta set độ ưu tiên của phép lũy thừa “^” là cao nhất, với 3 điểm, nhân “*” và chia “/“ là 2 điểm, rồi đến cộng “+“ và trừ “-” 1 điểm.

Tokenization - Tách chuỗi nhập thành các token

Một chuỗi nhập vào phải được tách thành các thành phần đơn lẻ bao gồm: toán tử, toán hạng và dấu ngoặc. Quá trình này được gọi là tokenization, và mỗi thành phần được gọi là một token. Ví dụ: Một chuỗi: “1+2-11*(3-1)“ sẽ được tách thành một mảng [1,+,2,-,11,*,(,3,-,1,)]. Mình sẽ không xử lý quá chi tiết phần này, các bạn có thể đơn giản dùng một regular expression để làm thực hiện như sau:

function tokenize(expression) {
    // split the expression by operators and operands
    // filter out empty strings
    const tokens = expression.split(/([+\-*/()^])/).filter(token => token.trim() !== '');
    return tokens.map(token => token.trim());
}

Thuật toán 2: Chuyển đổi biểu thức trung tố sang hậu tố

  • Đầu vào là một array các tokens

  • Đặt stack s={}, postfix={}

  • Đưa token ‘(‘ vào stack s và thêm token ‘)’ vào tokens

  • Duyệt từng phần tử t của tokens:

    • Nếu t là dấu mở ngoặc ‘(‘, đưa vào stack s.push(t)

    • Nếu t là toán hạng, đưa vào postfix

    • Nếu t là toán tử, lần lượt gỡ tất cả các phần tử o=s.pop() của stack s và đưa vào postfix cho đến khi gặp một toán tử có độ ưu tiên cao hơn t hoặc gặp dấu ngoặc ‘(‘ thì ngừng và đưa t vào stack s.push(t)

    • Nếu t là dấu đóng ngoặc ‘)’, gỡ tất cả các phần tử của stack x=s.pop() và đưa vào postfix.push(x) cho đến khi gặp dấu mở ngoặc x==’(‘.

  • Return postfix

Code minh họa

function infix2Postfix(tokens) {
    const precedence = {
        '+': 1,
        '-': 1,
        '*': 2,
        '/': 2,
        '^': 3
    };

    const s = [];
    const postfix = [];

    // step 1: add '(' to the stack
    s.push('(');

    // step 2: add ')' to the end of the tokens
    tokens.push(')');

    for (let i = 0; i < tokens.length; i++) {
        const token = tokens[i];
        // if token is '(' push it to the stack
        if (token === '(') {
            s.push(token);
        } else if (token === ')') { // if token is ')' pop from the stack until '('
            while (s[s.length - 1] !== '(') {
                postfix.push(s.pop()); // add the popped token to the postfix
            }
            s.pop(); // remove '(' from the stack
        } else if (token in precedence) {
            // if the token is an operator
            // pop from the stack until the precedence of the token is greater than the precedence of the top of the stack
            while (precedence[s[s.length - 1]] >= precedence[token]) {
                postfix.push(s.pop()); // add the popped token to the postfix
            }
            // push the current token to the stack
            s.push(token);
        } else { // if the token is an operand
            // add the token to the postfix
            postfix.push(token);
        }
    }

    return postfix;
}

Demo