【问题标题】:Fast checking of ranges in Python在 Python 中快速检查范围
【发布时间】:2014-05-13 18:54:30
【问题描述】:

我有很多[(1, 1000), (5000, 5678), ... ] 形式的范围。我试图找出检查数字是否在任何范围内的最快方法。这些范围由longs 组成,并且太大而无法仅保留所有数字中的set

最简单的解决办法是这样的:

ranges = [(1,5), (10,20), (40,50)]  # The real code has a few dozen ranges
nums = range(1000000)  
%timeit [n for n in nums if any([r[0] <= n <= r[1] for r in ranges])]
# 1 loops, best of 3: 5.31 s per loop

榕树快一点:

import banyan
banyan_ranges = banyan.SortedSet(updator=banyan.OverlappingIntervalsUpdator)
for r in ranges:
    banyan_ranges.add(r)
%timeit [n for n in nums if len(banyan_ranges.overlap_point(n))>0]
# 1 loops, best of 3: 452 ms per loop

虽然只有几十个范围,但对这些范围进行了数百万次检查。进行这些检查的最快方法是什么?

(注意:这个问题类似于Python: efficiently check if integer is within *many* ranges,但没有相同的Django相关限制,只关心速度)

【问题讨论】:

  • 只有几十个范围,但对这些范围进行了数百万次检查
  • 范围是否存储在数据库中?这些东西很擅长。
  • 从算法的角度来看,您最好将重叠范围列表转换为非重叠范围列表。然后您可以bisect 前往最近的范围并仅检查那个范围。这会将您的 O(N*M) 算法转换为 O(N * log(M)) 算法。在实践中,很难说这是否真的让你受益匪浅(尤其是对于小 M)
  • @mgilson,您实际上并不需要拆分范围。只需对它们进行排序,首先按下限,然后按上限。接下来,二进制搜索第一个范围的上限低于被检查的值。然后,获取集合中的下一个范围并比较下限(它是排序的最低值)。
  • 我能想到的最快方法是使用exec 将几十个检查转换为无循环函数,检查按某种顺序排序(可能类似于二叉树),然后在 PyPy 中执行整个事情(对这种代码很满意,只要它不是太长)。

标签: python optimization micro-optimization


【解决方案1】:

要尝试的事情:

  1. 预处理您的范围,使其不重叠,并将它们表示为半开区间。
  2. 使用bisect 模块进行搜索。 (不要手动实现自己的二分搜索!)请注意,通过 1 中的预处理,您只需要知道 bisect 调用的结果是偶数还是奇数。
  3. 如果可以选择批处理查询,请考虑将您的输入分组到一个数组中并使用numpy.searchsorted

一些代码和时间。首先是设置(这里使用 IPython 2.1 和 Python 3.4):

In [1]: ranges = [(1, 5), (10, 20), (40, 50)]

In [2]: nums = list(range(1000000))  # force a list to remove generator overhead

我机器上原始方法的计时(但使用生成器表达式而不是列表理解):

In [3]: %timeit [n for n in nums if any(r[0] <= n <= r[1] for r in ranges)]
1 loops, best of 3: 922 ms per loop

现在我们将范围重新设计为边界点列表; even 索引处的每个边界点都是某个范围的入口点,而 odd 索引处的每个边界点都是一个退出点。请注意转换为半开区间,并且我已将所有数字放入一个列表中。

In [4]: boundaries = [1, 6, 10, 21, 40, 51]

有了这个,使用bisect.bisect 很容易获得与以前相同的结果,但速度更快。

In [5]: from bisect import bisect

In [6]: %timeit [n for n in nums if bisect(boundaries, n) % 2]
1 loops, best of 3: 298 ms per loop

最后,根据上下文,您可以使用 NumPy 中的searchsorted 函数。这类似于bisect.bisect,但同时对整个值集合进行操作。例如:

In [7]: import numpy

In [8]: numpy.where(numpy.searchsorted(boundaries, nums, side="right") % 2)[0]
Out[8]: 
array([ 1,  2,  3,  4,  5, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 40,
       41, 42, 43, 44, 45, 46, 47, 48, 49, 50])

乍一看,%timeit 的结果相当令人失望。

In [9]: %timeit numpy.where(numpy.searchsorted(boundaries, nums, side="right") % 2)[0]
10 loops, best of 3: 159 ms per loop

然而,事实证明,大部分性能成本是将输入从 Python 列表转换为 searchsorted 到 NumPy 数组。让我们将两个列表预转换为数组,然后再试一次:

In [10]: boundaries = numpy.array(boundaries)

In [11]: nums = numpy.array(nums)

In [12]: %timeit numpy.where(numpy.searchsorted(boundaries, nums, side="right") % 2)[0]
10 loops, best of 3: 24.6 ms per loop

比其他任何东西都快得多。但是,这有点作弊:我们当然可以预处理 boundaries 以将其转换为数组,但如果您要测试的值不是以数组形式自然生成的,则需要考虑转换成本.另一方面,它表明搜索本身的成本可以降低到一个足够小的值,它不再可能是运行时间的主导因素。

这是沿着这些思路的另一个选择。它再次使用 NumPy,但对每个值进行直接的非惰性线性搜索。 (请原谅乱序的IPython提示:我后来加了这个。:-)

In [29]: numpy.where(numpy.logical_xor.reduce(numpy.greater_equal.outer(boundaries, nums), axis=0))
Out[29]: 
(array([ 2,  3,  4,  5,  6, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 41,
        42, 43, 44, 45, 46, 47, 48, 49, 50, 51]),)

In [30]: %timeit numpy.where(numpy.logical_xor.reduce(numpy.greater_equal.outer(boundaries, nums), axis=0))
10 loops, best of 3: 16.7 ms per loop

对于这些特定的测试数据,这比searchsorted快,但是时间会随着范围数线性增长,而对于searchsorted,它应该根据范围数的对数增长。请注意,它还使用与len(boundaries) * len(nums) 成比例的内存量。这不一定是个问题:如果您发现自己遇到内存限制,您可以将数组分块为更小的尺寸(比如一次 10000 个元素),而不会损失太多性能。

向上移动,如果这些都不符合要求,我接下来会尝试 Cython 和 NumPy,编写一个搜索函数(将输入声明为整数数组)对 boundaries 数组进行简单的线性搜索。我试过这个,但没有比基于bisect.bisect 的结果更好。作为参考,这是我尝试过的 Cython 代码;你也许可以做得更好:

cimport cython

cimport numpy as np

@cython.boundscheck(False)
@cython.wraparound(False)
def search(np.ndarray[long, ndim=1] boundaries, long val):
    cdef long j, k, n=len(boundaries)
    for j in range(n):
        if boundaries[j] > val:
           return j & 1
    return 0

还有时间:

In [13]: from my_cython_extension import search

In [14]: %timeit [n for n in nums if search(boundaries, n)]
1 loops, best of 3: 793 ms per loop

【讨论】:

    【解决方案2】:

    @ArminRigo 评论的实现,非常快。时间来自 CPython,而不是 PyPy:

    exec_code = "def in_range(x):\n"
    first_if = True
    for r in ranges:
       if first_if:
          exec_code += "    if "
          first_if = False
       else:
          exec_code += "    elif "
       exec_code += "%d <= x <= %d: return True\n" % (r[0], r[1])
    exec_code += "    return False"
    exec(exec_code)
    
    %timeit [n for n in nums if in_range(n)]
    # 10 loops, best of 3: 173 ms per loop
    

    【讨论】:

      【解决方案3】:

      尝试使用二分查找而不是线性查找。它应该及时花费“Log(n)”。见下文:

      list = []
      for num in nums:
          start = 0
          end = len(ranges)-1
          if ranges[start][0] <= num <= ranges[start][1]:
              list.append(num)
          elif ranges[end][0] <= num <= ranges[end][1]:
              list.append(num):
          else:
              while end-start>1:
                  mid = int(end+start/2)
                  if ranges[mid][0] <= num <= ranges[mid][1]:
                      list.append(num)
                      break
                  elif num < ranges[mid][0]:
                      end = mid
                  else:
                      start = mid
      

      【讨论】:

      • 这似乎真的很慢 - 比问题中的任何一个实现都慢得多。几分钟后,我终止了 %timeit 调用。我不知道为什么它这么慢 - 可能是列表的增量构建或 for 循环。
      • 奇怪!可能 any() 已针对此进行了优化。但是,如果您确认 nums 始终是由 mean range() 生成的列表,您可能会反转问题,并为范围中的每个范围选择子范围/范围中落入 nums 列表中的所有数字。这将是真正的线性。
      猜你喜欢
      • 2020-12-10
      • 2020-08-14
      • 2021-07-01
      • 1970-01-01
      • 1970-01-01
      • 2023-04-09
      • 1970-01-01
      • 2010-11-14
      • 2016-06-12
      相关资源
      最近更新 更多