樹的遍歷與遞歸

最近做一個統計工作,需要遍歷一些文件,一個文件夾下面有很多層的小文件,如何算出這個文件夾下面有多少文件?相信很多人第一時間都能想到遞歸遍歷,這是最直接,最簡單的辦法。在計算機中,函數調用是通過棧(stack)這種數據結構實現的,每當進入一個函數調用,棧就會加一層棧幀,每當函數返回,棧就會減一層棧幀。由於棧的大小不是無限的,所以,遞歸調用的次數過多,可能會導致棧溢出。當文件夾深度足夠深,遞歸的反覆調用會導致方法一直無法釋放,造成jvm的棧溢出。那我們該怎麼辦?

如何遍歷文件夾

學過數據結構的都知道,文件夾就類似於數據結構的中的樹,遍歷文件夾就如同遍歷樹。常見的遍歷思想有深度優先遍歷和廣度優先遍歷,其中遞歸遍歷就屬於深度優先遍歷的一種。

一、遞歸遍歷

public void traverseFile_recursion(File root) {
    if (root != null) {
        if (root.isDirectory()) {
            File[] files = root.listFiles();
            for (File f : files) {
                traverseFile_recursion(f);
            }
        } else {
            System.out.println(root.getPath());
        }
    }
}

遞歸方法遍歷很容易看懂,遞歸函數的優點是定義簡單,邏輯清晰。這裏不細說了,看上面代碼就OK了。

二、非遞歸的深度優先遍歷

public void traverseFile_depth(File root) {
    Stack<File> fileStack = new Stack<>();
    File file;
    if (root != null && root.isDirectory()) {
        fileStack.push(root);
    }
    while (!fileStack.isEmpty()) {
        file = fileStack.pop();
        File[] files = file.listFiles();
        for (File f : files) {
            if (f.isDirectory()) {
                fileStack.push(f);
            } else {
                System.out.println(f.getPath());
            }
        }
    }
}

深度優先遍歷,藉助了一個棧,然後按次序讀取文件夾的元素,並判斷如果是文件夾則把該文件夾入棧。然後棧頂的文件夾再出棧,遍歷,以此類推,直到所有的文件夾都出棧,再也沒有文件夾入棧。

三、廣度優先遍歷

public void traverseFile_Width(File root) {
    Queue<File> fileQueue = new ArrayDeque<>();
    File file;
    if (root != null && root.isDirectory()) {
        fileQueue.add(root);
    }
    while (!fileQueue.isEmpty()) {
        file = fileQueue.remove();
        File[] files = file.listFiles();
        for (File f : files) {
            if (f.isDirectory()) {
                fileQueue.add(f);
            } else {
                System.out.println(f.getPath());
            }
        }
    }
}

廣度優先遍歷,藉助了一個隊列,然後按次序讀取文件夾的元素,並判斷如果是文件夾則把該文件夾加入隊尾,然後隊首的文件夾再出隊,遍歷,以此類推,直到所有的文件夾都出隊,再也沒有文件夾入隊。

關於遞歸的性能問題

雖然遞歸方法在解決一些問題時,邏輯思路很清晰,在很多情況下遞歸還是不建議使用的,效率偏低,嚴重的情況下,會造成棧溢出。解決辦法是使用尾遞歸或者使用循環方式。理論上,所有的遞歸函數都可以寫成循環的方式,但循環的邏輯不如遞歸清晰。下面,舉例子說明一下

一、斐波拉契數列

斐波拉契數列指的是這樣一個數列:1、1、2、3、5、8、13、21、34、……第1個和第2個數是1,從第3個位置起,每個數等於它前面兩個數的和。求第 n 個位置的數是多少?
使用遞歸方法實現很簡單,方法如下:

public long fun1(int x) {
    if (x == 1)
        return 1;
    else if (x == 2)
        return 1;
    else {
        return fun1(x - 1) + fun1(x - 2);
    }
}

測試代碼如下:

public static void main(String[] args) {
    long start_time = System.currentTimeMillis();
    long result = new Fibo().fun1(46);
    System.out.println("結果:  " + result);
    long end_time = System.currentTimeMillis();
    System.out.println("耗時: " + (end_time - start_time));
}

以下是基於我的PC(i7-7700,16G-DDR4-2400)的執行結果:
位置:46

結果:  1836311903
耗時: 5170

位置:47

結果:  2971215073
耗時: 8355

位置:48

結果:  4807526976
耗時: 13377

位置:49

結果:  7778742049
耗時: 21941

位置:50

結果:  12586269025
耗時: 34915

位置:51

結果:  20365011074
耗時: 57158

看這個時間增加,你還想用遞歸求嗎?求位置51的用時接近一分鐘。
下面用循環來實現:

public long fun2(int x) {
    long num1 = 1;
    long num2 = 1;
    long result = 0;
    if (x == 1) {
        return 1;
    } else if (x == 2) {
        return 1;
    } else {
        for (int i = 3; i <= x; i++) {
            result = num1 + num2;
            num1 = num2;
            num2 = result;
        }
        return result;
    }
}

同樣測試位置 51 的結果:

結果:  20365011074
耗時: 0

幾乎是秒算出結果。再看看100位置:

結果:  3736710778780434371
耗時: 1

同樣幾乎是秒出結果。這說明,用循環的方式,時間幾乎是常數級的。若要用遞歸方式求100位置的,我想我可以讓程序執行,然後去睡覺了。

二、遍歷打印 List 元素

上面關於遞歸使用,由於迭代層次還沒到一定級別,所以只能時間長,還沒到棧溢出的地步,下面我們測試一下遍歷上萬元素的了 List ,來說明,上代碼:

public static void printElement(Iterator<Integer> iterator) {
    if (!iterator.hasNext()) {
        return;
    } else {
        System.out.println(iterator.next());
        printElement(iterator);
    }
}

測試代碼:

public static void main(String[] args) {
    List<Integer> list = new ArrayList();
    for (int i = 0; i < 20000; i++) {
        list.add(i);
    }
    long start_time = System.currentTimeMillis();
    printElement(list.iterator());
    long end_time = System.currentTimeMillis();
    System.out.println("耗時: " + (end_time - start_time));
}

我們用遞歸的方式遍歷 List ,運行結果部分截圖:

程序崩了,報的錯,正是棧溢出。下面用循環方式遍歷:

public static void printElement2(Iterator<Integer> iterator) {
    while (iterator.hasNext()) {
        System.out.println(iterator.next());
    }
}

測試結果部分截圖如下:

方法調用時的內存情況

下面摘錄《數據結構預算法分析-Java語言描述》中的幾段話:
說明了方法調用的過程,由此也解釋了深層次遞歸性能低的原因。

當調用一個新方法時,主調例程的所有局部變量需要由系統存儲起來,否則被調用的新方法將會重寫由主調例程的變量所使用的內存。不僅如此,該主調例程的當前位置也必須要存儲,以便在新方法運行完後知道向哪裏轉移。這些變量一般由編譯器指派給機器的寄存器,但存在某些衝突(通常所有的方法都是獲取指定給1號寄存器的某些變量),特別是涉及到遞歸的時候。該問題類似於平衡符號的原因在於,方法調用和方法返回基本上類似於開括號和閉括號。

當存在方法調用的時候,需要存儲的所有重要信息,諸如寄存器的值(對應變量的名字)和返回地址 (它可從程序計數器得到,一般情況是在一個寄存器中)等, 都要以抽象的方法存在“一張紙上”並被置於一個堆(pile)的頂部。然後控制轉移到新方法,該方法自由地用它的一些值替代這些寄存器。如果它又進行其它的方法調用,那麼它也遵循相同的過 程。當該方法要返回時,它查看堆頂部的那張“紙”並復原所有的寄存器,然後進行返回轉移。

顯然,所有全部工作均可由一個棧來完成,而這正是在實現遞歸的每一種程序設計語言中實際發生的事實。所儲存的信息或稱爲活動記錄(activation record),或叫做幀棧(stack frame)。

在實際計算機中的棧常常是從內存分區的高端向下增長,而在許多非Java系統中是不檢測溢出的。失控遞歸可能導致棧溢出。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章