<CMU 15-445> project 0: C++ Primer 实现思路

270 阅读4分钟

Task 1: copy-on-write trie

实现一个基于 copy-on-write 的 trie(前缀树),存储 key-value 数据。

储存 value 的节点为值节点,其他为非值节点。需要注意的是值节点可以是叶子节点,也可以是非叶节点,这是处理节点的关键点。

值节点类型为 TrieNodeWithValue,继承自 TrieNode。

底层原理

当需要对一个节点进行修改操作时,不直接对原节点上的数据进行操作,而是复制一个与原节点相同的节点,然后在新节点上进行修改操作。每次修改都会创建一个新的 trie,对于不涉及修改的节点,在新 trie 中会进行保留。

get 操作

获取操作比较简单,向下搜索即可。
1、遍历 key 所有字符,从根节点开始向下搜索节点;
2、找到节点时,需要判断是不是值节点,可以通过动态类型转化,如果返回 nullptr,表示其为非值节点。

for (char ch : key) {
  ...
}
// 顺利遍历完所有字符,表示 trie 存在前缀 key
const TrieNode *node = shared_ptr_node.get();
auto *value_node = dynamic_cast<const TrieNodeWithValue<T> *>(node);

put 操作

新增操作需要注意的地方比较多,在向下探索节点时,需要根据情况复制原节点或者创建新节点。
1、遍历 key 所有字符,首先,需要对途径的节点进行复制操作;其次,如果节点不存在,需要创建新节点;
2、走到最后一个节点时,无论节点存不存在,都需要创建新的值节点。

for (size_t i = 0; i < key.size(); i++) {
  char ch = key[i];
  if (shared_ptr_node->children_.count(ch) > 0) {
    ...
    if (i == key.size() - 1) {
      // 最后一个节点存在,创建一个新的值节点
      // make_shared<TrieNodeWithValue<T>>()
        ...
    } else {
      // 对途径的节点进行复制,调用节点 Clone函数
      // shared_ptr<TrieNode>(node->Clone())
      ...
    }
  } else if (i == key.size() - 1) {
    // 最后一个节点不存在,创建一个新的值节点
    // make_shared<TrieNodeWithValue<T>>()
  } else {
    // 节点不存在,不是最后一个节点,创建非值节点
    // make_shared<TrieNode>()
    ...
  }
}

关键点
a. put 操作需要支持 key = "";
b. 创建最后一个节点时,如果原节点是存在的,需要考虑它的子节点指针;
c. 节点的子节点指针是 const,在遍历节点时,需要将当前节点指针指向新的节点才能进行更改。

remove 操作

由于 remove 操作需要递归删除子节点为空的节点,所以在向下过程中要保存父节点,再向上依次删除节点。
1、遍历 key 所有字符,从根节点开始向下搜索节点,同时保存父节点;
2、找到节点是值节点,如果它是叶子节点,直接删除,否则,创建一个非值节点,继承原节点的子节点指针;
3、向上递归父节点,将子节点数为 0 的非值父节点删除,剩下的父节点进行复制。
注:这里的删除是指对于原节点不进行复制的意思,并不是对原节点进行删除。

for (char ch : key) {
  ...
  // 保存父节点
}
// 顺利遍历完所有字符,表示 trie 存在前缀 key
// 判断是不是值节点
if (shared_ptr_node->is_value_node_) {
  // 判断是不是叶节点
  ...
  while (!parents.empty()) {
    ...
    if (shared_ptr_node != nullptr) {
      // 子节点未被删除,复制父节点
    } else if (parent->children_.size() - 1 > 0 || parent->is_value_node_) {
      // 父节点是值节点或者子节点数为 0,复制父节点
    }
    // 子节点数为 0,删除(跳过父节点,不进行复制)
  }
}

Task2: 并发存储

每次对 trie 的增删操作,都会返回一个新的 trie,这样使得对 trie 的写操作不会影响到 trie 的读取,可以方便实现并发控制。

// 该类用于实现并发 trie
class TrieStore {
  ...
  // 存储当前 trie
  Trie root_;
  // 保护 root_
  std::mutex root_lock_;
  // 写操作需要互斥
  std::mutex write_lock_;
}

读操作

读取需要先获取当前的 trie,表示 trie 被引用,trie 所分配的节点不会被释放,之后可以安全进行读取。
在获取到 trie 后就可以释放锁了,如果在持有锁时在 trie 中进行 get 操作,将阻塞其他线程读取影响读取性能。

root_lock_.lock();
auto root = root_;
root_lock_.unlock();
// get value

写操作

写操作需要互斥,每次都需要获取写锁,防止同时创建多个 trie 造成混乱。
写操作完成后,需要保存新 trie 为当前 trie,之后的读取将读取新 trie。

write_lock_.lock();
auto new_root = root_.Put<T>(key, std::move(value));
root_lock_.lock();
root_ = new_root;
root_lock_.unlock();
write_lock_.unlock();

性能分析
优点:在读多写少的场景,整体并发性较好。
缺点:比较消耗内存,写频繁场景下会出现大量拷贝操作,对性能影响较大。