1. 程式人生 > >程式設計競賽中的除錯技巧

程式設計競賽中的除錯技巧

程式碼能力是我們在程式設計競賽中常常談到的一種能力,是指選手把演算法用程式碼準確地實現的能力。
2005 年,Comars 曾經給程式碼能力作過一個比較準確的定義:如果我 150150 行以內的題目,1Y(提交一次就正確通過,Y 是指 Yes)率非常高,並且保持穩定;而當代碼長度超過 150150 行以後,1Y 率就開始急速下降了。如果我們畫出一條程式碼行數與 1Y 率之間的關係的曲線,150150 行就是一個轉折點。我們不妨認為,150150 行就是 Comars 當時的程式碼能力。一年以後,經過努力,Comars 把程式碼能力提高到了 250250 行,也就意味著他通常可以無錯誤地寫出一份 250250 行的程式碼。
當然,想要把程式碼能力提高到 150150 行並非是一個簡單的事情。在程式碼能力不夠強的時候,就需要有足夠的除錯(debug)能力了。結合我的比賽經歷和平時訓練的經驗,給大家分享一些程式設計競賽題目 debug 的技巧。
在開始 debug 之前,要先在腦海中過一遍思路,必須保證自己有一個清晰的演算法思路和一個正確的演算法(至少自己要相信它是正確的)。一定要有清晰的思路,不然寫出來的程式碼可能連自己都看不懂;而基於一個錯誤演算法的 debug 是毫無意義的。當你確認了上面兩件事情以後,才有必要開始 debug。

  1. 順著你的思路仔細閱讀自己的程式碼兩到三遍,注意是仔細,要一句一句地讀。核對你的程式碼和你的思路是否一致,不要放過任何一個小細節。如果遇到拿不準的地方,立刻停下來仔細想想,直到想清楚以後再繼續。在這個過程中,往往可以找到很多低階錯誤,尤其是對於程式碼能力不太好的同學,比如——變數打錯、程式碼寫錯位置、變數賦值錯誤等等。靜態閱讀程式碼的效率是非常高的,因為往往讀一份自己寫出的程式碼的時間遠小於寫的時間——既然都已經花了那麼多時間寫出來了,何必還在乎這點時間多讀幾遍呢。
  2. 如果經過上面的過程還沒有找到程式中的錯誤,或者找到了一些問題但是程式的結果還是不對,這時我們就要通過執行程式來 debug。根據我以往參加競賽的經驗,經過上面的過程基本上可以解決一半以上的問題。如果一定要走到這一步,很可能已經給自己挖了一個巨大的坑。想要通過執行程式來 debug,第一步是需要拿到一組能使得程式出錯的資料,拿到錯誤資料以後,debug 就成功了一半。在造錯誤資料時,一定要靜下心來耐心出,不要指望一下就能造出錯誤資料。並且造出的資料儘量不要規模太小、太簡單,資料越複雜,找到錯誤的概率會越高。對於 ACM/ICPC 這種團隊比賽,必要的時候你可以讓隊友幫你造資料。例如某年 ACM/ICPC 瀋陽賽區,我當時除錯一道模擬題半個多小時還是錯的,距離比賽結束還有 1010 分鐘時,隊友找到一組錯誤資料,然後我調了 55 分鐘就找出了那個致命的 bug。在比賽進行到 295295 分鐘(比賽一共 300300 分鐘)的時候正確通過了這題(很關鍵的一題)。同年上海賽區,也是最後幾分鐘隊友給出一組給力資料,在 299299 分鐘絕殺一道 dp 題目。有了資料以後,剩下的除錯應該很簡單了。根據錯誤資料,輸出一些重要的中間變數的值,然後觀察是否和預期一樣。這裡也可以藉助二分思想輸出中間變數,快速定位到錯誤程式碼塊。不過實踐中,通常是根據經驗,覺得哪塊容易出錯就重點輸出哪一塊的變數。無論是平時訓練還是比賽中我都建議少用 IDE 斷點除錯功能和單步除錯功能,通常比較浪費時間。
  3. 對於某些特殊題目,小資料可以很容易寫出一個時間效率低但確保正確的“暴力”程式。這時候,我們可以用暴力程式和出錯程式對拍。對於造出的一些小資料,同時用自己的程式和暴力程式得出答案然後對比。這個小資料的範圍是暴力程式能在短時間內得到正確結果的最大範圍。因為暴力程式一般都很簡單,沒那麼容易寫錯,所以你通常把暴力程式當成小資料的標準程式。如果連暴力都寫錯,建議多做練習,提高程式碼能力,確保短程式碼都能儘量做到零失誤。
    經過上面的 debug 過程以後,如果你手造的複雜資料多達 1010 組以上還沒有發現錯誤。你可以嘗試下面的做法:
    重新思考一個新思路,或者嘗試去發現原思路中的問題。
    先靜下心來,先看看其他題,AC 一些其他題調整一下狀態,然後回來重新 debug。
    如果你確定思路一定沒問題,程式碼也一定沒錯,那通常是因為你讀錯題目了,重新回去讀題。
    如果到這一步你的程式還是無法正確通過,並且你確保沒讀錯題,只要有足夠的自信,聯絡出題人,和他確認資料是否有問題。
    debug 過程中不要輕易請教別人,請教別人思路沒問題,但是請教別人幫你 debug 不太好。除非你已經連續 debug 了一天還沒有發現錯誤,再考慮去請教其他人。這裡教大家一個 debug 技巧——斷言(assert)。
    1
    assert(x >= 0);
    如果x >= 0不成立,則程式會因為執行錯誤而退出。比如寫一個整數除法函式:
    1
    int division(int x, int y) {
    2
    assert(y != 0);
    3
    return x / y;
    4
    }
    如果y == 0程式就會異常退出,你一定是在程式的其他什麼地方寫錯了。

