lexer 中只有 48 个运算符时,线性查找还是二分查找更快?
结论
只有 48 个运算符时,不能仅凭复杂度判断线性查找和二分查找谁更快,也不能根据现有估算认定二分查找快 1.43 倍。运算符的排列、输入分布、字符串比较和机器执行开销都会影响结果,需要测量才能得出具体结论。
对于这个业余 compiler 项目,可以继续使用已有的 std::lower_bound,前提是查找表按 symbol 的字典序排列。线性查找也合理;如果性能尚未成为问题,优先保证实现正确、容易维护。
当前代码中还有一处更值得先检查:如果 src 是 std::string,std::string_view potential = src.substr(index, len); 会留下悬空视图。
为什么比较次数不能直接换算成速度?
原代码依次尝试长度为 3、2、1 的候选字符串,每个长度都会调用一次 std::lower_bound。因此,识别一个运算符可能需要多次查找,具体次数取决于在哪个长度匹配成功。
一次候选字符串查找的比较次数大致如下:
| 情况 | 线性查找 | 二分查找 |
|---|---|---|
| 成功,且 48 个位置等概率命中 | 平均 24.5 次元素比较 | lower_bound 最多约 6 次比较,之后还需判断是否相等 |
| 未命中 | 48 次元素比较 | lower_bound 最多约 6 次比较,之后检查结果 |
这里的“元素比较”并不对应固定数量的 CPU 指令:
- 字符串比较可能在第一个不同字符处结束,未必检查全部 3 个字符。
- 线性查找连续访问内存,循环简单。如果常见运算符排在前面,平均比较次数还会下降。
- 二分查找检查的元素更少,但跳转依赖比较结果,其成本受编译器和硬件影响。
n/2 只是在成功位置近似均匀时的粗略估计;未命中的线性查找需要扫描全部元素。两种算法都需要比较字符串,因此,把线性查找的一次字符串比较视为单位成本,却给二分查找单独乘以字符长度 3,不能得到公平的耗时比较。
先确认二分查找的排序前提
原代码使用的比较器是:
[](const Operator& a, std::string_view b) {
return a.symbol < b;
}
因此,最直接的做法是让 ops 按 symbol 的字典序排列。只按 opID 排序,不能保证满足这个查找的前提。
如果其他代码需要按 opID 访问运算符,可以保留现有存储,再建立一个按 symbol 排序的索引,例如存放下标或指针。这样无需复制整份运算符信息,但需要维护索引与原存储之间的有效关系。
另外,C++ 中的 std::map 查找复杂度是 O(log n)。平均 O(1) 查找通常指 std::unordered_map;它还涉及哈希计算,最坏情况为 O(n)。对于 48 个短字符串,不能只凭平均复杂度断言哈希表更快。
修正 string_view 的构造方式
这一行是否安全,取决于 src 的类型:
std::string_view potential = src.substr(index, len);
如果 src 是 std::string_view,substr() 返回另一个视图。只要底层字符仍然有效,就可以使用。
如果 src 是 std::string,substr() 返回临时的 std::string。临时对象在这一行结束时被销毁,potential 随后不再指向有效的字符串内容。
可以先为完整源文本建立视图,再从视图截取候选字符串。下面沿用现有的 Operator、Token 和 advance(),假设 src 的底层字符在查找期间保持有效,且 ops 已按 symbol 排序:
const std::string_view source{src};
const auto& ops = getOpList();
if (index < source.size()) {
const size_t remaining = source.size() - index;
const size_t maxLen = remaining < 3 ? remaining : 3;
for (size_t len = maxLen; len > 0; --len) {
const std::string_view potential = source.substr(index, len);
auto it = std::lower_bound(
ops.begin(), ops.end(), potential,
[](const Operator& a, std::string_view b) {
return a.symbol < b;
});
if (it != ops.end() && it->symbol == potential) {
const Operator_ID match = it->opID;
for (size_t i = 0; i < len; ++i) {
advance();
}
return Token(Token_ID::OPERATOR, match);
}
}
}
// Continue with the lexer's existing non-operator handling.
这个写法也避免了用 index + len 判断边界时潜在的无符号整数溢出。std::string_view 不拥有字符数据,因此不能让它指向已经销毁,或因修改、重新分配而失效的存储。
如何比较线性查找和二分查找?
在线性查找版本中,可以保留同样的长度循环和成功处理逻辑,只把查找部分换成:
auto it = std::find_if(
ops.begin(), ops.end(),
[potential](const Operator& op) {
return op.symbol == potential;
});
随后用 it != ops.end() 判断是否找到即可。线性查找没有字典序排序要求。
要判断哪个版本适合你的 lexer,可以这样测量:
- 使用开启优化的构建配置,并在计时之外准备好运算符表和输入。
- 使用有代表性的源代码,包含不同长度的运算符,以及长度为 3 或 2 的候选字符串未命中、随后较短候选命中的情况。
- 让两个版本生成相同的 token 序列,并实际使用结果,避免编译器消除查找工作。
- 重复测量足够多次,同时关注完整 lexer 的耗时。如果总体差异很小,就选择更清楚的实现。
不要只反复查找一个固定运算符:这种输入可能让缓存和分支预测表现得过于理想,也无法反映真实的命中分布。
保留最长匹配规则
当前代码从长度 3 向长度 1 尝试,遵循最长匹配规则。例如,假设语言同时支持 >、>> 和 >>=,就应该先尝试较长的形式。
无论改成线性查找还是哈希表,都需要保留这条规则。如果以后测量发现运算符识别确实是热点,还可以考虑按首字符分组或使用 trie,减少无关候选的比较。对于目前的 48 个运算符,先修正生命周期和排序问题,再测量两种简单实现就足够了。
备注:内容仅供参考。
