【面试高频题】热门数据结构面试题合集(哈希表)

1,541 阅读10分钟

本文正在参加「金石计划」

哈希表

哈希表一直是面试数据结构中的重中之重,今天通过 55 道与哈希表相关的题目来进行学习。


380. O(1) 时间插入、删除和获取随机元素

实现 RandomizedSet 类:

  • RandomizedSet() 初始化 RandomizedSet 对象
  • bool insert(int val) 当元素 val 不存在时,向集合中插入该项,并返回 true;否则,返回 false
  • bool remove(int val) 当元素 val 存在时,从集合中移除该项,并返回 true;否则,返回 false
  • int getRandom() 随机返回现有集合中的一项(测试用例保证调用此方法时集合中至少存在一个元素)。每个元素应该有 相同的概率 被返回。

你必须实现类的所有函数,并满足每个函数的 平均 时间复杂度为 O(1)O(1)

示例:

输入
["RandomizedSet", "insert", "remove", "insert", "getRandom", "remove", "insert", "getRandom"]
[[], [1], [2], [2], [], [1], [2], []]

输出
[null, true, false, true, 2, true, false, 2]

解释
RandomizedSet randomizedSet = new RandomizedSet();
randomizedSet.insert(1); // 向集合中插入 1 。返回 true 表示 1 被成功地插入。
randomizedSet.remove(2); // 返回 false ,表示集合中不存在 2 。
randomizedSet.insert(2); // 向集合中插入 2 。返回 true 。集合现在包含 [1,2] 。
randomizedSet.getRandom(); // getRandom 应随机返回 1 或 2 。
randomizedSet.remove(1); // 从集合中移除 1 ,返回 true 。集合现在包含 [2] 。
randomizedSet.insert(2); // 2 已在集合中,所以返回 false 。
randomizedSet.getRandom(); // 由于 2 是集合中唯一的数字,getRandom 总是返回 2 。

提示:

  • 231<=val<=2311-2^{31} <= val <= 2^{31} - 1
  • 最多调用 insertremovegetRandom 函数 2 1052 * 10^5
  • 在调用 getRandom 方法时,数据结构中 至少存在一个 元素。
哈希表 + 删除交换

对于 insertremove 操作容易想到使用「哈希表」来实现 O(1)O(1) 复杂度,但对于 getRandom 操作,比较理想的情况是能够在一个数组内随机下标进行返回。

将两者结合,我们可以将哈希表设计为:以入参 val 为键,数组下标 loc 为值。

为了确保严格 O(1)O(1),我们不能「使用拒绝采样」和「在数组非结尾位置添加/删除元素」。

因此我们需要申请一个足够大的数组 nums(利用数据范围为 21052* 10^5),并使用变量 idx 记录当前使用到哪一位(即下标在 [0,idx][0, idx] 范围内均是存活值)。

对于几类操作逻辑:

  • insert 操作:使用哈希表判断 val 是否存在,存在的话返回 false,否则将其添加到 nums,更新 idx,同时更新哈希表;
  • remove 操作:使用哈希表判断 val 是否存在,不存在的话返回 false,否则从哈希表中将 val 删除,同时取出其所在 nums 的下标 loc,然后将 nums[idx] 赋值到 loc 位置,并更新 idx(含义为将原本处于 loc 位置的元素删除),同时更新原本位于 idx 位置的数在哈希表中的值为 loc(若 locidx 相等,说明删除的是最后一个元素,这一步可跳过);
  • getRandom 操作:由于我们人为确保了 [0,idx][0, idx] 均为存活值,因此直接在 [0,idx+1)[0, idx + 1) 范围内进行随机即可。

代码:

class RandomizedSet {
    static int[] nums = new int[200010];
    Random random = new Random();
    Map<Integer, Integer> map = new HashMap<>();
    int idx = -1;
    public boolean insert(int val) {
        if (map.containsKey(val)) return false;
        nums[++idx] = val;
        map.put(val, idx);
        return true;
    }
    public boolean remove(int val) {
        if (!map.containsKey(val)) return false;
        int loc = map.remove(val);
        if (loc != idx) map.put(nums[idx], loc);
        nums[loc] = nums[idx--];
        return true;
    }
    public int getRandom() {
        return nums[random.nextInt(idx + 1)];
    }
}
  • 时间复杂度:所有操作均为 O(1)O(1)
  • 空间复杂度:O(n)O(n)

895. 最大频率栈

设计一个类似堆栈的数据结构,将元素推入堆栈,并从堆栈中弹出出现频率最高的元素。

实现 FreqStack 类:

  • FreqStack() 构造一个空的堆栈。
  • void push(int val) 将一个整数 val 压入栈顶。
  • int pop() 删除并返回堆栈中出现频率最高的元素。

如果出现频率最高的元素不只一个,则移除并返回最接近栈顶的元素。

示例 1:

输入:
["FreqStack","push","push","push","push","push","push","pop","pop","pop","pop"],
[[],[5],[7],[5],[7],[4],[5],[],[],[],[]]

输出:[null,null,null,null,null,null,null,5,7,5,4]

解释:
FreqStack = new FreqStack();
freqStack.push (5);//堆栈为 [5]
freqStack.push (7);//堆栈是 [5,7]
freqStack.push (5);//堆栈是 [5,7,5]
freqStack.push (7);//堆栈是 [5,7,5,7]
freqStack.push (4);//堆栈是 [5,7,5,7,4]
freqStack.push (5);//堆栈是 [5,7,5,7,4,5]
freqStack.pop ();//返回 5 ,因为 5 出现频率最高。堆栈变成 [5,7,5,7,4]。
freqStack.pop ();//返回 7 ,因为 5 和 7 出现频率最高,但7最接近顶部。堆栈变成 [5,7,5,4]。
freqStack.pop ();//返回 5 ,因为 5 出现频率最高。堆栈变成 [5,7,4]。
freqStack.pop ();//返回 4 ,因为 4, 5 和 7 出现频率最高,但 4 是最接近顶部的。堆栈变成 [5,7]。

提示:

  • 0<=val<=1090 <= val <= 10^9
  • push 和 pop 的操作数不大于 2×1042 \times 10^4
  • 输入保证在调用 pop 之前堆栈中至少有一个元素
哈希表

这是一道很纯的哈希表题儿。

首先,我们容易想到建立 第一个哈希表 cnts 用于记录某个数值的出现次数,cnts[val] = c 含义为数值 val 当前在栈中的出现次数为 c。我们称该哈希表为「计数哈希表」

再结合每次 pop 需要返回「频率最大的元素,若有多个则返回最考虑栈顶的一个」的要求,我们还可以 建立第二个哈希 map,该哈希表以「出现次数 c」为键,以「出现次数均为 c 的元素序列」为值,map[c] = A = [...] 含义为出现次数为 c 的序列为 A,并且序列 A 中的结尾元素为出现次数为 c 的所有元素中最靠近栈顶的元素。我们称该哈希表为「分桶哈希表」

最后再额外使用一个变量 max 记录当前最大出现频数,不难发现,max 必然是以步长 ±1\pm 1 进行变化(当出现次数为 max 的元素被 pop 掉了一个后,必然剩下 max - 1 个),因此当我们在某次 pop 操作后发现出现次数为 max 的集合为空时,对 max 进行自减操作即可。

将题目给的样例作为 🌰 ,大家可以看看 cntsmapmax 三者如何变化,以及 pop 的更新逻辑:

Python 代码:

class FreqStack:
    def __init__(self):
        self.cnts = defaultdict(int)
        self.map = defaultdict(list)
        self.mv = 0

    def push(self, val: int) -> None:
        self.cnts[val] += 1
        c = self.cnts[val]
        self.map[c].append(val)
        self.mv = max(self.mv, c)

    def pop(self) -> int:
        ans = self.map[self.mv].pop()
        self.cnts[ans] -= 1
        self.mv -= 0 if self.map[self.mv] else 1
        return ans
  • 时间复杂度:所有操作均为 O(1)O(1)
  • 空间复杂度:所有入栈的节点最多会被存储两次,一次在计数哈希表中,一次在分桶哈希表中,复杂度为 O(n)O(n)

388. 文件的最长绝对路径

假设有一个同时存储文件和目录的文件系统。下图展示了文件系统的一个示例:

这里将 dir 作为根目录中的唯一目录。dir 包含两个子目录 subdir1subdir2

