1. 程式人生 > >RMQ(Range minimum query) based LCA solution

RMQ(Range minimum query) based LCA solution

何為RMQ

在文章《Tarjan’s off-line lowest common ancestors algorithm》我們用圖形化的方式展示了Tarjan’s off-line LCA的求解過程,但是該文章有很多遺漏,例如下面的這些問題。在本篇文章中,我會介紹另外一種求解LCA的方法,然後嘗試順帶回答列出的這些問題。

  • 既然有了Leetcode 236中標準解法,為什麼還需要Tarjan這種比較重的方法?
  • 在那篇文章中,提到Tarjan方法的本質是並查集,但是那種說法並不嚴謹,並查集只是Tarjan實現途徑

現階段比較好的求解LCA的方式是基於RMQ的求解方法,本文章會著重介紹什麼是RMQ以及常見的幾種求解RMQ的方法, 注:本篇文章完全按照TopCoder中的

此篇文章展開的,所以如果看過那篇文章就不用浪費時間本篇文章了

RMQ,全稱Range minimum query,用於查詢一個數組中子陣列的最值,這樣一個看似簡單的問題,卻有很多值得玩味的地方。

In computer science, a range minimum query (RMQ) solves the problem of finding the minimal value in a sub-array of an array of comparable objects. 《Range minimum query》

樸素解法

例如,給定包含 N 個數的陣列 data[N] 和 Q 個查詢。每個查詢的輸入 (a, b) 都是一對整數,要求打印出 data[a] 到 data[b] 之間的最大值和最小值之差。例如,N = 6,Q = 3,一個輸入樣例是 d

ata:17342569data\ :\ 1\ 7\ 3\ 4\ 2\ 5\ 6\ 9 query:04query: 0\ 4 35\qquad \quad \ \ 3\ 5 11\qquad \quad \ \ 1\ 1

比較直觀的方法是,然後得到Max和Min,然後求差值,在陣列沒有發生變化的情況下,這種方法有很多資源的浪費,存在很多重複計算。例如我們在查詢(1155)之間Max和Min,可以順手將子區間的最大值和最小值記錄下來,使用一種Record Table來儲存計算後的結果。

Record Table

顯而易見將所有可能的查詢下標對(a

abb)記錄下來,需要O(N2)O(N^2)的空間複雜度。求子陣列最小值的記錄表格如下所示:

0 1 2 3 4 5 6 7
0 1 1 1 1 1 1 1 1
1 7 3 3 2 2 2 2
2 3 3 2 2 2 2
3 4 2 2 2 2
4 2 2 2 2
5 5 5 5
6 6 6
7 9

注:這種半矩陣肯定有更好的儲存方式

該方法的複雜度如下所示:

  • 前期準備工作,亦即計算該表格的時間複雜度為O(N2)O(N^2)
  • 查詢複雜度為O(1)O(1)
  • 空間複雜度為O(N2)O(N^2)

該方法的查詢複雜度雖然很低,但是空間複雜度卻比較高,那麼是否可以對儲存的表格進行精簡?

可以使用動態規劃來求解該表格,注意對Table[i][i]的賦值移動到雙層loop中,但是那種做法沒有下面這種形式高效,經過我在quick-bench上的測試,下面的這種形式比另外一種形式快1.6倍,測試結果見http://quick-bench.com/oTprU_S6yaNvqI2xpjtBemq9O4w。下面這種方式比較快的原因可能是C++中的not pay for what you don’t use,類似於copy-and-swap idiom相較於傳統方式的優勢。

#include <iostream>
#include <vector>

using TableType = std::vector<std::vector<int>>;

void solution(std::vector<int> &Array, TableType &Table) {
    size_t size = Array.size();

    for (int i = 0; i < size; ++i)
        Table[i][i] = Array[i];
    
    for (size_t i = 0; i < size; ++i) {
        for (size_t j = i; j < size; ++j) {
            if (Table[i][j-1] < Array[j])
                Table[i][j] = Table[i][j-1];
            else
                Table[i][j] = Array[j];
        }
    }
}

int main() {
    std::vector<int> Vec{1, 7, 3, 4, 2, 5, 6, 9};
    TableType Table{Vec.size(), std::vector<int>(Vec.size(), 0)};
    solution(Vec, Table);
    return 0;
}

block-based Table

Sqrt-based Table

我們可以犧牲查詢操作的效率,來得到更小的表格需要的儲存空間。我們可以將ArrayArray分成幾個chunks,儲存各chunk的最小值,然後將某次查詢經由這些chunk的最小值組合而成,由於我們至多可以將ArrayArray分割成NN個chunk,所以儲存空間至多為O(N)O(N)。TopCoder直接將Array分割成了sqrt(N)sqrt(N)個chunk,並沒有解釋緣由,GeekforGeeks中有一篇很好關於為什麼常常將ArrayArray分割成sqrt(N)sqrt(N)的講解,見Sqrt (or Square Root) Decomposition Technique | Set 1 (Introduction)

The key concept of this technique is to decompose given array into small chunks specifically of size sqrt(n).

我們以開頭陣列為例,選擇將ArrayArray分割成不同的chrunk,如下圖所示: chunks

所以每次查詢都可分為下面兩種情況,查詢的複雜度就可以通過下面兩種情況中較大的複雜度決定。

  • 查詢跨越多個chunk
  • 查詢只侷限在一個chunk中 那麼分成幾個chunk,才能使查詢的最壞複雜度最小呢?答案是將長度為NNArrayArray分為sqrt(N)sqrt(N)個chunk時,worst case complexity最小。

Why sqrt is perfect?

假如我們將WC(N,x)WC(N, x)定義為將長度為NN的陣列分割為xx個chunk的複雜度,那麼該函式如下所示:

WC(N,x)={N/x,ifN/x&gt;xx,otherwiseWC(N, x) = \begin{cases} N/x, \qquad {\rm if\ } N/x &gt; x \\ x, \qquad \quad \ \ {\rm otherwise} \end{cases} xxsqrt(N)sqrt(N)時,WC(N,x)WC(N, x)達到最小值,如下圖所示,也就是8/x8/xxx交點的位置。 函式

此時空間複雜度為O(sqrt(N))O(sqrt(N)),查詢複雜度為O(sqrt(N))O(sqrt(N)),構建Table的複雜度是O(N)O(N)

但是這種方式有個問題,就是雖然我們有了一個額外的table,但是還得必須訪問原有的陣列

泛華形式

Sparse Table

注:該小節的標題其實不是很合適,sparse table是一個很寬泛的概念,上一小節中的block-based table其實也可以算作這一小節中 現如今針對RMQ中的sparse table就特指文章《The LCA Problem Revisited》中提出的sparse table的方法(注:也是該篇文章首次將LCA問題轉換成RMQ問題求解的)。該方法首先也是基於預先處理原陣列,然後使用一個額外的Table儲存指定query的值得方式

首先我們定義Mi,jM{_i}{_,}{_j},來表示子陣列A[i...i+2j1]A[i...i + 2^j-1]的最小值的index(從這裡可以看到當j=0j=0時,表示就是A[i]這一個陣列單元),如下圖所示。任何關於子陣列的query都可以通過兩個Mi,jM{_i}{_,}{_j}覆蓋。例如A[2...8]A[2...8]就可以由A[2...5]A[2...5],亦即M2,2M{_2}{_,}{_2},和A[5...8]A[5...8],亦即M5,2M{_5}{_,}{_2}覆蓋。所以求一個子陣列最小值的問題就轉化成為求兩個預先儲存好兩個值的最小值的問題ST solution 注:該圖摘於《Faster range minimum queries》

由於對於陣列中的元素iihi{_i}{_h}而言,都有log2nlog_2n個值要儲存,所以

  • 空間複雜度為 O(nlog2n)O(n * log_2n)
  • 查詢時間複雜度為O(1)O(1)
  • 預處理複雜度,使用動態規劃來計算的話,複雜度也是O(nlog2n)O(n * log_2n)

下面我們給出,這個O(nlog2n)O(n * log_2n)空間複雜度的動態規劃演算法。根據上面Mi,jM{_i}{_,}{_j}的定義,轉移方程如下: Mi,j={Mi,j1ifA[Mi,j]&lt;=A[Mi+2j1,j1]Mi+2j1,j1M{_i}{_,}{_j} = \begin{cases} M_{i, j-1} \qquad if A[M_{i,j}] &lt;= A[M_{i + 2^{j-1}, j-1}] \\ M_{i+2^{j-1}, j-1} \end{cases} 注:示例陣列是從下標1開始的 例如我們要計算M2,2M_{2, 2},首先計算得到M2,1=3M_{2, 1} = 3M4,1=5M_{4,1} = 5,然後A[3]&lt;A[5]A[3] &lt; A[5],所以M2,2=M2,1=3M_{2,2} = M_{2,1} = 3

然後介紹一下,給定一個查詢RMQA(l,r)RMQ_A(l, r),如何計算出能夠覆蓋該子陣列A[r...l]A[r...l]的兩個已經儲存好的區間。例如我們想要求出A[2...8]A[2...8]的最小值,那麼首先這個區間長度為ith...jth=6i_{th}...j_{th} = 6