PAYATHON 2026

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,可以这样测量:

  1. 使用开启优化的构建配置,并在计时之外准备好运算符表和输入。
  2. 使用有代表性的源代码,包含不同长度的运算符,以及长度为 3 或 2 的候选字符串未命中、随后较短候选命中的情况。
  3. 让两个版本生成相同的 token 序列,并实际使用结果,避免编译器消除查找工作。
  4. 重复测量足够多次,同时关注完整 lexer 的耗时。如果总体差异很小,就选择更清楚的实现。

不要只反复查找一个固定运算符:这种输入可能让缓存和分支预测表现得过于理想,也无法反映真实的命中分布。

保留最长匹配规则

当前代码从长度 3 向长度 1 尝试,遵循最长匹配规则。例如,假设语言同时支持 >、>> 和 >>=,就应该先尝试较长的形式。

无论改成线性查找还是哈希表,都需要保留这条规则。如果以后测量发现运算符识别确实是热点,还可以考虑按首字符分组或使用 trie,减少无关候选的比较。对于目前的 48 个运算符,先修正生命周期和排序问题,再测量两种简单实现就足够了。

备注:内容仅供参考。