跳轉到內容

程式設計科學/現在讓我們來點完全不同的東西

來自華夏公益教科書,開放世界的開放書籍

夠了,不要再求導了!是時候嘗試點新的了。新的,但並不完全不同。一旦你學習了微積分中的求導,你肯定會開始學習積分。正如早先所說,積分就是將曲線的所有微小(無窮小)部分加起來。所以如果dy表示曲線y的(無窮多個)微小部分之一,那麼

   

事實上,如果你把一個事物的全部部分加起來,你就會得到這個事物本身。 [1]

與求導類似,積分也有兩種,數值積分和符號積分。與求導一樣,數值積分給出近似結果,而符號積分給出精確結果。由於數值積分涉及對許多小部分進行實際求和,讓我們練習一下求和。

以下式子的和是多少:

   
   

其中有無窮多個分數遵循這種模式。在我們編寫程式碼來給出答案之前,我們需要解決兩個問題。第一個是,這個和似乎由兩個不同的東西組成,一個整數 (1) 和一堆分數。

當所有事物都一樣時,生活就容易多了;如果是這樣,我們就不需要用if表示式來決定我們正在處理什麼型別的事物。 [2] 一般來說,如果我們希望對兩種不同的事物進行相同處理,我們需要要麼使第一個看起來像第二個,要麼使第二個看起來像第一個,或者使兩者都看起來像第三種事物。在本例中,我們需要要麼

  • 使分數看起來像整數,
  • 使整數看起來像分數,或者
  • 使整數和分數看起來像第三種事物

在這三種策略中,第二種似乎是最簡單的途徑,因為可以透過在分母中放置一個 1 來輕鬆地將整數表示為分數

   

在這一點上,你應該熟悉 迭代迴圈遞迴迴圈


我們需要解決的最後一個問題是如何構建我們的程式碼。很明顯,我們需要某種迴圈(遞迴或迭代),來新增儘可能多的分數。為了使用迴圈,我們通常需要使我們迴圈的專案看起來相同,或者至少是迴圈計數器的函式。在我們的例子中,我們如何使每個分數看起來相同?

如果我們看分母,我們會發現每個分母都可以描述為 2 的冪

   

似乎我們可以使用一個迴圈計數器(從零開始)作為每個分數分母中的指數。解決了這兩個問題之後,我們可以開始編寫一些程式碼。讓我們首先嚐試遞迴方法。

遞迴方法

[編輯 | 編輯原始碼]

我們將使用n作為我們的迴圈計數器

   function total(n)
       {
       1 / (2 ^ n) + total(n + 1);
       }

如果我們用n的初始值為零呼叫total,我們應該得到

   1 / (2 ^ 0) + total(1)

在下一個遞迴呼叫中,我們應該得到

   1 / (2 ^ 0) + [1 / (2 ^ 1) + total(2)]

然後

   1 / (2 ^ 0) + [1 / (2 ^ 1) + [1 / (2 ^ 2) + total(3)]]

這正是我們想要的,但最終我們將對無窮多個項求和。我們忽略了遞迴停止的基準情況。讓我們將我們要加起來的項數作為max傳遞進去

   function total(n,max)
       {
       if (n == max)
           {
           0;
           }
       else
           {
           1 / (2 ^ n) + total(n + 1,max);
           }
       }

n達到max時,遞迴停止。

如果我們加起來五個分數,我們會得到什麼?

   sway> total(0,5);
   REAL_NUMBER: 1.9375000000

這是正確的嗎?讓我們顯式地加起來五個分數

   sway> 1.0 / 1 + (1.0 / 2) + (1.0 / 4) + (1.0 / 8) + (1.0 / 16);
   REAL_NUMBER: 1.9375000000

似乎是正確的。十個分數呢?

   sway> total(0,10);
   REAL_NUMBER: 1.9980468750

五十個分數呢?

   sway> total(0,50);
   REAL_NUMBER: 2.0000000000

一百個分數呢?

   sway> total(0,100);
   REAL_NUMBER: 2.0000000000

