算法-02-双指针与单调栈

330 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

双指针与单调栈

本节分享一下双指针与单调栈相关的题目和解法,虽然他们俩实现起来截然不同,但是解决的问题是类似的,他们通常和上一节的前缀和一样解决的是子段问题。

双指针

双指针是用来解决子段的统计问题,而且该子段中间的部分不会影响到结果,我们只需要关心两头指针的移动,比如说一个端点会跟随另外一个端点单调移动,这种“滑动窗口”,是典型的双指针问题。

例题1:LeetCode15 三数之和

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        Arrays.sort(nums);
        List<List<Integer>> res = new ArrayList<>();
        for (int i = 0; i < nums.length; i++) {
            if (i != 0 && nums[i] == nums[i - 1]) continue;
            int left = i + 1;
            int right = nums.length - 1;
            while (left < right) {
                int now = nums[left] + nums[right] + nums[i];
                if (now < 0) left++;
                else if (now > 0) right--;
                else {
                    ArrayList<Integer> nowL = new ArrayList<>();
                    nowL.add(nums[left]);
                    nowL.add(nums[right]);
                    nowL.add(nums[i]);
                    res.add(nowL);
                    while (left < right && nums[left] == nums[left + 1]) left++;
                    while (left < right && nums[right] == nums[right - 1]) right--;
                    left++;
                    right--;
                }
            }
        }
        return res;
    }
}

首先我们需要对数组进行排序,如果是无序的数组双指针就无法判断如何移动了。

在求三数之和之前,可以想一下在有序数组中求两数之和应该怎么求,其实在求两数之和的时候我们已经用到了双指针,将一个指针放在数组头,一个指针放在数组尾,如果这两个数相加大于target,那么右指针相左移,否则的话左指针向右移。为了防止结果重复,如果找到一组相加为target的,那么左指针应该向右移,直到当前的数字和刚刚左指针所指的数不同,右指针同理。

如果我们要求三个数的和,我们必须得固定一个不变量,再用双指针的方法。那么三数之和其实就是在两数之和的基础上多加了一层循环,把第一个数固定,作为这个子数组的头,左指针指向这个数加一的位置,右指针指向数组尾。接下来的步骤就和两数求和一样了。其实四数之和也是同理,只是在外侧再加一个循环,固定子数组的尾部。

类似题目:LeetCode18

例题2:LeetCode11 盛最多水的容器

class Solution {
    public int maxArea(int[] height) {
        int left = 0, right = height.length - 1;
        int res = 0;
        while (left < right) {
            int low = Math.min(height[left], height[right]);
            res = Math.max(res, (right - left) * low);
            if (height[left] < height[right]) left++;
            else right--;
        }
        return res;
    }
}

有一个非常著名的理论叫做木桶定律,一个容器能装多少水取决于最短的那块板。而且这个容器能装多少水和中间是什么情况,只取决于两端的高度,因此我们选择双指针的方法。

当我们去计算容量时,找到两端更短的高度,再用这个高度乘上中间的宽度就得到了容量。我们在移动指针的时候只需要去移动更短的那个指针就可以,因为在移动指针的时候宽度肯定是更短了,如果我们期望有更大的容量的话只能去期待有更高的高度。

单调栈

单调栈顾名思义是栈内的数据都是具有单调性的,单调递增或递减。它和双指针一样都是解决子段问题,不一样的是,单调栈解决的问题需要考虑子段中间的部分,子段中间会影响最终的结果。双指针关注的是指针移动的时机,而单调栈则是关注数据进栈和出栈的时机。

例题1:LeetCode84 柱状图中的最大矩形

class Solution {
    class Shape {
        int width;
        int height;

        Shape (int width, int height) {
            this.width = width;
            this.height = height;
        }
    }

    public int largestRectangleArea(int[] heights) {
        LinkedList<Shape> stack = new LinkedList<>();
        int res = 0;
        for (int i = 0; i < heights.length; i++) {
            int nowWidth = 0;
            while (!stack.isEmpty() && stack.peek().height > heights[i]) {
                nowWidth += stack.peek().width;
                res = Math.max(res, nowWidth * stack.pop().height);
            }
            stack.push(new Shape(nowWidth + 1, heights[i]));
        }
        int nowWidth = 0;
        while (!stack.isEmpty()) {
            nowWidth += stack.peek().width;
            res = Math.max(res, nowWidth * stack.pop().height);
        }
        return res;
    }
}

这道题和上一道能装多少水的容器有点像,但是这道题不仅仅要考虑两端的情况,还要考虑中间子段的情况,那么我们使用单调栈的方法。

我们根据题面可知,如果说下一矩形比当前矩形要矮的话,当前矩形就无法以现在的高度继续延伸了,那我们就需要得出当前矩形的能够构成多大的矩形,也就是说我们需要出栈了,由此可以知道这道题栈内我们需要保存的应该是高度单调递增的矩形。

需要注意的是,我们遍历到的这个矩形不仅仅是向后拓展的,它还会向前拓展,因此我们在出栈的时候需要有一个变量去记录出栈的这个矩形的宽度,因为如果现在出栈的话说明当前遍历到的矩形肯定是比出栈的这个矩形要矮的,那我们就需要去用这个更矮的矩形去继承出栈矩形的宽度,同时这个nowWidth还是为接下来出栈的矩形服务。

当我们把整个数组遍历完成后,还需要把栈内的数据清空,最后返回结果。

例题2:LeetCode239 滑动窗口最大值

class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        ArrayDeque<Integer> queue = new ArrayDeque<>();
        int[] res = new int[nums.length - k + 1];
        int idx = 0;
        //init
        for (int i = 0; i < k; i++) {
            if (queue.isEmpty()) {
                queue.offer(nums[i]);
                continue;
            }
            while (!queue.isEmpty()) {
                if (queue.peekLast() < nums[i]) queue.pollLast();
                else break;
            }
            queue.offer(nums[i]);
        }
        res[idx++] = queue.peekFirst();

        for (int i = k; i < nums.length; i++) {
            if (nums[i - k] == queue.peekFirst()) queue.pollFirst();
            while (!queue.isEmpty()) {
                if (queue.peekLast() < nums[i]) queue.pollLast();
                else break;
            }
            queue.offer(nums[i]);
            res[idx++] = queue.peekFirst();
        }
        return res;
    }
}

这道题用一个故事来解释会更加形象,单调队列就像是一个王国,队列内最大的数永远排在第一就是这个国王。有一个数闯进来,如果现在王国空无一人那他就自动成为国王,如果有人,他就会看排在他前面的人是不是比他大,比他大的话他就乖乖地排队,如果比他小,那就不好意思,这个数会把比他小的数给干掉也就是出队列。

只有两种情况会更换国王,第一种情况就是寿终正寝,当前的滑动窗口不再包括他了,那么他就会出队列,另一种情况是一个比他更大的数闯了进来并且把他干掉。

由此可得,整个队列其实是单调递减的,最大的数永远排在第一个,依次往后逐渐递减。按照这个逻辑就可以进行代码书写。先对队列进行初始化,确定起始滑动窗口的情况,后面就是按照上述的逻辑进行出队列和进队列。

总结

由此可以看出双指针和单调栈虽然都是解决子段问题,但是双指针解决的问题是只关心指针两端的情况,指针中间的数据与结果无关,只需要解决指针如何移动的问题。而单调栈或者单调队列需要关心中间的数据,就像最大矩形需要关心中间的矩形是否能够扩张以及滑动窗口中间的值是否会成为最大值一样。

感谢观看!