C++朝花夕拾

从一道 groupAnagrams 题谈 STL 容器、哈希与字符串优化

起点:一段"看起来聪明但其实买椟还珠"的代码

// 用 map<int,int> 统计字符频次,再把这个 map 作为外层 map 的 key
map<map<int,int>, int> cache;

直觉上"用频次代替排序"是好思路(O(n) vs O(n log n)),但容器选择把这个优势全部抹掉

一、map 的 key 不走哈希

第一个常见误解:std::map<K, V>红黑树 + operator<,不是哈希表。

  • std::map 查找:O(log N) 次 key 之间的字典序比较
  • std::unordered_map 查找:平均 O(1),需要 std::hash<K>

map<int,int> 当 key,每次 find 是 O(log N) 次树遍历比较,每次比较还要遍历内层 map 的所有 pair——常数极差。

二、复杂度分析要分清"渐近"和"常数"

渐近最优(asymptotically optimal)= 在大 O 意义上达到问题下界,再优化只能改善常数。

groupAnagrams 在 σ=26(小写字母)假设下:

方案key 构造key 比较
map<map<int,int>, int>O(k)O(σ)=O(1)
排序 stringO(k log k)O(k)
array<int,26>O(k)O(σ)=O(1)

关键认知

  • σ 视为常数时,方案一和方案三渐近同阶,差距全在常数(堆分配 vs 栈数组)
  • σ 视为变量时,三者才有渐近差距
  • "渐近最优"和"工程最优"不是一回事——常数因子在实际系统里可能差几十倍

三、k 的规模决定方案选型

k 范围推荐理由
k ≤ ~20排序 string落在 SSO 内,零堆分配,代码最短
20 < k ≤ 100统计到 string(26,0)key 固定 26 字节,hash/compare 快
k > 100array<int,26> + 自定义 hash完全栈上,无分配

不要为了小规模 k 过早优化——sort 和 count 的差距小到要 profile 才能感知。

四、std::array 没有默认 hash

unordered_map<array<int,26>, vector<string>> m;  // 编译失败

C++ 标准库没有std::array 提供 std::hash 特化(标委会认为"组合方式没有唯一正确答案")。三种解法:

  1. string(26, 0) 当桶:复用 std::hash<string>,但 26 > SSO 阈值,每次堆分配
  2. 自定义 hash(推荐):array 在栈上,零分配
  3. 二进制布局编码成 string:质量好但不必要——还是堆分配

五、boost::hash_combine 深入

不是标准库(提案 P0814 至 C++26 仍未通过),五行可以自己抄:

template<class T>
void hash_combine(size_t& seed, const T& v) {
    seed ^= std::hash<T>{}(v) + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}

逐项拆解

  • 0x9e3779b9 = 黄金比例 φ × 2³² 取整。Knuth 在 TAOCP Vol.3 证明这个常数作为乘法哈希因子能让任意输入分布最均匀,避免簇聚
  • (seed << 6) + (seed >> 2):让 seed 高低位都参与扰动(雪崩效应)
  • ^= 而非 =:使 hash 顺序敏感(hash(a,b) ≠ hash(b,a))
  • 单独一项不够强:纯异或会让 hash(a,a)=0,纯加法满足交换律——必须组合扰动

六、SSO(Small String Optimization)

std::string 对象本身约 24 字节,复用这块内存存短字符串,避免 malloc:

实现SSO 容量(不含 \0)
libstdc++ (GCC)15
libc++ (Clang)22
MSVC15

实际影响

string a = "hello";          // 5 字符,SSO,零分配
string b(26, 0);             // 26 字节,超出阈值,堆分配

避免一次堆分配(malloc 约 100 ns)往往比避免一次循环更值钱,尤其在 hot path。

判断技巧

cout << (void*)s.data() << " vs " << (void*)&s;
// 地址相近 → SSO;地址区间不同 → 堆分配

七、最终参考实现

// k ≤ 100,简洁优先
vector<vector<string>> groupAnagrams(vector<string>& strs) {
    unordered_map<string, vector<string>> groups;
    for (auto& s : strs) {
        string key = s;
        sort(key.begin(), key.end());
        groups[move(key)].push_back(s);
    }
    vector<vector<string>> rst;
    rst.reserve(groups.size());
    for (auto& [_, v] : groups) rst.push_back(move(v));
    return rst;
}

// k 较大,性能优先
struct ArrayHash {
    size_t operator()(const array<int,26>& a) const noexcept {
        size_t seed = 0;
        for (int x : a) seed ^= hash<int>{}(x) + 0x9e3779b9 + (seed<<6) + (seed>>2);
        return seed;
    }
};
unordered_map<array<int,26>, vector<string>, ArrayHash> groups;

核心收获

  1. 看 STL 容器的复杂度,先看底层数据结构map 是树不是哈希
  2. 渐近最优 ≠ 工程最优:常数因子在真实系统里能差一两个数量级
  3. 避免堆分配 > 避免循环:SSO 是 string 性能的隐形分水岭
  4. 标准库没有给 array hash:必要时手写五行 hash_combine
  5. 0x9e3779b9 不是魔法:是黄金比例的整数化,专治哈希簇聚

评论

此博客中的热门博文

Programming in Lua First Edition

Lua Gems