這些結果讓我們相信,如果我們將這些分數加到無窮大,總和將為 2。當然,由於我們的結果是實數,我們需要謹慎對待其絕對可靠性,但在這種情況下,我敢打賭結果是正確的。

我們可以在這個函式中新增一些視覺化內容嗎?我們可能應該先這樣做,以確保我們的程式碼按預期執行

   function total(n,max)
       {
       if (n == max)
           {
           println("0");
           0;
           }
       else
           {
           var denom = 2 ^ n;
           print("1/",integer(denom)," + ");
           1 / denom + total(n + 1,max);
           }
       }

請注意,我們“預先計算”了分母,因為我們需要它兩次,一次用於視覺化,一次用於實際計算。我們還使用integer函式將指數產生的實數轉換回整數。

   sway>total(0,5);
   1/1 + 1/2 + 1/4 + 1/8 + 1/16 + 0
   REAL_NUMBER: 1.9375000000

看起來不錯!當然,我們的視覺化沒有生成有效的 Sway 表示式(空格和優先順序問題),但這對於視覺化來說很少有必要。

迭代方法

[編輯 | 編輯原始碼]

讓我們嘗試使用迭代迴圈。將遞迴迴圈轉換為迭代迴圈有一個標準方法。第一步是初始化一個區域性變數,例如result,使其等於遞迴迴圈的基準情況返回值(然後返回它)。在本例中,返回值為零

   function total(n,max)
       {
       var result = 0;
       return result;
       }

現在我們新增一個 while 迴圈,其中包含與遞迴迴圈if表示式中找到的測試相反的內容

   function total(n,max)
       {
       var result = 0;
       while (not(n == max))     //better is n != max)
           {
           }
       return result;
       }

在迴圈體中,我們放置遞迴情況計算

   function total(n,max)
       {
       var result = 0;
       while (n != max))
           {
           var denom = 2 ^ n;
           1 / denom + total(n + 1,max);
           }
       return result;
       }

result替換遞迴呼叫

   function total(n,max)
       {
       var result = 0;
       while (n != max))
           {
           var denom = 2 ^ n;
           1 / denom + result;   //was total(n + 1,max)
           }
       }

並將整個表示式賦值回result

   function total(n,max)
       {
       var result = 0;
       while (n != max))
           {
           var denom = 2 ^ n;
           result = 1 / denom + result;   //was total(n + 1,max)
           }
       return result;
       }

最後,如果在原始遞迴呼叫中更新了任何變數,請使用賦值在迴圈底部更新該變數

   function total(n,max)
       {
       var result = 0;
       while (n != max))
           {
           var denom = 2 ^ n;
           result = 1 / denom + result;
           n = n + 1;
           }
       return result;
       }

瞧!我們完成了。這種技術通常效果很好,即使存在多個基準情況和多個遞迴情況,它也能正常工作。但是,有時轉換會遇到一些問題。以下是一個例子;讓我們轉換最大公約數函式的遞迴版本

   function gcd(n,d)
       {
       if (d == 0)
           {
           n;
           }
       else
           {
           gcd(d,n % d);
           }
       }

我們從基準情況返回值開始

    function gcd(n,d)
        {
        var result = n;
        return result;
        }

接下來,我們新增一個 while 迴圈

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            }
        return result;
        }

然後我們複製遞迴情況計算

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            gcd(d,n % d);
            }
        return result;
        }

我們在計算中用result替換遞迴呼叫

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            result;      //was gcd(d,n % d);
            }
        return result;
        }

現在我們更新在遞迴呼叫中發生更改的變數

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            result;      //was gcd(d,n % d);
            n = d;
            d = n % d;
            }
        return result;
        }

我們應該在此時完成,但我們仍然遇到幾個問題。第一個問題是語句

   result;

在 while 迴圈體中沒有執行任何有用的操作。這是我們第一個發現問題所在的地方。現在,讓我們刪除它

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            n = d;
            d = n % d;
            }
        return result;
        }

