軟件工程大師能夠組織好自己的程序,預先發現自己程序的行為方式,即使發生問題也能很快的排出錯誤,就像一部汽車一樣,擁有各個獨立的模塊,可以分別構造、替換、排出錯誤。我們這一章正是要了解這種抽象的能力,將對計算過程的認識從實際層面隔離開來,形成更為高階的抽象認識。
接下來的課程將使用Lisp中的scheme方言,這一語言應用的范圍并不廣,但是有一個很好的優點是:計算過程的Lisp描述本身又可以作為Lisp的數據來表示和操作。
1.1 程序設計的基本元素(Lisp語言基礎知識)
在程序的設計中,我們必須要處理兩類:過程和數據。這一章的例子中我們使用的是簡單的數值數據,以將我們對注意力放在構造過程對規則中去。
① 表達式
程序設計里面最簡單的就是表達式,你最好找到一個lisp的解釋器,輸入表達式然后得到響應,我使用的是DrRacket和mit-scheme。
你可以嘗試輸入一些表達式,這里和我們平常的程序設計語言不同的是對于表達式的求值,使用的是前綴的方式,這樣也有兩個好處:1> 完全適用于任意多個實際參數的過程;2> 允許嵌套可以直接擴充;
我們建議在適用復雜的表達式的時候,保持各運算對象的垂直對齊:
② 命名和環境
我們編寫復雜的程序,也是一步步由簡單的過程構成,如果必須記住并重復每一個細節,這棟大廈是根本無法完工的。好在解釋器提供了一個名字和對象的關聯功能,也就是lisp中的define,提供了一種由名字到過程的簡單抽象。
如此以來,當我們在計算圓周的時候就不需要重復的輸入pi和radius的值,直接經由define定義的名字引用就可以了。
解釋器提供的這種由符號到具體值的關聯過程也就意味著必須要有一種存儲能力,這種存儲被稱為環境,而環境所扮演的角色就是用于確定表達式中各個符號的意義。一個過程可能完全涉及多個不同的環境,這點后面講。
③ 組合式的求值
組合式求值不難理解,一般來說有以下步驟:
1> 求值各個子表達式的值;
2> 將最左邊的運算符應用于第一步求得的各個子表達式的值;
我們來看一個例子:
( *? ? ? ? ? (+ 2? (* 4 6) )
? ? ? ? ? ? ? (+ 3 5 7)? ? ? ? ? ? )
這一求值過程有三個子表達式,共有4個運算符,我們畫一張圖來理解:
這個樹形分支結構的每個節點的最左邊是我們的運算符,相應的右邊是表達式或者具體的值。用這種結構來看待求值的過程,可以想象子表達式求得的值向上穿行,最終形成計算結果的一個過程(390)。
反復的運用于步驟1,始終能把我們帶到一個子節點,他的最左邊是運算符,右邊是相應的具體的值,我們通常所說的運算符(+ - * /)其實也是指令序列與之關聯,也就是環境中的每個符號都有意義。
④ 定義過程
我們這里所說的過程就像我們最開始學習程序設計中的定義函數,這是一種威力更強大的抽象技術,我們可以組合多個操作,然后為其提供一個名字,在lisp中我們如下定義過程:
這一過程也就是:
(define (函數名? 形參)? (函數體))
我們也可以將我們定義好的過程由于其他過程的定義中:
(define (sum-of-squares x y)? ? ? (+ (square x) (square y)) )
⑤ 過程求值的代換模型
我們這里要講解一個代換模型,而后的篇章將發現這個簡單模型越來越不適用,直到必須使用更精細的模型取代,這種從簡而繁的過程正是建立復制事物的基礎。
我們繼續定義函數f,使得其有如下行為:
(define? (f a)? ? ? (sum-of-squares (+ a 1) (* a 2)))
我們來看看(f 5)的求值過程:
我們從實際參數5出發,一步一步向上求值,得到最終的結果136,每一步我們都將得到的具體值傳遞給相應的過程。這樣的一個求值過程稱為應用序。還有一種被稱為正則序的過程,如下:
完全展開而后求值被就是正則序,這兩種方式得到結果是一樣的,而應用序剔除了重復計算的過程,效率更高,Lisp采用的是應用序。
⑥ 條件語句和斷言
這里使用cond條件的三條語句,將順序求值,如果1,2,3中有一為真,表達式的結果就是其后的值,如果都為false的話cond的值就沒有定義。
變式,else語句:
變式,if語句:
備注:復合邏輯運算符and,or,not同于我們學過的程序語言的求值方式,不作講解。
⑦ 采用牛頓法求平方根
說明性的知識與存在性的知識之間本身存在著巨大的差異,在計算機的世界里我們需要的是更多行動性的知識,我們先來看看平方根的定義:
有了這個定義以后我們并不能轉化成有效的程序,我們沒有相應的求解的方式,只能簡單的對一個數是否是另一個數的平方根進行判斷,來看看牛頓大神的方法(哪里都有他):(牛頓法詳細為什么這么做)
假設我們要求出x的平方根,初始的猜測值為y,我們只需要計算(y + (x / y))? /? 2,就能改進我們的猜測值,隨著多次的迭代我們就將越來越趨近于我們的答案。有了方法以后,我們來看看如何轉換成相應的代碼:
1.2 過程與它們所產生的計算
我們在1.1節中已經學習了編寫程序的基本規則,但這還遠遠不夠,就像下象棋一樣,你只了解基本的走法是不可能戰勝對手的,你必須要學會打譜,了解一下經典的開局、戰術和策略。我們編寫的過程就是這種策略,要想成為編程專家,你必須要了解不同種類的過程所產生的計算過程和結果,所消耗的計算機資源以及計算過程的形狀。下面我們從最基本的過程策略講起:
① 線性的遞歸和迭代(兩種常見模式)
讓我們以階乘函數 ,其數學定義為:
我們直接將其翻譯成遞歸代碼形式:
我們還可以使用一種線性的迭代,它的原理就是如果我們準備3個變量,product保存乘積,counter作為計數器,max-count作為我們要計算的數。當我們同樣計算6的階乘的時候,我們首先令product為1,計算器counter為1,通過不斷的使用product = counter * product,counter = counter +1的方法,更新counter,將乘積累積在product直到counter的值超越了所要求得的階乘值時,停止,這一過程可以定義為如下函數:
兩種過程雖然都是同一個函數的求解,但是其計算過程的形狀完全不同,遞歸過程有一個完全展開而后計算收縮的鏈條,這個鏈條是由解釋器負責維護;而迭代過程卻不需要有這一的鏈條,所有的東西都在product、counter、max-count中保存,這種過程有變量更新計數,和函數結束時候的檢測。
這里特別要注意一下,我們區分兩個概念:過程和計算過程,當我們所某一個過程是遞歸過程的時候,形如上面兩個過程是說其形式上都有調用自身的現象。但這兩個遞歸過程的計算過程卻不同,第一個遞歸調用過程的計算有線性遞歸,而第二個遞歸過程的計算過程卻是線性迭代。所以說我們看事物的時候不要被起表面形式所迷惑,深究事物的本質有其重大的意義。
② 樹形遞歸
樹形遞歸是我們要介紹的另外一種計算模式,最典型的例子就是斐波拉契數列,數學定義我們就不再重復介紹,直接上代碼(遞歸形式)
其計算形狀為典型的樹形:
我們明顯看出這一計算過程有太多的重復,特別是對fib3的計算基本上占了大部分的內容。
當然也可以使用迭代的方式,構建兩個變量a、b滿足如下關系:
完整的代碼如下:
兩個計算過程比較來看樹形遞歸的確不如迭代高效,但有一個明顯的優點是便于理解,幾乎是斐波拉契數列的直譯版本。如果使用迭代的方法,我們需要重構這個過程,發現其中的奧秘,對大部分人來說可能過于困難。
實例:換零錢的方法統計
我們有5種貨幣:1美分、5美分、10美分、25美分、50美分,換成1美元換法(美元里面美元角的概念,所以1美元=100美分)描述如下:
1->(樹形左側)用了最大的面額的,減去最大面額的值,繼續處理(默認使用最大面額);
2->(樹形右側)沒有用最大面額兌換的,不考慮最大的面額,繼續處理(不用最大面額);
畫個圖分析一下:
我并沒有畫完,因為這個過程太過龐大了,但大致的結構如上圖。
我們用一個簡化的圖形來說明這個過程:
計算的結果和我列舉的一樣7種,當amount為0的時候只有一種;當amount<0或者貨幣沒有的時候為0種。
③ 增長率
我們有時候需要估算不同代碼在消耗計算資源上的不同,畢竟如果一個算法耗時太長以至于無法忍受的程度,就算其有解也是毫無意義的。我們引入一種標記方法:Θ(讀作theta),R(n)表示一個在參數n的規模下,所消耗的計算資源(resource),記為:
Θ(f(n)) =? R(n)
這一計算方法為我們在估算一個計算過程的問題規模改變時,提供了有用的線索,對于某種計算過程:
這三種計算過程的不同步長,有同樣的增長率
④ 求冪
我們接下來的內容具體分析一下不同計算過程的資源占用率,看看如何改進我們的計算過程。求冪的數學定義我們就不作講解了,先來看看第一個版本:
這一模型基本上是數學函數的直接代碼翻譯,這一模型需要Θ(n)步和Θ(n)的空間(遞歸),我們很容易翻譯成迭代形式:
這一模型需要Θ(n)步和Θ(1)的空間,我們還有方法改進這一模型嗎?
觀察這一一個事實:
對于指數為2的乘冪都可以使用以上這種方法求解,計算模型變更為:
定義新的計算過程如下:
⑤ 最大公約數
最大公約數的數學定義我們不做解釋,將一個著名的算法說一下,歐幾里得算法:
GCD(a,b) = GCD(b,r)其中r是a除以b的余數
多次相除以后r必定為0,剩下的b就是最大公約數,看一個例子:
我們很容易將其翻譯成代碼計算過程:
分析其資源消耗率,我們發現一個定理,Lame定理:歐幾里得算法所需要的k步計算出一對整數的gcd,那個這兩個整數中較小的那個數必然大于或者等于第k個斐波拉契數。這樣,算法的增長率也就是Θ(log n)。
⑥ 實例:素數檢測
素數定義為在大于1的自然數中,除了1和它自身外不能再有其他因子。最直接的方法就是從2開始直接計算被測驗數n是否能被整除,請看下面的代碼:
其中核心的函數是find-divisor,它有兩個參數,被測驗數n和測試因子(從2開始計算)。這一函數的結束判斷有兩種,cond的第一個條件是被測驗數大于了n的開方,停止檢查(求值的范圍只能在1至n的開方之間);第二個條件就是找到了能被整除的數test-divisor,結束檢查不是素數。else執行我們的遞歸調用,更新測試數,繼續查驗。這也就意味著這一計算過程擁有the-tan的開方的增長率。
費馬檢測法:
第二種方法使用的是,對于給定的整數n,隨機選取一個數a<n,計算出a的n次方除以n的余數,如果得到的結果不等于a,那么n就肯定不是素數;如果得到的結果等于a,那么n是素數的概率就要大些。多次測驗以后我們的信心就會不斷的增強。我們來看看代碼:
先來看看主要函數fast-prime?,輸入兩個值被檢測數n和測驗次數times,當測驗次數遞減為0任然沒有檢測出不是素數的話,輸出true;否則調用fermat-test函數測驗n在隨機生成數a的情況下是否滿足費馬檢測條件,如果滿足素數的條件繼續下一次調用fast-prime?,并將次數times更新減去1;
再來看看fermat-test函數,主要使用的是random生成了1到n-1之間的隨機數a,放入try-it封裝的expmod函數進行處理,返回的結果與a比較看是否滿足費馬條件;
最后來看看expmod函數,它有三個參數隨機數base(隨機數),exp(冪),和m(被測驗數),主要用于計算一個數的冪對另外一個數取模的結果,用到了1.2.4中的奇偶分形判別法,并將結果與被測驗數n取模的結果返回給調用函數。
總結一下,費馬檢測這種方法不同于我們已經學到的其他算法,通過費馬檢測的數只能說是概率上很有可能是素數,如果我們測驗的次數足夠多,我們能夠將這種概率調至任意我們滿意的程度。這一類算法叫做概率算法。
1.3 使用高階過程構造抽象
如果我們將過程限制為只能接受以數為參數,就會嚴重的限制我們構建抽象的能力,在這一小節中我們需要了解高階過程的抽象原理,所謂的高階過程就是以過程為參數,或者以過程作為返回值的過程。
① 以過程作為參數
我們首先觀察三個過程:
雖然表面上看是三個不同的計算過程,但是其模型幾乎是一樣的,我們替換出來就是:
有了這個模型以后,我們嘗試寫出通用的求和算法:
同計算模型不同的地方是,這里有term和next兩個過程作為參數,只需要一些簡單的定義,就可以用作計算a到b的立方和了:
同樣的原理,上面的三個例子都可以使用這種方法稍加改變就能開始計算。
② 用lambda構造過程
lambda表達式提供了一個更加簡練的函數式語法來寫匿名方法,不需要定義過多的輔助過程。
使用的方法上和define相同,只是不提供相應的函數名:
使用let創建局部變量:
假設我們有如下函數
為了便于理解,我們令:
我們使用兩種方法定義這一計算過程(普通和lambda):
這種結構非常清晰和好用,以至于專門發明了一種叫做let的表達式:
使得var1具有exp1的值,var2具有exp2的值,依次類推。我們使用新的let表達式重寫上面的lambda結構:
let表達式的第一部分維持的是一個變量名-表達式的對偶表,每個變量名關聯于對應的表達式的值,這些變量名都為let的局部變量,再來求值let的body中的內容。我們觀察到這一表達式僅僅是lambda表達式的一個變種,不需要解釋器再提供其他的新的機制就可以依靠lambda來實現。有兩點需要注意的地方:
1> let使得人們能在盡可能接近其使用的地方建立起局部變量約束;
2> 變量的值是在let之外計算的。(說明:
? ? 注:如果x的值是2,那么在let內的x=3,y=4,y的值是在let之外計算的,不同于其局部性變量x的值,這一點要特別的注意)
③ 找出函數零點(求根)和不動點的一般方法
區間折半法尋找方程的根:
這種方法的基本思路是如果有f(a)< 0 < f(b)也就是說f(a)和f(b)中必然有一個零點,我們可以通過求得a和b的平均值來計算出相應的f(value)如果為正,就同f(a)繼續計算中間點,如果為負,就同f(b)計算中間點,函數逐漸逼近到我們想要的任何精度即可求出f(x) = 0。直接上代碼:
找出函數的不動點:
函數的不動點是指f(x) = x,對于某些函數,通過不斷的調用:
當我們看到結果的value變化不大的時候,就說我們找到了函數的不動點。請看代碼:
這時候我們回想起之前講到的計算某一個數的平方根,我們將其轉換成一個方程就是:
為了不掉入一個無限循環,這里的y的取值范圍需要作如下處理:
代碼如下:
④ 過程作為返回值
我們這一小節研究的是如何將過程返回給調用函數,這將進一步提高我們程序的表達力。我們需要來看看在講述不動點尋找函數的平方根的例子中的平均阻尼思想化作新的一個以過程為返回值的函數
原來的函數是:
新定義一個average-damp函數如下:
這里的average-damp是一個過程,它的參數f也是一個過程,同樣的返回值是lambda定義的另外一個過程,我們使用這一新的技術來更新一起的計算平方根的過程:
這一新的計算方法很好的將三種技術:不動點搜尋、平均阻尼以及函數y=x/y,很好的結合在了同一個方法中,并且分割開來各有各的函數定義。特別的清晰和便于理解。
計算一個數的平方根的方法我們使用了多種,但這種依靠不同部件的合理組合正是我們需要學習的能力,因為它有很強的重構能力。就像螺絲和螺母一樣,我們依靠他可以建成高樓大廈,也能構造座椅板凳。一點點的修改就可以用于計算一個數的立方根:
※ 牛頓法計算平方根:
現在我們再來看看我們之前講到過的使用牛頓法計算一個數的平方根的方法。這里用到了一個計算導數的過程還不是很理解,寫在這里以后在回來看看(&*此處需要重復閱讀-2017-10-03*):
一些想法:上面的這兩種計算一個數的平方根,其實都是在對函數的某個不動點求值的一種變化。站在這個層面上我們可以在上升一個臺階,造就出一個專門用于計算不動點的過程:
這就是我們所說的高階函數操作這些一般性的方法,建立了更高層次的一種抽象。真正的大師,能從眾多令人迷惑不解的模式中識別出更高層次的抽象,并基于此去構造更偉大的程序。這是一種能力,需要時間的打磨才能理解、融匯、貫通。時間還長,路還遠,還要努力。
2017年10月03日15:11:22 ? 完