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

前言

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

回顧

爲了回顧上一章節,同時用本章的例子做對比,咱們這裏使用經典的斐波那契數列求解問題做爲例子來說解。
斐波那契數列表示這樣的一組數 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.\]java

遞歸求解

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

/**
     * 遞歸求解斐波那契數列
     *
     * @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表達式的調用等一些基本開銷佔用了大部分的時間,可是這是常數級別的時間,計算過程自己幾乎不花費什麼時間,因此性能也是十分好的。數據結構

迭代求解

尾遞歸在優化以後在計算過程上就變成了自底向上,所以也就是轉變成了迭代的過程,這裏你們配合迭代求解來理解尾遞歸求解應該會容易許多。app

/**
     * 斐波那契的迭代解法,自底向上求解
     * @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的時候,所要花費的時間ide

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,熟能生巧,但願每一個人都能成爲本身心目中的大師 :)

相關文章
相關標籤/搜索