最佳化程式碼速度/複雜度最佳化順序
通常,演算法具有漸近計算複雜度。假設輸入的大小為 N,我們可以說演算法將在 O(N)、O(N^2)、O(N^3)、O(N*log(N)) 等時間內完成。這意味著它是一個輸入大小的特定數學表示式,並且演算法在它的兩個因子之間完成。
通常,程式底層演算法的複雜度越低,執行速度越快,並且隨著輸入規模的擴大,可擴充套件性越好。因此,我們通常應該尋求更有效的演算法來降低複雜度。
假設我們要在包含N個專案的集合中查詢特定專案。一個樸素的查詢演算法是逐個遍歷所有專案,看看它們是否與該專案匹配。然後,當我們找到該專案時,我們可以停止,或者如果我們沒有找到它,則宣告它沒有找到。
這被稱為線性搜尋,平均(和最壞情況)複雜度為 O(N)。現在,如果我們打算多次進行這種樸素的查詢,那麼每次查詢的成本將是 O(N)。這通常是不可接受的。
更好的方法是使用更有效的查詢方法。例如,二分搜尋 是 O(log(N))。它假設我們將現有專案儲存在排序陣列中,或者儲存在平衡樹中。雜湊表 是一種啟發式方法,透過精心設計其底層引數,可以提供平均 O(1) 的查詢,但 O(log(N)) 通常已經足夠好。
弗利塞爾求解器 是一個用ANSI C 編寫的庫,它可以解決弗利塞爾 和類似的“完全知識”紙牌遊戲。弗利塞爾求解器從一開始就使用了傳統的遊戲人工智慧 啟發式方法,如深度優先搜尋 和最佳優先搜尋。因此,需要維護之前遇到的棋盤位置(或“狀態”)的集合,以避免重複檢查。
求解器的第一個版本(用Perl 編寫)使用了一個數組上的線性搜尋。這證明對於解決即使是最基本的遊戲也是太慢了。之後,程式用 C 重新實現,並使用了一個排序陣列,其中“排序邊距”使用 ANSI C 的qsort 函式 進行排序,該函式執行快速排序 演算法,或其他有效的排序演算法,平均複雜度為 O(N*log(N)),使程式的平均查詢時間為 O(log(N)),累積增加時間在 O(N*log(N)) 和 O(N^2) 之間。
後來的版本可選地使用了兩個平衡二叉樹 庫:GNU libavl 和libredblack,它們的最大查詢和插入時間為 O(log(N)),累積執行時間為 O(N*log(N))。後來,有時用 C 語言編碼了一個自定義雜湊表,它的執行速度甚至比平衡二叉樹略快,並且具有平均 O(N) 的執行時間。這個雜湊表後來透過微最佳化進一步最佳化。
諸如快速排序、歸併排序 或堆排序 等普通的比較排序演算法的執行時間為 O(N*log(N))。這比“插入排序”或“氣泡排序”等樸素的比較排序演算法要好得多,它們的執行時間為 O(N^2)。但是,在某些情況下,我們也可以改進 O(N*log(N))。
其中一種情況是,如果所討論的鍵是整數,或者可以對映到某個範圍內的整數。在這種情況下,我們可以使用“計數排序” 等演算法,以大約 O(N) 的時間進行排序。如果我們在基數中有多個這樣的“位”(只要它們的數目保持不變),我們也可以嘗試使用“基數排序” 以及計數排序。
另一方面,如果要排序的元素數量很少,那麼有時使用插入排序比歸併排序或快速排序更好,因為後者的演算法開銷很大。
在他的文章“回到基礎” 中,喬爾·斯波斯基說明了一種常見的(也是不必要的)模式,它會增加程式的複雜度。本質上,當一個人在 C 中做
char string[1000]; strcpy (string, "One "); strcat (string, "Two "); strcat (string, "Three "); strcat (string, "Four "); . . .
等等,那麼 strcat 呼叫將一遍又一遍地從字串的開頭開始,並尋找(不斷增加的)終點。因此,附加 N 個每個長度有限的字串的複雜度變為 O(N^2),而不是 O(N)。
消除程式碼中的這種問題實現可以帶來顯著的提速。
需要注意的是,一些演算法雖然具有比等效演算法更低的 Big-O 表示法,但要麼過於複雜而無法有效實現,要麼具有巨大的執行時間因子,使其不適用於大多數合理的資料集,或者兩者兼而有之。例如,在 90 年代發現了一種線上性時間 (O(N)) 內查詢陣列中位數(= 中間元素)的演算法,但它非常複雜並且具有非常大的線性因子,因此高效的 O(N*Log(N)) 排序通常會更快。而且,我們經常對中位數感興趣以最佳化排序,因此從一開始就不使用這種演算法是有意義的。