从头再来!社招找工作——算法题复习十:双指针/前缀和/滑动窗口

双指针/前缀和/滑动窗口

本篇博客我们将把三个重要解题技巧放在一起讲,它们分别是双指针前缀和滑动窗口。为什么放在一起呢,因为实际上前缀和滑动窗口都在双指针的范畴内,在具体实现时也会用到两个下标或者索引或者指针,所以我们先介绍明确使用了双指针的一些算法题,然后针对前缀和滑动窗口讲解几道有意思的算法题。

双指针

双指针是一种解题技巧,它可以灵活运用在很多算法题题解中。
如果仔细阅读了我前面几篇博客的朋友们,相信对双指针一定不陌生了。我们在第二篇博客链表中介绍“如何判断链表中是否存在环”这道题目时,曾经提到过用快慢指针的方法。另外,在贪心算法中,也多次在代码注释中提到了双指针。其实,双指针的内涵很明确,就是用到了两个下标、索引或者指针,你可以给它们取名快、慢指针,也可以取名左、右指针,或者就是 i 和 j,反正只要是两个指针相互配合相互操作的,都可以叫做运用了双指针的解题技巧。
所以广泛上来说,我们已经运用过许多许多次双指针技巧了:二分查找中的左下标left和右下标right,链表的“合并有序链表”中一个指针指向A链表中的结点而另一个指向B链表中的结点,排序中快速排序确定哨兵元素后要设置左右指针,等等等。所以说,双指针的技巧,大家一定不陌生了,今天我们再来展示一些比较有趣的、用双指针可以快速解题的算法题。

判断是否为回文字符串(Easy)

牛客网
给定一个长度为 n 的字符串,请编写一个函数判断该字符串是否回文。

这道题不需要我来讲解了吧,相信大家都会做吧,左指针指向最左下标 0,右指针指向最右下标 length-1,然后向中间靠拢。代码如下:

public boolean isPalindrome(String s) {
	char[] cs = s.toCharArray();
	int left = 0;
	int right = cs.length - 1;
	while (left < right && cs[left] == cs[right]) {
		left++;
		right--;
	}
	return left >= right;
}

Leetcode上有一道题稍微对题目做了修改,但是内核思想是一样的。

盛水最多的容器(Middle)

牛客网
Leetcode
给定一个长度为 n 的整数数组 height。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。
找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水,返回容器可以储存的最大水量。你不能倾斜这个容器。

给大家一个 Leetcode 中例子,以便大家理解:
height[8] = {1, 8, 6, 2, 5, 4, 8, 3, 7}
图片来自于Leetcode
left = 1, right = 8 是能盛最多水的容器。

这道题在Leetcode中是 Hard 难度的,但我觉得其实也就 Middel 难度。大家只需要知道两点,就能用双指针轻松结这道题:
(1)已知 left 和 right 了,当前能盛多少水?这个比较简单,能盛 min{height[left], height[right]} * (right - left) 的水。
(2)已知 left 和 right 了,双指针如何移动?这个大家根据例子自行演变,相信也能得出答案:哪个挡板矮,哪个挡板就向中间移动。

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

前缀和

前缀和是双指针的一种特殊运用手段。它对于一维数组的解题过程一般是这样的:固定左指针 left 始终为0,右指针 right 则从 0 或者 1 开始向右遍历,在遍历的过程中不断对 (left, right) 区间的子数组进行某种操作。由于左指针不动、右指针向右,这个子数组就像是整个数组的前缀一样;又由于最常见的操作是加法,所以给这种方法取名叫“前缀和”。

数组的平均数值(Easy)

给定一个浮点数数组 nums,请返回一个浮点数数组,该数组中第 i 个元素是 nums 数组中前 i 个值的平均数。
这是一道我自己编出来的题目,用来给大家展示前缀和的基础应用。直接给出代码:

public float[] preAverage(float[] nums) {
	float[] result = new float[nums.length];
	result[0] = nums[0];
	for (int i = 1;i < len;i++) {
		nums[i] += nums[i-1]; // 相当于左指针一直为0,右指针为 i
		result[i] = sum / (i+1);
	}
	return result;
}

除自身外数组的乘积(Middle)

Leetcode
给定一个整数数组 nums,返回数组 answer,其中 answer[i] 等于 nums 中除了 nums[i] 之外其余各元素的乘积。保证数组 nums之中任意元素的全部前缀元素和后缀元素的乘积都在 32 位整数范围内。
要求:请不要使用除法,且在 O(n) 时间复杂度内完成此题。

