C++模板实现通用二叉搜索树:集合与映射二合一

最近整理手头几个内部组件,发现凡是做索引、做去重、做有序集合,最后都绕不开同一棵树。本来想直接调库,但又需要一份不依赖外部框架的跨平台版本,于是把二叉搜索树从裸模板到支持 key-value 重新写了一遍。你要是有过类似经历,肯定能get到这类需求的尴尬:面试题里的二叉搜索树只存一个 int,但实际用的时候,要么需要判断某个 key 在不在集合里,要么需要根据 key 拿出对应的 value,甚至要同时支持这两种形态。

这篇文章就围绕这个核心矛盾展开。我会用一套 C++ 模板把 BST 设计成既能当“有序集合”用(只存 key),也能当“有序映射”用(存 key-value 对),重点讲清楚存储层怎么抽象、比较键怎么统一提取、插入删除有哪些坑。内容面向有 C++ 基础但没系统写过数据结构的读者,同时也会给出一份可以直接编译运行的代码骨架。

1. 先拆需求:这套树到底要给谁用

1.1 三种真实使用场景

第一类场景是白名单或者去重。最常见的就是维护一批用户 ID、设备序列号、字符串黑名单,只关心“某个 key 是否存在”,value 完全用不上。这时候树的节点里只存一个 key,相当于标准库里std::set做的事。

第二类场景是配置项映射、词频统计、缓存表这一类。除了需要判断 key 是否存在,还要从 key 找到对应的 value,比如从端口号找到进程句柄、从指令名字找到处理函数。这是std::map的职责。

第三类场景是有序区间查询。比如按时间戳取出 2024-03-01 到 2024-03-07 之间的所有记录,或者按分数范围筛出一批候选人。二叉搜索树的中序遍历天然有序,做这种范围扫描比哈希表顺滑得多。

这三类需求并不冲突,可以收敛到同一棵树上。问题是:怎么用一套模板类把“只存 key”和“存 key-value 对”两种模式统一起来,而不是复制粘贴两个差不多的类。

1.2 存储层设计的三个候选方案

方案一是节点直接存std::pair<Key, Value>,value 用不上的时候传一个空类型占位。优点是实现最简单,所有节点的结构都一样;缺点是空 value 也占了内存,接口层面还得暴露一个毫无意义的“value 字段”,相当别扭。

方案二是给BST<Key, Value>做模板特化,单独写一版BST<Key, void>。这样内存和接口都干净,但问题也很现实:两个特化版本需要各自维护插入、删除、查找、遍历一套代码,改一个小逻辑要同步改两处,漏改一处就会行为不一致。

方案三是我最后采用的思路:用std::conditional在编译期根据Value是不是void决定节点存储类型,然后用 C++17 的if constexpr在函数里区分不同逻辑。一套类,两套行为,存储和接口都干净。

方案存储开销接口清晰度代码复用标准要求
节点存pair<Key, Value>key-only 时浪费差最好C++98
模板特化两个版本无浪费好差C++98
std::conditional+if constexpr无浪费好好C++17

1.3 最终选型与版本说明

我选择方案三,原因只有一条:这一类树结构,算法骨架占大头,真正不同的只是“节点里存什么”。把这个差异点收口到一个类型别名和几个辅助函数里,后面的插入、查找、删除几乎可以原样复用。

所以本文代码默认 C++17。如果你还在用 C++11,最接近的替代做法是做两个特化类,或者写一个宏把公共部分摊开,但那就偏离今天的主题了。

2. 模板结构设计:一套代码兼容两种存储

2.1 用std::conditional决定存储类型

核心就一句话:让编译器当你的搬运营。模板签名写成template <typename Key, typename Value = void>,默认情况下它就是一棵只存 key 的树;需要 key-value 时传入具体的 value 类型。

内部先定义编译期常量:

static constexpr bool kIsVoidValue = std::is_void<Value>::value;

然后定义存储类型:

using Storage = typename std::conditional< kIsVoidValue, Key, std::pair<Key, Value> >::type;

std::conditional相当于一个编译期三元表达式:条件为真就用第一个类型,为假就用第二个类型。当Value是void时,Storage 就是Key,节点只存键;当Value是std::string之类时,Storage 就变成std::pair<int, std::string>,节点同时持有键和值。

这就解决了“支持 key 或 key-value”的第一层问题:存储结构本身随着模板参数自动切换。

2.2 节点定义与统一键提取

有了 Storage,节点可以这样定义:

struct Node { Storage data; Node* left = nullptr; Node* right = nullptr; explicit Node(const Storage& d) : data(d) {} };

光有节点还不够。插入、删除、查找、遍历,四处都要用到“从存储内容里提取 key 做比较”。如果每次都在代码里写if constexpr判断当前模式,逻辑会重复且容易出错。所以我把取 key 这件事收口成一个静态函数:

static const Key& key_of(const Storage& item) { if constexpr (kIsVoidValue) { return item; } else { return item.first; } }

这个函数是整棵树的“比较接口”。key-only 模式它原样返回元素本身,key-value 模式它取 pair 的 first 字段。后续所有比较逻辑都只跟key_of打交道,不再关心当前是哪种存储模式。

这也是我给所有写树类的人的第一条建议:先定好“键怎么从节点数据里取出来”,再写算法。这个接口一旦稳定,算法部分就不会被模板参数干扰。

2.3 比较器怎么设计

比较器我直接暴露成模板参数,默认用std::less<Key>:

template < typename Key, typename Value = void, typename Compare = std::less<Key> > class BST { ... };

为什么不用std::pair自带的比较?因为std::pair的operator<是先比 first 再比 second,如果我们让节点直接存 pair 并天然用 pair 做比较,会出现两个问题。

第一,同一个Key对应不同Value时,字典序比较可能误判,比如key相同而value不同的两个节点会被当成“大于”或“小于”,而不是“相等”。这会导致重复键无法正确合并。

第二,key-only 模式下根本没 value 可比较。所以把模板参数设计成 Key 和 Value 分离,而不是让用户传入一个整体对象再从中提取比较键,正是为了避免这种误用。

如果某个Key类型本身没有operator<,也没关系,调用者可以传入自定义仿函数:

struct CaseInsensitiveCompare { bool operator()(const std::string& a, const std::string& b) const { return strcasecmp(a.c_str(), b.c_str()) < 0; } }; BST<std::string, void, CaseInsensitiveCompare> words;

这样一来,“如何比较”和“存储什么”完全解耦,树的排序规则由调用方决定,内部逻辑完全不用动。

2.4 完整类骨架预览

把上面的决策串起来,类的大体框架就是这样:

#include <iostream> #include <functional> #include <type_traits> #include <utility> template < typename Key, typename Value = void, typename Compare = std::less<Key> > class BST { static constexpr bool kIsVoidValue = std::is_void<Value>::value; using Storage = typename std::conditional< kIsVoidValue, Key, std::pair<Key, Value> >::type; struct Node { Storage data; Node* left = nullptr; Node* right = nullptr; explicit Node(const Storage& d) : data(d) {} }; Node* root_ = nullptr; Compare cmp_; static const Key& key_of(const Storage& item) { if constexpr (kIsVoidValue) { return item; } else { return item.first; } } public: ~BST() { // 后续会给出非递归析构 } void insert(const Key& key) { ... } void insert(const Key& key, const Value& value) { ... } bool contains(const Key& key) const { ... } Value* findValue(const Key& key) { ... } void erase(const Key& key) { ... } void inOrder() const { ... } };

到这里,模板设计的核心已经完成了。接下来的插入、查找、删除,就是在这个存储抽象之上填肉。

3. 核心操作实现:插入、查找、删除

3.1 插入接口与重复键策略

插入分成两个对外接口。key-only 模式调insert(const Key&),key-value 模式调insert(const Key&, const Value&)。

void insert(const Key& key) { static_assert(kIsVoidValue, "仅 key-only 模式可调用单参数 insert"); Storage data = key; root_ = insertRecursive(root_, data); } void insert(const Key& key, const Value& value) { static_assert(!kIsVoidValue, "仅 key-value 模式可调用双参数 insert"); Storage data = std::make_pair(key, value); root_ = insertRecursive(root_, data); }

static_assert的作用是在编译期拦住错误调用。如果你拿一棵纯 key 树去当 map 用,编译器会直接报错,而不是等运行时崩。

真正的递归插入逻辑长这样:

Node* insertRecursive(Node* node, const Storage& data) { if (!node) { return new Node(data); } const Key& targetKey = key_of(data); const Key& nodeKey = key_of(node->data); if (cmp_(targetKey, nodeKey)) { node->left = insertRecursive(node->left, data); } else if (cmp_(nodeKey, targetKey)) { node->right = insertRecursive(node->right, data); } else { if constexpr (!kIsVoidValue) { node->data.second = data.second; } } return node; }

这段代码最有价值的地方是“父节点回填法”:递归函数返回的是“处理完后的子树根”,调用方把它接到自己的 left 或 right 上。你不需要在节点里保存 parent 指针,也不用在迭代循环里勤勤恳恳记录父节点,整个插入过程非常干净。

重复键的处理策略我默认是“新值覆盖旧值”。这更贴合 map 的语义。如果你想做成 multiset 那样的可重复 key,要么在节点里加一个count字段,要么把比较规则改为“相等时往右走”,这两条路都简单,看需求取用即可。

3.2 查找接口:一个查找,两种返回

查找我们拆成两个函数:contains返回 bool,用来回答“key 在不在”;findValue返回指针,用来回答“key 对应的 value 是什么”。

bool contains(const Key& key) const { Node* cur = root_; while (cur) { const Key& curKey = key_of(cur->data); if (cmp_(key, curKey)) { cur = cur->left; } else if (cmp_(curKey, key)) { cur = cur->right; } else { return true; } } return false; }

contains 实现为迭代,不涉及递归,走一趟从根到目标节点的路径就行。这里注意比较规则:先试key < curKey,再试curKey < key,两个都不成立才说明相等。这正是有序容器比较的通用写法,能正确处理任意满足严格弱序的比较器。

取 value 的版本:

Value* findValue(const Key& key) { static_assert(!kIsVoidValue, "key-only 模式没有 value 可查"); Node* cur = root_; while (cur) { const Key& curKey = key_of(cur->data); if (cmp_(key, curKey)) { cur = cur->left; } else if (cmp_(curKey, key)) { cur = cur->right; } else { return &(cur->data.second); } } return nullptr; }

返回裸指针而不是返回值,好处是既能判空,又能让调用方直接修改红内的 value。如果你不想暴露修改能力,可以再加一个 const 版本返回const Value*。实际项目里,我还会专门提供一个bool find(const Key& key, Value* out)版本,把“是否存在”和“带出结果”合并成一个调用,避免调用方写两遍查找。

3.3 删除操作三情况与后继替换

删除是二叉搜索树里最容易写错的部分,没有之一。递归删除函数的套路是:先按 key 找到目标节点,找不到就原样返回 nullptr;找到之后分三种情况处理。

情况一,目标节点没有孩子,直接 delete 并返回 nullptr,父节点接到空指针,节点自然脱离树。

情况二,目标节点只有一个孩子,delete 当前节点,把孩子接上来。比如目标只有右孩子,就保存右孩子指针,释放当前节点,返回右孩子。

情况三,目标节点有两个孩子,这是核心难点。经典解法有两种:用左子树最大节点覆盖,或用右子树最小节点覆盖。我习惯取右子树最小节点,也就是后继节点。

Node* eraseRecursive(Node* node, const Key& key) { if (!node) return nullptr; const Key& nodeKey = key_of(node->data); if (cmp_(key, nodeKey)) { node->left = eraseRecursive(node->left, key); } else if (cmp_(nodeKey, key)) { node->right = eraseRecursive(node->right, key); } else { if (!node->left && !node->right) { delete node; return nullptr; } else if (!node->left) { Node* right = node->right; delete node; return right; } else if (!node->right) { Node* left = node->left; delete node; return left; } else { Node* successor = node->right; while (successor->left) { successor = successor->left; } node->data = successor->data; node->right = eraseRecursive(node->right, key_of(successor->data)); } } return node; }

双孩子情况里有个隐蔽细节值得单独说。我并没有真的把 successor 节点从树上拆下来再挂到 node 位置,而是先让 node 保存 successor 的数据,再去右子树里递归删除 successor。这样整体的“树指针结构”只动了局部的孩子连接,不需要处理 parent 链。

另一个细节是:右子树的最小节点必然没有左孩子,或者只有右孩子,所以递归删除它的时候,一定会命中“单孩子或叶子”的分支,不会无限循环。

这种“数据覆盖”方案在面试里最容易被追问,因为它有个副作用:如果外部持有指向这个节点的指针,删除后指针本身还在,但 data 已经被替换成另一个节点的数据。涉及外部引用时要慎用。

3.4 中序遍历与调试辅助

二叉搜索树的中序遍历是有序的,所以调试树的时候我几乎每次都把它打出来看,能直接验证插入顺序、重复键策略、删除结果是否合理。

void inOrder() const { inOrderRecursive(root_); std::cout << std::endl; } void inOrderRecursive(Node* node) const { if (!node) return; inOrderRecursive(node->left); if constexpr (kIsVoidValue) { std::cout << node->data << " "; } else { std::cout << node->data.first << ":" << node->data.second << " "; } inOrderRecursive(node->right); }

这里用if constexpr区分输出格式,key-only 打印一个值,key-value 打印key:value。调试透明,树的结构一眼能看穿。

4. 实战避坑:退化、递归深度与模板报错

4.1 最经典的退化问题

我见过最多的 BST 翻车现场,就是把已经有序的数据一股脑插入树里。比如你按1, 2, 3, 4, 5的顺序插入,树会变成一条只有右孩子的链表。此时查找第 5 个元素,本来以为是二叉树 O(log N),实际变成链表 O(N)。

有一回我在性能测试里跑 10 万条有序数据,查找最后一个 key,时间直接飙到几十毫秒,而用随机顺序插入只需要几毫秒。这个差距会随数据量继续放大。

解决办法有三个层面。数据源可控时,先随机打乱再插入;数据频繁变动时,改用 AVL 或红黑树;只做静态查询时,可以把有序数组直接构建成完全平衡树。后两种方案我会在第 5 章展开。

4.2 析构函数的递归深度陷阱

节点是用裸指针串起来的,类析构必须负责释放整棵树。很多人第一反应是写递归析构:

~Node() { delete left; delete right; }

代码挺简洁,但隐患非常明显。如果树退化成链状,析构递归深度等于节点数,10 万层的树直接爆栈。我在自己项目里就吃过这个亏,上线后偶发崩溃,查了半天才发现是析构时递归太深。

工程安全的写法是后序遍历的迭代版本,用一个栈手动模拟递归:

~BST() { if (!root_) return; std::vector<Node*> stack; stack.push_back(root_); Node* prev = nullptr; while (!stack.empty()) { Node* cur = stack.back(); if (cur->left && cur->left != prev && cur->right != prev) { stack.push_back(cur->left); } else if (cur->right && cur->right != prev) { stack.push_back(cur->right); } else { stack.pop_back(); delete cur; prev = cur; } } }

核心逻辑是:先把左子树入栈,处理完左子树再处理右子树,最后释放当前节点。用prev记录上一次释放的节点,防止左右孩子被重复入栈。这段代码比递归多了几行,但换来了深树下的稳定性,我认为非常值得。

4.3 模板报错的排查思路

泛型代码的编译报错一向是噩梦,BST 的模板报错更是重灾区。我列几个高频的:

no match for operator<是最常见的。这是 Key 类型不支持operator<。解决办法是给模板传入一个自定义 Compare,或者给这个类型重载<。

static assertion failed说明你调错了接口。在 key-only 树上调了insert(k, v),或者反过来,编译器已经把错误信息写得很直白了,顺着提示找调用点即可。

std::conditional相关的报错,多半是 Value 类型不完整。模板实例化时,std::conditional的两个分支类型都必须是完整类型,不能是不完整声明的自定义结构体。

排查模板报错,我的习惯是先写一个最小用例,比如只用BST<int>编译,通过后再加上Value参数。别指望编译器一次给全信息,分步缩小范围最快。

4.4 常见问题速查表

现象可能原因处理办法
有序插入后查找极慢树退化成链表改用 AVL/红黑树,或先随机化输入
深度很大时析构崩溃析构递归过深爆栈用迭代栈做后序遍历析构
同样 key 插入后 value 不更新重复键策略写错分支检查两条比较分支后是否有 else 覆盖
删除双孩子节点后序遍历错乱后继替换逻辑没递归清理覆盖数据后递归删除右子树中的后继节点
key-only 模式调了双参数 insert调错接口看 static_assert 提示,或检查 Value 类型是否没写 void
自定义类型不支持<Key 类型缺失严格弱序比较给 Key 重载operator<或传入自定义 Compare

5. 测试验证与可扩展方向

5.1 两个最小复现测试

写树类不测试等于没写。先测 key-only 模式:

BST<int> ids; ids.insert(5); ids.insert(3); ids.insert(8); ids.insert(3); // 重复,什么都不改 std::cout << ids.contains(3) << std::endl; // 1 std::cout << ids.contains(7) << std::endl; // 0 ids.inOrder(); // 3 5 8

输出结果是1 0 3 5 8,有序、去重、存在性判断都正确。

再测 key-value 模式:

BST<int, std::string> dict; dict.insert(1, "one"); dict.insert(3, "three"); dict.insert(2, "two"); dict.insert(2, "TWO"); // 覆盖 value dict.inOrder(); // 1:one 2:TWO 3:three std::string* v = dict.findValue(2); if (v) { *v = "two-plus"; // 通过返回值修改 } dict.inOrder(); // 1:one 2:two-plus 3:three dict.erase(2); dict.inOrder(); // 1:one 3:three

inOrder的输出顺序严格按 key 排列,覆盖 value 和删除都生效。

再补充一个删除双孩子节点的独立用例,树{5,3,8,1,4,7,9},删根节点 5,验证中序仍是1 3 4 7 8 9。这一步最容易写错,值得单独跑。

5.2 从 BST 到平衡树的扩展路线

很多读者下一步诉求必然是:能不能让它自动保持平衡?能,而且起点就是当前的树结构。

AVL 树的思路是在节点里加一个高度字段,每次插入和删除后从递归返回时检查平衡因子。平衡因子绝对值超过 1,就做 LL、RR、LR、RL 四种旋转之一。旋转的本质是局部调整指针,并不影响中序遍历的顺序。在当前递归插入返回节点的地方,把return node改成return balance(node)就已经完成了一半。

红黑树的实现复杂度更高,需要处理颜色翻转和叔叔节点的关系,但思路相同,也是在插入删除后做结构调整。如果只是想快速得到一个不退化、好维护的版本,我建议直接做 Treap,给每个节点加一个随机优先级,用旋转维持堆序优先性,代码量小且不容易越调越乱。

我自己做项目时,会先把这一版 BST 当作基线,跑通业务逻辑后再把模板的Compare和key_of接口原样迁移到 AVL 版本里。算法的数据结构变了,接口契约不变,业务层完全无感。

5.3 什么时候不该手写二叉搜索树

这里必须浇一盆冷水:手写 BST 不是万能的。如果场景是线上高并发查询、数据量过百万、需要持久化落盘、或者要跨线程访问,我第一反应是直接用成熟容器或数据库索引,而不是自己造轮子。

手写树类更适合这些场景:教学与面试准备;嵌入式环境里标准库容器不可用;对慢查询和内存有极致控制的场景;或者你已经确认这棵树只服务单线程,并且数据规模可控。

如果是写个人项目,造轮子可以帮你彻底搞懂原理。落地到团队项目,除非有明确的性能或定制需求,否则引入成熟方案更安全。这两种选择我都做过,后者的教训是想当深刻。

最后再分享一个调试小技巧

树结构最容易看不见摸不着,出问题时靠肉眼盯代码很难盯出来。我平时调试 BST,会用“中序输出 + 缩进打印”的组合。中序输出确认顺序,缩进打印确认树的真实形态。

缩进打印的方法是:写一个额外函数printTree(Node* node, int depth, char side),里面用深度决定缩进空格数,再标出当前节点是左孩子还是右孩子,这样树的结构能直接呈现出来。比如插入5, 3, 8, 1, 4, 7, 9后,打印出来的效果就是这样:

R:8 R:9 R:5 L:3 L:1 R:4 R:7

一眼就能看出左右子树形态对不对,比在纸上手动画快得多。这也是我个人写树结构时最后一步必定要做的事:先把结构可视化,再谈优化算法。

相关新闻

C++菱形继承二义性解析:虚继承原理与对象内存布局实战

C++菱形继承二义性解析:虚继承原理与对象内存布局实战

菱形继承是C里一张绕不开的考卷&#xff0c;也是面试官最爱用来检验候选人对对象模型理解深度的经典题型。我在第一次被问到“菱形继承为什么会二义性”时&#xff0c;还天真地以为是编译器“太笨”&#xff0c;直到自己动手写了一个菱形继承的Demo&#xff0c;看着那条报错信息…

2026/10/20 6:28:34 阅读更多 →
Delphi7集成Chromium渲染引擎实战指南

