Editorial for 七言對聯 (APCS 2021-09 初級)
簡潔題意
每組對聯有兩句、每句 \(7\) 個字,每個字用 \(0\)(平聲)或 \(1\)(仄聲)表示。逐組檢查三條規則——A(每一句自己的第 \(2\)、\(4\) 字不同、第 \(2\)、\(6\) 字相同)、B(第一句尾字是 \(1\)、第二句尾字是 \(0\))、C(兩句的第 \(2\)、\(4\)、\(6\) 字上下互不相同)——每組輸出一行,依 A、B、C 順序把違反的代號連著印;都沒違反印 None。(\(1 \le n \le 30\))
依正確通過的測試資料筆數給分,其中:
- 子題組 1(\(50\) 分):\(n = 1\)(如範例一)。
- 子題組 2(\(50\) 分):無額外限制。
把三條規則翻成條件式
這一題不需要任何演算法。規則唸起來文謅謅(平仄、仄起平收、上下相對),但翻進 \(0\) 和 \(1\) 的世界之後,每一條就只是幾個「相等/不相等」的比較。它考的是兩件事:把規則讀懂,然後翻譯得精準。
先把讀入安排好。每組對聯就是連著兩行、各 \(7\) 個數字,開兩個陣列 int a[8], b[8],多開一格、從索引 \(1\) 用起(6.3):這樣 a[2] 就是「第一句的第 \(2\) 個字」,題目說「第二、四個字」你就寫 a[2]、a[4],完全不用在腦中做「減一」的換算——這種到處都在講「第幾個字」的題目,\(1\)-base 能少掉一整類 off-by-one 錯誤。
再來是本題最重要的一個技巧。三條規則都是用「必須怎樣」寫的,但題目要你找的是「違反」。如果直接動手寫「怎樣算違反」,你就得自己做邏輯反轉:「二四不同且二六同」的反面是「二四相同或二六不同」——不只每個比較要顛倒,連「且」都要變「或」(3.10 的笛摩根定律),一不小心就寫錯。更穩的做法是:照題目字面把「有遵守」寫成 bool,違反就是在前面加一個 !(3.3),反轉的工作交給編譯器。
三條規則逐條照字面翻:
- A. 二四不同二六同——注意主詞是「每一句」,所以兩句要各檢查一次,兩句都符合才算遵守:
bool okA1 = (a[2] != a[4] && a[2] == a[6]); // 第一句:二四不同、二六同
bool okA2 = (b[2] != b[4] && b[2] == b[6]); // 第二句也要檢查
bool okA = okA1 && okA2;
- B. 仄起平收——對照「平 \(= 0\)、仄 \(= 1\)」的字典:起(第一句)的結尾要是仄聲 \(1\)、收(第二句)的結尾要是平聲 \(0\):
bool okB = (a[7] == 1 && b[7] == 0);
- C. 上下相對——兩句的第 \(2\)、\(4\)、\(6\) 個字逐一比,三個位置都要不同:
bool okC = (a[2] != b[2] && a[4] != b[4] && a[6] != b[6]);
輸出的時候依 A、B、C 的順序,用 if(3.5)逐條檢查,違反的(!okA)就印出代號;而「要印 None」的條件也照字面直接翻——三條都遵守,就是 okA && okB && okC。這裡不需要任何額外的變數去記「印過了沒」,四個 if 各管各的:
if (!okA) cout << "A";
if (!okB) cout << "B";
if (!okC) cout << "C";
if (okA && okB && okC) cout << "None"; // 三條都遵守
cout << endl;
最後把「處理一組對聯」整包裝進一個函式。每一組要做的事完全一樣,正好寫成一個 void solve()(7.3):main 只負責讀入 \(n\)、用 for 迴圈(4.3)呼叫 \(n\) 次,「一組怎麼處理」則全部住在 solve 裡。這樣還有一個隱形的好處:a、b 和三個 ok 都是 solve 裡的區域變數,每次呼叫都是全新的一份——「上一組的結果殘留到下一組」這種多組測資的經典 bug,從結構上就不可能發生。完整程式:
#include <iostream>
using namespace std;
void solve() {
int a[8], b[8]; // 多開一格:用索引 1~7 對應「第幾個字」
for (int j = 1; j <= 7; j++) cin >> a[j];
for (int j = 1; j <= 7; j++) cin >> b[j];
// 照題目字面,把每條規則「有沒有遵守」寫成 bool
bool okA1 = (a[2] != a[4] && a[2] == a[6]); // 第一句:二四不同、二六同
bool okA2 = (b[2] != b[4] && b[2] == b[6]); // 第二句也要檢查
bool okA = okA1 && okA2;
bool okB = (a[7] == 1 && b[7] == 0); // 仄起(尾字 1)平收(尾字 0)
bool okC = (a[2] != b[2] && a[4] != b[4] && a[6] != b[6]); // 上下相對
if (!okA) cout << "A";
if (!okB) cout << "B";
if (!okC) cout << "C";
if (okA && okB && okC) cout << "None"; // 三條都遵守
cout << endl;
}
int main() {
int n;
cin >> n;
for (int i = 0; i < n; i++) {
solve();
}
return 0;
}
測過再交:範例只給了一半,自己補滿八種
寫完先拿範例 1 走一遍,它埋了一個很好的陷阱:第一句 1 1 0 0 0 1 1 其實完全符合 A(第 \(2\)、\(4\) 字是 \(1\)、\(0\) 不同,第 \(2\)、\(6\) 字都是 \(1\))——壞的是第二句(第 \(2\)、\(4\) 字都是 \(0\))。如果你的規則 A 只檢查了第一句,這一筆就會少印 A,範例 1 當場幫你抓到。
再想一層:三條規則各自獨立,輸出總共有 \(2^3 = 8\) 種——None、A、B、C、AB、AC、BC、ABC。三個範例只涵蓋了其中四種(AC、None、AB、ABC),剩下的 A、B、C、BC 要自己補測資。構造的訣竅是先想清楚「哪些位置歸哪條規則管」:第 \(2\)、\(4\)、\(6\) 字同時被 A 和 C 盯著,隨手改一個字常常兩條規則一起變動;句尾(第 \(7\) 字)只有 B 管;第 \(1\)、\(3\)、\(5\) 字沒有任何規則管。所以,從範例 2 那組 None 對聯(0 1 1 0 1 1 1 / 1 0 1 1 0 0 0)出發:
- 想單獨違反 B:把第一句句尾改成 \(0\) 就好,A、C 完全不受影響。
- 想單獨違反 C:把第二句的第 \(2\)、\(4\)、\(6\) 字直接抄第一句——抄過來的樣式本身還是「二四不同、二六同」,所以 A 不動。
- 想單獨違反 A 最需要動腦:改一句的第 \(2\)、\(4\) 或 \(6\) 字一定會驚動 C,但把某個位置上下交換(例如兩句的第 \(4\) 字互換)之後上下仍然相反,C 保持遵守,而第一句自己的「二四不同」就被弄壞了。
照這個思路補出四筆,餵進程式逐一核對:
| 第一句 | 第二句 | 正確輸出 | 構造方式 |
|---|---|---|---|
0 1 1 1 1 1 1 |
1 0 1 0 0 0 0 |
A |
兩句的第 \(4\) 字上下交換 |
0 1 1 0 1 1 0 |
1 0 1 1 0 0 0 |
B |
第一句句尾 \(1 \to 0\) |
0 1 1 0 1 1 1 |
1 1 1 0 0 1 0 |
C |
第二句的第 \(2\)、\(4\)、\(6\) 字照抄第一句 |
0 1 1 0 1 1 0 |
1 1 1 0 0 1 0 |
BC |
上面 B、C 兩個改法一起套 |
八種輸出都親眼看過正確,這題就穩了。
常犯錯誤
- 規則 A 只檢查第一句:題目寫的是「每一句的第二、四個字必須不同平仄」,兩句都要檢查。範例 1 就是專門考這個——第一句完全符合、壞在第二句,漏檢的話範例 1 就過不了。
- 自己手動反轉條件、把「且」「或」弄錯:「二四不同且二六同」的違反是「二四相同或二六不同」,不是「且」(3.10 的笛摩根定律)。照主文的寫法——先寫「有遵守」、違反用
!——就完全不會踩到。 - 位置 off-by-one:用 \(0\) 起始的陣列時,「第 \(2\) 個字」是
s[1]不是s[2]。建議照主文多開一格、從索引 \(1\) 用起(6.3),讓索引和「第幾個字」直接對齊。 - 規則 B 的方向寫反:平聲是 \(0\)、仄聲是 \(1\);「仄起平收」是第一句結尾 \(= 1\)、第二句結尾 \(= 0\)。範例 1 和範例 2 都是漂亮的「仄起平收」,方向寫反的話這兩筆都會多印一個 B,範例當場抓到。
- 印
None的條件三個都要到齊:這一行常常是複製貼上打出來的,很容易寫出okA && okB && okB這種第三個忘了改成okC的版本。它編譯照樣過,而且三個範例都照樣全對(範例剛好沒有「只違反 C」的組合);只有在單獨違反 C 的時候,才會又印C又印None、輸出變成CNone。上面自測表格的第三列,就是專門堵這種錯的。 - 輸出格式:代號固定依 A、B、C 的順序連著印;
None只有第一個字母大寫;每組對聯輸出一行。