如何使用一系列键搜索c#字典?

时间:2014-06-06 21:17:56

标签: c# algorithm dictionary

我有一个数据类似的字典(字典将有大约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.这些范围可能变得非常大,所以我知道只搜索字典中的两个范围之间的每个值都没有意义。我的预感是我应该从字典中获取一个键列表,对其进行排序,然后遍历我的范围并进行某种搜索以查找范围内的所有键。这是正确的方法吗?有没有一种简单的方法可以做到这一点?

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

4 个答案:

答案 0 :(得分:4)

你可以这样做:

// 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();
}

(注意:此解决方案不会考虑您的编辑;它不会处理非连续范围和&#34;其他&#34;范围) < / p>

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

以下是可能的实施方式:

// 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
        }
    }
}

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

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

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

答案 1 :(得分:2)

考虑使用已排序的List<T>及其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条目,您应该使用数据库,如评论中的p.s.w.g所示。

答案 2 :(得分:2)

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

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

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

  • | 0 - &gt; 0(隐式哨兵值)
  • | 1 - &gt; 5 - 5
  • | 7 - &gt; 55 - 50 + 5
  • | 30 - &gt; 58 - 3 + 50 + 5
  • | 1000 - &gt; 59 - 1 + 3 + 50 + 5
  • | 100000 - &gt; 94 - 35 + 1 + 3 + 50 + 5

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

例如,如果您看到查询{0, 10},则会按如下方式处理:

  • 二进制搜索0,获取0
  • 的标记值
  • 10上的二进制搜索,7的值为55(10上没有完全匹配)
  • 从55减去0得到55的答案。

对于查询11,1000,您执行此操作:

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

答案 3 :(得分:1)

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

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<T>.BinarySearch)表明,当列表大小小于10个项目时,顺序搜索会更快,尽管这在一定程度上取决于密钥比较的成本。不过,平均而言,我发现二进制搜索开销要花费我5到10次密钥比较。当你考虑哪种算法更快时,你必须考虑到这一点。

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

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