再如,solve(x, y)如果應該等於solve(y, x),我們可以assert(solve(x, y) == solve(y, x)),如果執行錯誤,那麼必然solve寫錯了。在解決 Polya 計數題目的時候,可以assert(sum % |G| == 0)。除了用來除錯以外,assert還可以用來驗證資料是否規範,對出題人很方便,還可以用來驗證 OJ 資料的準確性。

經驗之談
最後列出一些比賽和訓練中特別容易犯的一些低階錯誤和治療方法:

錯誤程式碼
1
int n;
2
int a[n];
治療方法:初學者很容易寫出這樣的程式碼,當然老隊員肯定寫不出這種程式碼的。儘量避免這種寫法,定義陣列用常量,比題目約定的資料範圍稍微大一點。比如資料範圍是 1 \leq n \leq 100001≤n≤10000,則開一個 10000 + 1010000+10 的陣列會比較穩妥,因為你也許後來心血來潮讓下標從 11 開始計數。
1
const int maxn = 10000 + 10;
2
int a[maxn];
錯誤程式碼
1
for (int i = 0; i < n; i++) {
2
if (i = n) {
3
printf("%d\n", i);
4
}
5
else {
6
printf("%d “, i);
7
}
8
}
治療方法:剁手。編譯警告(warning)會提醒的,不要忽略甚至直接關閉編譯警告。建議在做題的時候把編譯選項-Wall開啟:
1
g++ -Wall -o main main.cpp
錯誤程式碼
1
double a = 1 / 3 * 3;
2
double b = 1;
3
if (a == b) {
4
printf(“Yes”);
5
}
治療方法:判斷浮點數相等應該用極小值eps來輔助,一般eps取1e-8足夠了,確保比題目約定的精度誤差要求更小。
1
const double eps = 1e-8;
2
double a = 1 / 3 * 3;
3
double b = 1;
4
if (fabs(a - b) < eps) {
5
printf(“Yes”);
6
}
錯誤程式碼
1
const int inf = 0x7fffffff;
2
int dp[10];
3
int main() {
4
for (int i = 0; i < 10; ++i) {
5
dp[i] = inf;
6
}
7
for (int i = 1; i < 10; ++i) {
8
dp[i] = min(dp[i], dp[i] + dp[i - 1]);
9
}
10
return 0;
11
}
治療方法:這裡inf + inf會溢位,超出了int的範圍。可以把inf的定義改成:const int inf = 0x3fffffff,就可以確保不會溢位了。順便給大家推薦一個小技巧:
1
int dist[100];
2
memset(dist, 0x3f, sizeof(dist));
如上的程式碼可以讓dist陣列中的所有元素賦值為0x3f3f3f3f,並且兩個初始值相加也不會溢位,常用於圖論或動態規劃中陣列的初始化。
錯誤程式碼
1
// 1. 線段樹 build
2
void build(int id, int l, int r) {
3
if (l == r) {
4
return;
5
}
6
int mid = (l + r) >> 1;
7
build(id << 1, l, mid);
8
build(id << 1 + 1, mid + 1, r);
9
}
10
// 2. 取 dp 答案
11
printf(”%d\n", dp[1 << n - 1]);
治療方法:沒有弄清楚操作符優先順序。在優先順序不確定的情況下,用小括號來明確指定優先順序能夠避免這類問題的發生。當然,最好還是要弄清這些符號之間優先順序的關係。
1
// 1. 線段樹 build
2
void build(int id, int l, int r) {
3
if (l == r) {
4
return;
5
}
6
int mid = (l + r) >> 1;
7
build(id << 1, l, mid);
8
build(id << 1 | 1, mid + 1, r);
9
}
10
// 2. 取 dp 答案
11
printf("%d\n", dp[(1 << n) - 1]);
錯誤程式碼
1
#define MAXN 1000 + 10
2
#define MULTIPLY(x, y) x * y
3
int a[MAXN * 4];
4
int main() {
5
int x = MULTIPLY(1 + 2, 3);
6
return 0;
7
}
治療方法:儘量不要用巨集定義,常量用const來定義。巨集定義雖然很方便,但是用起來很容易出錯,比如上面這段程式碼。如果你一定要用巨集定義,先去了解巨集定義常見的“坑”再用。
錯誤程式碼
1

#include <iostream>
2
 using namespace std;
3
 bool t, first;
45
 int main() {
6
     first = true;
7
     cin >> t;
8
     while (t--) {
9
         ...
10
     }
11
     return 0;
12
 }

治療方法:寫程式碼的時候不要太“隨性”,這種情況的發生通常都是寫程式時中途加了一個變數導致的,只要不太粗心就能避免。由於可能會發生這類錯誤,所以在本地造資料的時候,對於多組資料的題目要儘可能在一次測試中多造幾組資料,以儘量避免此類問題。

在大多數平臺和 ACM/ICPC 現場賽時,C++ 的 long long 用%lld輸入;而對於一些搭建在 Windows 上的 OJ(如 Codeforces、HDOJ),要用%I64d讀入。具體使用哪個佔位符要多看 FAQ。

變數在訪問前一定要初始化。好的習慣是在定義一個變數的時候就立刻初始化。一定注意,很多平臺(包括計蒜客的題庫)的編譯器是不會在定義陣列後將陣列內元素全部初始化為 00 的,如果你遇到本地和線上結果不一致的情況,可以從這個方向來找問題。

避免訪問非法記憶體。訪問非法記憶體的事情經常發生,但是可以通過養成好習慣來避免。比如stack、queue、set訪問之前必須先確認不為空;訪問指標之前確保指標不是野指標;陣列記憶體開得足夠大,等等。