从“会用”到“巧用”的思维转变
大多数开发者都熟悉数组、链表、栈、队列、哈希表等基础数据结构,但面对具体问题时,往往陷入“只会用ArrayList和HashMap”的思维定式。巧用的核心在于:根据问题的访问模式和数据特征,选择能最大化发挥时间与空间优势的结构,并利用其底层特性实现非直观的解法。本文将通过五个实战场景,展示这种思维如何落地。
技巧一:用数组实现O(1)复杂度的LRU缓存
LRU(最近最少使用)缓存通常用“哈希表+双向链表”实现,但若数据量固定且较小,可巧用数组+时间戳。思路是:用一个数组存储键值对,并额外记录每个元素最后访问的“逻辑时钟”。访问时更新时钟并记录最大时钟;缓存满时,线性扫描数组找到最小时钟的槽位覆盖。
class ArrayLRU { private int[] keys, values, timestamps; private int size, clock; public ArrayLRU(int cap) { keys = new int[cap]; values = new int[cap]; timestamps = new int[cap]; size = 0; clock = 0; } public int get(int key) { for (int i = 0; i < size; i++) { if (keys[i] == key) { timestamps[i] = ++clock; return values[i]; } } return -1; } public void put(int key, int val) { for (int i = 0; i < size; i++) { if (keys[i] == key) { values[i] = val; timestamps[i] = ++clock; return; } } if (size < keys.length) { keys[size] = key; values[size] = val; timestamps[size] = ++clock; size++; } else { int minIdx = 0; for (int i = 1; i < size; i++) if (timestamps[i] < timestamps[minIdx]) minIdx = i; keys[minIdx] = key; values[minIdx] = val; timestamps[minIdx] = ++clock; } }}此方法避免了链表节点分配与指针操作,在容量小(如几十个)时,线性扫描的常数极小,实际性能可能优于复杂实现。适用场景:缓存容量固定、get/put频率均衡的小型嵌入式系统。
技巧二:双栈实现浏览器前进后退
浏览器的前进后退是典型的“栈”应用,但一个栈不够。巧用两个栈:当前页面作为分界点,后退栈保存历史,前进栈保存被后退弹出的页面。
- 访问新页面:清空前进栈,将当前页面压入后退栈。
- 后退:若后退栈不空,将当前页面压入前进栈,并从后退栈弹出作为新当前页。
- 前进:若前进栈不空,将当前页面压入后退栈,并从前进栈弹出作为新当前页。
这个模式同样适用于任何“撤销/重做”功能。核心是用两个栈的对称操作模拟一个可双向移动的游标,避免了数组插入删除的O(n)开销。
技巧三:巧用哈希表与双向链表实现O(1)的LFU
LFU(最不经常使用)比LRU更复杂,需要同时考虑访问频率和最近时间。巧用“频率桶”结构:外层哈希表映射频率到双向链表,内层哈希表映射键到链表节点。每个节点存储键值、访问计数。访问时,将节点从当前频率链表移除,插入到频率+1的链表头部。
// 伪代码示意访问逻辑void access(key) { Node node = keyToNode.get(key); int freq = node.freq; freqMap.get(freq).remove(node); if (freqMap.get(freq).isEmpty()) freqMap.remove(freq); node.freq++; freqMap.computeIfAbsent(freq+1, k->new LinkedList<>()).addFirst(node);}淘汰时,只需找到最小频率的桶,移除其尾部节点。所有操作均为O(1)。关键点在于用“桶”将相同频率的元素聚在一起,再用哈希表直接定位节点,避免了扫描。
技巧四:单调栈解决“下一个更大元素”
给定数组,求每个元素右边第一个比它大的元素。暴力法是O(n^2),巧用单调递减栈可降至O(n)。遍历数组,当当前元素大于栈顶元素时,说明栈顶元素的下一个更大元素就是当前元素,弹出并记录结果。
int[] nextGreater(int[] nums) { int[] res = new int[nums.length]; Arrays.fill(res, -1); Deque<Integer> stack = new ArrayDeque<>(); // 存下标 for (int i = 0; i < nums.length; i++) { while (!stack.isEmpty() && nums[i] > nums[stack.peek()]) { res[stack.pop()] = nums[i]; } stack.push(i); } return res;}单调栈的巧妙之处在于利用栈内元素的单调性,一次性消除了大量无效比较。同样的思路可解决接雨水、柱状图最大矩形等难题。
技巧五:用堆解决Top K高频词
统计一篇文章中出现频率最高的K个词。先用哈希表统计频率,然后维护一个大小为K的最小堆(按频率排序)。遍历哈希表,若堆未满则插入;若堆已满且当前词频大于堆顶,则替换堆顶并调整堆。
PriorityQueue<Map.Entry<String,Integer>> minHeap = new PriorityQueue<>( (a,b) -> a.getValue() - b.getValue());for (Map.Entry<String,Integer> e : freqMap.entrySet()) { if (minHeap.size() < K) minHeap.offer(e); else if (e.getValue() > minHeap.peek().getValue()) { minHeap.poll(); minHeap.offer(e); }}最终堆内元素即Top K。时间复杂度O(n log K),当K远小于n时,远优于全排序。核心是“用小根堆保留最大K个”的反向思维,避免了存储全部数据。
巧用数据结构的本质不是炫技,而是深刻理解每种结构的“性格”与代价,再结合问题约束,选择最合适的组合。多练习从访问模式、空间限制、操作频率三个维度去分析,你也能写出让人眼前一亮的代码。