淺談可持久化Trie與線段樹的原理以及實現
引言
當我們需要儲存一個數據結構不同時間的每個版本,最樸素的方法就是每個時間都建立一個獨立的資料結構,單獨儲存。
但是這種方法不僅每次複製新的資料結構需要時間,空間上也受不了儲存這麼多版本的資料結構。
然而有一種叫git的工具,可以維護工程程式碼的各個版本,而空間上也不至於十分爆炸。怎麼做到呢?
答案是版本分支,即每次建立新的版本不完全複製老的資料結構,而是在老的資料結構上加入不同版本的分支。
下面以連結串列為例
A-->B
B-->C
C-->D
D-->E
E-->F
F-->G
B-->Z[C_新版本]
Z-->Y[D_新版本]
Y-->E
新的版本是部分建立在老的版本之上的。不變的地方不變,有編號的地方就加入新版本的分支。
實現可持久化Trie
基於版本分支的思想,我們怎麼建立一個可持久化Trie呢?
其次我們注意到在上面那張連結串列圖中進入下一個節點的時候,每次都要判斷有沒有我們要進入的新版本的分支,十分麻煩。有沒有方法可以保證我們向下找的節點全部是我們要的版本呢?
有方法,我們只需要記錄修改過的Trie的關鍵路徑就好了。
什麼叫關鍵路徑呢?
這裡先假設我們只修改Trie上的一個節點。而所謂關鍵路徑就是從Trie的根節點到修改節點的路徑,我們只建立這路徑上的節點,其餘節點全部繼承一個老版本的節點。
如圖
ROOT-.c.->c
ROOT-.m.->m
c--a-->ca
ca--t-->cat
m--a-->ma
ma--p-->map
ROOT_NEW--c-->c;
ROOT_NEW--m-->m_NEW
m_NEW--a-->ma_NEW
ma_NEW--p-->map
ma_NEW--r-->mar_NEW
mar_NEW--k-->mark_NEW
這是一個向有{cat,map}的Trie裡插入mark的新單詞的例子。
不難發現,在ROOT_NEW可以到達的節點構成的樹中,凡是不在mark這個單詞的路徑上的節點統統用的是老版本樹的節點。
程式碼實現
由於可持久化Trie不是我們的主題,程式碼就不放了
程式碼很簡單,就不多做解釋了
#include <iostream>
#include <string>
using namespace std;
const int N = 1e1 + 128;
int his[128];
int h;
int to[N][26];
int p;
void insert(string &s)
{
int old = p; //老樹的節點
his[++h] = ++p;
int now = p; //新樹的
for (auto i : s)
{
for (int j = 0; j < 26; j++)
if (i - 'A' != j) //非關鍵路徑上的節點繼承老樹
to[now][j] = to[old][j];
to[now][i - 'A'] = ++p; //關鍵路徑上的節點就新建
now = p;
old = to[now][i - 'A']; //老樹也要跟下去
}
return;
}
bool ask(int h, string &s) //詢問某個版本的Trie裡,是否有對應的單詞
{
int now = his[h];
for (auto i : s)
{
if (to[now][i - 'A'] == 0)
return false;
now = to[now][i - 'A'];
}
return true;
}
int main()
{
int opt;
while (cin >> opt)
{
if (opt == 1) //插入
{
string str;
cin >> str;
insert(str);
}
else
{
string str;
int h;
cin >> str >> h;
cout << ((ask(h, str)) ? 'Y' : 'N') << endl;
}
}
return 0;
}
可持久化線段樹
終於到了我們的主題了。可持久化線段樹顧名思義就是可持久化的線段樹
存在的意義首先是滿足部分線段樹的要求,然後也能根據線段樹的特性解決一部分可持久化Trie的弊端。
聰明的小夥伴可以發現在一個Trie中,我們要把一條完整的子鏈完全複製下來,如果我們老版本的Trie本來就是一條鏈,這種操作無異於把Trie重新複製一遍,還是相當慢,怎麼辦?
在維護一個Trie的時候,這種問題可能會讓人頭疼。但是線段樹可以完全避免,因為線段樹的樹高是完全有限的[\(Log (n)\)級別]。
基本思路還是隻記錄修改過的關鍵路徑,不在關鍵路徑上的節點繼承老版本的子樹。
基本原理還是和可持久化Trie差不多,看圖和程式碼基本也能理解了
A([1->4]).->B([1->2])
A.->C([3->4])
B-->D([1])
B-->E([2])
C.->F([3])
C.->G([4])
H([1->4_new])-->B
H-->I([3->4_new])
I-->F
I-->J([4_new])
程式碼實現
有一點要注意的是,這個線段樹的節點關係已經是一個有向圖了,不能用滿二叉樹的性質去計算他的左右兒子。需要手動記錄。
其實就是P3919 【模板】可持久化線段樹 1(可持久化陣列)的題解
#include <iostream>
using namespace std;
const int N = 5e7 + 128;
int num[N / 10];
int his[N / 10];
int h_ptr;
int lc[N], rc[N], val[N];
int p;
int n, m;
void build(int u, int l, int r) //和常規的線段樹建立差不多,就是要左右兒子不能用滿二叉樹性質算出來了,所以要手動存
{
if (l == r)
{
val[u] = num[l];
return;
}
lc[u] = ++p;
rc[u] = ++p;
int mid = (l + r) >> 1;
build(lc[u], l, mid);
build(rc[u], mid + 1, r);
}
void fork_only(int h) //僅僅只複製一個歷史版本
{
his[h_ptr++] = his[h];
}
void fork_and_edit(int h, int addr, int val_) //複製一個帶修改的版本為h的歷史版本到最新版本中(把addr這裡的數修改為val)
{
int old = his[h];
int now = ++p;
his[h_ptr++] = p;
int l = 1, r = n;
while (l < r)
{
int mid = (l + r) >> 1;
if (addr <= mid) //關鍵路徑在左兒子
{
rc[now] = rc[old]; //所以右兒子直接繼承
lc[now] = ++p; //新建一個左兒子
now = lc[now];
old = lc[old];
r = mid;
}
else
{
lc[now] = lc[old]; //反之繼承左兒子
rc[now] = ++p;
now = rc[now];
old = rc[old];
l = mid + 1;
}
}
val[now] = val_;
return;
}
int query(int u, int addr)
{
int l = 1, r = n;
while (l < r)
{
int mid = (l + r) >> 1;
if (addr <= mid)
u = lc[u], r = mid;
else
u = rc[u], l = mid + 1;
}
return val[u];
}
int main()
{
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
cin >> n >> m;
for (int i = 1; i <= n; i++)
cin >> num[i];
his[h_ptr++] = ++p;
build(p, 1, n);
for (int i = 1; i <= m; i++)
{
int v, opt, loc;
cin >> v >> opt >> loc;
if (opt == 1)
{
int value;
cin >> value;
fork_and_edit(v, loc, value);
}
else
{
cout << query(his[v], loc) << endl;
fork_only(v);
}
}
return 0;
}
如果對程式碼有問題歡迎評論斧正。