演算法和資料結構-初級 | 第五課:演算法複雜度實踐

程式 = 資料結構 + 演算法
作者 謝恩銘 轉載請註明出處
公眾號「 ofollow,noindex">程式設計師聯盟 」(微信號:ProgrammerLeague )
原文: https://www.jianshu.com/p/060ef52580af
內容簡介
- 前言
- 尋找最大和最小的元素
- 尋找不重複的元素
- 尋找不重複的元素:另一種方法
- 第六課預告
1. 前言
經過 演算法和資料結構-初級 | 第三課:演算法複雜度(上) 和 演算法和資料結構-初級 | 第四課:演算法複雜度(下) ,我們講完了演算法複雜度,是時候來做點實踐的練習,鞏固一下所學知識點了。
演算法的複雜度是個不錯的知識點,但是它與我們這門演算法的課程有什麼關係呢?我們慢慢來看。
演算法學(Algorithmics)是設計和研究演算法的科學,它的歷史可比電腦科學的歷史久遠多了,但今天演算法學卻幾乎全由電腦科學家實踐。
演算法學是一個非常廣泛的領域,需要不少數學知識。當然了,並非所有電腦科學家都需要成為天才的演算法學家。從演算法的角度來看,大多數程式設計師面臨的問題實際上非常簡單。
但我們有時需要實現一些更復雜的東西。在這種情況下,演算法方面的基本知識就會顯得非常有用。我們並不要求你發明一種革命性的新演算法並給出其複雜度的具體證明,但為了能夠準確地使用那些在網路上或軟體庫中找到的演算法,還是有必要接受一下“基礎培訓”的。
懂演算法會讓你更有效率,能夠更好地理解你所要解決的問題,也不會寫出不規範的程式碼:有一些程式碼儘管可以正常執行,但從演算法的角度來看卻是不合理的。一個經驗不豐富的程式設計師可能會直接使用這些不合格的演算法(他會想:“程式碼能執行,所以應該沒什麼問題”),但你因為懂演算法,就能很快發現程式碼的問題,並改寫出一個優秀得多的版本。
聽我說了這些,你可能有點躍躍欲試了。下面是兩個簡單的對於演算法複雜度的研究題,它們可以讓你更準確地瞭解演算法複雜度的作用。
2. 尋找最大和最小的元素
問題 1:
有一個正整數列表,我們想要找到列表中最大的整數。
這個問題的經典解法如下:遍歷此列表,並一直儲存迄今為止發現的最大元素,稱其為“當前最大值”。
我們可以將此演算法描述為:
一開始,“當前最大值”等於 0。我們用列表中每一個元素去和“當前最大值”做比較,如果當前遍歷到的元素比“當前最大值”更大,則將“當前最大值”設為當前元素的值。在遍歷完整個列表後,“當前最大值”就真的“實至名歸”了。
下面我們給出此演算法的一種實現,是用“世界上最好的程式語言” PHP 來實現的(當然,你也可以用其他程式語言來實現):
<?php function max($list) { $current_max = 0; foreach ($list as $item) if ($item > $current_max) $current_max = $item; return $current_max; } ?>
我們可以快速驗證此演算法是正確的:我們只需要確認在此演算法執行時,“當前最大值”總是等於到目前為止所遍歷到的列表元素裡最大的那個值。
我們也注意到此演算法是會“結束”的,它不會陷入無限迴圈:此演算法遍歷完整個列表,然後停止。這看起來像一個不重要的細節,但實際上有一些程式語言裡是可以表示無限多元素的列表的:在這種情況下,我們的演算法是不正確的。
現在讓我們研究此演算法的複雜度。我們要考慮哪些操作呢?顯然,大部分工作都在於將當前元素與“當前最大值”進行比較(畢竟,“當前最大值” current_max 的初始化(初始化為 0)並不佔多少執行時間),因此我們計算“比較操作”的次數,將其作為此演算法的運算元。
演算法的執行時間取決於哪些引數呢?可以想見,執行時間並不依賴於列表中每個元素的值(在此,我們假設兩個整數的比較時間是恆定的,不論它們的值是多少)。因此,我們用元素列表的長度 N 來量化輸入。
對於一個包含 N 個元素的列表,我們要進行 N 次比較:每個元素都與“當前最大值”進行一次比較。因此,演算法的時間複雜度是 O(N):它的執行時間是呈線性的,與列表的元素數目 N 成正比。
那麼,此演算法的空間複雜度是多少呢?此演算法使用了一個列表,裡面的元素佔用了一定的記憶體空間。但是,這個列表在我們查詢其最大元素之前就已經存在了,它所佔用的記憶體空間並不是由我們的演算法分配的,因此我們說此列表的元素數目 N 並不會被考慮到演算法的空間複雜度的計算中,我們只考慮由我們的演算法直接申請的記憶體。
而我們的演算法直接申請的記憶體空間幾乎可以忽略不計,因為最多就是佔用了一個臨時變數(current_max),用以儲存“當前最大值”。因此,我們的演算法所佔用的記憶體空間不依賴於列表的長度: (我們將空間複雜度記為 O(1),表示它不依賴於 N)。
對於我們的演算法,現在只剩下一個小細節要注意了:如果我們的列表是空的,那麼返回的最大值將是 0。要說“一個空的列表的最大值是 0” 顯然不一定是正確的:在某些情況下,如果列表是空的,最好返回一個錯誤。
因此我們可以改進一下我們的演算法:我們不再為“當前最大值”賦初值為 0,而是以列表的第一個元素(如果該列表為空,則返回一個錯誤)作為“當前最大值”的初始值。然後,我們從第二個元素開始比較。
經過改進後的演算法執行 N-1 次比較(因為我們不必將第一個元素與它自己進行比較)。不過,這並沒有改變演算法的時間複雜度:N 和 N-1 之間的時間差並不依賴於 N,它是恆定的,因此我們可以忽略它:兩種演算法具有相同的時間複雜度,它們都是時間線性的(時間複雜度是 O(N) )。
最後,我們注意到第二個演算法也適用於負數(如果列表的所有元素都是負數,第一個演算法會返回 0,這顯然不正確)。因此改良後的第二個演算法更通用,也更好。
當然了,查詢列表中最小值的演算法和查詢最大值是類似的,我們就不贅述了。
3. 尋找不重複的元素
現在我們來看第 2 個問題。
問題 2:
有一個列表 1,其中包含重複項(多次出現的元素):我們想要構建一個包含與列表 1 相同元素的列表 2,但是列表 2 中每個元素只重複出現一次。
例如,列表 1 裡有以下元素:
AABCDBCA
則列表 2 將包含以下元素:
ABCD
你想到解決這個問題的演算法了嗎?在閱讀我的解決方案之前,請自己思考一下。
我的解決方案
我的演算法如下:
對於給定的包含重複元素的列表 L,我們要構建一個新的列表 U(取英語 Unique(“獨一無二的”)的第一個字母),列表 U 一開始是空的,我們需要往裡面填充元素。
我們遍歷列表 L,對於列表 L 中的每一個元素,我們確認一下它是否存在於列表 U 中(可以用與之前的查詢最大元素類似的演算法,畢竟就是逐一比較元素嘛)。
如果列表 L 中遍歷到的元素還不在列表 U 中,就將這個元素新增進列表 U 中;如果已經存在於列表 U 中,就不新增。
遍歷完列表 L 後,列表 U 中就擁有了和列表 L 相同的元素,只是這些元素都是不重複出現的。
練習:使用你喜歡的程式語言來實現上述從列表中提取不重複元素的演算法。
複雜度
這個演算法的複雜度是多少?如果你充分理解了之前查詢列表最大值的演算法的複雜度的計算,那麼這對你來說應該很簡單。
對於給定列表 L 中的每個元素,我們都會執行遍歷列表 U 的操作,因此執行的運算元與列表 U 包含的元素數目有關。
但問題是:列表 U 的大小在遍歷給定列表 L 的過程中會發生變化,因為我們會新增元素進列表 U。當我們遍歷到列表 L 中的第一個元素時,列表 U 還是空的(因此我們不執行任何比較操作);當我們遍歷到列表 L 的第二個元素時,列表 U 有 1 個元素,所以我們要再執行一個比較操作。
但是當我們遍歷到列表 L 中的第三個元素時,我們就變得不是那麼肯定了:如果列表 L 中的前兩個元素是不相同的,它們都被新增到 U 中,在這種情況下我們要執行 2 次比較操作(將列表 L 中的第三個元素分別與列表 U 中的兩個元素作比較);如果前兩個元素是相同的,那麼列表 L 中的第二個元素就沒有被新增到列表 U 中,只執行 1 次比較操作。
正如我們的課程裡已經說過的,複雜度的計算需要考慮在“最壞的情況”(worst case)下:也就是執行的運算元目最多時的複雜度。因此,我們將認為給定列表 L 的所有元素都是不相同的。
在“最壞的情況”下,我們將給定列表 L 的所有元素逐一新增進列表 U 中。假設給定列表 L 一共有 N 個元素,在遍歷到給定列表 L 的第 N 個元素時,我們已經向列表 U 添加了 (N-1) 個元素了,因此這時要做 (N-1) 次比較操作。
所以我們總共要做的比較運算元是 0 + 1 + 2 + ... + (N-1) 。開始時的運算元少,越到後面做的操作越多(有點像人生,出生時責任比較少,慢慢地責任越來越大,要處理的事情也越來越多,不過也說明你在成長,畢竟“能者多勞”)。
上面這一串數字相加,得到的總運算元是 N * (N - 1) / 2(這個不難,是數學裡面的等差數列求和公式),由於我們在計算複雜度時考慮的是 N 很大的情況,上面的結果可以約等於 N * N / 2,即 N 2 / 2 個操作。
因此,我們的演算法具有 O(N 2 ) 的時間複雜度(我們去除了常數因子 1/2)。我們也可以稱 O(N 2 ) 為“二次/平方”的複雜度(正如我們稱 O(N) 具有“線性”的複雜度)。
與之前那個查詢最大元素的演算法比起來,現在這個演算法除了速度較慢(時間複雜度較高)之外,還具有更高的空間複雜度:我們構建了一個最初不存在的列表 U(因此申請了記憶體空間)。
在最壞的情況下,列表 U 還具有與給定列表 L 一樣多的元素:因此將為 N 個元素分配空間,這使得空間複雜度為 O(N)。之前查詢最大元素的演算法的空間複雜度是恆定的(O(1)),但現在這個演算法的空間複雜度卻是線性的(O(N))。
該演算法只需要比較元素,因此被操作的元素並不一定要是整數:我們可以用相同的演算法來消除單詞列表中重複的單詞,重複的浮點數,等等。因此,許多演算法是與使用的元素的具體型別無關的。
4. 尋找不重複的元素:另一種方法
尋找不重複的元素,其實還有另一種演算法(聰明如你可能也想到了):我們可以先對給定列表 L 中的元素進行排序,使得所有重複的元素都相鄰,這樣排除重複元素將變得很簡單。
比如給定列表 L 初始是這樣的:
AABCDBCA
我們可以在構建列表 U 前,先對列表 L 進行排序,使其變成下面這樣:
AAABBCCD
這樣,我們之後構建列表 U 的演算法就簡單了。
演算法如下:
只需遍歷排序後的列表 L,並記住最近一次遍歷到的那個元素。如果當前元素與前一個元素相同,則這個元素是重複的,就不要把它包含在不重複元素的列表 U 中。
如果重複的元素彼此不相鄰,則上述演算法不再有效。因此我們必須先對列表進行排序。
這個新的演算法的時間複雜度是什麼?消除重複是在列表的單次遍歷中完成的,因此是線性的( O(N))。但由於我們必須先對列表進行排序,因此第一步排序的操作也必須被考慮進這種新演算法的總複雜度中。
當然了,在這裡提到列表的排序還稍微有一些太早了,因為我們在之後的課程裡才會講到排序演算法(在我們整個課程結束後,你應該會使用好幾種排序演算法)。
儘管目前我們還沒有學習排序演算法和它們的複雜度,但我還是想說一下這個新演算法的複雜度問題。
事實證明,這種演算法的複雜度取決於排序的複雜度:因為,排序基本上會執行 N 2 個操作,這遠遠超過我們之後的構建列表 U 時的 N 個操作,所以整體複雜度是 O(N 2 )。
然而,也存在更高階的排序演算法,雖然仍然執行多於 N 個操作,但比 N 2 要少得多。
我們將在之後的課程裡學習排序演算法,目前你只需要知道這個多了一步排序的新演算法比舊演算法更有效,也更“高階”。
“在列表中搜索指定元素”與“找出列表中最大/小值的元素”是非常相似的演算法,都是線性時間的(演算法的時間複雜度是 O(N)),空間複雜度都是 O(1)。
消除列表中的重複元素的演算法更復雜一些,因為最簡單的演算法在時間上具有平方的時間複雜度(O(N 2 )),其空間複雜度具有線性(O(N))。
我希望這些更具體的研究能讓你確信演算法學和演算法複雜度還是很有用的。現在你也應該已經習慣“演算法”,“時間複雜度”,“空間複雜度”這些基本概念了。
下一課開始,我們要學習資料結構了,這樣我們就能把資料結構和演算法相結合並融會貫通了,畢竟這一對“活寶”是休慼相關的。
5. 第六課預告
今天的課就到這裡,一起加油吧!
下一課:演算法和資料結構-初級 | 第六課:資料結構之陣列和連結串列(上)
365 天,堅持寫作之 5 / 365,愛上你的每一天!
我是謝恩銘,在巴黎奮鬥的軟體工程師。
熱愛生活,喜歡游泳,略懂烹飪。
人生格言:「向著標杆直跑」