1. 程式人生 > 其它 >【資料結構學習筆記】14:Trie樹

【資料結構學習筆記】14:Trie樹

技術標籤:資料結構Trie樹字典樹字串

1 簡述

Trie樹是能夠高效儲存和查詢字串集合的資料結構,比較適合字元種類較少的問題,例如如果字串中只可能出現 26 26 26種小寫字元,那麼Trie樹長成這樣:
在這裡插入圖片描述

也就是說Trie樹的樹根是一個虛擬結點,然後它會分叉出 26 26 26個結點,分別對應每個小寫字母,每個結點又分叉出 26 26 26個結點,這樣往下不斷展開。

這樣看起來Trie樹的空間是指數級別的,非常大,但是實際上Trie樹一開始只需要有一個虛擬的根節點,然後每次插入字串只需要把字串上的每個字元對應的結點建立好就行了,實際的空間佔用很小。

例如插入了 a c b acb

acb後的Trie樹:
在這裡插入圖片描述

接下來再插入一個字串 a c f acf acf,則發現 a c ac ac都是已經建立的結點了,這就是Trie樹的一個重要的節省空間的地方,可以共享已經建立的字首,所以插入後的Trie樹如下(其實就只需要在 a c ac ac這棵子樹上建立一個新的 f f f結點):
在這裡插入圖片描述

注意,由於共享字首也會帶來一些問題,比如光看上面的圖是沒法知道 a c ac ac這個字串到底有沒有存在的,因為它既可能是作為一個獨立的字串插入過的,也有可能僅僅是其它字串的字首。所以還需要在Trie樹的結點上打上標記,從根節點開始,到被標記的結點的路徑所代表的字串才是真正被插入到Trie樹裡的。這樣設計的Trie樹才是既能支援插入操作,又能支援查詢操作的:

在這裡插入圖片描述

具體在實現的時候,Trie樹既可以用傳統的結構體+指標的方式來實現,也可以用類似靜態連結串列的方式來實現(這種方式其實就是用陣列來模擬分配空間的思想,陣列下標其實就可以理解成給一個結點分配的地址,下標 0 0 0就是虛擬根節點)。

2 模板題:Trie字串統計

這題說明了輸入字元的總長度不超過 1 0 5 10^5 105,所以算上虛擬根節點一共最多也就是 1 0 5 + 1 10^5 + 1 105+1個結點,這個值對應著陣列至少應該開多大。

這裡用陣列模擬分配結點的方式,用son[N][26]來記錄每個結點的 26 26 26個孩子的地址(即在陣列中的下標)。下標 0 0 0表示虛擬根節點的下標,作為son的值出現時表示這個結點還沒分配,要把++idx

這個下標分配給它。

由於這題的查詢操作不僅要知道一個字串是否存在,還要知道字串出現的次數,所以這裡用int型別的陣列cnt[N]來在每個結點上記錄一個數,即表示從虛擬根節點出發,到這個結點為止,路徑所對應的字串出現的次數。

#include <iostream>

using namespace std;

const int N = 1e5 + 10;

// Trie樹:son[i][j]表示地址為i的結點的j+'a'孩子所在的地址
// son[i]表示地址為i的結點對應的字串出現的次數
// idx是地址分配點,由於0位置放虛擬根節點,所以從1開始分配
int son[N][26], cnt[N], idx;

// 在Trie樹中插入字串s
void insert(string s) {
    // 從虛擬根節點開始
    int p = 0;
    // 遍歷字串s的每個字元
    for (int i = 0; i < s.size(); i ++ ) {
        // 這個字元對應的子節點號(0~25)
        int j = s[i] - 'a';
        // 如果這個子節點沒建立
        if (son[p][j] == 0)
            son[p][j] = ++ idx; // 就建立它
        // 指標p向這個子節點位置走
        p = son[p][j];
    }
    // 至此,p指向字串的最後一個位置對應的結點
    // 在cnt陣列中記錄一下這個字串出現次數多了1
    cnt[p] ++ ;
}

// 在Trie樹中查詢字串s出現次數
int query(string s) {
    // 從虛擬根節點開始
    int p = 0;
    // 遍歷字串s的每個字元
    for (int i = 0; i < s.size(); i ++ ) {
        // 這個字元對應的子節點號(0~25)
        int j = s[i] - 'a';
        // 如果這個子節點沒建立,那麼查詢的字串s一定不存在,返回0
        if (son[p][j] == 0) return 0;
        // 否則向子節點位置走
        p = son[p][j];
    }
    // 至此,p指向字串的最後一個位置對應的結點
    // 在cnt陣列中記錄了這個字串出現次數,將其返回
    return cnt[p];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    int m;
    cin >> m;
    // 對每個操作
    while (m -- ) {
        // 讀入操作型別和字串
        char op;
        string s;
        cin >> op >> s;
        // 插入字串s
        if (op == 'I') {
            insert(s);
        }
        // 查詢字串s的插入次數
        else { // Q
            cout << query(s) << endl;
        }
    }
    return 0;
}

3 例題:最大異或對

這題是給出一堆 31 31 31位整數,問兩個數異或,最大的數是多少。可以想到,對於一個給定的數 x x x,期望與它的異或結果最大,那麼一定是從高位開始儘可能的異或出 1 1 1出來。

從第 30 30 30位到第 0 0 0位重要性是依次下降的,對於某一位,如果想讓異或結果是 1 1 1,那麼這一位就應該和 x x x的這一位相反。

也就是說,如果 x < < i x << i x<<i這一位是 1 1 1,那麼希望目標數 d d d的這一位,即 d < < i d << i d<<i儘可能是 0 0 0

這樣就可以把所有的數看作一個從高位到低位放置的字串,比如十進位制的 13 13 13的二進位制值從高位到低位寫夠 31 31 31位就是:
0000000 00000000 00000000 00001101 0000000~00000000~00000000~00001101 0000000000000000000000000001101

將所有的輸入的資料按照這個方式插入到Trie樹中(由於二進位制每一位只能是 0 0 0或者 1 1 1,所以這棵Trie樹每個結點只能有兩個孩子)。然後再遍歷一下每個數,對於每個數按照從高位到低位儘可能與之 0 / 1 0/1 0/1相反的方式到Trie樹裡查詢資料,最後找到的就是能和這個樹異或起來最大的數了。

但是實際上也不需要把這個數找到,再和 x x x異或,只需要在每一位判斷一下存不存在和輸入的 x x x的這一位 x < < i x << i x<<i 0 / 1 0/1 0/1相反的子節點。如果存在的話,那麼 r e s res res的這一位就是 1 1 1,並往這個方向走;如果不存在的話,那麼 r e s res res的這一位就是 0 0 0(本來就是 0 0 0,所以啥也不用做),並只能往和它 0 / 1 0/1 0/1相同的方向走。

在這題裡,由於每個數都是 31 31 31位的,所以不需要在每個結點記錄字串是不是存在了,因為只有走完這 31 31 31位的才是實際存進去的。

另外不需要擔心某一個結點既沒有 0 0 0方向的子節點也沒有 1 1 1方向的子節點,因為之前的每一步走法都是往已經存在的結點走的,在 31 31 31步走完之前一定有路可走,有路可走也就對應了已經存好的至少一個數。

#include <iostream>

using namespace std;

const int N = 1e5 + 10;

const int M = 31 * 1e5 + 10; // 31位數,一共1e5個數

// 存輸入的n個數,因為要全部插入後才能一個一個查詢
int a[N];

// 由於每個數都是31位,所以不需要記錄出現過沒有
// 只要遍歷完31位,就一定存在這個數了
int son[M][2], idx;

// 在字典樹裡插入一個31位整數x,從高位到低位看成一個0/1串
void insert(int x) {
    // 指標p從虛擬根結點開始
    int p = 0;
    // 遍歷這個0/1串的每一位
    for (int i = 30; i >= 0; i -- ) {
        // j記錄了這一位是0還是1
        int j = (x >> i) & 1;
        // 如果該子節點沒建立,就建立一下
        if (son[p][j] == 0) son[p][j] = ++ idx;
        // 往這個子節點方向走
        p = son[p][j];
    }
    // 這樣邊走,沒建立的結點就建立,做完之後就插好了
    // 這題由於串是固定長度31,所以不需要額外記錄這個位置有串存在
}

// 對於給定的數x,在字典樹裡找一個0/1串對應的數和x異或結果最大,並把異或結果返回
int search(int x) {
    // 指標p從虛擬根結點開始,異或答案res初始化為0
    int p = 0, res = 0;
    // 遍歷這個0/1串的每一位
    for (int i = 30; i >= 0; i -- ) {
        // j記錄了這一位是0還是1
        int j = (x >> i) & 1;
        // !j就是和這一位0/1相反的方向
        // 如果這個反方向子節點是存在的
        if (son[p][!j]) {
            // 那麼就要往這個方向走,異或結果res的第i位就是1
            res += 1 << i;
            p = son[p][!j];
        }
        // 否則,只能往通向走,異或結果res的第i位就是0
        else p = son[p][j];
    }
    return res;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);
    // 讀入n個數,並把從高位到低位的0/1串插入到字典樹裡
    int n;
    cin >> n;
    for (int i = 0; i < n; i ++ ) {
        cin >> a[i];
        insert(a[i]);
    }
    // 對每個數,到字典樹裡找最大異或結果,所有結果裡最大的就是答案
    int res = 0;
    for (int i = 0; i < n; i ++ ) {
        res = max(res, search(a[i]));
    }
    cout << res << endl;
    return 0;
}