【问题标题】:Intersection of two lists of ranges in PythonPython中两个范围列表的交集
【发布时间】:2020-04-04 18:33:18
【问题描述】:

我的一个朋友把他最近收到的一个面试问题递给了我,我对我解决问题的方法不太满意。问题如下:

  • 您有两个列表。
  • 每个列表将包含长度为 2 的列表,它们表示一个范围(即 [3,5] 表示从 3 到 5 的范围,包括端点)。
  • 您需要返回集合之间所有范围的交集。如果我给你 [1,5] 和 [0,2],结果将是 [1,2]。
  • 在每个列表中,范围将始终增加并且永远不会重叠(即它将是 [[0, 2], [5, 10] ... ] 永远不会 [[0,2], [2,5] 。 .. ])

一般而言,在列表的排序或重叠方面没有“陷阱”。

例子:

a = [[0, 2], [5, 10], [13, 23], [24, 25]]
b = [[1, 5], [8, 12], [15, 18], [20, 24]]

预期输出: [[1, 2], [5, 5], [8, 10], [15, 18], [20, 24]]

我的惰性解决方案是将范围列表展开为整数列表,然后进行集合交集,如下所示:

def get_intersection(x, y):
    x_spread = [item for sublist in [list(range(l[0],l[1]+1)) for l in x] for item in sublist]
    y_spread = [item for sublist in [list(range(l[0],l[1]+1)) for l in y] for item in sublist]
    flat_intersect_list = list(set(x_spread).intersection(y_spread))
...

但我想有一个既可读又更高效的解决方案。

如果您不介意,请说明您将如何在精神上解决这个问题。时间/空间复杂度分析也会有所帮助。

谢谢

【问题讨论】:

  • [1, 2] 是[0, 2] 和[1, 5] 的联合体吗?
  • 你“懒惰”的解决方案甚至没有给出预期的输出。
  • @DimKoim 是的,我只是想看看我要去哪里
  • @EliSadoff 对不起,十字路口。谢谢
  • @EliSadoff 作为一个范围,0-2 和1-5 的交集是1-2

标签: python algorithm


【解决方案1】:
[[max(first[0], second[0]), min(first[1], second[1])] 
  for first in a for second in b 
  if max(first[0], second[0]) <= min(first[1], second[1])]

给出答案的列表推导: [[1, 2], [5, 5], [8, 10], [15, 18], [20, 23], [24, 24]]

分解:

[[max(first[0], second[0]), min(first[1], second[1])] 

第一项的最大值,第二项的最小值

for first in a for second in b 

对于第一项和第二项的所有组合:

if max(first[0], second[0]) <= min(first[1], second[1])]

仅当第一个的最大值不超过第二个的最小值时。


如果您需要压缩输出,则以下函数会执行此操作(在O(n^2) 时间,因为从列表中删除是O(n),我们执行O(n) 时间的步骤):

def reverse_compact(lst):
    for index in range(len(lst) - 2,-1,-1):
        if lst[index][1] + 1 >= lst[index + 1][0]:
            lst[index][1] = lst[index + 1][1]
            del lst[index + 1]  # remove compacted entry O(n)*
    return lst

考虑到它们是按顺序,它会连接接触的范围。它是反向执行的,因为这样我们就可以就地执行此操作,并在我们进行时删除压缩的条目。如果我们不反向执行,删除其他条目会影响我们的索引。

>>> reverse_compact(comp)
[[1, 2], [5, 5], [8, 10], [15, 18], [20, 24]]
  • 通过执行就地前向压缩并复制回元素,可以将压缩函数进一步简化为 O(n),因为每个内部步骤都是 O(1)(get/set 而不是 del),但这不太可读:

这以O(n) 的时间和空间复杂度运行:

def compact(lst):
    next_index = 0  # Keeps track of the last used index in our result
    for index in range(len(lst) - 1):
        if lst[next_index][1] + 1 >= lst[index + 1][0]:
            lst[next_index][1] = lst[index + 1][1]
        else:    
            next_index += 1
            lst[next_index] = lst[index + 1]
    return lst[:next_index + 1]

使用任一压缩器,列表推导是这里的主要术语,时间 =O(n*m),空间 =O(m+n),因为它比较了两个列表的所有可能组合,没有提前出局。这不利用了提示中给出的列表的有序结构:您可以利用该结构将时间复杂度降低到O(n + m),因为它们总是增加并且从不重叠,这意味着您可以这样做一次性完成所有比较。


请注意,解决方案不止一种,希望您能解决问题,然后迭代改进。

满足所有可能输入的 100% 正确答案不是面试问题的目标。就是看一个人是如何思考和应对挑战的,以及他们是否能够推理出解决方案。

事实上,如果你给我一个 100% 正确的、教科书式的答案,那可能是因为你以前看过这个问题并且你已经知道解决方案......因此这个问题对作为面试官的我没有帮助. “检查一下,是否可以反刍在 StackOverflow 上找到的解决方案。” 这个想法是看着你解决问题,而不是反刍解决方案。

太多候选人只见树木不见森林:承认缺点并提出解决方案是回答面试问题的正确方法。您不必有解决方案,您必须展示您将如何解决问题。

如果您能够解释它并详细说明使用它的潜在问题,那么您的解决方案就很好。

我因未能回答面试问题而得到了现在的工作:在我花了大部分时间尝试之后,我解释了为什么我的方法不起作用,如果有更多时间我会尝试第二种方法,以及我可能遇到的陷阱看到了这种方法(以及我最初选择第一个策略的原因)。

【讨论】:

  • 不应该有 [[20, 23], [24, 24]] 而是 [[20,24]]。这正是我被困住的地方,无法找到一个好的方法来做到这一点。
  • a) 提示没有指定是这种情况 & b) 之后压缩列表并不困难。
  • 你怎么说more correct than the OPs example??
  • 您提供的解决方案并不能处理所有情况。如果答案类似于 [[1,3],[4,4],[5,5]],答案应该是 [[1,5]] 并且您的列表压缩将给出 [[1,4], [4,5]]
  • 但还是不正确 :P 看看你能不能处理上述情况。
【解决方案2】:

OP,我相信这个解决方案有效,它在 O(m+n) 时间内运行,其中 m 和 n 是列表的长度。 (可以肯定的是,将ranges 设为链表,以便在恒定时间内更改其长度。)

def intersections(a,b):
    ranges = []
    i = j = 0
    while i < len(a) and j < len(b):
        a_left, a_right = a[i]
        b_left, b_right = b[j]

        if a_right < b_right:
            i += 1
        else:
            j += 1

        if a_right >= b_left and b_right >= a_left:
            end_pts = sorted([a_left, a_right, b_left, b_right])
            middle = [end_pts[1], end_pts[2]]
            ranges.append(middle)

    ri = 0
    while ri < len(ranges)-1:
        if ranges[ri][1] == ranges[ri+1][0]:
            ranges[ri:ri+2] = [[ranges[ri][0], ranges[ri+1][1]]]

        ri += 1

    return ranges

a = [[0,2], [5,10], [13,23], [24,25]]
b = [[1,5], [8,12], [15,18], [20,24]]
print(intersects(a,b))
# [[1, 2], [5, 5], [8, 10], [15, 18], [20, 24]]

【讨论】:

  • 当代码包括对所有连续的四个数字组进行排序时,这个 O(m+n) 怎么样?
  • 根据输入列表的长度对长度为 4 的列表进行排序,在恒定时间内运行,并且您执行此排序 O(m+n) 次。
  • 我明白了,每个排序都独立于n,所以它是恒定的,这是有道理的。感谢您的澄清。
【解决方案3】:

算法

给定两个区间,如果它们重叠,则交点的起点是两个区间起点中的最大值,其终点是终点中的最小值:

要找到所有可能相交的间隔对,从第一对开始,并不断增加间隔与较低的停止点:

最多考虑m + n 对间隔,其中m 是第一个列表的长度,n 是第二个列表的长度。计算一对区间的交集是在常数时间内完成的,所以这个算法的时间复杂度是O(m+n)。

实施

为了保持代码简单,我使用 Python 的内置 range 对象作为间隔。这与问题描述略有偏差,因为范围是半开区间而不是闭区间。也就是说,

(x in range(a, b)) == (a <= x < b)

给定两个range 对象x 和y,它们的交集是range(start, stop),其中start = max(x.start, y.start) 和stop = min(x.stop, y.stop)。如果这两个范围不重叠,那么start &gt;= stop 你只会得到一个空范围:

>>> len(range(1, 0))
0

因此,给定两个范围列表,xs 和 ys,每个范围的起始值都增加,交集可以计算如下:

def intersect_ranges(xs, ys):

    # Merge any abutting ranges (implementation below):
    xs, ys = merge_ranges(xs), merge_ranges(ys)

    # Try to get the first range in each iterator:
    try:
        x, y = next(xs), next(ys)
    except StopIteration:
        return

    while True:
        # Yield the intersection of the two ranges, if it's not empty:
        intersection = range(
            max(x.start, y.start),
            min(x.stop, y.stop)
        )
        if intersection:
            yield intersection

        # Try to increment the range with the earlier stopping value:
        try:
            if x.stop <= y.stop:
                x = next(xs)
            else:
                y = next(ys)
        except StopIteration:
            return

从您的示例看来,范围可以邻接。所以必须先合并任何相邻的范围:

def merge_ranges(xs):
    start, stop = None, None
    for x in xs:
        if stop is None:
            start, stop = x.start, x.stop
        elif stop < x.start:
            yield range(start, stop)
            start, stop = x.start, x.stop
        else:
            stop = x.stop
    yield range(start, stop)

将此应用于您的示例:

>>> a = [[0, 2], [5, 10], [13, 23], [24, 25]]
>>> b = [[1, 5], [8, 12], [15, 18], [20, 24]]
>>> list(intersect_ranges(
...     (range(i, j+1) for (i, j) in a),
...     (range(i, j+1) for (i, j) in b)
... ))
[range(1, 3), range(5, 6), range(8, 11), range(15, 19), range(20, 25)]

【讨论】:

  • 这个算法与@BallpointBen 的相比如何?哪个更快?我需要大规模使用这个算法
【解决方案4】:

我知道这个问题已经得到了正确答案。为了完整起见,我想提一下我前段时间开发的一个 Python 库,即portion (https://github.com/AlexandreDecan/portion),它支持这种操作(原子区间列表之间的交集)。

您可以查看实现,它与此处提供的一些答案非常接近:https://github.com/AlexandreDecan/portion/blob/master/portion/interval.py#L406

为了说明它的用法,我们来看看你的例子:

a = [[0, 2], [5, 10], [13, 23], [24, 25]]
b = [[1, 5], [8, 12], [15, 18], [20, 24]]

我们需要先将这些“项目”转换为封闭(原子)区间:

import portion as P

a = [P.closed(x, y) for x, y in a]
b = [P.closed(x, y) for x, y in b]

print(a)

...显示[[0,2], [5,10], [13,23], [24,25]](每个[x,y] 是一个Interval 对象)。

然后我们可以创建一个表示这些原子区间的并集的区间:

a = P.Interval(*a)
b = P.Interval(*b)

print(b)

... 显示[0,2] | [5,10] | [13,23] | [24,25](单个Interval 对象,代表所有原子对象的联合)。

现在我们可以很容易地计算交集:

c = a & b
print(c)

...显示[1,2] | [5] | [8,10] | [15,18] | [20,23] | [24]。

请注意,我们的答案与您的不同([20,23] | [24] 而不是 [20,24]),因为库需要连续的值域。按照https://github.com/AlexandreDecan/portion/issues/24#issuecomment-604456362 中提出的方法,我们可以很容易地将结果转换为离散区间,如下所示:

def discretize(i, incr=1):
  first_step = lambda s: (P.OPEN, (s.lower - incr if s.left is P.CLOSED else s.lower), (s.upper + incr if s.right is P.CLOSED else s.upper), P.OPEN)
  second_step = lambda s: (P.CLOSED, (s.lower + incr if s.left is P.OPEN and s.lower != -P.inf else s.lower), (s.upper - incr if s.right is P.OPEN and s.upper != P.inf else s.upper), P.CLOSED)
  return i.apply(first_step).apply(second_step)

print(discretize(c))

... 显示[1,2] | [5] | [8,10] | [15,18] | [20,24]。

【讨论】:

    【解决方案5】:

    我不是那种 Python 程序员,但不要认为这个问题适合用 Python 式的简洁高效的解决方案。

    Mine 将区间边界视为标记为 1 和 2 的“事件”,按顺序处理它们。每个事件都会触发奇偶校验字中的相应位。当我们切换到 3 或从 3 切换时,是时候发出相交间隔的开始或结束了。

    棘手的部分是,例如[13, 23], [24, 25] 被视为[13, 25];相邻的间隔必须连接。下面的嵌套if 通过继续当前间隔而不是开始一个新间隔来处理这种情况。此外,对于相等的事件值,必须在结束之前处理间隔开始,以便例如[1, 5] 和 [5, 10] 将作为 [5, 5] 发出,而不是什么都没有。这由事件元组的中间字段处理。

    由于排序,此实现为 O(n log n),其中 n 是两个输入的总长度。通过成对合并两个事件列表,它可能是 O(n),但 this article 建议列表必须很大,然后库合并才能击败库排序。

    def get_isect(a, b):
      events = (map(lambda x: (x[0], 0, 1), a) + map(lambda x: (x[1], 1, 1), a)
              + map(lambda x: (x[0], 0, 2), b) + map(lambda x: (x[1], 1, 2), b))
      events.sort()
      prevParity = 0
      isect = []
      for event in events:
        parity = prevParity ^ event[2]
        if parity == 3:
          # Maybe start a new intersection interval.
          if len(isect) == 0 or isect[-1][1] < event[0] - 1:
            isect.append([event[0], 0])
        elif prevParity == 3:
          # End the current intersection interval.
          isect[-1][1] = event[0]
        prevParity = parity
      return isect
    

    这是一个复杂一点的 O(n) 版本,因为它通过合并输入列表来即时找到下一个事件。它还只需要输入和输出之外的持续存储:

    def get_isect2(a, b):
      ia = ib = prevParity = 0
      isect = []
      while True:
        aVal = a[ia / 2][ia % 2] if ia < 2 * len(a) else None
        bVal = b[ib / 2][ib % 2] if ib < 2 * len(b) else None
        if not aVal and not bVal: break
        if not bVal or aVal < bVal or (aVal == bVal and ia % 2 == 0):
          parity = prevParity ^ 1
          val = aVal
          ia += 1
        else:
          parity = prevParity ^ 2
          val = bVal
          ib += 1
        if parity == 3:
          if len(isect) == 0 or isect[-1][1] < val - 1:
            isect.append([val, 0])
        elif prevParity == 3:
          isect[-1][1] = val
        prevParity = parity
      return isect
    

    【讨论】:

      【解决方案6】:

      回答您的问题,因为我个人可能会回答面试问题,并且可能也非常感谢您的回答;受访者的目标可能是展示一系列技能,而不仅限于python。所以这个答案无疑会比这里的其他答案更抽象。

      询问有关我所面临的任何限制的信息可能会有所帮助。操作时间和空间复杂度是常见的限制条件,开发时间也是如此,所有这些都在此处之前的答案中提到;但也可能出现其他限制。与其中任何一项一样常见的是维护和与现有代码的集成。

      在每个列表中,范围将始终增加且永远不会重叠

      当我看到这个时,这可能意味着有一些预先存在的代码来规范化范围列表,对范围进行排序和合并重叠。这是一个很常见的联合操作。加入现有团队或正在进行的项目时,成功的最重要因素之一是与现有模式的整合。

      交集操作也可以通过联合操作来执行。反转排序的范围,合并它们,然后反转结果。

      对我来说,这个答案展示了对算法的一般经验和具体的“范围”问题,对最易读和可维护的代码方法通常是重用现有代码的欣赏,以及帮助团队成功超越我自己的困惑的愿望.

      另一种方法是将两个列表一起排序为一个可迭代列表。迭代列表,将每个开始/结束引用计数为增量/减量步骤。范围在引用计数 1 和 2 之间的转换时发出。如果排序操作满足我们的需求(而且它们通常这样做),这种方法本质上可以扩展为支持两个以上的列表。

      除非另有说明,否则我将提供一般方法并讨论我在编写代码之前可能使用每种方法的原因。

      所以,这里没有代码。但是您确实要求提供一般方法和思考:D

      【讨论】:

        猜你喜欢
        • 2013-10-07
        • 2014-07-04
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2014-11-22
        • 1970-01-01
        • 2016-02-04
        相关资源
        最近更新 更多