1. 程式人生 > 其它 >該暴力時就暴力: 替罪羊樹

該暴力時就暴力: 替罪羊樹

替罪羊樹(Scapegoat_Tree)

定義

一種平衡樹, 能達到宗法樹和 Treap 的常數, 不用旋轉維護自己的平衡, 邏輯和宗法樹類似, 在左右子樹極度不平衡時維護它的平衡性, 只不過宗法樹是旋轉, 比較溫和, 而替罪羊樹採用的是重構左右子樹這種比較激進的策略.

所以替罪羊樹不需要旋轉.

和旋轉不同的是, 一次重構可以使它的整個子樹的每個節點的兩個兒子平衡, 所以就不需要像宗法樹一樣, 每次遇到左右不平衡的節點都維護, 只要在這次操作經過的所有節點中, 找到深度最小的那個不平衡的節點, 然後重構它的子樹即可.

這就是替罪羊樹得名的原因: 即使有很多點需要重構, 選深度最小的點作為替罪羊, 只重構替罪羊的子樹. 原因也很簡單, 即使從下到上的所有節點都重構, 結果也和只重構那個最靠上的一樣. (雖然依次重構這些點不會使複雜度更劣, 但是會得到兩倍的常數).

struct Node {
  Node *LS, *RS;
  int Value, Size, Cnt, RealSize;
}N[100005], *PntStk[100005], *CntN(N), *Root(N), *TmpN, *TmpNF, *TmpF;

重構

字面意思, 重新建樹, 如果子樹規模為 \(n\), 則重構複雜度是實實在在的 \(O(n)\), 至於為什麼時間複雜度正確, 之後會給出證明.

操作邏輯很簡單, \(O(n)\) 中序遍歷, 得到一個排序好的序列, 然後每次取中點作為子樹的根, 將序列分成兩段, 兩段分別遞迴建樹.

實現起來一般是將樹上的節點按中序遍歷壓入一個棧, 在重構的時候, 使用棧裡的節點的記憶體, 這樣就能減少垃圾節點的數量並且不會申請新的記憶體.

Node *Build(unsigned L, unsigned R) {
  if(L == R) {
    PntStk[Hd]->Size = PntStk[Hd]->Cnt = CntStack[L], PntStk[Hd]->LS = PntStk[Hd]->RS = NULL, PntStk[Hd]->Value = Stack[L], PntStk[Hd]->RealSize = 1;
    return PntStk[Hd--];
  }
  register unsigned Mid((L + R) >> 1);
  register Node *x(PntStk[Hd--]);
  x->RealSize = 1, x->Value = Stack[Mid], x->Size = x->Cnt = CntStack[Mid];
  if(L < Mid) x->LS = Build(L, Mid - 1), x->RealSize += x->LS->RealSize, x->Size += x->LS->Size;
  else x->LS = NULL;
  x->RS = Build(Mid + 1, R);
  x->RealSize += x->RS->RealSize, x->Size += x->RS->Size;
  return x;
}
inline void DFS(Node *x) {
  if(x->LS) DFS(x->LS);
  if(x->Cnt) PntStk[++Hd] = x, CntStack[Hd] = x->Cnt, Stack[Hd] = x->Value;
  if(x->RS) DFS(x->RS);
}
Node *Rebuild(Node *x) {
  Hd = 0, DFS(x);
  return Build(1, Hd);
}

刪除

先說刪除, 是因為替罪羊樹的刪除不同於基於旋轉的平衡樹, 因為不能旋轉, 因此不能合併, 所以一個點的刪除不能僅僅將它的兩個子樹合併後插入這個點原來的位置.

為了避免破壞樹的結構, 我們用 \(Cnt\) 來表示這個節點對應元素出現的次數, 對於需要刪除的元素對應的節點, 如果這個元素存在至少一個, 將 \(Cnt\) 減少 \(1\), \(Size\) 也同樣變化.

當然, 為了防止有那種瘋狂刪除節點的資料, 我們需要在有效元素明顯小於節點數的時候重構這棵子樹. 實現起來就是維護兩個 \(Size\), 其中一個 \(Size\) 存這個子樹實際上有多少個元素, 另一個 \(RealSize\) 存這個子樹有多少節點.

對於 \(Size\), 我們在遞迴時維護即可, 因為重構不會改變 \(Size\) 大小.

對於 \(RealSize\), 刪除不會刪除節點, 所以刪除操作不會改變 \(RealSize\), 但是重構會刪除所有 \(Cnt\), 所以我們需要在重構之後重新計算重構之後的子樹的 \(RealSize\). 因為 \(RealSize\) 只在回溯到某個點的時候使用, 所以可以暫時不更新一個子樹的祖先的 \(RealSize\), 當需要用到 \(RealSize\) 值的時候, 這時的 \(RealSize\) 一定是從葉子回溯回來的, 也就是正確的.

注意這裡的幾個 \(Tmp\) 變數, 這是因為在重構之後, 子樹的根可能會改變, 根改變後, 就不能通過它的父親遞迴到正確的節點, 所以需要儲存 \(TmpN\) 這棵子樹原來的父親是誰, 存到 \(TmpNF\) 中, 然後記錄它是哪個兒子, \(TmpNT = 0\) 時, \(TmpN\)\(TmpNF\) 的左兒子, 反之是右兒子. 其餘 \(Tmp\) 就是在遞迴時暫存對於 \(x\) 而言的 \(TmpNF\)\(TmpNT\) 的, 用全域性變數和區域性變數結合避免了遞迴傳參, 優化了常數.

void Delete(Node *x) {
  register Node *TmpofTmp(TmpF);
  register char TmpofTmpTg(TmpTg);
  TmpF = x;
  if(x->Value == B) {if(x->Cnt) --(x->Cnt), --(x->Size);}
  else  if(x->Value > B) {if (x->LS) TmpTg = 0, x->Size -= x->LS->Size, Delete(x->LS), x->Size += x->LS->Size;}
        else if (x->RS) TmpTg = 1, x->Size -= x->RS->Size, Delete(x->RS), x->Size += x->RS->Size;
  x->RealSize = 1;
  if(x->LS) x->RealSize += x->LS->RealSize; 
  if(x->RS) x->RealSize += x->RS->RealSize; 
  if(x->RealSize > 3 && x->Size) {
    if((!(x->LS)) || (!(x->RS))) {TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;return;}
    if((x->LS->Size * 2 < x->RS->Size) || (x->RS->Size * 2 < x->LS->Size)) {TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;return;}
    if(x->RealSize * 3 > x->Size * 4) TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;
  }
}

插入

插入操作和刪除的不同是: 一定成功. 不像刪除操作可能沒有對應元素導致無法刪除.

所以對於任何遞迴到的節點, \(Size\) 一定會增加 \(1\), 所以所有經過的點, \(Size\) 先增加再說.

\(RealSize\) 則不然, 所以需要遞迴時維護 \(RealSize\), 回溯時判斷是否重構.

void Insert(Node *x) {
  ++(x->Size);
  register Node *TmpofTmp(TmpF);
  register char TmpofTmpTg(TmpTg);
  TmpF = x;
  if(x->Value == B) {++(x->Cnt);}
  else {
    if(x->Value < B) {
      if(!(x->RS)) ++(x->RealSize), x->RS = ++CntN, x->RS->Value = B, x->RS->Cnt = x->RS->RealSize = x->RS->Size = 1;
      else TmpTg = 1, x->RealSize -= x->RS->RealSize, Insert(x->RS), x->RealSize += x->RS->RealSize;
    } else {
      if(!(x->LS)) ++(x->RealSize), x->LS = ++CntN, x->LS->Value = B, x->LS->Cnt = x->LS->RealSize = x->LS->Size = 1;
      else TmpTg = 0, x->RealSize -= x->LS->RealSize, Insert(x->LS), x->RealSize += x->LS->RealSize;
    }
  }
  x->RealSize = 1;
  if(x->LS) x->RealSize += x->LS->RealSize; 
  if(x->RS) x->RealSize += x->RS->RealSize;
  if(x->RealSize > 3 && x->Size) {
    if((!(x->LS)) || (!(x->RS))) {TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;return;}
    if((x->LS->Size * 2 < x->RS->Size) || (x->RS->Size * 2 < x->LS->Size)) TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;
    if(x->RealSize * 3 > x->Size * 4) TmpN = x, TmpNF = TmpofTmp, TmpNT = TmpofTmpTg;
  }
}

查排名

查排名和一般的 BST 是一樣的, 沒有什麼特點.

唯一的細節是小心當前節點的 \(Cnt\)\(0\) 的情況, 這是在其他 BST 中不會出現的 (貌似不判斷也不會出錯).

void Rank (Node *x) {
  if(x->LS) if(x->Value > B) return Rank(x->LS); else Ans += x->LS->Size;
  if(x->Cnt) if(x->Value < B) Ans += x->Cnt;
  if(x->RS) return Rank(x->RS);
}

查第 \(k\)

這時的 \(Cnt = 0\) 貌似也不影響正確性, 和一般的 BST 一樣, 按部就班查詢即可.

void Find(Node *x) {
  if(x->LS) if(x->LS->Size >= B) return Find(x->LS); else B -= x->LS->Size;
  if(x->Cnt) if(x->Cnt >= B) {Ans = x->Value; return;} else B -= x->Cnt;
  if(x->RS) return Find(x->RS);
}

查前驅

未曾設想的道路: 查詢 \(x\) 的前驅, 需要先求 \(x\) 的排名 \(r\), 然後查詢第 \(r - 1\) 大數的即可, 不需要單獨寫一個函式.

查後繼

想都不敢想的道路: 查詢 \(x + 1\) 的排名 \(r\), 然後查詢第 \(r\) 大的數即可.

主函式

這裡主要是注意用 \(TmpN\), \(TmpNF\), \(TmpNT\) 重構替罪羊, 並且將重構之後的替罪羊接回原樹對應的位置. 對於根做替罪羊的情況, 將根的指標指向新的根即可; 其餘情況, 則是根據 \(TmpNT\), 將新的子樹根連線到 \(TmpNF\) 的對應兒子處. 至於不更新 \(Size\)\(RealSize\) 的原因, 在刪除的部分已經解釋過了.

unsigned Hd(0), m, n, A;
int Ans, B, Stack[100005], CntStack[100005];
char TmpNT(0), TmpTg(0);
int main() {
  n = RD(), N[0].Value = 0x3f3f3f3f, N[0].Size = 1;  
  for (register unsigned i(1); i <= n; ++i) {
    A = RD(), B = RDsg();
    switch(A) {
      case 1:{
        TmpN = NULL, Insert(Root);
        if(TmpN) {
          if(TmpN == Root) {Root = Rebuild(TmpN);break;}
          if(TmpNT) TmpNF->RS = Rebuild(TmpN);
          else TmpNF->LS = Rebuild(TmpN);
        }
        break;
      }
      case 2:{
        TmpN = NULL, Delete(Root);
        if(TmpN) {
          if(TmpN == Root) {Root = Rebuild(TmpN);break;}
          if(TmpNT) TmpNF->RS = Rebuild(TmpN);
          else TmpNF->LS = Rebuild(TmpN);
        }
        break;
      }
      case 3:{Ans = 1, Rank(Root);break;}
      case 4:{Find(Root);break;}
      case 5:{Ans = 0, Rank(Root), B = Ans, Find(Root);break;}
      case 6:{++B, Ans = 1, Rank(Root), B = Ans, Find(Root);break;}
    }
    if(A >= 3) printf("%d\n", Ans);
  }
  return Wild_Donkey;
}

複雜度

首先假設不會有元素相同, 因為有元素相同只能在元素數量相同的情況下使節點更少, 顯然不會使複雜度更劣, 所以考慮更壞的情況, 即元素各不相同.

然後假設對極度不平衡的定義是左子樹比右子樹的兩倍還要大, 或右子樹比左子樹的兩倍還要大.

對於一棵子樹需要重構的, 子樹節點規模為 \(k\) 的替罪羊, 它從上一次被重構, 到這一次, 至少會有 \(\frac k3\) 次插入或刪除操作使得它變得極度不平衡. 也就是說一次 \(O(k)\) 的操作出現, 必定是 \(O(\frac{klogk}3)\) 的操作的結果.

另一種重構的情況是一棵子樹的元素數量小於節點數的 \(\frac 34\), 則這棵子樹從上一次重構到這時至少進行了 \(\frac k4\) 次刪除操作. 也就是說一次 \(O(k)\) 的操作的出現, 必定是 \(O(\frac{klogk}4)\) 次操作的結果.

這兩種情況的分析證明了在 \(logk > 4\) 的時候, 重構操作並不影響時間複雜度. 而對於 \(logk < 4\) 的時候, 即使每次操作都重構, 單次操作的複雜度也是 \(O(logn + 16)\), 同樣不影響複雜度.