我打算跟着学完CMU15-445的秋季课程,网址为15445.courses.cs.cmu.edu/fall2025/
这是Projects 0的内容,要求我们使用C++实现一个概率数据结构Count-Min Sketch,用于实时估计每个对象出现的频率(次数)。
背景:假设咱是一名非常火爆的blog网站的管理员,现在我要用实时的方法统计每个IP地址出现的次数,由于网站流量非常大,传统的数据结构要么太慢,要么太耗内存,应该咋办?
解决方法:Count–Min Sketch(CM Sketch),一种概率数据结构,使用sublinear memory(就是个二维数组:))来对频率进行计数。它维护了一个二维计数器数组(初始值均为0),通过若干个(行数个)独立哈希种子的哈希函数来寻址。每次更新都会increment每行的一个cell,查询会返回最小的计数器。此外,CM Sketch是可合并的,即两个CM Sketch的统计是可以合并为一个。CM Sketch广泛应用于网络流量监控、流分析和数据库系统优化。
实现的重要提示:
- 哈希函数使用
common/util/hash_util.h中定义的,不要自行定义 - 测试中包含了并行测试,不过我们只需要保证
Insert(item)是并发安全的 - 测试中包含了一个性能测试,只有我们的实现的性能超过基准(严格的串行)的1.2才能通过测试,不要使用全局锁保护数据结构,那样会得到接近1的性能,想办法降低锁的细粒度
先来看看实验代码中给出的头文件count_min_sketch.h,
-
成员变量:
width_表示二维矩阵的列数,depth_表示二维矩阵的行数hash_functions_是一个存放哈希函数的函数vector,该函数输入的是一个KeyType类型的值,返回一个size_t类型的值HashFunction,该函数用于构造哈希函数+求余,即通过调用hash_functions_得到的是对应行的下标,而不是哈希值
private: /** Dimensions of the count-min sketch matrix */ uint32_t width_; // Number of buckets for each hash function uint32_t depth_; // Number of independent hash functions /** Pre-computed hash functions for each row */ std::vector<std::function<size_t(const KeyType &)>> hash_functions_; /** @fall2025 PLEASE DO NOT MODIFY THE FOLLOWING */ constexpr static size_t SEED_BASE = 15445; /** * @brief Seeded hash function generator * * @param seed Used for creating independent hash functions * @return A function that maps items to column indices */ inline auto HashFunction(size_t seed) -> std::function<size_t(const KeyType &)> { return [seed, this](const KeyType &item) -> size_t { auto h1 = std::hash<KeyType>{}(item); auto h2 = bustub::HashUtil::CombineHashes(seed, SEED_BASE); return bustub::HashUtil::CombineHashes(h1, h2) % width_; }; } -
成员函数:
- 这里删除默认构造函数、拷贝构造函数和拷贝赋值运算符是为了强制使用移动语义来管理对象,保证资源的安全性
- C++中的noexcept关键字用于指明一个函数在运行时不会抛出任何异常,有助于编译器进行优化,并且在某些标准库容器移动操作(如std::vector的move)。当移动构造函数/赋值操作被声明为noexcept时,会优先使用移动而不是拷贝。
template <typename KeyType> class CountMinSketch { public: // 构造函数 explicit CountMinSketch(uint32_t width, uint32_t depth); // 删除默认构造函数、拷贝构造函数和拷贝赋值运算符 CountMinSketch() = delete; CountMinSketch(const CountMinSketch &) = delete; auto operator=(const CountMinSketch &) -> CountMinSketch & = delete; // 移动构造函数和移动赋值运算符 CountMinSketch(CountMinSketch &&other) noexcept; auto operator=(CountMinSketch &&other) noexcept -> CountMinSketch &; // 插入元素到count-min sketch void Insert(const KeyType &item); // 获取item到近似频率 auto Count(const KeyType &item) const -> uint32_t; // 清除sketch matrix void Clear(); // 合并两个count-min sketch void Merge(const CountMinSketch<KeyType> &other); // 获取出现频率最高的k个item和频率 auto TopK(uint16_t k, const std::vector<KeyType> &candidates) -> std::vector<std::pair<KeyType, uint32_t>>;
为什么CM-Sketch可以实现实时的频率统计?
- 每插入一个item,都会对每行某个位置(由哈希函数决定)自增1,而在获取某个item的频率时则会取每行这些位置的值的最小者
- 首先,如果一个item出现过,每行对应位置必然+1,因此每行对应位置必然记录下了当前item出现的次数
- 当然其他item进行插入时可能与当前item的某些行冲突,导致这些行中统计的频率数不准确,CM-Sketch的假设是所有行都出现冲突的情况还是相对较低的,因此在获取频率时取的是所有对应行的最小值
- 需要注意,列数越大 + 行数越大 => 出错的概率越小,近似值越准确
我的实现:
-
添加成员变量:
count_matrix_,一个二维vector,存放统计信息mutex_,互斥锁,用于保证Insert操作时的count_matrix_的并发安全
/** @todo (student) can add their data structures that support count-min sketch operations */ /** * 这里没有使用指针(如uint32_t** 或 std::vector<uint32_t*>)来存储计数矩阵的原因如下: * 1. 安全性与易用性:直接使用std::vector<std::vector<uint32_t>>可以自动管理内存,避免手动new/delete带来的内存泄漏和悬垂指针等问题。 * 2. 简化生命周期管理:vector会自动在对象析构时释放所有资源,无需手动释放,代码更简洁健壮。 * 3. 支持拷贝/移动语义:vector天然支持拷贝和移动操作,方便CountMinSketch的拷贝构造、移动构造和赋值等操作。 * 4. 更好的异常安全:vector的操作具有强异常安全保证,异常发生时不会导致内存泄漏。 * 5. 性能优化:vector的内存布局更有利于CPU缓存友好,遍历和批量操作时性能更高。 * 6. 代码风格统一:现代C++推荐优先使用智能指针和容器类,减少原始指针的直接使用。 * 总结:使用vector嵌套vector而非指针,能让代码更安全、易维护、性能更优,是现代C++的最佳实践。 */ std::vector<std::vector<uint32_t>> count_matrix_; // 互斥锁,保护count_matrix_的线程安全 mutable std::mutex mutex_; -
构造函数:参数检测 + 初始化成员变量 + 构造哈希函数
template <typename KeyType> CountMinSketch<KeyType>::CountMinSketch(uint32_t width, uint32_t depth) : width_(width), depth_(depth) { /** @TODO(student) Implement this function! */ // 1. 检查不合理的参数 if (width == 0 || depth == 0) { throw std::invalid_argument("Width and depth must be greater than 0."); } // 2. 初始化成员变量 this->width_ = width; this->depth_ = depth; count_matrix_ = std::vector<std::vector<uint32_t>>(depth_, std::vector<uint32_t>(width_, 0)); /** @fall2025 PLEASE DO NOT MODIFY THE FOLLOWING */ // Initialize seeded hash functions // 3. 初始化depth_个哈希函数 hash_functions_.reserve(depth_); for (size_t i = 0; i < depth_; i++) { // 这个哈希函数会将item映射为每行的索引 hash_functions_.push_back(this->HashFunction(i)); } } -
移动构造函数:拷贝+move成员变量,
// 移动构造函数 template <typename KeyType> CountMinSketch<KeyType>::CountMinSketch(CountMinSketch &&other) noexcept : width_(other.width_), depth_(other.depth_) { /** @TODO(student) Implement this function! */ // 移动 count_matrix_ 的所有权 this->count_matrix_ = std::move(other.count_matrix_); // 确保count_matrix_的每一行长度正确 // 确保在移动构造函数中,count_matrix_的每一行都被调整为当前对象的width_宽度,多余的元素会被截断,不足的会补0。 // 这样做的目的是防止移动后行宽与新对象的width_不一致,保证数据结构的正确性和一致性。 for (auto &row : count_matrix_) { row.resize(width_, 0); // 第二个参数用于扩展时补0 } // 这里不能直接move other.hash_functions_,而必须重新生成hash_functions_,原因如下: // 1. hash_functions_中的每个哈希函数都捕获了当前对象的width_和depth_(通过this指针), // 如果直接move过来,lambda中的this仍然指向other对象,导致哈希行为错误甚至未定义行为。 // 2. 移动后本对象的width_和depth_已变,哈希函数必须基于新对象的参数重新生成,才能保证哈希分布正确。 // 3. 这样做可以确保CountMinSketch的哈希函数总是与其width_和depth_一致,避免潜在的bug。 hash_functions_.reserve(depth_); for (size_t i = 0; i < depth_; i++) { hash_functions_.push_back(this->HashFunction(i)); } // 重置 other 的状态 other.width_ = 0; other.depth_ = 0; } -
移动拷贝赋值函数:思路同上
// 移动赋值运算符 template <typename KeyType> auto CountMinSketch<KeyType>::operator=(CountMinSketch &&other) noexcept -> CountMinSketch & { /** @TODO(student) Implement this function! */ if (this == &other) { return *this; } // 移动资源 this->width_ = other.width_; this->depth_ = other.depth_; this->count_matrix_ = std::move(other.count_matrix_); // 确保count_matrix_的每一行长度正确 for (auto &row : count_matrix_) { row.resize(width_, 0); } // 重新生成hash_functions_,因为它们依赖于width_和depth_ hash_functions_.clear(); hash_functions_.reserve(depth_); for (size_t i = 0; i < depth_; i++) { hash_functions_.push_back(this->HashFunction(i)); } // 重置 other 的状态 other.width_ = 0; other.depth_ = 0; return *this; } -
Insert:使用每个哈希函数对item求哈希值并求余得到下标,然后让对应下标位置元素+1
template <typename KeyType> void CountMinSketch<KeyType>::Insert(const KeyType &item) { /** @TODO(student) Implement this function! */ // 1. 计算item的索引 for (uint32_t i = 0; i < depth_; i++) { uint32_t index = hash_functions_[i](item); // 尽可能降低锁的粒度 std::lock_guard<std::mutex> lock(mutex_); // 2. 将索引对应的计数器加1 (count_matrix_)[i][index]++; } // std::lock_guard自动管理互斥锁,无需显式解锁 } -
Merge:对应位置元素相加即可,必须保证两个矩阵width和depth相同
template <typename KeyType> void CountMinSketch<KeyType>::Merge(const CountMinSketch<KeyType> &other) { if (width_ != other.width_ || depth_ != other.depth_) { throw std::invalid_argument("Incompatible CountMinSketch dimensions for merge."); } /** @TODO(student) Implement this function! */ for (uint32_t i = 0; i < depth_; i++) { for (uint32_t j = 0; j < width_; j++) { (count_matrix_)[i][j] += (other.count_matrix_)[i][j]; } } } -
Count:一定要先检查数据结构的有效性,遍历访问每行的对应位置的值,取最小值
template <typename KeyType> auto CountMinSketch<KeyType>::Count(const KeyType &item) const -> uint32_t { // 检查数据结构是否有效 if (count_matrix_.empty() || count_matrix_.size() != depth_ || hash_functions_.size() != depth_) { return 0; } uint32_t min_count = UINT32_MAX; for (uint32_t i = 0; i < depth_; i++) { // 检查每一行的长度是否正确 if (count_matrix_[i].size() != width_) { // 如果长度不匹配,返回一个合理的默认值 return count_matrix_[i].empty() ? 0 : count_matrix_[i][0]; } size_t hash_index = hash_functions_[i](item); if (hash_index >= width_) { // 防止越界访问 continue; } min_count = std::min(min_count, count_matrix_[i][hash_index]); } return min_count == UINT32_MAX ? 0 : min_count; } -
Clear:这里的Clear只是将矩阵置0,不要动其他的(我一开始理解错了:()
template <typename KeyType> void CountMinSketch<KeyType>::Clear() { /** @TODO(student) Implement this function! */ // 只清空计数矩阵的内容,保持维度不变以支持后续的Merge操作 std::lock_guard<std::mutex> lock(mutex_); for (size_t i = 0; i < depth_; ++i) { for (size_t j = 0; j < width_; ++j) { count_matrix_[i][j] = 0; } } // 不重置width_和depth_,因为这些是结构性参数,应该保持不变 // 不清空hash_functions_,因为它们在后续操作中还需要使用 } -
TopK:遍历每个candidates,将其和频率加入vector,然后按频率非升序排序,取前k个即可
template <typename KeyType> auto CountMinSketch<KeyType>::TopK(uint16_t k, const std::vector<KeyType> &candidates) -> std::vector<std::pair<KeyType, uint32_t>> { /** @TODO(student) Implement this function! */ std::vector<std::pair<KeyType, uint32_t>> top_k; if (k == 0) { return top_k; } for (const auto &candidate : candidates) { uint32_t count = Count(candidate); top_k.emplace_back(candidate, count); } std::sort(top_k.begin(), top_k.end(), [](const auto &a, const auto &b) { return a.second > b.second; // 降序排序 }); // 保留前k个元素 top_k.resize(std::min(static_cast<size_t>(k), top_k.size())); return top_k; }
结语: 这个数据结构的思路和实现都很简单,但是我Go写久了,对于C++的语法不太熟了,花了点功夫复习C++的写法。之前一直弄不明白的C++中的移动语义,在学过Rust之后一切都通畅了,移动语义的作用在于通过赋值时直接转移数据的控制权来保证内存资源的安全性、避免深拷贝,提高性能。