PyQt6-第19讲-重复文件查找器

发布时间:2026/11/5 3:43:06
PyQt6-第19讲-重复文件查找器

第 19 讲 | 重复文件查找器:哈希、分组与安全隔离

本讲目标:从几万个文件里找出内容完全相同的那些。核心难点有两个——
一个是性能(不能真的两两比对),一个是安全(找到了也不能直接删)。

配套代码:cases/c19_duplicate_finder.py完整下载链接在底部


一、判断"两个文件是不是同一个",比你想的难

先问一个看似简单的问题:怎么判断两个文件内容完全相同?

直觉会给出几个答案,但都不成立:

思路为什么不行
看文件名下载两次同一个文件,一个叫report.pdf、一个叫report(1).pdf
看文件大小都是 1024 字节的两个文本文件,内容可以完全不同
看修改时间复制粘贴会改时间;同名文件可能时间一样内容不一样
逐个字节比对准确,但如果目录里有 3000 个文件,两两配对是 450 万次比对

逐字节比对是唯一准确的方法,但直接做会跑一整夜。所以真实工具都用哈希:
把文件内容映射成一个固定长度的字符串(摘要),内容相同 → 哈希必然相同。
两个文件比哈希,比比内容快几个数量级。

但哈希仍然要读一遍文件内容。3000 个文件全算一遍哈希,还是要读 3000 个文件的全部字节。
所以真正的关键是:先想办法把绝大多数不可能重复的文件排除掉。


二、三步查找法:把工作量砍掉 90%

本讲的算法分三步,每一步都在缩小范围:

第 0 步:枚举所有文件(按最小体积过滤掉小文件) ↓ 5000 个 第 1 步:按文件大小分组 —— 大小不同的文件绝不可能内容相同 ↓ 只剩"组内 ≥ 2 个文件"的组,假设 300 个 第 2 步:只对这 300 个文件算哈希 ↓ 按哈希分组,组内 ≥ 2 个才输出结果 最终:得到若干组真正的重复文件

第 1 步是整个算法的灵魂。它依据是一个必然成立的数学事实:
内容相同 ⟹ 字节数相同。反过来,字节数不同 ⟹ 内容必然不同。

一个 5000 个文件的目录里,大部分文件的大小是唯一的——
每个文件大小都恰好和另一个文件相同,这种情况很少见。
所以第 1 步能把 5000 个砍到 300 个,后面只有 6% 的文件需要真正读一遍。

代码只有几行:

# ---- 第 1 步:按文件大小分组(大小不同的文件绝不可能内容相同)----by_size:dict[int,list[Path]]=defaultdict(list)forfinfiles:by_size[f.stat().st_size].append(f)candidates=[groupforgroupinby_size.values()iflen(group)>1]

defaultdict(list)是这里的省事神器。不用它就得写:

key=f.stat().st_sizeifkeynotinby_size:by_size[key]=[]by_size[key].append(f)

四行变一行,而且by_size[size].append(f)的语义更直接:“给我这个大小的那个桶,我要往里放东西”,
桶不存在就自动建一个。这个模式在分组统计里出现频率极高,值得养成习惯。

然后第 2 步只对候选算哈希:

forgroupincandidates:by_hash:dict[str,list[Path]]=defaultdict(list)forfingroup:try:by_hash[file_md5(f)].append(f)exceptOSError:continuescanned+=1report(15+int(scanned/total_cand*80),f"计算哈希{f.name}")fordigest,sameinby_hash.items():iflen(same)>1:size=same[0].stat().st_size groups.append({"hash":digest,"size":size,"files":[str(p)forpinsorted(same)]})

一个不起眼但重要的细节:except OSError: continue必须写。
算哈希要打开文件,而文件可能被别的程序独占(比如正在被 Word 打开),
或者刚被删掉。一个文件读不了,不能让整个扫描崩掉。
这是批量工具的通用原则,和上一讲的"移动失败继续往下走"是同一个思路。


三、哈希要分块读,不能一把读进内存

新手最容易写成的版本:

deffile_md5(path):returnhashlib.md5(path.read_bytes()).hexdigest()# ← 危险

对一个 2GB 的 ISO 文件,这行代码会瞬间申请 2GB 内存。32 位 Python 直接崩,
64 位也会让机器卡死几秒。正确写法是分块:

deffile_md5(path:Path,chunk:int=1024*256)->str:"""分块算 MD5 —— 1GB 的文件也不会把内存撑爆"""h=hashlib.md5()withopen(path,"rb")asfp:whileTrue:block=fp.read(chunk)ifnotblock:breakh.update(block)returnh.hexdigest()

三个要点:

① 每块 256KB。太小(比如 4KB)会导致while循环次数太多,函数调用开销上去;
太大(比如 64MB)在低配机器上内存压力大。256KB~1MB 是经验区间。

②h.update(block)是增量累积的。不是覆盖,是"接着算"。所以分块和整块的结果完全一致。

③if not block: break。空bytes在布尔上下文里是False,所以读到文件末尾就退出。
而且要注意:不能用while fp.read(chunk):这种带赋值的写法,
Python 3.8 之后海象运算符:=可以,但while (block := fp.read(chunk)):可读性一般,
显式break更清楚。

顺便说一句:MD5 到底能不能用

MD5 在密码学上早就"不安全"了——理论上可以构造两个内容不同但 MD5 相同的文件
(叫哈希碰撞)。但那是"恶意构造"的场景。我们的场景是"判断用户自己的文件是否重复",
没有人会为了骗这个工具去精心构造一个碰撞文件。

从性能上说,MD5 比 SHA-256快约 1.5~2 倍,在几万个文件上这个差距是分钟级的。

所以本讲用 MD5。如果哪天你要做的是"下载文件的完整性校验"(防篡改),
那就该换成hashlib.sha256(),一行的事。


四、展示:为什么这里用 QTreeWidget 而不是 QTableWidget

前几讲的表格都是QTableWidget,但这一讲的结构不一样:

组 1:3 个文件,每个 8.4 MB,可省 16.8 MB ├ 设计稿_v1.psd (保留) ├ 设计稿_副本.psd └ 设计稿_v1_备份.psd 组 2:2 个文件,每个 1.2 MB,可省 1.2 MB ├ 发票.pdf (保留) └ 发票(1).pdf

这是典型的一对多(树形)结构:组下面挂着文件。用表格做的话得靠重复行或者合并单元格,很别扭。
树形结构就交给QTreeWidget——它是这一讲唯一的新控件,用法和表格几乎一样:

top=QTreeWidgetItem([f"组{i}:{len(g['files'])}个文件,每个 "f"{self._human(g['size'])},可省{self._human(g['size']*(len(g['files'])-1))}",self._human(total),""])top.setForeground(0,Qt.GlobalColor.darkRed)# 组标题用暗红,醒目forj,finenumerate(g["files"]):p=Path(f)tag="(保留)"ifj==0else""child=QTreeWidgetItem([p.name+tag,self._human(g["size"]),mtime_str])child.setToolTip(0,str(p))# ★ 鼠标悬停看完整路径ifj==0:child.setForeground(0,Qt.GlobalColor.darkGreen)top.addChild(child)top.setExpanded(True)top.setData(0,0x0100,g)# 把整组数据挂在节点上self.tree.addTopLevelItem(top)

两个关键点

①child.setToolTip(0, str(p))很有必要。树里只显示文件名,
但同名的文件可能在两个不同的子目录里。悬停能看到完整路径,
用户才敢确认"这两个确实是重复的"。

②top.setData(0, 0x0100, g)—— 把整组数据挂在节点上。

这一招在第 12 讲见过(item.setData(0x0100, row["id"]))。当时挂的是主键,
这里挂的是整个分组字典。原理一样:Qt 的数据项可以有"显示的文字"和"隐藏的数据"两层。

为什么要挂?因为用户点"隔离"时,我们需要知道"这一组里哪些文件要处理"。
如果不挂,就得从界面上反推——把文件名的字符串拼起来再去查字典,又慢又容易错。
挂上去之后,取数据就是一行:

defquarantine(self)->None:items=self.tree.selectedItems()ifnotitems:returnitem=items[0]group=item.data(0,0x0100)ifgroupisNone:# 选中的是子节点,往上找父节点group=item.parent().data(0,0x0100)ifnotgroup:return...

注意if group is None: group = item.parent().data(...)这段——
用户可能点的是组标题(top),也可能点的是组里的某个文件(child)。
两种情况都要能正确处理
,所以选了子节点就往上找父节点。

0x0100 是什么?它是Qt.ItemDataRole.UserRole的整数值。
写0x0100和写Qt.ItemDataRole.UserRole完全等价,
但后者可读性好得多。本工程用 0x0100 只是因为它短、在代码里不占地方。
正式项目建议写Qt.ItemDataRole.UserRole。


五、找到了之后:隔离,而不是删除

这是本讲第二个重点,也是最重要的一条设计原则。

一个"发现 40 组重复文件"的扫描结果,用户最容易干的事就是点"全部删除"。
但这是极其危险的,因为:

  1. 保留哪一个是随机的。同一份文件散落在三个目录里,
    sorted(same)之后保留的是路径字典序最小的那个。用户以为自己保留的是"原版",
    实际上可能是两个月前随手复制的那份。
  2. 同名文件可能真的不是同一个。比如A/发票.pdf和B/发票.pdf内容不同
    (不同月份),我们的哈希判定是准确的,但用户看到的列表里它们是两个不同的组,
    用户可能看漏。
  3. 删了就没有了。Windows 回收站对程序化的os.remove不一定生效(取决于实现),
    直接删等于永久丢失。

所以本工具的动作叫**“隔离”**:

defquarantine_task(report,group:dict)->dict:"""把一组重复文件里除第一个之外的都移进隔离区(不删,随时能拿回来)"""files=[Path(p)forpingroup["files"]]keep,others=files[0],files[1:]QUARANTINE.mkdir(parents=True,exist_ok=True)moved,failed=0,[]fori,finenumerate(others,1):try:target=QUARANTINE/f.name n=1whiletarget.exists():target=QUARANTINE/f"{f.stem}_{n}{f.suffix}"n+=1shutil.move(str(f),str(target))moved+=1

"隔离区"就是一个普通目录:

QUARANTINE=DATA_DIR/"output"/"第19讲_重复文件隔离区"

文件从原位置移动到这里。用户的目录瞬间变干净了,但数据一个字节都没丢。
过一周发现"哎那个文件怎么没了",去隔离区找回来就行;确认真的没用,
再手动清空隔离区。风险从"不可逆"降到了"可逆"。

这和上一讲的"自动整理归档"、"批量重命名"是同一个思想:
批量文件工具的第一原则不是功能多强,而是"用户敢用"。
而"敢用"的前提就是——每一步都能退回去。

界面上那句确认文案也刻意写清楚了:

ifQMessageBox.question(self,"确认",f"将把这一组的{n}个多余副本移动到隔离区:\n{QUARANTINE}\n\n"f"(不是删除,随时可以手动拿回)\n\n确认执行?")!=QMessageBox.StandardButton.Yes:return

“(不是删除,随时可以手动拿回)” 这句话必须写。
因为用户点这个按钮时内心是紧张的——他不知道会发生什么。一句话消除疑虑,
这就是工具和脚本的区别。


六、结果排序:先处理最值得处理的

扫描完之后,组可能有很多。按什么顺序排?

# 按浪费的空间排序,最该处理的排最前groups.sort(key=lambdag:g["size"]*(len(g["files"])-1),reverse=True)wasted=sum(g["size"]*(len(g["files"])-1)forgingroups)

排序依据是单个文件大小 × (重复个数 - 1),也就是**“处理掉这一组能省多少空间”**。

为什么是这个公式?看两个例子:

组单个大小个数可省空间
A4 KB100 个396 KB
B800 MB2 个800 MB

如果按"重复个数"排,A 组会排在最前面,用户处理完 100 个 4KB 的小文本文件,
只省了不到 400KB,体验很差。按可省空间排,B 组排第一,
一次操作省 800MB——这才是用户真正想要的。

这个公式在界面上也复用了一次(组标题里显示"可省 xx MB"):
排序依据和展示信息保持一致,用户才不会困惑"为什么这个排前面"。

七、四个坑

坑 1:用path.read_bytes()算哈希

2GB 的文件直接把内存打爆。必须分块读。

坑 2:所有文件都算哈希,跳过"按大小分组"

一个 5000 个文件的目录,耗时从 10 秒涨到 3 分钟。
按大小分组这一步能砍掉 90%+ 的哈希计算。

坑 3:直接unlink()删除重复文件

数据永久丢失。移进隔离区,让用户自己决定什么时候真正删。

坑 4:忘记except OSError

一个被占用的文件让整个扫描崩掉。批量操作里,单个文件失败必须能被吞掉。


八、小结

  • 三步查找法:枚举 →按大小分组(核心)→ 只对候选算哈希
  • 哈希必须分块读(256KB/块),大文件也不会爆内存
  • MD5 用于"查重复"够用;防篡改场景才需要 SHA-256
  • 一对多用QTreeWidget,比表格自然
  • setData(0, 0x0100, obj)把数据挂在节点上,取用时不用反推
  • 容器是"隔离"不是"删除"——批量工具的第一原则是可逆
  • 按"可省空间"排序,而不是按"重复个数"

到这里,"文件批量处理"这一整块(第 11、18、19 讲)就闭环了。
三讲下来你会发现它们其实是同一套骨架:扫描 → 预览 → 冲突处理 → 可撤销执行。
换的只是中间的算法。

下一讲:把"用户手动触发"变成"自动感知"。做文件夹实时监控——
新文件一落到目录里,程序自己就知道。这一讲会讲QFileSystemWatcher,
以及一个绝大多数教程不会告诉你的坑:文件"刚出现"的时候往往还没写完。


本讲是《PyQt6 客户端编程从入门到实战》专栏的第 19 讲。专栏共 22 讲、22 个可运行案例。
**完整下载地址:git clone https://gitee.com/wuyikui555/pyqt6-client.git。