【问题标题】:Binary search with comparer is faster than without有比较器的二分搜索比没有比较器快
【发布时间】:2023-01-13 05:06:35
【问题描述】:

我有一个包含大约 200 万条记录的数据。我试图找到最接近给定时间范围的单个数据。数据列表是有序的,数据由以下类表示:

    public class DataPoint
    {
        public long OpenTimeTs;
    }

我已经实施了 3 种方法来完成相同的工作并产生相同的结果。我对为什么其中一种方法执行得更快有一些疑问

方法一

long 列表中使用二进制搜索

        private DataPoint BinaryFindClosest(List<DataPoint> candles, List<long> times, long dateToFindMs)
        {
            int index = times.BinarySearch(dateToFindMs);

            if (index >= 0)
                return candles[index];

            // If not found, List.BinarySearch returns the complement 
            // of the index where the element should have been.
            index = ~index;

            // This date search for is larger than any
            if (index == times.Count)
                return candles[index - 1];

            // The date searched is smaller than any in the list.
            if (index == 0)
                return candles[0];

            if (Math.Abs(dateToFindMs - times[index - 1]) < Math.Abs(dateToFindMs - times[index]))
                return candles[index - 1];
            else
                return candles[index];
        }

方法二

与方法 1 几乎相同,除了它使用自定义对象比较器。

        private DataPoint BinaryFindClosest2(List<DataPoint> candles, DataPoint toFind)
        {
            var comparer = Comparer<DataPoint>.Create((x, y) => x.OpenTimeTs > y.OpenTimeTs ? 1 : x.OpenTimeTs < y.OpenTimeTs ? -1 : 0);

            int index = candles.BinarySearch(toFind, comparer);

            if (index >= 0)
                return candles[index];

            // If not found, List.BinarySearch returns the complement 
            // of the index where the element should have been.
            index = ~index;

            // This date search for is larger than any
            if (index == candles.Count)
                return candles[index - 1];

            // The date searched is smaller than any in the list.
            if (index == 0)
                return candles[0];

            if (Math.Abs(toFind.OpenTimeTs - candles[index - 1].OpenTimeTs) < Math.Abs(toFind.OpenTimeTs - candles[index].OpenTimeTs))
                return candles[index - 1];
            else
                return candles[index];
        }

方法三

最后,这是我在其他主题中发现关于 stackoverflow 的 BinarySearch 方法之前一直使用的方法。

        private DataPoint FindClosest(List<DataPoint> candles, DataPoint toFind)
        {
            long timeToFind = toFind.OpenTimeTs;

            int smallestDistanceIdx = -1;
            long smallestDistance = long.MaxValue;

            for (int i = 0; i < candles.Count(); i++)
            {
                var candle = candles[i];
                var distance = Math.Abs(candle.OpenTimeTs - timeToFind);
                if (distance <= smallestDistance)
                {
                    smallestDistance = distance;
                    smallestDistanceIdx = i;
                }
                else
                {
                    break;
                }
            }

            return candles[smallestDistanceIdx];
        }

问题

现在问题来了。运行一些基准测试后,我注意到第二种方法(使用自定义比较器)是其他方法中最快的。

我想知道为什么自定义比较器的方法比 longs 列表中的二进制搜索方法执行得更快。

我正在使用以下代码来测试这些方法:

            var candles = AppState.GetLoadSymbolData();
            var times = candles.Select(s => s.OpenTimeTs).ToList();

            var dateToFindMs = candles[candles.Count / 2].OpenTimeTs;
            var candleToFind = new DataPoint() { OpenTimeTs = dateToFindMs };

            var numberOfFinds = 100_000;

            var sw = Stopwatch.StartNew();
            for (int i = 0; i < numberOfFinds; i++)
            {
                var foundCandle = BinaryFindClosest(candles, times, dateToFindMs);
            }
            sw.Stop();
            var elapsed1 = sw.ElapsedMilliseconds;

            sw.Restart();
            for (int i = 0; i < numberOfFinds; i++)
            {
                var foundCandle = BinaryFindClosest2(candles, candleToFind);
            }
            sw.Stop();
            var elapsed2 = sw.ElapsedMilliseconds;
            
            sw.Restart();
            for (int i = 0; i < numberOfFinds; i++)
            {
                var foundCandle = FindClosest(candles, candleToFind);
            }
            sw.Stop();
            var elapsed3 = sw.ElapsedMilliseconds;

            Console.WriteLine($"Elapsed 1: {elapsed1} ms");
            Console.WriteLine($"Elapsed 2: {elapsed2} ms");
            Console.WriteLine($"Elapsed 3: {elapsed3} ms");

在发布模式下,结果如下:

  • 经过 1:19 毫秒
  • 经过 2:1 毫秒
  • 经过 3:60678 毫秒

从逻辑上讲,我会假设比较多头列表应该更快,但事实并非如此。我尝试分析代码,但它只指向 BinarySearch 方法执行缓慢,没有别的。所以必须有一些内部进程会减慢 longs 的速度。

编辑:听从建议后,我使用benchmarkdotnet 实施了适当的基准测试,结果如下

Method N Mean Error StdDev Gen0 Allocated
BinaryFindClosest 10000 28.31 ns 0.409 ns 0.362 ns - -
BinaryFindClosest2 10000 75.85 ns 0.865 ns 0.722 ns 0.0014 24 B
FindClosest 10000 3,363,223.68 ns 63,300.072 ns 52,858.427 ns - 2 B