第二個問題是,在遞迴呼叫中,對變數d的更新使用了n的舊值,但在我們的轉換中,對d的更新使用了n的新值。我們需要儲存舊值,並使用臨時變數來做到這一點

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            var temp = n;
            n = d;
            d = temp % d;
            }
        return result;
        }

最後一個問題是result從未改變。這是因為我們最初將result設定為n的原始值,而不是n的最後一個值。請注意,遞迴版本使用了n的最後一個值,如預期的那樣。因此,我們在 while 迴圈終止後更新result,以獲得n的最後一個值。

    function gcd(n,d)
        {
        var result = n;
        while (d != 0)
            {
            var temp = n;
            n = d;
            d = temp % d;
            }
        result = n;
        return result;
        }

當然,我們可以透過意識到result根本沒有執行任何操作來稍微清理一下我們的函式;我們可以直接返回n的最後一個值

    function gcd(n,d)
        {
        while (d != 0)
            {
            var temp = n;
            n = d;
            d = temp % d;
            }
        return n;
        }

這種技術充其量能讓你得到正確答案。最糟糕的是,它能讓你接近正確答案。

求和抽象

[編輯 | 編輯原始碼]

請注意,在我們對原始求和的遞迴和迭代解決方案中,我們都硬編碼了計算序列中下一個元素的函式(即反向求冪)。編寫計算機程式的一個重要設計策略稱為*關注點分離*。使用此策略,我們嘗試編寫函式或函式集,以便每個函式執行單個任務。在我們的解決方案中,下一個分數的生成(一個關注點)和這些分數的求和(另一個關注點)都在單個函式中找到,我們可以將這些關注點分離到單獨的函式中。一個函式生成要求和的分數;另一個函式在給定先前生成的分數集合的情況下執行實際求和。因此,我們的第一個任務是生成分數集合。我們將研究兩種收集集合的方法,*陣列*和*列表*。


此時,您應該熟悉陣列和列表


以下是生成所需分數列表的程式碼

   function invPowOfTwo(n,max)
       {
       if (n == max)
           {
           :null;
           }
       else
           {
           1 / (2 ^ n) join invPowOfTwo(n + 1,max);
           }
       }

如果我想要陣列而不是列表,我可以將函式寫成:[3]

   function invPowOfTwo(n,max)
       {
       var a = allocate(max);
       while (n < max)
           {
           a[n] = 1 / (2 ^ n);
           n = n + 1;
           }
       a;
       }

無論哪種方式,一旦我有了分數集合,我就可以使用通用求和器對其進行求和

   function sum(items)
       {
       if (items == :null)
           {
           0;
           }
       else
           {
           head(items) + sum(tail(items);
           }
       }

無論我將陣列還是列表傳遞給sum,我都會得到集合中所有專案的總和。 [4]

我也可以迭代地編寫sum

   include("basics");
    
   function sum(items)
       {
       var i;
       var total = 0;
       for-each(i,items)
           {
           total = total + i;
           }
       total;
       }

此版本也將對列表或陣列形式的專案集合進行求和,但對於其中一種形式,該過程相當緩慢(參見練習)。

將元素生成與求和過程分離的優勢在於:一旦我們有了元素列表,我們就可以做更多的事情,而不僅僅是求和。我們可以視覺化它們、反轉它們、(反轉後)找到是梅森素數加 1 的那些等等。同樣,我們可以使用我們的求和函式來對其他型別的集合進行求和。

偽無限序列

[edit | edit source]

如果我們費盡心思生成了一組元素以某種方式進行求和或處理,卻忽略了生成足夠大的集合怎麼辦?我們被迫再次重新生成集合,丟棄之前完成的所有工作。

問題是我們必須在執行求和之前指定max

   function invPowOfTwo(n,max)
       {
       if (n == max)
           {
           :null;
           }
       else
           {
           1 / (2 ^ n) join invPowOfTwo(n + 1,max);
           }
       }

如果我們不必預先指定集合的大小,那就太好了。不需要max 也會簡化程式碼

   function invPowOfTwo(n)
       {
       1 / (2 ^ n) join invPowOfTwo(n + 1);
       }

問題是,如果沒有基本情況,我們將陷入無限的遞迴迴圈。但是,我們可以透過*延遲*或*惰性求值*來避免這個陷阱。


此時,您應該熟悉惰性求值


讓我們編寫一個invPowOfTwo版本,該版本延遲遞迴呼叫的求值

   function invPowOfTwo(n)
       {
       1 / (2 ^ n) join delay(invPowOfTwo(n + 1));
       }

請注意,我們使用了delay 函式包裝遞迴呼叫。delay 所做的是儲存計算遞迴呼叫所需的一切,而無需實際進行呼叫。實際呼叫可以在稍後使用delay 儲存的資訊進行。

讓我們看一下呼叫新函式的結果

   sway> var items = invPowOfTwo(0);
   LIST: (1.0000000000 # <THUNK 11167>)

我們看到列表的第一個元素是 1.0,正如預期的那樣。但是,我們沒有看到列表的其餘部分,而是看到一個thunk 作為列表的尾部貼上了(尖銳符號表示列表的尾部不是正確的列表)。我們可以檢查thunk

   sway> var t = tail(items);
   THUNK: <THUNK 11167>
    
   sway> pp(t);
   <THUNK 11086>:
       context: <OBJECT 11071>
       code: invPowOfTwo(n + 1)
   THUNK: <THUNK 11086>

我們看到 thunk 物件的code 欄位儲存了實際呼叫。context 欄位儲存了一個包含ninvPowOfTwo 值的環境,這些是進行實際呼叫所需的一切。

這種列表被稱為延遲流或簡稱為。使用流的隱喻,因為只要有水源(記憶體),流就不會乾涸。

我們如何從流中獲得除第一個元素之外的其他元素?使用force 函式。force 函式在給定 thunk 作為引數時,會執行最初延遲的求值

   sway> head(items);
   REAL_NUMBER: 1.0000000000
     
   sway> tail(items):
   THUNK: <THUNK 11167>
   sway> force(tail(items));
   LIST: (0.5000000000 # <THUNK 11193>)

透過強制原始列表的尾部,我們最終得到一個由呼叫生成的新的列表invPowOfTwo(n + 1). 當此呼叫被延遲時,n 的值為零。現在此呼叫正在進行,invPowOfTwo 被傳遞了一個值為 1 的值。這將生成一個新的列表,其頭部為,另一個延遲的遞迴呼叫。這次,delay 會記住n 的值為 1 而不是 0。

讓我們定義一個函式來獲取流中的任何元素。我們將傳入索引,然後連續強制列表的尾部,直到我們到達正確的元素

   function streamIndex(s,i)
       {
       if (index == 0) // the head of the stream is desired
           {
           head(s);
           }
       else
           {
           streamIndex(force(tail(s)),i - 1);
           }
       }

請注意,如果我們想要具有超過i 個元素的列表的第i 個元素,則這與想要列表尾部的第i - 1 個元素相同。當我們取列表的尾部時,所需的元素似乎向列表的前面移動了一步。 [5]

現在我們可以對流進行求和

   function sumStream(s,count)
       {
       if (count == 0)
           {
           0;
           }
       else
           {
           head(s) + sumStream(force(tail(s)),count - 1);
           }
       }

無需擔心最初生成了多少個元素。迭代地,對流求和可能看起來像

   function sumStream(s,count)
       {
       var total = 0;
       while (count > 0)
           {
           total = total + head(s);
           s = force(tail(s));
           count = count + 1;
           }
       total;
       }

問題

[edit | edit source]

腳註

[edit | edit source]
  1. 假設你不相信那個神奇的營銷術語*協同效應*。
  2. 我們之前在將常數看起來像一個項時看到了這一點。
  3. 列表適合遞迴解決方案,陣列適合迭代解決方案。
  4. 不要在其他語言中嘗試!在幾乎所有其他語言中,用於分解列表的運算子/函式與分解陣列的運算子/函式不同。
  5. 這一點非常重要。說服自己確實如此。

盡你所能 · 上上下下

華夏公益教科書