最近被人問到這樣一個問題的解決方案:在一個餐館的預定系統中,接受使用者在未來任意一段時間內的預訂用餐,使用者在預訂的時候需要提供用餐的開始時間和結束,餐館的餐桌是用限的,問題是,系統要在最快的時間段計算出在該使用者預定的時間段內是否還有可用的餐桌?其實類似的問題我們在做系統時經常碰到,比如在一個“任務管理”系統中,我們要知道某個任務的執行時間段是否跟已知的時間段有重疊,揭開這些特定需求的外表,本質的問題可以這樣描述:在一個線性的空間中,已存在很多區間段分佈在該線性空間中,現給出一個指定的區間段,求出空間中所有和該區間段有重疊的空間段集合。
定義區間重疊
怎樣定義“兩個區間重疊”?大家都能立刻判斷出這個結果,但是我們要用語言定義出來,或者用數學公式表達出來才能建立解決模型。先看下面一張圖:
我們把上面的區間叫做t1,下面的區間叫做t2,根據上圖可以看出,區間t2和區間t1有重疊的話,必然要滿足下列三種情況之一:
- t2的開始時間落在t1區間段內
- t2的結束時間落在t1區間段內
- t2直接包含了整個t1區間
如果我們用數學公式表達的話,就是:
\begin{equation}
t2_{starttime} <= t1_{endtime} \quad and \quad t2_{endtime} >= t1_{starttime}
\end{equation}
窮舉法
根據上面的公式,窮舉所有區間集合中的元素,逐個計算,兩兩比較,返回所有滿足要求的區間元素。時間計算複雜度是 \(\theta(N)\)
交集
根據上面的公式1,可以構建兩個有序集合,分別存放所有區間段的開始時間和結束時間,假設兩個集合分別是 S 和 E,則查詢和指定區間(s,e)重疊的所有區間可以這樣計算:先計算集合S中所有小於e的元素,再計算出集合E中所有大於s的元素,計算出這兩個結果的交集,則為最終結果。用公式表達就是:
\begin{equation}
{x|x \in S \land x \leq e } \cap {y|y \in E \land y \geq s }
\end{equation}
在具體系統開發中,實現方式有多種。如果基於資料庫,如MySQL,可以直接通過
Merge Index
利用兩個索引欄位。Redis中也有集合的交集運算實現ZINTERSTORE
。這種方式從直觀感覺上比窮舉法好像快很多。我們可以大概計算評估下:第一步是要從兩個集合中範圍查詢子集,採用一般的樹結構
,都能做到 \(\theta(\log{N})\),第二步要做兩個子集的交集運算,複雜度又回到了 \(\theta(N)\)。這其實和上面的窮舉法感覺沒有什麼區別。
初識IntervalTree演算法
其實各種各樣的樹結構
,都是利用二分原理快速找到需要的資料,其複雜度都是 \(\theta(\log{N})\)級。IntervalTree也是利用這一特性,把每個區間二分對摺,淘汰掉另外一半來快速找到所要區間資料。
構建
構建一個IntervalTree很簡單,每次新增一個區間元素t時,先比較區間t是否覆蓋x_center(x_center就是當前整個區間的中間點,從演算法效率上來講,不應該是區間起點和終點的平均值,而應該是落中這個區間內所有元素的中位值)值,如果覆蓋則把區間的開始值和結束值分別存放在該節點的兩個有序集合中,分別是所有覆蓋區間的開始時間集合和結束時間集合。如果區間t在x_center之後,則放到右子節點上,處理方式一樣(遞迴處理);如果區間t在x_center之前,則放到左子點上,也是遞迴處理。這樣每個節點的資料結構大概這樣:
class Node(object):
def __init__(self, boundary):
# 區間範圍
self.boundary = boundary
# 中間值
self.x_center = (boundary[1] - boundary[0]) / 2 + boundary[0]
# 左子節點,該節點下的所有區間都小於x_center
self.left = None
# 右子節點,該節點下的所有區間都大於x_center
self.right = None
# 覆蓋x_center的所有節點的開始時間集合
self.begins = []
# 覆蓋x_center的所有節點的結束時間集合
self.ends = []
def add_overlap_interval(self, start_point, end_point):
self.begins.append(start_point)
self.begins = sorted(self.begins)
self.ends.append(end_point)
self.ends = sorted(self.ends)
boundary參數列左該節點所能影響到整個區間範圍,包含了一個起點和終點。這裡簡單的把x_center值取成範圍的中間值。left 和 right 分別為左子節點和右子節點。begins為有序集合,裡面的元素為所有滿足特定條件(覆蓋x_center)的區間的開始值。同begins一樣,ends存放的是所有覆蓋x_center的區間的結束值的有序集合。方法add_overlap_interval
的作用就是新增能覆蓋x_center的間到此節點中。
有了上面描述的節點定義,IntervalTree就是由上述節點組成的,即然是樹結構,所以就有根節點的概念。每個IntervalTree有一個根節點。
class IntervalTree(object):
def __init__(self, min_point, max_point):
self.min_point = min_point
self.max_point = max_point
self.root = Node((min_point, max_point))
def add(self, start_point, end_point):
node = self.root
while end_point < node.x_center or start_point > node.x_center:
# 如果區間沒有覆蓋x_center,則新增到子節點中去
if end_point < node.x_center:
# 新增到左子節點
if not node.left:
node.left = Node((node.boundary[0], node.x_center))
node = node.left
else:
# 新增到右子節點
if not node.right:
node.right = Node((node.x_center, node.boundary[1]))
node = node.right
else:
# 區間覆蓋x_center,則新增到此節點
node.add_overlap_interval(start_point, end_point)
查詢
對於一個區間集合 S,對於給定的區間 q,現要查詢出所有和區間 q 有重疊的區間子集合,怎樣做呢?根據前面的區間重疊定義中說的,如果一個區間的開始時間或者結束時間落在了另外一個區間內,或者完全包含這個區間,則是重疊的。所以我們按照這個思路分別求解。
先查出所有點(無論開始時間或結束時間點)落在查詢區間 q 段內的資料。這點很好做,可以把所有開始時間和結束時間放在一個排序的資料結構中(如紅黑樹),這樣求解就轉換成了在一個樹中求範圍資料,其複雜度是 \(\theta(\log{N})\)。
再找出那些區間完全包含了查詢區間q的資料。這裡有個技巧可以利用,在區間q中隨便取一個點p,我們可以有如下結論推理:凡是區間能覆蓋到點p的,則肯定和區間q有重疊。這個用數學公式很好推理出來。所以現在的問題就是在一個IntervalTree樹中查出給定一個點的所有覆蓋區間子集合。這個問題的求解和構建樹結構一致。從根節點開始查詢,查詢此節點中所有可覆蓋的區間。然後根據指定點落在左,或右子節點上來2分查詢,直到沒有沒有子節點時退出。這裡要注意一點:如果指定點剛好等於x_center點,則立即停止查詢子節點,並返回當前節點所包含的所有區間資料。查詢演算法如下:
def search_intervals(self, point):
# 從根節點開始查詢
node = self.root
result = []
while point != node.x_center:
# 如果查詢點沒有和x_center相同
if point < node.x_center:
# 如果查詢點在x_center前邊,則該節點內所有的區間中,開始時間早於或者等於point的區間都是覆蓋point的
result += [s for s in node.begins if s <= point]
node = node.left
else:
# 如果查詢點在x_center後邊,則該節點內所有的區間中,結束時間晚於或者等於point的區間都是覆蓋point的
result += [s for s in node.ends if s >= point]
node = node.right
if not node:
break
else:
result += node.begins
return result
至此,整個IntervalTree的大概思路表述完了。上面的程式碼其實更多的是講述思路,細節沒有注意,比如Node結構中begins和ends用LinkedList還是RBTree更合適。還有其它一些思考,比如區間的刪除,以及具體資料業務場景中,選擇什麼樣的x_center的取值方式使樹更平衡些。留言下說你的思考,謝謝!