最近做一個統計工作,需要遍歷一些文件,一個文件夾下面有很多層的小文件,如何算出這個文件夾下面有多少文件?相信很多人第一時間都能想到遞歸遍歷,這是最直接,最簡單的辦法。在計算機中,函數調用是通過棧(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系統中是不檢測溢出的。失控遞歸可能導致棧溢出。