Chapter II. 新手上路
實作技巧
常見錯誤列表
作者baluteshih

上傳了,卻沒有 AC!


很多時候,也許你的演算法邏輯是正確的,卻因為我們在撰寫的是一支完整的「程式」,所以任何的小細節都是不能允許錯誤的!

但初學者常常會被一些經典的簡單錯誤困住,也許是因為不夠自信,常常會以為自己是不是整份程式碼的邏輯都錯了,卻沒有發現到僅僅只是不小心寫出了幾個小錯誤才會造成問題。

因此,本文將嘗試介紹那些在程式競賽中,最為基本的那些小錯誤,以幫助讀者自我檢查並學習解決和簡單的預防方法。

未定義行為


在介紹各種常見錯誤之前,讓我們先來認識一下什麼是未定義行為(Undefined Behavior,以下簡稱 UB)。

什麼是 UB 呢?白話的說,每個程式語言在設計時心中都會有一套「標準」,但如果這套標準還要顧慮各種有的沒的例外情況就太麻煩了。因此在設計標準時,設計者乾脆「假設某些行為不會發生」,來讓程式語言的邏輯更為順暢,這些被假設不會發生的行為就是「不需要定義的行為」,故稱作未定義行為。

要比喻的話,可以想成設計一套程式語言就相當於經營一家酒館,正常情況下我們總能接受酒館只賣酒相關的食品,但若這時候一位顧客走了進來點一盤蝦仁蛋炒飯,不覺得就太過無理取鬧了嗎?作為酒館的經營者,我們可以假設「並不會有顧客這樣做」。

說是這樣說,但跟走進酒館點一盤蝦仁蛋炒飯畢竟不同,使用者寫程式還是有可能不小心寫出 UB,畢竟沒有被定義,所以這時候使用者就得負起責任來面對「因為編譯器不覺得會發生這種情況,所以發生什麼都不關他的事」的狀況。可是寫出 UB 時到底會發生什麼事呢?由於編譯器不會負責,我們是無法完美預測程式會反映出的狀況的,這時候我們只能透過經驗、或是對常用編譯器的了解來猜出「編譯器大概是這樣運作的,所以應該會發生這件事吧!」的結論。

在下面的錯誤列表中,我們也會提到一些常容易不小心寫出來的 UB,來讓讀者大致感受一下寫出 UB 時可能會發生的事情。

Wrong Answer


整數溢位(Overflow)

相信讀者肯定知道,我們常使用的整數型態 int 是有範圍限制的。實際上,若不小心存了太大的數字進去,這樣的行為在 C++ 裡其實是一個 UB!

一個經典的例子就是沒有注意到題目的數字範圍就貿然做運算,就好比在一道輸入 $n$ 個數字、輸出總和的題目裡,如果 $n\leq 10^5$、數字範圍也大到 $10^5$:

int n, sum = 0;
cin >> n;
for (int i = 0; i < n; ++i) {
    int x;
    cin >> x;
    sum += x;
}
cout << sum << "\n";

由於 int 最多也只能存到 $2^{31} - 1 = 2147483647$,初學者很可能就不會發現最大的答案會到 $10^{10}$,導致其超出了 int 上限造成 overflow。但我們自己測試時又不見得會去測試這麼大的數字,若題目再複雜一些,初學者就很容易不清楚造成錯誤的原因是什麼感到困惑,但其實只需要把 int 改成 long long 就能通過了。

不過既然是 UB,那不小心 overflow 了會發生什麼事呢?通常,程式本身其實還是會將其當成一個正常的數字做運算,由於一般程式都是用二進位儲存數字的,這時候過大範圍的數字就會「進位」到範圍外的地方,進而直接被丟棄。所以,除非這些 overflow 後的數字還有在後續被拿來做其他操作,不然很有可能在接下來的過程中都只是一個「錯誤的數字」在被拿來進行運算而已,在這樣的情況下,這個 UB 只會造成 WA 就合理許多了。

讀者可以試試看宣告變數並初始為 2147483647,再多加一之後會發生什麼事──在大多數的環境中,結果幾乎都是 -2147483648。有興趣的讀者可以去研究一下一般程式儲存數字的邏輯來理解為什麼會這樣,但正是因為 overflow 常常就是會把一堆正數總和成一個負數,所以有一個很簡單測試有無 overflow 的方法就是刻意輸入一些很大的正數、並輸出運算出來的結果,若突然在一堆正數運算中出現負數,就可以大方懷疑是 overflow 啦!

