【進階篇】Java 專案中對使用遞迴的理解分享

CodeBlogMan發表於2024-07-02

【進階篇】Java 專案中對使用遞迴的理解分享

目錄
  • 【進階篇】Java 專案中對使用遞迴的理解分享
    • 前言
    • 一、什麼是遞迴
      • 1.1基本概念
      • 1.2優缺點
      • 1.3與迭代的區別
    • 二、實際案例
    • 三、改進方案
      • 3.1控制遞迴層數
      • 3.1用 Stream 遍歷
    • 四、文章小結

前言

筆者在最近的專案開發中,遇到了兩個父子關係緊密相關的場景:評論樹結構、部門樹結構。具體的需求如:找出某條評論下的所有子評論id集合,找出某個部門下所有的子部門id集合。

在之前的專案開發經驗中,遞迴使用得是較少的,但作為一個在資料結構操作中遍歷樹節點的解決方案,我還是拿出來作為技術積累進行記錄以及分享。

一、什麼是遞迴

1.1基本概念

這裡就有必要簡單介紹一下關於遞迴的基本概念了。

在 Java 中,遞迴是指在方法的定義中呼叫自身的過程,遞迴是基於方法呼叫棧的原理實現的:當一個方法被呼叫時,會在呼叫棧中建立一個對應的棧幀,包含方法的引數、區域性變數和返回地址等資訊。在遞迴中,方法會在自身的定義中呼叫自身,這會導致多個相同方法的棧幀依次入棧。當滿足終止條件時,遞迴開始回溯,棧幀依次出棧,方法得以執行完畢。

遞迴的關鍵是定義好遞迴的終止條件和遞迴呼叫的條件。如果沒有適當的終止條件或遞迴呼叫的條件不滿足,遞迴可能會陷入無限迴圈,導致棧記憶體溢位。

1.2優缺點

優點:

  1. 簡化問題:遞迴能夠將複雜問題分解成更小規模的子問題,簡化了問題的解決過程;
  2. 實現高效演算法:遞迴在某些演算法中能夠實現高效的解決方法,如資料結構操作中遍歷樹節點等。

缺點:

  1. 棧溢位風險:遞迴可能導致方法呼叫棧過深,造成棧記憶體溢位;
  2. 效能損耗:遞迴呼叫需要建立多個棧幀,對系統資源有一定的消耗;
  3. 可讀性不高:遞迴的使用需要謹慎,不合理地使用可能造成程式碼難以理解和除錯。

1.3與迭代的區別

  • 迭代(Iteration)

    迭代常見於 for 迴圈中:比如有一個集合 A,對 A 進行 foreach,在內部設定條件,符合條件後將集合中某個元素的值替換成別的值。

【進階篇】Java 專案中對使用遞迴的理解分享
迭代示例簡圖
    @Test
    public void iterationTest(){
        ArrayList<String> list = new ArrayList<>();
        list.add("計算機技術");
        list.add("土木工程");
        list.add("市場營銷");
        list.forEach(val -> {
            if (val.contains("計算機")){
                log.info("迭代前的的專業名稱:{}", val);
                String str = val.replace(val, "電腦科學與技術");
                log.info("迭代後的的專業名稱:{}", str);
            }
        });
    }

結果為:

【進階篇】Java 專案中對使用遞迴的理解分享
迭代結果簡圖
  • 遞迴(Recursion)

    【進階篇】Java 專案中對使用遞迴的理解分享

遞迴的例子會在下一小節詳細給出。


二、實際案例

下面筆者以遞迴獲取某個評論id下面所有的子級評論id為例子,向大家介紹這個遞迴的過程。

首先,這裡給出一個簡單的資料庫評論表的 demo,id 是主鍵id 也是評論唯一 id,parent_id 是該條評論的父評論 id,status 為1表示稽核透過的狀態。

【進階篇】Java 專案中對使用遞迴的理解分享

其中,我們可以簡單發現:這裡21為第一層,28和29為第二層、31和32為第三層,草圖如下所示:

【進階篇】Java 專案中對使用遞迴的理解分享
評論id簡單層級示意圖

那麼,我們如何將21、28、29、31、32都放進一個集合裡返回呢?下面的程式碼示例可以給你一個參考。

但是,在看程式碼之前,有個問題請你思考一下:

從21開始後,遍歷的路線是21-28-29?還是21-28-31?還是21-29-32?或者是21-28-31-29-32?

下面是經過脫敏處理後的參看程式碼示例,註釋都寫得比較清楚了:

    /**
     * 這裡可以看作是外部介面的呼叫,會得到遞迴的結果
     * @param id
     */
    private List<Integer> getIdListMethod(Integer id){
        ArrayList<Integer> idList = new ArrayList<>();
        this.getAllIdByRecursion(id, idList);
        log.info("遞迴後得到的id集合:{}", idList);
        return idList;
    }

    /**
     * 這裡是遞迴的過程
     * @param id
     * @param idList
     */
    private void getAllIdByRecursion(Integer id, List<Integer> idList){
        LambdaQueryWrapper<Comment> wrapper = new LambdaQueryWrapper<>();
        //先把該id下所有的第一級子id找到
        wrapper.eq(Comment::getParentId, id).eq(Comment::getStatus, NumberUtils.INTEGER_ONE);
        List<Comment> commentList = this.list(wrapper);
        for (Comment children : commentList){
            this.getAllIdByRecursion(children.getId(), idList);
        }
        log.info("放入集合的id為:{}", id);
        idList.add(id);
    }

上面問題的答案是:遞迴後得到的id集合:[21,28,31,29,32],原因就是:迭代會從一棵樹開始遍歷到底,沒有元素了再從頭開始遍歷,依次迭代,類似於深度優先遍歷。

比如:21下面有兩個子id:28和29,那麼會先走21-28-31這棵樹,到底了後接著按照29-32遍歷。


三、改進方案

我根據自己的開發經驗,可以從控制遞迴層數和改用 Stream 這兩種辦法來對遞迴進行改進。

3.1控制遞迴層數

JVM 預設控制的遞迴最大深度限制在 1000 層,可以透過設定 JVM 引數來控制其深度,如:

java -Xss5m #表示將每個執行緒的棧記憶體大小設定為5MB,已經是比較大了

或者在程式碼層面對遞迴的層數進行控制:

        int depth = 0;
        //遞迴方法呼叫
        for (int i = 0; i < 20; i++) {
            depth++;
        }
        if (depth > 100){
            //其它操作
        }

3.1用 Stream 遍歷

核心思路是:先資料庫全量查詢(10萬條以內),記憶體中使用 Stream 流操作、Lambda 表示式、Java 地址引用進行篩選。

適用於資料總量不多的情況,如:部門樹,部門數量一般情況是比較固定的,一個組織或者公司最多也就幾百上千個部門。

詳情可以看我這篇文章:https://www.cnblogs.com/CodeBlogMan/p/17965824


四、文章小結

筆者確實不推薦在專案中過度使用遞迴,但是合理使用的話也能成為解決特定問題的一個利器,至於怎麼拿捏這個度,那就要看大家的具體情況了。

Java 專案中對使用遞迴的理解分享到這裡就結束了,文章如有不足和錯誤,或者你有更好的解決思路,歡迎大家的指正和交流!

相關文章