運(yùn)行時(shí)間可以直觀(guān)且準(zhǔn)確地反映算法的效率。如果我們想要準(zhǔn)確預(yù)估一段代碼的運(yùn)行時(shí)間,應(yīng)該如何操作呢?
例如在以下代碼中,輸入數(shù)據(jù)大小為
// 在某運(yùn)行平臺(tái)下
void algorithm(int n) {
int a = 2; // 1 ns
a = a + 1; // 1 ns
a = a * 2; // 10 ns
// 循環(huán) n 次
for (int i = 0; i < n; i++) { // 1 ns ,每輪都要執(zhí)行 i++
cout << 0 << endl; // 5 ns
}
}
根據(jù)以上方法,可以得到算法運(yùn)行時(shí)間為6n+12 ns :
但實(shí)際上,統(tǒng)計(jì)算法的運(yùn)行時(shí)間既不合理也不現(xiàn)實(shí)。首先,我們不希望將預(yù)估時(shí)間和運(yùn)行平臺(tái)綁定,因?yàn)樗惴ㄐ枰诟鞣N不同的平臺(tái)上運(yùn)行。其次,我們很難獲知每種操作的運(yùn)行時(shí)間,這給預(yù)估過(guò)程帶來(lái)了極大的難度。
時(shí)間復(fù)雜度分析統(tǒng)計(jì)的不是算法運(yùn)行時(shí)間,而是算法運(yùn)行時(shí)間隨著數(shù)據(jù)量變大時(shí)的增長(zhǎng)趨勢(shì)。
“時(shí)間增長(zhǎng)趨勢(shì)”這個(gè)概念比較抽象,我們通過(guò)一個(gè)例子來(lái)加以理解。假設(shè)輸入數(shù)據(jù)大小為 A
、B
和 C
:
// 算法 A 的時(shí)間復(fù)雜度:常數(shù)階
void algorithm_A(int n) {
cout << 0 << endl;
}
// 算法 B 的時(shí)間復(fù)雜度:線(xiàn)性階
void algorithm_B(int n) {
for (int i = 0; i < n; i++) {
cout << 0 << endl;
}
}
// 算法 C 的時(shí)間復(fù)雜度:常數(shù)階
void algorithm_C(int n) {
for (int i = 0; i < 1000000; i++) {
cout << 0 << endl;
}
}
圖 2-7 展示了以上三個(gè)算法函數(shù)的時(shí)間復(fù)雜度。
圖 2-7 算法 A、B 和 C 的時(shí)間增長(zhǎng)趨勢(shì)
相較于直接統(tǒng)計(jì)算法運(yùn)行時(shí)間,時(shí)間復(fù)雜度分析有哪些特點(diǎn)呢?
給定一個(gè)輸入大小為
void algorithm(int n) {
int a = 1; // +1
a = a + 1; // +1
a = a * 2; // +1
// 循環(huán) n 次
for (int i = 0; i < n; i++) { // +1(每輪都執(zhí)行 i ++)
cout << 0 << endl; // +1
}
}
設(shè)算法的操作數(shù)量是一個(gè)關(guān)于輸入數(shù)據(jù)大小 n 的函數(shù),記為 T(n) ,則以上函數(shù)的的操作數(shù)量為:
T(n) 是一次函數(shù),說(shuō)明其運(yùn)行時(shí)間的增長(zhǎng)趨勢(shì)是線(xiàn)性的,因此它的時(shí)間復(fù)雜度是線(xiàn)性階。
我們將線(xiàn)性階的時(shí)間復(fù)雜度記為 O(n) ,這個(gè)數(shù)學(xué)符號(hào)稱(chēng)為「大 O 記號(hào) big-O notation」,表示函數(shù) T(n) 的「漸近上界 asymptotic upper bound」。
時(shí)間復(fù)雜度分析本質(zhì)上是計(jì)算“操作數(shù)量函數(shù) T(n)”的漸近上界,其具有明確的數(shù)學(xué)定義。
函數(shù)漸近上界
若存在正實(shí)數(shù)c 和實(shí)數(shù)
如圖 2-8 所示,計(jì)算漸近上界就是尋找一個(gè)函數(shù) f(n) ,使得當(dāng) n 趨向于無(wú)窮大時(shí),T(n) 和 f(n) 處于相同的增長(zhǎng)級(jí)別,僅相差一個(gè)常數(shù)項(xiàng) c 的倍數(shù)。
圖 2-8 函數(shù)的漸近上界
漸近上界的數(shù)學(xué)味兒有點(diǎn)重,如果你感覺(jué)沒(méi)有完全理解,也無(wú)須擔(dān)心。因?yàn)樵趯?shí)際使用中,我們只需要掌握推算方法,數(shù)學(xué)意義就可以逐漸領(lǐng)悟。
根據(jù)定義,確定 f(n) 之后,我們便可得到時(shí)間復(fù)雜度 O(f(n)) 。那么如何確定漸近上界 f(n) 呢?總體分為兩步:首先統(tǒng)計(jì)操作數(shù)量,然后判斷漸近上界。
針對(duì)代碼,逐行從上到下計(jì)算即可。然而,由于上述 c?f(n) 中的常數(shù)項(xiàng) c 可以取任意大小,因此操作數(shù)量 T(n) 中的各種系數(shù)、常數(shù)項(xiàng)都可以被忽略。根據(jù)此原則,可以總結(jié)出以下計(jì)數(shù)簡(jiǎn)化技巧。
給定一個(gè)函數(shù),我們可以用上述技巧來(lái)統(tǒng)計(jì)操作數(shù)量。
void algorithm(int n) {
int a = 1; // +0(技巧 1)
a = a + n; // +0(技巧 1)
// +n(技巧 2)
for (int i = 0; i < 5 * n + 1; i++) {
cout << 0 << endl;
}
// +n*n(技巧 3)
for (int i = 0; i < 2 * n; i++) {
for (int j = 0; j < n + 1; j++) {
cout << 0 << endl;
}
}
}
以下公式展示了使用上述技巧前后的統(tǒng)計(jì)結(jié)果,兩者推出的時(shí)間復(fù)雜度都為
T(n)=2n(n+1)+(5n+1)+2 完整統(tǒng)計(jì)(-.-|||)
=2n2+7n+3
T(n)=n2+n 偷懶統(tǒng)計(jì)(o.O)
時(shí)間復(fù)雜度由多項(xiàng)式 T(n) 中最高階的項(xiàng)來(lái)決定。這是因?yàn)樵?nbsp;n 趨于無(wú)窮大時(shí),最高階的項(xiàng)將發(fā)揮主導(dǎo)作用,其他項(xiàng)的影響都可以被忽略。
表 2-2 展示了一些例子,其中一些夸張的值是為了強(qiáng)調(diào)“系數(shù)無(wú)法撼動(dòng)階數(shù)”這一結(jié)論。當(dāng) n 趨于無(wú)窮大時(shí),這些常數(shù)變得無(wú)足輕重。
表 2-2 不同操作數(shù)量對(duì)應(yīng)的時(shí)間復(fù)雜度
操作數(shù)量T(n) | 時(shí)間復(fù)雜度O(f(n)) |
100000 | O(1) |
3n+2 | O(n) |
2n2+3n+2 | O(n2) |
n3+10000n2 | O(n3) |
22+10000n10000 | O(2^n) |
設(shè)輸入數(shù)據(jù)大小為
圖 2-9 常見(jiàn)的時(shí)間復(fù)雜度類(lèi)型
常數(shù)階的操作數(shù)量與輸入數(shù)據(jù)大小 n 無(wú)關(guān),即不隨著 n 的變化而變化。
在以下函數(shù)中,盡管操作數(shù)量 size 可能很大,但由于其與輸入數(shù)據(jù)大小 n 無(wú)關(guān),因此時(shí)間復(fù)雜度仍為 O(1) :
time_complexity.cpp
/* 常數(shù)階 */
int constant(int n) {
int count = 0;
int size = 100000;
for (int i = 0; i < size; i++)
count++;
return count;
}
線(xiàn)性階的操作數(shù)量相對(duì)于輸入數(shù)據(jù)大小 n 以線(xiàn)性級(jí)別增長(zhǎng)。線(xiàn)性階通常出現(xiàn)在單層循環(huán)中:
time_complexity.cpp
/* 線(xiàn)性階 */
int linear(int n) {
int count = 0;
for (int i = 0; i < n; i++)
count++;
return count;
}
遍歷數(shù)組和遍歷鏈表等操作的時(shí)間復(fù)雜度均為O(n),其中n為數(shù)組或鏈表的長(zhǎng)度:
time_complexity.cpp
/* 線(xiàn)性階(遍歷數(shù)組) */
int arrayTraversal(vector<int> &nums) {
int count = 0;
// 循環(huán)次數(shù)與數(shù)組長(zhǎng)度成正比
for (int num : nums) {
count++;
}
return count;
}
值得注意的是,輸入數(shù)據(jù)大小n需根據(jù)輸入數(shù)據(jù)的類(lèi)型來(lái)具體確定。比如在第一個(gè)示例中,變量n為輸入數(shù)據(jù)大小;在第二個(gè)示例中,數(shù)組長(zhǎng)度n為數(shù)據(jù)大小。
平方階的操作數(shù)量相對(duì)于輸入數(shù)據(jù)大小 n 以平方級(jí)別增長(zhǎng)。平方階通常出現(xiàn)在嵌套循環(huán)中,外層循環(huán)和內(nèi)層循環(huán)都為 O(n) ,因此總體為 O(n2) :
time_complexity.cpp
/* 平方階 */
int quadratic(int n) {
int count = 0;
// 循環(huán)次數(shù)與數(shù)組長(zhǎng)度成平方關(guān)系
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
count++;
}
}
return count;
}
圖 2-10 對(duì)比了常數(shù)階、線(xiàn)性階和平方階三種時(shí)間復(fù)雜度。
圖 2-10 常數(shù)階、線(xiàn)性階和平方階的時(shí)間復(fù)雜度
以冒泡排序?yàn)槔鈱友h(huán)執(zhí)行 n?1 次,內(nèi)層循環(huán)執(zhí)行 n?1、n?2、…、2、1 次,平均為 n/2 次,因此時(shí)間復(fù)雜度為 O((n?1)n/2)=O(n2) 。
time_complexity.cpp
/* 平方階(冒泡排序) */
int bubbleSort(vector<int> &nums) {
int count = 0; // 計(jì)數(shù)器
// 外循環(huán):未排序區(qū)間為 [0, i]
for (int i = nums.size() - 1; i > 0; i--) {
// 內(nèi)循環(huán):將未排序區(qū)間 [0, i] 中的最大元素交換至該區(qū)間的最右端
for (int j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交換 nums[j] 與 nums[j + 1]
int tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
count += 3; // 元素交換包含 3 個(gè)單元操作
}
}
}
return count;
}
生物學(xué)的“細(xì)胞分裂”是指數(shù)階增長(zhǎng)的典型例子:初始狀態(tài)為 1 個(gè)細(xì)胞,分裂一輪后變?yōu)?nbsp;2 個(gè),分裂兩輪后變?yōu)?nbsp;4 個(gè),以此類(lèi)推,分裂 n 輪后有 2^n 個(gè)細(xì)胞。
圖 2-11 和以下代碼模擬了細(xì)胞分裂的過(guò)程,時(shí)間復(fù)雜度為 O(2^n) 。
time_complexity.cpp
/* 指數(shù)階(循環(huán)實(shí)現(xiàn)) */
int exponential(int n) {
int count = 0, base = 1;
// 細(xì)胞每輪一分為二,形成數(shù)列 1, 2, 4, 8, ..., 2^(n-1)
for (int i = 0; i < n; i++) {
for (int j = 0; j < base; j++) {
count++;
}
base *= 2;
}
// count = 1 + 2 + 4 + 8 + .. + 2^(n-1) = 2^n - 1
return count;
}
圖 2-11 指數(shù)階的時(shí)間復(fù)雜度
在實(shí)際算法中,指數(shù)階常出現(xiàn)于遞歸函數(shù)中。例如在以下代碼中,其遞歸地一分為二,經(jīng)過(guò) n 次分裂后停止:
time_complexity.cpp
/* 指數(shù)階(遞歸實(shí)現(xiàn)) */
int expRecur(int n) {
if (n == 1)
return 1;
return expRecur(n - 1) + expRecur(n - 1) + 1;
}
指數(shù)階增長(zhǎng)非常迅速,在窮舉法(暴力搜索、回溯等)中比較常見(jiàn)。對(duì)于數(shù)據(jù)規(guī)模較大的問(wèn)題,指數(shù)階是不可接受的,通常需要使用動(dòng)態(tài)規(guī)劃或貪心等算法來(lái)解決。
與指數(shù)階相反,對(duì)數(shù)階反映了“每輪縮減到一半”的情況。設(shè)輸入數(shù)據(jù)大小為 n ,由于每輪縮減到一半,因此循環(huán)次數(shù)是 log2?? ,即 2? 的反函數(shù)。
圖 2-12 和以下代碼模擬了“每輪縮減到一半”的過(guò)程,時(shí)間復(fù)雜度為 O(log2? n) ,簡(jiǎn)記為 O(log? n) 。
time_complexity.cpp
/* 對(duì)數(shù)階(循環(huán)實(shí)現(xiàn)) */
int logarithmic(float n) {
int count = 0;
while (n > 1) {
n = n / 2;
count++;
}
return count;
}
圖 2-12 對(duì)數(shù)階的時(shí)間復(fù)雜度
與指數(shù)階類(lèi)似,對(duì)數(shù)階也常出現(xiàn)于遞歸函數(shù)中。以下代碼形成了一個(gè)高度為 log2? n 的遞歸樹(shù):
time_complexity.cpp
/* 對(duì)數(shù)階(遞歸實(shí)現(xiàn)) */
int logRecur(float n) {
if (n <= 1)
return 0;
return logRecur(n / 2) + 1;
}
對(duì)數(shù)階常出現(xiàn)于基于分治策略的算法中,體現(xiàn)了“一分為多”和“化繁為簡(jiǎn)”的算法思想。它增長(zhǎng)緩慢,是僅次于常數(shù)階的理想的時(shí)間復(fù)雜度。
O(log? n) 的底數(shù)是多少?
準(zhǔn)確來(lái)說(shuō),“一分為m
也就是說(shuō),底數(shù) m
線(xiàn)性對(duì)數(shù)階常出現(xiàn)于嵌套循環(huán)中,兩層循環(huán)的時(shí)間復(fù)雜度分別為 O(log? n) 和 O(n) 。相關(guān)代碼如下:
time_complexity.cpp
/* 線(xiàn)性對(duì)數(shù)階 */
int linearLogRecur(float n) {
if (n <= 1)
return 1;
int count = linearLogRecur(n / 2) + linearLogRecur(n / 2);
for (int i = 0; i < n; i++) {
count++;
}
return count;
}
圖 2-13 展示了線(xiàn)性對(duì)數(shù)階的生成方式。二叉樹(shù)的每一層的操作總數(shù)都為 n
圖 2-13 線(xiàn)性對(duì)數(shù)階的時(shí)間復(fù)雜度
主流排序算法的時(shí)間復(fù)雜度通常為 O(n log? n) ,例如快速排序、歸并排序、堆排序等。
階乘階對(duì)應(yīng)數(shù)學(xué)上的“全排列”問(wèn)題。給定
階乘通常使用遞歸實(shí)現(xiàn)。如圖 2-14 和以下代碼所示,第一層分裂出n個(gè),第二層分裂出 n-1
time_complexity.cpp
/* 階乘階(遞歸實(shí)現(xiàn)) */
int factorialRecur(int n) {
if (n == 0)
return 1;
int count = 0;
// 從 1 個(gè)分裂出 n 個(gè)
for (int i = 0; i < n; i++) {
count += factorialRecur(n - 1);
}
return count;
}
圖 2-14 階乘階的時(shí)間復(fù)雜度
請(qǐng)注意,因?yàn)楫?dāng) n≥4 時(shí)恒有 n!>2^n ,所以階乘階比指數(shù)階增長(zhǎng)得更快,在 n 較大時(shí)也是不可接受的。
算法的時(shí)間效率往往不是固定的,而是與輸入數(shù)據(jù)的分布有關(guān)。假設(shè)輸入一個(gè)長(zhǎng)度為 n 的數(shù)組 nums ,其中 nums 由從 1 至 n 的數(shù)字組成,每個(gè)數(shù)字只出現(xiàn)一次;但元素順序是隨機(jī)打亂的,任務(wù)目標(biāo)是返回元素 1 的索引。我們可以得出以下結(jié)論。
“最差時(shí)間復(fù)雜度”對(duì)應(yīng)函數(shù)漸近上界,使用大 O 記號(hào)表示。相應(yīng)地,“最佳時(shí)間復(fù)雜度”對(duì)應(yīng)函數(shù)漸近下界,用 Ω 記號(hào)表示:
worst_best_time_complexity.cpp
/* 生成一個(gè)數(shù)組,元素為 { 1, 2, ..., n },順序被打亂 */
vector<int> randomNumbers(int n) {
vector<int> nums(n);
// 生成數(shù)組 nums = { 1, 2, 3, ..., n }
for (int i = 0; i < n; i++) {
nums[i] = i + 1;
}
// 使用系統(tǒng)時(shí)間生成隨機(jī)種子
unsigned seed = chrono::system_clock::now().time_since_epoch().count();
// 隨機(jī)打亂數(shù)組元素
shuffle(nums.begin(), nums.end(), default_random_engine(seed));
return nums;
}
/* 查找數(shù)組 nums 中數(shù)字 1 所在索引 */
int findOne(vector<int> &nums) {
for (int i = 0; i < nums.size(); i++) {
// 當(dāng)元素 1 在數(shù)組頭部時(shí),達(dá)到最佳時(shí)間復(fù)雜度 O(1)
// 當(dāng)元素 1 在數(shù)組尾部時(shí),達(dá)到最差時(shí)間復(fù)雜度 O(n)
if (nums[i] == 1)
return i;
}
return -1;
}
值得說(shuō)明的是,我們?cè)趯?shí)際中很少使用最佳時(shí)間復(fù)雜度,因?yàn)橥ǔV挥性诤苄「怕氏虏拍苓_(dá)到,可能會(huì)帶來(lái)一定的誤導(dǎo)性。而最差時(shí)間復(fù)雜度更為實(shí)用,因?yàn)樗o出了一個(gè)效率安全值,讓我們可以放心地使用算法。
從上述示例可以看出,最差或最佳時(shí)間復(fù)雜度只出現(xiàn)于“特殊的數(shù)據(jù)分布”,這些情況的出現(xiàn)概率可能很小,并不能真實(shí)地反映算法運(yùn)行效率。相比之下,平均時(shí)間復(fù)雜度可以體現(xiàn)算法在隨機(jī)輸入數(shù)據(jù)下的運(yùn)行效率,用 Θ 記號(hào)來(lái)表示。
對(duì)于部分算法,我們可以簡(jiǎn)單地推算出隨機(jī)數(shù)據(jù)分布下的平均情況。比如上述示例,由于輸入數(shù)組是被打亂的,因此元素 1 出現(xiàn)在任意索引的概率都是相等的,那么算法的平均循環(huán)次數(shù)就是數(shù)組長(zhǎng)度的一半 n/2 ,平均時(shí)間復(fù)雜度為 Θ(n/2)=Θ(n) 。
但對(duì)于較為復(fù)雜的算法,計(jì)算平均時(shí)間復(fù)雜度往往是比較困難的,因?yàn)楹茈y分析出在數(shù)據(jù)分布下的整體數(shù)學(xué)期望。在這種情況下,我們通常使用最差時(shí)間復(fù)雜度作為算法效率的評(píng)判標(biāo)準(zhǔn)。
為什么很少看到 Θ 符號(hào)?
可能由于 O 符號(hào)過(guò)于朗朗上口,我們常常使用它來(lái)表示平均時(shí)間復(fù)雜度。但從嚴(yán)格意義上看,這種做法并不規(guī)范。在本書(shū)和其他資料中,若遇到類(lèi)似“平均時(shí)間復(fù)雜度 O(n)”的表述,請(qǐng)將其直接理解為 Θ(n) 。
更多建議: