最适合前缀匹配搜索的数据结构

Best suited data structure for prefix matching search

我必须创建一个客户列表系统(可以有 1000 万客户),每个客户都有一个唯一的 ID,一个唯一的 ID 由 10 个字母组成,前 3 个是大写字母,最后一个7 位是数字(例如:LQK0333208、HCK1646129、...)。系统必须以最快的方式执行两种搜索操作(完全匹配搜索和部分匹配搜索):

那么适合这个系统的数据结构是什么?目前,我正在使用AVL树来处理问题:

但我想进行部分匹配搜索,系统会在时间复杂度方面进行更好的搜索。 那么关于数据结构的任何建议是否适合系统的要求?

编辑 1: 我已经使用 Trie 树和三元搜索树测试了该程序,但针对的是更大的数据集,例如(1000 万客户)。我无法将内存中的数据结构存储在具有更大数据集的内存中。那么有什么建议吗?
编辑 2: 我已经测试了排序数组数据结构,它在 1000 万用户的数据集上运行良好。实际上,当我对 Trie 树或三元树一无所知时,这是我的第一个方法。据我了解,首先我们将所有客户存储在一个数组中,然后使用一些排序算法(如快速排序)对数组进行排序。然后进行二分查找查找key,也就是O(log(n))进行查找操作,相当不错!但是从长远来看,当我们需要向数组添加额外的数据(不是创建新数据,而是添加到数组)时,例如只需要多一个客户,那么添加新元素最坏情况下会花费 O(n)案例,因为我们需要找到添加和移动元素的位置。 但是对于像 Trie 或 Ternary 树这样的数据结构,在添加新元素时,它可能只需要 O(1),因为我们只需要遍历树来找到字符串。如果我们不介意 space 的复杂性,我认为 trie 或三叉树最适合这个项目。

一个合适的数据结构是trie。这是一棵所有前缀的树,其中每个节点(根节点除外)代表一个字符,从根节点到叶子节点的每条可能路径都是一个字符序列,对应一个有效的ID。

部分匹配意味着存在从根到内部节点的路径。

如果使用高效的 child 查找实现,则可以在 10 个步骤中找到此特定用例中的匹配项。因此,如果我们将 10 视为常数,则可以在常数时间内完成匹配,而不管树有多大(即)。这假设可以在恒定时间内(平均)通过字符查找 child。

因为在这个特定的用例中,字母表是有限的(仅限大写或仅限数字),一个节点最多可以有 26 个 child 条目,这些条目可以存储在该大小的数组中,其中索引映射到相应的字符。这将确保从 parent 节点步进到相关 child 节点的时间恒定。或者,也可以使用哈希系统(而不是具有 26 个槽的数组)。

这是 JavaScript 中的演示实现(使用 object 表示 children,即“字典”):

class TrieNode {
    constructor(data=null) {
        this.children = {}; // Dictionary, <character, TrieNode>
        this.data = data; // Non-null when this node represents the end of a valid word
    }
    addWord(word, data) {
        let node = this; // the root of the tree
        for (let ch of word) {
            if (!(ch in node.children)) {
                node.children[ch] = new TrieNode(); 
            }
            node = node.children[ch]; // Walk down the tree
        }
        node.data = data;
    }
    *getAllData() { // This method returns an iterator over all data in this subtree
        if (this.data != null) yield this.data;
        // Recursively yield all data in the children's subtrees
        for (const child in this.children) yield* this.children[child].getAllData();
    }
    *find(prefix) { // This method returns an iterator over matches
        let node = this;
        // Find the node where this prefix ends:
        for (let ch of prefix) {
            if (!(ch in node.children)) return; // No matches
            node = node.children[ch];
        }
        // Yield all data in this subtree
        yield* node.getAllData();
    }
}

class Customer {
    constructor(id, name) {
        this.id = id;
        this.name = name;
    }
    toString() {
        return this.name + " (" + this.id + ")";
    }
}

// Demo
// Create some Customer data:
const database = [
    new Customer('LQK0333208', 'Hanna'),
    new Customer('LQK0333311', 'Bert'),
    new Customer('LQK0339999', 'Joline'),
    new Customer('HCK1646129', 'Sarah'),
    new Customer('HCK1646130', 'Pete'),
    new Customer('HCK1700012', 'Cristine')
];

// Build a trie for the database of customers
const trie = new TrieNode(); // The root node of the trie.
for (const customer of database) {
    trie.addWord(customer.id, customer);
}
// Make a few queries
console.log("query: LQK0333");
for (const customer of trie.find("LQK0333")) console.log("found: " + customer);
console.log("query: HCK16461");
for (const customer of trie.find("HCK16461")) console.log("found: " + customer);
console.log("query: LQK0339999");
for (const customer of trie.find("LQK0339999")) console.log("found: " + customer);
console.log("query: LQK09 should not yield results");
for (const customer of trie.find("LQK09")) console.log("found: " + customer);

排序数组

另一种方法是将客户记录存储在排序数组中。 JavaScript 没有这样的数据结构,但是 splice 在 JavaScript 中出奇地快,因此您可以通过在其排序位置插入新条目来维护排序顺序。二分查找可用于定位查找或插入条目的索引:

class SortedArray {
    constructor(keyField) {
        this.arr = [];
        this.keyField = keyField;
    }
    addObject(obj) {
        const i = this.indexOf(obj[this.keyField]);
        if (this.arr[i]?.[this.keyField] === obj[this.keyField]) throw "Duplicate not added";
        this.arr.splice(i, 0, obj);
    }
    *find(prefix) { // This method returns an iterator over matches
        for (let i = this.indexOf(prefix); i < this.arr.length; i++) {
            const obj = this.arr[i];
            if (!obj[this.keyField].startsWith(prefix)) return;
            yield obj;
        }
    }
    indexOf(key) {
        let low = 0, high = this.arr.length;
        while (low < high) {
            const mid = (low + high) >> 1;
            if (key === this.arr[mid][this.keyField]) return mid;
            if (key > this.arr[mid][this.keyField]) {
                low = mid + 1;
            } else {
                high = mid;
            }
        }
        return low;
    }
}

class Customer {
    constructor(id, name) {
        this.id = id;
        this.name = name;
    }
    toString() {
        return this.name + " (" + this.id + ")";
    }
}

const database = [
    new Customer('LQK0333208', 'Hanna'),
    new Customer('LQK0333311', 'Bert'),
    new Customer('LQK0339999', 'Joline'),
    new Customer('HCK1646129', 'Sarah'),
    new Customer('HCK1646130', 'Pete'),
    new Customer('HCK1700012', 'Cristine')
];

const arr = new SortedArray("id");
for (const customer of database) {
    arr.addObject(customer);
}
console.log("query: LQK0333");
for (const customer of arr.find("LQK0333")) console.log("found: " + customer);
console.log("query: HCK16461");
for (const customer of arr.find("HCK16461")) console.log("found: " + customer);
console.log("query: LQK0339999");
for (const customer of arr.find("LQK0339999")) console.log("found: " + customer);
console.log("query: LQK09 should not yield results");
for (const customer of arr.find("LQK09")) console.log("found: " + customer);