最快的算法 - 与公认的答案相比,性能提高了 100 倍(真的:))
其他解决方案中的方法非常酷,但它们忘记了重复文件的一个重要属性——它们具有相同的文件大小。仅对相同大小的文件计算昂贵的哈希将节省大量 CPU;最后的性能比较,这里是解释。
迭代@nosklo 给出的可靠答案,并借用@Raffi 的想法来获得每个文件开头的快速散列,并仅在快速散列中的冲突计算完整的散列,以下是步骤:
- 建立文件的哈希表,其中文件大小是关键。
- 对于大小相同的文件,用它们的前 1024 个字节的哈希值创建一个哈希表;非碰撞元素是唯一的
- 对于前 1k 字节具有相同哈希的文件,计算完整内容的哈希 - 匹配的文件不是唯一的。
代码:
#!/usr/bin/env python
# if running in py3, change the shebang, drop the next import for readability (it does no harm in py3)
from __future__ import print_function # py2 compatibility
from collections import defaultdict
import hashlib
import os
import sys
def chunk_reader(fobj, chunk_size=1024):
"""Generator that reads a file in chunks of bytes"""
while True:
chunk = fobj.read(chunk_size)
if not chunk:
return
yield chunk
def get_hash(filename, first_chunk_only=False, hash=hashlib.sha1):
hashobj = hash()
file_object = open(filename, 'rb')
if first_chunk_only:
hashobj.update(file_object.read(1024))
else:
for chunk in chunk_reader(file_object):
hashobj.update(chunk)
hashed = hashobj.digest()
file_object.close()
return hashed
def check_for_duplicates(paths, hash=hashlib.sha1):
hashes_by_size = defaultdict(list) # dict of size_in_bytes: [full_path_to_file1, full_path_to_file2, ]
hashes_on_1k = defaultdict(list) # dict of (hash1k, size_in_bytes): [full_path_to_file1, full_path_to_file2, ]
hashes_full = {} # dict of full_file_hash: full_path_to_file_string
for path in paths:
for dirpath, dirnames, filenames in os.walk(path):
# get all files that have the same size - they are the collision candidates
for filename in filenames:
full_path = os.path.join(dirpath, filename)
try:
# if the target is a symlink (soft one), this will
# dereference it - change the value to the actual target file
full_path = os.path.realpath(full_path)
file_size = os.path.getsize(full_path)
hashes_by_size[file_size].append(full_path)
except (OSError,):
# not accessible (permissions, etc) - pass on
continue
# For all files with the same file size, get their hash on the 1st 1024 bytes only
for size_in_bytes, files in hashes_by_size.items():
if len(files) < 2:
continue # this file size is unique, no need to spend CPU cycles on it
for filename in files:
try:
small_hash = get_hash(filename, first_chunk_only=True)
# the key is the hash on the first 1024 bytes plus the size - to
# avoid collisions on equal hashes in the first part of the file
# credits to @Futal for the optimization
hashes_on_1k[(small_hash, size_in_bytes)].append(filename)
except (OSError,):
# the file access might've changed till the exec point got here
continue
# For all files with the hash on the 1st 1024 bytes, get their hash on the full file - collisions will be duplicates
for __, files_list in hashes_on_1k.items():
if len(files_list) < 2:
continue # this hash of fist 1k file bytes is unique, no need to spend cpy cycles on it
for filename in files_list:
try:
full_hash = get_hash(filename, first_chunk_only=False)
duplicate = hashes_full.get(full_hash)
if duplicate:
print("Duplicate found: {} and {}".format(filename, duplicate))
else:
hashes_full[full_hash] = filename
except (OSError,):
# the file access might've changed till the exec point got here
continue
if __name__ == "__main__":
if sys.argv[1:]:
check_for_duplicates(sys.argv[1:])
else:
print("Please pass the paths to check as parameters to the script")
还有,这是有趣的部分 - 性能比较。
基线 -
- 一个包含 1047 个文件的目录,32 mp4,1015 - jpg,总大小 - 5445.998 MiB - 即我手机的相机自动上传目录:)
- 小型(但功能齐全)处理器 - 1600 BogoMIPS,1.2 GHz 32L1 + 256L2 Kbs 缓存,/proc/cpuinfo:
处理器:Feroceon 88FR131 rev 1 (v5l)
BogoMIPS : 1599.07
(即我的低端 NAS :),运行 Python 2.7.11。
所以,@nosklo 非常方便的解决方案的输出:
root@NAS:InstantUpload# time ~/scripts/checkDuplicates.py
Duplicate found: ./IMG_20151231_143053 (2).jpg and ./IMG_20151231_143053.jpg
Duplicate found: ./IMG_20151125_233019 (2).jpg and ./IMG_20151125_233019.jpg
Duplicate found: ./IMG_20160204_150311.jpg and ./IMG_20160204_150311 (2).jpg
Duplicate found: ./IMG_20160216_074620 (2).jpg and ./IMG_20160216_074620.jpg
real 5m44.198s
user 4m44.550s
sys 0m33.530s
而且,这里是大小检查过滤器的版本,然后是小散列,如果发现冲突,最后是完整散列:
root@NAS:InstantUpload# time ~/scripts/checkDuplicatesSmallHash.py . "/i-data/51608399/photo/Todor phone"
Duplicate found: ./IMG_20160216_074620 (2).jpg and ./IMG_20160216_074620.jpg
Duplicate found: ./IMG_20160204_150311.jpg and ./IMG_20160204_150311 (2).jpg
Duplicate found: ./IMG_20151231_143053 (2).jpg and ./IMG_20151231_143053.jpg
Duplicate found: ./IMG_20151125_233019 (2).jpg and ./IMG_20151125_233019.jpg
real 0m1.398s
user 0m1.200s
sys 0m0.080s
两个版本各运行 3 次,以获得所需时间的平均值。
所以v1是(user+sys)284s,另一个-2s;相当不同,呵呵:)
随着这一增加,人们可以使用 SHA512,甚至更高级 - 性能损失会因所需的计算量减少而减轻。
否定:
- 比其他版本更多的磁盘访问 - 每个文件访问一次以获取大小统计信息(这很便宜,但仍然是磁盘 IO),并且每个副本都打开两次(对于小的前 1k 字节哈希,以及完整的内容哈希)
- 由于存储哈希表运行时会消耗更多内存