Overflow 是最為常見的小錯誤之一,筆者這邊建議讀者在解題時可以多留意一下題目數字的範圍,若吃了 WA,也可以檢查一下運算過程有沒有可能會 overflow。

當然,為了避免 overflow,筆者也建議稍微記一下變數的範圍,不然如果在比賽中還要花時間懷疑大小就太浪費時間了!這裡附上幾個常用的變數型態、他們的實際儲存範圍以及程式競賽中常用來記憶的範圍。

變數型態實際儲存範圍常用記憶準則
int$[-2^{31}, 2^{31})$正負 $2\times 10^9$ 以內的數字
long long$[-2^{63}, 2^{63})$正負 $9\times 10^{18}$ 以內的數字
char$[-128, 127)$通常不用來存數字,但若在很偶爾時拿來運算一定要特別留意

上述提到的記憶準則主要是用來粗略估計用,通常只要發現數字運算起來好像會碰到這些界線,建議就要多留意 overflow 的可能性。

變數初始化

我們在全域、區域變數時有提到,區域變數在剛宣告出來時,是不會有正式的預設值的,因此,貿然使用這個變數的值就會讀到不正確的值,進而造成錯誤。

聽起來還算單純,但變數沒有初始化最可怕的其實是那些「與之產生連鎖問題的 bug」們,就像是下面這個例子:

int n, arr[100];
for (int i = 0; i < n; ++i)
    cin >> arr[i];

這份 code 有什麼問題?沒錯,忘記輸入 n 了!由於沒有初始化的變數內部還是會有值,若這個值是一個正數,就會導致程式讀取了這個正數量的數字,程式出錯時我們又常常以為是演算法邏輯的部分出錯,所以這種最白痴的「在輸入就出事了」的問題就往往會不小心被遺漏,造成無謂的 debug 時間。可別小看這個 bug,有各式各樣問題可以與之連鎖產生,還請特別留意。

雖然講得很可怕,但要避免沒初始化的變數搞事其實是相對容易的──乖乖看過每一個沒有預設值的變數就對了。

變數重設

在程式競賽中,「首行輸入一個 $T$,表示共有 $T$ 筆測資」這種題目是非常常見的,而在這樣多筆測資的題目裡,重設那些共同使用的變數就變成了不可以忽略的重要任務。

就好比以下這個最為單純的例子:

int t, sum = 0;
cin >> t;
while (t--) {
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i) {
        int x;
        cin >> x;
        sum += x;
    }
    cout << sum << "\n";
}

因為 sum 是宣告在迴圈之外,所以在第二筆測資計算總和時,sum 的值就會繼承前一筆測資的結果,造成 WA。有時候這樣沒有重設的問題可能更可怕,若範例測資不夠強,很有可能就會不小心沒發現問題導致 WA。

因此,遇到多筆測資的題目時,最好多檢查一下有沒有多筆測資之間會共同使用到的變數、並想過一遍他們是否需要重設。若要謹慎一點的話,可以故意把範測提供的測資們複製出兩三倍的量後跑看看,若這兩三遍輸出的值不一樣,就肯定是變數沒重設啦!

還有一個簡單的迴避方式就是盡量用區域宣告的方式來只在需要一個變數時將其宣告出來,就好比上面的例子中,sum 其實是可以直接宣告在迴圈內部的,這樣就肯定不會出事了。當然,由於有 Stack overflow 的問題,有些選手會很習慣把大陣列都丟到全域宣吿、或是在撰寫全域函式時發現有區域變數需要被其讀取,就為了方便把他一起丟到全域去。不過實際上,陣列可以動態開、函式也可以透過語法來宣告區域的版本,所以大多數的情況下這其實是可以很容易辦到的,這方面的技術就可能需要一些更深入對 C++ 的認知了。

行尾空白

在現今常見的各種 Online Judge 上,大多數的題目在檢查程式輸出的答案都是使用「非嚴格比對」這項規則,意即在比對答案時,OJ 會自己忽略那些多餘的換行、空格等不可見字元,來寬容那些跟程式邏輯無關的 bug 發生。

不過,使用「嚴格比對」的題目還是存在的,尤其是比較早期的程式競賽題目幾乎都是採用嚴格比對,也就是說,多輸出一個空格在後方會造成 WA!

最常見會不小心多輸出一個空格的狀況就是在輸出 $n$ 個數字的題目裡,若這樣寫的話:

