一个非常长的字符串列表,什么是适当的搜索/检索方法?

66

这不是一个非常罕见的问题,但我仍然找不到一个真正解释选择的答案。

我有一个非常大的字符串列表(精确地说是SHA-256哈希的ASCII表示),我需要查询该列表中是否存在某个字符串。

这个列表中可能会有超过1亿个条目,并且我需要多次重复查询是否存在某个条目。

考虑到大小,我怀疑我无法将所有内容都装入 HashSet<string> 中。什么是适当的检索系统以最大限度地提高性能?

我可以预先对列表进行排序,我可以将其放入SQL表中,也可以将其放入文本文件中,但我不确定在我的应用程序中哪种方法最合适。

在这些或其他检索方法中,是否有明显的性能优势者呢?


2
乍一看,由于需要搜索,首选的方法是将其存储在Sql表中,但这实际上取决于此列表是什么,如果它是一次性的、不可变的转换类型,是否需要维护等等... - Crono
1
@Crono,它基本上是不可变的,如果需要更改列表,则可能只需拆除并重新构建表格。如果使用SQL,单个带有聚集索引的列是否是最佳选择,还是我还可以做其他事情? - Grant H.
14
跟着“Trie”走 - https://zh.wikipedia.org/wiki/Trie。 - Enigmativity
25
有人意识到使用 HashSet<string> 存储 string 哈希值的字符串 的讽刺吗? - recursion.ninja
7
为什么要使用哈希来存储和查找本身就是哈希的数据?SHA256有256位。您的100M条目非常稀疏,因此在同一个桶中发生冲突的几率几乎为零。只需从条目中取出32位(或根据RAM的大小取其他数字),并创建一个大的向量数组(包含对字符串的引用)以供查找。对于冲突,只需移动到下一个空桶。 - Stephen Chung
显示剩余6条评论
16个回答

62
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;

namespace HashsetTest
{
    abstract class HashLookupBase
    {
        protected const int BucketCount = 16;

        private readonly HashAlgorithm _hasher;

        protected HashLookupBase()
        {
            _hasher = SHA256.Create();
        }

        public abstract void AddHash(byte[] data);
        public abstract bool Contains(byte[] data);

        private byte[] ComputeHash(byte[] data)
        {
            return _hasher.ComputeHash(data);
        }

        protected Data256Bit GetHashObject(byte[] data)
        {
            var hash = ComputeHash(data);
            return Data256Bit.FromBytes(hash);
        }

        public virtual void CompleteAdding() { }
    }

    class HashsetHashLookup : HashLookupBase
    {
        private readonly HashSet<Data256Bit>[] _hashSets;

        public HashsetHashLookup()
        {
            _hashSets = new HashSet<Data256Bit>[BucketCount];

            for(int i = 0; i < _hashSets.Length; i++)
                _hashSets[i] = new HashSet<Data256Bit>();
        }

        public override void AddHash(byte[] data)
        {
            var item = GetHashObject(data);
            var offset = item.GetHashCode() & 0xF;
            _hashSets[offset].Add(item);
        }

        public override bool Contains(byte[] data)
        {
            var target = GetHashObject(data);
            var offset = target.GetHashCode() & 0xF;
            return _hashSets[offset].Contains(target);
        }
    }

    class ArrayHashLookup : HashLookupBase
    {
        private Data256Bit[][] _objects;
        private int[] _offsets;
        private int _bucketCounter;

        public ArrayHashLookup(int size)
        {
            size /= BucketCount;
            _objects = new Data256Bit[BucketCount][];
            _offsets = new int[BucketCount];

            for(var i = 0; i < BucketCount; i++) _objects[i] = new Data256Bit[size + 1];

            _bucketCounter = 0;
        }

        public override void CompleteAdding()
        {
            for(int i = 0; i < BucketCount; i++) Array.Sort(_objects[i]);
        }

        public override void AddHash(byte[] data)
        {
            var hashObject = GetHashObject(data);
            _objects[_bucketCounter][_offsets[_bucketCounter]++] = hashObject;
            _bucketCounter++;
            _bucketCounter %= BucketCount;
        }

        public override bool Contains(byte[] data)
        {
            var hashObject = GetHashObject(data);
            return _objects.Any(o => Array.BinarySearch(o, hashObject) >= 0);
        }
    }

    struct Data256Bit : IEquatable<Data256Bit>, IComparable<Data256Bit>
    {
        public bool Equals(Data256Bit other)
        {
            return _u1 == other._u1 && _u2 == other._u2 && _u3 == other._u3 && _u4 == other._u4;
        }

        public int CompareTo(Data256Bit other)
        {
            var rslt = _u1.CompareTo(other._u1);    if (rslt != 0) return rslt;
            rslt = _u2.CompareTo(other._u2);        if (rslt != 0) return rslt;
            rslt = _u3.CompareTo(other._u3);        if (rslt != 0) return rslt;

            return _u4.CompareTo(other._u4);
        }

        public override bool Equals(object obj)
        {
            if (ReferenceEquals(null, obj))
                return false;
            return obj is Data256Bit && Equals((Data256Bit) obj);
        }

        public override int GetHashCode()
        {
            unchecked
            {
                var hashCode = _u1.GetHashCode();
                hashCode = (hashCode * 397) ^ _u2.GetHashCode();
                hashCode = (hashCode * 397) ^ _u3.GetHashCode();
                hashCode = (hashCode * 397) ^ _u4.GetHashCode();
                return hashCode;
            }
        }

        public static bool operator ==(Data256Bit left, Data256Bit right)
        {
            return left.Equals(right);
        }

        public static bool operator !=(Data256Bit left, Data256Bit right)
        {
            return !left.Equals(right);
        }

        private readonly long _u1;
        private readonly long _u2;
        private readonly long _u3;
        private readonly long _u4;

        private Data256Bit(long u1, long u2, long u3, long u4)
        {
            _u1 = u1;
            _u2 = u2;
            _u3 = u3;
            _u4 = u4;
        }

        public static Data256Bit FromBytes(byte[] data)
        {
            return new Data256Bit(
                BitConverter.ToInt64(data, 0),
                BitConverter.ToInt64(data, 8),
                BitConverter.ToInt64(data, 16),
                BitConverter.ToInt64(data, 24)
            );
        }
    }

    class Program
    {
        private const int TestSize = 150000000;

        static void Main(string[] args)
        {
            GC.Collect(3);
            GC.WaitForPendingFinalizers();

            {
                var arrayHashLookup = new ArrayHashLookup(TestSize);
                PerformBenchmark(arrayHashLookup, TestSize);
            }

            GC.Collect(3);
            GC.WaitForPendingFinalizers();

            {
                var hashsetHashLookup = new HashsetHashLookup();
                PerformBenchmark(hashsetHashLookup, TestSize);
            }

            Console.ReadLine();
        }

        private static void PerformBenchmark(HashLookupBase hashClass, int size)
        {
            var sw = Stopwatch.StartNew();

            for (int i = 0; i < size; i++)
                hashClass.AddHash(BitConverter.GetBytes(i * 2));

            Console.WriteLine("Hashing and addition took " + sw.ElapsedMilliseconds + "ms");

            sw.Restart();
            hashClass.CompleteAdding();
            Console.WriteLine("Hash cleanup (sorting, usually) took " + sw.ElapsedMilliseconds + "ms");

            sw.Restart();
            var found = 0;

            for (int i = 0; i < size * 2; i += 10)
            {
                found += hashClass.Contains(BitConverter.GetBytes(i)) ? 1 : 0;
            }

            Console.WriteLine("Found " + found + " elements (expected " + (size / 5) + ") in " + sw.ElapsedMilliseconds + "ms");
        }
    }
}

结果非常有前途。它们是单线程运行的。哈希集版本可以在使用7.9GB RAM时达到略高于100万次查找每秒的速度。基于数组的版本使用更少的RAM(4.6GB)。两者的启动时间几乎相同(388 vs 391秒)。哈希集以查找性能换取RAM。由于内存分配限制,两者都必须进行桶分配。

数组性能:

哈希和添加花费了307408毫秒

哈希清理(通常是排序)花费了81892毫秒

在562585毫秒内找到30000000个元素(预期为30000000)[每秒53k次搜索]

======================================

哈希集性能:

哈希和添加花费了391105毫秒

哈希清理(通常是排序)花费了0毫秒

在74864毫秒内找到30000000个元素(预期为30000000)[每秒400k次搜索]


