はじめに
ハードディスクの中にインターネットからダウンロードしたり,キャプチャしたファイルが(推定)数万個ある。このうち全く同一内容のファイルが数百個はあり,ストレージの無駄遣いなので削除してしまいたい。
おそらく自分に限らないだろうが,長年 PC を利用しているうちに新旧データが入り混じってきて PC のストレージは徐々に混沌な状況になっていき,まったく同じデータを重複して持っていることに気づかない。そのときそのときの気分(当時のマイブーム的な感じ)でファイル名を付けているため,全く同一のデータであってもファイル名まで同じとは限らないのだ。
さて,そのような重複するデータをチェックしてストレージの中を掃除をしたいのだが,ファイルの総数を $n$ 個として単純にファイル同士のバイナリ比較を行うと $n(n - 1) / 2$ 回の比較を行わなくてはならない。さすがにこれはちょっとあり得ない。
アルゴリズムを考える
三日ほど悩んで下記のアルゴリズムに辿り着いた。
ファイルを読み込んでハッシュ値を作る。ファイルの内容が異なれば滅多に衝突しないもの,ハッシュ値の計算が軽く,標準ライブラリなどに搭載されているものが良い。ということで MD5(Message-Digest Algorithm 5)を使うことにした。
次にハッシュ値のリストをソートしてやり,隣接するリストのハッシュ値が同一の場合,ファイルの内容は同一であるとみなす。
これなら計算量 $O(n\log{n})$ でいけるはずだ。
お品書き(仕様案)
- .NET Framework の頃から MD5 がサポートされているということで開発言語を C# とする。
- コマンドラインから実行するコンソールプログラムとする。
- 重複チェックを行うファイル名を複数指定できる。ファイル名の指定にはワイルドカードが使える。
- 重複したファイルを削除するとき,ファイル名を昇順ソートして最上位のファイルのみ残す。
- ファイル名を昇順ソートするときは,Explorer 互換アルゴリズムも選択できる。
- オプション /SSub-directory を指定するとサブディレクトリ以下を検索できる。
- オプション /Tto Trashbox を指定すると重複するファイルをゴミ箱に送る。
- オプション /VView only を指定すると重複するファイル名を表示するだけで削除しない。ちなみにオプション/Tと/Vを同時指定した場合は/Vが優先されることにする。
オプション /T と /V を用意したのはハッシュ値が衝突する危険性があるからだ。つまり,最終判断を人間が行うためのものだ。
注意事項
コマンドラインからファイル名(検索パターン)を複数指定できるようにしたが,うっかり同じファイルを複数回指定してしまうと(当たり前だが)内容は完全一致するので削除されてしまう。
これを防ぐためには,まず対象となるファイル名をフルパスに変換してやり,名前が重複するものはリストから削除してやらなくてはならない。Windows のファイルシステムの都合上,ファイル名の大文字・小文字は区別しないモードで名前の重複チェックを行う必要がある。
MD5 によるハッシュ値
MD5 を扱うのは初めてなのでテストプログラムを作成した。
using System;
using System.IO;
using System.Text;
using System.Reflection;
using System.Security.Cryptography;
using System.Runtime.InteropServices;
class MD5HASH {
	static	int	Main(string[] args) {
		if(args.Length < 1) {
			Console.Error.WriteLine("ファイルの MD5 ハッシュ値を求めます。");
			Console.Error.WriteLine("");
			Console.Error.WriteLine("MD5HASH(.EXE) [ファイル名]");
			return -1;
		}
		if(!File.Exists(args[0])) {
			Console.Error.WriteLine("ファイル {0} が存在しません!!", args[0]);
			return -1;
		}
		var	bytes = File.ReadAllBytes(args[0]);
		var	md5 = MD5.Create();
		var	hash = md5.ComputeHash(bytes);
		var	s = BitConverter.ToString(hash);
		Console.WriteLine(s);
		return 0;
	}
}
テスト用コードの実行例を以下に示す。MD5 は 16 bytes(128bits)のハッシュ値を出力するが,これを BitConverter.ToString() を用いて文字列に変換すると16進数の英字は大文字になり,1 byte 毎にハイフン - が入る。
c:\qiita>md5hash md5hash.exe
D0-45-F1-14-2A-FC-C3-A2-44-A9-80-78-2D-FB-67-89
ハイフンが入ると余分にメモリを消費するし,比較時間も余計にかかるので後で削除したい。なお .NET 5 以降であれば Convert.ToHexString() により,ハイフン無しの16進数文字列を得られる。
コード解説
ファイル一覧の作成
List<string> list はコマンドラインから与えられたファイル名のリストが格納されている。ファイル名にはワイルドカードが含まれている場合があり,ファイル名をディレクトリ名とそれ以外に分離して検索する。オプション /S が指定されていると recursiveSearch が有効になり,サブディレクトリ以下を検索する。検索した結果であるファイル名一覧はリスト files に登録され,最後に Distinct メソッドを用いて重複要素を削除する。
var	files = new List<string>();
var	opt = recursiveSearch
		? SearchOption.AllDirectories
		: SearchOption.TopDirectoryOnly;
for(int i = 0; i < list.Count; i++) {
	var	s = list[i];
	var	path = Path.GetDirectoryName(s);
	var	file = Path.GetFileName(s);
	if(path == "") {
		path = ".";
	} else if(!Directory.Exists(path)) {
		Console.Error.WriteLine("ディレクトリ {0} は存在しません!!", path);
		return -1;
	}
	path = Path.GetFullPath(path);
	files.AddRange(Directory.GetFiles(path, file, opt));
}
files = files.Distinct(new IgnoreCaseComparer()).ToList();
大文字・小文字を区別しない比較クラスは下記の通り。
class IgnoreCaseComparer : IEqualityComparer<string> {
	public	bool	Equals(string s1, string s2) {
		return string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase);
	}
	public	int	GetHashCode(string s) {
		return s.ToUpper().GetHashCode();
	}
}
MD5 によるハッシュ作成
MD5 によるハッシュ値を計算する。すでに述べたように16進数文字列にはハイフン - が含まれるので,これを除去したものを新たにハッシュリスト hash として作成する。
var	hash = new List<string>();
var	md5 = MD5.Create();
for(int i = 0; i < files.Count; i++) {
	if(!File.Exists(files[i])) {
		Console.Error.WriteLine("ファイル {0} が存在しません!!", files[i]);
		return -1;
	}
	var	bytes = File.ReadAllBytes(files[i]);
	var	a = md5.ComputeHash(bytes);
	var	s = BitConverter.ToString(a).Replace("-", "");
	hash.Add(s);
}
ハッシュ値のソート
ハッシュリスト hash を直接ソートしないでインデクス配列 index をソートするようにした。これはファイル名リスト files も参照する必要があるからだ。なお,ハッシュ値が同値の場合,ファイル名で比較する。デフォルトでは Explorer 互換アルゴリズムでソートするが,オプション指定により classicCompare が有効になると単なる文字列比較(大文字・小文字を区別しない)でソートする。
var	index = new int[files.Count];
for(int i = 0; i < index.Length; i++) index[i] = i;
Comparison<int>	compare = delegate(int a, int b) {
	int	ret = string.Compare(hash[a], hash[b]);
	if(ret != 0)
		return ret;
	else if(classicCompare)
		return string.Compare(files[a], files[b], StringComparison.OrdinalIgnoreCase);
	else
		return LogicalStringComparer.Compare(files[a], files[b]);
};
Array.Sort(index, compare);
Explorer 互換ソートクラスである。Win32 API を呼び出して使う。
class LogicalStringComparer {
	[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
	extern	static	int	StrCmpLogicalW(string s1, string s2);
	public	static	int	Compare(string s1, string s2) {
		return StrCmpLogicalW(s1, s2);
	}
}
ハッシュ値の重複するファイルを削除する
ハッシュ値をソートしたので,前回値 prev と同じであれば削除する。なお,オプション /V が指定されると viewOnlyMode が有効になり,オプション /T が指定されると trashBoxMode が有効になる。
var	prev = "";
for(int i = 0; i < index.Length; i++) {
	int	n = index[i];
	var	name = Path.GetFileName(files[n]);
	Console.Out.Write("{0} {1}", name, hash[n]);
	var	s = "";
	if(prev == hash[n]) {
		if(viewOnlyMode) {
			s = " ... (*)";		/* 何もしない */
		} else if(trashBoxMode) {
			s = " ... (TRASH)";	VBLIB.RemoveFile(files[n]);
		} else {
			s = " ... DELETED";	File.Delete(files[n]);
		}
	}
	Console.Out.WriteLine(s);
	prev = hash[n];
}
実装コード
実装コードを以下に示す。
実装コードはコチラ(VBLIB.CS,UNIQFILE.CS)
ゴミ箱に移動する VisualBasic のライブラリである。筆者は基本的に公開用の小さなプログラムはできるだけ単一のファイルに収めるように心掛けている(One Filer)が,残念ながら System.IO と Microsoft.VisualBasic.FileIO で名前空間の一部衝突が起こってしまうので分離せざるを得なかった。
using Microsoft.VisualBasic.FileIO;
//------------------------------------------------------------------------------
// VisualBasic ライブラリクラス
//------------------------------------------------------------------------------
class VBLIB {
	//--------------------------------------------------------------------------
	// ファイルをゴミ箱へ移動する
	//--------------------------------------------------------------------------
	public	static	void	RemoveFile(string path) {
		FileSystem.DeleteFile(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
	}
}
本体を以下に示す。
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Runtime.InteropServices;
//------------------------------------------------------------------------------
// Explorer 互換ソートクラス
//------------------------------------------------------------------------------
class LogicalStringComparer {
	[DllImport("shlwapi.dll", CharSet = CharSet.Unicode)]
	extern	static	int	StrCmpLogicalW(string s1, string s2);
	public	static	int	Compare(string s1, string s2) {
		return StrCmpLogicalW(s1, s2);
	}
}
//------------------------------------------------------------------------------
// 大文字・小文字を区別しない比較クラス
//------------------------------------------------------------------------------
class IgnoreCaseComparer : IEqualityComparer<string> {
	public	bool	Equals(string s1, string s2) {
		return string.Equals(s1, s2, StringComparison.OrdinalIgnoreCase);
	}
	public	int	GetHashCode(string s) {
		return s.ToUpper().GetHashCode();
	}
}
//------------------------------------------------------------------------------
// エントリポイントを持つクラス
//------------------------------------------------------------------------------
class UNIQFILE {
	//--------------------------------------------------------------------------
	// メイン関数
	//--------------------------------------------------------------------------
	static	int	Main(string[] args) {
		var	classicCompare  = false;	// ファイル名のソートを単純な文字列比較で行います。
		var	recursiveSearch = false;	// ファイルを再帰的に検索します。
		var	trashBoxMode    = false;	// ファイルをゴミ箱に移動します。
		var	viewOnlyMode    = false;	// ファイルを削除しません。
		//----------------------------------------------------------------------
		// ヘルプメッセージ
		//----------------------------------------------------------------------
		if(args.Length < 1) {
			Console.Error.WriteLine("重複するファイルを削除します。");
			Console.Error.WriteLine("");
			Console.Error.WriteLine("UNIQFILE(.EXE) (オプション) [ファイル名] ...");
			Console.Error.WriteLine("");
			Console.Error.WriteLine("<オプション>");
			Console.Error.WriteLine("/C ファイル名のソートを単純な文字列比較で行います。");
			Console.Error.WriteLine("  デフォルトは Explorer 互換アルゴリズムで比較します。");
			Console.Error.WriteLine("/S ファイルを再帰的に検索します。");
			Console.Error.WriteLine("/T ファイルをゴミ箱に移動します。");
			Console.Error.WriteLine("/V ファイルを削除しません。");
			Console.Error.WriteLine("");
			Console.Error.WriteLine("<注意事項>");
			Console.Error.WriteLine("・ファイルの内容が重複する場合,ファイル名を昇順にソートして");
			Console.Error.WriteLine(" 先頭のファイルのみ残し,残りのファイルは削除します。");
			Console.Error.WriteLine("・複数のファイル名を指定できます。");
			Console.Error.WriteLine("・ファイル名の指定にはワイルドカードを使用できます。");
			return -1;
		}
		//----------------------------------------------------------------------
		// オプション解析
		//----------------------------------------------------------------------
		var	list = new List<string>();	// ファイル名(ワイルドカード可)
		for(int i = 0; i < args.Length; i++) {
			var	s = args[i];
			if(s[0] == '/' || s[0] == '-') {
				switch(s[1]) {
				  case 'C': case 'c':	classicCompare  = true;	break;
				  case 'S': case 's':	recursiveSearch = true;	break;
				  case 'T': case 't':	trashBoxMode    = true;	break;
				  case 'V': case 'v':	viewOnlyMode    = true;	break;
				  default:
					Console.Error.WriteLine("不正なオプション {0} を指定しました!!", s);
					return -1;
				}
			} else {
				list.Add(s);
			}
		}
		if(list.Count == 0) {
			Console.Error.WriteLine("ファイル名を指定して下さい!!");;
			return -1;
		}
		//----------------------------------------------------------------------
		// ファイル一覧の作成
		//----------------------------------------------------------------------
		var	files = new List<string>();
		var	opt = recursiveSearch
				? SearchOption.AllDirectories
				: SearchOption.TopDirectoryOnly;
		for(int i = 0; i < list.Count; i++) {
			var	s = list[i];
			var	path = Path.GetDirectoryName(s);
			var	file = Path.GetFileName(s);
			if(path == "") {
				path = ".";
			} else if(!Directory.Exists(path)) {
				Console.Error.WriteLine("ディレクトリ {0} は存在しません!!", path);
				return -1;
			}
			path = Path.GetFullPath(path);
			files.AddRange(Directory.GetFiles(path, file, opt));
		}
		files = files.Distinct(new IgnoreCaseComparer()).ToList();
		if(files.Count == 0) {
			Console.Error.WriteLine("ファイルが見つかりません!!");
			return -1;
		} else if(files.Count == 1) {
			Console.Error.WriteLine("ファイル数が不足しています!!");
			return -1;
		}
		//----------------------------------------------------------------------
		// MD5によるハッシュ値を生成する
		//----------------------------------------------------------------------
		var	hash = new List<string>();
		var	md5 = MD5.Create();
		for(int i = 0; i < files.Count; i++) {
			if(!File.Exists(files[i])) {
				Console.Error.WriteLine("ファイル {0} が存在しません!!", files[i]);
				return -1;
			}
			var	bytes = File.ReadAllBytes(files[i]);
			var	a = md5.ComputeHash(bytes);
			var	s = BitConverter.ToString(a).Replace("-", "");
			hash.Add(s);
		}
		//----------------------------------------------------------------------
		// ハッシュ値に従ってインデクスをソートする
		//----------------------------------------------------------------------
		var	index = new int[files.Count];
		for(int i = 0; i < index.Length; i++) index[i] = i;
		Comparison<int>	compare = delegate(int a, int b) {
			int	ret = string.Compare(hash[a], hash[b]);
			if(ret != 0)
				return ret;
			else if(classicCompare)
				return string.Compare(files[a], files[b], StringComparison.OrdinalIgnoreCase);
			else
				return LogicalStringComparer.Compare(files[a], files[b]);
		};
		Array.Sort(index, compare);
		//----------------------------------------------------------------------
		// ハッシュ値の重複するファイルを削除する
		//----------------------------------------------------------------------
		int	count = 0;
		var	prev = "";
 		for(int i = 0; i < index.Length; i++) {
			int	n = index[i];
			var	name = Path.GetFileName(files[n]);
			Console.Out.Write("{0} {1}", name, hash[n]);
			var	s = "";
			if(prev == hash[n]) {
				if(viewOnlyMode) {
					s = " ... (*)";		/* 何もしない */
				} else if(trashBoxMode) {
					s = " ... (TRASH)";	VBLIB.RemoveFile(files[n]);
				} else {
					s = " ... DELETED";	File.Delete(files[n]);
				}
				count++;
			}
			Console.Out.WriteLine(s);
			prev = hash[n];
		}
		var	fmt =   count == 0 ? "全ファイル {0} 個のうち,重複ファイルはありません。"
				: viewOnlyMode ? "全ファイル {0} 個のうち,重複ファイル {1} 個を削除できます。"
				: trashBoxMode ? "全ファイル {0} 個のうち,重複ファイル {1} 個をゴミ箱に送りました。"
				:                "全ファイル {0} 個のうち,重複ファイル {1} 個を削除しました。";
		Console.Out.WriteLine(fmt, index.Length, count);
		return 0;
	}
}
ビルド
筆者は基本的に Winows10 に備え付けの C# コンパイラでビルドできるように心掛けており,.NET Framework 世代の古い C# コンパイラでも無事ビルドが通る。
c:\qiita>csc -o -w:4 UNIQFILE.CS VBLIB.CS -r:Microsoft.VisualBasic.DLL
Microsoft (R) Visual C# Compiler version 4.8.9232.0
for C# 5
Copyright (C) Microsoft Corporation. All rights reserved.
This compiler is provided as part of the Microsoft (R) .NET Framework,
but only supports language versions up to C# 5, which is no longer
the latest version. For compilers that support newer versions of the
C# programming language, see http://go.microsoft.com/fwlink/?LinkID=533240
結論というか感想
実際にこのプログラムを実行させると,それなりに時間がかかる。ハッシュ値を計算するために対象となるファイルを全部読み込んでいるからだ。
$\color{red}{\Huge\textsf{まあ,そりゃそうだよね}}$
今更だが,ファイルを全部読み込まなくとも,たとえばファイルサイズで予備チェックを行うなどすれば良かったと思う。
次回
上記の課題を解決した記事を追記した。