1. 程式人生 > >實戰演算法——多叉樹全路徑遍歷

實戰演算法——多叉樹全路徑遍歷

多叉樹全路徑遍歷

本文為原創作品,首發於微信公眾號:【阪本先生】,如需轉載請在文首明顯位置標明“轉載於微信公眾號:【阪本先生】”,否則追究其法律責任。
微信文章地址:實戰演算法——多叉樹全路徑遍歷

前言

本文研究的是如何對一個多叉樹進行全路徑的遍歷,並輸出全路徑結果。該問題的研究可以用在:Trie樹中檢視所有字典值這個問題上。本文將對該問題進行詳細的模擬及進行程式碼實現,討論了遞迴和非遞迴兩種方法優劣並分別進行實現,如果讀者對這兩種方法的優劣不感興趣可直接跳到問題構建章節進行閱讀。文章較長,推薦大家先收藏再進行閱讀。

文章目錄

  • 多叉樹全路徑遍歷
    • 前言
    • 文章目錄
    • 遞迴和迭代比較
      • 遞迴
      • 迭代
      • 遞迴的劣勢和優勢
        • 遞迴的劣勢
        • 遞迴的優勢
    • 問題構建
    • 問題解決
      • 遞迴方法
      • 迭代方法
    • 結論
    • 參考資料

遞迴和非遞迴比較

