算法珠玑(二):栈

286 阅读15分钟

时隔很久的更新……因为很多事情,很迷茫……

每种数据结构其实都有其语义。比如,树代表着均摊,而栈代表着回溯。

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

思考一个很简单的问题:一个数组的连续子数组个数究竟有多少个?答案是\tbinom{n+1}{2} =n(n+1)/2个(通过观察也能发现)。这里是n+1是因为考虑到子数组为单个元素的情况,需要将左右边界设置为一个半闭半开区间。可以这样思考:子数组分别包括长度为1到n的数组,而长度为i的数组,个数必然等于长度为 i-1的数组个数减1——假设我们给所有长度为 i-1的数组在后面续上一个元素,那么正好有一个子数组不能满足,即包含数组第n 个元素的子数组。所以所有子数组个数为1+2+3+\dots+n

回到这道题。所有以A[i]为最小值的子数组,是具有单调性的,它会被左右比它小的数字截断。也就是说,只要找到每个位置左右比A[i]大的最远距离,就可以算出所有以A[i]为最小值的子数组。


除了上述以外,还有许多非常棒的题目,不过我觉得可能不具有代表性就没有列出来了,可以在 LeetCode 自行按照标签搜索。