1
所以,我昨晚尝试了一下,结果非常出色!将所有数据加载到内存中大约需要20分钟(可以并行化处理,但是担心所需的缓冲可能会使我过度负载),但一旦加载完成,查询速度就非常快。内存使用量相当高(约9GB),但我的64位机器配有16GB的内存没有任何问题。 - Grant H.
使用多个哈希集的目的是什么?此外,由于正在搜索SHA哈希值,因此哈希的每个部分都应足够随机,以显着简化“GetHashCode()”。 - Cory Nelson
3
多个哈希集是因为一个哈希集在记录达到93m时会OOM。可以通过使用哈希数据来确定将哈希放入哪个存储桶来改进该类。这可能会产生更不均匀的存储分布,但查找将直接转到相关的哈希,而不是尝试查找它们所有。所有相等部分都是由R#自动生成的。 - Bryan Boettcher
在您的 app.config 中设置 <gcAllowVeryLargeObjects> 没有让您创建一个更大的哈希集合? - Jim Mischel
我成功地分配了一个包含1.5亿个32位元素的数组:超过4GB。你需要设置gcAllowVeryLargeObjects,并且还要确保如果你使用Any CPU编译,那么在项目选项中禁用“prefer 32 bit”。不幸的是,我无法测试你的HashSet解决方案是否适用于这么多的元素,因为我只有8GB的RAM。但是如果你有足够的RAM,它应该可以工作。但是你需要接近16GB的可用空间,因为哈希集合必须通过复制来增长。除非你从数组创建哈希集合...嗯嗯嗯。 - Jim Mischel
显示剩余5条评论

21
如果列表随时间变化,我会把它放在数据库中。
如果列表不变,我会将其放入排序文件中,并为每个查询执行二进制搜索。
在这两种情况下,我会使用Bloom filter来最小化I/O。我会停止使用字符串并使用带有四个无符号长整型的二进制表示(以避免对象引用成本)。 如果你有超过16 GB(假设使用Base64编码)的空间可用,一个选项是创建一个Set&ltstring>并且很快乐。当然,如果您使用二进制表示,它将占用少于7 GB。 David Haney的答案告诉我们,内存成本不是那么容易计算的。

1
使用布隆过滤器是个好主意,但只有在值不在集合中的可能性中等偏高时才使用它。它只能对问题“这个值是否在集合中?”提供“肯定不是”或“很可能是”的答案。如果答案是“很可能在集合中”,那么你仍然需要查找以确保它不是误报。 - AutomatedChaos

17

使用<gcAllowVeryLargeObjects>,您可以拥有更大的数组。为什么不将那些256位哈希码的ASCII表示形式转换为实现了IComparable<T>的自定义结构体呢?它会像这样:

struct MyHashCode: IComparable<MyHashCode>
{
    // make these readonly and provide a constructor
    ulong h1, h2, h3, h4;

    public int CompareTo(MyHashCode other)
    {
        var rslt = h1.CompareTo(other.h1);
        if (rslt != 0) return rslt;
        rslt = h2.CompareTo(other.h2);
        if (rslt != 0) return rslt;
        rslt = h3.CompareTo(other.h3);
        if (rslt != 0) return rslt;
        return h4.CompareTo(other.h4);
    }
}

然后,您可以创建一个这些结构的数组,大约占据3.2 GB的空间。您可以使用 Array.BinarySearch 进行简单的搜索。

当然,您需要将用户输入从ASCII转换为其中一种哈希码结构,但这很容易。

至于性能,这不会像哈希表那样快,但肯定比数据库查找或文件操作要快。

想想看,您可以创建一个HashSet<MyHashCode>。您需要覆盖MyHashCode上的Equals方法,但那真的很容易。据我回忆,HashSet每个条目的成本大约为24个字节,并且您还需要更大的结构体成本。如果要使用HashSet,则总计大约为五到六GB的内存。内存更多了,但仍然可行,而且您可以获得O(1)的查找。


15
这些答案没有考虑字符串内存在应用程序中的影响。在.NET中,字符串不是1个char等于1个字节。每个字符串对象需要20个字节的对象数据常量。缓冲区每个字符需要2个字节。因此,一个字符串实例的内存使用估计为20 + (2 * Length)个字节。
让我们进行一些数学计算。 - 100,000,000个独特的字符串 - SHA256 = 32个字节(256位) - 每个字符串的大小= 20 +(2 * 32个字节)= 84个字节 - 所需总内存:8,400,000,000个字节= 8.01 GB
这样做是可能的,但这将无法在.NET内存中良好存储。你的目标应该是将所有数据加载到可以访问/分页的形式中,而不是一次性将其全部保留在内存中。为此,我会使用Lucene.net,它将在磁盘上存储您的数据并智能搜索它。将每个字符串写入可搜索的索引,然后搜索该索引以获取字符串。现在您拥有了一个可扩展的应用程序,可以处理此问题;您唯一的限制将是磁盘空间(填满1TB驱动器需要大量字符串)。或者,将这些记录放入数据库中并针对其进行查询。这就是为什么数据库存在的原因:为了将事物持久化在RAM之外。 :)

9
SHA256哈希值的长度为256位,而不是256字节。32字节的十六进制字符表示为64个字符或128字节。每个字符串大约需要148字节,而不是532字节。他应该能够将所有字符串装入11或12GB内存中。顺便说一句,如果哈希值长度为256字节,每个哈希值将需要1024字节(2个字符编码一个字节,每个字符2字节)。 - Jim Mischel
2
如果你要存储字符串(在这里是无意义的,因为显然有比十六进制字符串更紧凑的32字节二进制结构表示),那么你不一定要将它们存储为字符串。例如,一个紧凑的DAWG通常会有一些插入操作可以减少总内存大小。 - Jon Hanna
3
实际上,我打赌这个可以用前缀树来高效地表示。事实上,我敢说它会非常高效。 - Haney
1
实际上,我正在讨论将字符串表示为十六进制字符(仅使用0-9和A-F字符)。Base64编码需要44个字符(尽管您可以将其削减到43个,因为您知道在这种情况下最后一个字符无关紧要)来表示32个字节。因此,如果哈希值被表示为Base64,则字符串只有86个字节,再加上分配开销。 - Jim Mischel
1
@JonHanna 我使用 这个 工具生成了大约 30,000 个随机的 64 字符 SHA256 哈希字符串的 DAWG。它大约有 7 MB,至少比 Scrabble 字典 TWL06 的 DAWG 大 13 倍,该字典包含约 180,000 个单词。因此,由于随机性使其无法使用,DAWG 可能不适合此任务。 - max
显示剩余6条评论

8
一个哈希集将您的数据分成桶(数组)。在64位系统上,数组的大小限制为2 GB,大约是20亿字节。由于字符串是引用类型,且每个引用占用8个字节(假设是64位系统),每个桶可以容纳大约250,000,000(2.5亿)个字符串引用。这似乎比您需要的还要多得多。尽管如此,正如Tim S.指出的那样,即使引用适合哈希集,您也极不可能有足够的内存来保存字符串本身。对于这种情况,数据库会更加适合。

1
@GrantH。它不会。数组不存储字符串本身,而是存储对字符串的引用。想象一下亿万颗星星散落在夜空中,然后想象一条人群排成一行,每个人都指向一个单独的星星。这条线不能超过2.5亿人。(抱歉,我太兴奋了,看到《宇宙》的回归)。 - dcastro
3
一个SHA256哈希值为256位。使用base64编码后(我想这是指“ASCII表示”),需要大约341个字符。在.Net中,字符串中的每个字符由两个字节(UTF-16)表示,因此占用大约682个字节。682字节* 1亿 ~= 63 TB。因此,除非您有64TB的内存,否则无论如何都无法一次性保留这么多数据(无论如何引用它)。 - Tim S.
指针实际上是指向某个地方的,不是吗?而那个地方恰好是一个字符数组。一次加载数百万个字符,内存将成为一个问题。 - Crono
1
如果您正确配置您的应用程序,就不再存在2GB的限制。 - Cory Nelson
4
SHA256哈希值的长度是256位,而不是字节。他可以将所有字符串存储在11或12兆字节的空间里。但这是一种非常昂贵的做法。使用32字节结构体的数组只需要占用3.2 GB 的空间,这看起来非常合理。 - Jim Mischel
显示剩余9条评论

8