看起来执行方法的顺序确实弄乱了我的初始结果。现在看起来第一种方法工作得更快(而且应该如此)。最慢的当然是我自己实现了。我稍微调整了一下,但它仍然是最慢的方法:

        public static DataPoint FindClosest(List<DataPoint> candles, List<long> times, DataPoint toFind)
        {
            long timeToFind = toFind.OpenTimeTs;

            int smallestDistanceIdx = -1;
            long smallestDistance = long.MaxValue;

            var count = candles.Count();
            for (int i = 0; i < count; i++)
            {
                var diff = times[i] - timeToFind;
                var distance = diff < 0 ? -diff : diff;
                if (distance < smallestDistance)
                {
                    smallestDistance = distance;
                    smallestDistanceIdx = i;
                }
                else
                {
                    break;
                }
            }

            return candles[smallestDistanceIdx];
        }

长话短说——使用合适的基准测试工具。

【问题讨论】:

  • 请提供minimal reproducible example,包括列表初始化。旁注:通常强烈建议不要自行测量时间,而应使用一些现有的经过验证的时间,例如benchmarkdotnet.org
  • 您可能想尝试使用不同的起始值进行搜索。对于中间的值,对于二进制搜索,您可能会直接命中,而您测量的差异只是查找默认比较器而不是使用给定比较器的开销。甚至可能在此比较中使用随机起始值。
  • 对不起我的愚蠢问题。如果列表是有序的(因为它应该应用任何 divide et impera 算法)为什么你花时间编写假设列表未排序的第三种方法?
  • +1 到 Alexei 的评论 - 你的时间测量没有考虑到这样一个事实,即 CLR 可以在执行几次后重新编译代码,如果它认为它是一条热路径并且应该优化的话。我怀疑如果您要更改测试顺序,您的时间安排会有所不同。 benchmarkdotnet自动计算
  • @AndrewWilliamson 这是正确的。我改变了顺序,现在方法 2 工作得更快。我会写一些基准并尽快更新帖子。附言我可以保证数据是按time (milliseconds)升序排列的。所以这很好..

标签: c# binary-search


【解决方案1】:

请查看方法 1 和 2 生成的 IL。这可能是一个无效的测试。它们应该是几乎相同的机器码。

第一:我看不出你在哪里保证订购。但假设它以某种方式在那里。二分搜索将在几乎 20 到 25 步内找到最隐藏的数字 (log2(2.000.000))。这个测试闻起来很奇怪。

第二:BinaryFindClosestCandle(candles, times, dateToFindMs)的定义在哪里?为什么它同时接收类实例和longs 的列表?为什么不返回在长列表上应用二分搜索的索引,并用它来索引原始蜡烛列表? (如果您使用 select 创建 longs 的列表,列表中的 1:1 关系将被保留)

第三:你正在使用的数据是一个类,所以所有的元素都住在堆上。您在 method2 中装箱了一个包含 200 万个长数字的数组。这几乎是一种犯罪。从堆中引用数据将比比较本身花费更多。我仍然认为列表没有排序。

创建一个交换列表以应用搜索算法,就像您对 times 所做的那样,但将它转换为一个数组,而不是 .ToArray() 并将其放在堆栈上。我认为市场上没有比 long valueTypes 的默认比较器更好的了。

编辑解决方案提示: 根据您在一次查找最小值之前执行的插入次数,我将执行以下操作:

if (insertions/lookups > 300.000)
{
    a. store the index of the minimum (and the minimum value) apart in a dedicated field, I would store also a flag for IsUpdated to get false at the first deletion from the list.
    b. spawn a parallel thread to refresh that index and the minumum value at every now an then (depending on how often you do the lookups) if the IsUpdated is false, or lazily when you start a lookup with a IsUpdated = false.
}
else
{
    use a dictionary with the long as a key ( I suppose that two entities with the same long value are likely to be considered equal).
}

【讨论】:

  • 关于 BinaryFindClosestCandle - 这显然是我的错误。我在将它复制到 SO 时重命名了该方法。虽然签名是一样的。所以这就是我在最初的帖子中所说的method 1。关于方法的顺序 - 你也是正确的!我已经更改了顺序,现在第二种方法比第一种方法执行得更快 :) 关于顺序 - 我可以保证我已经得到照顾。数据(时间)按升序排列。关于索引的返回。。我特意做了,返回最近的数据点。
  • 您能否详细说明 method2 和装箱有什么问题?对象存在于堆中,值存在于栈中。这意味着 long 字段在堆栈上,不是吗?当我比较对象的属性时,出于比较原因不需要将它们装箱到对象中。我正在比较longslongs。还是我缺少什么?
  • 我已经设法微调了我的method3,现在它似乎比其他两个表现得更好:)性能瓶颈是Count()Math.Abs()方法,它们运行得非常慢。我将编写一些单元测试,如果它们被确认成功,那么我将编写基准测试并更新原始帖子。谢谢你的帮助!
  • 所以请标记为已回答。谢谢! :-)
  • 我并没有说类存在于堆上而 valueTypes 存在于堆栈上。我不能在这里写下 Eric Lippert 或 Jon Skeet 递归重复的内容,请看看他们优雅的答案。在伟大的 syntesys 中,类存在于堆上(因为它们必须以某种方式进行管理,它们的生命周期不受创建它们的堆栈级别的限制)。
猜你喜欢
  • 2013-12-29
  • 2018-08-10
  • 2021-01-13
  • 2019-01-13
  • 2018-09-23
  • 2014-06-21
  • 1970-01-01
  • 2022-11-25
  • 2021-09-06
相关资源
最近更新 更多