這個問題知乎上已經有了很多答案(https://www.zhihu.com/question/20278387),在其基礎上我進行了一波總結:

遞迴

將一個問題分解為若干相對小一點的問題,遇到遞迴出口再原路返回,因此必須儲存相關的中間值,這些中間值壓入棧儲存,問題規模較大時會佔用大量記憶體。

非遞迴

執行效率高,執行時間只因迴圈次數增加而增加,沒什麼額外開銷。空間上沒有什麼增加

遞迴的劣勢和優勢

遞迴的劣勢

  • 遞迴容易產生"棧溢位"錯誤(stack overflow)。因為需要同時儲存成千上百個呼叫記錄,所以遞迴非常耗費記憶體。
  • 效率方面,遞迴可能存在冗餘計算。使用遞迴的方式會有冗餘計算(比如最典型的是斐波那契數列,計算第6個需要計算第4個和第5個,而計算第5個還需要計算第4個,所處會重複)。迭代在這方面有絕對優勢。

遞迴的優勢

遞迴擁有較好的程式碼可讀性,對於資料量不算太大的運算,使用遞迴演算法綽綽有餘。

問題構建

現在存在一個多叉樹,其結點情況如下圖,需要給出方法將葉子節點的所有路徑進行輸出。

最終輸出結果應該有5個,即[rad,rac,rbe,rbf,rg]

問題解決

首先我們對結點進行分析,構建一個結點類(TreeNode),然後我們需要有一個樹類(MultiTree),包含了全路徑列印的方法。最後我們需要建立一個Main方法進行測試。最終的專案結構如下:

注意:本文使用了lombok註解,省去了get,set及相關方法的實現。如果讀者沒有使用過lombok也可以自己編寫對應的get,set方法,後文會對每個類進行說明需要進行實現的方法,對核心程式碼沒有影響。

TreeNode類

節點類,主要包含兩個欄位:

  • content:用於儲存當前節點儲存的內容
  • childs:用於儲存子節點資訊,HashMap的string儲存的是子節點內容,childs採用HashMap實現有利於實現子節點快速查詢

該類中包含了必要的get,set方法,一個無參構造器,一個全參構造器

@Data
@RequiredArgsConstructor
@AllArgsConstructor
public class TreeNode {
    private String content;
    private HashMap<String,TreeNode> childs;
}

MultiTree類

包含的欄位只有兩個:

  • root:根節點
  • pathList:用於儲存遍歷過程中得到的路徑

該類中的建構函式中我手動建立問題構建中的樹,相關程式碼如下:

    public MultiTree(){
        //建立根節點
        HashMap rootChilds = new HashMap();
        this.root = new TreeNode("r",rootChilds);

        //第一層子節點
        HashMap aChilds = new HashMap();
        TreeNode aNode = new TreeNode("a",aChilds);

        HashMap bChilds = new HashMap();
        TreeNode bNode = new TreeNode("b",bChilds);

        HashMap gChilds = new HashMap();
        TreeNode gNode = new TreeNode("g",gChilds);

        //第二層結點
        HashMap dChilds = new HashMap();
        TreeNode dNode = new TreeNode("d",dChilds);

        HashMap cChilds = new HashMap();
        TreeNode cNode = new TreeNode("c",cChilds);

        HashMap eChilds = new HashMap();
        TreeNode eNode = new TreeNode("e",eChilds);

        HashMap fChilds = new HashMap();
        TreeNode fNode = new TreeNode("f",fChilds);

        //建立結點聯絡
        rootChilds.put("a",aNode);
        rootChilds.put("b",bNode);
        rootChilds.put("g",gNode);

        aChilds.put("d",dNode);
        aChilds.put("c",cNode);

        bChilds.put("e",eNode);
        bChilds.put("f",fNode);
    }

在這個樹中,每個節點都有childs,如果是葉子節點,則childs中的size為0,這是下面判斷一個節點是否為葉子節點的重要依據接下來我們會對核心演算法程式碼進行實現。

Main類

public class Main {
    public static void main(String[] args) {
        MultiTree tree = new MultiTree();
        List<String> path1 = tree.listAllPathByRecursion();
        System.out.println(path1);
        List<String> path2 = tree.listAllPathByNotRecursion();
        System.out.println(path2);
    }
}

遞迴方法

需要完善MultiTree類中的listAllPathByRecursion方法和listPath方法

遞迴過程方法:listAllPathByRecursion

演算法流程圖如下:

程式碼實現如下:

public void listPath(TreeNode root,String path){

    if(root.getChilds().isEmpty()){//葉子節點
        path = path + root.getContent();
        pathList.add(path); //將結果儲存在list中
        return;
    }else{ //非葉子節點
        path = path  + root.getContent() + "->";

        //進行子節點的遞迴
        HashMap<String, TreeNode> childs = root.getChilds();
        Iterator iterator = childs.entrySet().iterator();
        while(iterator.hasNext()){
            Map.Entry entry = (Map.Entry)iterator.next();
            TreeNode childNode  = (TreeNode) entry.getValue();
            listPath(childNode,path);
        }
    }
}

遞迴呼叫方法:listAllPathByRecursion

public List<String> listAllPathByRecursion(){
    //清空路徑容器
    this.pathList.clear();
    listPath(this.root,"");
    return this.pathList;
}

非遞迴方法

非遞迴方法的程式碼量和遞迴方法一比,簡直是太多了,而且內容不好理解,不知道大家能不能看懂我寫的程式碼,我已經盡力寫上相關注釋了。

首先建立了兩個棧,示意圖如下,棧的實現使用Deque,需要注意的是程式碼中的空指標情況。

  • 主棧:用於處理節點和臨時路徑的儲存,主棧為空時說明,節點處理完畢

  • 副棧:用於存放待處理節點,副棧為空時說明,節點遍歷完畢

其他相關變數介紹:

  • popCount :用於儲存一個節點的子節點的彈出個數。例如r有3個子節點,如果r對應的彈出個數為3,說明r的葉子節點處理完畢,可以彈出r。因為r彈出後,主棧沒有元素,故處理完畢。
  • curString:用於儲存臨時路徑,當主棧元素變化時,curString也會進行變化,例如上圖curString為“r->g->”,當棧頂元素彈出時,需要減去"g->"。如果棧頂元素是葉子節點說明該條路徑已經遍歷完成,需要新增到path路徑容器中。

程式流程圖:

具體實現程式碼如下:

/**
 * 非遞迴方法輸出所有路徑
 */
public List<String> listAllPathByNotRecursion(){
    //清空路徑容器
    this.pathList.clear();
    //主棧,用於計算處理路徑
    Deque<TreeNode> majorStack = new ArrayDeque();
    //副棧,用於儲存待處理節點
    Deque<TreeNode> minorStack = new ArrayDeque();
    minorStack.addLast(this.root);

    HashMap<String,Integer> popCount = new HashMap<>();
    String curString  = "";

    while(!minorStack.isEmpty()){
        //出副棧,入主棧
        TreeNode minLast = minorStack.pollLast();
        majorStack.addLast(minLast);
        curString+=minLast.getContent()+"->";
        //將該節點的子節點入副棧
        if(!minLast.getChilds().isEmpty()){
            HashMap<String, TreeNode> childs = minLast.getChilds();
            Iterator iterator = childs.entrySet().iterator();
            while(iterator.hasNext()){
                Map.Entry entry = (Map.Entry)iterator.next();
                TreeNode childNode  = (TreeNode) entry.getValue();
                minorStack.addLast(childNode);
            }
        }
        //出主棧
        TreeNode majLast = majorStack.peekLast();
        //迴圈條件:棧頂為葉子節點 或 棧頂節點孩子節點遍歷完了(需要注意空指標問題)
        while(majLast.getChilds().size() ==0 ||
                (popCount.get(majLast.getContent())!=null && popCount.get(majLast.getContent()).equals(majLast.getChilds().size()))){

            TreeNode last = majorStack.pollLast();
            majLast = majorStack.peekLast();

            if(majLast == null){ //此時主棧為空,運算完畢
                return this.pathList;
            }
            if(popCount.get(majLast.getContent())==null){//第一次彈出孩子節點,彈出次數設為1
                popCount.put(majLast.getContent(),1);
            }else{ //非第一次彈出孩子節點,在原有基礎上加1
                popCount.put(majLast.getContent(),popCount.get(majLast.getContent())+1);
            }
            String lastContent = last.getContent();
            if(last.getChilds().isEmpty()){//如果是葉子節點才將結果加入路徑集中
                this.pathList.add(curString.substring(0,curString.length()-2));
            }
            //調整當前curString,減去2是減的“->”這個符號
            curString = curString.substring(0,curString.length()-lastContent.length()-2);
        }
    }
    return this.pathList;
}

測試

呼叫Main類中的main方法,得到執行結果,和預期結果相同,程式碼通過測試

listAllPathByRecursion[r->a->c, r->a->d, r->b->e, r->b->f, r->g]
listAllPathByNotRecursion[r->g, r->b->f, r->b->e, r->a->d, r->a->c]

結論

其實該文章是我在研究《基於Trie樹的敏感詞過濾演算法實現》的一箇中間產物,其實原來應該也實現過多叉樹的路徑遍歷問題,但是因為時間原因加之原來沒有較好的知識管理系統,程式碼和筆記都丟了,今天趁機再進行一波總結。希望該文章能夠幫助到需要的人。

參考資料

  • [遞迴」和「迭代」有哪些區別? - 葉世清的回答 - 知乎
  • 遞迴如何轉換為非遞迴