簡體   English   中英

Python 中兩個范圍列表的交集

[英]Intersection of two lists of ranges in Python

我的一個朋友向我傳遞了他最近收到的一個面試問題,我對我的解決方案不太滿意。 問題如下:

  • 你有兩個列表。
  • 每個列表將包含長度為 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))
...

但我想有一個既可讀又更高效的解決方案。

如果您不介意,請說明您將如何在精神上解決這個問題。 時間/空間復雜度分析也會有所幫助。

謝謝

[[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) (獲取/設置而不是 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 上找到的解決方案。” 這個想法是看着你解決一個問題,而不是反芻一個解決方案。

太多的候選人只見樹木不見森林:承認缺點並提出解決方案是回答面試問題的正確方法。 您不必有解決方案,您必須展示您將如何解決問題。

如果您可以解釋它並詳細說明使用它的潛在問題,那么您的解決方案就很好。

我因為沒有回答面試問題而得到了現在的工作:在花了大部分時間嘗試之后,我解釋了為什么我的方法不起作用,我嘗試的第二種方法有更多的時間,以及我在其中看到的潛在陷阱方法(以及我最初選擇第一個策略的原因)。

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]]

算法

給定兩個區間,如果它們重疊,則交點的起點是兩個區間起點的最大值,其停止點是停止點的最小值:

相交區間圖 相交區間圖

要找到所有可能相交的區間對,從第一對開始,並用較低的停止點繼續增加區間:

算法動畫

最多考慮m + n對區間,其中m是第一個列表的長度, n是第二個列表的長度。 計算一對區間的交集是在恆定時間內完成的,因此該算法的時間復雜度為O(m+n)

實施

為了保持代碼簡單,我使用 Python 的內置range對象作為間隔。 這與問題描述略有偏差,因為范圍是半開區間而不是閉區間。 也就是說,

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

給定兩個range對象xy ,它們的交集是range(start, stop) ,其中start = max(x.start, y.start)stop = min(x.stop, y.stop) 如果這兩個范圍不重疊,則start >= stop並且您只會得到一個空范圍:

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

因此,給定兩個范圍列表xsys ,每個列表的起始值都增加,交集可以計算如下:

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)]

我知道這個問題已經得到了正確的答案。 為了完整起見,我想提一下我前段時間開發了一個 Python 庫,即支持這種操作(原子間隔列表之間的交集)的portionhttps://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] [0,2] | [5,10] | [13,23] | [24,25] [0,2] | [5,10] | [13,23] | [24,25] (單個Interval對象,代表所有原子對象的並集)。

現在我們可以輕松計算交集:

c = a & b
print(c)

... 顯示[1,2] | [5] | [8,10] | [15,18] | [20,23] | [24] [1,2] | [5] | [8,10] | [15,18] | [20,23] | [24] [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] [1,2] | [5] | [8,10] | [15,18] | [20,24] [1,2] | [5] | [8,10] | [15,18] | [20,24]

我不是 Python 程序員,但不要認為這個問題適合於同樣高效的 Python 式簡短解決方案。

我的將區間邊界視為標記為 1 和 2 的“事件”,按順序處理它們。 每個事件都會觸發奇偶校驗字中的相應位。 當我們切換到 3 或從 3 切換時,是時候發出相交間隔的開始或結束。

棘手的部分是例如[13, 23], [24, 25]被視為[13, 25] 相鄰的間隔必須連接。 下面的嵌套if通過繼續當前間隔而不是開始一個新間隔來處理這種情況。 此外,對於相等的事件值,必須在結束之前處理間隔開始,以便例如[1, 5][5, 10]將作為[5, 5]而不是什么都不發出。 這是用事件元組的中間字段處理的。

由於排序,此實現是 O(n log n),其中 n 是兩個輸入的總長度。 通過成對合並兩個事件列表,它可能是 O(n),但本文建議列表必須很大,然后庫合並才能擊敗庫排序。

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

回答你的問題,因為我個人可能會回答一個面試問題,也可能最感謝你的回答; 受訪者的目標可能是展示一系列技能,而不僅限於 Python。 所以這個答案無疑會比這里的其他答案更抽象。

詢問有關我正在操作的任何約束的信息可能會有所幫助。 操作時間和空間復雜度是常見的限制條件,開發時間也是如此,所有這些都在前面的答案中提到過; 但也可能出現其他限制。 與其中任何一個一樣常見的是維護和與現有代碼的集成。

在每個列表中,范圍將始終增加並且永遠不會重疊

當我看到這個時,這可能意味着有一些預先存在的代碼來規范化范圍列表,對范圍進行排序並合並重疊。 這是一個很常見的聯合操作。 加入現有團隊或正在進行的項目時,成功的最重要因素之一是與現有模式集成。

交運算也可以通過聯合運算來執行。 反轉排序的范圍,合並它們,然后反轉結果。

對我來說,這個答案展示了一般算法和“范圍”問題的經驗,最易讀和可維護的代碼方法通常是重用現有代碼,並且希望幫助團隊成功而不是我自己的困惑。

另一種方法是將兩個列表一起排序為一個可迭代列表。 迭代列表,引用計數每個開始/結束作為增量/減量步驟。 范圍是在引用計數 1 和 2 之間的轉換時發出的。如果排序操作滿足我們的需要(它們通常會這樣做),這種方法本質上是可擴展的以支持兩個以上的列表。

除非另有說明,否則我將在編寫代碼之前提供一般方法並討論我可能使用每種方法的原因。

所以,這里沒有代碼。 但是您確實要求提供一般方法和思考:D

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM