【问题标题】:How can I search a c# dictionary using a range of keys?如何使用一系列键搜索 c# 字典?
【发布时间】:2014-06-06 21:17:56
【问题描述】:

我有一个包含类似数据的字典(字典将有大约 100k 个条目):

[1] -> 5
[7] -> 50
[30] -> 3
[1000] -> 1
[100000] -> 35

我还有一个范围列表(大约 1000 个)

MyRanges
    Range
        LowerBoundInclusive -> 0
        UpperBoundExclusive -> 10
        Total
    Range
        LowerBoundInclusive -> 10
        UpperBoundExclusive -> 50
        Total
    Range
        LowerBoundInclusive -> 100
        UpperBoundExclusive -> 1000
        Total
    Range
        LowerBoundInclusive -> 1000
        UpperBoundExclusive -> 10000
        Total
    Range (the "other" range)
        LowerBoundInclusive -> null
        UpperBoundExclusive -> null
        Total

我需要计算字典中这些范围的总数。例如,0-10 的范围是 55。这些范围可能会变得非常大,所以我知道只在字典中搜索两个范围之间的每个值是没有意义的。我的直觉是我应该从字典中获取一个键列表,对其进行排序,然后遍历我的范围并进行某种搜索以查找范围内的所有键。这是正确的方法吗?有没有简单的方法可以做到这一点?

编辑: 感谢您的回复。真正聪明的东西。不过,我忘记了一个非常重要的警告。不能保证范围是连续的,最终的范围是不在其他范围内的所有内容。

【问题讨论】:

  • 不,你的预感是错误的。您应该只遍历范围检查键是否存在,如果存在则使用该值。
  • 100K 条目?或许可以考虑改用某种数据库。
  • 我建议SortedDictionary<K, V>msdn.microsoft.com/en-us/library/f7fta44c(v=vs.110).aspx。这样一来,您就可以在达到范围上限时中止迭代。
  • 在您的示例中,第三个和第四个范围重叠;这是故意的吗?你真的有重叠的范围吗?如果这样做,则意味着可以将一个值计为多个范围的一部分,因此它会大大改变实现...
  • 这是无意的。没有重叠范围。

标签: c# algorithm dictionary


【解决方案1】:

你可以这样做:

// Associate each value with the range of its key
var lookup = dictionary.ToLookup(
    kvp => ranges.FirstOrDefault(r => r.LowerBoundInclusive <= kvp.Key
                              && r.UpperBoundExclusive > kvp.Key),
    kvp => kvp.Value);

// Compute the total of values for each range
foreach (var r in ranges)
{
    r.Total = lookup[r].Sum();
}

(注意:此解决方案不考虑您的编辑;它不处理非连续范围和“其他”范围)

但是,如果您有很多范围,则效率不高,因为它们会针对字典中的每个条目进行枚举...如果您首先按键对字典进行排序,则可以获得更好的结果。

这是一个可能的实现:

// We're going to need finer control over the enumeration than foreach,
// so we manipulate the enumerator directly instead.
using (var dictEnumerator = dictionary.OrderBy(e => e.Key).GetEnumerator())
{
    // No point in going any further if the dictionary is empty
    if (dictEnumerator.MoveNext())
    {
        long othersTotal = 0; // total for items that don't fall in any range

        // The ranges need to be in ascending order
        // We want the "others" range at the end
        foreach (var range in ranges.OrderBy(r => r.LowerBoundInclusive ?? int.MaxValue))
        {
            if (range.LowerBoundInclusive == null && range.UpperBoundExclusive == null)
            {
                // this is the "others" range: use the precalculated total
                // of previous items that didn't fall in any other range
                range.Total = othersTotal;
            }
            else
            {
                range.Total = 0;
            }

            int lower = range.LowerBoundInclusive ?? int.MinValue;
            int upper = range.UpperBoundExclusive ?? int.MaxValue;

            bool endOfDict = false;
            var entry = dictEnumerator.Current;


            // keys that are below the current range don't belong to any range
            // (or they would have been included in the previous range)
            while (!endOfDict && entry.Key < lower)
            {
                othersTotal += entry.Value;
                endOfDict = !dictEnumerator.MoveNext();
                if (!endOfDict)
                    entry = dictEnumerator.Current;
            }

            // while the key in the the range, we keep adding the values
            while (!endOfDict  && lower <= entry.Key && upper > entry.Key)
            {
                range.Total += entry.Value;
                endOfDict = !dictEnumerator.MoveNext();
                if (!endOfDict)
                    entry = dictEnumerator.Current;
            }

            if (endOfDict) // No more entries in the dictionary, no need to go further
                break;

            // the value of the current entry is now outside the range,
            // so carry on to the next range
        }
    }
}

(已更新以考虑您的编辑;适用于非连续范围,并将不属于任何范围的项目添加到“其他”范围)

我没有运行任何基准测试,但它可能非常快,因为字典和范围只枚举了一次。

显然,如果范围已经排序,则不需要ranges 上的OrderBy

【讨论】:

  • +1 这正是我想要的。
【解决方案2】:

你说得对,字典不是任务的正确数据结构。

你对做什么的想法也是正确的。您可以通过一些预处理来改进它,以使执行时间达到(N + Q) * Log N,其中N 是原始字典中的项目数,Q 是您需要运行的查询数。

想法如下:将字典中的项目放入一个平面列表中,然后对其进行排序。然后通过将运行总计存储在相应节点中来预处理列表。您的列表最终将如下所示:

  • | 0 -> 0(隐式标记值)
  • | 1 -> 5 -- 5
  • | 7 -> 55 -- 50 + 5
  • | 30 -> 58 -- 3 + 50 + 5
  • | 1000 -> 59 -- 1 + 3 + 50 + 5
  • | 100000 -> 94 -- 35 + 1 + 3 + 50 + 5

有了预处理的列表,您可以在第一个列表(即{1, 7, 30, 1000, 100000} 列表)上对查询的两端运行两次二进制搜索,如果完全匹配,则在当前点取总数,或者在如果没有完全匹配,则在前面点,从较低点的总和中减去较高点的总和,并将其用作查询的答案。

例如,如果您看到查询 {0, 10},您会这样处理它:

  • 对0进行二分查找,得到0的sentinel值
  • 对 10 进行二分查找,得到 7 的值 55(在 10 上没有完全匹配)
  • 从 55 中减去 0 得到 55 的答案。

对于查询 11, 1000,您可以这样做:

  • 搜索11,得到值为55的7
  • 搜索1000,得到值为59的1000
  • 为查询的答案减去 59-55=4。

【讨论】:

  • 这看起来和我的想法一样,所以我不明白为什么(N+Q)logN time,而不是QlogN
  • @BartoszKP 排序列表是一个额外的N*LogN,所以它是N*LogN+Q*LogN,即(N+Q)*LogN。排序在预处理步骤中占主导地位,在N 中是线性的。
  • 对。我假设整个存储将保持排序,因此存在差异。
【解决方案3】:

考虑使用排序的List&lt;T&gt; 及其BinarySearch 方法。如果您有很多查询,那么每个查询都可以用O(logn) 回答,总时间复杂度为O(qlogn),其中n 是条目数,q 是查询数:

//sorted List<int> data

foreach (var range in ranges)                             // O(q)
{
    int lowerBoundIndex = data.BinarySearch(range.Start); // O(logn)
    lowerIndex = lowerIndex < 0
        ? ~lowerIndex
        : lowerIndex;

    int upperBoundIndex = data.BinarySearch(range.End);   // O(logn)
    upperBoundIndex = upperBoundIndex < 0
        ? ~upperBoundIndex - 1
        : upperBoundIndex;

    var count = (upperBoundIndex >= lowerBoundIndex)
        ? (upperBoundIndex - lowerBoundIndex + 1)
        : 0;

    // print/store count for range
}

对于字典的情况,平均复杂度为O(q*l),其中q 是查询数(如上),l 是查询范围的平均长度。因此,如果范围很大,排序列表方法会更好。

无论如何,对于 100k 条目,您应该使用数据库,正如 cmets 中的 p.s.w.g 所建议的那样。

【讨论】:

  • 不错的方法,但你应该充实一些。例如,将键复制到List&lt;key&gt;,对其进行排序,然后在范围的开头和范围的末尾进行二进制搜索,以确定该范围中有多少键。一个代码示例会很有帮助。也就是说,只有 100K 条目和可能 1,000 个范围,对键和范围进行排序并按顺序遍历列表,执行实际上是合并的操作可能同样快。
  • "对于 100k 条目,您应该使用数据库" 我敢打赌,具有预先计算的前缀和的排序数组提供的查询时间比任何给定的数据库都要好得多,这可能使用更复杂和更昂贵的数据库B树等数据结构
  • @NiklasB。我最近在做最近邻研究,内存中的 10 万个样本开始出现性能问题,在切换到数据库方法后,它的运行速度肯定更快。这当然取决于特定情况和您使用的机器(在我的情况下是 16 GB RAM/Windows)。
  • @JimMischel 是的,那是我的意图。我添加了代码示例以防不清楚。至于 100K 条目 - 取决于具体情况,正如我在之前的评论中所说:我研究中的几百 K 条目有点痛苦,尤其是在多次查询时。
【解决方案4】:

技术含量低的方法在这里可能是更好的方法。我将做出一个可能无效的假设,即您的字典不会经常更改;基本上,查询比字典或范围修改更频繁。因此,您可以创建和缓存字典键的列表,如果字典被修改,则根据需要刷新它。所以,给定:

List<KeyType> keys = dict.Keys.OrderBy(k => k).ToList();
List<RangeType> ranges = rangeList.OrderBy(r => r.LowerBound).ToList();

var iKey = 0;
var iRange = 0;
var count = 0;
// do a merge
while (iKey < keys.Count && iRange < ranges.Count)
{
    if (keys[iKey] < ranges[i].LowerBound)
    {
        // key is smaller than current range's lower bound
        // move to next key

        // here you could add this key to the list of keys not found in any range
        ++iKey;
    }
    else if (keys[iKey] > ranges[i].UpperBound)
    {
        // key is larger than current range's upper bound
        // move to next range
        ++iRange;
    }
    else
    {
        // key is within this range
        ++count;
        // add key to list of keys in this range
        ++iKey;
    }
}
// If there are leftover keys, then add them to the list of keys not found in a range
while (iKey < keys.Count)
{
    notFoundKeys.Add(keys[iKey]);
    ++iKey;
}

请注意,这假定范围不重叠。

这个算法是 O(n),其中 n 是字典中键的数量。

这可能看起来很昂贵,但我们只是在谈论 100,000 次比较,这在现代硬件上将是非常快的。这种方法的美妙之处在于它的实现非常简单,而且它的速度很可能足以满足您的目的。值得一试。太慢的话可以看优化。

一个明显的优化是对下限和上限进行二分搜索,以获得适合范围的项目的索引。该算法的复杂度为 O(q log n),其中 q 是查询的数量。 log2(100000) 约为 16.6。每个查询需要两次二进制搜索,因此查找 1,000 个范围将需要大约 33,200 次键比较 - 是我上面介绍的顺序算法的三分之一。

该算法看起来像:

foreach (var range in ranges)
{
    int firstIndex = keys.BinarySearch(range.LowerBound);

    // See explanation below
    if (firstIndex < 0) firstIndex = ~firstIndex;

    int lastIndex = keys.BinarySearch(range.UpperBound);
    if (lastIndex < 0) lastIndex = ~lastIndex-1;

    if (keys[firstIndex] >= range.LowerBound && keys[lastIndex] <= range.UpperBound)
        count += 1 + (lastIndex - firstIndex);
}

List.BinarySearch 返回下一个较大元素所在的索引的按位补码。上面的代码会调整未找到项目时返回的索引,以获取范围内的项目。

将未找到的键添加到列表将涉及跟踪为每个范围找到的最后一个键,并将该键以及为下一个范围找到的第一个键添加到未找到的键列表中。这是对上面代码的相当简单的修改。

对该算法的一种可能的优化是使用BinarySearch overload,它可以让您指定起始索引。毕竟,如果您已经确定范围 0-50 在索引 27 处结束,那么在 27 以下搜索范围 51-100 是没有用的。这种简单的优化可能会抵消我在下面讨论的顺序搜索的优势。

虽然算法分析表明这应该更快,但它没有考虑设置每个二进制搜索所涉及的开销,或者由于缓存未命中而可能成为性能杀手的非顺序内存访问。我将二进制搜索与 C# 中的顺序搜索(使用 List&lt;T&gt;.BinarySearch)进行比较的实验表明,当列表大小小于 10 项时,顺序搜索会更快,尽管这在某种程度上取决于键比较的成本。不过,平均而言,我发现二进制搜索开销要花费我 5 到 10 次关键比较。在考虑哪种算法更快时,您必须考虑到这一点。

如果范围的数量很少,二分搜索算法将是明显的赢家。但随着范围数量的增加,它变得更加昂贵。在某些时候,无论范围数如何,运行时间几乎恒定的顺序搜索算法将比二分搜索算法更快。确切地说,这一点在哪里尚不清楚。我们知道它小于 3,000 个范围,因为 n/(2*log2(n)) 等于 3,012。

同样,由于您所说的数字相对较小,因此任何一种算法都可能对您来说表现得很好。如果您每秒要打这个东西数百或数千次,那么您将需要使用代表性数据和不同数量的范围进行详细分析和时间执行。如果您不经常遇到它,那么只需放入一些有效的东西,如果它成为性能问题,请担心优化。

【讨论】:

    猜你喜欢
    • 2016-02-22
    • 1970-01-01
    • 2011-07-07
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多