for (int i = 0; i < n; ++i)
    cout << arr[i] << " ";

最後一個數字後面就會多一個空格,因此會需要改成以下這種寫法來避免這個問題:

for (int i = 0; i < n; ++i) {
    cout << arr[i];
    if (i + 1 == n) cout << "\n";
    else cout << " ";
}

因此,若在不熟悉的 OJ 上面解題,卻 WA 到懷疑人生的話,建議可以懷疑看看是否有行尾空白的問題喔!

不過,其實有一個很偷吃步的寫法,是利用了把字串當陣列、布林值能夠直接轉換成整數的特性來達成的:

for (int i = 0; i < n; ++i)
    cout << arr[i] << " \n"[i + 1 == n];

其具體的運作原理就交給讀者自行領悟。

運算優先順序

C++ 內建非常多的運算子,當混用這些運算子寫成一條式子時,就像是我們習慣先乘除後加減一樣,C++ 也會遵守一套優先順序規則來處理這些運算子的順序。

但這樣的順序可能並不總是都如我們想像中一般的運作。舉例來說,我們知道利用 n & 1 這個方式就可以根據回傳值是 01 來決定 n 是偶數還是奇數。由於整數也能直接當成布林值判斷,因此寫成

if (n & 1)

就可以寫出「如果 n 是奇數時」的判斷式,但如果是偶數的話就不能直接利用了,因此可能會不小心寫出以下判斷式:

if (n & 1 == 0)

乍看之下是想要判斷 n & 1 是不是 0,但這樣可就錯了!這是因為 == 的優先順序其實比 & 還高,所以上述的式子其實等價 n & 0,同時也就是 false,會變成一個永遠都執行不了的判斷式!

熟記運算子優先順序固然是一種方法,但是人就難免會不小心記錯,若在不夠有自信時,尤其牽扯到位元運算等操作時,建議還是多加一些括號,就像是

if ((n & 1) == 0)

才會比較保險喔!

負數取模

講到 $-5$ 除以 $3$ 的餘數,你會覺得是多少呢?根據國中數學教我們的,答案應該是 $1$,這是因為餘數要是「最小的正數」。但在 C++ 中,% 運算子回傳的餘數並不會是如此,實際上,若寫出 -5 % 3 的話,回傳值其實是 -2

這是為什麼呢?讀者可以想成 C++ 中的 a % b 其實等價 a - a / b * b,其中 / 是整數除法。也因此,代入 a=-5, b=3 的話就會得到 -2。直觀理解就可以想成 C++ 是在不斷把 a 透過加減 b0 靠近,直到 a 的絕對值小於 b 為止。

不過,常常題目要求的輸出都會統一是「最小的正數」,也因此在遇到有負數時還取模直接輸出就會造成 WA。這時候,一個簡單的式子可以直接解決這個問題:

(a % b + b) % b

也就是先得到「最大負數」,由於他的絕對值一定小於 b,所以加上 b 後自然就變成「最小正數」了。

負數取模是初學者非常容易踩到的 bug,讀者可以在取模時多加留意。

Time Limit Exceeded


沒有加上輸入優化

我們在基本常識有提過,C++ 的 cincout 在有無輸入優化的情況下速度是差很多的,所以請務必要加上 ios::sync_with_stdio(false), cin.tie(nullptr); 這行來加速輸入輸出。

無窮迴圈

若不小心吃了 TLE 的結果,卻不覺得自己複雜度有錯的話,可以多檢查一下那些以 while (true)for (;;) 形式寫成的迴圈們,看是不是存在一些沒想到的情況讓他們不會被跳出。

當然,上面這個是比較單純的狀況,有另一種無窮迴圈的 bug 可能是靠這樣寫出來的:

for (int i = 0; i < n; ++i)
    for (int j = 0; j < n; ++i) {
        // do something
    }

發現了嗎?第二層迴圈內有一個 j 打成 i 了!如此一來一進第二層迴圈就會出不來了。這種多層迴圈不小心打錯或是用錯的問題偶爾還是會發生的。

忘記使用 Reference

在章節 Reference 中,我們有提到可以透過 reference 來不用複製地傳入物件給函式,但有時候可能少加一個 & 就會直接造成複雜度的大退化,例如:

bool check_exists(int v, set<int> st) {
    return st.find(v) != st.end();
}

void solve() {
    for (int i = 1; i <= n; ++i)
        if (check_exists(arr[i], st)) {
            // do_something
        }
}