subdir1 包含文件 file1.ext 和子目录 subsubdir1subdir2 包含子目录 subsubdir2,该子目录下包含文件 file2.ext

在文本格式中,如下所示(⟶表示制表符):

dir
⟶ subdir1
⟶ ⟶ file1.ext
⟶ ⟶ subsubdir1
⟶ subdir2
⟶ ⟶ subsubdir2
⟶ ⟶ ⟶ file2.ext

如果是代码表示,上面的文件系统可以写为 "dir\n\tsubdir1\n\t\tfile1.ext\n\t\tsubsubdir1\n\tsubdir2\n\t\tsubsubdir2\n\t\t\tfile2.ext"'\n''\t' 分别是换行符和制表符。

文件系统中的每个文件和文件夹都有一个唯一的 绝对路径 ,即必须打开才能到达文件/目录所在位置的目录顺序,所有路径用 '/' 连接。上面例子中,指向 file2.ext 的 绝对路径 是 "dir/subdir2/subsubdir2/file2.ext" 。每个目录名由字母、数字和/或空格组成,每个文件名遵循 name.extension 的格式,其中 name 和 extension 由字母、数字和/或空格组成。

给定一个以上述格式表示文件系统的字符串 input ,返回文件系统中 指向 文件 的 最长绝对路径 的长度 。 如果系统中没有文件,返回 00

示例 1:

输入:input = "dir\n\tsubdir1\n\tsubdir2\n\t\tfile.ext"

输出:20

解释:只有一个文件,绝对路径为 "dir/subdir2/file.ext" ,路径长度 20

提示:

  • 1<=input.length<=1041 <= input.length <= 10^4
  • input 可能包含小写或大写的英文字母,一个换行符 '\n',一个制表符 '\t',一个点 '.',一个空格 ' ',和数字。
模拟 + 哈希表

为了方便,我们将 input 替换为 s

对于每一个文件或文件夹而言,我们可以通过访问到结尾(\n)的方式取得,记为 cur,然后根据 cur 前面有多少个 \t 得知其所在的层级,假设当前其所在层级为 level,那么它自然归属到最新一个层级为 level - 1 的文件夹中,因此我们可以使用哈希表记录每个层级最新的文件夹路径,通过字符串拼接的方式得到 cur 所在的完整路径 path,并在处理整个 s 过程中,统计长度最大的文件路径。

代码:

