6. 函數,遞歸,循環

轉載自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間??空間值較?。?,從而應該是更理想的一種實現方式。


add1add2的區別可以理解為,我們能否由程序的參數確定系統的狀態。
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)

總結

  1. 函數的基本知識
  2. 遞歸,迭代,循環

下一步

更多控制流方法

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容