Editorial for 遊戲選角 (APCS 2024-01 初級)


簡潔題意

有 \(n\) 位角色,第 \(i\) 位的攻擊力是 \(a_i\)、防禦力是 \(b_i\),「綜合能力」定義成 \(a_i^2 + b_i^2\)。請找出綜合能力第二高的角色,輸出他的攻擊力與防禦力(\(3 \le n \le 20\),攻擊力與防禦力都是 \(1\) 到 \(100\) 的整數,題目保證所有角色的綜合能力兩兩相異)

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

  • 子題組 1(\(60\) 分):\(n = 3\)。
  • 子題組 2(\(40\) 分):無額外限制。

綜合能力:這題唯一要算的東西

題目說得很直白,綜合能力就是 \(a^2 + b^2\)。動手之前先確認兩件事。

第一,會不會溢位? 攻擊力與防禦力最大都是 \(100\),所以綜合能力最大是 \(100^2 + 100^2 = 20000\)——離 int 的上限(大約 \(2.1 \times 10^9\))遠得很,全程用 int 完全安全。這種估算的方法在上冊 2.9

第二,\(0\) 可以拿來當「還沒有人」的記號。 攻擊力與防禦力最小都是 \(1\),所以綜合能力最小是 \(1^2 + 1^2 = 2\)——\(0\) 一定比任何角色都低,用它當初值很安全。(這個「初值要挑值域外的數」的判斷,等一下就派上用場。)

接下來介紹兩條路:一條只用到上冊前四單元(連陣列和函式都不用)就能走完,一條用了下冊的容器與排序、程式短了一半。兩條都是滿分解,看你學到哪裡。

做法一:邊讀邊打擂台,前四單元的知識就夠

上冊 3.9 的熱身教過「打擂台」找最大值:先讓第一個人上台當擂主,後面的人輪流來挑戰,誰大誰當新擂主。這題要的是第二名,所以擂台上留兩個位置:第一名第二名

一位新角色走上台時,只有三種情況:

新角色的綜合能力 該做什麼
比第一名高 原本的第一名退居第二,新角色當第一名
打不贏第一名,但比第二名高 直接取代第二名,第一名不動
連第二名都贏不了 什麼都不用做

中間那一步是這題的重點:第一名被換掉的時候,舊的第一名不是消失,而是退到第二名——他還是全場第二強的人。漏掉這件事,程式在很多情況下看起來還是對的(下面的自測表就是為了逼出它)。

擂台上總共只有兩個位置,每個位置要記三件事(綜合能力、攻擊力、防禦力),六個變數就裝得下——這題不必用到陣列,也不必自己寫函式:

  • best1a1b1:目前第一名的綜合能力、攻擊力、防禦力
  • best2a2b2:目前第二名的

初值一定要寫,而且要用前面說的 \(0\):擂台一開始沒有人,綜合能力要先放一個「比誰都低」的記號,任何角色一上台都贏得了它。變數宣告了卻沒給初值就拿去比大小,裡面裝的是什麼完全沒有保證(上冊 2.5 有整段在講這件事);開了 -Wall 的編譯器還會直接對你說 best1 may be used uninitialized——編譯器都開口了就要處理,別賭它剛好是 \(0\)。

完整程式碼:

#include <iostream>
using namespace std;

int main() {
    int n;
    cin >> n;

    // 擂台上兩個位置,各記三件事:綜合能力、攻擊力、防禦力
    int best1 = 0, a1 = 0, b1 = 0;   // 目前的第一名
    int best2 = 0, a2 = 0, b2 = 0;   // 目前的第二名

    for (int i = 0; i < n; i++) {
        int a, b;
        cin >> a >> b;
        int v = a * a + b * b;   // 這位角色的綜合能力
        if (v > best1) {         // 比第一名還強:第一名退居第二,新人上台
            best2 = best1;
            a2 = a1;
            b2 = b1;
            best1 = v;
            a1 = a;
            b1 = b;
        } else if (v > best2) {  // 打不贏第一名,但贏得了第二名
            best2 = v;
            a2 = a;
            b2 = b;
        }
    }

    cout << a2 << ' ' << b2 << endl;
    return 0;
}

推位那三行有順序:一定要先把第一名搬去第二名,再讓新角色坐上第一名。順序反過來寫(先 best1 = v;best2 = best1;),搬過去的就變成新角色自己,第一名和第二名會一模一樣。

第二個分支要寫 else if 不能寫 if:一位角色只佔一個位置,已經當上第一名的人不該再被拿去跟第二名比一次。

題目保證 \(n \ge 3\),至少三個人上過台,第二名的位置一定坐得滿;也保證綜合能力兩兩相異,所以「第二高」不會有兩個人來搶。

做法二:全部存起來排序,程式短一半

打擂台要小心,是因為你得一邊讀一邊維護名次。換個想法:如果先把所有人存起來、照綜合能力排好,「第二高」就只是「第 \(2\) 格」而已——下冊 11.1 的表格早就寫過,找第 \(k\) 大這件事,排好之後直接拿。

麻煩的是「一位角色」有三個數字要一起搬(綜合能力、攻擊力、防禦力),排序的時候不能拆散。下冊 10.12array 正是為這件事準備的:vector<array<int, 3>> 就是一個「每一格裝三個數字」的 vector(10.2)。

關鍵在三個數字誰排前面array 跟 vector 一樣可以直接比大小,規則就是 10.6 的字典序:先比第 \(0\) 格,一樣才比第 \(1\) 格。所以只要把「綜合能力」放進第 \(0\) 格,排序就自動變成「照綜合能力排」,一行自訂比較函式都不用寫。這跟 11.5vector<pair>「先比 first,再比 second」是同一件事:要照哪個值排,那個值就放最前面

剩下的交給 11.4sort 預設由小到大,第三個參數給 greater() 就變成由大到小。排完之後第 \(0\) 格是第一名,第 \(1\) 格就是我們要的第二名。

這一版的 a * a + b * b 藏在一行比較長的算式裡,順手寫個小函式給它一個名字會好讀很多(上冊 7.1),也不會不小心把某個 a * a 打成 a * b

int sqr(int v) { return v * v; }

完整程式碼:

#include <algorithm>
#include <array>
#include <iostream>
#include <vector>
using namespace std;

int sqr(int v) { return v * v; }

int main() {
    int n;
    cin >> n;

    vector<array<int, 3>> player(n);   // 每位角色記 {綜合能力, 攻擊力, 防禦力}
    for (int i = 0; i < n; i++) {
        for (int j = 1; j <= 2; j++) cin >> player[i][j];
        player[i][0] = sqr(player[i][1]) + sqr(player[i][2]);
    }

    sort(player.begin(), player.end(), greater());   // 依綜合能力由大到小
    cout << player[1][1] << ' ' << player[1][2] << endl;
    return 0;
}

讀入的小迴圈 for (int j = 1; j <= 2; j++) cin >> player[i][j]; 把攻擊力與防禦力讀進第 \(1\)、\(2\) 格,第 \(0\) 格留給下一行算出來的綜合能力。

兩種做法在這題都拿滿分(\(n \le 20\),快慢完全不是問題)。真正的差別是腦袋裡要顧幾件事:做法一得自己想清楚三種情況、還要記得推位,但只用到前四單元;做法二把「維護名次」整包外包給 sort,程式只剩下「算出來、排好、拿第 \(1\) 格」三句話。學過下冊就用做法二,還沒學到的話做法一一樣是滿分。

測過再交:兩個範例都沒讓第一名最後才出現

先盤點範例蓋到了什麼:範例 1 的第一名在第 \(2\) 筆、第二名在第 \(3\) 筆;範例 2 的第一名在第 \(2\) 筆、第二名在第 \(1\) 筆。兩個範例的第一名都不是最後一筆——而「第一名最後才出現」正好會逼做法一走那個最容易漏掉的推位動作。

另外,範例 1 是 \(n = 3\),這個大小自帶一個陷阱:三個數字的「第二大」剛好就是「第二小」,所以排序方向寫反了,範例 1 照樣會過。

自己補這幾筆再交:

想測什麼 輸入 正確輸出
第一名最後一筆才出現(舊的第一名要退居第二) 3 / 1 1 / 2 2 / 3 3 2 2
第一名第一筆就定案(後面的人只能爭第二) 3 / 5 5 / 3 3 / 1 1 3 3
值域兩端:最小的 1 1 與最大的 100 100 3 / 100 100 / 1 1 / 3 4 3 4
\(n\) 到上限 \(20\) 20 之後接 1 12 1、……、20 1 19 1

第一列會把「忘記把舊的第一名推到第二名」當場抓出來(那種寫法在這筆會印出 0 0,等於把沒人坐過的初值直接印給你看);第四列會抓出「排序方向寫反」,它會印出第二2 1。這四筆都親眼看過正確,這題就穩了。

常犯錯誤
  1. 排序方向寫反,分數剛好停在 \(60\) 分:做法二忘了加 greater()player[1] 就變成第二的角色。子題組 1 全是 \(n = 3\) 會整包答對、範例 1 也會過,子題組 2 全掛。範例 2 會印出 4 5

  2. 忘記把舊的第一名推到第二名:做法一裡第一名被換掉時只更新了 best1a1b1,漏掉搬去第二名的那三行。範例 1 剛好躲得過去,範例 2 會印出 6 6;自測表第 \(1\) 列會印出 0 0(第二名的位置從頭到尾沒人坐過)。

  3. 擂台的變數沒有初始化int best1, a1, b1; 就直接拿去比大小,-Wall 當場警告;自己測「剛好是對的」跟「保證是對的」是兩回事。

  4. 綜合能力算成 \(a + b\):題目寫的是攻擊力與防禦力的平方和。範例 1 會印出 3 2、範例 2 會印出 6 6

  5. 印出第一名:做法一印成 a1b1、做法二印成 player[0]。範例 1 會印出 5 2、範例 2 會印出 5 8

  6. 做法二把綜合能力放在最後一格:存成 {攻擊力, 防禦力, 綜合能力} 再排序,字典序就變成「先比攻擊力」。要照哪個值排序,那個值就得放第 \(0\) 格。