【算法】 算法和數據結構緒論
算法和算法分析
先說點無關緊要的。初中的時候,知道有CS這門專門的學科存在的時候最開始的概念中CS就是等同於算法。這有可能是因為當時的前桌是後來一代CS傳奇WJMZBMR。。因為當時看起來十分高端,再加上後來努力的方向完全和CS不搭邊,所以對於算法二字一直心中抱著一種敬畏之情,覺得是整個CS中最幹的幹貨部分。後來決定入這行之後,我的領導對我說算法這東西雖然很高大上,但是在日常工作中我們用的並不多(我們部門主要做運維和DevOps,確實對這方面的需求不大)所以也就一直耽擱著。但是隨著深入,以及在網上和各種場合查閱到越來越多的資料中接觸算法和一些算法術語越來越頻繁。我覺得是時候學習一下了。所以我就買了一本北大一位先生著的數據結構與算法~Python語言描述~,一方面想了解一點算法和數據結構的知識,另一方面也可以學習一下Python。這本書不是很厚,作者也說了沒有涉及很高深的知識,雖然我也不一定能學會學好,但是我想,努力一下試試看把,什麽都不學總比在床上刷刷B站,玩玩遊戲要好一點。
■ 問題,問題實例和算法
要搞清楚算法的一些概念,區分這三個概念十分重要。問題對應的是一種需求,對應一種需求,人們可以通過分析和推斷來抽象出一個計算機需要解決的問題。問題具有通用的特點,比如判斷一個正整數是否為素數這就是一個問題。問題實例通俗點來說就是一個具體的問題,它很明確的指出一個問題的很具體的描述,一般具有正確解。比如1013這個正整數是否為素數,相對於上面的那個問題,就是一個問題實例。顯然,一個問題反映出所有相關問題實例的共性。算法是對解決問題過程的一個嚴格的描述。因為算法是和問題對應的,所以這個問題的所有實例都可以用算法來求得解。比如設計一個判斷某個正整數是否為素數的算法A,這個算法A對應的是上面的問題,當然把這個算法A套用在問題實例中,我們就可以得出1013以及其他各種各樣的正整數是不是素數了。
● 算法具有的性質
算法是一種對問題解決過程的具體描述,為了使其嚴格有效,算法通常具有以下性質
有窮性(算法描述的有窮性):算法應該可以用有限的語言,尤其是語言中有限的祈使(或者計算機語言中就是指令)進行描述。
可行性:算法中的的指令必須清晰明確,所描述的過程完全可以通過機器來機械地執行。
確定性:根據某個問題(通常也是以問題實例的形式給出,通過對問題實例的分析以及配合算法的測試來抽象出一個問題),算法將產生一個唯一確定的動作序列,任何一個相關問題的實例通過這個確定的動作序列之後,就可以得到相關解
終止性(算法行為的有窮性):對於任何實例,算法產生的動作序列都是有限的
輸入/輸出:算法有明確的輸入和輸出
● 算法的描述形式
算法可以用自然語言描述,這樣對不了解計算機語言的人比較友好,但是通常比較啰嗦而且容易出現歧義
如果用計算機語言描述算法,可以做到很精確(因為最終算法就是以這種形式呈現到程序中的。)但是對於一般閱讀者,即使是懂計算機語言的人而言閱讀起來也需要一些力氣,可以說對閱讀者不是很友好
折中一下,用偽代碼的形式描述。偽代碼結合了計算機語言(通常用於邏輯結構的表示)和自然語言(對於具體的內容操作表示)。偽代碼描述的形式結合了自然語言的表達友好和機器語言的簡潔清晰。
■ 算法設計與分析
所謂算法設計,就是從一個問題出發,通過分析和思考來得到一個能夠解決問題的算法。算法設計中有一些常見的設計模式:
枚舉法:列舉出問題所有可能的解並從中篩選出合適的解。這種方法可以說是利用了計算機強大的計算性能。電腦比人腦高明之處就在於它可以快速地重復大量相似或者相同的工作,以快速得到結果。
貪心發:根據問題的信息得到部分解,認為部分解可以作為解的一種或者把部分解逐步擴充得到完整解。在問題比較復雜時適用が,通常找到的解並不是最好的解
分治法:把問題分解成一個個小問題,然後逐步解決這些小問題,組合每個小問題的解得到整個問題的解
回溯法:當問題解決沒有清晰的路徑時,程序需要逐步試錯,當發現一種方法走不通的時候就要回溯到之前的路徑點來嘗試新的路徑
動態規劃:當問題很難直接了當地求解,需要更多信息時,可以在解決問題的過程中逐漸積累信息,這些信息可以為後面問題解決過程所用,而後面的這些過程又可以進一步地積累到更多的信息
分支限界法:可以看做是回溯法的一種優化,在搜索過程中可能會得到一些沒有用的信息,就把這些信息給刪除以減小求解成本
以上算法設計模式,並不是嚴格推導出來的,而是前人在無數的實踐中的一些總結,當然這些描述也是非常抽象的,光看下來可能沒法知道任何有用的信息。但是需要記住的是,真正的算法設計過程通常需要綜合考慮多種設計模式。
另,算法實現為一個程序之後就需要開始運算,而運算作為處理信息的一種過程肯定是要有運算消耗的。比如時間上的消耗和空間上的消耗。這種消耗除了跟算法有關,跟硬件情況,運行環境,實現方式(哪種語言)等等。在以上條件都相同的情況下,算法就確定一個程序的消耗。消耗越小的算法,其運行效率當然也就越高了。
當我們設計出一個算法之後,我們就需要分析這個算法是不是夠高效。在有些情況下,因為計算機的高效的特性,算法是否高效可能顯得不是那麽有意義,但是在更多時候,很可能決定了算法有沒有存在的價值。比如一個算法要花三天算出明天的天氣預報和三小時算出明天的天氣預報意義完全不一樣。為了衡量算法是不是高效,我們還需要一種度量。
● 算法度量的單位和方法
在計算過程中,硬件每執行算法中的一個操作所帶來的時間上和空間上的消耗都是不同的,而為了算法度量能夠有一定通用性(比較不同操作算法的效率),在制定算法度量的時候就需要一定抽象,比如下面的兩條假設:
1. 所用的計算設備準備了一組儲存單元,每個單元都能保存固定的一點有限數據(以此標準化空間上的消耗)
2. 機器能夠執行的一次基本操作都是消耗一個單位時間(以此標準化時間上的消耗)
假設中提到的儲存單元的大小,以及單位時間的長短,可能根據硬件,環境等條件不同而不同,但是這不是算法度量需要考慮的。在算法比較中,通常默認是比較除了算法之外,其他條件都完全相同的兩個程序的執行效率。所以可以借助上面兩條假設把算法度量抽象化,標準化。
雖然算法是針對地解決問題的,但是機器不可能看得懂一個問題的描述,所以通常算法度量還是得以具體的問題實例來進行。這就帶出一個概念,問題規模。比如解1013是否為素數和10331310131是否為素數這兩個問題,顯然兩者可以套用同一套算法,但是兩者的消耗完全不同。對於這樣一種算法,到底算高效還是不高效,並不是通過一個問題實例的具體消耗能決定的。所以,算法的度量通常是一種計算資源消耗和問題規模相關的函數關系。如果問題規模很小,不論用哪種算法的消耗都差不多,且在可以接受的範圍內,那麽算法度量就顯得不是那麽有意義。而當問題規模越來越大時,如果計算消耗增長得越來越快,那麽就可以說算法的效率不是太好,應該避免。問題規模在上面求素數那兩個實例中,可以認為是數的大小,或者數的位數等等,一般來說只要有問題實例的一個統一的度量,具體這個度量是什麽並不是很重要。總之能看出來哪些問題規模較大哪些較小即可。
另外還需要註意的一點,即使是規模相同的問題實例,在有些算法中消耗也是不同的。比如判斷1013和1012是否是素數的話,比如在算法中最開始添加一個判斷:如果是偶數就直接返回否,這樣兩者消耗就相差很多了。對於這種情況,其實對於規模相同的問題實例,我們通常關註的是最壞的情況下算法的消耗(有時候也會關註平均消耗),但是不太會關註比較樂觀的情況下的消耗。
● 算法復雜度
算法復雜度就是一種算法的度量方法。如上所說,對於抽象的算法通常無法給出精確地度量,所以要做的是估計算法的復雜性,而算法復雜性量化一點說就是算法的消耗處在的量級(因為不論在何種外部條件下,算法度量中的單位時間和空間都是很小的,所以多一個少一個不是很有所謂)。在估算量級的過程中,常量因子可以認為沒有什麽價值,比如100n**2和3n**2都是n**2量級的(n是問題規模的描述)。這裏借用了微積分中常用的無窮小的概念,並采取了無窮小的記法f(n) = O(g(n))。f(n)就是算法復雜度這個算法度量(一個消耗關於問題規模的函數),而g(n)是類似於n**2,logn,n,1(常量函數)的一個關於問題規模的n的函數。把g(n)記入大O表明算法復雜度f(n)隨著n的增長,其增長速度受到g(n)的限制。兩個算法,只要其g(n)相同,就可以認為兩個算法的量級相同,就認為兩者的復雜度基本一樣。
常用的g(n)有1,logn,n,nlogn,n**2,n**3,2**n。這幾個函數從前到後其增長速率逐漸變快。具有這些g(n)的算法復雜度也被稱為常量復雜度,對數復雜度,平方復雜度,指數復雜度等等。假如一個算法A1是對數復雜度而A2是平方復雜度,通常而言同樣規模的問題實例用A1算法進行運算的消耗要遠小於用A2算法計算的消耗(當然這只是通常,上面也說了算法度量只是關註最壞情況,假如某個實例剛好是A2的樂觀情況那麽可能A2很快就能算出來了)
● 算法分析
算法分析就是通過一個已知的算法來得出其復雜度的過程。以考慮時間開銷的時間復雜度為例,從算法層面看,一個普通的程序通常包含了基本操作,順序結構,循環結構和選擇結構這幾種結構。
基本操作的復雜度通常認為是常量復雜度,比如賦值,四則運算,以及這些的組合都是基本操作。
順序結構是指多個操作按順序復合的情況。通常其復雜性是每一步操作復雜性的總和。
循環結構的復雜度是循環頭的復雜度乘以循環體的復雜度。
選擇結構的復雜度是各個選擇子句中最大復雜度(這裏又體現出考慮最壞情況)
比如這樣一個Python程序:
#把n階矩陣m1和m2的乘積存入矩陣m for i in range(n): #O(n) for j in range(n): #O(n) x = 0.0 #O(1) for k in range(n): #O(n) x = x + m1[i][k] * m2[k][j] #O(1) m[i][j] = k #O(1)
其復雜度T(n)是:
T(n) = O(n)*O(n)*(O(1)+O(n)*O(1)+O(1)) = O(n)*O(n)*O(n) = O(n**3)
可以看到python中的for i in range(n)這樣的語句,因為是遍歷一個長度為n的列表,其復雜度n個O(1)相加,即O(n)。循環頭的O(n)再拿去乘以循環體的復雜度,嵌套循環則用括號在算式中也嵌套。在得到算式後面的化簡過程遵循無窮小之間的運算規律。比如括號中只考慮階最高的無窮小,相加的低階無窮小被忽略了,相乘的無窮小則其參數互相相加。
最終可以得到這條算法的復雜度是立方復雜度。
■ Python的復雜度
上面所說的算法復雜度是泛泛而談,具體到Python中又有一些特殊的情況。比如Python作為一門比較高級(相對底層而言)的語言,它已經提供了很多包裝好了的“基本操作”。在用這些操作的時候,有時候我們會以為我們做的是一個基本操作但是實際上有可能是一個復雜度並非為O(1)的操作。下面是一些簡單的說明,具體的分析留到後面具體的章節中
基本運算和賦值是基本操作,復雜度是O(1)
序列的復制和切片操作是O(n),跟序列的長度有關
list,tuple的元素訪問、賦值和修改都是O(1)
構造一個空的對象是O(1),如果像是list,str這種類型構造時如果指定了長度為n的內容那麽就是O(n)
dict加入新鍵值對最壞情況下是O(n)但是平均情況下的復雜度是O(1)
以上復雜度都是針對時間消耗而言。對於空間消耗需要註意的是
Python中對於各種組合元素(通常是指str,list,tuple,dict等python自帶的高級一點的數據類型)都沒有預設最大元素個數。但在實際使用中,從內存角度看元素個數長度只會增不會減。比如li = [1,2,3]之後li中確實是有3個元素的長度。如果li.append(4)之後就是4個長度。這很好理解。但是如果此時del(li[3])之後,雖然len(li)變成了3但是內存中的li對象依然保持4個元素 的長度,這是需要註意的
Python中的數據結構
■ 什麽是數據結構
書上有很大一堆比較學術性的解釋。在我的體驗中,我認為所謂數據結構就是人為地規定一些數據格式來方便對問題的抽象和編程。從集合論來看,一般而言,一個數據結構D = (E,R)。其中E表示一個數量有窮的數據集合而R代表E中這些數據之間的某種關系。換言之,一個具體的數據結構就是要有具體的數據和這些數據之間的邏輯關系。
一些典型的數據結構有:
集合結構:其數據元素之間沒有明確的關系指定,即R是一個空集,這樣的數據結構就是把元素包裝成一個整體,是最簡單的一類數據結構
序列結構:數據元素之間有明確的先後關系,存在一個排位在最前的元素。除了最後的元素之外每個元素都有唯一的後元素。序列結構還可以細分成簡單線性結構,環形結構和ρ型結構
層次結構:其數據元素分屬於一些不同的層次,一個上層元素可以關聯著一個或者多個下層元素,關系R形成一種層次性。
樹形結構:屬於層次結構的一種。
圖結構:數據元素之間可以有任意的互相關聯,其R十分復雜且靈活多變,是一類復雜的數據結構。其實前面所有的數據結構都可以認為是圖結構的一種簡化或限制的情況
根據數據結構的不同特點,還可以細分數據結構結構性數據結構和功能性數據結構。結構性數據結構(Python中如list,str,tuple)等,結構性數據結構指出的是一種有具體結構要求的數據結構。功能性數據結構沒有結構上死的規定,可以看做是容器一樣支持存放數據,然後利用其特性進行一些運算,功能性數據結構的例子有棧,隊列,優先隊列等等。
■ 內存單元和地址
(不知道為什麽這部分內容要放在數據結構中。。)
內存的基本結構是一批線性排列的數據單元,每個單元有唯一的編號被稱為單元地址,對內存中的數據進行訪問必須要知道相關單元的地址。在許多計算機中,可以一次性存取多個單元的內容,在現在常見的64位計算機中,CPU一次可以存取8個字節的數據,也就是說可以一次性訪問8個數據單元。
正如上面提到過的大多組合數據類型存取值是一個O(1)的操作,這也就說明了,基於單元地址的對內存中一個存儲單元的訪問是一個O(1)的操作,這和單元所在位置,內存整體大小等無關。
■ Python對象和數據結構
● python中的變量和對象
對於初學Python的人來說這兩個概念經常容易搞混,其實Python在數據存儲的本質上來說和C,Java等語言是不同的。在Python中,給變量約束一個值看似和C中差不多,但是實際上,python首先把這個值構造成一個對象存儲在內存中,然後把這個內存中對象的地址約束給相應的變量。所以在Python中,我們不需要指出某個變量的類型和它應該有的長度,因為無論是什麽變量,都存的是一個地址,所有變量所需要的空間大小是一樣的。而那個地址指向的內存儲存單元(或者以該單元為開始的一片內存空間中)儲存的才是真的數據。這種變量的實現方式被稱為變量的引用語義。而像C一樣把值直接存在變量的儲存區的做法被稱為變量的值語義。
在Python中,通過變量來取得一些具體數據的操作也是O(1)的所以這方面的消耗並不比低級語言大很多。
● Python中對象的表示
表示是指為了讓電腦能夠更好的理解邏輯數據的構造的數據結構。Python中的對象的表示其實是已經設計完成,不需要我們太多關心的,但是了解一下有利於我們更好地進行工作。
Python語言的實現基於一套精心設計的鏈接結構,變量與其值對象的關系通過鏈接的方式實現,對象之間的聯系同樣也通過鏈接。一個復雜的對象內部也可能包含了幾個子部分。相互之間通過鏈接建立聯系,例如一個list中包含了10個字符串的話,在實現中,這個list在內存中其實保存了這10個字符串各自的鏈接關系。
Python中的組合對象可以是任意大規模的,每個對象需要的儲存單元數量不同,還可以有內部的復雜結構。對於這樣一種復雜的情況,要有效地安排,管理內存是比較麻煩的。不過好在Python自帶了一套存儲管理系統,負責管理可用內存,釋放不再使用的內存,安排各種對象的存儲以實現靈活有效的內存管理。程序中要求建立對象時,管理系統會為它安排存儲;當某些對象不再使用時,就回收其占有的內存。存儲管理系統屏蔽了具體內存使用的細節,減輕了編程人員的負擔。
【算法】 算法和數據結構緒論