Delphi7集成Chromium渲染引擎实战指南

简介&#xff1a;本资源是目前最新版CEF4Delphi开源控件集成包&#xff0c;专为Delphi7及兼容版本开发者设计&#xff0c;用于在原生Delphi应用中无缝嵌入Chromium内核浏览器功能&#xff0c;解决传统WebBrowser组件功能弱、兼容性差、无法支持现代前端技术栈等痛点&#xff0c…

2026/10/20 6:28:34 阅读更多 →
CSES本地化算法训练:构建可验证的动态规划闭环

CSES本地化算法训练:构建可验证的动态规划闭环

简介&#xff1a;CSES Problem Set 是面向算法学习者与编程竞赛选手的 C 专项题解资源&#xff0c;覆盖基础语法、动态规划、图论、贪心、数论、位运算等核心算法模块&#xff0c;特别适合准备 ACM/ICPC、LeetCode 高频面试题及提升工程化编码能力的学习者。资源包为 ZIP 格式&…

2026/10/20 6:28:34 阅读更多 →

最新新闻

Repast多智能体建模实战:从SIR模型到Headless批量仿真

Repast多智能体建模实战:从SIR模型到Headless批量仿真

简介&#xff1a;一份针对 Repast 建模与仿真平台的英文原版教程与配套实例&#xff0c;面向社会科学、经济学及复杂系统领域的研究者&#xff0c;也适合希望快速上手多智能体仿真的学生和工程师。整个资源包内共包含72个文件&#xff0c;主体为66个HTML/HTM格式的教程页面&…

2026/10/20 7:11:54 阅读更多 →
用Python实现海明校验码:原理、代码与纠错实战

用Python实现海明校验码:原理、代码与纠错实战

海明校验码&#xff08;Hamming Code&#xff09;这个名词&#xff0c;学计算机、通信、嵌入式方向的朋友多少都见过。它属于那种看着简单、真自己上手写一遍才发现细节特别容易翻车的东西。我最早在项目里用到它&#xff0c;是做一个数据回传模块&#xff0c;想在软件层面给每…

2026/10/20 7:11:54 阅读更多 →
深入解析Lombok源码:javac编译期如何通过注解处理器修改AST生成代码

深入解析Lombok源码:javac编译期如何通过注解处理器修改AST生成代码

简介&#xff1a;面向Java开发者的Lombok实现原理源码资源包&#xff0c;围绕注解处理器、字节码操作与IDE集成展开&#xff0c;适合希望弄清楚Lombok为何能在编译期自动生成getter/setter、equals/hashCode、构造器等样板代码的读者。资源共40个文件&#xff0c;约88KB&#x…

2026/10/20 7:11:54 阅读更多 →
KAN网络与Stacking集成:五模型回归对比实战

KAN网络与Stacking集成:五模型回归对比实战

这两年做回归预测&#xff0c;圈子里的讨论慢慢从“要不要换模型”变成了“能不能把新模型塞进老框架”。我这次的项目标题很直白&#xff1a;把传统机器学习模型和新型KAN网络做Stacking集成&#xff0c;同时做一轮五模型回归对比。做出这个项目之前&#xff0c;我满脑子还是“…

2026/10/20 7:11:54 阅读更多 →
轨道交通延误下跳站与限流协同优化模型:建模、求解与避坑指南

轨道交通延误下跳站与限流协同优化模型:建模、求解与避坑指南

简介&#xff1a;这份资源面向城市轨道交通运营管理领域的研究人员、工程师及高校师生&#xff0c;围绕列车延误场景下的跳站与多车站客流控制协同优化问题&#xff0c;提供了一套双层线性规划模型的完整实现方案。上层以列车总延误最小为目标并考虑载客能力约束&#xff0c;下…

2026/10/20 7:11:54 阅读更多 →
改进双向人工势场引导RRT*的三维路径规划与Matlab实现

改进双向人工势场引导RRT*的三维路径规划与Matlab实现

先放一个结论&#xff1a;在三维空间里裸跑 RRT*&#xff0c;效果通常不会太好。我前两年做无人机巡检路径规划项目的时候&#xff0c;被这个经典算法折磨过&#xff1a;100100100 的地图&#xff0c;迭代 5000 次&#xff0c;首条可行路径拐了十几个弯&#xff0c;拿到手还得再…

2026/10/20 7:10:53 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →