3.数组算法
在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针和快慢指针。
所谓左右指针 ,就是两个指针相向而行或者相背而行;而所谓快慢指针,就是两个指针同向而行,一快一慢。
对于单链表来说,大部分技巧都属于快慢指针 ,「链表算法」 都涵盖了,比如链表环判断,倒数第 K
个链表节点等问题,它们都是通过一个 fast
快指针和一个 slow
慢指针配合完成任务。
在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针,这样也可以在数组中施展双指针技巧,本文主要讲数组相关的双指针算法。
3.1 快慢指针
原地修改
扩展一下链表类似题目
除了让你在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行「原地删除」。
滑动窗口
数组中另一大类快慢指针的题目就是「滑动窗口算法」,滑动窗口的代码框架:
sql
// 滑动窗口算法框架伪码
int left = 0, right = 0;
while (right < nums.size()) {
// 增大窗口
window.addLast(nums[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.removeFirst(nums[left]);
left++;
}
}
具体的题目在后文,这里只强调滑动窗口算法的快慢指针特性:
left
指针在后,right
指针在前,两个指针中间的部分就是「窗口」,算法通过扩大和缩小「窗口」来解决某些问题。
3.2 左右指针
二分查找
二分查找相关框架以及算法在后文,这里只写最简单的二分算法,旨在突出它的双指针特性:
sql
int binarySearch(int[] nums, int target) {
// 一左一右两个指针相向而行
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = (right + left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
}
return -1;
}
反转数组
回文串判断
回文串就是正着读和反着读都一样的字符串。比如说字符串 aba
和 abba
都是回文串,因为它们对称,反过来还是和本身一样;反之,字符串 abac
就不是回文串。
能感觉到回文串问题和左右指针肯定有密切的联系,比如判断一个字符串是不是回文串,可以写出下面这段代码:
sql
boolean isPalindrome(String s) {
// 一左一右两个指针相向而行
int left = 0, right = s.length() - 1;
while (left < right) {
if (s.charAt(left) != s.charAt(right)) {
return false;
}
left++;
right--;
}
return true;
}
那接下来我提升一点难度,给一个字符串,用双指针技巧从中找出最长的回文串,如何做?
找回文串的难点在于,回文串的的长度可能是奇数也可能是偶数,解决该问题的核心是从中心向两端扩散的双指针技巧。
如果回文串的长度为奇数,则它有一个中心字符;如果回文串的长度为偶数,则可以认为它有两个中心字符。所以我们可以先实现这样一个函数:
javascript
// 在 s 中寻找以 s[l] 和 s[r] 为中心的最长回文串
String palindrome(String s, int l, int r) {
// 防止索引越界
while (l >= 0 && r < s.length()
&& s.charAt(l) == s.charAt(r)) {
// 双指针,向两边展开
l--; r++;
}
// 此时 s[l+1..r-1] 就是最长回文串
return s.substring(l + 1, r);
}
这样,如果输入相同的 l
和 r
,就相当于寻找长度为奇数的回文串,如果输入相邻的 l
和 r
,则相当于寻找长度为偶数的回文串。
那么回到最长回文串的问题,解法的大致思路就是:
java
for 0 <= i < len(s):
找到以 s[i] 为中心的回文串
找到以 s[i] 和 s[i+1] 为中心的回文串
更新答案
3.3 二维数组的花式遍历
顺/逆时针旋转矩阵
矩阵的螺旋遍历
3.4 经典习题
3.5 nSum 问题
3.6 前缀和数组
这两道题的前缀和技巧是利用预计算的 preSum 数组快速计算索引区间内的元素和,但实际上它不仅仅局限于求和,也可以用来快速计算区间乘积等其他场景。
但,前缀和技巧有几个局限性:
-
使用前缀和技巧的前提是原数组 nums 不会发生变化
- 如果原数组中的某个元素改变了,那么 preSum 数组中该元素后面的值就会失效,需要重新花 O(n)的时间计算 preSum,和普通的暴力解法就没太大区别了。
-
前缀和技巧只适用于存在逆运算的场景
-
比方说求和的场景,你知道x +6=10,那么可以推导x =10−6=4,求乘积的场景也是类似的,你知道 x ∗6=12,那么可以推导出 2x=12/6=2,这就叫存在逆运算,都可以使用前缀和技巧。
-
但有些场景是没有逆运算的,比方说求最大值的场景,你知道 max (x ,8)=8,此时你无法推导出 xx 的值。
-
想要同时解决这两个问题,就需要更高级的数据结构,最通用的解决方案是线段树。
-
3.7 前缀和技巧习题
前缀和+哈希表
前缀和数组帮你快速计算子数组的元素之和,但如果让你寻找某个符合条件的子数组,怎么办?
比方说,让你在 nums
中寻找和为 target
的子数组,就算有前缀和数组的帮助,你也要写个嵌套 for 循环去遍历 preSum 去求所有可能的。
其实这个时候就转化为了 2Sum 问题了:一个原有的数组(这里是前缀和数组),让你求这个数组中和为 target 的两个元素的下标。
直接当 2Sum 问题处理即可,加个哈希表存值与下标的映射,然后遍历数组(前缀和数组)的每一个元素,然后再去 map 中找目标值。
3.8 差分数组
拓展延伸
-
第一个问题,想要使用差分数组技巧,必须创建一个长度和区间长度一样的差分数组
diff
,那如果我有一个非常大的区间,比如[0, 10^9]
,那岂不是上来就要创建一个长度为10^9
的数组,才能开始区间增减操作? -
第二个问题,前缀和技巧可以快速进行区间查询,差分数组可以快速进行区间增减。能不能把他俩结合起来,既可以快速进行区间增减,又可以随时进行区间查询?
其实这两个问题是处理区间问题 的常见问题,终极答案是 线段树 这种数据结构,它可以在 O (logN) 的时间复杂度内完成任意长度的区间增减和区间查询操作。
3.9 滑动窗口
算法模板
javascript
// 滑动窗口算法伪码框架
void slidingWindow(String s) {
// 用合适的数据结构记录窗口中的数据,根据具体场景变通
// 比如说,我想记录窗口中元素出现的次数,就用 map
// 如果我想记录窗口中的元素和,就可以只用一个 int
Object window = ...
int left = 0, right = 0;
while (right < s.length()) {
// c 是将移入窗口的字符
char c = s[right];
window.add(c)
// 增大窗口
right++;
// 进行窗口内数据的一系列更新
...
// *** debug 输出的位置 ***
// 注意在最终的解法代码中不要 print
// 因为 IO 操作很耗时,可能导致超时
printf("window: [%d, %d)\n", left, right);
// ***********************
// 判断左侧窗口是否要收缩
while (left < right && window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
window.remove(d)
// 缩小窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
基于这个框架,遇到子串/子数组相关的题目,只需要回答以下三个问题:
1、什么时候应该移动 right 扩大窗口?窗口加入字符时,应该更新哪些数据?
2、什么时候窗口应该暂停扩大,开始移动 left 缩小窗口?从窗口移出字符时,应该更新哪些数据?
3、什么时候应该更新结果?
只要能回答这三个问题,就说明可以使用滑动窗口技巧解题。
为什么要「左闭右开」区间
理论上可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。
因为这样初始化
left = right = 0
时区间[0, 0)
中没有元素,但只要让right
向右移动(扩大)一位,区间[0, 1)
就包含一个元素0
了。如果设置为两端都开的区间,那么让
right
向右移动一位后开区间(0, 1)
仍然没有元素;如果设置为两端都闭的区间,那么初始区间
[0, 0]
就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。
3.10 经典习题
3.11 Rabin Karp 字符匹配算法
把一个字符串对象转化成了一个数字,这是什么?这就是自己设计的一个哈希算法 ,生成的数字就可以认为是字符串的哈希值。在滑动窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧。
3.12 二分搜索
算法并不难,细节是魔鬼!
关键在于区间的定义,即left、right
的初始值,由区间定义以及题目条件决定while
循环条件是,以及mid
赋值是 +1 还是 -1 还是直接left=mid
。
如果right=nums.length
,那么区间就是[left, right)
,循环条件就是 left < right
,因为只有小于才能包含所有 nums 元素,那么left = mid+1,right = mid
。其他情况类似。
1、分析二分查找代码时,不要出现 else,全部展开成 else if 方便理解。把逻辑写对之后再合并分支,提升运行效率。
2、注意「搜索区间」和 while 的终止条件,如果存在漏掉的元素,记得在最后检查。
3、如需定义左闭右开的「搜索区间」搜索左右边界,只要在 nums[mid] == target
时做修改即可,搜索右侧时需要减一。
4、如果将「搜索区间」全都统一成两端都闭,好记,只要稍改 nums[mid] == target
条件处的代码和返回的逻辑即可,推荐拿小本本记下,作为二分搜索模板。
以上二分搜索的框架属于「术」的范畴,如果上升到「道」的层面,二分思维的精髓就是:通过已知信息尽可能多地收缩(折半)搜索空间,从而增加穷举效率,快速找到目标。
3.13 二分搜索框架运用
二分搜索的原型就是在「有序数组」中搜索一个元素 target
,返回该元素对应的索引。
如果该元素不存在,那可以返回一个什么特殊值,这种细节问题只要微调算法实现就可实现。
还有一个重要的问题,如果「有序数组」中存在多个 target
元素,那么这些元素肯定挨在一起,这里就涉及到算法应该返回最左侧的那个 target
元素的索引还是最右侧的那个 target
元素的索引,也就是所谓的「搜索左侧边界」和「搜索右侧边界」,这个也可以通过微调算法的代码来实现。
在具体的算法问题中,常用到的是「搜索左侧边界」和「搜索右侧边界」这两种场景,很少有让你单独「搜索一个元素」。
因为算法题一般都让求最值,比如让你求吃香蕉的「最小速度」,让你求轮船的「最低运载能力」,求最值的过程,必然是搜索一个边界的过程。
搜索左右边界,其实可以用一个坐标系+折线图来表:

二分搜索问题的泛化
什么问题可以运用二分搜索算法技巧???
首先,要从题目中抽象出一个自变量 x,一个关于 x 的函数 f(x),以及一个目标值 target。同时,x,f(x),target 还要满足以下条件:
-
f(x)必须是在 x 上的单调函数(单调增、减都可以)。
-
题目是让计算满足约束条件 f(x) == target 时的 x 的值。
来个栗子:
给你一个升序排列的有序数组 nums
以及一个目标元素 target
,请你计算 target
在数组中的索引位置,如果有多个目标元素,返回最小的索引。
这就是「搜索左侧边界」这个基本题型,解法代码之前都写了,但这里面 x, f(x), target
分别是什么呢?
我们可以把数组中元素的索引认为是自变量 x
,函数关系 f(x)
就可以这样设定:
java
// 函数 f(x) 是关于自变量 x 的单调递增函数
// 入参 nums 是不会改变的,所以可以忽略,不算自变量
int f(int x, int[] nums) {
return nums[x];
}
其实这个函数 f
就是在访问数组 nums
,因为题目给我们的数组 nums
是升序排列的,所以函数 f(x)
就是在 x
上单调递增的函数。
最后,题目让我们求什么来着?是不是让我们计算元素 target
的最左侧索引?
是不是就相当于在问我们「满足 f(x) == target
的 x
的最小值是多少」?

如果遇到一个算法问题,能够把它抽象成这幅图,就可以对它运用二分搜索算法。
算法代码如下:
sql
// 函数 f 是关于自变量 x 的单调递增函数
int f(int x, int[] nums) {
return nums[x];
}
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (f(mid, nums) == target) {
// 当找到 target 时,收缩右侧边界
right = mid;
} else if (f(mid, nums) < target) {
left = mid + 1;
} else if (f(mid, nums) > target) {
right = mid;
}
}
return left;
}
具体来说,想要用二分搜索算法解决问题,分为以下几步:
1、确定 x, f(x), target
分别是什么,并写出函数 f
的代码,x 一般就是题目所求。
2、找到 x
的取值范围作为二分搜索的搜索区间,初始化 left
和 right
变量。
3、根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码。
3.14 经典习题
二维数组中的二分搜索
二分搜索判定子序列
二分搜索+数组双指针
寻找峰值
特殊数组上的
前缀和+二分查找
3.15 常用技巧
-
「原地修改」 技巧:数组问题中比较常见的快慢指针技巧,是让你原地修改数组。核心思路就是利用快慢指针隐式的维护一个窗口,移动指针动态维护有效窗口。注意初始时有效窗口的定义(大多是题目要求的答案有关),以及指针的初始化。
-
「虚拟窗口」 技巧:为所求问题进行定义,定义一个虚拟窗口,即一个有效区间,利用双指针不停地将进行判断,将有效数据加入窗口。注意有效窗口的定义以及指针的初始值。大多在**「原地修改」** 相关题目中使用。为什么是**虚拟?**因为并没有真正意义上的申请一个窗口用于添加删除数据,而是在原数据的基础上在逻辑上创建了个窗口。
题 1、2、3、4 皆是利用了「虚拟窗口」的解法。
-
**「数组降维」**技巧:多维转一维的话,很多时候不用真的申请个一维数组,而是写一个 get 通用方法在需要时进行逻辑转换获取。
-
「前缀和」 技巧:主要适用原始数组不会被修改的场景下,频繁获取某个区间的和。如果牵扯到要求
xxx子数组
,往往需要配合哈希表来解答,哈希表大多存valueToIndex
,通过遍历前缀和数组去哈希表中查值。 -
**「差分数组」**技巧:主要使用场景是频繁对原数组的某个区间的元素进行增减。
-
「滑动窗口」 技巧:滑动窗口可以归为快慢双指针 ,一快一慢两个指针前后相随,中间的部分就是窗口。滑动窗口算法技巧主要用来解决子数组问题,比如让你寻找符合某个条件的最长/最短子数组。
-
「Rabin kib」 算法:核心价值在于将字符串比较转化为数值比较,也可以认为是哈希思想,将难以处理的数据进行数字化处理,变成容易处理的数字。
-
**「二分搜索」**技巧:核心框架是定义好 x,f(x), target,看看题目是否存在单调函数,注意 left,right 指针初始化以及搜索的区间定义。二分的思想大概就是比较然后移动缩小范围,继续比较然后缩小,不一定非要对半。比如题 53、54,也没进行二分对半搜索,而是简单的比较 target 然后缩小搜索区间,即变化搜索指针位置即可。