轉載自http://wanwu.tech/2017/03/08/function-recursive-and-loop/
本文中有大量數學公式,簡書中不太方便展示,如有需要請閱讀原文。
到目前為止,我們已經具備了編程的最基礎知識。我們可以建立自己需要的常量或者變量,可以按照自己的思路做簡單的任務,可以根據條件選擇做事的手段,也可以重復做一件事情。為了進一步抽象你的代碼,我們引入函數的感念。
函數是整個程序的靈魂,它定義了一段有某種功能的代碼。編程語言中的函數類似數學中的公式。一方面,函數可以優化你的代碼,另一方面,函數也可以提高你的程序的可讀性。
這一章,將初步學習函數的知識,然后通過函數,理解遞歸,并進一步理解遞歸和循環的關系。
函數基礎
假設你經常要打印自己的名字,那么你可以把這段代碼抽象為一個函數:
func printMyName() {
print("我叫小明")
}
上面是函數定義的一段代碼。語法如下:
func 函數名() {
代碼
}
如果想使用這個函數,可以這樣:
printMyName()
自己試試什么效果。
那么,你覺得我們一直在用的print
是什么?對了,也是一個函數。
參數
到現在為止,函數只是靜止的一段功能塊,比如上一章計算1到100的和,我們可以包裝為一個函數:
func sum() {
var number = 2
let maxNum = 100
var s = 1
while number <= maxNum {
s += number
number += 1
}
print(s)
}
但是你想讓他真的和數學公式一樣工作,還需要能夠自定義一些參數傳給它,還能返回一個結果,比如求從1到m的和的數學表達式如下:
$$ sum=\sum _{n=1}^{m}n $$
我們要自己代入m的值,還能得到這個運算返回的結果。那么如果我今天想要算1到1000呢?直接把m設為1000即可,其他完全不需要變化。這個在編程語言中,就是一個參數化的問題。
參數化
我們可以這樣參數化我們求和程序,并提供一個返回值:
func sumFrom1(to maxNum: Int) -> Int {
var number = 2
var s = 1
while number <= maxNum {
s += number
number += 1
}
return s
}
let sum1 = sumFrom1(to: 100)
let sum2 = sumFrom1(to: 10)
let sum3 = sumFrom1(to: 1000)
我們試著讀出func sumFrom1(to maxNum: Int) -> Int
:求和函數接受一個類型為Int的參數maxNum,返回一個Int類型的返回值。因為編寫Swift的人是美國人,所以如果你用英語讀的話,可能會豁然開朗:This function caculates the sum from 1 to maxNum and returns a value。
語法可以寫作:
func 函數(實參標簽 參數名: 參數類型) -> 返回值類型 {
代碼,其中可使用參數名
return 返回值
}
函數(實參標簽: 參數)
可以看出,為了表達一個參數,我們提供了三個信息:實參標簽,參數名和參數類型。在函數內,我們通過使用參數名來指代這個參數,即形參。在調用函數的時候,我們通過實參標簽來傳遞實參到形參。返回值使用return
關鍵字返回給外部以便使用。
值得注意的是,上面求和函數的函數名不是
sumFrom1
,而是sumFrom1(to:)
,也就是說,函數名是函數(實參標簽:)
。
可讀性與簡單性
通過為一個參數使用三個信息,我們可以盡可能的提高函數的可讀性。但是,有的時候,這樣寫并沒有對可讀性有大幅提升,卻使函數名稱過長,不易交流,為了解決這個問題,Swift允許我們省略書寫實參標簽,比如寫為這樣:
func sumFrom1(to: Int) -> Int {
var number = 2
var s = 1
while number <= to {
s += number
number += 1
}
return s
}
let sum1 = sumFrom1(to: 100)
let sum2 = sumFrom1(to: 10)
let sum3 = sumFrom1(to: 1000)
這個情況下,實參標簽等于參數名,同時注意函數內代碼的變化。
在個別情況下,我們甚至覺得在外部調用函數的時候不需要實參標簽,我們可以在實參標簽位置使用下劃線_
:
func sumFrom1(_ to: Int) -> Int {
var number = 2
var s = 1
while number <= to {
s += number
number += 1
}
return s
}
let sum1 = sumFrom1(100)
let sum2 = sumFrom1(10)
let sum3 = sumFrom1(1000)
但是我們現在這個函數的情況中,不使用實參標簽已經影響到了程序的可讀性,所以我個人認為在這個函數中,這樣修改不太好。
但是下面的加一功能的程序,不使用實參標簽完全不會影響可讀性:
func addOneTo(_ num: Int) -> Int{
return num + 1
}
let result = addOneTo(3)
所以,到底怎么用,主要還是在簡單性和可讀性之間取一個最優值得問題。
上面例子中,返回值的位置我直接使用了一個計算表達式
參數列表
我們還可以向函數中提供更多參數,那么參數就變成了參數列表,比如計算兩個數之和:
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
let sumOfTwo = add(3, 4)
這里,我們沒有使用實參標簽,并沒有影響可讀性,如果添加實參標簽,就變成了:
func add(num1 a: Int, num2 b: Int) -> Int {
return a + b
}
let sumOfTwo = add(num1: 3, num2: 4)
添加實參標簽后,并沒有提高可讀性,卻使函數更長,沒有太大必要,所以我認為這里不需要添加實參標簽。
在Swift 2中,實參標簽曾經稱為外部名,參數名曾經稱為內部名
參數默認為常量
Swift中,形參默認為常量,不可更改:
func addOneTo(_ num: Int) -> Int{
num = num + 1 // 報錯
return num
}
上面代碼錯誤,證明了形參的確是常量。下面是這段代碼的報錯信息:
error: cannot assign to value: 'num' is a 'let' constant
深入理解循環
我們已經了解了基本的函數知識,下面我們通過這些知識,理解更多關于循環的問題。
設想我們處在這樣一個世界,我們不知道加法如何運算,也就是說我們都不知道a + b
等于多少。但是有一點我們知道,我們知道如何數數(1,2,3,4,5...),也就是知道a + 1
等于多少,也知道a - 1
。換一種說法就是我們知道如果數到a,后面那個數字就是a + 1
,前面那個數字就是a - 1
。
基本問題描述
現在我們想要做一個加法運算。
我們把想要做的寫為一個函數:
func add(_ a: Int, _ b: Int) -> Int {
return a + b
}
但是在我們設想的這個世界,我們不知道如何計算a + b
。怎么辦?
我們知道什么
我們把我們所知道的a + 1
寫成一個遞增函數:
func increase(_ num: Int) -> Int {
return num + 1
}
我們把我們所知道的a - 1
寫成一個遞減函數:
func decrease(_ num: Int) -> Int {
return num - 1
}
用已知解釋未知
那么現在,我們想一下怎么用遞增和遞減函數寫出加法函數。
我們這樣來看這個問題:
a + b = ((a - 1) + b) + 1
= ((((a - 1) - 1) + b) + 1) + 1
= ((((((a - 1) - 1) - 1) + b) + 1) + 1) + 1
= (...(((b + 1) + 1) + 1) + ... + 1) + 1
...
我們將a減1(遞減)的同時,整體加1(遞增)。一直遞減a,同時一直整體遞增,直到a變為0。然后,變成了一個b遞增的過程。b遞增完成后,就可以得到結果了。這樣,我們就將一個加法問題,變成了一個遞增和遞減的問題。
比如4 + 3
可以寫為:
4 + 3 = (3 + 3) + 1
= ((2 + 3) + 1) + 1
= (((1 + 3) + 1) + 1) + 1
= ((4 + 1) + 1) + 1
= (5 + 1) + 1
= 6 + 1
= 7
這樣,如果用Swift寫出來,就變成了:
func add1(_ a: Int, _ b: Int) -> Int {
var num1 = a
let num2 = b
if num1 == 0 {
return num2
} else {
num1 = decrease(num1)
let s = add2(num1, num2)
let result = increase(s)
return result
}
}
上面代碼中,我們的add()
函數中調用了自己(add()
),這個過程叫做遞歸。這里,暫時不對遞歸做深入分析,但是有一點要知道,遞歸比較費系統資源。
我們還可以這樣來看這個問題:
a + b = (a + 1) + (b - 1)
= ((a + 1) + 1) + ((b - 1) - 1)
= (((a + 1) + 1) + 1) + (((b - 1) - 1) - 1)
...
當加號右邊b遞減的部分為0的時候,那么加號左邊的部分就成為了我們需要的結果,是嗎?這樣,我們就將一個加法問題,變成了一個遞增和遞減的問題。
比如4 + 3
可以寫為:
4 + 3 = 5 + 2
= 6 + 1
= 7
這樣,如果用Swift寫出來,就變成了:
func add2(_ a: Int, _ b: Int) -> Int {
var num1 = a
var num2 = b
if num2 == 0 {
return num1
} else {
num1 = increase(num1)
num2 = decrease(num2)
let result = add(num1, num2)
return result
}
}
分析兩種方法
我們再來分析一下上面兩種遞歸方法,前一種我們記為add1,后一種add2。
首先分析add1,4 + 3
為例:
4 + 3 // add1(4, 3),num1不為0,調用add1(3, 3)
= (3 + 3) + 1 // add1(3, 3),num1不為0,調用add1(2, 3)
= ((2 + 3) + 1) + 1 // add1(2, 3),num1不為0,調用add1(1, 3)
= (((1 + 3) + 1) + 1) + 1 // add1(1, 3),num1不為0,調用add1(0, 3)
= ((((0 + 3) + 1) + 1) + 1) + 1 // add1(0, 3),num1為0,返回num2 = 3
= ((4 + 1) + 1) + 1 // 回到add1(1, 3),返回3 + 1 = 4
= (5 + 1) + 1 // 回到add1(2, 3),返回4 + 1 = 5
= 6 + 1 // 回到add1(3, 3),返回5 + 1 = 6
= 7 // 回到add1(4, 3),返回6 + 1 = 7
根據上面分析,我們可以發現這里是一層層深入,再一層層返回的過程。
如果我們把橫向考慮為空間,縱向認為是時間,那么可見時間上需要7步,空間上先增大后減小。在這個過程中,建立起一個延遲鏈,這里就是延遲遞增操作。這個過程需要程序跟蹤我們將要執行的操作,即存儲一部分信息。以上操作中,我們延遲遞增操作,不返回任何值,一直到num1變為0才開始返回。因此,需要存儲的信息總量隨著a的增大而線性增大,如下圖所示:
| 4 + 3
| = (3 + 3) + 1
| = ((2 + 3) + 1) + 1
| = (((1 + 3) + 1) + 1) + 1
| = ((((0 + 3) + 1) + 1) + 1) + 1
| = ((4 + 1) + 1) + 1
| = (5 + 1) + 1
| = 6 + 1
| = 7
|--------------------------------------> 空間
↓
時間
然后分析add2,4 + 3
為例:
4 + 3 // add2(4, 3),num2不為0,返回add2(5, 2)
= 5 + 2 // add2(5, 2),num2不為0,返回add2(6, 1)
= 6 + 1 // add2(6, 1),num2不為0,返回add2(7, 0)
= 7 + 0 // add2(7, 0),num2為0,返回num1
= 7
同樣把橫向考慮為空間,縱向認為是時間,那么可見時間上需要3步,空間上沒有變化。在這個過程中,我們跟蹤當前num2的值,最后返回num1的值??梢园?strong>num2看做一個指示器,num1看做一個寄存器。整個系統變化中,指示器以一定的規律變化,結果存儲在寄存器中,可以根據指示器的信息判斷出系統的狀態。一旦指示器符合某種條件,那么系統停止運行,并返回寄存器的值,即為所需結果。
| 4 + 3
| = 5 + 2
| = 6 + 1
| = 7 + 0
| = 7
|------------> 空間
↓
時間
從上面的“時間-空間”圖可以猜測出,后面一種方法占用的資源較?。〞r間??空間值較?。?,從而應該是更理想的一種實現方式。
add1和add2的區別可以理解為,我們能否由程序的參數確定系統的狀態。
add1情況中,我們不能由輸入的參數完全確定系統狀態和運行位置,還需要一些隱藏的附加信息才可以。這些附加信息隱藏在我們剛才所說的延遲鏈中,延遲鏈越長,附加信息越多。這個過程叫做遞歸過程,實現的方法是遞歸方法。
add2情況中,我們可以由輸入的參數完全確定系統狀態和運行位置(我們輸入參數包括指示器和寄存器)。如果我們在某一步停止運算,然后又想繼續運行,所有我們需要做的就是把幾個參數傳給add2。這個過程叫做迭代過程,但是使用的是遞歸方法實現的。
綜上所述,雖然兩種方法都是遞歸方法,但是一個是遞歸過程,一個是迭代過程。大多數編程語言(C,C++,Java等)都不支持對采用遞歸方法的迭代過程的優化,一概都認為是遞歸,從而性能比較差。Swift稍微好點,但是也沒有做到很好,所以呢,我們不能靠寫出迭代過程的遞歸方法提高性能。
遞歸變為循環
那怎么辦呢?我們可以通過循環來改寫迭代過程的遞歸方法,改寫add2如下:
func add3(_ a: Int, _ b:Int) -> Int {
var num1 = a
var num2 = b
while num2 != 0 {
num1 = increase(num1)
num2 = decrease(num2)
}
return num1
}
回想add2,我們說過“可以把num2看做一個指示器,num1看做一個寄存器”,那么這里,我們就跟蹤num2,最終返回num1即可。當num2不為0的時候,就進入循環體進行num1的遞增和num2的遞減。一旦num2為0,退出循環,返回num1。 注意到我們add2遞歸放在了方法末尾,所以這種形式的遞歸過程叫做尾遞歸(Tail Recursion)。在Swift中,尾遞歸可以通過改寫為循環來提高性能。
這樣,使用循環,就可以將性能提高。
求和
再以求和運算為例,看看遞歸和循環。
觀察下面公式:
$$ sum=\sum {n=k{0}}^{k_{m}}n $$
可以寫為:
$$ \begin{align} & sum=k_{0}+\sum {n=k{1}}^{k_{m}}n\ &
=k_{0} + (k_{1} + \sum {n=k{2}}^{k_{m}}n) \ &
=k_{0} + (k_{1} + (k_{2} + \sum {n=k{3}}^{k_{m}}n)) \ &
=\ldots \ &
=k_{0} + (k_{1} + (k_{2} + \ldots + (k_{m-1} + \sum {n=k{m}}^{k_{m}}n))) \&
=k_{0} + (k_{1} + (k_{2} + \ldots + (k_{m-1} + k_{m}))) \&
\end{align}
$$
這樣一步步下來,我們可以看出,\(k_{0}\) 到\(k_{m}\)的和變成了從后到前先計算\(s_{1}=(k_{m-1} + k_{m})\),再計算\(s_{2}=(k_{m-2} + s_{1})\),一步一步向前計算,直到計算\(sum=s_{m}=(k_{0} + s_{m-1})\)
以上過程寫成Swift代碼如下:
func sum1From(_ from: Int, to: Int) -> Int {
if from > to {
return 0
} else {
return from + sumFrom(from + 1, to: to)
}
}
let total = sumFrom(1, to: 100)
根據前面的討論,這是一個遞歸過程的遞歸方法。下面我們將它改變為迭代過程的遞歸方法。
怎么樣做呢?我們首先需要一個指示器,一個寄存器。這里,我們可以使用from > to
作為指示器,然后引入一個專門的參數作為寄存器??梢园凑找韵滤悸酚嬎悖喝绻?code>from > to不成立,那就將from
遞增,然后將from
的原值加到寄存器。一旦from > to
成立,那就返回寄存器的值。Swift代碼如下:
func sum2From(_ from: Int, to: Int) -> Int {
func iterateFrom(_ from: Int, result: Int) -> Int {
if from > to {
return result
} else {
return iterateFrom(from + 1, result: result + from)
}
}
return iterateFrom(from, result: 0)
}
值得注意的是,我們在函數sum2From(_:to:)
內又定義了一個函數iterateFrom(_:result:)
,這在Swift中完全正確。然后我們返回iterateFrom(_:result:)
的計算結果。
我們如果將這段代碼轉為循環呢:
func sum3From(_ from: Int, to: Int) -> Int {
var result = 0
var start = from
while start <= to {
result += start
start += 1
}
return result
}
這里,指示器還是from > to
,寄存器是函數內定義的變量result
。
上面的這些遞歸和循環的討論,有沒有一種將簡單問題復雜化的感覺呢?那為什么還要這么做呢?未來,我們會遇到很多遞歸很好解決,但是不容易想出如何使用迭代,也就是循環的情況。如果你掌握了這里的技巧,那么計算效率將會有顯著提高。
求解平方根
上面的例子我們是不是有一種感覺,Swift的函數就是數學里的方程?但是考慮下面這種情況:
$$ y=\sqrt {x},& 其中y /geq 0 而且y^{2}=x $$
怎么寫為Swift函數?
上面的數學方程定義了什么是平方根,但是并沒有任何如何求解的信息。但是如果要寫一個Swift函數,必須要有怎么求解的信息。Swift函數和數學方程的區別在于前者描述做事的方法,后者描述這件事的性質。
那么我們到底應該如何計算呢?我們可以使用一種簡化的牛頓法求解。這種方法首先需要一個初始猜測值,然后連續采取特定的操作改進這個初始值,直到滿意為止。
如何把這種思路用在求平方根呢?我們可以將平方根的問題轉變為:
$$ y^{2}=x $$
$$ y=\dfrac {x} {y} $$
可以想象,如果設置一個初始猜測值\(y_{0}\),然后取\(y_{0}\)與\(\dfrac {x} {y_{0}\)的平均值,然后將此平均值作為新的猜測值代入函數,繼續取平均值。連續采取此步驟,直到滿意。
func loopSqrtOf(_ num: Float, guess: Float) -> Float {
func isCloseEnough(_ num1: Float, _ num2: Float) -> Bool {
if abs(num1 - num2) < 0.001 {
return true
} else {
return false
}
}
var myGuess = guess
while !isCloseEnough(myGuess, num / myGuess) {
myGuess = (myGuess + num / myGuess) / 2
}
return myGuess
}
loopSqrtOf(3, guess: 1)
由于以上代碼已經是尾遞歸,我們很方便就可以將它轉變為循環代碼:
func sqrtOf(_ num: Float, guess: Float) -> Float {
func isCloseEnough(_ num1: Float, _ num2: Float) -> Bool {
if abs(num1 - num2) < 0.001 {
return true
} else {
return false
}
}
if isCloseEnough(guess, num / guess) {
return guess
} else {
return sqrtOf(num, guess: (guess + num / guess) / 2)
}
}
sqrtOf(3, guess: 1)
總結
- 函數的基本知識
- 遞歸,迭代,循環
下一步
更多控制流方法