class Solution {
    public int lengthLongestPath(String s) {
        Map<Integer, String> map = new HashMap<>();
        int n = s.length();
        String ans = null;
        for (int i = 0; i < n; ) {
            int level = 0;
            while (i < n && s.charAt(i) == '\t' && ++level >= 0) i++;
            int j = i;
            boolean isDir = true;
            while (j < n && s.charAt(j) != '\n') {
                if (s.charAt(j++) == '.') isDir = false;
            }
            String cur = s.substring(i, j);
            String prev = map.getOrDefault(level - 1, null);
            String path = prev == null ? cur : prev + "/" + cur;
            if (isDir) map.put(level, path);
            else if (ans == null || path.length() > ans.length()) ans = path;
            i = j + 1;
        }
        return ans == null ? 0 : ans.length();
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(n)O(n)
优化

上述做法只是为了方便我们输出具体方案。

实际上,我们只关心最终的路径长度,而不关心具体路径,因此容易将解法一修改为只记录长度,而不记录路径的做法,从而避免掉字符串拼接带来的消耗,同时利用 s 的长度数据范围,使用数组来替代常数较大的哈希表。

代码:

class Solution {
    static int[] hash = new int[10010];
    public int lengthLongestPath(String s) {
        Arrays.fill(hash, -1);
        int n = s.length(), ans = 0;
        for (int i = 0; i < n; ) {
            int level = 0;
            while (i < n && s.charAt(i) == '\t' && ++level >= 0) i++;
            int j = i;
            boolean isDir = true;
            while (j < n && s.charAt(j) != '\n') {
                if (s.charAt(j++) == '.') isDir = false;
            }
            Integer cur = j - i;
            Integer prev = level - 1 >= 0 ? hash[level - 1] : -1;
            Integer path = prev + 1 + cur;
            if (isDir) hash[level] = path;
            else if (path > ans) ans = path;
            i = j + 1;
        }
        return ans;
    }
}
  • 时间复杂度:O(n)O(n)
  • 空间复杂度:O(C)O(C)

146. LRU 缓存机制

运用你所掌握的数据结构,设计和实现一个  LRU (最近最少使用) 缓存机制 。 实现 LRUCache 类:

  • LRUCache(int capacity) 以正整数作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 1-1
  • void put(int key, int value) 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字-值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。

进阶:你是否可以在 O(1)O(1) 时间复杂度内完成这两种操作?

示例:

输入
["LRUCache", "put", "put", "get", "put", "get", "put", "get", "get", "get"]
[[2], [1, 1], [2, 2], [1], [3, 3], [2], [4, 4], [1], [3], [4]]
输出
[null, null, null, 1, null, -1, null, -1, 3, 4]

解释
LRUCache lRUCache = new LRUCache(2);
lRUCache.put(1, 1); // 缓存是 {1=1}
lRUCache.put(2, 2); // 缓存是 {1=1, 2=2}
lRUCache.get(1);    // 返回 1
lRUCache.put(3, 3); // 该操作会使得关键字 2 作废,缓存是 {1=1, 3=3}
lRUCache.get(2);    // 返回 -1 (未找到)
lRUCache.put(4, 4); // 该操作会使得关键字 1 作废,缓存是 {4=4, 3=3}
lRUCache.get(1);    // 返回 -1 (未找到)
lRUCache.get(3);    // 返回 3
lRUCache.get(4);    // 返回 4

提示:

  • 1<=capacity<=30001 <= capacity <= 3000
  • 0<=key<=30000 <= key <= 3000
  • 0<=value<=1040 <= value <= 10^4
  • 最多调用 31043 * 10^4getput
基本分析

LRU 是一种十分常见的页面置换算法。

将 LRU 翻译成大白话就是:当不得不淘汰某些数据时(通常是容量已满),选择最久未被使用的数据进行淘汰。

题目让我们实现一个容量固定的 LRUCache 。如果插入数据时,发现容器已满时,则先按照 LRU 规则淘汰一个数据,再将新数据插入,其中「插入」和「查询」都算作一次“使用”。

可以通过 🌰 来理解,假设我们有容量为 22LRUCache 和 测试键值对 [1-1,2-2,3-3] ,将其按照顺序进行插入 & 查询:

  • 插入 1-1,此时最新的使用数据为 1-1
  • 插入 2-2,此时最新使用数据变为 2-2
  • 查询 1-1,此时最新使用数据为 1-1
  • 插入 3-3,由于容器已经达到容量,需要先淘汰已有数据才能插入,这时候会淘汰 2-23-3 成为最新使用数据

键值对存储方面,我们可以使用「哈希表」来确保插入和查询的复杂度为 O(1)O(1)

另外我们还需要额外维护一个「使用顺序」序列。

我们期望当「新数据被插入」或「发生键值对查询」时,能够将当前键值对放到序列头部,这样当触发 LRU 淘汰时,只需要从序列尾部进行数据删除即可。

期望在 O(1)O(1) 复杂度内调整某个节点在序列中的位置,很自然想到双向链表。

双向链表

具体的,我们使用哈希表来存储「键值对」,键值对的键作为哈希表的 Key,而哈希表的 Value 则使用我们自己封装的 Node 类,Node 同时作为双向链表的节点。

  • 插入:检查当前键值对是否已经存在于哈希表:

    • 如果存在,则更新键值对,并将当前键值对所对应的 Node 节点调整到链表头部(refresh 操作)

    • 如果不存在,则检查哈希表容量是否已经达到容量:

      • 没达到容量:插入哈希表,并将当前键值对所对应的 Node 节点调整到链表头部(refresh 操作)
      • 已达到容量:先从链表尾部找到待删除元素进行删除(delete 操作),然后再插入哈希表,并将当前键值对所对应的 Node 节点调整到链表头部(refresh 操作)
  • 查询:如果没在哈希表中找到该 Key,直接返回 1-1;如果存在该 Key,则将对应的值返回,并将当前键值对所对应的 Node 节点调整到链表头部(refresh 操作)

一些细节: 为了减少双向链表左右节点的「判空」操作,我们预先建立两个「哨兵」节点 headtail

代码:

class LRUCache {
    class Node {
        int k, v;
        Node l, r;
        Node(int _k, int _v) {
            k = _k;
            v = _v;
        }
    }
    int n;
    Node head, tail;
    Map<Integer, Node> map;
    public LRUCache(int capacity) {
        n = capacity;
        map = new HashMap<>();
        head = new Node(-1, -1);
        tail = new Node(-1, -1);
        head.r = tail;
        tail.l = head;
    }
    
    public int get(int key) {
        if (map.containsKey(key)) {
            Node node = map.get(key);
            refresh(node);
            return node.v;
        } 
        return -1;
    }
    
    public void put(int key, int value) {
        Node node = null;
        if (map.containsKey(key)) {
            node = map.get(key);
            node.v = value;
        } else {
            if (map.size() == n) {
                Node del = tail.l;
                map.remove(del.k);
                delete(del);
            }
            node = new Node(key, value);
            map.put(key, node);
        }
        refresh(node);
    }
	
    // refresh 操作分两步:
    // 1. 先将当前节点从双向链表中删除(如果该节点本身存在于双向链表中的话)
    // 2. 将当前节点添加到双向链表头部
    void refresh(Node node) {
        delete(node);
        node.r = head.r;
        node.l = head;
        head.r.l = node;
        head.r = node;
    }
	
    // delete 操作:将当前节点从双向链表中移除
    // 由于我们预先建立 head 和 tail 两位哨兵,因此如果 node.l 不为空,则代表了 node 本身存在于双向链表(不是新节点)
    void delete(Node node) {
        if (node.l != null) {
            Node left = node.l;
            left.r = node.r;
            node.r.l = left;
        }
    }
}
  • 时间复杂度:各操作均为 O(1)O(1)
  • 空间复杂度:O(n)O(n)

1218. 最长定差子序列

给你一个整数数组 arr 和一个整数 difference,请你找出并返回 arr 中最长等差子序列的长度,该子序列中相邻元素之间的差等于 difference

子序列 是指在不改变其余元素顺序的情况下,通过删除一些元素或不删除任何元素而从 arr 派生出来的序列。

示例 1:

输入:arr = [1,2,3,4], difference = 1

输出:4

解释:最长的等差子序列是 [1,2,3,4]

提示:

  • 1<=arr.length<=1051 <= arr.length <= 10^5
  • 104<=arr[i],difference<=104-10^4 <= arr[i], difference <= 10^4
状态机序列 DP + 哈希表

定义 f[i][j]f[i][j]jj0011) 为代表考虑前 ii 个数,且第 ii 个数的选择情况为 jj 时,得到的最长定差子序列长度。

最终答案为 max(f[n1][0],f[n1][1])\max(f[n - 1][0], f[n - 1][1]),同时我们有显然的初始化条件 f[0][0]=0f[0][0] = 0f[0][1]=1f[0][1] = 1

不失一般性考虑 f[i][j]f[i][j] 如何转移:

  • f[i][0]f[i][0]:明确了第 ii 个不选,那么此时最大长度为前一个位置的结果。即有:
f[i][0]=max(f[i1][0],f[i1][1])f[i][0] = \max(f[i - 1][0], f[i - 1][1])
  • f[i][1]f[i][1]:明确了第 ii 个要选,此时进行分情况讨论:

    • arr[i]arr[i] 独立成为一个子序列,此时有:f[i][1]=1f[i][1] = 1

    • arr[i]arr[i] 接在某一个数的后面,由于给定了差值 differencedifference,可直接算得上一位的值为 prev=arr[i]differenceprev = arr[i] - difference,此时应当找到值为 prevprev,下标最大(下标小于 ii)的位置,然后从该位置转移过来,即有:f[i][1]=f[hash[prev]][1]+1f[i][1] = f[hash[prev]][1] + 1;

    容易证明:如果存在多个位置的值为 prevprev,从中选择一个下标最大的位置(下标小于 ii)进行转移,结果相比于最优位置不会变差。因此我们「贪心」选择下标最大的位置(下标小于 ii)即可,这引导我们在转移过程中使用「哈希表」记录处理过的位置的值信息。

    综上,我们有:

f[i][1]={1hash[arr[i]difference]=1f[hash[prev]][1]+1hash[arr[i]difference]1f[i][1] = \begin{cases} 1 & hash[arr[i] - difference] = -1 \\ f[hash[prev]][1] + 1 & hash[arr[i] - difference] \neq -1 \end{cases}

代码(使用数组充当哈希表的代码在 P2P2):

class Solution {
    public int longestSubsequence(int[] arr, int d) {
        int n = arr.length;
        Map<Integer, Integer> map = new HashMap<>();
        int[][] f = new int[n][2];
        f[0][1] = 1;
        map.put(arr[0], 0);
        for (int i = 1; i < n; i++) {
            f[i][0] = Math.max(f[i - 1][0], f[i - 1][1]);
            f[i][1] = 1;
            int prev = arr[i] - d;
            if (map.containsKey(prev)) f[i][1] = Math.max(f[i][1], f[map.get(prev)][1] + 1);
            map.put(arr[i], i);
        }
        return Math.max(f[n - 1][0], f[n - 1][1]);
    }
}
class Solution {
    int N = 40009, M = N / 2;
    public int longestSubsequence(int[] arr, int d) {
        int n = arr.length;
        int[] hash = new int[N];
        Arrays.fill(hash, -1);
        int[][] f = new int[n][2];
        f[0][1] = 1;
        hash[arr[0] + M] = 0;
        for (int i = 1; i < n; i++) {
            f[i][0] = Math.max(f[i - 1][0], f[i - 1][1]);
            f[i][1] = 1;
            int prev = arr[i] - d;
            if (hash[prev + M] != -1) f[i][1] = Math.max(f[i][1], f[hash[prev + M]][1] + 1);
            hash[arr[i] + M] = i;
        }
        return Math.max(f[n - 1][0], f[n - 1][1]);
    }
}
  • 时间复杂度:令 nn 为数组长度,共有 n2n * 2 个状态需要被计算,每个状态转移的复杂度为 O(1)O(1)。整体复杂度为 O(n)O(n)
  • 空间复杂度:O(n)O(n)
优化状态定义

不难发现,我们多定义一维状态来区分某个位置的值是否被选择,目的是为了正确转移出第 ii 位被选择的情况。

事实上,利用哈希表本身我们就能轻松做到这一点。

我们调整状态定义为:f[i]f[i] 为考虑前 ii 个数(第 ii 个数必选)时,得到的最长定差子序列长度。

不失一般性考虑 f[i]f[i] 该如何转移,分情况讨论:

  • arr[i]arr[i] 独立成为一个子序列,此时有:f[i]=1f[i] = 1
  • arr[i]arr[i] 接在某一个数的后面,由于给定了差值 differencedifference,可直接算得上一位的值为 prev=arr[i]differenceprev = arr[i] - difference,此时应当找到 arr[j]arr[j]prevprev 的最新位置(下标最大,同时满足 j<ij < i)当时的转移结果,在此基础上加一即可,即有:f[i]=hash[prev]+1f[i] = hash[prev] + 1;

综上,我们有(hashhash 初始化为 00):

f[i]=hash[prev]+1f[i] = hash[prev] + 1

代码(使用数组充当哈希表的代码在 P2P2):

class Solution {
    public int longestSubsequence(int[] arr, int d) {
        int ans = 1;
        Map<Integer, Integer> map = new HashMap<>();
        for (int i : arr) {
            map.put(i, map.getOrDefault(i - d, 0) + 1);
            ans = Math.max(ans, map.get(i));
        }
        return ans;
    }
}
class Solution {
    int N = 40009, M = N / 2;
    public int longestSubsequence(int[] arr, int d) {
        int ans = 1;
        int[] hash = new int[N];
        for (int i : arr) {
            hash[i + M] = hash[i - d + M] + 1;
            ans = Math.max(ans, hash[i + M]);
        }
        return ans;
    }
}
  • 时间复杂度:令 nn 为数组长度,共有 nn 个状态需要被计算,每个状态转移的复杂度为 O(1)O(1)。整体复杂度为 O(n)O(n)
  • 空间复杂度:O(n)O(n)

总结

相比于「数组」或是「链表」,哈希表能够有效假设我们的查询效率,在一些检索目标数据为瓶颈的算法题中,引入「哈希表」来降低复杂度往往是解题关键。