假設 st 是一個 $O(n)$ 量級的 set,這段 code 可能原本預期只需要花費 $O(n\log n)$ 就能檢查對於每個 arr[i] 是否有出現在 st 內,卻因為少加一個 & 在第一行,就花了 $n$ 次複製了一個 $O(n)$ 量級的 set,導致時間複雜度退化成了 $O(n^2)$!

這樣的問題往往在程式寫複雜後就會容易漏看,這種「少一個字元」的 bug 更有可能讓人找得非常痛苦,遇到 TLE 時可以多加留意。

Run Time Error


RE 的問題相對直接,但大多數造成 RE 的原因都是 UB,也因此讀者要稍微注意,不要在遇到 RE 時才檢查 RE 相關的 bug,很可能因為程式的一些巧合導致你的程式得到的結果沒有造成 RE。

除以 $0$

相信除以 $0$ 在幾乎所有程式語言都會造成 RE 是眾所皆知的事情,理論上除以 0 也是相對容易留意到的錯誤,只要偶爾留意一下邊界情況,例如在程式需要用到 / (n - 1) 這種操作時遇到輸出範圍可能出現 $n=1$ 的這種狀況就可以了。

還有一個和除以 $0$ 一樣的情況是使用 % 運算子來進行模 $0$ 的操作,由於 % 運算子在編譯過程中也會轉換成除法,所以模 $0$ 也是會踩到除以 $0$ 的問題的。

越界存取

開了大小為 $n$ 的陣列,當然就只能讀 $0\sim n-1$ 的位置。相信若吃了 RE,檢查一下自己的陣列存取有沒有可能不小心超出範圍是相對容易的。

這裡稍微提一下初學者容易踩到的雷,例如遇到 $n\leq 10^5$ 的題目時,初學者可能會天真的直接宣告一個陣列 arr[100000] 來迎合範圍,結果一不小心使用了 1 開始編號的系統(1-base),就會直接讀到 arr[100000] 造成 RE。一個競賽選手們常用的招數是,若範圍上限是 $N$,就故意把陣列大小上限開到 $N+5$ 或 $N+10$ 等,來避免可能會不小心多讀到超過一兩格的位置。

順帶一提,越界存取在 C++ 中是一個 UB,所以有可能因此獲得的結果不是 RE 是 WA,甚至還可能是 TLE!

更詳細的說的話,由於陣列在 C++ 中是一段連續的記憶體,在連續記憶體的假設下,C++ 可以直接透過運算來取得該存取的記憶體位置。所以如果傳入的是一個過大的索引值,C++ 就會直接用一般的方法算出對應的記憶體位址然後硬是讀或寫下去,在這種情況下,若讀取到的記憶體不是 C++ 覺得合理的位址,那就可以正常跳出 RE;但如果恰好這個位置剛好是別的變數的位址,那就可能會繼續執行造成錯誤的讀取或寫入。

舉例來說,假設今天有一堂小組討論的課程,而你們組別的座位被分到第二排最左邊三個位置。假設每排只有十個位置,這時你到了教室,有同學跟你說你的位置在左邊數過來第十一個位置,那你自然就會知道這是一個錯誤的訊息(每排只有十個位置啊!),也就是產生了 RE;但如果他跟你說你的位置在左邊數過來第四個位置,那你可能就會不假思索的坐錯位置(確實有這個位置,但是是別組的!),造成後續的錯誤,此時可能導致的結果就是 WA;而當情況更極端時,坐錯位置造成整間教室大亂,進而導致 TLE 相信也就不難想像了。

存取不正確的記憶體

與越界存取不同,這個狀況是特別指使用指標時可能會讀取到的錯誤記憶體位址。這個問題常發生在自行動態分配記憶體時,不小心在刪除記憶體時忘記將原本的指標歸零導致,要避免這個問題,使用指標還請務必養成手動把不用的指標歸零的好習慣。

另一方面,有一個會跟變數初始化複合的 bug 如下:

struct node {
    node *l, *r;  
};

在使用指標實作二元樹等資料結構時,常常會需要寫出類似上述的語法,這時候如果動態分配、或在區域宣告一個 node 的物件,就會獲得沒有初始化的值,導致 lr 裡面存的不是 0,就會導致我們在寫程式時誤判斷以為這些值有指到別的記憶體導致存取錯誤。

較為好的習慣是盡可能的幫這些變數賦予預設值:

struct node {
    node *l = 0, *r = 0;
};

