哈希查找和二分查找哪一个更快?

81
给定一个静态对象集合,即一旦加载便很少或不会更改,需要进行重复的并发查找以获得最佳性能,那么使用什么比较器的数组二分查找或HashMap哪个更好?
答案与对象或结构类型有关吗?哈希和/或相等函数的性能?哈希唯一性?列表大小?HashSet大小/集合大小?
我正在查看的集合大小可以从500k到10m不等 - 如果这些信息有用的话。
虽然我正在寻找一个C#答案,但我认为真正的数学答案不在语言中,因此我没有包括该标签。 但是,如果有C#特定的事情需要注意,就需要这些信息。

1
什么是“查找”?你只想测试成员资格(特定元素是否存在)吗?还是你有键值对,并想要找到与某个键相关联的值? - ShreevatsaR
取决于哈希函数的完美程度。 - jmucchiello
17个回答

57

对于非常小的集合,这种差异是可以忽略不计的。在您的范围低端(500k项)时,如果您需要进行大量查找操作,您将开始看到差异。二分查找的时间复杂度为O(log n),而哈希表的查找时间复杂度为O(1),“摊销时间”(amortized)。虽然这并非真正恒定,但您需要使用非常糟糕的哈希函数才能获得比二分查找更差的性能。

(当我说“糟糕的哈希”时,我的意思是类似以下这样的东西:

hashCode()
{
    return 0;
}

是的,虽然它本身非常快,但会导致你的哈希映射变成链表。

ialiashkevich编写了一些C#代码,使用数组和字典来比较这两种方法,但它使用Long值作为键。我想测试一些实际执行查找期间的哈希函数的东西,因此我修改了该代码。我将其更改为使用字符串值,并将填充和查找部分重构为自己的方法,以便在分析器中更易于查看。我还保留了使用Long值的代码,仅作为比较之用。最后,我摆脱了自定义二进制搜索函数,并使用Array类中的函数。

以下是那段代码:

class Program
{
    private const long capacity = 10_000_000;

    private static void Main(string[] args)
    {
        testLongValues();
        Console.WriteLine();
        testStringValues();

        Console.ReadLine();
    }

    private static void testStringValues()
    {
        Dictionary<String, String> dict = new Dictionary<String, String>();
        String[] arr = new String[capacity];
        Stopwatch stopwatch = new Stopwatch();

        Console.WriteLine("" + capacity + " String values...");

        stopwatch.Start();

        populateStringArray(arr);

        stopwatch.Stop();
        Console.WriteLine("Populate String Array:      " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        populateStringDictionary(dict, arr);

        stopwatch.Stop();
        Console.WriteLine("Populate String Dictionary: " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        Array.Sort(arr);

        stopwatch.Stop();
        Console.WriteLine("Sort String Array:          " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        searchStringDictionary(dict, arr);

        stopwatch.Stop();
        Console.WriteLine("Search String Dictionary:   " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        searchStringArray(arr);

        stopwatch.Stop();
        Console.WriteLine("Search String Array:        " + stopwatch.ElapsedMilliseconds);

    }

    /* Populate an array with random values. */
    private static void populateStringArray(String[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            arr[i] = generateRandomString(20) + i; // concatenate i to guarantee uniqueness
        }
    }

    /* Populate a dictionary with values from an array. */
    private static void populateStringDictionary(Dictionary<String, String> dict, String[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            dict.Add(arr[i], arr[i]);
        }
    }

    /* Search a Dictionary for each value in an array. */
    private static void searchStringDictionary(Dictionary<String, String> dict, String[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            String value = dict[arr[i]];
        }
    }

    /* Do a binary search for each value in an array. */
    private static void searchStringArray(String[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            int index = Array.BinarySearch(arr, arr[i]);
        }
    }

    private static void testLongValues()
    {
        Dictionary<long, long> dict = new Dictionary<long, long>(Int16.MaxValue);
        long[] arr = new long[capacity];
        Stopwatch stopwatch = new Stopwatch();

        Console.WriteLine("" + capacity + " Long values...");

        stopwatch.Start();

        populateLongDictionary(dict);

        stopwatch.Stop();
        Console.WriteLine("Populate Long Dictionary: " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        populateLongArray(arr);

        stopwatch.Stop();
        Console.WriteLine("Populate Long Array:      " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        searchLongDictionary(dict);

        stopwatch.Stop();
        Console.WriteLine("Search Long Dictionary:   " + stopwatch.ElapsedMilliseconds);

        stopwatch.Reset();
        stopwatch.Start();

        searchLongArray(arr);

        stopwatch.Stop();
        Console.WriteLine("Search Long Array:        " + stopwatch.ElapsedMilliseconds);
    }

    /* Populate an array with long values. */
    private static void populateLongArray(long[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            arr[i] = i;
        }
    }

    /* Populate a dictionary with long key/value pairs. */
    private static void populateLongDictionary(Dictionary<long, long> dict)
    {
        for (long i = 0; i < capacity; i++)
        {
            dict.Add(i, i);
        }
    }

    /* Search a Dictionary for each value in a range. */
    private static void searchLongDictionary(Dictionary<long, long> dict)
    {
        for (long i = 0; i < capacity; i++)
        {
            long value = dict[i];
        }
    }

    /* Do a binary search for each value in an array. */
    private static void searchLongArray(long[] arr)
    {
        for (long i = 0; i < capacity; i++)
        {
            int index = Array.BinarySearch(arr, arr[i]);
        }
    }

    /**
     * Generate a random string of a given length.
     * Implementation from https://dev59.com/qHM_5IYBdhLWcg3whzjx#1344258
     */
    private static String generateRandomString(int length)
    {
        var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
        var stringChars = new char[length];
        var random = new Random();

        for (int i = 0; i < stringChars.Length; i++)
        {
            stringChars[i] = chars[random.Next(chars.Length)];
        }

        return new String(stringChars);
    }
}

以下是使用不同大小集合的结果,时间以毫秒为单位。

500000个长整型数值...
填充长整型字典: 26
填充长整型数组: 2
搜索长整型字典: 9
搜索长整型数组: 80

500000个字符串数值...
填充字符串数组: 1237
填充字符串字典: 46
排序字符串数组: 1755
搜索字符串字典: 27
搜索字符串数组: 1569

1000000个长整型数值...
填充长整型字典: 58
填充长整型数组: 5
搜索长整型字典: 23
搜索长整型数组: 136

1000000个字符串数值...
填充字符串数组: 2070
填充字符串字典: 121
排序字符串数组: 3579
搜索字符串字典: 58
搜索字符串数组: 3267

3000000个长整型数值...
填充长整型字典: 207
填充长整型数组: 14
搜索长整型字典: 75
搜索长整型数组: 435

3000000个字符串数值...
填充字符串数组: 5553
填充字符串字典: 449
排序字符串数组: 11695
搜索字符串字典: 194
搜索字符串数组: 10594

10000000个长整型数值...
填充长整型字典: 521
填充长整型数组: 47
搜索长整型字典: 202
搜索长整型数组: 1181

10000000个字符串数值...
填充字符串数组: 18119
填充字符串字典: 1088
排序字符串数组: 28174
搜索字符串字典: 747
搜索字符串数组: 26503

作为比较,这里是程序最后一次运行的分析器输出(1000万条记录和查找)。我已经突出了相关函数。它们与上面的计时指标非常接近。

10 million records and lookups 的性能分析输出

可以看出,字典查找比二分查找快得多,并且(如预期的那样)差异随着集合大小的增加而更加明显。因此,如果您有一个合理的哈希函数(执行较快,没有太多碰撞),哈希查找应该比二分查找在这个范围内的集合上表现更好。


12
不是“完全不合适”,只是慢一些。即使是良好的非加密哈希函数在较小的尺寸下确实比二分查找要慢。 - Nick Johnson
1
是的,默認字符串哈希函數是一個非常糟糕的哈希函數。如果鍵很長,哈希將比平均比較慢得多。 - Stephan Eggermont
2
小修正 - 对于随机数据和良好的哈希函数,平均情况下是O(1),而不是摊销的O(1)。 - orip
2
不,getHashCode 比 compare 慢。对于长字符串来说慢得多。 - Stephan Eggermont
34
这个回答被点赞这么多有点令人震惊,因为这个答案是错误的—— 二分查找比哈希表更快其实很常见。log n 只是一个相对较小的因子,很容易被缓存效应、常数缩放因子等等(针对任何大小的数据)所抵消—— 毕竟,那些数据需要适合这个宇宙;实际上,没有太多的数据结构可能包含超过 2^64 个条目,并且在你开始更具体地考虑性能之前,可能没有超过 2^30 个。 - Eamon Nerbonne
显示剩余13条评论

42
Bobby、Bill和Corbin的答案是错误的。对于固定/有限的n,O(1)不比O(log n)慢: log(n)是常数,所以它取决于常数时间。
对于一个较慢的哈希函数,你听说过md5吗?
默认的字符串哈希算法可能会访问所有字符,并且比长字符串键的平均比较要慢100倍。走过这条路,也做过这件事。
如果您可以将其分成大约相同大小的256个块,则可能可以(部分)使用基数排序。那样做很可能会提供更好的性能。
[编辑] 太多人因为不理解而投票反对。
对于二分查找已排序集合的字符串比较具有非常有趣的特性:它们越接近目标,速度越慢。首先它们会在第一个字符上中断,在最后只会在最后一个字符上中断。假设它们的常数时间是不正确的。

13
@Stephan: 我们三个都认为O(1)比O(log n)更快。你还需要了解大O符号的含义。它比较算法在输入大小变化时相对资源使用情况。讨论固定的n是没有意义的。 - Bill the Lizard
1
嗯... @Mike:n是常数很重要。如果n是常数且较小,则O(log n)比O(1)快得多,因为O(1)中的常数时间操作需要很长时间。但是如果n不是常数,那么O(log n)极不可能比O(1)更快。 - Claudiu
1
@Bill:这个问题是关于一个几乎不变的集合的。当然,哈希可能会更快,但也可能会有20倍的冲突。你必须比较实际的实现。 - Stephan Eggermont
4
实际上,关于字符串比较越接近目标会变得越慢的观点,并非二分查找本身固有的缺点,因为在缩小子集的同时跟踪公共前缀是可行的。(只是没有人这样做。) - Mike Dunlavey
2
@StephanEggermont 谢谢您的回答。在性能方面,迭代次数只是一个考虑因素,对于较小的n值,二分查找的查找时间很可能会优于哈希映射。 - Justin Meiners
显示剩余21条评论

28
这个问题的唯一合理答案是:取决于数据的大小、形状、哈希实现、二分查找实现以及数据存储位置(即使问题中没有提到)。其他几个答案已经说得很清楚了,所以我可以将这段话删除。不过,分享一下我从原始答案反馈中学到的东西可能会很好。
  1. 我写道,"哈希算法是O(1),而二分查找是O(log n)。" - 如评论中所述,大O符号估计的是复杂度,而不是速度。这是绝对正确的。值得注意的是,我们通常使用复杂度来了解算法的时间和空间需求。因此,虽然假设复杂度严格等于速度是愚蠢的,但没有将时间或空间放在心中进行复杂度估计是不寻常的。我的建议:避免使用大O符号。
  2. 我写道,"因此,当n趋近于无穷大时..." - 这是我在答案中包含的最愚蠢的事情。无穷大与您的问题无关。您提到了1000万的上限。忽略无穷大。正如评论者所指出的,非常大的数字会导致哈希出现各种问题。(非常大的数字也不会使二分搜索变得轻松。)我的建议:除非您确实指的是无穷大,请勿提及无穷大。
  3. 同样来自评论:谨防默认字符串哈希(您是否对字符串进行哈希?您没有提到。),数据库索引通常是B树(值得思考)。我的建议:考虑所有选项。考虑其他数据结构和方法......例如老式的trie(用于存储和检索字符串)或R-tree(用于空间数据)或MA-FSA(最小无环有限状态自动机-小存储占用量)。

根据评论,你可能会认为使用哈希表的人是疯子。哈希表是否鲁莽和危险?这些人是疯了吗?

事实证明他们不是。就像二叉树擅长某些事情(按顺序遍历数据,存储效率),哈希表也有它发光的时刻。特别是,在减少读取数据所需的数量方面,哈希表可以非常出色。哈希算法可以生成一个位置并直接跳转到内存或磁盘中的它,而二分查找在每次比较中读取数据以决定下一步要读取什么。每次读取都有可能导致缓存未命中,这比CPU指令慢一个数量级(甚至更多)。

这并不意味着哈希表比二分查找更好。它们不是。这也不意味着所有哈希和二分查找实现都相同。他们不是。如果我有一个观点,那就是:两种方法都有存在的理由。由您决定哪种最适合您的需求。

原始答案:


哈希算法的时间复杂度是O(1),而二分查找的时间复杂度是O(log n)。因此,随着n趋近于无穷大,相对于二分查找,哈希性能会得到提升。这取决于n的大小、哈希实现和二分查找实现,结果会有所不同。 关于O(1)的有趣讨论。改述如下:
O(1)并不意味着瞬间完成。它意味着性能不会随着n的增长而改变。你可以设计一个哈希算法,它非常慢,没有人会使用它,但它仍然是O(1)。然而,我相当确定.NET/C#不会受到成本限制的哈希算法的影响 ;)

11
大O符号表示算法的复杂度,而不是相对于其他算法的速度。声称哈希是O(1)因此比O(log n)的二分查找更快并不完全正确。 - Juliet
3
甚至在实际上也不正确。默认字符串哈希会涉及整个字符串,而且比比较慢得多。 - Stephan Eggermont
2
@Stephan:同意!好的替代方案是字符串长度+前8个字符的哈希值或长度+前4个和后4个的哈希值。除了使用整个字符串外,任何方案都可以。 - Zan Lynx
1
@Stephan - 这是三叉树的一个声称的优势 - 基本上是第一个字符的二叉树,然后是第二个字符的另一个二叉树,依此类推。大多数字符串搜索会很早失败,并且不需要触及大部分字符串,但哈希表会扫描整个字符串。由于字符串比较也倾向于尽早发现不匹配,同样适用于普通的二叉树 - 但当然只要最常见的字符串未被找到的条件成立,这在现实中并不像n个字符组合计算所示那样经常发生。 - user180247
1
“当n趋近于无穷大时,哈希性能相对于二分查找会提高。”这是完全错误的。实际上,由于碰撞的原因,情况恰恰相反。你是否曾经想过为什么大多数数据库不实现哈希索引?因为对于足够大的数据,B树索引更快。 - freakish
显示剩余3条评论

23

好的,我会尝试简短明了。

C#简短回答:

测试两种不同的方法。

.NET提供了让你通过一行代码更改你的方法的工具。 否则使用System.Collections.Generic.Dictionary, 并确保使用一个大数值来初始化它作为初始容量, 否则您将因为GC需要收集旧桶数组而花费余生插入项目。

较长的答案:

一个哈希表几乎具有恒定的查找时间, 而在现实世界中获得哈希表中的项并不仅需要计算哈希。

为了到达一个项,您的哈希表将执行以下操作:

  • 获取键的哈希
  • 获取该哈希的桶编号(通常映射函数看起来像这样bucket = hash%bucketsCount)
  • 遍历项链(基本上是一个共享相同桶的项目列表, 大多数哈希表使用此处理桶/哈希冲突的方法), 该链从该桶开始,并将每个键与您正在尝试添加/删除/更新/检查是否包含的项目的键进行比较。

查找时间取决于哈希函数的“好坏”程度(输出有多稀疏和快速), 您使用的桶数以及键比较器的速度, 它并不总是最佳解决方案。

更好和更深入的解释: http://en.wikipedia.org/wiki/Hash_table


8
哈希通常更快,尽管二进制搜索具有更好的最坏情况特性。哈希访问通常是计算出哈希值以确定记录将在哪个“桶”中,因此性能通常取决于记录分布的均匀程度和搜索桶的方法。使用不良的哈希函数(留下一些桶有很多记录)进行线性搜索会导致慢速搜索。(另一方面,如果您正在读取磁盘而不是内存,则哈希桶可能是连续的,而二进制树几乎保证非本地访问。)
如果您想要通常快速,请使用哈希。如果您真的想要保证有界性能,可以选择二进制树。

树也有退化情况,实际上变成了一个列表。当然,大多数变体都有严格的不变量来避免这种情况。 - Javier
1
误导性答案。在实践中导致哈希破裂的性能问题通常是哈希函数本身,而不是哈希冲突。 - Stephan Eggermont
@Javier - 实际的二叉树(AVL、红黑树等)没有那些退化的情况。话虽如此,由于冲突处理策略是一种选择,某些哈希表也不会有这样的情况出现。如果我没记错的话,D语言的开发者在处理Dscript的哈希表冲突时使用了(不平衡的)二叉树方案,并因此获得了显着改善的平均情况性能。 - user180247

8
如果您的对象集是真正静态和不变的,您可以使用完美哈希来获得O(1)的性能保证。我曾经看到过gperf被提到过几次,但我自己从未使用过它。

1
如果您可以对任何算法或数据结构的大小设定一个恒定的上限,那么您可以声称其性能具有O(1)的上限。这在现实中经常发生 - 例如,在B树节点内搜索的性能被认为是恒定的,因为(无论是线性搜索还是二分搜索)节点的最大大小是恒定的。对于好建议加1分,但对于O(1)的声明,我认为您有点作弊。 - user180247
1
@Steve314,我认为你没有理解完美哈希的重点。通过定制哈希函数,您可以确保没有冲突,因此一旦获得其哈希值,访问数据实际上只需要一个操作,再加上一次比较以确保您没有搜索表中不存在的内容。 - Mark Ransom
但我的观点是,您可以为特定且恒定数量的数据自定义哈希。您对完美哈希的优点非常正确,但由于它无法处理变化的n(甚至无法处理n内的数据变化),因此仍然是欺骗行为。 - user180247

6
字典/哈希表与数组相比,使用的内存更多,初始化时间也更长。 但是与在数组中进行二分查找相比,字典的搜索速度更快。
以下是要搜索和初始化的1000万个Int64项目的数字。 以及一个您可以自行运行的示例代码。
字典内存: 462,836 数组内存: 88,376 填充字典: 402 填充数组: 23 搜索字典: 176 搜索数组: 680
using System;
using System.Collections.Generic;
using System.Diagnostics;

namespace BinaryVsDictionary
{
    internal class Program
    {
        private const long Capacity = 10000000;

        private static readonly Dictionary<long, long> Dict = new Dictionary<long, long>(Int16.MaxValue);
        private static readonly long[] Arr = new long[Capacity];

        private static void Main(string[] args)
        {
            Stopwatch stopwatch = new Stopwatch();

            stopwatch.Start();

            for (long i = 0; i < Capacity; i++)
            {
                Dict.Add(i, i);
            }

            stopwatch.Stop();

            Console.WriteLine("Populate Dictionary: " + stopwatch.ElapsedMilliseconds);

            stopwatch.Reset();

            stopwatch.Start();

            for (long i = 0; i < Capacity; i++)
            {
                Arr[i] = i;
            }

            stopwatch.Stop();

            Console.WriteLine("Populate Array:      " + stopwatch.ElapsedMilliseconds);

            stopwatch.Reset();

            stopwatch.Start();

            for (long i = 0; i < Capacity; i++)
            {
                long value = Dict[i];
//                Console.WriteLine(value + " : " + RandomNumbers[i]);
            }

            stopwatch.Stop();

            Console.WriteLine("Search Dictionary:   " + stopwatch.ElapsedMilliseconds);

            stopwatch.Reset();

            stopwatch.Start();

            for (long i = 0; i < Capacity; i++)
            {
                long value = BinarySearch(Arr, 0, Capacity, i);
//                Console.WriteLine(value + " : " + RandomNumbers[i]);
            }

            stopwatch.Stop();

            Console.WriteLine("Search Array:        " + stopwatch.ElapsedMilliseconds);

            Console.ReadLine();
        }

        private static long BinarySearch(long[] arr, long low, long hi, long value)
        {
            while (low <= hi)
            {
                long median = low + ((hi - low) >> 1);

                if (arr[median] == value)
                {
                    return median;
                }

                if (arr[median] < value)
                {
                    low = median + 1;
                }
                else
                {
                    hi = median - 1;
                }
            }

            return ~low;
        }
    }
}

6
我很惊讶没有人提到布谷鸟哈希,它提供了保证的O(1)操作,与完美哈希不同的是,它能够使用其分配的所有内存,而完美哈希可能会浪费大部分分配的内存,尽管也能保证O(1)。但插入时间可能会非常慢,特别是随着元素数量的增加,因为所有优化都是在插入阶段执行的。我相信某些版本的路由器硬件用于IP查找中采用了这种方法。

请参见链接文本


1
完美哈希可以使用其分配的所有内存。由于寻找这样一个完美的哈希函数所需的工作量,它通常不会使用所有内存,但对于小数据集来说,完全可行。 - user180247

4

我强烈怀疑,在一个大小约为1M的问题集中,哈希将更快。

仅就数字而言:

二分查找需要大约20次比较(2^20 == 1M)

哈希查找只需要对搜索键进行1次哈希计算,可能还需要几次比较来解决可能的碰撞问题。

编辑:数字如下:

    for (int i = 0; i < 1000 * 1000; i++) {
        c.GetHashCode();
    }
    for (int i = 0; i < 1000 * 1000; i++) {
        for (int j = 0; j < 20; j++)
            c.CompareTo(d);
    }

times: c = "abcde", d = "rwerij" hashcode: 0.0012 秒。比较: 2.4 秒。

免责声明:实际上,对哈希查找和二进制查找进行基准测试可能比这个不完全相关的测试更好。我甚至不确定 GetHashCode 是否在幕后进行了备忘录。


3
使用一个良好的优化器,两者的结果应该都是0。 - Stephan Eggermont
@StephanEggermont恐怕没有任何优化器会这样做,因为c.GetHashCode()和c.CompareTo()是虚函数。 只有调用不其他函数的纯函数可以被消除。 我怀疑任何编译器都不会对虚函数进行公共子表达式消除。 除非c类型是固定的,并且编译器能够将其替换为静态函数,该函数被标识为纯函数? 我怀疑C# JIT不会这样做。 - Arnaud Bouchez

2
我认为主要取决于哈希和比较方法的性能。例如,使用非常长但随机的字符串键时,比较操作总是会快速返回结果,但默认哈希函数将处理整个字符串。

但在大多数情况下,哈希表应该更快。


1
哈希函数并不一定要使用整个字符串,这是没有必要的。 - Javier
只是一个非常实用的方法,你不希望所有字符串的扩展都最终落入同一个桶中(除非你将其用作一种基数,并从桶元素中删除前缀,将其转换为类似Trie结构的形式)。 - Stephan Eggermont

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接