文件搜索与递归综合案例 | JavaSE

文件搜索与递归综合案例

一、学习目标

学完本章,你应该能够:

  1. 能够解释为什么 listFiles() 只能解决一级目录遍历,而递归可以遍历未知深度的目录树。
  2. 能够独立设计“文件搜索”递归方法,正确确定递归对象、递归出口和递归方向。
  3. 能够使用 File + listFiles() + isFile() + isDirectory() 搜索指定文件名。
  4. 能够扩展实现按照文件后缀搜索、统计文件数量、统计目录大小等需求。
  5. 能够正确处理目录不存在、传入普通文件、listFiles() == null 等边界。
  6. 能够解释目录递归遍历本质上是一种深度优先搜索(Depth-First Search, DFS)
  7. 能够识别递归文件操作中的栈溢出、权限、符号链接等潜在风险。

二、核心知识

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 知识问答

  1. 为什么 listFiles() 只能解决一级遍历?
  2. 为什么目录结构适合递归?
  3. 文件搜索中的递归问题如何定义?
  4. 文件搜索的递归出口有哪些?
  5. 遇到文件和遇到目录分别应该如何处理?
  6. equals()contains()endsWith() 分别适合什么搜索规则?
  7. 为什么递归遍历目录本质上属于 DFS?
  8. 文件系统可以抽象成什么数据结构?
  9. 普通文件可以理解为树中的什么节点?
  10. 为什么 listFiles() == null 必须处理?
  11. 递归目录搜索的时间复杂度大致与什么有关?
  12. 递归调用栈深度主要与文件数量还是目录嵌套深度有关?

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);
        }
    }
}

回答:

  1. 这个方法搜索的是什么?
  2. 它的递归出口在哪里?
  3. 哪一行是递归调用?
  4. 子问题是什么?
  5. 为什么递归最终可以结束?
  6. 如果删除 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);
        }
    }
}

问题:

  1. 递归调用参数错在哪里?
  2. 为什么可能发生无限递归?
  3. 应该传 dir 还是 file
  4. 哪些入口参数没有检查?
  5. listFiles() 的什么边界没有处理?
  6. 修复整个方法。

7.5 综合训练

建立测试目录:

test-data
├── README.md
├── src
│   ├── Main.java
│   └── service
│       ├── UserService.java
│       └── UserServiceImpl.java
├── docs
│   ├── java.md
│   └── mysql.md
└── image
    ├── logo.png
    └── avatar.jpg

完成:

  1. 打印完整目录树;
  2. 搜索所有 .java 文件;
  3. 搜索所有 .md 文件;
  4. 搜索 README.md
  5. 统计普通文件总数;
  6. 统计目录总数;
  7. 统计整个目录树文件总字节数。

要求:

尽量把“遍历目录树”和“具体业务处理”分开思考。


7.6 本章验收

你应该能够闭卷说明:

listFiles()
只能得到一级成员

如果成员是普通文件
→ 直接判断、统计、输出

如果成员是目录
→ 对该目录再次执行相同逻辑

于是形成:
目录
↓
子目录
↓
子目录
↓
……

这就是递归遍历目录树。

从数据结构角度:
文件系统可以看成树。

从算法角度:
这种不断深入一个分支的遍历属于 DFS。

最终:

  • [ ] 能独立写递归文件搜索。
  • [ ] 能处理所有基础入口边界。
  • [ ] 能处理 listFiles() == null
  • [ ] 能按名称搜索。
  • [ ] 能按后缀搜索。
  • [ ] 能打印目录树。
  • [ ] 能递归统计文件数量。
  • [ ] 能递归统计目录大小。
  • [ ] 能解释 DFS。
  • [ ] 能解释文件系统为什么属于树形结构。