• 最快比较两个文件内容


    NET CORE下最快比较两个文件内容

    最近项目有个需求,需要比较两个任意大小文件的内容是否相同,要求如下:

    1. 项目是.NET CORE,所以使用C#进行编写比较方法
    2. 文件大小任意,所以不能将文件内容全部读入到内存中进行比较(更专业点说,需要使用非缓存的比较方式)
    3. 不依赖第三方库
    4. 越快越好

    为了选出最优的解决方案,我搭建了一个简单的命令行工程,准备了两个大小为912MB的文件,并且这两个文件内容完全相同.在本文的最后,你可以看到该工程的Main方法的代码.

    下面我们开始尝试各个比较方法,选出最优的解决方案:

    比较两个文件是否完全相同,首先想到的是用哈希算法(如MD5,SHA)算出两个文件的哈希值,然后进行比较.

    废话少说,撸起袖子写一个MD5比较方法:

    /// <summary>
    /// MD5
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareByMD5(string file1, string file2)
    {
        // 使用.NET内置的MD5库
        using (var md5 = MD5.Create())
        {
            byte[] one, two;
            using (var fs1 = File.Open(file1, FileMode.Open))
            {
                // 以FileStream读取文件内容,计算HASH值
                one = md5.ComputeHash(fs1);
            }
            using (var fs2 = File.Open(file2, FileMode.Open))
            {
                // 以FileStream读取文件内容,计算HASH值
                two = md5.ComputeHash(fs2);
            }
            // 将MD5结果(字节数组)转换成字符串进行比较
            return BitConverter.ToString(one) == BitConverter.ToString(two);
        }
    }

    比较结果:

    Method: CompareByMD5, Identical: True. Elapsed: 00:00:05.7933178

    耗时5.79秒,感觉还不错.然而,这是最佳的解决方案吗?

    其实我们仔细想一下,答案应该是否定的.

    因为任何哈希算法本质上都是对字节进行一定的计算,而计算过程是要消耗时间的.

    很多下载网站上提供了下载文件的哈希值,那是因为下载的源文件本身不会改变,只需要计算一次源文件的哈希值,提供给用户验证即可.

    而我们的需求中,两个文件都是不固定的,那么每次都要计算两个文件的哈希值,就不太合适了.

    所以,哈希比较这个方案被PASS.

    这种求算法最优解的问题,我以往的经验是: 去stackoverflow查找 :)

    经过我的艰苦努力,找到了一个非常切题的答案: How to compare 2 files fast using .NET?

    得赞最多一个答案,将代码改造了一下放入工程中:

    /// <summary>
    /// https://stackoverflow.com/a/1359947
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareByToInt64(string file1, string file2)
    {
        const int BYTES_TO_READ = sizeof(Int64);        // 每次读取8个字节
        int iterations = (int)Math.Ceiling((double)new FileInfo(file1).Length / BYTES_TO_READ); // 计算读取次数
    
        using (FileStream fs1 = File.Open(file1, FileMode.Open))
        using (FileStream fs2 = File.Open(file2, FileMode.Open))
        {
            byte[] one = new byte[BYTES_TO_READ];
            byte[] two = new byte[BYTES_TO_READ];
    
            for (int i = 0; i < iterations; i++)
            {
                // 循环读取到字节数组中
                fs1.Read(one, 0, BYTES_TO_READ);
                fs2.Read(two, 0, BYTES_TO_READ);
    
                // 转换为Int64进行数值比较
                if (BitConverter.ToInt64(one, 0) != BitConverter.ToInt64(two, 0))
                    return false;
            }
        }
    
        return true;
    }

    该方法基本的原理是循环读取两个文件,每次读取8个字节,转换为Int64,再进行数值比较.那么效率如何呢?

    Method: CompareByToInt64, Identical: True. Elapsed: 00:00:08.0918099

    什么?8秒!竟然比MD5还慢?这不是SO得赞最多的答案吗,怎么会这样?

    其实分析一下不难想到原因,因为每次只读取8个字节,程序频繁的进行IO操作,导致性能低下.看来SO上的答案也不能迷信啊!

    那么优化的方向就变为了如何减少IO操作带来的损耗.

    既然每次8个字节太少了,我们定义一个大一些的字节数组,比如1024个字节.每次读取1024个字节到数组中,然后进行字节数组的比较.

    但是这样又带来一个新问题,就是如何快速比较两个字节数组是否相同?

    我首先想到的是在MD5方法中用过的----将字节数组转换成字符串进行比较:

    /// <summary>
    /// 读入到字节数组中比较(转为String比较)
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareByString(string file1, string file2)
    {
        const int BYTES_TO_READ = 1024 * 10;
    
        using (FileStream fs1 = File.Open(file1, FileMode.Open))
        using (FileStream fs2 = File.Open(file2, FileMode.Open))
        {
            byte[] one = new byte[BYTES_TO_READ];
            byte[] two = new byte[BYTES_TO_READ];
            while (true)
            {
                int len1 = fs1.Read(one, 0, BYTES_TO_READ);
                int len2 = fs2.Read(two, 0, BYTES_TO_READ);
                if (BitConverter.ToString(one) != BitConverter.ToString(two)) return false;
                if (len1 == 0 || len2 == 0) break;  // 两个文件都读取到了末尾,退出while循环
            }
        }
    
        return true;
    }

    结果:

    Method: CompareByString, Identical: True. Elapsed: 00:00:07.8088732

    耗时也接近8秒,比上一个方法强不了多少.

    分析一下原因,在每次循环中,字符串的转换是一个非常耗时的操作.那么有没有不进行类型转换的字节数组比较方法呢?

    我想到了LINQ中有一个比较序列的方法SequenceEqual,我们尝试使用该方法比较:

    /// <summary>
    /// 读入到字节数组中比较(使用LINQ的SequenceEqual比较)
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareBySequenceEqual(string file1, string file2)
    {
        const int BYTES_TO_READ = 1024 * 10;
    
        using (FileStream fs1 = File.Open(file1, FileMode.Open))
        using (FileStream fs2 = File.Open(file2, FileMode.Open))
        {
            byte[] one = new byte[BYTES_TO_READ];
            byte[] two = new byte[BYTES_TO_READ];
            while (true)
            {
                int len1 = fs1.Read(one, 0, BYTES_TO_READ);
                int len2 = fs2.Read(two, 0, BYTES_TO_READ);
                if (!one.SequenceEqual(two)) return false;
                if (len1 == 0 || len2 == 0) break;  // 两个文件都读取到了末尾,退出while循环
            }
        }
    
        return true;
    }

    结果:

    Method: CompareBySequenceEqual, Identical: True. Elapsed: 00:00:08.2174360

    竟然比前两个都要慢(实际这也是所有方案中最慢的一个),LINQ的SequenceEqual看来不是为了效率而生.

    那么我们不用那些花哨的功能,回归质朴,老实儿的使用while循环比较字节数组怎么样呢?

    /// <summary>
    /// 读入到字节数组中比较(while循环比较字节数组)
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareByByteArry(string file1, string file2)
    {
        const int BYTES_TO_READ = 1024 * 10;
    
        using (FileStream fs1 = File.Open(file1, FileMode.Open))
        using (FileStream fs2 = File.Open(file2, FileMode.Open))
        {
            byte[] one = new byte[BYTES_TO_READ];
            byte[] two = new byte[BYTES_TO_READ];
            while (true)
            {
                int len1 = fs1.Read(one, 0, BYTES_TO_READ);
                int len2 = fs2.Read(two, 0, BYTES_TO_READ);
                int index = 0;
                while (index < len1 && index < len2)
                {
                    if (one[index] != two[index]) return false;
                    index++;
                }
                if (len1 == 0 || len2 == 0) break;
            }
        }
    
        return true;
    }

    结果是....

    Method: CompareByByteArry, Identical: True. Elapsed: 00:00:01.5356821

    1.53秒!大突破!看来有时候看起来笨拙的方法反而效果更好!

    试验到此,比较两个900多MB的文件耗时1.5秒左右,读者对于该方法是否满意呢?

    No!我不满意!我相信通过努力,一定会找到更快的方法的!

    同样.NET CORE也在为了编写高性能代码而不断的优化中.

    那么,我们如何继续优化我们的代码呢?

    我突然想到在C# 7.2中加入的一个新的值类型: Span<T>,它用来代表一段连续的内存区域,并提供一系列可操作该区域的方法.

    对于我们的需求,因为我们不会更改数组的值,所以可以使用另外一个只读的类型ReadOnlySpan<T>追求更高的效率.

    修改代码,使用ReadOnlySpan<T>:

    /// <summary>
    /// 读入到字节数组中比较(ReadOnlySpan)
    /// </summary>
    /// <param name="file1"></param>
    /// <param name="file2"></param>
    /// <returns></returns>
    private static bool CompareByReadOnlySpan(string file1, string file2)
    {
        const int BYTES_TO_READ = 1024 * 10;
    
        using (FileStream fs1 = File.Open(file1, FileMode.Open))
        using (FileStream fs2 = File.Open(file2, FileMode.Open))
        {
            byte[] one = new byte[BYTES_TO_READ];
            byte[] two = new byte[BYTES_TO_READ];
            while (true)
            {
                int len1 = fs1.Read(one, 0, BYTES_TO_READ);
                int len2 = fs2.Read(two, 0, BYTES_TO_READ);
                // 字节数组可直接转换为ReadOnlySpan
                if (!((ReadOnlySpan<byte>)one).SequenceEqual((ReadOnlySpan<byte>)two)) return false;
                if (len1 == 0 || len2 == 0) break;  // 两个文件都读取到了末尾,退出while循环
            }
        }
    
        return true;
    }

    核心是用来比较的SequenceEqual方法,该方法是ReadOnlySpan的一个扩展方法,要注意它只是方法名与LINQ中一样,实现完全不同.
    那么该方法的表现如何呢?

    Method: CompareByReadOnlySpan, Identical: True. Elapsed: 00:00:00.9287703

    不 到 一 秒!

    相对上一个已经不错的结果,速度提高了差不多40%!

    对此结果,我个人觉得已经很满意了,如果各位有更快的方法,请不吝赐教,我非常欢迎!

    关于Span<T>结构类型,各位读者如有兴趣,可浏览该文章,该文有非常详细的介绍.

    后记

    • 文中的代码只是出于实验性质,实际应用中仍可以继续细节上的优化, 如:

      1. 如两个文件大小不同,直接返回false
      2. 如果两个文件路径相同,直接返回true
      3. ...
    • 试验工程的Main方法源码:

      static void Main(string[] args)
      {
          string file1 = @"C:UsersWAKUDesktopfile1.ISO";
          string file2 = @"C:UsersWAKUDesktopfile2.ISO";
          var methods = new Func<string, string, bool>[] { CompareByMD5, CompareByToInt64, CompareByByteArry,  CompareByReadOnlySpan };
          foreach (var method in methods)
          {
              var sw = Stopwatch.StartNew();
              bool identical = method(file1, file2);
              Console.WriteLine("Method: {0}, Identical: {1}. Elapsed: {2}", method.Method.Name, identical, sw.Elapsed);
          }
      }

    完.

  • 相关阅读:
    加分二叉树
    逃离牧场
    [Apio2012]dispatching
    靶形数独
    POJ 1459-Power Network(网络流-最大流-ISAP)C++
    题解 最优的挤奶方案(Optimal Milking)
    [HNOI2007]紧急疏散EVACUATE (湖南2007年省选)
    【LCA求最近公共祖先+vector构图】Distance Queries
    BZOJ1143: [CTSC2008]祭祀river
    BZOJ2140: 稳定婚姻
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/11072104.html
Copyright © 2020-2023  润新知