函數運算式求值 題解|APCS 2019 年 2 月中高級 第 3 題


前置知識

這題的核心(前序記法怎麼「讀一個 token 就決定接下來要再讀幾個運算式」)下面會從頭講;要先會的是:

簡潔題意

輸入一行 token(以一個空白隔開),是三個函數 \(f(x) = 2x - 3\)、\(g(x, y) = 2x + y - 7\)、\(h(x, y, z) = 3x - 2y + z\) 用前序記法寫成的運算式:先寫函數名稱、再依序寫它的每個參數,參數本身也可以是運算式;h f 5 g 3 4 3 就是 \(h(f(5), g(3, 4), 3) = 18\)。輸出整個運算式的值(token 數 \(1\) 到 \(500\),每個整數在 \(-1000\) 到 \(1000\) 之間,運算過程中每個函數值和最後答案的絕對值都不超過 \(10^9\))。

依正確通過的測試資料筆數給分,其中:

  • 子題組 1(\(10\) 分):只會出現 f,整數都在 \(0\) 到 \(9\)。
  • 子題組 2(\(40\) 分):整數都在 \(0\) 到 \(9\)。
  • 子題組 3(\(50\) 分):無額外限制。

先拿下子題組 1:數有幾個 f

只有 f 的運算式長得像 f f f 2:一串 f 包著一個整數。從裡往外一層一層套 \(f\) 就好——數有幾個 f、把最後那個整數讀出來,套那麼多次 \(2x - 3\)。

#include <bits/stdc++.h>
using namespace std;

int main() {
    string token;
    int countF = 0;
    long long value = 0;
    while (cin >> token) {               // 一個一個 token 讀到沒有為止
        if (token == "f") countF++;      // 數有幾層 f
        else value = stoll(token);       // 最後一個 token 是那個整數
    }
    for (int i = 0; i < countF; i++) value = 2 * value - 3;   // 由內往外套 countF 次
    cout << value << endl;
    return 0;
}

範例 1 f f f 2:\(2 \to 1 \to -1 \to -5\)。這個版本遇到 g、h 會把它當整數轉換而直接當掉(範例 2 就會),子題組 1 的 \(10\) 分穩穩到手。

從 10 分到 100 分:讀到函數名稱就遞迴讀它的參數

前序記法的好處是不用括號也能唯一決定結構:讀到 f 就知道後面緊接著恰好一個運算式是它的參數;讀到 g 後面緊接著兩個、h 三個;讀到整數就是一個完整的運算式。「讀入下一個完整的運算式並回傳它的值」寫成函式 eval():

  • 讀一個 token。
  • 是 f:呼叫一次 eval() 拿到 \(x\),回傳 \(2x - 3\)。
  • 是 g:呼叫兩次拿到 \(x\)、\(y\),回傳 \(2x + y - 7\)。
  • 是 h:呼叫三次拿到 \(x\)、\(y\)、\(z\),回傳 \(3x - 2y + z\)。
  • 都不是:它就是一個整數,stoll 之後回傳。

每次呼叫 eval() 都會把「屬於這個運算式的 token」剛好讀完,所以下一次呼叫自然從下一個參數開始讀——不用自己記位置。範例 2 的呼叫過程見圖 1。

圖 1:h f 5 g 3 4 3 的運算式樹,h 的三個參數分別是 f(5)=7、g(3,4)=3、3,所以 h(7,3,3)=18
#include <bits/stdc++.h>
using namespace std;

long long eval() {                   // 讀入「下一個完整的運算式」,回傳它的值
    string token;
    cin >> token;
    if (token == "f") {
        long long x = eval();        // f 後面接一個運算式
        return 2 * x - 3;
    }
    if (token == "g") {
        long long x = eval();        // g 後面接兩個運算式,先讀到的是 x
        long long y = eval();
        return 2 * x + y - 7;
    }
    if (token == "h") {
        long long x = eval();        // h 後面接三個運算式
        long long y = eval();
        long long z = eval();
        return 3 * x - 2 * y + z;
    }
    return stoll(token);             // 不是函數名稱,就是一個整數(stoll 自己會處理負號)
}

int main() {
    cout << eval() << endl;
    return 0;
}

幾個細節:

  • token 用 string 讀再用 stoll 轉,負號和多位數(-1000)自然都對;子題組 2 和 3 的差別只在整數的範圍,這份程式一次拿下 \(90\) 分。
  • 題目保證每個函數值不超過 \(10^9\),但算 \(h\) 的時候會先算 \(3x\),\(x\) 接近 \(10^9\) 時 \(3x\) 可到 \(3 \times 10^9\),超過 int 的 \(2147483647\)(估算方法見語法書 2.9)——中間值一律用 long long。
  • 每個 token 剛好被讀一次,\(O(T)\);遞迴最深 \(500\) 層(f f f ... 3),完全不是問題。

測過再交:範例沒有負數和多位數

範例 1 是一串 f、範例 2 三種函數各一個,但整數全是一位數、沒有負數,而且 h 的後兩個參數剛好都是 \(3\)——把 \(y\)、\(z\) 拿反也看不出來。補的是:單一個整數、負的多位數、g 裡面包 h、h 三個參數都不一樣、最深的遞迴。

輸入 正確輸出 這一筆在測什麼
7 7 只有一個整數、沒有任何函數
f -1000 -2003 負的多位數:\(2 \times (-1000) - 3\)
g f 3 h 1 2 3 1 函數包在第二個參數裡:\(g(3, 2) = 6 + 2 - 7\)
h 1 2 3 2 三個參數都不同:\(3 - 4 + 3\);\(y\)、\(z\) 拿反會印 -1
用程式造:\(499\) 個 f 後面接一個 3(共 \(500\) 個 token) 3 最深的遞迴:\(f(3) = 3\),套幾次都是 \(3\)

五筆都親眼看過正確,這題就穩了。

另一種做法:從後面往前用堆疊

不遞迴也行:先把所有 token 存進陣列,從最後一個往前看。整數就推進堆疊;遇到 f 就從堆疊頂端拿一個數當參數、算完推回去;g 拿兩個、h 拿三個。因為是倒著看,離函數名稱最近的參數會在堆疊最上面,所以先拿到的是 \(x\)、再來是 \(y\)、\(z\)——順序剛好對。整個運算式看完,堆疊裡只剩一個數就是答案。《AP325》第 3 章的習題 Q-3-3 加減乘除 是同一招。

#include <bits/stdc++.h>
using namespace std;

int main() {
    vector<string> tokens;
    string token;
    while (cin >> token) tokens.push_back(token);
    vector<long long> values;                    // 當堆疊用:最後面是最上面
    for (int i = (int)tokens.size() - 1; i >= 0; i--) {   // 從最後一個 token 往前看
        if (tokens[i] == "f") {
            long long x = values.back();         // 最上面的就是它的參數
            values.pop_back();
            values.push_back(2 * x - 3);
        } else if (tokens[i] == "g") {
            long long x = values.back();         // 先拿到的是 x(它離 g 比較近)
            values.pop_back();
            long long y = values.back();
            values.pop_back();
            values.push_back(2 * x + y - 7);
        } else if (tokens[i] == "h") {
            long long x = values.back();
            values.pop_back();
            long long y = values.back();
            values.pop_back();
            long long z = values.back();
            values.pop_back();
            values.push_back(3 * x - 2 * y + z);
        } else {
            values.push_back(stoll(tokens[i]));  // 整數:先放著,等函數來拿
        }
    }
    cout << values.back() << endl;               // 最後堆疊裡只剩整個運算式的值
    return 0;
}

兩種做法都是滿分;遞迴版照著題目的定義寫、程式最短,堆疊版是「運算式求值」這類題的標準套路,之後遇到中序、後序的題目都用得上。

常犯錯誤
  • 自己一個字元一個字元把 token 轉成整數、沒處理負號:兩個範例都過(整數都是正的一位數),本站計分測資掛 \(8\) 筆;自測表第 \(2\) 列印 -58003。
  • 用 char 一次讀一個字元當 token:-1000 會被拆成五個 token——兩個範例都過,計分測資掛 \(9\) 筆(大多直接當掉);自測表第 \(2\) 列現形。
  • h 的 \(y\)、\(z\) 拿反(算成 \(3x - 2z + y\)):範例 2 的 \(y = z = 3\) 看不出來,計分測資掛 \(10\) 筆;自測表第 \(3\) 列印 -2、第 \(4\) 列印 -1。
  • 寫成 return 2 * eval() + eval() - 7;:C++ 沒有規定加號兩邊哪個先算,本站的 g++ 剛好由左到右、答案是對的,但換個編譯器就可能把 \(x\)、\(y\) 對調——兩個參數分開存進變數再算,零風險。
  • 子題組 1 的「數 f」版直接交:遇到 g、h 就當掉,只有子題組 1 的 \(2\) 筆和另外 \(3\) 筆碰巧只有 f 的測資會過。