ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写哈希表:从底层原理到工程实践的完整指南

手写哈希表:从底层原理到工程实践的完整指南 哈希表这个东西我在面试和实际工程里都吃过不少甜头也踩过不少坑。别看它表面就“键值对映射”这一句话真要往深了抠里面的门道够讲一整篇。今天这篇文章就用纯手写代码的思路从底层结构到工程实践把哈希表拆开揉碎讲清楚。不管你是刚学算法准备笔试还是工作几年想回来补补内功这篇应该都能让你对哈希表有个不一样的认识。1. 内容整体设计与思路拆解1.1 哈希表到底解决了什么问题数组大家都熟按下标访问是 O(1) 的但它的前提是“位置语义”必须知道数据存在哪个下标。如果现在场景换成“根据姓名找电话号码”你不可能拿“张三”当数组下标去访问。哈希表的核心贡献就是给任意类型的“键”做一次映射把它转成一个数组下标然后把 O(n) 的线性查找直接变成 O(1) 的近似访问。换个生活化的类比就像你去快递驿站取件如果没有分区编号你得在堆成山的包裹里挨个翻找自己的名字有了“货架号层号”的编码体系你按图索骥直接走过去就能拿。哈希表干的就是“把客户姓名换算成货架号”这回事这个换算函数就叫哈希函数。1.2 哈希表数据结构的基本构成一张哈希表从底层看其实就三样东西一块连续的内存区域学名叫桶数组它决定了一张表能存多少“主仓位”。一个哈希函数把任意输入的键换算成桶数组的下标。一套冲突处理策略因为不同键映射到同一个下标是必然的问题不是“会不会撞”而是“撞了之后怎么处理”。这个设计顺序很重要。我见过不少初学者上来就背“哈希表是O(1)查找”这句话结果问他冲突是怎么解决的他一愣。其实这个问题的答案直接决定了你的哈希表是“理论快”还是“真快”。最经典的开链法和开放寻址法前者是每个桶挂一个链表后者是冲突了就往后找空位两种方案的性能和适用场景完全不同。1.3 手写哈希表的整体技术选型我用 C 手写哈希表时选型的思路是这么走的存储载体用动态数组 vector这样才能在扩容时方便地重新映射。冲突处理先写链地址法也就是“数组 链表”因为它实现直观删除操作容易不容易踩到开放寻址里“删除后会把查找链断掉”的坑。哈希函数从最简单的取模开始但会强调一个关键细节模数不能随便选最好用质数因为质数能减少哈希值的聚集现象。键类型用 int这样你在看代码时能把注意力完全集中在哈希表本身的逻辑上而不是被字符串哈希的边角细节干扰。我需要先说清楚这里手写哈希表绝不是为了让你在工程里重复造轮子。真正的价值有两个一是面试时你对着 map 和 unordered_map 不至于只会背“红黑树快还是哈希表快”这种结论而是能讲清楚底层差异二是遇到性能瓶颈时你知道应该去优化哈希表的哪个环节而不是瞎猜。2. 哈希函数与冲突处理的核心机制解析2.1 哈希函数为什么不能太“随便”哈希函数是整张哈希表的灵魂。它的理想状态是把键均匀地撒到数组的每个桶里。但现实是完美均匀几乎不可能所以我们评估一个哈希函数好不好主要看三个维度第一计算速度快不快。如果你的哈希函数本身就要跑一个复杂加密算法那即使查找次数再少时间也全花在算哈希值上了整体性能一样难看。第二雪崩效应够不够好。意思是输入哪怕只变一点点输出也应该天翻地覆地变而不是只差一个数字否则两个相近的键很容易聚在同一个桶。第三碰撞率低不低。碰撞率直接决定了链表的平均长度链表越长查询效率越差。拿最基础的取模法举例。假如桶数组大小是 100哈希函数是 key % 100这看起来没问题但实际上如果 key 的分布正好和 100 有关系比如系统产生的 ID 全是偶数你会发现所有数据都落到了 0、2、4、6 这些偶数桶里奇数桶全部闲置。这种分布不均说白了就是哈希函数的“自私”它有规律地偏爱某些下标导致数据严重倾斜。2.2 冲突处理策略详解链地址法与开放寻址法链地址法也叫拉链法是工程最常用的方案Java 的 HashMap 和 C 的 unordered_map 都是基于它实现的变种。它的做法是每个桶不直接存数据而是存一个链表的头指针。当多个键映射到同一个桶时就把它们依次挂到这个桶的链表后面。我拿这个方案练手最大的感受是它的“删除操作”特别自然。找到桶之后在链表里做常规的删除节点操作就行不需要额外标状态。而且只要哈希函数选得还可以每个桶的链表平均长度就会很短短到链表扫描的成本可以忽略不计。开放寻址法和链地址法就是两个完全不同的思路。它不设链表所有元素直接躺在桶数组里。冲突发生时按照一定规则向后探测找到下一个空位放进去。查找的时候也是按同样的规则探测直到找到目标或者遇到空桶。听起来开放寻址法好像还更省内存因为它不需要维护链表节点的额外指针。但它的致命弱点在于删除操作会破坏探测链导致明明存在的元素找不到了。为了解决这个问题通常要引入“墓碑标记”给被删除的位置做一个特殊记号查找时看到墓碑要继续往后探测。这就让实现复杂度上了一个台阶所以新手入门我更推荐先用链地址法跑通整体逻辑把开放寻址当作进阶思考题去研究。2.3 模数的选择为什么用质数是更好的实践我之前说过取模的模数尽量用质数。这话不是玄学背后是数论层面的原因。假设桶大小是 12某个键产生哈希值的规律是每隔 2 递增比如 0、2、4、6...那么全部对 12 取模后你会惊讶地发现结果只在 0、2、4、6、8、10 这几个偶数里打转。为什么会这样因为 2 是 12 的因子所以步长 2 和模数 12 有公因子就会不断踩进同一个“模数环”里跳不出来。但如果桶大小取质数比如 132 和 13 互质那步长 2 的遍历就会覆盖到 0 到 12 的每一个值数据分布就铺开了。这就是为什么很多 STL 老鸟会说哈希表桶数量尽量取质数。但在 C 的 STL 里这个原则常常被专门的“桶数量生成策略”替代它不一定严格用质数但一定做了合理的选数避免明显的公因子问题。2.4 扩容机制负载因子与重新哈希哈希表不能无限往里塞数据因为桶的总数是固定的塞得越多链表越长哈希表退化成链表的风险越大。所以每个哈希表都会有一个负载因子的概念计算方式是负载因子 存储元素个数 / 桶总数链地址法通常把阈值定在 0.75 到 1.0 左右。C 的 unordered_map 把这个值暴露给大家也允许开发者自己调整。负载因子超过阈值后就要执行扩容。扩容的策略是重新申请一张更大的桶数组通常大小变成原来的两倍左右然后把旧数据全部重新计算哈希值、重新插入到新桶里。这个过程叫 rehash它的时间复杂度是 O(n)单次触发会卡顿但均摊到每次插入操作后整体复杂度依然是 O(1)。我见过不少人在手写哈希表时忽略扩容结果测试的时候数据量一大直接超时这不是算法问题是工程习惯问题。后面写代码时我会把扩容槽位直接做进去这才是和 STL 行为对齐的做法。3. 实战C 手写一个链地址法哈希表3.1 为什么用 C 写哈希表有人可能问哈希表明明是个语言无关的数据结构为什么这里要用 C 来演示一方面是因为很多刷题场景和面试场景默认用的就是 C另一方面是 C 对内存和对象生命周期的控制更显式你在操作指针和节点时能更直观地理解哈希表底层“每一块内存”是怎么被组织起来的。语言越贴底层你对数据结构的理解就越扎实。如果换 Python很多操作都会被 dict 给遮住了你根本看不到后面发生了什么换 Java大量都封装在 HashMap 里你看到的是方法调用。当然这不代表 Java 不行只是从“学习原理”这个角度出发C 手写一遍是最让人通透的。3.2 关键代码实现节点、哈希表类、插入、查找、删除直接上代码。我会按步骤拆开讲不求一上来就写成大型库只求逻辑完整、能跑。第一步定义链表节点。这个节点既要存放键值对也要有一个指向下一个节点的指针这里我把 key 和 value 分开存后面做练习时你就可以自己扩展成模板。为了简单我先用整数键值对来演示struct Node { int key; int value; Node* next; Node(int k, int v) : key(k), value(v), next(nullptr) {} };第二步定义哈希表类主体。类的成员变量包括桶数组 vector桶数量以及当前存储元素个数class HashTable { private: vectorNode* buckets; int bucketCount; int size; int hashFunc(int key) { // 简单取模桶数以质数为主 return key % bucketCount; } void rehash(); public: HashTable(int count 13) : bucketCount(count), size(0) { buckets.resize(bucketCount, nullptr); } void insert(int key, int value); bool find(int key, int value); bool remove(int key); ~HashTable(); };第三步是插入函数。这个函数的核心逻辑是先算哈希值找到对应的桶然后遍历这个桶的链表如果发现 key 已经存在就更新值不存在就头插一个新节点。头插法比尾插法省去遍历到链表末尾的过程简洁高效void HashTable::insert(int key, int value) { int index hashFunc(key); Node* head buckets[index]; Node* cur head; while (cur ! nullptr) { if (cur-key key) { cur-value value; // 键已存在更新值 return; } cur cur-next; } // 键不存在头插新节点 Node* newNode new Node(key, value); newNode-next buckets[index]; buckets[index] newNode; size; // 检查负载因子决定是否扩容 if ((double)size / bucketCount 0.75) { rehash(); } }第四步是查找函数。查找的逻辑和插入的前半部分完全一致这本身就体现出哈希表操作的统一性算下标、遍历链表、按 key 比较bool HashTable::find(int key, int value) { int index hashFunc(key); Node* cur buckets[index]; while (cur ! nullptr) { if (cur-key key) { value cur-value; return true; } cur cur-next; } return false; }第五步是删除函数。删除有两种常见做法一种是用两个指针做 prev 和 cur 的交替遍历另一种是保存前驱节点。我建议用双指针思路清楚不容易出现自引用问题bool HashTable::remove(int key) { int index hashFunc(key); Node* cur buckets[index]; Node* prev nullptr; while (cur ! nullptr) { if (cur-key key) { if (prev nullptr) { // 删除的是头节点 buckets[index] cur-next; } else { prev-next cur-next; } delete cur; size--; return true; } prev cur; cur cur-next; } return false; }第六步是析构函数和 rehash。析构函数要释放所有链表节点防止内存泄漏。rehash 负责在负载因子超标时扩容HashTable::~HashTable() { for (int i 0; i bucketCount; i) { Node* cur buckets[i]; while (cur ! nullptr) { Node* temp cur; cur cur-next; delete temp; } } } void HashTable::rehash() { int newBucketCount bucketCount * 2 1; // 尽量选一个有利于分布的奇数/质数 vectorNode* newBuckets(newBucketCount, nullptr); for (int i 0; i bucketCount; i) { Node* cur buckets[i]; while (cur ! nullptr) { Node* next cur-next; int newIndex cur-key % newBucketCount; cur-next newBuckets[newIndex]; newBuckets[newIndex] cur; cur next; } } buckets.swap(newBuckets); bucketCount newBucketCount; }这里我采用bucketCount * 2 1来生成新桶数。需要注意它不严格保证是质数但在常见场景下已经能有效避免连续公因子问题。如果你要更严谨的质数表策略可以参考 STL 内部那套立体化增长策略它会维护一个预置的质数列表并依次切换。3.3 unordered_map 的使用方式与手写版对比工程里直接用 C STL 的 unordered_map 是最常见的操作。它的底层就是哈希表不过做了大量优化支持任意可哈希类型比如 string。使用示例#include iostream #include unordered_map #include string int main() { std::unordered_mapstd::string, int ageMap; ageMap[Alice] 30; ageMap[Bob] 25; auto it ageMap.find(Alice); if (it ! ageMap.end()) { std::cout it-second std::endl; } return 0; }和手写版对比unordered_map 的差异主要体现在三块第一它自动管理节点内存你不用手动 new 和 delete也就不容易泄漏第二它支持自定义哈希函数和相等比较函数适合复杂键类型第三它内部采用链表加桶数组的组织方式还维护了节点之间的迭代顺序细节工程鲁棒性更强。但为什么仍然建议手写一遍我打个比方你会开自动挡汽车不等于你懂发动机原理。真到车辆出故障你还是一头雾水。手写哈希表最大的价值就在于逼迫你在内存层面搞清楚“每个节点怎么挂”“扩容时数据怎么挪”这些核心问题。很多坑你只在用 unordered_map 时是永远看不到的。3.4 字符串键的哈希处理经验实际工作中更多时候用的键是字符串。C 里常见字符串哈希是“多项式滚动哈希”原理是把字符串每个字符当成一个数字按位加权求和hash (hash * base str[i]) % modbase 一般取一个质数比如 31 或 131mod 取一个大质数比如 1e97。为什么 base 取质数因为质数能让字符序列的排列组合在哈希空间里分布更散减少不同字符串映射到同一哈希值的概率。我在项目里也踩过字符串哈希不均导致查询变慢的坑。当时是给一批 URL 生成哈希桶base 取了 10结果所有 URL 最后几位的数字变化被 10 的幂次反复加权放大了哈希聚集非常严重。后来换成 base 131问题立刻缓解。这种实际调参的经验比单纯背公式有用得多。4. 常见问题与排查技巧实录4.1 哈希冲突严重查询退化如何排查现象是数据量不大的时候哈希表查询却明显变慢。这时候第一反应不是怀疑代码写得不对而是怀疑哈希函数的分布不均匀。排查方法很简单写一个统计脚本把所有键的哈希下标分布打印出来看它是不是均匀落在各个桶里。如果发现大量键集中在某几个桶基本可以确认是哈希函数的问题。此时可以尝试改 base 或 mod或者用更好的混合哈希算法来打散数据。我在实际项目里遇到过用默认哈希函数处理一组特定字符串时大量冲突的问题换成一个带随机种子的哈希函数后立刻缓解。这种坑不好提前避免只能靠监控和切换策略兜底。4.2 删除操作找不到节点的问题如果使用开放寻址法删除时直接“置空”会导致后续探测器在停止条件上误判从而找不到那些原本还存在的元素。这是开放寻址法最经典的坑。你明明插入了数据删除一个不相干的键之后再查之前的键却返回不存在就是墓碑标记没做好。链地址法虽然天然规避了这个问题但如果你在链表删除时没正确维护 prev 指针也会出现“断链”或“自引用”的 bug表现为删除一个节点后整个链表的部分元素突然不可达了。排查这类问题我会在纸上一格一格画链表结构顺着指针跑一遍很快就能定位到是哪个指针丢了。4.3 扩容与迭代器失效问题C 中unordered_map 在扩容后之前获得的迭代器可能失效。这个问题在单线程里容易踩在多线程环境里更容易踩。你是不是觉得扩容只是内部数组搬数据迭代器应该还在其实哈希表的迭代器内部保存了当前节点和桶编号的关联信息扩容后桶数组完全重排旧迭代器对应的桶和节点关系全部乱了。解决办法很简单在循环插入扩容的代码里不要长期持有旧迭代器每次使用 find 重新获取或直接通过键访问。4.4 哈希表常见坑位清单问题类型现象主要原因解决方案哈希分布不均数据量大时查询变慢桶数选了合数或哈希函数不均衡桶数尽量取质数改进哈希函数删除后查不到明明存在却 find 失败开放寻址法缺少墓碑标记改用链地址法或实现好标记逻辑内存泄漏程序跑久了内存增长析构函数没有释放全部链表节点逐个释放每个桶的所有节点迭代器失效循环中插入后访问失效扩容导致桶结构重排扩容后重新获取迭代器负载因子过高查询退化到 O(n)未触发扩容插入后检查现有 size 并执行 rehash这张表是实际开发中最常碰到的五个坑的浓缩写在纸上贴在工位旁边比临时上网找答案要快得多。4.5 自定义哈希函数与对象键的扩展心得当你用自定义结构体做键时比如一个包含两个 int 的坐标类C 默认的 std::hash 是不能直接使用的需要自己提供哈希函数。常规做法是把多个字段合并成一个整数再套用一个混合函数打散。我用过最简洁的方案是struct PointHash { size_t operator()(const Point p) const { return hashint()(p.x) ^ (hashint()(p.y) 1); } };这种“异或加移位”的组合方式比简单相加更稳因为如果两个字段拼成字符串的话(1,23)和(12,3)就有撞车风险而异或加移位能更好地区分不同字段组合。当然它依然不是完美方案但作为自定义键的哈希它已经比很多人直接用相加要严谨得多。5. 哈希表的工程应用与实际落地场景5.1 缓存系统里免密查找的价值哈希表最典型的应用是缓存。无论是内存级的缓存组件还是应用内的小缓存底层几乎都离不开哈希表。因为缓存的核心要求就是用一个键快速判断数据是否存在并把命中的数据尽快取出来。链表和数组在“快速定位”这件事上都力不从心只有哈希表能以近乎 O(1) 的代价完成。我自己维护过一个小的热点数据缓存当时用哈希表存商品 ID 和商品详情的映射压测下来单次查询耗时从原来的毫秒级降到微秒级。哈希表在这个场景下的价值不是“比树快一倍”这种程度而是质的差距。尤其当数据量大了以后红黑树的 O(log n) 在千万级别数据下意味着单次查询要比较二十多次而哈希表稳定在个位数次操作性能差距非常直观。5.2 计数与去重场景的标准答案另外两个高频应用场景是计数和去重。统计一段文本中每个单词出现的次数、计算两个数组的交集、判断一个元素是否已经出现过这些场景全是哈希表的主场。我之前做过一个日志分析工具要统计海量日志中各类错误码的出现次数直接拿 unordered_map 存错误码到计数的映射代码量不到十行就能跑出结果。同样的事要用数组做要么你得先知道错误码的范围要么就得接受 O(n²) 的暴力查找。哈希表在这里的不可替代性非常明显它允许你在“未知范围”和“任意类型”的键上做高效聚合用空间换时间。5.3 分布式系统分片与哈希环的进阶话题哈希表在单机场景下已经够好用但在分布式系统里它还会升级出两个重要变种一致性哈希和哈希分片。一致性哈希解决的是“节点变动时数据迁移量过大”的问题。传统取模方案里如果后端节点数从 5 变成 6那么 5/6 的键都会映射到新节点上缓存命中率会瞬间崩溃。一致性哈希把哈希结果映射到一个虚拟环上每个节点负责环上的一段区间节点增删时只会影响相邻区间数据迁移量大幅减少。这在微服务架构和缓存集群里是个极其重要的设计面试也爱问。哈希分片则是把一个大哈希表按哈希值范围拆到不同的物理节点上。比如把 userId 做哈希后取前两位把数据横向切到不同分片。这种方案能突破单机内存上限是很多分布式数据库的底层思路之一。这些进阶话题不需要你现在就完全掌握但当你理解了哈希函数、冲突、扩容这些基础概念之后再去读一致性哈希的文档你会有一种“原来如此”的感觉所有上层复杂设计底层踩的还是那几个最原始的原则。6. 复杂度分析与选型决策6.1 时间复杂度与空间复杂度的精确定位哈希表的理想时间复杂度是 O(1) 查找但这里有个前提哈希函数均匀且负载因子控制得好。如果负载因子过高、冲突严重最坏情况下所有键都堆在一个桶里查找会退化到 O(n)这和链表没什么区别了。均摊角度来说插入操作因为有偶尔的 rehash单次可能 O(n)但所有插入加起来的总成本均摊下来依然是 O(1)。这个“均摊”概念很重要我在面试中经常会让候选人解释为什么哈希表说是 O(1)却偶尔会卡一下。答案就是扩容重哈希在作祟。空间复杂度方面哈希表需要为每个元素额外存储指针和可能被浪费的空桶所以它本质上是“用空间换时间”的典型代表。6.2 哈希表和平衡树怎么选择这是程序员在选择数据结构时绕不开的问题。HashMap 的查找通常比 TreeMap 快但 TreeMap 有它不可替代的杀手锏有序性。需要范围查询、按顺序遍历、找最大值最小值前缀等场景平衡树完胜。另外平衡树的性能更稳定不会出现哈希表那种极端情况下退化到 O(n) 的意外。我的选择经验很简单只要对顺序有要求就放弃哈希表只要对查找速度有极致要求且不需要顺序就上哈希表。举个例子统计词频用哈希表最舒服但要做“按出现次数排序后输出前十个单词”那就是先哈希表统计再排序。两个根本不矛盾它们是配合关系而不是对立关系。6.3 空间占用对比的细节把控同样是存一万个键值对哈希表的空间开销其实比很多人想象的要大。首先是桶数组本身即使某个桶里没有元素也要占据一个指针的空间其次是链表的每个节点还有额外指针。我在定过一个结构体数组和哈希表做对比测试数据量 10 万级别时哈希表的内存占用几乎是数组的 2 到 3 倍。所以如果内存极度紧张、键的范围又已知且连续数组永远是更合适的选择这点要心里有数。7. 性能优化与扩展思路7.1 预留空间减少扩容次数unordered_map 有一个接口叫 reserve可以提前告诉它将来大概要存多少元素它会在初始阶段就分配好足够大的桶数组。这样后续插入就不必频繁触发 rehash性能会大幅提升。我在处理大批量数据导入时习惯先根据预估数据量调用 reserve。对比测试中同样的 100 万条数据导入不调用 reserve 比调用 reserve 的耗时高出 30% 以上。这个优化真的属于花最小成本得到最大收益的操作建议任何批量场景都要用上。7.2 让哈希函数调用成本更低哈希函数的计算成本很容易被忽略。很多哈希函数在底层对字符串做了逐字符遍历字符串越长成本越高。如果能从业务上缩短键的长度或者把字符串键预编译成整数 ID 再插入哈希表性能会提升得很明显。我以前做过一个文本索引系统本来直接用句子做哈希键后面改成预先把句子编号映射为 int ID再用 ID 做哈希键查询速度提升了将近一倍。这类优化看着不起眼但在高 QPS 场景下省下来的时间就是实实在在的机器成本。7.3 多线程环境下的哈希表使用策略多线程访问哈希表时最容易出问题因为哈希表的写操作涉及节点的申请、链表的修改、甚至整个桶数组的扩容。最简单粗暴的方案就是全局加锁但这样并发度会被锁拖垮。推荐的做法有几种一是用读写锁读多写少时能提升并发二是对每个桶独立加锁即分段锁不同桶的读写互不干扰三是使用无锁哈希表高性能但实现难度高适合核心场景。我自己常用的策略是单线程写、多线程读。只要保证写操作完成后再发布读请求读线程可以安全并发访问哈希表不需要锁。这个策略在许多缓存场景里已经够用而且避免了几乎所有的并发复杂度。8. 哈希表学习路线与算法面试要点8.1 从零基础到能手写哈希表的路径规划如果你是完全零基础我建议按四条线递进。第一条线先彻底理解数组和链表这是哈希表的左边基石和右边基石没搞懂这两个数据结构就去学哈希表会一头雾水。第二条线用 C 手写一遍链地址法哈希表包含插入、查找、删除、扩容四个操作这是第一次真正落地。第三条线用 unordered_map 刷十道左右经典题目包括两数之和、字母异位词分组、最长连续序列、LRU 缓存等目的是把标准和手写对应起来。第四条线研究底层实现和冲突优化包括字符串哈希、一致性哈希、布隆过滤器延伸到这个阶段你已经能独立设计哈希相关的方案了。很多同学卡在第二步觉得手写一遍没必要其实我自己教过的所有人里凡是能不看代码把插入和删除逻辑写出来的面试时聊到哈希表都很有底气因为他是真懂而不是背结论。8.2 面试中关于哈希表的高频追问面试官问哈希表一般不是让你背定义而是会连环追问几个细节负载因子是什么为什么是 0.75哈希表和红黑树各自底层结构和选择依据是什么如果让你设计一个存一亿个 URL 的系统你会怎么优化哈希如果哈希冲突很严重你怎么排查和解决回答这些问题的时候思路比标准答案更重要。比如被问到“为什么负载因子是 0.75”如果只是背出常数面试官不会觉得很惊艳但如果能顺带提到 0.75 是在时间与空间之间做权衡的结果桶数越密查找越快但浪费空间越大桶数越稀省内存但链表变长一个合理的负载因子就是在寻找那个均衡点面试官就会认定你是真的理解这个设计。8.3 推荐刷题清单与场景复盘我把学哈希表后适合碰的几道题列在这里基本覆盖了哈希表的常见考法两数之和最经典的“用哈希表记历史”思路。字母异位词分组用排序后的词作为哈希键考验对键的设计。最长连续序列核心是用哈希表 O(1) 判断数字是否存在配合跳跃扫描避免重复计算。LRU 缓存哈希表加双向链表组合使用最能体现数据结构联合作战的能力。找到所有消失的数字哈希表做标记去重。这些题做完一遍你对哈希表的理解就不再停留在原理层面而是真正知道它在实际问题里是怎么用的。以后再看源码、读论文、做架构选型你心里都有一根哈希表的“标尺”知道什么场景适合它什么场景该换别的结构。这就是把基础打扎实之后带来的长期复利。
返回列表