如果没有题目中最后一句话的限制,我们其实能想到两种基础方法:第一种,先计算出所有元素的总乘积,然后遍历数组进行除法运算;第二种,循环两遍,外部循环是确定当前元素不参与成绩,内部循环对所有非外部循环确定的元素求得总乘积。
但是很遗憾,题目中最后一句话把这两种方法的路都给堵上了。我们再细细读一下题目,发现有个关键部分“前缀元素和后缀元素”,这就是在提示我们要采用前缀和的技巧来解题。
有些朋友们会说了,这里是乘法不是加法啊,而且不止有“前缀”还有“后缀”呢!这里啊大家不要陷入思维定势,不要说“前缀和”就只能解决“前缀”与“和”的问题。如果把这个数组倒过来看,后缀不就是前缀嘛!或者说,我们固定右指针 right = nums.length - 1,左指针不断向左遍历,这也是一种前缀和的变体。另外,求和只是前缀和最常见的一种应用,还可以求乘积、求平均值,都是可以的。

所以这道题的解法,就是先求出某个元素的不包含自身的“前缀积”数组,再同样取得“后缀积”数组,最后二者相乘,即得到题目要求的除自身外数组的乘积。

public int[] productExceptSelf(int[] nums) {
	int len = nums.length;
	int[] prefix = new int[len];
	prefix[0] = 1; // 由于是乘法运算,nums[0]左边的总乘积应为 1 而不是 0
	for (int i = 1;i < len;i++) {
		prefix[i] = prefix[i-1] * nums[i-1];
	}
	int[] postfix = new int[len];
	int[] answer = new int[len];
	postfix[len-1] = 1;
	answer[len-1] = prefix[len-1] * postfix[len-1];
	for (int i = len-2;i >= 0;i--) {
		postfix[i] = postfix[i+1] * nums[i+1];
		answer[i] = prefix[i] * postfix[i];
	}
	return answer;
}

二维整数数组的左上角部分的前缀和(Hard)

给定一个二维整数数组 nums,请返回一个数组 sum,sum[i, j] 是 nums 数组中 (i, j) 的左上角(包含)部分的总和。
这也是一道我自己编出来的题目,用来给大家展示二维前缀和的基础应用。

我们先根据定义,写出 sum 数组中每个元素是由哪些 nums 元素相加得到的:
sum[0, 0] = nums[0, 0]
sum[i, 0] = nums[0, 0] + nums[1, 0] + … + nums[i, 0] = sum[i-1, 0] + nums[i, 0], i >= 1
sum[0, j] = nums[0, 0] + nums[0, 1] + … + nums[0, j] = sum[0, j-1] + nums[0, j], j >= 1
sum[i, j] = nums[0, 0] + nums[1, 0] + … + nums[0, j]
+ nums[1, 0] + nums[1, 1] + … + nums[1, j]
+ …
+ nums[i, 0] + nums[i, 1] + … + nums[i, j], i >= 1 and j >= 1

大家可以看一下,sum[i, j] 确实可以被形象地称为“前缀和”,将 nums[i, j] 的左上角的数字都加起来了。这里求 sum 数组的过程还有点动态规划的味道,先是 sum[0, 0],再是 sum[i, 0],接着 sum[0, j],最后 sum[i, j]。不过有个问题,就是 sum[i, j] 具体怎么求呢?总不能真的再来个二重循环把所以 nums 元素加起来吧,应该想一个和已有的 sum 元素相关的等式,这才是动态规划的思想。而这点,也正是二维数组前缀和的 keypoint!
这里直接给出公式:
sum[i, j] = sum[i-1, j] + sum[i, j-1] - sum[i-1, j-1] + nums[i, j];
一种不完备的证明如下:
上述问题可以画成这样的图:
在这里插入图片描述

我们要证明的是:sum[x2, y2] = sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2))
先看等式左边 sum[x2, y2] = x2 * y2
再看等式右边
sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2))
= x1 * y2 + x2 * y1 - x1 * y1 + (x2-x1)*(y2-y1)
= x1 * y2 + x2 * y1 - x1 * y1 + x2 * y2 - x2 * y1 - x1 * y2 + x1 * y1
= (x1 * y2 - x1 * y2) + (x2 * y1 - x2 * y1) + (x1 * y1 - x1 * y1) + x2 * y2
= x2 * y2
得证。

用动态规划来获得二维前缀和的代码如下:

public int[][] maxSideLength(int[][] mat, int threshold) {
	int m = mat.length;
	int n = mat[0].length;
	int[][] sum = new int[m][n];
	sum[0][0] = nums[0][0];
	for (int i = 1;i < m;i++) {
		sum[i][0] = sum[i-1][0] + nums[i][0];
	}
	for (int j = 1;j < n;j++) {
		sum[0][j] = sum[0][j-1] + nums[0][j];
	}
	for (int i = 1;i < m;i++) {
		for (int j = 1;j < n;j++) {
			sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] + nums[i][j];
		}
	}
	return sum;
}

由刚才的等式:sum[x2, y2] = sum[x1, y2] + sum[x2, y1] - sum[x1, y1] + Area((x1, y1)-(x2,y2)),
还可以扩展出:Area((x1, y1)-(x2,y2)) = sum[x2, y2] - sum[x1, y2] - sum[x2, y1] + sum[x1, y1],
就是通过二维前缀和,计算二维数组中任意一个矩阵区域内元素之和的公式。

