3.数组算法

3.数组算法

在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针和快慢指针。

所谓左右指针 ,就是两个指针相向而行或者相背而行;而所谓快慢指针,就是两个指针同向而行,一快一慢。

对于单链表来说,大部分技巧都属于快慢指针「链表算法」 都涵盖了,比如链表环判断,倒数第 K 个链表节点等问题,它们都是通过一个 fast 快指针和一个 slow 慢指针配合完成任务。

在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针,这样也可以在数组中施展双指针技巧,本文主要讲数组相关的双指针算法。

3.1 快慢指针

原地修改

  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;
}

反转数组

回文串判断

回文串就是正着读和反着读都一样的字符串。比如说字符串 abaabba 都是回文串,因为它们对称,反过来还是和本身一样;反之,字符串 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);
}

这样,如果输入相同的 lr,就相当于寻找长度为奇数的回文串,如果输入相邻的 lr,则相当于寻找长度为偶数的回文串。

那么回到最长回文串的问题,解法的大致思路就是:

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,这就叫存在逆运算,都可以使用前缀和技巧。

    • 但有些场景是没有逆运算的,比方说求最大值的场景,你知道 maxx ,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) == targetx 的最小值是多少」?

如果遇到一个算法问题,能够把它抽象成这幅图,就可以对它运用二分搜索算法。

算法代码如下:

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 的取值范围作为二分搜索的搜索区间,初始化 leftright 变量。

3、根据题目的要求,确定应该使用搜索左侧还是搜索右侧的二分搜索算法,写出解法代码。

3.14 经典习题

二维数组中的二分搜索

二分搜索判定子序列

二分搜索+数组双指针

寻找峰值

特殊数组上的

前缀和+二分查找

3.15 常用技巧

  • 「原地修改」 技巧:数组问题中比较常见的快慢指针技巧,是让你原地修改数组。核心思路就是利用快慢指针隐式的维护一个窗口,移动指针动态维护有效窗口。注意初始时有效窗口的定义(大多是题目要求的答案有关),以及指针的初始化。

  • 「虚拟窗口」 技巧:为所求问题进行定义,定义一个虚拟窗口,即一个有效区间,利用双指针不停地将进行判断,将有效数据加入窗口。注意有效窗口的定义以及指针的初始值。大多在**「原地修改」** 相关题目中使用。为什么是**虚拟?**因为并没有真正意义上的申请一个窗口用于添加删除数据,而是在原数据的基础上在逻辑上创建了个窗口。

    题 1、2、3、4 皆是利用了「虚拟窗口」的解法。

  • **「数组降维」**技巧:多维转一维的话,很多时候不用真的申请个一维数组,而是写一个 get 通用方法在需要时进行逻辑转换获取。

  • 「前缀和」 技巧:主要适用原始数组不会被修改的场景下,频繁获取某个区间的和。如果牵扯到要求 xxx子数组,往往需要配合哈希表来解答,哈希表大多存valueToIndex,通过遍历前缀和数组去哈希表中查值。

  • **「差分数组」**技巧:主要使用场景是频繁对原数组的某个区间的元素进行增减。

  • 「滑动窗口」 技巧:滑动窗口可以归为快慢双指针 ,一快一慢两个指针前后相随,中间的部分就是窗口。滑动窗口算法技巧主要用来解决子数组问题,比如让你寻找符合某个条件的最长/最短子数组。

  • 「Rabin kib」 算法:核心价值在于将字符串比较转化为数值比较,也可以认为是哈希思想,将难以处理的数据进行数字化处理,变成容易处理的数字。

  • **「二分搜索」**技巧:核心框架是定义好 x,f(x), target,看看题目是否存在单调函数,注意 left,right 指针初始化以及搜索的区间定义。二分的思想大概就是比较然后移动缩小范围,继续比较然后缩小,不一定非要对半。比如题 53、54,也没进行二分对半搜索,而是简单的比较 target 然后缩小搜索区间,即变化搜索指针位置即可。

相关推荐
晨非辰27 分钟前
【数据结构入坑指南】--《层序分明:堆的实现、排序与TOP-K问题一站式攻克(源码实战)》
c语言·开发语言·数据结构·算法·面试
hansang_IR37 分钟前
【题解】P2217 [HAOI2007] 分割矩阵 [记忆化搜索]
c++·数学·算法·记忆化搜索·深搜
Voyager_42 小时前
算法学习记录03——二叉树学习笔记:从两道题看透后序位置的关键作用
笔记·学习·算法
我搞slam7 小时前
快乐数--leetcode
算法·leetcode·哈希算法
WWZZ20258 小时前
快速上手大模型:机器学习3(多元线性回归及梯度、向量化、正规方程)
人工智能·算法·机器学习·机器人·slam·具身感知
东方佑9 小时前
从字符串中提取重复子串的Python算法解析
windows·python·算法
西阳未落9 小时前
LeetCode——二分(进阶)
算法·leetcode·职场和发展
通信小呆呆9 小时前
以矩阵视角统一理解:外积、Kronecker 积与 Khatri–Rao 积(含MATLAB可视化)
线性代数·算法·matlab·矩阵·信号处理
CoderCodingNo10 小时前
【GESP】C++四级真题 luogu-B4068 [GESP202412 四级] Recamán
开发语言·c++·算法
一个不知名程序员www11 小时前
算法学习入门---双指针(C++)
c++·算法