为了达到最大的速度,将它们存储在RAM中。这只是大约3GB的数据加上您的数据结构所需的任何额外开销。HashSet<byte[]>应该可以很好地工作。如果您想降低开销和GC压力,请打开<gcAllowVeryLargeObjects>,使用单个byte[]以及带有自定义比较器的HashSet<int>来索引。

为了速度和低内存使用率,请将它们存储在基于磁盘的哈希表中。 为了简单起见,将它们存储在数据库中。

无论您做什么,都应该将它们存储为普通二进制数据,而不是字符串。


一个 HashSet<byte[]> 是相当昂贵的。分配一个数组需要大约50字节的开销。因此,您的开销比数据还要大。最好创建一个由4个 ulong 值组成的 struct。×评论只能编辑5分钟×评论只能编辑5分钟×评论只能编辑5分钟 - Jim Mischel

7

可能需要一些时间(1)来转储聚集索引表中的所有记录(最好使用它们的值,而不是它们的字符串表示(2))并让SQL进行搜索。它将为您处理二进制搜索,为您处理缓存,并且如果您需要对列表进行更改,则可能是最容易处理的东西。我相信查询事物的速度将与构建自己的速度一样快(或更快)。

(1): 要加载数据,请查看SqlBulkCopy对象,像ADO.NETEntity Framework这样的东西会很慢,因为它们逐行加载数据。

(2): SHA-256 = 256位,因此二进制(32)就足够了;这只有你现在使用的64个字符的一半。(或者如果你使用Unicode数字,那么它只有四分之一=P) 不过,如果你目前的信息是以纯文本文件的形式存在的,你仍然可以按照char(64)的方式进行,并使用bcp.exe将数据简单地转储到表中。数据库会更大,查询稍微慢一些(因为需要更多的I/O + 相同数量的RAM缓存只保存一半的信息),等等...但这很容易做到,如果你对结果不满意,仍然可以编写自己的数据库加载程序。


7
在这种情况下,您需要小心,因为大多数语言中的大多数集合并不真正设计或优化用于那种规模。正如您已经确定的那样,内存使用也将是一个问题。
明显的赢家是使用某种形式的数据库。可以使用SQL数据库或适当的NoSQL数据库。
SQL服务器已经被设计和优化用于跟踪大量数据,索引它并在这些索引之间搜索和查询。它是为了做到您正在尝试做的事情而设计的,所以确实是最好的选择。
为了提高性能,您可以考虑使用嵌入式数据库,在您的进程内运行并保存结果通信开销。对于Java,我可以推荐Derby数据库,但我不知道C#的等效物足以做出建议,但我想适当的数据库存在。

6

如果集合是恒定的,那么只需制作一个大型排序哈希列表(以原始格式为32字节)。 存储所有哈希,使其适合磁盘扇区(4KB),并且每个扇区的开头也是哈希的开头。 在特殊索引列表中保存每第N个扇区的第一个哈希,它将轻松适合内存。 在此索引列表上使用二进制搜索来确定哈希应该开始的扇区群集的起始扇区,然后在此扇区群集内使用另一个二进制搜索来查找您的哈希。 值N应该根据测试数据进行测量来确定。

编辑:另一种方法是在磁盘上实现自己的哈希表。该表应使用开放地址策略,并且探测序列应尽可能限制在同一磁盘扇区中。空插槽必须用特殊值(例如全零)标记,因此在查询存在性时应特别处理此特殊值。为避免冲突,表不应少于80%的值,因此在您的情况下,具有32字节大小的100百万条目的表至少应具有125百万个插槽,并且具有125M * 32 = 4 GB的大小。您只需要创建将2 ^ 256域转换为125M的哈希函数和一些漂亮的探测序列即可。


5
你可以尝试使用后缀树,这个问题讲述了如何在C#中实现它。
或者你可以像这样进行搜索。
var matches = list.AsParallel().Where(s => s.Contains(searchTerm)).ToList();

使用AsParallel可以加速查询,因为它会创建一个查询的并行化。


1
这不需要先将完整的字符串列表加载到内存中吗? - Crono
@datatest,我无法将这个记录集完全加载到内存中,因为它太大了。 - Grant H.
1
更重要的是,如果您将所有字符串加载到内存中,那么最好使用哈希集。 - Taemyr

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