时隔很久的更新……因为很多事情,很迷茫……
每种数据结构其实都有其语义。比如,树代表着均摊,而栈代表着回溯。
42. Trapping Rain Water

从图中可以看到,蓝色区域可以被划分为同样的宽度为 1 的柱子。这样,每个柱子的高度就比较容易计算了,它是左右最高的柱子当中比较小的那个,同时还要去掉它所在的位置黑色区域的面积。可以用一个数组来记录每个位置的最值,然后第二次遍历就可以计算蓝色区域的面积了。
除了上述做法,也可以把这个面积一次性计算出来,这就需要用到栈结构。通过观察我们可以发现,一块雨水总是由凹槽形成,且出现一个最高的柱子时,可以把之前的雨水面积和后面的分开(互不影响)。不过,如果一直没有遇到更高的柱子,雨水面积就会难以计算(如上图中最后一块的面积)——而且也不能简单的去掉这一块来计算,因为可能包含雨水。比如:

而使用单调栈可以将凹槽准确地找出来。这里将雨水的面积横向划分:

这样划分的目的在于,每次只需要出栈比当前位置矮的柱子,就可以清晰地把雨水面积分而治之地处理。再结合前面说的,每次最高的柱子都可以『分割计算』雨水面积,整个思路就非常简单了。
一点细节是要注意这个宽高度的计算,是按照栈顶下面的那个元素来比较的。
不过最后 rank 不是很好……
public int trap(int[] height) {
int res = 0;
/* TUNING */
Deque<Integer> down = new ArrayDeque<Integer>(height.length);
for (int i = 0; i < height.length; i++) {
if (down.isEmpty() && height[i] == 0) {
continue;
}
while (!down.isEmpty() && height[down.element()] < height[i]) {
int cur = down.pop();
if (down.isEmpty()) {
break;
}
int last = down.element();
res += ((height[last] > height[i] ? height[i] : height[last]) - height[cur]) * (i - last - 1);
}
down.push(i);
}
return res;
}
71. Simplify Path
这道题倒不是很难,也许算不上中等难度,但是有一些细节需要注意,比如开头的/和结尾的/,很容易多了或者漏了。我一开始就把结尾去掉这个/的判断写在了i=path.length() - 1
时,而测试用例有一个"/a/../../b/../c//.//"
我就没有通过,因为它的/c/
是在中间产生的。
我一开始写的不太好,主要是考虑..
这样的路径是否存在,后来发现测试用例里没有,但是题目也没有明确说明啊。
84. Largest Rectangle in Histogram
这道题和42题接雨水有异曲同工之妙,而且它们的题号都是正比的(暗示?)。
正如接雨水中,出现凹槽的条件是单调递减后递增,这里是先递增后递减。区别在于,当出现一个柱子高度低于栈顶时,不是按照这个柱子计算,而是计算栈顶所在位置的最大矩形(这个 timing 很好理解,此时这个最大矩形的面积相当于固定了)。且每次出栈都要计算。和接雨水一样,栈中的索引未必是连续的。比如1,4,3,2中,最后在2的位置时,压入1和3。此时2的最大矩形面积是它和1的距离乘以它的高度。
那如果一直递增怎么办呢?最简单的方法是加一个0。这样可以保证栈里所有的元素被清除(强制清除栈也是可以的,只是没有这样来的简单)。
这道题我吸取了前面使用Deque
的教训,自己用一个数组来做栈实现。另外也没有用泛型,因为拆箱有开销。
class Solution {
public class Stack {
int[] arr;
int point = 0;
public Stack(int capacity) {
arr = new int[capacity];
}
public int pop() {
return arr[--point];
}
public int element() {
return arr[point - 1];
}
public boolean isEmpty() {
return point == 0;
}
public void push(int x) {
arr[point++] = x;
}
}
public int largestRectangleArea(int[] heights) {
int res = 0;
Stack up = new Stack(heights.length + 1);
for (int i = 0; i < heights.length + 1; i++) {
int height = i == heights.length ? 0 : heights[i];
if (up.isEmpty() || height >= heights[up.element()]) {
} else {
int cur;
do {
int last = up.pop();
cur = up.isEmpty() ? -1 : up.element();
res = Math.max(res, (i - cur - 1) * heights[last]);
} while (!up.isEmpty() && heights[cur] > height);
}
up.push(i);
}
return res;
}
}
最后时间和空间的排名都还可以,大约90%和80%。 不过如果使用其他算法可能会更快一点。
94. Binary Tree Inorder Traversal
从实现上,先序,中序,后序遍历最大的区别是,前序不需要往回走,中序和后序都要往回。也就是说,要有放回。为了实现回溯,必须要能够区别需要回溯和不需要回溯的节点。中序解决这个问题的方法很简单:只放入左边已经被遍历的节点用于回溯。回溯的时候,我们立即输出这个节点,然后再按照正常节点的逻辑处理右子树即可。
先序遍历很常见,比如递归搜索文件夹就是先序的。也就是说,先序可以体现层次结构。这个在Windows里的dir
中可以看到。
后序遍历和先序相反,它需要先获取子树的值,因此用于删除节点的场景。
中序遍历似乎比较少见。它可以用于中缀表达式的表示。另外,对于二叉搜索树的中序正是结果的排序。
public List<Integer> inorderTraversal(TreeNode root) {
List<Integer> res = new LinkedList<Integer>();
Deque<TreeNode> stack = new ArrayDeque<TreeNode>();
while (root != null || !stack.isEmpty()) {
if (root == null) {
TreeNode back = stack.poll();
root = back.right;
res.add(back.val);
} else {
stack.push(root);
root = root.left;
}
}
return res;
}
103. Binary Tree Zigzag Level Order Traversal
这道题可以用两个栈解决,因为前一次最后打印的节点的子节点,是下一次最开始。不过因为题目没有约束,所以使用 Java 的双端队列会简洁和快速很多。
这个双端队列既需要输出(打印当前层的节点),同时需要输入(将该节点的左右节点入队)。为了避免输入和输出冲突,这两个过程必然是方向相反的;同时,又因为锯齿遍历的需求必须反转方向,因此每次需要改变输出的方向(输出口从头部变为尾部,或者相反)。同时一旦改变输出方向,输入方向也要变化。这个反转是在每一层遍历结束以后。
public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
Deque<TreeNode> q = new LinkedList<TreeNode>();
q.push(root);
List<List<Integer>> res = new LinkedList<List<Integer>>();
boolean order = false;
while (q.size() > 0) {
List<Integer> row = new LinkedList<Integer>();
int count = q.size();
while (count > 0) {
TreeNode node = order ? q.poll() : q.pollLast();
count--;
if (node == null) {
continue;
}
if (order) {
q.add(node.right);
q.add(node.left);
} else {
q.addFirst(node.left);
q.addFirst(node.right);
}
row.add(node.val);
}
order = !order;
if (row.size() > 0) {
res.add(row);
}
}
return res;
}
145. Binary Tree Postorder Traversal
这些非递归形式的代码还是有意义的,如果用递归实现,不共享全局变量无法实现尾递归。
后序遍历和中序遍历相比,中序在返回,也就是出栈时总是可以不放回根节点,而后序遍历在返回时,根节点必须被放回。那么,当后序遍历出栈时,栈中节点的状态是不是左右子树都被访问过了的节点?

可以看一下维基上的图,后序遍历的输出顺序是ACEDBHIGF。在当前的游标从 C 指向 D 时,D 的右子树还没有访问。从 E 返回时 D 的右子树已经被访问。
后序遍历有一个关键特点是右子树总是和根一起输出,比如 D 和 E,B 和 D,F 和 G。因此,只需要记录上一个访问的节点,如果它是右子树,就可以判断是否当前节点是否需要输出。但这一点并不完全成立。上一个访问的节点有可能不是右节点而是左节点(如果右节点为空),也可能是其他节点(说明当前节点是叶节点)。
public List<Integer> postorderTraversal(TreeNode root) {
List<Integer> res = new LinkedList<Integer>();
Deque<TreeNode> stack = new ArrayDeque<TreeNode>();
TreeNode last = null;
while (root != null || !stack.isEmpty()) {
if (root == null) {
while (!stack.isEmpty()
&& (stack.element().right == null || stack.element().right == last)) {
res.add((last = stack.pop()).val);
}
if (stack.isEmpty()) {
break;
}
root = stack.element().right;
} else {
stack.push(root);
root = root.left;
}
}
return res;
}
155. Min Stack
这道题虽然是 easy 题但很有意思。理论上,随着不断入栈出栈,最小值会不断变化,而要在常数内完成查询,似乎要进行排序。但这样做的话还需要索引对照,处理删除等复杂情况。更好的方法是给每个节点包含一个当前位置的最小值(在实现中,也许并不需要最小值的数量和栈的数量相同)。这是因为到栈的子结构的最小值具有不变性。
后来看了别人的题解,发现还可以用将最小值用差值的方式保存在栈顶,有点厉害。不过,这里其实也可以有一个计算和存储的tradeoff,因为每个值都计算差值似乎有点不划算。
225. Implement Stack using Queues
和最小栈一样,这道题也是非常有意思的。
为了实现 LIFO,需要将最近的元素放在队列头。使用双队列就可以做到这一点。类似于插入排序维护一个有序区,双队列可以让每次入栈的元素都作为队列头实现全局的 LIFO,代价是做一次遍历(这个代价似乎可以通过增加队列数量来均摊)。
另外还有用栈实现队列,思想和这里有一丢丢类似,不过更巧妙,使用了惰性加载的思想:两个栈都来做输入和输出,同时只有输出的栈用完的时候才会将输入栈倒过来。
331. Verify Preorder Serialization of a Binary Tree
这道题看上去是要验证这个树整个结构,实际上因为只给了待验证序列,所以只需要保证是否能形成一棵二叉树。从直觉上,可以自底向上去掉叶节点来验证:先去掉叶节点,然后倒数第二层的节点就变成了新的叶子,循环往复,直到发现异常。每次应该去掉2个#,然后将父节点设置为#。如果最后剩余奇数个#,说明是异常的。
感觉写的很流畅,但 rank 并不好……
public boolean isValidSerialization(String preorder) {
Deque<Boolean> stack = new LinkedList<Boolean>();
int i = 0;
while (i < preorder.length()) {
int end = preorder.indexOf(",", i);
if (end == -1) {
end = preorder.length();
}
String ch = preorder.substring(i, end);
if (ch.equals("#")) {
while (!stack.isEmpty() && stack.peek()) {
stack.pop();
if (stack.isEmpty()) {
return false;
}
stack.pop();
}
stack.push(true);
} else {
stack.push(false);
}
i = end + 1;
}
return stack.size() == 1 && stack.peek();
}
402. Remove K Digits
关键是发现题目中的贪心过程:从左开始遍历,如果一个元素比它的左邻居小,说明左邻居需要被删除。且这个删除是全局最优解的一部分。依次顺序遍历就可以得到满足条件的最小序列。
除此以外,还需要注意边界条件,比如遍历结束没有删除够 k 个数字,以及前置 0 的问题。最后的 rank 不太好,代码就不放了(我感觉没什么太大问题啊,现在的人都这么强了吗)……
456. 132 Pattern
假设我们找出每个位置左边的最小值,那么就可以继续找出每个位置右边(大于这个最小值)的最小值。这样我们就满足了1和3的条件,同时,遍历时可以找出每个位置是否大于右边的最小值。
哦不,稍等一下。似乎这里第二点不太好做。事实上我们需要挖掘到一个隐含条件:从右向左遍历时,左边的最小值总是递增的。所以只保留一个全局最小值是不可行的,因为下个位置可能这个值就不适用了。因此,精髓就是使用一个单调递减栈,栈顶最小——实际上也不需要刻意维护单调,因为一旦违反了单调,说明存在逆序,即返回 true。
顺便一提官方题解也是这么做的,不过大概没有我说的这么直白。说实话这道题虽然是 medium,我却有一种在做hard的感觉。
496. Next Greater Element I
找到下一个比自己大的元素,考虑到这种情况:6435,所以还是用单调栈。这道题还用到了哈希表查找,感觉麻雀虽小五脏俱全啊。
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
Stack stack = new Stack(nums2.length);
Map<Integer, Integer> nextGreater = new HashMap<Integer, Integer>(nums2.length);
for (int i : nums2) {
while (!stack.isEmpty() && stack.element() < i) {
nextGreater.put(stack.pop(), i);
}
stack.push(i);
}
while (!stack.isEmpty()) {
nextGreater.put(stack.pop(), -1);
}
for
return Arrays.stream(nums1).map(c -> nextGreater.get(c)).toArray();
}
时间很差,别人硬写循环都比我快,我吐了,测试用例有问题……
类似问题还有后面的每日温度,也是单调栈的套路。
503. Next Greater Element II
加强版是使用循环数组。最简单的思路,直接循环两次就行了——不过感觉这样做的话跟 medium难度不搭,不过我也没想到更好的。
当然依旧有一些细节需要处理,比如本题就允许重复值,出现多个最大值怎么办?
public int[] nextGreaterElements(int[] nums) {
Deque<Integer> stack = new ArrayDeque<Integer>(nums.length);
int[] nextGreater = new int[nums.length];
int count = 2;
for (int c = 0; c < count; c++) {
for (int i = 0; i < nextGreater.length; i++) {
while (!stack.isEmpty() && nums[stack.element()] < nums[i]) {
nextGreater[stack.pop()] = nums[i];
}
if (c == count - 1 && nums[stack.peek()] == nums[stack.peekLast()]) {
break;
}
stack.push(i);
}
}
while (!stack.isEmpty()) {
nextGreater[stack.pop()] = -1;
}
return nextGreater;
}
856. Score of Parentheses
这道题估计大多数人都会第一反应想到栈。看了一下题解,栈的解法和我想的完全一样,需要用一个0来标记当前的深度(其实是标记左括号)。
不过如果只是这样的话就不值得拿来说一说了。这道题还有一个终极解法:
public int scoreOfParentheses(String S) {
int ans = 0, bal = 0;
for (int i = 0; i < S.length(); ++i) {
if (S.charAt(i) == '(') {
bal++;
} else {
bal--;
if (S.charAt(i-1) == '(')
ans += 1 << bal;
}
}
return ans;
}
其实说是终极,也就是利用了题目中只有两种情况:要么是深度的叠加,那么是并列。这意味着,整个字符串可以通过分治来处理,而这里精髓之处在于,它是直接计算真正的核心分数,比如(()()())
,它其实是直接算出2+2+2,而不是(1+1+1)*2。
895. Maximum Frequency Stack
看到这道题,第一反应都是先弄一个每个元素的 Map。不过,无法得到最大的频率,而且题目还要求出栈按照栈的规则。为了不对这个 Map 进行遍历,可以再反向存储频率作为第二个 Map 的 key,对应的元素作为 value(类似于倒排索引)。
为了计算最大值,我使用了 TreeMap。其实应该使用优先队列也是同理。
class FreqStack {
private SortedMap<Integer, ArrayDeque<Integer>> freqs;
private Map<Integer, Integer> indexes;
public FreqStack() {
freqs = new TreeMap<Integer, ArrayDeque<Integer>>();
indexes = new HashMap<Integer, Integer>();
}
public void push(int x) {
if (indexes.get(x) == null) {
indexes.put(x, 1);
}
int index = indexes.getOrDefault(x, 0) + 1;
ArrayDeque<Integer> freq = freqs.computeIfAbsent(index, k -> new ArrayDeque<Integer>());
freq.push(x);
indexes.put(x, index);
}
public int pop() {
ArrayDeque<Integer> freq;
while ((freq = freqs.get(freqs.lastKey())).size() == 0) {
freqs.remove(freqs.lastKey());
}
int res = freq.pop();
indexes.compute(res, (k, v) -> v - 1);
return res;
}
}
另外,这道题其实是 LFU 和 LRU 题目的一个简化版本。
901. Online Stock Span
这道题是每日温度的变种,不提前给出数组的全部。此时不能用反向遍历的方式,但单调栈的思想还是可以运用的。每个位置的跨度,总是由大于它的上一个元素决定的。对于任何一个位置i,从0到i一旦确定了最大值,次大值,等等,位置i的跨度总是可以由前面的这些值表示——也就是说,并不需要存储所有跨度,因为按照单调栈存储信息量已经够了。
前面说的有点抽象。直白的说,比如9458,这里到8时栈中只有9和5,因为8比5大(反之,更不需要计算,因为到5已经为止了),所以一定能继承5的跨度。同时,又因为保留了9,所以8的跨度可以被准确的计算。
老实说Java 没有 tuple 实在很尴尬。
class StockSpanner {
LinkedList<Integer> stocks;
LinkedList<Integer> weights;
public StockSpanner() {
stocks = new LinkedList<Integer>();
weights = new LinkedList<Integer>();
}
public int next(int price) {
int weight = 1;
while (!stocks.isEmpty() && stocks.peek() <= price) {
stocks.pop();
weight += weights.pop();
}
stocks.push(price);
weights.push(weight);
return weight;
}
}
907. Sum of Subarray Minimums
思考一个很简单的问题:一个数组的连续子数组个数究竟有多少个?答案是个(通过观察也能发现)。这里是
是因为考虑到子数组为单个元素的情况,需要将左右边界设置为一个半闭半开区间。可以这样思考:子数组分别包括长度为1到n的数组,而长度为i的数组,个数必然等于长度为 i-1的数组个数减1——假设我们给所有长度为 i-1的数组在后面续上一个元素,那么正好有一个子数组不能满足,即包含数组第n 个元素的子数组。所以所有子数组个数为
。
回到这道题。所有以为最小值的子数组,是具有单调性的,它会被左右比它小的数字截断。也就是说,只要找到每个位置左右比
大的最远距离,就可以算出所有以
为最小值的子数组。
除了上述以外,还有许多非常棒的题目,不过我觉得可能不具有代表性就没有列出来了,可以在 LeetCode 自行按照标签搜索。