Day 068-Q4 — 再帰下降構文解析(Recursive Descent Parser・式木・変数評価)

2026-06-21 赤色 Master / Phase 8+ ★★★★★★★★★ 再帰下降・演算子優先度・右結合べき乗

問題

変数と四則演算・べき乗・括弧を含む数式を構文解析し、変数に値を代入して評価せよ。

文法(EBNF):

expr   ::= term (('+' | '-') term)*
term   ::= power (('*' | '/') power)*
power  ::= unary ('^' unary)*   ← 右結合
unary  ::= '-' unary | factor
factor ::= '(' expr ')' | NUMBER | VARIABLE

制約

パラメータ範囲
$Q$$1 \le Q \le 100$
数式長$\le 200$
変数小文字アルファベット1文字
変数値$-100 \le \text{val} \le 100$
べき乗の指数非負整数
除算整数除算(0除算は発生しない)

入出力例

入力例 1

2
(a+b)*(a-b)
2
a 3
b 2
x^(y+1)+z*2
3
x 2
y 3
z 5

出力例 1

5
26

(3+2)*(3-2) = 5*1 = 5。2^(3+1)+5*2 = 2^4+10 = 16+10 = 26。

概念図: 演算子優先度階層と呼び出しグラフ

優先度(低→高) parse_expr: +, - parse_term: *, / parse_power: ^ (右結合) parse_unary: 単項 - parse_factor: 数値・変数・() 例: a^b^c の構文木(右結合) ^ a ^ b c a^(b^c): 右結合 = parse_power が再帰呼び出し 左結合ならwhileループ、右結合なら再帰(自己呼び出し)

ヒント(段階的開示)

ヒント1: 方向性
再帰下降構文解析(Recursive Descent Parser)は、BNF文法の各非終端記号に対応する関数を再帰的に実装する手法。関数の呼び出し階層がそのまま演算子優先度を表現する。
ヒント2: アプローチ
  • pos をカーソルとして文字列を左から右へ消費
  • 各関数は対応する部分を消費して値を返す
  • 左結合(+,-,*,/)は while ループで処理
  • 右結合(^)は再帰呼び出しで処理
  • 変数は辞書で管理し parse_factor で参照
ヒント3: コード骨格
def parse_power(self):
    """右結合: a^b^c = a^(b^c)"""
    base = self.parse_unary()
    if self.peek() == '^':
        self.consume('^')
        exp = self.parse_power()  # ← 再帰で右結合
        return int(base ** exp)
    return base

def parse_expr(self):
    """左結合: whileループ"""
    val = self.parse_term()
    while self.peek() in ('+', '-'):
        op = self.consume()
        r = self.parse_term()
        val = val + r if op == '+' else val - r
    return val

模範解答 (Python)

import sys
input = sys.stdin.readline

class Parser:
    def __init__(self, s, var):
        self.s = s.replace(' ', '')
        self.pos = 0
        self.var = var

    def peek(self):
        if self.pos < len(self.s):
            return self.s[self.pos]
        return ''

    def consume(self, c=None):
        ch = self.s[self.pos]
        if c is not None:
            assert ch == c
        self.pos += 1
        return ch

    def parse_expr(self):
        val = self.parse_term()
        while self.peek() in ('+', '-'):
            op = self.consume()
            r = self.parse_term()
            val = val + r if op == '+' else val - r
        return val

    def parse_term(self):
        val = self.parse_power()
        while self.peek() in ('*', '/'):
            op = self.consume()
            r = self.parse_power()
            val = val * r if op == '*' else int(val / r)
        return val

    def parse_power(self):
        base = self.parse_unary()
        if self.peek() == '^':
            self.consume('^')
            exp = self.parse_power()  # 右結合
            return int(base ** exp)
        return base

    def parse_unary(self):
        if self.peek() == '-':
            self.consume('-')
            return -self.parse_unary()
        return self.parse_factor()

    def parse_factor(self):
        ch = self.peek()
        if ch == '(':
            self.consume('(')
            val = self.parse_expr()
            self.consume(')')
            return val
        elif ch.isdigit():
            num = 0
            while self.peek().isdigit():
                num = num * 10 + int(self.consume())
            return num
        elif ch.islower():
            name = self.consume()
            return self.var[name]
        else:
            raise ValueError(f"Unexpected: {ch!r}")

def main():
    Q = int(input())
    out = []
    for _ in range(Q):
        expr = input().strip()
        m = int(input())
        var = {}
        for _ in range(m):
            v, val = input().split()
            var[v] = int(val)
        parser = Parser(expr, var)
        out.append(str(parser.parse_expr()))
    print('\n'.join(out))

main()

Step-by-Step 解説

Step 1: 優先度階層の設計

関数処理する演算子結合性
parse_expr+, -左結合(while ループ)
parse_term*, /左結合(while ループ)
parse_power^右結合(再帰呼び出し)
parse_unary単項 -右結合(再帰呼び出し)
parse_factor(), NUMBER, VAR

Step 2: 右結合べき乗

a^b^ca^(b^c) と解釈される(右結合)。parse_power 内で ^ を見つけたら右辺を parse_power() で再帰的に解析することで自動的に右結合になる。左結合の +* は while ループで処理する点と対比して理解する。

Step 3: 整数除算の注意点

Python の // は切り捨て除算で、負数の場合に挙動が異なる(-7 // 2 = -4)。ゼロ方向への切り捨てが必要な場合は int(a/b) を使う。

Step 4: AST 拡張

今回は値を直接返す評価器だが、Node クラスを使って抽象構文木(AST)を構築し、後から複数回評価・最適化・シンボリック微分などに活用できる。

Step 5: 計算量

処理計算量
1式の解析$O(L)$($L$ = 数式長)
全クエリ$O(Q \cdot L)$

よくあるミス

ミス原因正しい書き方
演算子優先度の逆転高優先度を外側の関数で処理低優先度を外側、高優先度を内側に
右結合を左結合で実装^ に while ループを使うparse_power で再帰呼び出し
単項マイナスの扱い-a*b を -(a*b) と解釈してしまうunary 層で単項 - を処理
空白の処理忘れスペースが入ると pos がずれる事前に replace(' ', '') で除去

次のステップ

発展問題: 数式のシンボリック微分(式木をASTとして構築し、微分規則を適用)。Pratt Parser(Top-Down Operator Precedence)による汎用実装。

自己評価