滑动窗口

当看到题目中要求数组的子数组字符串的子串等这种取整体的部分连续内容时,而且想要的值是子数组/子串满足某个条件的最长子数组/子串的长度或者满足条件的子数组/子串个数,我们就应该想到是不是可以用滑动窗口来做。
滑动窗口的解法有其特定的流程,以一维数组为例:先设定左指针 left = 0,右指针从 0 开始向右遍历直到找到满足条件的右边界为止;然后固定左指针,将右指针继续向右移动,直到不满足该条件为止;再将左指针向右移动,使得条件又被重新满足为止;接着右指针向右移动,直到不满足为止……这就像左右指针形成的窗口在不断滑动,寻找那些满足条件的窗口,并取最值或者计数。我们将这个过程形象地称为“滑动窗口”。

最长无重复字符的子串长度(Easy)

牛客网
Leetcode
给定一个字符串 s,请你找出其中不含有重复字符的最长子串的长度。

按照上述描述的流程,我们可以借助哈希思想来判断是否有重复字符,写出用滑动窗口解题的代码:

public int lengthOfLongestSubstring(String s) {
	char[] cs = s.toCharArray();
    int len = cs.length;
    if (len <= 1) {
        return len; // 特殊情况
    }
    Set<Character> set = new HashSet<>(); // 哈希,用集合来判断是否出现了重复字符
    int left = 0;
    int max = 0;
    for (int right = 0;right < len;right++) { // 固定左指针,移动右指针
        if (!set.contains(cs[right])) {
            set.add(cs[right]);
        }
        else {
            max = Math.max((right-1) - left + 1, max); // (left, right-1)就是要求的子串
            while (cs[left] != cs[right]) { // 固定右指针,移动左指针。左指针移动到 cs[left] == cs[right] 为止,且期间遇到的所有字符都要移出 set。
                set.remove(cs[left++]);
            }
            left++; // 因为 当前cs[left] == cs[right],所以左指针仍需自增一次
        }
    }
    max = Math.max((len - 1) - left + 1, max); // 右指针将整个数组遍历完之后,(left, len-1)也是个符合要求的子串
    return max;
}

中间的细节处理还是挺耐人寻味的,我就写错了很多细节,大家自行写一遍尝试一下。

总和大于某个值的长度最小的子数组(Easy)

Leetcode
给定一个含有 n 个正整数的数组和一个正整数 target。找出该数组中满足总和大于等于 target 的长度最小的子数组,并返回其长度。如果不存在这样的子数组则返回 0。

有了前一天的铺垫,本题还算简单了,不过注意代码中 if (sum >= target) {...} 为什么要做这个 sum >= target 判断。

public int minSubArrayLen(int target, int[] nums) {
	int len = nums.length;
    if (len == 1) { // 特殊情况
        return nums[0] <= target ? 0 : 1;
    }
    int left = 0;
    int sum = 0;
    int min = len + 1; // 设置一个绝不可能达到的较大值
    for (int right = 0;right < len;right++) { // 固定左指针,移动右指针
        sum += nums[right];
        if (sum >= target) {
            while (left <= right && sum >= target) { // 固定右指针,移动左指针
                sum -= nums[left++];
            }
            if (left > right) { // 特殊情况,nums[right] 一个值就大于 target 了
                return 1;
            }
            min = Math.min(right-(left-1)+1, min); // (left-1, right)就是要求的子数组
        }
    }
    if (min == len + 1) {
        return 0; // 极特殊情况:数组中所有元素加起来都达不到 target
    }
    else {
        return min;
    }
}

最大连续的1的个数(Hard)

Leetcode
给定一个二进制数组 nums 和一个整数 k,假设最多可以翻转 k 个 0,则返回执行操作后数组中连续 1 的最大个数。

这道题看起来很棘手,没有切入点。实际上,我们应该换个角度来看这个问题。
如果[left, right]区间内可以通过换 k 个以内的 0 使得其成为连续 1 的子数组,那么说明该区间内原来的 0 的个数小于等于 k。基于这一点,让我们对原题目换一种描述方法:
给定一个二进制数组 nums 和一个整数 k,请寻找 0 的个数小于等于 k 的最长子数组,并返回其长度。
这样一来,本题就变成了一道标标准准的滑动窗口题目,参照上面两题的解法就可以了,代码如下:

public int longestOnes(int[] nums, int k) {
	int len = nums.length;
    if (k >= len) {
        return len;
    }
    int left = 0;
    int cnt = 0; // 统计遇到的 0 的个数
    int max = 0;
    for (int right = 0;right < len;right++) { // 固定左指针,移动右指针
        cnt += 1-nums[right];
        if (cnt > k) { //  // 固定右指针,移动左指针。由于 cnt 只可能等于 k+1,因此不需要 while、只减一次即可
            cnt -= 1-nums[left++];
        }
        max = Math.max(right - left + 1, max); // (left, right)正好是所求的最长子数组的范围
    }
    return max;
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值