文件搜索与递归综合案例 | JavaSE
文件搜索与递归综合案例
一、学习目标
学完本章,你应该能够:
- 能够解释为什么
listFiles()只能解决一级目录遍历,而递归可以遍历未知深度的目录树。 - 能够独立设计“文件搜索”递归方法,正确确定递归对象、递归出口和递归方向。
- 能够使用
File + listFiles() + isFile() + isDirectory()搜索指定文件名。 - 能够扩展实现按照文件后缀搜索、统计文件数量、统计目录大小等需求。
- 能够正确处理目录不存在、传入普通文件、
listFiles() == null等边界。 - 能够解释目录递归遍历本质上是一种深度优先搜索(Depth-First Search, DFS)。
- 能够识别递归文件操作中的栈溢出、权限、符号链接等潜在风险。
二、核心知识
2.1 为什么只使用 listFiles() 不够
假设文件系统如下:
project
├── README.md
├── pom.xml
├── src
│ ├── Main.java
│ └── service
│ ├── UserService.java
│ └── impl
│ └── UserServiceImpl.java
└── docs
└── java
└── io.md
如果:
File directory = new File("project");
File[] files = directory.listFiles();
只能得到:
README.md
pom.xml
src
docs
它不会自动进入:
src
docs
service
impl
java
更不会自动找到:
UserServiceImpl.java
io.md
所以:
listFiles()
=
一级目录遍历
如果目录深度固定:
第一层
第二层
第三层
理论上可以不断手写循环。
但是现实文件系统可能:
1 层
5 层
20 层
100 层
……
深度根本无法提前确定。
这时就需要:
递归
2.2 为什么目录天然适合递归
观察一个目录:
目录
├── 文件
├── 文件
└── 子目录
再观察子目录:
子目录
├── 文件
├── 文件
└── 子目录
你会发现:
子目录与父目录具有相同的结构。
也就是说:
处理一个目录
这个问题内部可能再次出现:
处理一个目录
这就是典型的:
自相似结构。
因此递归逻辑可以概括成:
处理当前目录
│
├── 遇到文件
│ ↓
│ 直接处理
│
└── 遇到目录
↓
再处理这个目录
对应代码思想:
for (File file : files) {
if (file.isFile()) {
// 处理文件
} else if (file.isDirectory()) {
// 递归处理子目录
search(file);
}
}
2.3 文件搜索的核心问题
假设需求:
从某个目录开始,搜索所有名为
pom.xml的文件。
真正需要解决的只有:
1. 当前对象是不是合法目录?
2. 当前目录下面有什么?
3. 遇到普通文件怎么办?
4. 遇到子目录怎么办?
5. 什么情况下结束?
转换成程序逻辑:
搜索目录 dir
│
▼
dir 合法吗?
│
├── 否 → return
│
▼
listFiles()
│
├── null → return
│
▼
遍历一级成员
│
├── 文件
│ │
│ └── 名称匹配?
│ ├── 是 → 输出
│ └── 否 → 跳过
│
└── 目录
│
└── searchFile(子目录)
这就是完整算法。
2.4 文件搜索中的递归三要素
上一章学习了:
递归公式
递归出口
递归方向
现在直接应用。
递归问题
定义:
searchFile(dir, target)
表示:
在
dir目录及其所有子目录中搜索target。
递归出口
如果:
dir == null
或者:
dir 不存在
或者:
dir 不是目录
那么:
return;
同时:
dir.listFiles()
如果返回:
null
也应该结束这一条搜索路径。
递归方向
遇到:
子目录
执行:
searchFile(child, target);
问题范围从:
整个父目录
缩小到:
其中一个子目录
继续向目录树更深处推进。
最终会遇到:
没有子目录的目录
或:
无法继续遍历的目录
递归自然结束。
三、使用方法
3.1 搜索指定文件名
例如搜索:
pom.xml
完整实现:
import java.io.File;
public class FileSearchDemo {
public static void main(String[] args) {
File root = new File("D:/code");
searchFile(root, "pom.xml");
}
public static void searchFile(
File directory,
String targetName) {
if (directory == null
|| !directory.exists()
|| !directory.isDirectory()) {
return;
}
File[] files = directory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isFile()) {
if (file.getName()
.equals(targetName)) {
System.out.println(
"找到文件:"
+ file.getAbsolutePath()
);
}
} else if (file.isDirectory()) {
searchFile(
file,
targetName
);
}
}
}
}
这里真正重要的是:
不是背代码
而是看懂:
文件
→ 判断是否匹配
目录
→ 递归进入
3.2 为什么使用 equals 而不是 contains
如果搜索:
pom.xml
并使用:
file.getName().contains("pom.xml")
那么:
pom.xml
my-pom.xml
pom.xml.bak
都有可能匹配。
如果需求是:
文件名必须完全等于
pom.xml
应该:
file.getName().equals(targetName)
如果需求本身是:
名称中包含指定文本
才使用:
contains()
所以:
equals()
=
精确匹配
contains()
=
包含匹配
API 选择必须服从需求。
3.3 忽略文件名大小写
如果业务允许:
README.md
readme.md
ReadMe.md
都算匹配,可以:
file.getName()
.equalsIgnoreCase(targetName);
但必须注意:
文件系统本身是否区分文件名大小写,与 Java 字符串比较是否区分大小写,是两个问题。
因此:
equalsIgnoreCase()
表达的是:
你的搜索规则忽略大小写。
3.4 按后缀搜索文件
需求:
搜索目录树中的所有
.java文件。
可以:
import java.io.File;
public class JavaFileSearch {
public static void main(String[] args) {
File root = new File("D:/code");
searchBySuffix(root, ".java");
}
public static void searchBySuffix(
File directory,
String suffix) {
if (directory == null
|| !directory.exists()
|| !directory.isDirectory()) {
return;
}
File[] files = directory.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isFile()) {
if (file.getName()
.endsWith(suffix)) {
System.out.println(
file.getAbsolutePath()
);
}
} else if (file.isDirectory()) {
searchBySuffix(
file,
suffix
);
}
}
}
}
核心变化只是:
equals(targetName)
变成:
endsWith(suffix)
递归框架完全没有变化。
这说明:
递归负责“访问所有节点”,匹配条件负责“决定哪些节点是目标”。
这两个职责应该分开理解。
3.5 打印完整目录树
递归不仅可以搜索文件,也可以显示目录树。
例如:
import java.io.File;
public class DirectoryTreeDemo {
public static void main(String[] args) {
File root = new File("project");
printTree(root, 0);
}
public static void printTree(
File file,
int depth) {
if (file == null || !file.exists()) {
return;
}
System.out.println(
" ".repeat(depth)
+ file.getName()
);
if (!file.isDirectory()) {
return;
}
File[] children = file.listFiles();
if (children == null) {
return;
}
for (File child : children) {
printTree(
child,
depth + 1
);
}
}
}
这里新增了参数:
depth
表示:
当前所在层级
例如:
depth = 0
project
depth = 1
src
depth = 2
service
depth = 3
UserService.java
每次递归:
depth + 1
就表示进入下一层。
3.6 为什么每一层 depth 不会互相覆盖
因为:
printTree(file, 0)
和:
printTree(child, 1)
是:
两次不同的方法调用。
每层都有自己的:
file
depth
局部变量
执行状态
它们分别保存在不同调用栈帧中。
因此递归天然可以保存:
当前节点
+
当前深度
3.7 统计整个目录树中的文件数量
如果只是一级统计:
listFiles()
就够了。
如果统计:
所有层级的文件总数
则需要递归。
可以让方法:
返回当前目录树中的文件数量
实现:
import java.io.File;
public class FileCountDemo {
public static void main(String[] args) {
File root = new File("project");
long count = countFiles(root);
System.out.println(
"文件总数:" + count
);
}
public static long countFiles(File file) {
if (file == null || !file.exists()) {
return 0;
}
if (file.isFile()) {
return 1;
}
File[] children = file.listFiles();
if (children == null) {
return 0;
}
long count = 0;
for (File child : children) {
count += countFiles(child);
}
return count;
}
}
注意这里出现了一个很漂亮的递归定义:
普通文件
=
1 个文件
目录:
目录中的文件总数
=
所有子节点文件数量之和
即:
count(directory)
=
count(child1)
+
count(child2)
+
...
3.8 统计整个目录大小
同样可以定义:
size(file)
如果当前对象是普通文件:
return file.length();
如果是目录:
目录大小
=
所有子节点大小之和
代码:
public static long directorySize(File file) {
if (file == null || !file.exists()) {
return 0;
}
if (file.isFile()) {
return file.length();
}
File[] children = file.listFiles();
if (children == null) {
return 0;
}
long total = 0;
for (File child : children) {
total += directorySize(child);
}
return total;
}
这也解释了为什么前面不能直接:
directory.length();
来表示整个目录树的总大小。
真正的逻辑是:
遍历所有文件
+
累加每个文件 length()
四、原理与进阶
4.1 本质上是在遍历一棵树
文件系统:
project
├── src
│ ├── main
│ └── test
├── docs
└── pom.xml
可以抽象成:
project
/ | \
src docs pom.xml
/ \
main test
这就是典型的:
树形结构(Tree Structure)
其中:
目录
=
可以继续拥有子节点
而:
普通文件
=
叶子节点
因此:
递归遍历目录
其实是在:
遍历一棵树。
4.2 深度优先搜索 DFS
当前算法:
project
↓
src
↓
main
↓
...
会先沿着一个分支不断深入。
走到底以后:
返回上一层
再处理下一个分支。
这种策略称为:
深度优先搜索(Depth-First Search, DFS)
例如:
A
├── B
│ ├── D
│ └── E
└── C
一种 DFS 顺序可能是:
A
B
D
E
C
所以你现在学习的:
递归文件搜索
实际上已经开始接触算法中的:
树遍历
DFS
这也是以后学习:
二叉树
图
回溯
搜索算法
的重要基础。
4.3 时间复杂度
假设整个目录树总共有:
N
个文件系统节点。
为了完整搜索:
最坏情况下
每一个节点都需要访问
因此时间复杂度通常可以近似理解为:
O(N)
如果目标文件在最深、最后一个位置:
仍然可能遍历几乎整个目录树
4.4 空间复杂度与目录深度有关
递归调用栈的最大深度不是:
文件总数
而主要与:
目录最大嵌套深度
有关。
假设:
A
└── B
└── C
└── D
└── ...
目录嵌套极深:
递归调用栈
也会越来越深。
极端情况下仍可能:
StackOverflowError
因此:
递归很适合教学和一般目录树处理,但不能假设任意深度都绝对安全。
4.5 权限与 I/O 边界
在真实文件系统中,并不是:
看得见路径
=
一定可以读取目录内容
某些目录可能:
没有读取权限
发生 I/O 错误
属于特殊系统目录
于是:
listFiles()
可能:
返回 null
所以:
if (files == null) {
return;
}
不是多余代码。
它属于:
文件系统程序必须考虑的现实边界。
4.6 符号链接与特殊目录
在更复杂的操作系统文件系统中,还存在:
symbolic link
junction
mount point
等特殊结构。
它们可能让:
表面上的树结构
变得没有想象中那么简单。
生产级文件遍历还需要考虑:
循环链接
权限
异常
最大深度
是否跟随链接
本课程当前先掌握:
普通目录树
+
File
+
递归
即可。
后续如果进行更现代、更严格的文件系统开发,可以学习:
Path
Files
Files.walk()
Files.walkFileTree()
五、实践应用
5.1 Java 项目源码搜索器
需求:
搜索一个 Java 项目中所有
.java文件。
可以组合:
File
+
递归
+
endsWith(".java")
这已经可以用于:
统计项目源码文件
寻找指定类
源码扫描
简单代码分析工具
5.2 星雨笔录内容文件扫描
假设服务器:
content
├── tutorials
│ ├── javase
│ └── database
├── blogs
└── images
可以递归:
扫描所有 Markdown
统计图片数量
统计内容文件总大小
检查某个文件是否存在
这些都建立在今天的:
目录树递归
之上。
5.3 一个通用思维框架
以后看到任何树形问题,可以问自己:
当前节点是什么?
当前节点有哪些子节点?
叶子节点怎么处理?
子节点是不是和当前问题具有相同结构?
如果答案是:
是
那么通常值得考虑:
递归
六、常见问题
6.1 为什么 listFiles() 不能直接搜索整个磁盘?
因为:
listFiles()
只获取:
当前目录一级成员
必须:
遇到子目录
↓
再次 listFiles()
才能继续深入。
6.2 文件搜索到底是哪一步实现“搜索”?
两个部分共同完成:
递归
→ 保证访问目录树
匹配条件
→ 判断是不是目标文件
例如:
file.getName().equals(target)
负责匹配。
递归本身并不知道:
你要找什么文件。
6.3 为什么不能直接假设 listFiles() 一定非 null?
因为:
不是目录
或
发生 I/O 错误
都可能返回:
null
所以应当进行边界判断。
6.4 contains()、equals()、endsWith() 怎么选择?
根据需求:
完整文件名匹配
→ equals()
名称包含关键词
→ contains()
后缀匹配
→ endsWith()
不要为了“都能搜到”而随意选择。
6.5 找到一个文件之后要不要停止搜索?
取决于需求。
如果:
只需要找到任意一个
可以设计:
找到以后立即返回
如果:
需要找到全部同名文件
则必须:
继续遍历整个目录树
所以搜索算法是否“提前结束”属于:
业务需求的一部分。
6.6 为什么递归搜索整个磁盘可能很慢?
因为可能存在:
几十万
几百万
个文件系统对象。
完整搜索最坏情况下要访问大量节点。
因此:
递归正确
不等于:
搜索一定高效
6.7 为什么不要随便从整个系统盘开始测试?
例如:
new File("C:/")
可能包含:
大量系统目录
权限受限目录
极深目录
巨大文件数量
学习阶段建议自己建立:
test-data
这样的测试目录树。
更加:
安全
可控
容易观察
七、练习与验收
7.1 知识问答
- 为什么
listFiles()只能解决一级遍历? - 为什么目录结构适合递归?
- 文件搜索中的递归问题如何定义?
- 文件搜索的递归出口有哪些?
- 遇到文件和遇到目录分别应该如何处理?
equals()、contains()、endsWith()分别适合什么搜索规则?- 为什么递归遍历目录本质上属于 DFS?
- 文件系统可以抽象成什么数据结构?
- 普通文件可以理解为树中的什么节点?
- 为什么
listFiles() == null必须处理? - 递归目录搜索的时间复杂度大致与什么有关?
- 递归调用栈深度主要与文件数量还是目录嵌套深度有关?
7.2 代码阅读
不要运行:
public static void search(
File dir,
String suffix) {
if (dir == null
|| !dir.exists()
|| !dir.isDirectory()) {
return;
}
File[] files = dir.listFiles();
if (files == null) {
return;
}
for (File file : files) {
if (file.isFile()) {
if (file.getName()
.endsWith(suffix)) {
System.out.println(
file.getAbsolutePath()
);
}
} else if (file.isDirectory()) {
search(file, suffix);
}
}
}
回答:
- 这个方法搜索的是什么?
- 它的递归出口在哪里?
- 哪一行是递归调用?
- 子问题是什么?
- 为什么递归最终可以结束?
- 如果删除
files == null判断,可能产生什么问题?
7.3 手写代码
任务一:指定名称搜索
手写:
searchFile(
File directory,
String targetName
)
要求:
精确匹配文件名
递归搜索所有子目录
处理非法目录
处理 listFiles() == null
任务二:后缀搜索
实现:
搜索所有 .java 文件
禁止复制上一段代码。
先写:
递归出口
递归方向
文件匹配条件
再编码。
任务三:目录树
编写:
printTree(File file, int depth)
要求输出类似:
project
src
Main.java
service
UserService.java
pom.xml
任务四:文件统计
递归统计:
整个目录树中的普通文件总数
整个目录树中的目录总数
任务五:目录大小
编写:
long size(File file)
计算:
所有普通文件 length() 之和
7.4 Debug
观察:
public static void search(File dir) {
File[] files = dir.listFiles();
for (File file : files) {
if (file.isDirectory()) {
search(dir);
} else {
System.out.println(file);
}
}
}
问题:
- 递归调用参数错在哪里?
- 为什么可能发生无限递归?
- 应该传
dir还是file? - 哪些入口参数没有检查?
listFiles()的什么边界没有处理?- 修复整个方法。
7.5 综合训练
建立测试目录:
test-data
├── README.md
├── src
│ ├── Main.java
│ └── service
│ ├── UserService.java
│ └── UserServiceImpl.java
├── docs
│ ├── java.md
│ └── mysql.md
└── image
├── logo.png
└── avatar.jpg
完成:
- 打印完整目录树;
- 搜索所有
.java文件; - 搜索所有
.md文件; - 搜索
README.md; - 统计普通文件总数;
- 统计目录总数;
- 统计整个目录树文件总字节数。
要求:
尽量把“遍历目录树”和“具体业务处理”分开思考。
7.6 本章验收
你应该能够闭卷说明:
listFiles()
只能得到一级成员
如果成员是普通文件
→ 直接判断、统计、输出
如果成员是目录
→ 对该目录再次执行相同逻辑
于是形成:
目录
↓
子目录
↓
子目录
↓
……
这就是递归遍历目录树。
从数据结构角度:
文件系统可以看成树。
从算法角度:
这种不断深入一个分支的遍历属于 DFS。
最终:
- [ ] 能独立写递归文件搜索。
- [ ] 能处理所有基础入口边界。
- [ ] 能处理
listFiles() == null。 - [ ] 能按名称搜索。
- [ ] 能按后缀搜索。
- [ ] 能打印目录树。
- [ ] 能递归统计文件数量。
- [ ] 能递归统计目录大小。
- [ ] 能解释 DFS。
- [ ] 能解释文件系统为什么属于树形结构。