首先,爲何會有「時間複雜度」和「空間複雜度」這兩個概念呢?
人在作任何事情時,都但願投入最少時間、金錢或精力等就能得到最佳收益。而在針對問題設計算法時,人們一樣也但願花費最少時間,佔用最少存儲空間來解決問題。所以,就有了「時間複雜度」和「空間複雜度」兩項指標來分別衡量算法在時間維度上的效率和空間維度上的效率。算法解決問題用時越短,時間維度上的效率越高;佔用存儲空間越少,空間維度上的效率就越高。java
在這一講中,咱們將先討論「時間複雜度」這一律念。算法
小白同窗:衡量在時間維度的效率的話很簡單呀,直接把算法寫出來跑一遍,計時一下運行了多少時間就行了!編程
但仔細一想,很快就能發現這種「馬後炮」方法的不足:運行的機器硬件,實現的編程語言以及操做系統等等因素都會影響運行的時間,而咱們但願的是衡量算法時間效率的指標僅僅只由算法自己和數據規模大小來決定。因此這種「馬後炮」方法並不能用來衡量算法在時間效率上的優劣。數組
那有沒有什麼辦法能讓咱們用「肉眼」就能看出一段代碼的執行時間呢?編程語言
先看下面這一段代碼。spa
int cal(int n) { int sum = 0; int i = 1; for (; i <= n; i++) { sum = sum + i; } return sum; }
咱們用\(T(n)\)表明代碼的執行時間,並假設每條語句的執行時間均爲\(unitTime\),以上語句的總執行次數爲\(1+1+2 \times n+1\),即\(2n + 3\)次,那麼\(T(n) = unitTime \times (2n+3)\)。若用大O表示成正比的關係,那麼該表達式就能夠記爲\(T(n) = O(2n + 3)\)。再把大O括號內的式子用\(f(n)\)表示,那麼就是\(T(n) = O(f(n))\)。該式子表示這段代碼的總執行時間\(T(n)\)與式子\(f(n)\)成正比,這就是大O時間複雜度表示法。操作系統
咱們能夠看到上面的計算都只是粗略的估計,因此大O表示法也只是用於表示代碼執行時間隨數據規模增加的變化趨勢,因此它也叫作「漸進時間複雜度」,簡稱時間複雜度。這裏的「漸進」就是指數據愈來愈大,趨近於極限的意思。.net
既然表示的只是變化趨勢,那麼不少細枝末節的東西就能夠被忽略,因此咱們只須要抓住\(f(n)\)中影響力最大的因子便可(一般爲最高次項)。顯然上述代碼中影響力最大的因子是\(n\)(係數能夠忽略),那麼剛纔的式子咱們就能夠直接記爲\(T(n) = O(n)\),表示代碼的執行時間和數據規模大小成正比。設計
剛纔咱們是經過數出全部代碼的總執行次數,再將最高次項去掉係數得出大O時間複雜度。那既然不須要再去計算那種細枝末節的東西,有沒有更簡單直接的方式來得出一段代碼的時間複雜度呢?
答:有的,直接關注代碼中循環執行次數最多的那段代碼便可。code
例如,在剛纔的例子中,第4行代碼和第5行代碼顯然就是循環執行次數最多的那段代碼,那麼\(T(n) = O(2n)\),去掉係數,便可快速得出\(T(n) = O(n)\)。
在上一小節中,咱們見到了量級爲線性階(linear)的時間複雜度。常見的時間複雜度量級還包括常數階(constant),對數階(logarithmic),平方階(quadratic),立方階(cubic),指數階(exponential),階乘階(factorial)。
int a = 1; int b = 2; int c = 3;
小白同窗:總共3行代碼,那時間複雜度不是\(O(3)\)嗎,怎麼會是\(O(1)\)呢?
答:\(O(1)\)是用來表示代碼的執行時間爲常數,即代碼的執行時間並不隨着n的增大而增加。這類的代碼即使有10000行代碼,時間複雜度也仍爲\(O(1)\)。
while (n > 1) n = n/2
這段代碼不斷將 \(n\) 自除以2來接近1,假設該語句的執行次數爲\(x\),則\(\frac{n}{2^x} = 1\),變換公式可得\(x = log_2n\)。因此這段代碼的時間複雜度爲\(O(log_2n)\)。
再看下面代碼,同理,得出其時間複雜度爲\(O(log_3n)\)。
while (n > 1) n = n/3
可實際上,不管是以 2 爲底、以 3 爲底,仍是以 10 爲底,全部對數階的時間複雜度都是記爲\(O(logn)\)。爲何呢?
答:由於對數之間是能夠相互轉換的。由於\(log_32 \times log_2n = log_3n\),因此\(O(log_3n) = O(log_32 \times log_2n)\)。而\(log_32\)是個常數,咱們在前面也提到過常量係數在大O表示法中是能夠被忽略的,因此\(O(log_2n) = O(log_3n)\)。因此在量級爲對數階的複雜度裏,咱們乾脆忽略對數的底,統一表示爲\(O(logn)\)。
再看這段代碼。
for (int i = 0; i < n; i++) { while (n > 1) n = n/2 }
將時間複雜度爲\(O(logn)\)的代碼執行n遍,即爲\(O(nlogn)\)。
int a = 0; for (int i = 0; i < n; i++) { for(int j = 0; j < n; j++) { a++; } }
循環執行次數最多的代碼爲a++
,執行次數爲\(n^2\),因此\(T(n) = O(n^2)\)。
立方階同理,只要再多套一層for循環便可。
for (int i = 0; i < 2^n; i++) { n++; }
int factorial(int n) { for (int i = 0; i < n; i++) { factorial(n - 1); } }
循環執行次數最多的語句爲factorial(n - 1)
,在當前 \(n\) 下,會調用n次factorial(n - 1)
,而在每一個 \(n - 1\) 下,又會調用n - 1次factorial(n - 2)
,以此類推,得執行次數爲 \(n \times (n - 1) \times (n - 2) \times ... \times 1\),即 \(n!\)。
很明顯,對於相同的數據規模,不一樣的時間複雜度在時間維度的表現上有着巨大的差別。
時間效率排名爲(越靠右越表明算法的時間效率越低):\(O(1) < O(logn) < O(n) < O(n^2) < O(n^3) < O(2^n)\)。
此外,咱們還須要知道,同一段代碼在不一樣狀況下會有不同的時間複雜度,讓咱們看下面這段代碼。
// Tell whether the array a contains x. boolean contains(int[] a, x) { for (int i = 0; i < a.length; i++) { if (x == a[i]) return true; } return false }
首先,執行次數最多的語句很明顯爲if (x == a[i])
。
接着問題來了,假如咱們想找的元素x正好就處於數組的第一個位置,那麼不管數組規模多大,該語句的執行次數都爲\(1\),此時\(T(n) = O(1)\),這種狀況就是最好時間複雜度(best-case time complexity);假如咱們想找的元素x不在數組中,那麼這整個數組都會被遍歷一遍,if (x == a[i])
的執行次數爲\(n\),則\(T(n) =O(n)\) ,這種狀況就是最壞時間複雜度(worst-case time complexity),咱們一般會以最壞的角度來進行時間複雜度的評估。\(\frac{x+y}{y+z}\)
設 \(T_1(n)\), 設\(T_2(n)\), ...分別爲全部可能狀況下的時間複雜度;設 \(P_1(n)\), \(P_2(n)\), ...爲這些對應狀況的分佈機率。則平均時間複雜度(average-case time complexity)爲 \(P_1(n)T_1(n) + P_2(n)T_2(n)+ ...\)
平均時間複雜度一般來講較難計算,由於難以得出各種狀況的分佈機率,有時爲了簡便,會將最好時間複雜度以及最壞時間複雜度相加除以二來得出平均時間複雜度,例如上述代碼的平均時間複雜度能夠簡單計算爲\(O(\frac{1 + n}{2})\),即爲\(O(n)\)。
除了大O表示法,你可能還會見過\(\Omega\)表示法和\(\Theta\)表示法。這三類表示法均可以用於表示時間複雜度,可是略有不一樣。大O表示法\(O(f(n))\)表示的是漸進上界(upper bound),即當數據規模愈來愈大時,算法的執行時間最多也不會超過\(M \cdot f(n)\)(M爲某一常數)。接下來咱們將詳細介紹另外兩種表示法。
\(\Omega\)表示法\(\Omega(f(n))\)表示的是漸進下界(lower bound),即當數據規模愈來愈大時,算法的執行時間最少也不會小於\(k \cdot f(n)\)(k爲某一常數)。
那麼如何快速地找出一段代碼的漸進下界呢?
先看個例子,\(T(n)\)仍用於表明代碼的總執行時間,並假設每條語句的執行時間均爲\(unitTime\)。
假設某算法的總執行時間\(T(n) = unitTime \times (2^n + 5)\),由於不管\(n\)有多大,\(unitTime \times (2^n + 5) \geq unitTime \times 2^n\)的式子恆成立,那麼\(unitTime \times 2^n\)就可被稱爲是該算法在時間維度上的漸進下界,記爲\(T(n) = \Omega(2^n)\)(\(unitTime\)爲常量係數,可被忽略),表示該算法的執行時間最少不會小於\(k \cdot 2^n\)(\(k\)爲某常數)。
因此要想用\(\Omega\)表示法表示代碼的時間複雜度時,總結起來就是先列出\(T(n)\),\(T(n) = unitTime \times 代碼的總執行次數\),再糾出\(T(n)\)中的最高次項,將其去掉係數便可,即\(T(n) = \Omega(去掉係數的最高次項)\)。這樣就成功表示了這段代碼在時間維度上的漸進下界了。
\(\Theta\)表示法\(\Theta(f(n))\)表示的是漸進緊確界(tight bound),即當數據規模愈來愈大時,算法的執行時間將落在\(k_1 \cdot f(n)\)和\(k_2 \cdot f(n)\)之間(\(k_1,k_2\)均爲常數)。
那麼如何用\(\Theta\)表示法表示算法的時間複雜度呢?
若是能夠用同一個多項式表示一個算法的O和\(\Omega\),那麼這個多項式就是咱們要求的漸進緊確界了。
例如:假設某算法的\(T(n) = unitTime \times (3n^3 + 2n + 7)\)。那麼用大O表示其時間複雜度即爲\(O(n^3)\),用\(\Omega\)表示其時間複雜度即爲\(\Omega(n^3)\)。兩種表示法的多項式均爲\(n^3\),那麼用\(\Theta\)表示該算法的時間複雜度即爲\(\Theta(n^3)\),表示該算法的執行時間落在\(k_1 \cdot n^3\)和\(k_2 \cdot n^3\)之間(\(k_1,k_2\)均爲常數)。
小白同窗:「時間複雜度」這個概念和\(O、\Omega、\Theta\)這三種表示法的關係我仍是有點搞不清哎?
答:首先,「時間複雜度」這個概念是用來衡量算法在時間維度上的增加趨勢。這個概念具體能夠用\(O、\Omega、\Theta\)這三種表示法表示。若想表示「漸進上界」就用O;想表示「漸進下界」就用\(\Omega\);想表示「漸進緊確界」就用\(\Theta\)。實際工做中,最經常使用的是大O表示法。
小白同窗:怎麼感受三種表示法的求法都同樣?得出來的都是一樣的多項式?
答:確實同樣,基本求法都是最高次項去掉係數。儘管得出來的多項式相同,可是表達的意義卻不盡相同:O表示算法的執行時間最多不超過\(k_1 \cdot f(n)\);\(\Omega\)表示算法的執行時間最少不小於\(k_2 \cdot f(n)\);\(\Theta\)表示算法的執行時間在\(k_3 \cdot f(n)\)和\(k_4 \cdot f(n)\)之間。(\(k_1,k_2,k_3,k_4\)均爲常數)
小白同窗:最開始的時候介紹了最好狀況最壞狀況,怎麼後面又來了個漸進上界漸進下界?這二者是同樣的嗎,漸進上界(upper bound)就指的是最壞狀況(worst-case),漸進下界(lower bound)就指的是最好狀況(best-case),對嗎?
答:錯誤。上界、下界和最壞、最好狀況並非一回事。讓咱們先看下面這個故事(摘選自Khan Academy)。
假設小白同窗某天被抓進監獄,獄長告訴他只有玩完下面這個遊戲才能放他走。
他給小明同窗展現了兩個如出一轍的盒子,並告訴他遊戲規則以下:
具體地,A盒子裏有17只小蟲子,B盒子裏有32只小蟲子,不太小明並不知道。
接下里輪到小明作出選擇了,前提假設小明喜歡少吃點蟲子。
那麼對於小明來講,很顯然,最好的狀況(best-case)就是選到A盒子。
那在這種場景下,吃到的蟲子最少爲10只(下界),最多爲20只(上界)。
最壞的狀況(worst-case)是選到B盒子。
那在這種場景下,吃到的蟲子最少爲30只(下界),最多爲40只(上界)。
因此咱們能夠看出,不管是最好狀況仍是最壞狀況,兩種狀況下都是存在上界和下界的。
一樣也很容易看出,最壞狀況裏的上界是最最最糟糕的,而最好狀況裏的下界是最最最好的。
本講介紹了「時間複雜度」這一律念,用它來表示算法在時間維度上的效率。它不是計算算法具體耗時的,而是反映算法在時間維度上的一個趨勢。時間複雜度具體能夠用\(O、\Omega、\Theta\)這三種表示法表示,分別表示算法執行時間的「漸進上界」、「漸進下界」和「漸進緊確界」。此外還介紹了算法在最好、最壞、平均狀況下的時間複雜度以及一些常見的時間複雜度量級。
創做不易,點個贊再走叭~