算法 {持久化Trie树}
持久化Trie
定義
#背景#: 有2個操作{插入(字符串),查詢(字符串)} (這是一個標準TRIE樹的應用場景), 但是需要同時維護(版本信息) 就像一個Git系統一樣, 可以回退版本 也可以在某個版本的基礎上再進行插入操作;
.
顯然要實現一個(節點為一個TRIE樹)的Git系統, 你用暴力的方式 (每個節點 就是一個單獨的TRIE) 但這會超時, 而因為 TRIE樹的插入操作(插入一個長度為N的字符串) 他只會使用到(N+1)個節點, 即當前TRIE為A 往裡面插入一個字符串後 得到TRIE為B 那麼 B只有N+1
個節點 是與A不同的, 而B的其他所有節點的信息 和A的 是完全一樣的, 因此借用指針/引用的概念 我們可以讓B裡面的這些(與A相同的)節點 就指向A的節點 (兩者完全相同), 因此 AB這兩個TRIE 有如下性質: (本質結構相同 這點非常重要 不管動態開點這個性質 他倆都是(每個點有Branches
個分支)) 基於這個性質 比如說對於某個字符串C 他對應在A裡的節點為a 對應在B裡的節點為b, 那麼此時有2種情況: 要麼a=b
(這說明你的插入操作 不會訪問到這個節點 換句話說C不是你插入的字符串的前綴, 此時a/b
的值 可能是nullptr
即沒有開闢節點 也可能!=nullptr
雖然開闢了節點 但兩者的節點是同一個內存), 要麼a!=b
這說明 你B裡新插入的字符串 會訪問到這個節點(即C是你插入的字符串的前綴) 那麼此時a,b
兩者的內存一定不同(雖然他倆都代表C這個字符串 這就是前面講的本質結構是相同的);
#算法#: 每個節點 一定有一個int Son[ Branches];
他記錄 (當前節點的兒子的下標) 就相當於是指針(指向了他的兒子 -1
表示兒子還沒有開闢), 這種存儲方式的好處在於 你只要得到根節點root 就等價於 你得到了整個TRIE樹 (順藤摸瓜); 比如 此時的TRIE為A 他的根節點為RA
其版本號為VA
, 我要在他的基礎上 進行插入操作(插入字符串012
) 然後得到一個新版本VB
, 首先 申請一個新節點RB
(因為你插入的字符串 一定會訪問根節點 只要是會訪問到的節點 就要原版本的節點 不同) 執行Node[RB] = Node[RA]
(由於一個根節點 就代表了一整個TRIE樹, 因此你這個操作 意味著 此時RB
就代表了A這個TRIE!), 因為要插入012
這個字符串 然後你要移動到son = Node[ RB].Son[ 0]
此時有2種情況: {1(son!=-1
):[這說明 你要訪問的這個節點 在A裡是存在的(此時不同共用 你只要會訪問的節點 就必須和A的不同), 你需要開闢一個新節點new
然後讓Node[ new] = Node[son]
這個操作非常重要 這意味著(以new為根的樹 此時就是以son為根的樹) 最後讓Node[ RB].Son[0] = new
更改當前節點的這個兒子], 2(son=-1
):[此時就開闢一個新節點new
然後讓Node[ RB].Son[0] = new
即可 因為son在A裡而沒有子樹的 因此不需要向1:
一樣 做拷貝子樹的操作}; 然後繼續遍歷整個字符串 操作都是一樣的;
相關定義
#區間版本的查詢#
一般的查詢 即回退到某個版本R, 然後在R這個TRIE上進行查詢 這很簡單;
假設TRIE的修改操作只有插入, 假如說兩個版本L,R
且(L是R的祖先版本 即在L的基礎上 新添加了若干個字符串S後得到了R版本), 你需要求 新添加的這些字符串S的信息, 有點類似於前綴和思想, 我們舉個實際的例子:
字符串序列S[N]
, 對S[l...r]
這個區間的字符串 進行查詢(可以認為是 把這些S[l...r]
字符串 放到一個TRIE裡 進行查詢操作), 做法是: 令第i
版本的TRIE為S[...i]
即此時你有N個TRIE版本, 然後每個節點 存儲count
表示(以當前字符串為前綴的元素個數, 即你插入一個字符串"abc"
時 會執行"a".count++, "ab".count++, "abc".count++
), 那麼此時 假如你要查詢一個字符串"xyz"
在S[l+1, ..., r]
這些字符串裡 進行查詢, 令"x"
在L版本的TRIE裡的節點為LID 在R版本的TRIE裡為RID (這裡一定要聯繫的TRIE的(不同版本的TRIE樹的本質結構是相同的)這一性質) @IF(LID
是不存在的):[你只比較RID即可 即如果RID.count>0
說明"x"
是S[l+1 ...r]
裡的某個字符串的前綴 那麼可以繼續往下遍歷] @ELSE(LID
存在):[令c = RID.count - LID.count
這表示的 S[l+1 ...r]
裡面 有多少個字符串 他的前綴是等於"x"
的, 如果c>0
說明滿足條件 則可以繼續往下遍歷]; 這個count
屬性 本身也是有前綴和的性質的, 即對於"abcd"
你會令"a".count += 1
, 也就是 比如"a".count = 2
那麼說明有2個字符串 他的前綴是"a"
, 再根據 此時只有插入操作(沒有修改/刪除) 因此對於任何字符串 他在R
版本的count
值 一定是>=
其在L版本的count
值的, 因此 這兩個值 是可以進行相減的 (這裡的邏輯要搞清楚) 你讓"xxx"在R版本的count - "xxx"在L版本的count
這個值 就等於: S[l+1, ...,r]
這些字符串裡 有多少個 他的前綴是等於"xxx"
;
算法
以插入的每一個元素 作為新版本
性質
一個序列A, 第i
個版本的TRIE樹 存儲A[0...i]
這些數, 對於區間查詢A[l...r]
就對應為 第r
版本的TRIE樹RT 減去 第l-1
版本的TRIE樹LT, 這裡的減去 是指的RT與LT一一對應的節點信息的相減, 即我們構造一個TRIE樹T 他的節點x
的信息 等於RT的x
節點信息減去LT的x
節點信息, 那麼會有T等於存儲A[l...r]
這些數的TRIE樹; 當然你要保證 這個節點信息 是符合相減性質的;
@DELI;
代碼
template< class _TypeItem_, int _BranchesCount_>
class ___TrieTree_Persistent{
//<
// 一個TRIE樹(即某個版本的TRIE樹) 他對應與一個(字符串的集合), 換句話說 一個TRIE樹 和 一個字符串集合 是等價的;
// `_TypeItem_`: Trie樹所存儲的元素的數據類型, 比如`TypeItem=string` 那麼他的每個*字符*就對應Trie上的*節點*, 一個字符串的最大長度 決定了Trie樹的高度;
// `_BranchesCount_`: 每個點的分支個數;
public:
class __Node_{
public:
int SonId[ _BranchesCount_]; // `b=SonId[a]`: @IF(`b==-1`):[當前節點的第`a`個兒子暫未開闢], @ELES:[兒子為`Nodes[b]`];
int Count; // 對於某個TRIE樹(他對應的字符串集合為`S`), 對於任意字符串C 令其對應在TRIE的節點為`x` 則`x.Count`表示:[`S`中 前綴為`C`的字符串的個數];
//< 比如Trie裡只有一個"abc", 那麼`["a"/"ab"/"abc"].Count==1`;
__Node_(){ Initialize();}
void Initialize(){
memset( SonId, -1, sizeof( SonId)); Count = 0;
}
friend ostream& operator<<( ostream & _cout, const __Node_ & _a){
__Debug_list( _a.SonId, _a.Count, "\n");
return _cout;
}
};
vector<__Node_> Nodes; // `Nodes[a].SonId[b]=c`: `a`號節點的 第`b`個分支兒子的編號為`c` (如果`c=-1` 說明這個兒子節點還尚未存在);
//< 動態開點 當前已經使用了`[0,...,Nodes.size()-1]`這些節點;
vector<int> VersionRoot; // `VersionRoot[a]=b`: `a`號版本的Trie樹的根節點為`Nodes[b]`;
//< 當前已經有`[0,...,VersionRoot.size()-1]`這些版本;
vector<int> VersionFa; // `VersionFa[a]=b`: `a`號版本的父節點為`b`, 如果`b==-1` 說明`a`版本在Git樹中為*根節點*;
//< `VersionFa.size()=VerionRoot.size()`
void Initialize( int _nodesEstimateMaximum_, int _versionsEstimateMaximum){
//< `nodesEstimateMaximum`:[預估的總節點個數]; `versionsEstimateMaximum:[預估的總版本個數];
Nodes.reserve( _nodesEstimateMaximum_); Nodes.clear();
VersionRoot.reserve( _versionsEstimateMaximum); VersionRoot.clear();
VersionFa.reserve( _versionsEstimateMaximum); VersionFa.clear();
}
void Insert( pair<int,int> _flag, const _TypeItem_ & _str){
//< @IF(`flag.first=1`):[以`flag.second`號版本為父節點 創建一個新的版本(版本號為`VersionRoot.size()`)];
// @ELIF(`flag.first==-1`):[直接修改`flag.second`號版本 但要確保*這個版本一定是Git上的葉子節點* 即沒有`VersionFa[?]=flag.second`];
// @ELIF(`flag.first==0`):[直接創建一個*沒有父節點*的新版本(版本號為`VersionRoot.size()`)];
int curId; // 當前所操作Trie樹的根節點;
if( _flag.first == 1){ // 在父版本的基礎上 創建新版本
ASSERT_( IsInInterval_( _flag.second, 0, (int)VersionRoot.size()-1, true));
curId = Nodes.size();
Nodes.push_back( Nodes[ VersionRoot[ _flag.second]]); // 複製
VersionRoot.push_back( curId);
VersionFa.push_back( _flag.second);
}
else if( _flag.first == 0){ // 創建(無父節點的)新版本
curId = Nodes.size();
Nodes.emplace_back(); // 完全*空的*樹;
VersionRoot.push_back( curId);
VersionFa.push_back( -1);
}
else if( _flag.first == -1){ // 在已有版本上進行操作
ASSERT_( IsInInterval_( _flag.second, 0, (int)VersionRoot.size()-1, true));
ASSERT_MSG_( "確保不存在`VersionFa[?]==flag.second`, 即該版本是Git上的*葉子節點*;");
curId = VersionRoot[ _flag.second];
}
for( auto cur : _str){
int branchId = @TODO(`cur`對應的分支ID);
ASSERT_WEAK_( ::Tools::IsInInterval_( branchId, 0, _BranchesCount_-1, true));
if( Nodes[ curId].SonId[ branchId] == -1){ // 當前要訪問的節點不存在
Nodes[ curId].SonId[ branchId] = Nodes.size(); Nodes.emplace_back();
}
else{ // 當前要訪問的節點已經存在
if( _flag.first == 1){ // 父節點已經有當前這個節點了, 需要新拷貝一個節點
Nodes.push_back( Nodes[ Nodes[ curId].SonId[ branchId]]);
Nodes[ curId].SonId[ branchId] = (int)Nodes.size() - 1;
}
}
curId = Nodes[ curId].SonId[ branchId];
//>< `_str`的`[...cur]`這個前綴 對應在Trie樹中的節點編號為`curId`;
Nodes[ curId].Count ++;
}
//>< `_str` 對應在Trie樹中的節點編號為`curId`;
}
int Query( int _lv, int _rv, const _TypeItem_ & _str){
//< 令`T`為一個`TypeItem`的集合, 則當前函數是在`T`這個集合裡 對`_str`進行查詢, `T`的定義如下:
// @IF(`lv=-1`):[`rv`這個版本的Trie裡所有的`TypeItem`組成的集合 即為`T`];
// @ELSE(`lv!=-1`):[確保`lv`在Git樹中一定是`rv`的*祖節點*, 令`lv`版本的TRIE樹所對應的字符串集合為`SL` 令`rv`版本對應的字符串集合為`SR`, 則`T`等於差集`SR/SL`];
// . 注意 這裡的`lv,rv` 和前綴和思想很像 但有一點不同, 這裡是不包含`lv`的 即實際上你查詢的是`[lv+1, ..., rv]`這個區間 (即這些`(lv,rv]`版本所調用的修改操作);
ASSERT_( _lv>=-1 && _lv<=_rv && _rv>=0 && _rv<(int)VersionRoot.size());
int rightId = VersionRoot[ _rv];
int leftId;
if( _lv == -1){
leftId = -1;
}
else{
ASSERT_MSG_( "確保`lv`在Git樹中一定是`rv`的*祖節點*");
leftId = VersionRoot[ _lv];
}
for( auto cur : _str){
int branchId = @TODO(`cur`對應的分支ID);
ASSERT_WEAK_( ::Tools::IsInInterval_( branchId, 0, _BranchesCount_-1, true));
int count; // `T`裡面 前綴為`str[...cur]`的字符串的個數
if( Nodes[ rightId].SonId[ branchId] == -1){
count = 0;
}
else{
count = Nodes[ Nodes[rightId].SonId[branchId]].Count;
if( leftId != -1 && Nodes[ leftId].SonId[ branchId] != -1){
count -= Nodes[ Nodes[leftId].SonId[branchId]].Count;
}
}
if( count > 0){
}
else{
//>< `T`集合是空的;
return;
}
ASSERT_( Nodes[ rightId].SonId[ branchId] != -1);
if( leftId != -1){ leftId = Nodes[ leftId].SonId[ branchId];}
rightId = Nodes[ rightId].SonId[ branchId];
//>< `_str`的`[...cur]`這個前綴 對應在Trie樹中的節點編號為`curId`;
}
//>< `_str` 對應在Trie樹中的節點編號為`curId`;
}
friend ostream& operator<<( ostream & _cout, const ___TrieTree_Persistent & _tr){
__Debug_list( "\n", "*Trie-Debug-Begin*", "\n");
__Debug_list( _tr.VersionRoot, _tr.VersionFa, "\n");
FOR_( v, 0, (int)_tr.VersionRoot.size()-1){
function<void(int,string)> dfs = [&]( int _curId, string _str){
bool isLeaf = true;
for( int branchId = 0; branchId < _BranchesCount_; ++branchId){
if( _tr.Nodes[ _curId].SonId[ branchId] != -1){
isLeaf = false;
dfs( _tr.Nodes[ _curId].SonId[ branchId], _str + char('0' + branchId));
}
}
if( isLeaf){
__Debug_list( _str, "\n");
}
};
__Debug_list( "Version(" + to_string(v) + ")-Begin", "\n");
dfs( _tr.VersionRoot[ v], "");
__Debug_list( "Version(" + to_string(v) + ")-End", "\n");
}
__Debug_list( "*Trie-Debug-End*", "\n");
return _cout;
}
}; // class ___TrieTree_Persistent
___TrieTree_Persistent< int, 2> Tr;
Tr.Initialize( N*30, M);
FOR_( i, 0, N-1){
if( i == 0){ Tr.Insert( {0,1}, ?);}
else{ Tr.Insert( {1,i-1}, ?);}
}
性質
之前講過 對於只有(插入)操作的TRIE, 要判斷一個字符串/前綴 是否存在, 你可以直接通過Son[]
兒子的指針 來判斷其是否開闢 也就意味著 他是否存在;
但是在(區間版本的查詢)這裡, 你要涉及到2個版本之間的相減 因此就需要用到count
這個額外信息來維護 (表示某個前綴出現的次數), 對於某字符串S 用Version[R].count - Version[L].count
這個值 就代表了 從(L版本之後開始 到R版本結束) 這個階段 所新加的字符串集合裡面 前綴為S的字符串的個數;
例題
@LINK: https://editor.csdn.net/md/?not_checkout=1&articleId=133611997
;