Java8函數之旅 (七) - 函數式備忘錄模式優化遞歸


前言

在上一篇開始Java8之旅(六) -- 使用lambda實現Java的尾遞歸中,我們利用了函數的懶加載機制實現了棧幀的復用,成功的實現了Java版本的尾遞歸,然而尾遞歸的使用有一個重要的條件就是遞歸表達式必須是在函數的尾部,但是在很多實際問題中,例如分治,動態規划等問題的解決思路雖然是使用遞歸來解決,但往往那些解決方式要轉換成尾遞歸花費很多精力,這也違背了遞歸是用來簡潔地解決問題這個初衷了,本篇介紹的是使用備忘錄模式來優化這些遞歸,並且使用lambda進行封裝,以備復用。

回顧

為了回顧上一章節,同時用本章的例子作對比,我們這里使用經典的斐波那契數列求解問題作為例子來講解。
斐波那契數列表示這樣的一組數 1,1,2,3,5,8,13.... 其表現形式為數列的第一個和第二個數為1,其余的數都是它前兩位數的和,用公式表示為

\[ a_n =\left\{ \begin{aligned} 1, n <= 1\\ a_{n-1} + a_{n-2} , n > 1 \end{aligned} ,n\in N \right.\]

遞歸求解

這里我們依據上面的數列公式直接使用遞歸解法求解該問題

    /**
     * 遞歸求解斐波那契數列
     *
     * @param n 第n個斐波那數列契數
     * @return 斐波那契數列的第n個數
     */
    public static long fibonacciRecursion(long n) {
        if (n <= 1) return 1;
        return fibonacciRecursion(n - 1) + fibonacciRecursion(n - 2);
    }

    /**
     * 遞歸測試斐波那契數列
     */
    @Test
    public void testFibonacciRec() {
        long start = System.nanoTime();
        System.out.println(fibonacciRecursion(47));
        System.out.printf("cost %.2f ms %n", (System.nanoTime() - start) / Math.pow(10, 6));
    }

這里我們測試當n等於47的時候,所要花費的時間

4807526976
cost 13739.30 ms

Process finished with exit code 0

可以看出,遞歸的寫法雖然簡潔,但是消耗的時間是成指數級的。

尾遞歸求解

這里回顧上一章內容,使用尾遞歸求解,具體這里的尾遞歸接口的實現這里就不貼出來了(點擊這里查看),下面是尾遞歸的具體調用代碼,增加兩個變量分別保存\(a_{n-2}\)\(a_{n-1}\) 在下面的形參對應的分別是accPrevaccNext,尾遞歸是自底向上的,你可以理解成迭代的方式,每次調用遞歸將\(a_{n-1}\)賦值給\(a_{n-2}\),將\(a_{n-1} + a_{n-2}\) 賦值給 \(a_{n-1}\)

    /**
     * 尾遞歸求解斐波那契數列
     * @param accPrev 第n-1個斐波那契數
     * @param accNext 第n個斐波那契數
     * @param n 第n個斐波那契數
     * @return 包含了一系列斐波那契的完整計算過程,調用invoke方法啟動計算
     */
    public static TailRecursion<Long> fibonacciRecursionTail(final long accPrev,final long accNext, final long n) {
        if (n <= 1) return TailInvoke.done(accNext);
        return TailInvoke.call(() -> fibonacciRecursionTail(accNext, accPrev + accNext, n - 1));
    }

    /**
     * 尾遞歸測試斐波那契數列
     */
    @Test
    public void testFibonacciTailRec() {
        long start = System.nanoTime();
        System.out.println(fibonacciRecursionTail(1,1,47).invoke());
        System.out.printf("cost %.2f ms %n", (System.nanoTime() - start) / Math.pow(10, 6));
    }

同樣測試當n等於47的時候,所要花費的時間

4660046610375530309
cost 97.67 ms

Process finished with exit code 0

可以看出花費的時間是線性級別的,但是因為這里的尾遞歸是手動封裝的,所以接口類的建立以及lambda表達式的調用等一些基本開銷占用了大部分的時間,但是這是常數級別的時間,計算過程本身幾乎不花費什么時間,所以性能也是十分好的。

迭代求解

尾遞歸在優化之后在計算過程上就變成了自底向上,因此也就是轉變成了迭代的過程,這里大家配合迭代求解來理解尾遞歸求解應該會容易許多。

    /**
     * 斐波那契的迭代解法,自底向上求解
     * @param n 第n個斐波那契數
     * @return 第n個斐波那契數
     */
    public static long fibonacciIter(int n) {
        long prev = 1;
        long next = 1;

        long accumulate = 0;
        for (int i = 2; i <= n; i++) {
            accumulate = prev + next;
            prev = next;
            next = accumulate;
        }
        return accumulate;
    }

    /**
     * 迭代測試斐波那契數列
     */
    @Test
    public void testFibonacciIter() {
        long start = System.nanoTime();
        System.out.println(fibonacciIter(47));
        System.out.printf("cost %.2f ms %n", (System.nanoTime() - start) / Math.pow(10, 6));
    }

同樣測試當n等於47的時候,所要花費的時間

4660046610375530309
cost 0.09 ms

Process finished with exit code 0

這里只花費了0.09ms,其實迭代計算的時間和尾遞歸理論上應該是差不多的,但是上文也說到了,尾遞歸由於是自己的封裝接口並且本身使用lambda也會有一定的開銷,所以會造成一些性能上的差異。

分析遞歸效率低下的原因

可以看到上面的三種解決方案,尾遞歸與迭代的效率是可以接受的,而遞歸雖然寫起來最短,但是時間復雜度是指數級別的,完全不能夠接受,那么這里就分析為什么第一種的遞歸如此之慢,而第二種與第三種就要快上很多。
第一種的解決思路是最直接的,假設我們要求解f(5)這個數,我們會將問題轉化成f(3) + f(4),接着再轉化
f(1)+f(2)+f(2)+f(3)...依次類推,如圖所示


通過簡單的觀察可以發現,這里的f(0),f(1),f(2)等被重復計算了很多次,隨着樹的高度的提升,這樣的重復計算會以指數級別的程度增長,這就是為什么第一種遞歸解法的效率為什么這么低下的原因。

那么我們來看看為什么尾遞歸與迭代的效率會這么高,前面也說到了,經過優化之后的尾遞歸與迭代的計算方式是自底向上的,同樣以計算f(5)為例子,他們不是從f(5)開始往下計算,而是從前往后,先計算出f(2)然后根據f(2)計算出(3)再根據f(2)與f(3)計算出(f4)最終計算出f(5),也就是說,自底向上的每一次計算都運用到了前面計算的結果,因此中間過程並沒有重復的計算,所以效率很高。

經過上面的總結,我們得出了如果想要遞歸高效的進行,那么要解決的就是如何避免重復的計算,也就是要利用之前已經計算過的結果。

使用備忘錄模式存儲結果

經過上面的分析,我們得到了要想解決效率問題,就必要存儲並且重復利用之前的計算結果,因此顯而易見的我們這里使用散列表這個數據結構來存儲這些信息。
我們將已經計算過的結果存儲在散列表里,下一次遇到需要計算這個問題的時候直接取出來,如果散列表里沒有這樣的數據,我們才進行計算並且存儲計算結果,把他想象成計算結果的緩存來理解。

Before Java8

為了保證線程安全我們使用synchronized關鍵字與double-check來保證,代碼如下


  private static final Map<Integer, Long> cache = new HashMap<>();

  /**
   * 使用備忘錄模式來利用重復計算結果
   * @param n 第n個斐波那契數
   * @return 第n個斐波那契數
   */
  public static long fibonacciMemo(int n) {
    if (n == 0 || n == 1) return n;

    Long exceptedNum = cache.get(n);

    if (exceptedNum == null) {
      synchronized (cache) {
        exceptedNum = cache.get(n);
        if (exceptedNum == null) {
          exceptedNum = fibRecurOpt(n - 1) + fibRecurOpt(n - 2);
          cache.put(n, exceptedNum);
        }
      }
    }

    return exceptedNum;
  }

In Java8

這樣的代碼雖然可以達到效率的優化,但是不管是復用性還是可讀性基本上為0,因此這里我們使用java8 Map結構新增的computeIfAbsent,該方法接受兩個參數,一個key值,一個是function計算策略,從字面意思也可以明白,作用就是如果key值為空,那么就執行后面的function策略,因此使用computeIfAbsent后的優化代碼如下


  private static final Map<Integer, Long> cache = new HashMap<>();
  
    /**
     * 使用computeIfAbsent來優化備忘錄模式
     * @param n
     * @return
     */
  public static long fibonacciMemoOpt(int n) {
    if (n == 0 || n == 1) return n;
    return cache.computeIfAbsent(n, key -> fibRecurLambdaOpt(n - 1) + fibRecurLambdaOpt(n - 2));
  }

這樣代碼的可讀性就高了不少,每次調用遞歸方法的時候直接返回cache里的計算結果,如果沒有該計算結果,那么就執行后面一段計算過程來得到計算結果,下面進行時間的測試。

    /**
     * 測試備忘錄模式遞歸求解
     */
    @Test
    public void testFibonacciMemoOpt(){
        long start = System.nanoTime();
        System.out.println(fibonacciMemoOpt(47));
        System.out.printf("cost %.2f ms %n", (System.nanoTime() - start) / Math.pow(10, 6));
    }

輸出結果為

2971215073
cost 80.36 ms 

Process finished with exit code 0

發現運行的時間已經大大的減少了,並且消耗時間和之前的尾遞歸幾乎差不多。

到這一步,大部分工作已經完成了,遞歸代碼也十分的簡短高效,剩下的就是復用了,接下來我們對上述的分析過程進行抽象,將備忘錄模式完全封裝,這樣以后需要使用類似的情況可以直接使用。

使用lambda封裝上述備忘錄模式優化遞歸

簽名設計

其實看一看標題,感覺似乎一直到現在才講到重點,其實我也考慮過直接跳過上面所有的介紹寫這里,但是我覺得如果這么寫的話,給人的感覺會太直接,上一篇尾遞歸的封裝我就有這樣的感覺,感覺似乎太直接了,沒有具體的分析過程就直接封裝,感覺可讀性不是很高,所以這一篇花了比較長的篇幅來一步一步講解整個的過程,希望能讓大家更容易的去理解。

首先我們要考慮設計的封裝需要幾個參數,這里應該是兩個,分別是 斐波那契的算法策略 與輸入值,也就是說我們向這個備忘錄方法傳入一個斐波那契的算法策略function,以及一個輸入值n,這個備忘錄方法就應該返回正確的結果給我們,因此這個方法的簽名初步構想應該是這樣的

public static <T, R> R callMemo(final Function<T,R> function, final T input)
  • T為輸入值類型
  • R為返回值類型
  • function 為具體的計算策略,這這里的例子中,就是斐波那契的計算策略

但這僅僅是初步構想,這里會碰到的一個問題就是,因為我們的策略是遞歸策略,因此必須要有一個方法名,而眾所周知,lambda函數全部是匿名的,也就是說,直接單純的使用lambda根本無法遞歸調用,因為lambda方法沒有名字,怎么調用自己呢? 那該怎么辦呢?其實很簡單,我們只需要再封裝一層,也就是說將策略本身作為參數來傳遞,然后使用this調用即可,這里的思想其實就是利用了尾遞歸的思想,將每一次遞歸調用需要的策略本身作為參數來傳遞。

因此我們上面參數的function 要稍作修改,增加一個策略本身作為參數,因此function的類型應該是BiFunction<Function<T,R>,T,R> 仔細觀察一下泛型里的類型,只是由原來的<T,R>在前面多了一個策略本身參數Function<T,R>,這樣2個參數的組合我們使用BiFunction,因此最終的方法簽名如下

public static <T, R> R callMemo(final BiFunction<Function<T,R>,T,R> function, final T input)

知曉了方法簽名與每一個參數的意思之后,完成最終的實現就十分容易了

具體實現

    /**
     * 備忘錄模式 函數封裝
     * @param function 遞歸策略算法
     * @param input 輸入值
     * @param <T> 輸出值類型
     * @param <R> 返回值類型
     * @return 將輸入值輸入遞歸策略算法,計算出的最終結果
     */
    public static <T, R> R callMemo(final BiFunction<Function<T, R>, T, R> function, final T input) {

        Function<T, R> memo = new Function<T, R>() {
            private final Map<T, R> cache = new HashMap<>(64);
            @Override
            public R apply(final T input) {
                return cache.computeIfAbsent(input, key -> function.apply(this, key));
            }
        };
        
        return memo.apply(input);
    }

這里為了保證這個散列表Map每次只為一個遞歸策略服務,我們在方法內部實例化一個實現function的類,並將Map存入其中,這樣就能夠保證Map服務的唯一性,在apply方法中 cache.computeIfAbsent(input, key -> function.apply(this, key))這一句就是為什么方法的簽名要多一個function的參數原因,因為策略是遞歸策略,lambda函數沒有名字,所以必須顯示的將他存入參數中,這樣才能完成遞歸調用,這里使用this將自己本身作為策略傳遞下去。

此時我們要調用的話,只需要將完成這個策略即可,調用代碼如下

    /**
     * 使用同一封裝的備忘錄模式 執行斐波那契策略
     * @param n 第n個斐波那契數
     * @return 第n個斐波那契數
     */
    public static long fibonacciMemo(int n) {
        return callMemo((fib, number) -> {
            if (number == 0 || number == 1) return 1L;
            return fib.apply(number -1 ) + fib.apply(number-2);
        }, n);
    }

最終代碼

這樣調用的可讀性可能有點差,因此我們將第一個參數抽離出來,使用方法引用來調用,最終代碼如下

public class Factorial {

    /**
     * 使用統一封裝的備忘錄模式 對外開放的方法,在內部執行具體的斐波那契策略 {@link #fibonacciCallMemo(Function, Integer)}
     * @param n 第n個斐波那契數
     * @return 第n個斐波那契數
     */
    public static long fibonacciMemo(int n) {
        return callMemo(Factorial::fibonacciCallMemo, n);
    }

    /**
     * 私有方法,服務於{@link #fibonacciMemo(int)} ,內部實現為斐波那契算法策略
     * @param fib 斐波那契算法策略自身,用於遞歸調用, 在{@link #callMemo(BiFunction, Object)} 中通過傳入this來實例這個策略
     * @param n 第n個斐波那契數
     * @return 第n個斐波那契數
     */
    private static long fibonacciCallMemo(Function<Integer,Long> fib,Integer n){
        if (n == 0 || n == 1) return 1;
        return fib.apply(n -1 ) + fib.apply(n-2);
    }

    /**
     * 備忘錄模式 函數封裝
     * @param function 遞歸策略算法
     * @param input 輸入值
     * @param <T> 輸出值類型
     * @param <R> 返回值類型
     * @return 將輸入值輸入遞歸策略算法,計算出的最終結果
     */
    public static <T, R> R callMemo(final BiFunction<Function<T, R>, T, R> function, final T input) {
        Function<T, R> memo = new Function<T, R>() {
            private final Map<T, R> cache = new HashMap<>(64);
            @Override
            public R apply(final T input) {
                return cache.computeIfAbsent(input, key -> function.apply(this, key));
            }
        };
        
        return memo.apply(input);
    }

}

通過調用fibonacciMemo(47)方法來計算時間,輸出結果為

4807526976
cost 69.19 ms 

Process finished with exit code 0

運轉良好,並且復用性強,每次使用這個模式的時候並不需要編寫額外的代碼,也不需要考慮內部的Map的線程安全或者是策略獨立。

運用

這里我們是用來解決斐波那契數列遞歸問題,下面我們分別用於經典的分治算法-漢諾塔遞歸問題與動態規划-分割桿問題,篇幅有限,這兩個問題我不作具體說明了,直接給出初始的遞歸解法代碼,(具體的問題點擊上面兩個問題的藍色鏈接即可)來看看該如何使用我們封裝好的備忘錄模式方法。

漢諾塔遞歸問題

漢諾塔遞歸問題一般有2個,一個問最少要移動多少次,另一個一般是要給出具體的每一步的過程

  • 先看最少要移動多少次這個問題
    遞歸代碼如下
    public int countMovePlate(int n) {
        if (n <= 1) return 1;
        return countMovePlate(n - 1) + 1 +countMovePlate(n - 1);
    }

使用我們的備忘錄模式來優化解決如下(同樣可以使用上文的方法引用抽離第一個參數,使得代碼可讀性更高)

    public long countMovePlateMemo(int n) {
        return callMemo((count, num) ->{
            if (n <=1 ) return 1L;
            return count.apply(num) + 1 + count.apply(num);
        },n );
    }
  • 再看具體的每一步的移動過程
    遞歸代碼如下
    public void movePlate(int n, String from, String mid, String to) {
        if (n <= 1) {
            System.out.println(from + " -> " + to);
            return;
        }
        movePlate(n - 1, from, to, mid);
        System.out.println(from + " -> " + to);
        movePlate(n - 1, mid, from, to);
    }

這里我們使用方法引用,由於哈諾塔具體移動的初始代碼有4個參數,因此我們將參數存入數組中來處理,可以看到和原先的代碼相比,只是增加了一個參數處理,就使用了備忘錄模式,完全隱去了細節

public class movePlate{
    public static boolean movePlateMemo(int n, String from, String mid, String to){
        Object[] params = {n, from, mid, to};
        return callMemo(movePlate::movePlateCallMemo,params);
    }

    private static boolean movePlateCallMemo(Function<Object[],Boolean> function,Object[] params) {
        // 將數組里的參數初始化,這樣不影響之前的代碼
        int n = (int) params[0];
        String from = (String) params[1];
        String mid = (String) params[2];
        String to = (String) params[3];
		//原先的遞歸代碼,沒有差別,將遞歸調用轉換成為function.apply()
        if (n <= 1) {
            System.out.println(from + " -> " + to);
            return true;
        }
        function.apply(new Object[]{n - 1, from, to, mid});
        System.out.println(from + " -> " + to);
        function.apply(new Object[]{n - 1, mid, from, to});
        return false;
    }
}

測試是否可行,輸入參數3,A,B,C,發現運轉良好

A -> c
A -> B
c -> B
A -> c
B -> A
B -> c
A -> c

Process finished with exit code 0

桿切割問題

初始遞歸代碼

public int maxProfit(final int length) {
    int profit = (length <= prices.size()) ? prices.get(length - 1) : 0;
    for(int i = 1; i < length; i++) {
        int priceWhenCut = maxProfit(i) + maxProfit(length - i);
        if(profit < priceWhenCut) profit = priceWhenCut;
    }
    return profit;
}

同樣的簡單更改一下遞歸處的調用就可以更改為備忘錄的優化,這里為了節省代碼不使用方法引用,直接實現

public int maxProfit(final int rodLenth) {
    return callMemo(
        (func,length) -> {
        int profit = (length <= prices.size()) ? prices.get(length - 1) : 0;
        for(int i = 1; i < length; i++) {
            int priceWhenCut = func.apply(i) + func.apply(length - i);
            if(profit < priceWhenCut) profit = priceWhenCut;
        }
        return profit;
        }, rodLenth);
}

總結

不得不承認,這一章的內容是比較難的,尤其是在對遞歸方法的簽名設計上,要理解這一切需要有一定的函數編程設計的理解,所以我用了很長的篇幅來一步步講述為什么要這么封裝,前面的設計為什么要這么設計,以及最后選了斐波那契,漢諾塔,桿切割的原始遞歸代碼來優化成備忘錄模式,習慣了面向對象的設計在碰到函數式的方式設計的時候確實容易一頭包,不過沒有人生下來就會這一切,因此我在這里想說的是,practise makes perfect,熟能生巧,希望每個人都能成為自己心目中的大師 😃


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM