标题:在系统中查找重复文件
出处:609. 在系统中查找重复文件
6 级
给定一个目录信息列表 paths \texttt{paths} paths,包括目录路径和该目录中的所有包含内容的文件,返回文件系统中的所有重复文件组的路径。你可以按任意顺序返回结果。
一组重复的文件至少包括两个具有完全相同内容的文件。
输入列表中的单个目录信息字符串的格式如下:
这意味着在目录 root/d1/d2/.../dm \texttt{root/d1/d2/.../dm} root/d1/d2/.../dm 下有 n \texttt{n} n 个文件 (f1.txt, f2.txt ... fn.txt) \texttt{(f1.txt, f2.txt ... fn.txt)} (f1.txt, f2.txt ... fn.txt),内容分别是 (f1_content, f2_content ... fn_content) \texttt{(f1\_content, f2\_content ... fn\_content)} (f1_content, f2_content ... fn_content)。注意: n ≥ 1 \texttt{n} \ge \texttt{1} n≥1 且 m ≥ 0 \texttt{m} \ge \texttt{0} m≥0。如果 m = 0 \texttt{m} = \texttt{0} m=0,则表示该目录是根目录。
输出是重复文件路径组的列表。对于每个组,它包含具有相同内容的文件的所有文件路径。文件路径是具有下列格式的字符串:
示例 1:
输入: paths = ["root/a 1.txt(abcd) 2.txt(efgh)", "root/c 3.txt(abcd)", "root/c/d 4.txt(efgh)", "root 4.txt(efgh)"] \texttt{paths = ["root/a 1.txt(abcd) 2.txt(efgh)", "root/c 3.txt(abcd)", "root/c/d 4.txt(efgh)", "root 4.txt(efgh)"]} paths = ["root/a 1.txt(abcd) 2.txt(efgh)", "root/c 3.txt(abcd)", "root/c/d 4.txt(efgh)", "root 4.txt(efgh)"]
输出: [["root/a/2.txt","root/c/d/4.txt","root/4.txt"],["root/a/1.txt","root/c/3.txt"]] \texttt{[["root/a/2.txt","root/c/d/4.txt","root/4.txt"],["root/a/1.txt","root/c/3.txt"]]} [["root/a/2.txt","root/c/d/4.txt","root/4.txt"],["root/a/1.txt","root/c/3.txt"]]
示例 2:
输入: paths = ["root/a 1.txt(abcd) 2.txt(efgh)","root/c 3.txt(abcd)","root/c/d 4.txt(efgh)"] \texttt{paths = ["root/a 1.txt(abcd) 2.txt(efgh)","root/c 3.txt(abcd)","root/c/d 4.txt(efgh)"]} paths = ["root/a 1.txt(abcd) 2.txt(efgh)","root/c 3.txt(abcd)","root/c/d 4.txt(efgh)"]
输出: [["root/a/2.txt","root/c/d/4.txt"],["root/a/1.txt","root/c/3.txt"]] \texttt{[["root/a/2.txt","root/c/d/4.txt"],["root/a/1.txt","root/c/3.txt"]]} [["root/a/2.txt","root/c/d/4.txt"],["root/a/1.txt","root/c/3.txt"]]
给定的数组 paths \textit{paths} paths 中的每个元素表示一个目录的路径以及该目录中的所有文件的文件名和文件内容。如果一个元素包含用空格分隔的 m m m 项,则可以将该元素使用空格作为分隔符创建长度为 m m m 的数组,数组中的第 0 0 0 项为目录路径,其余 m − 1 m - 1 m−1 项分别为该目录中的每个文件的文件信息,包括文件名和文件内容,可以根据数组中的每个元素得到每个文件的绝对路径和文件内容。
为了找到重复文件,需要找到每种文件内容对应的文件列表,并判断文件列表中的文件个数,如果文件个数大于 1 1 1 则该文件列表为一组重复文件。可以使用哈希表记录每种文件内容对应的文件列表。
由于文件系统中的任意两个目录的路径都不同,任意两个文件的绝对路径都不同,因此不需要对目录的路径和文件的绝对路径做排重操作,使用列表表示每种文件内容的文件列表即可。
对于数组 paths \textit{paths} paths 中的每个元素,首先将该元素使用空格作为分隔符创建文件数组,然后对文件数组中的除了第 0 0 0 项以外的每一项得到文件的绝对路径和文件内容,在哈希表中将文件的绝对路径添加到文件内容的文件列表中。
遍历结束数组 paths \textit{paths} paths 之后,遍历哈希表,对于哈希表中的每种文件内容,如果对应的文件列表中的文件个数大于 1 1 1,则将该文件列表添加到结果中。
class Solution {
public List<List<String>> findDuplicate(String[] paths) {
Map<String, List<String>> map = new HashMap<String, List<String>>();
for (String path : paths) {
String[] array = path.split(" ");
StringBuffer directoryBuffer = new StringBuffer(array[0]);
if (directoryBuffer.charAt(directoryBuffer.length() - 1) != '/') {
directoryBuffer.append('/');
}
String directory = directoryBuffer.toString();
int length = array.length;
for (int i = 1; i < length; i++) {
String fileNameContent = array[i];
int fileNameContentLength = fileNameContent.length();
int leftParenthesisIndex = -1;
StringBuffer fileNameBuffer = new StringBuffer();
for (int j = 0; j < fileNameContentLength && leftParenthesisIndex < 0; j++) {
char c = fileNameContent.charAt(j);
if (c == '(') {
leftParenthesisIndex = j;
} else {
fileNameBuffer.append(c);
}
}
String fileName = fileNameBuffer.toString();
String content = fileNameContent.substring(leftParenthesisIndex + 1, fileNameContentLength - 1);
String completeFileName = directory + fileName;
map.putIfAbsent(content, new ArrayList<String>());
map.get(content).add(completeFileName);
}
}
List<List<String>> duplicates = new ArrayList<List<String>>();
Set<Map.Entry<String, List<String>>> entries = map.entrySet();
for (Map.Entry<String, List<String>> entry : entries) {
String content = entry.getKey();
List<String> files = entry.getValue();
if (files.size() > 1) {
duplicates.add(files);
}
}
return duplicates;
}
}
时间复杂度: O ( n ) O(n) O(n),其中 n n n 是文件系统中的文件总数。遍历文件系统中的全部文件并将每种文件内容和对应的文件列表存入哈希表,需要 O ( n ) O(n) O(n) 的时间,遍历哈希表得到结果也需要 O ( n ) O(n) O(n) 的时间。这里将字符串操作的时间视为 O ( 1 ) O(1) O(1)。
空间复杂度: O ( n ) O(n) O(n),其中 n n n 是文件系统中的文件总数。空间复杂度主要取决于哈希表,哈希表中需要存储每种文件内容对应的文件列表,空间是 O ( n ) O(n) O(n)。这里将字符串占用的空间视为 O ( 1 ) O(1) O(1)。
假设给你一个真正的文件系统,你将如何搜索文件?深度优先搜索还是广度优先搜索?
深度优先搜索和广度优先搜索的时间复杂度相同,因此应该根据空间复杂度决定使用哪种搜索方法。
如果文件系统树结构的深度是 d d d,平均分支数是 f f f(即树结构中的每个目录结点平均有 f f f 个子结点),则深度优先搜索的空间复杂度是 O ( d ) O(d) O(d),广度优先搜索的空间复杂度是 O ( f d ) O(f^d) O(fd)。大多数情况下, O ( d ) O(d) O(d) 优于 O ( f d ) O(f^d) O(fd),因此大多数情况下应该使用深度优先搜索。
如果文件内容非常大(GB 级别),你将如何修改你的解决方案?
如果文件内容非常大,则不能一次性存储文件的完整内容,因此需要将文件内容分块存储,并存储元数据,元数据包括文件名、文件大小、各分块的偏移量、各分块的哈希值等。
其中,一个分块的哈希值等于该分块的内容使用特定哈希函数计算得到的结果,每个文件的所有分块的哈希值计算都使用同一个哈希函数。
如果两个文件的内容相同,则这两个文件的大小一定也相同。因此,比较两个文件的内容是否相同时,首先比较两个文件的大小是否相同,如果大小不同则内容一定不同,如果大小相同再依次比较每个分块的哈希值是否相同,只要有一个分块的哈希值不同,则两个文件的内容不同,只有当每个分块的哈希值都相同时,才能认为两个文件的内容可能相同。使用哈希值进行比较虽然可以降低内存空间(只需要将哈希值存入内存,不需要将文件内容直接存入内存),但是可能存在误报的情况,问题 5 将提供解决方案。
如果每次只能读取 1 KB 的文件,你将如何修改你的解决方案?
每次只能读取 1 KB 的文件,则每个分块的大小最多为 1 KB,因此使用问题 2 的解决方案,将每个分块的大小设为 1 KB。
修改后的解决方案的时间复杂度是多少?其中最消耗时间的部分和最消耗内存的部分是什么?如何优化?
如果文件系统中的文件个数是 n n n,每个文件的平均大小是 k k k,则时间复杂度是 O ( k n 2 ) O(kn^2) O(kn2)。对于每个文件需要 O ( k ) O(k) O(k) 的时间计算元数据,其中计算每个分块哈希值的时间复杂度最高,共需要 O ( k n ) O(kn) O(kn) 的时间计算元数据。每两个文件之间都需要比较,比较次数是 n ( n − 1 ) 2 \dfrac{n(n - 1)}{2} 2n(n−1),每次比较首先比较文件大小,然后比较每个分块的哈希值,因此最坏情况下每次比较的时间复杂度是 O ( k ) O(k) O(k),总时间复杂度是 O ( k n 2 ) O(kn^2) O(kn2)。
最消耗时间和内存的部分是哈希值的计算和存储。
由于文件个数 n n n 和比较次数 n ( n − 1 ) 2 \dfrac{n(n - 1)}{2} 2n(n−1) 无法减少,因此可以优化的方面只有哈希值的计算。在条件允许的情况下,将每个分块的大小增加,即可减少分块个数。
如何确保你发现的重复文件不是误报?
误报的情况为两个文件的内容不同但是两个文件中的每个分块的哈希值对应相同。为了避免误报,当两个文件中的每个分块的哈希值都对应相同时,需要比较两个文件的内容,只有当内容相同时才能认为是重复文件。
这道题的进阶问题涉及到搜索,搜索是在树和图中常用的算法。由于文件系统是一个树的结构,因此搜索文件需要使用到搜索算法。常见的搜索算法有深度优先搜索和广度优先搜索。
深度优先搜索的做法是从起点出发,沿着一条路搜索到底,然后依次回退到之前遍历的每一个位置,寻找其他尚未遍历的路并继续搜索,直到搜索完所有的位置。
广度优先搜索的做法是从起点出发,按照距离从近到远的顺序依次搜索每个位置。
关于树的搜索,将在树结构部分具体讲解。关于其他场景下的搜索,将在搜索算法部分具体讲解。