KD-Tree|KD-Tree 算法的 C++ 实现

阅读本文前,建议查阅相关资料,了解 KNN 算法与 KD 树。
基础知识 【KD-Tree|KD-Tree 算法的 C++ 实现】如图所示,假设一个点 a 目前的最近邻点为 b,如果存在相对于 ba 更近的点,那么这个点一定在以 a 为圆心,ab 为半径的圆内。
现右侧的区域是未知的,如果 a 到分界线的距离 l 大于目前的最近距离 L(圆半径),则没有必要在右侧的未知区域继续寻找最近邻点(如图一),反之,则要继续寻找(如图二)。
相应的,投射到多维空间,假如切分边界为第 i 维,切分点的值为 v(标量),当前最近邻点为 y(向量),如果目标点 x(向量) 到切分边界的距离 |x[i] - v| 满足以下关系

KD-Tree|KD-Tree 算法的 C++ 实现
文章图片

时,需要在另一侧继续搜索。
KD-Tree|KD-Tree 算法的 C++ 实现
文章图片
图1:不需要在右侧未知区域继续搜索的情况
KD-Tree|KD-Tree 算法的 C++ 实现
文章图片
图2:需要在右侧未知区域继续搜索的情况
通常地,一个机器学习算法分为 fitpredict 两个阶段,基于线性搜索的 KNN 是一种惰性算法,它将全部的计算任务放到了 predict 阶段, predict 的时间复杂度为 O(n),KD 树之所以比线性搜索快,就是因为它将一部分任务放到了 fit(建立 KD 树) 阶段,从而在搜索时可以略去大量不必搜索的结点(最优情况下时间复杂度为 O(1))。
上面说的比较简单,关于 KNN 算法和 KD 树的详细内容,请参考李航博士的《统计学习方法》。
代码 我们给出部分关键性的代码。
基本数据结构

  • 训练集用一个一维数组 double *data 表示,它的长度为 n_samples * n_features,标签集也用一个一维数组 double *labels 表示,它的长度为 n_samples
  • 树的结点用以下数据结构表示
    struct tree_node { size_t id; // 表示训练集中的第 i 个数据 size_t split; // 切分的维度 tree_node *left, *right; // 左、右子树 };

  • 一个 KD 树的模型可用以下结构表示
    struct tree_model { tree_node *root; // 根结点 const double *datas; // X const double *labels; // y size_t n_samples; // 样例数 size_t n_features; // 每个样例的特征数 double p; // 距离度量 };

  • 求 K-近邻时需要用到大顶堆,我们直接用 C++ 的优先队列来表示,堆内现有的 n(n <= k) 个近邻点中,距离测试点最远的在堆顶
    struct neighbor_heap_cmp { bool operator()(const std::tuple &i, const std::tuple &j) { return std::get<1>(i) < std::get<1>(j); } }; typedef std::tuple neighbor; typedef std::priority_queue, neighbor_heap_cmp> neighbor_heap_; neighbor_heap k_neighbor_heap_;

KD-Tree 类
我们用类 KDTree 表示一个 KD 树类,它应该具有的功能有建树搜索
//(简化的代码,完整的代码详见最后) class KDTree { public: // 建树 KDTree(const double *datas, const double *labels, size_t rows, size_t cols, double p) // 返回树 tree_node *GetRoot() { return root; } // 求一个测试点的 k 邻 std::vector> FindKNearests(const double *coor, size_t k); private: tree_node *root_; }

寻找切分维和切分点
在建树之前,我们还要考虑如何选择切分维度和切分点。切分维度的选择有许多,一般的,可以取 dim = floor % n_features,即当前树的层数对特征数取余,我们在这里使用 dim = argmax(nmax - nmin),即选取当前结点集合中极差最大的维度。
(这里是不完整的代码,有些工具函数的定义请详见完整源代码) size_t KDTree::FindSplitDim(const std::vector &points) { if (points.size() == 1) return 0; size_t cur_best_dim = 0; double cur_largest_spread = -1; double cur_min_val; double cur_max_val; for (size_t dim = 0; dim < n_features; ++dim) { cur_min_val = GetDimVal(points[0], dim); cur_max_val = GetDimVal(points[0], dim); for (const auto &id : points) { if (GetDimVal(id, dim) > cur_max_val) cur_max_val = GetDimVal(id, dim); else if (GetDimVal(id, dim) < cur_min_val) cur_min_val = GetDimVal(id, dim); }if (cur_max_val - cur_min_val > cur_largest_spread) { cur_largest_spread = cur_max_val - cur_min_val; cur_best_dim = dim; } } return cur_best_dim; }

选择完切分维 k 之后,我们需选取当前结点集合中的结点在第 k 维的值的中位数 x 作为切分点的值,除去该点之外的点,第 k 维的值小于等于 x 的,放入左子树,反之放入右子树。
在求中位数时,不要全排序,然后取中间的点,可以采用类似快排的方法,找到中位数时就停止排序,这里我们就不写算法了,直接用 C++ 的函数。
std::tuple KDTree::MidElement(const std::vector &points, size_t dim) { size_t len = points.size(); for (size_t i = 0; i < points.size(); ++i) get_mid_buf_[i] = std::make_tuple(points[i], GetDimVal(points[i], dim)); std::nth_element(get_mid_buf_, get_mid_buf_ + len / 2, get_mid_buf_ + len, [](const std::tuple &i, const std::tuple &j) { return std::get<1>(i) < std::get<1>(j); }); return get_mid_buf_[len / 2]; }

建树
建树直接按照建立二叉树的方法即可
tree_node *KDTree::BuildTree(const std::vector &points) { size_t dim = FindSplitDim(points); std::tuple t = MidElement(points, dim); size_t arg_mid_val = std::get<0>(t); double mid_val = std::get<1>(t); tree_node *node = Malloc(tree_node, 1); node->left = nullptr; node->right = nullptr; node->id = arg_mid_val; node->split = dim; std::vector left, right; for (auto &i : points) { if (i == arg_mid_val) continue; if (GetDimVal(i, dim) <= mid_val) left.emplace_back(i); else right.emplace_back(i); } if (!left.empty()) node->left = BuildTree(left); if (!right.empty()) node->right = BuildTree(right); return node; }

搜索 K-近邻的规则
一般书上所讲的都是搜索最近邻,但是我们这里是搜索 K-近邻,需要对书上的算法做少许的扩充。
搜索最近邻时,我们一般设置两个变量 cur_min_idcur_min_dist,如果当前搜索到的点到测试点的距离 l < cur_min_dist 时,我们将上述两个变量更新为新点的 iddist
相应的,在搜索 K-近邻时,我们可以设置一个最多有 k 个元素的大顶堆,这样,在搜索时,当堆满时,只需比较当前搜索点的 dist 是否小于堆顶点的 dist,如果小于,堆顶出堆,并将当前搜索点压入,反之,则不变;当堆未满时,直接将该搜索点压入。
搜索 K-近邻的算法
我们直接使用二叉树深度优先遍历的非递归算法(具体的描述详见《统计学习方法》第 43 页算法 3.3)。
std::vector> KDTree::FindKNearests(const double *coor, size_t k) { std::memset(visited_buf_, 0, sizeof(bool) * n_samples); std::stack paths; tree_node *p = root; while (p) { HeapStackPush(paths, p, coor, k); p = coor[p->split] <= GetDimVal(p->id, p->split) ? p = p->left : p = p->right; } while (!paths.empty()) { p = paths.top(); paths.pop(); if (!p->left && !p->right) continue; if (k_neighbor_heap_.size() < k) { if (p->left) HeapStackPush(paths, p->left, coor, k); if (p->right) HeapStackPush(paths, p->right, coor, k); } else { double node_split_val = GetDimVal(p->id, p->split); double coor_split_val = coor[p->split]; double heap_top_val = std::get<1>(k_neighbor_heap_.top()); if (coor_split_val > node_split_val) { if (p->right) HeapStackPush(paths, p->right, coor, k); if ((coor_split_val - node_split_val) < heap_top_val && p->left) HeapStackPush(paths, p->left, coor, k); } else { if (p->left) HeapStackPush(paths, p->left, coor, k); if ((node_split_val - coor_split_val) < heap_top_val && p->right) HeapStackPush(paths, p->right, coor, k); } } } std::vector> res; while (!k_neighbor_heap_.empty()) { res.emplace_back(k_neighbor_heap_.top()); k_neighbor_heap_.pop(); } return res; }

完整代码 详见 https://github.com/WiseDoge/libkdtree
完整代码中除了 KD-Tree 的代码外,还给出了测试代码和 Python 接口代码,以及一些调用第三方库来加速的手段。

    推荐阅读