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 然后缩小搜索区间,即变化搜索指针位置即可。

相关推荐
Haohao+++6 小时前
Stable Diffusion原理解析
人工智能·深度学习·算法
ideaout技术团队9 小时前
leetcode学习笔记2:多数元素(摩尔投票算法)
学习·算法·leetcode
代码充电宝9 小时前
LeetCode 算法题【简单】283. 移动零
java·算法·leetcode·职场和发展
不枯石12 小时前
Matlab通过GUI实现点云的均值滤波(附最简版)
开发语言·图像处理·算法·计算机视觉·matlab·均值算法
不枯石12 小时前
Matlab通过GUI实现点云的双边(Bilateral)滤波(附最简版)
开发语言·图像处理·算法·计算机视觉·matlab
白水先森14 小时前
C语言作用域与数组详解
java·数据结构·算法
想唱rap14 小时前
直接选择排序、堆排序、冒泡排序
c语言·数据结构·笔记·算法·新浪微博
老葱头蒸鸡15 小时前
(27)APS.NET Core8.0 堆栈原理通俗理解
算法
视睿15 小时前
【C++练习】06.输出100以内的所有素数
开发语言·c++·算法·机器人·无人机