也可以試著養成寫建構子的習慣。

無窮遞迴

無窮遞迴就跟無窮迴圈很像,只要多加注意自己寫的遞迴函式有沒有可能無窮呼叫即可。不過其不一樣的表現在於無窮遞迴在本機顯示的結果通常都是 RE,這是因為你感受到他跑了很久之前,過深的遞迴通常會直接先造成 Stack overflow,也因此初學者有時在本機檢查 RE 問題時,會不小心漏掉這個可能的 bug。

超出記憶體上限

記憶體上限通常都是一個直接寫死在 Online Judge 題目裡的資訊,常見的記憶體範圍通常是 256 MB512 MB1 GB2 GB,通常在 256 MB (含)以下時就需要特別注意有可能超出記憶體上限的問題。當然,有些讀者可能會在想「超過記憶體上限不是應該會顯示 Memory Limit Exceeded(MLE)嗎?」。大多數 OJ 確實有 MLE 這個 verdict 沒錯,但有時候程式超出記憶體上限所產生的「信號」並不全都會被 OJ 偵測為 MLE,還不少 OJ 會在不少 MLE 的狀況顯示 RE 的!

另外,也有一些 OJ 為了避免疑惑,會直接把 MLE 這個 verdict 移除,變成統一把 MLE 當成 RE 顯示。綜上所述,讀者在遇到 RE 時,也可能得將超出記憶體上限當作可能要考慮的問題。

Misc


這個小節負責講一些出錯後會發生隨機結果的 bug。

邊界 case

在解題時,注意極端測試資料的情況是很重要的,有時候題目常常會在 $n=1$、輸入數字為 $0$、全部數字一樣、……等眾多特殊情況產生細小的變化。這些極端狀況的處理方法通常都是非常直觀的,但卻無法被我們寫出來的「通用演算法」所處理。

一個簡單的例子就像這樣:「有 $n$ 個座位排成一排,你要幫每個座位上色,滿足相鄰的座位顏色都不相同,請問你最少要塗幾種顏色?」。

這個問題的答案非常簡單,就是兩種……是這樣說沒錯,但如果習慣去思考邊界情況的話,就會發現 $n=1$ 時答案其實是一種!

諸如此類的問題在各種題目上都很常發生,所以養成在解題時多想一下邊界情況是很好的習慣。另一方面,在上傳程式前,簡單手動打些非常特殊的測資看看程式會不會突然 RE 也會是個不錯的檢查手法。

至於什麼樣的題目會有什麼樣的邊界 case 呢……?這就要仰賴經驗了。

讀錯題目

讀錯題目可以說是一個重傷,尤其在比賽中時,一次嚴重的讀錯題目很可能就會導致一大段時間被浪費掉。但我們總是無法避免讀錯題目的發生,也因此在錯誤到懷疑人生時,務必要多看一下題目敘述本身,來檢查一下是不是自己漏讀了什麼題目條件,以免盯著程式碼一輩子都找不到問題。

Shadowing

Shadowing 是什麼呢?就是重複名字的變數宣告。一般來說,這樣的行為在 C++ 中是不被允許的,但若是子區域的同名變數宣告,就能夠被允許,一個常見的 bug 可能是:

int n, arr[100];

int get_sum() {
    int sum = 0;
    for (int i = 0; i < n; ++i)
        sum += arr[i];
    return sum;
}

int main() {
    int n;
    cin >> n;
    for (int i = 0; i < n; ++i)
        cin >> arr[i];
    cout << get_sum() << "\n";
}

有些選手可能就如上面這份 code 一樣,為了在全域的函式使用區域變數,就懶惰的把區域變數搬到全域去宣告,卻忘了把區域的變數刪除!由於這種狀況 C++ 會讓區域內以區域宣告的變數為準,就會導致全域的變數沒有被修改到,造成 get_sum 永遠回傳的值都是 0

諸如此類的問題也可能發生在迴圈內部宣告變數時發生,讀者還請務必小心類似的問題發生。

我要怎麼避免這些問題呢?


雖然說是基本錯誤,但再資深的選手都還是可能會在比賽中犯下這些錯誤。

當然,大多數時候他們都可以更快的找到問題在哪並迅速修正,同時他們也比較不容易寫出這些問題,這通常就是因為:

所以,我們不應該妄想著自己「再也不會遇到這些錯誤」,而是試著養成好習慣,並透過經驗的累積來加強自己寫程式的穩定程度才是上策。