力扣刷题记录-数组相关问题

汇总力扣中数组相关问题,主要集中于可以使用双指针技巧的的题目、前缀和数组相关题目,以及对二维数组的花式遍历。

题目目录

在数组中找符合要求的数


LeetCode 剑指 Offer 03. 数组中重复的数字

原题链接

2023.06.03 二刷

思路:
1.HashSet,
时间O(n),空间O(n):需要额外空间,但不改变原数组内容

  • 利用HashSet,每次遍历一个数nums[i]时,先判断是否在set中,如果在,就是重复了;
  • 如果不在,就将这个数添加到set中;

代码如下:

// 1.HashSet,时间O(n),空间O(n)
class Solution {
    public int findRepeatNumber(int[] nums) {
        Set<Integer> set=new HashSet<>();
        for(int num:nums){
            // 在集合中说明元素重复
            if(set.contains(num)){
                return num;
            }else{
                set.add(num);
            }
        }
        return -1;
    }
} 

2.数组模拟hash即可,思路一样,时空复杂度同1

代码如下:

// 2.数组模拟hash,时间O(n),空间O(n)
class Solution {
    public int findRepeatNumber(int[] nums) {
        int[] hash=new int[nums.length];
        for(int num:nums){
            hash[num]++;
            if(hash[num]>1)return num;
        }
        return -1;
    }
}

3.原地交换
  在一个长度为 n 的数组 nums 里的所有数字都在 0 ~ n-1 的范围内 。 此说明含义:数组元素的【索引】 和 【值】 是 【一对多】 的关系。
  因此,可遍历数组并通过交换操作,使元素的【索引】与【值】一一对应(即 [nums[i]=i) 。

在这里插入图片描述

  从头扫描数组,遇到下标为i的数字nums[i]如果不是i的话,(假设为x),那么就拿与下标为x(nums[i])的数字交换。在交换过程中,如果想要交换的数字中,有重复的数字发生,那么终止返回重复数字。

代码如下:

// 3.原地交换(会改变nums),时间O(n),空间O(1)
class Solution {
    public int findRepeatNumber(int[] nums) {
        for(int i=0;i<nums.length;i++){
            // 如果nums[i]=i,说明nums[i]在自己对应位置上,不需要交换,直接跳过
            // 当nums[i]不在自己位置上的时候,需要将下标为i的nums[i]交换到它下标为nums[i]处
            // 但是下标为nums[i]处交换过来的数字,也可能不应该待在下标i这里,所以需要持续交换,用while进行
            while(nums[i]!=i){
                //需要交换过去的时候,如果发现下标为nums[i]的地方已经有nums[i]了,说明数字重复,返回要交换的nums[i]
                if(nums[nums[i]]==nums[i])return nums[i];
                int tmp=nums[i];
                nums[i]=nums[tmp];
                nums[tmp]=tmp;
            }                   
        }
        return -1;
    }
}


LeetCode 41. 缺失的第一个正数

原题链接

2024.05.21 三刷

评论区有人说是字节三面题

思路来自liweiwei1419的精选题解:

整数数组 nums ,要找出其中没有出现的最小的正整数,正整数是从1开始的,题目给的nums是整数数组,里面可能会出现负数、0、正整数。而我们只需要关注从1开始(包括1),往后的N(数组长度)个数,也就是说最终要找的缺失的第一个正数一定会在[1,N+1]中出现(1是最小正整数,N+1情况是原数组有1-N的所有数,那么第一个缺失的就是N+1)。

  由于题目要求我们「只能使用常数级别的空间」,而要找的数一定在 [1, N + 1] 左闭右闭(这里 N 是数组的长度)这个区间里。因此,我们可以就把原始的数组当做哈希表来使用。事实上,哈希表其实本身也是一个数组;

  • 我们要找的数就在 [1, N + 1] 里,最后 N + 1 这个元素我们不用找。因为在前面的 N 个元素都找不到的情况下,我们才返回 N + 1;
  • 那么,我们可以采取这样的思路:就把1这个数放到下标为0的位置, 2 这个数放到下标为1的位置,即将nums[i]放置到下标为nums[i]-1位置上,只要碰到不在自己位置上的数,就应该将它交换到属于它的位置上。
  • 如果交换过来的数还是不属于这个位置,就继续将它交换到属于它的位置上,一直重复,直到当前位置上放了应该放的数(即下标0放1,下标1放2,……,下标n-1放n)。按照这种思路整理一遍数组。
  • 当然,nums[i]-1这个下标可能不在数组的下标范围内(因为题目nums[i]范围-231 <= nums[i] <= 231 - 1,而1 <= nums.length <= 5 * 105),就可以先跳过这个数,进行后面的判断。那么等待它的只有两种可能:①在nums中存在属于这个位置的数,则遍历到后面,会将属于这个位置的数交换到nums[i]-1处。②在nums中不存在属于这个位置的数,那么在第二次从头遍历的时候,如果先遍历到它,说明它就是第一个缺失的正整数。
  • 然后我们再遍历一次数组,第 1 个遇到的它的值不等于下标+1的那个数(比如下标2上的数不是3),就是我们要找的缺失的第一个正数(3就是第一个缺失的正数)。

  这个思想就相当于我们自己编写哈希函数,这个哈希函数的规则特别简单,那就是数值为 nums[i] 的数映射到下标为 nums[i] - 1 的位置。

需要特别注意:如果 nums[i] 恰好与 nums[nums[i]−1] 相等(举例:nums[i]=3,nums[2]=3),即原来的位置上已经正确放置了一个数,但是别的地方还有那个位置要放的数,那么就会无限交换下去,因此while循环的时候,当nums[nums[i]-1]==nums[i]时,需要跳出循环,因为这时说明nums[i]这个值已经到了它对应的位置上。

举例如下:

在这里插入图片描述

代码如下:

class Solution {
    public int firstMissingPositive(int[] nums) {
        int n=nums.length;
        for(int i=0;i<n;i++){
            // 满足在指定范围内([1,N])、并且没有放在正确的位置上,才交换
            // 例如:数值 3 应该放在索引 2 的位置上
            while(nums[i]>0&&nums[i]<=n&&nums[nums[i]-1]!=nums[i]){
                int pos=nums[i]-1;
                int tmp=nums[pos];
                nums[pos]=nums[i];
                nums[i]=tmp;
            }
        }
        for(int i=0;i<n;i++){
            if(nums[i]!=i+1){
                return i+1;
            }
        }
        // 都在对应位置上,说明[1,N]都在对应位置,缺失的最小正整数就是N+1
        return n+1;
    }
}

时间复杂度:O(N),这里 N 是数组的长度。

空间复杂度:O(1)

  说明:while 循环不会每一次都把数组里面的所有元素都看一遍。如果有一些元素在这一次的循环中被交换到了它们应该在的位置,那么在后续的遍历中,由于它们已经在正确的位置上了,代码再执行到它们的时候,就会被跳过。

  最极端的一种情况是,在第 1 个位置经过这个 while 就把所有的元素都看了一遍,这个所有的元素都被放置在它们应该在的位置,那么 for 循环后面的部分的 while 的循环体都不会被执行。

  平均下来,每个数只需要看一次就可以了,while 循环体被执行很多次的情况不会每次都发生。这样的复杂度分析的方法叫做均摊复杂度分析。

  最后再遍历了一次数组,最坏情况下要把数组里的所有的数都看一遍,因此时间复杂度是 O(N)。


合并数组

LeetCode 88. 合并两个有序数组(从后向前插入)

原题链接

设置双指针在两个数组末尾,再设置一个p指针指向填入位置。

代码如下:

class Solution {
    public void merge(int[] nums1, int m, int[] nums2, int n) {
        int p1=m-1,p2=n-1;
        int p=m+n-1;//p为赋值位置
        while(p1>=0&&p2>=0){
            if(nums1[p1]<=nums2[p2]){
                nums1[p--]=nums2[p2--];
            }else{
                nums1[p--]=nums1[p1--];
            }
        }
        while(p2>=0){
            nums1[p--]=nums2[p2--];
        }
    }
}

移动数组

LeetCode 189. 轮转数组(利用反转的规律)

原题链接

2023.12.06 三刷

题解区里看到有人引用国外的一个短小精悍的题解:
示例:
nums = “----->–>”; k =3
result = “–>----->”;

过程
reverse “----->–>” we can get “<–<-----”
reverse “<–” we can get “–><-----”
reverse “<-----” we can get “–>----->”

代码如下:

class Solution {
    public void rotate(int[] nums, int k) {
        int n=nums.length;
        k%=n;//k可能比nums大,但是nums右移n位还是原来的nums
        reverse(nums,0,n-1);//反转区间两端都为闭
        reverse(nums,0,k-1);
        reverse(nums,k,n-1);
    }
    //对数组指定区间进行反转
    public void reverse(int[] nums,int l,int r ){
        while(l<r){
            // 基于异或运算的交换律和结合律,以及a^a=0,a^0=a;
            nums[l]^=nums[r];//nums[l]=nums[l]^nums[r]
            nums[r]^=nums[l];//nums[r]=nums[r]^nums[l]^nums[r]=nums[l]
            nums[l]^=nums[r];//nums[l]=(nums[l]^nums[r])^nums[l]=nums[r]
            l++;
            r--;
        }
    }
}

题目目录

前缀和数组

前缀和主要适⽤的场景是原始数组不会被修 改的情况下,频繁查询某个区间的累加和。

力扣 303. 区域和检索 - 数组不可变

原题链接
在这里插入图片描述

看到这题的第一反应应该都是每一次sumRange里面都执行一遍for循环,i从left开始,直到right为止,用一个sum对遍历到的每一个nums[i]进行累加和计算。

class NumArray {
	private int[] nums;
	public NumArray(int[] nums) {
		this.nums = nums;
	}
	
	public int sumRange(int left, int right) {
		int res = 0;
		for (int i = left; i <= right; i++) {
			res += nums[i];
		}
		return res;
	}
}

但是如果这样写的话,如果多次调用sumRange方法,那么每次调用此方法的时间复杂度都是O(n),这样的效率就比较低,需要想个方法把时间复杂度降到O(1),这就需要用到前缀和的方法了。

用一个preSum数组记录nums数组的前缀和,其中preSum[i]为nums[0]到nums[i-1]的元素和:
图片来自labuladong公众号
(图片来自labuladong公众号)

class NumArray {
    //用于计算nums数组的前缀和,preSum[i]为nums[0]到nums[i-1]的元素和
    private int[] preSum;
    public NumArray(int[] nums) {
        //preSum[0] = 0,便于计算累加和
        preSum=new int[nums.length+1];  
        for(int i=1;i<preSum.length;i++){
            preSum[i]+=preSum[i-1]+nums[i-1];
        }
    }

    //计算[left,right]区间内元素和
    public int sumRange(int left, int right) {
        return preSum[right+1]-preSum[left];
    }
}
/**
 * Your NumArray object will be instantiated and called as such:
 * NumArray obj = new NumArray(nums);
 * int param_1 = obj.sumRange(left,right);
 */

这样每次调用sumRange时,只需进行减法运算即可算出区间元素和,其实这也是一种空间换时间的方法,多用了一个preSum数组进行记录,换来每次调用方法的高效率。


LeetCode 560. 和为 K 的子数组

原题链接

2023.05.31 一刷

思路:

  • 思路1:暴力枚举
      i用于遍历nums,j从i开始,向后遍历,用sum进行累加i~j之间的总和,当sum==k时,负责计数的count+1。

时间:双重for循环–O(n^2)–用时1649ms,击败23.53%
空间:无需额外数组空间–O(1)–内存消耗43.8MB,击败86.46%

代码如下:

//1.暴力枚举,时间O(n^2),空间O(1)
class Solution {
    public int subarraySum(int[] nums, int k) {
        int count=0;
        for(int i=0;i<nums.length;i++){
            int sum=0;
            for(int j=i;j<nums.length;j++){
                sum += nums[j];
                count += sum==k ? 1:0; 
            }
        }
        return count;
    }
} 
  • 思路2:前缀和数组加速区间和计算
      第一遍先遍历nums,用前缀和数组存下每个位置的前缀和,然后在双重循环枚举时,直接用前缀和数组计算i~j之间的和。

时间:O(n^2)–用时1961ms,击败5.03%
空间:O(n)–内存消耗43.7MB,击败88.31%

代码如下:

//2.前缀和数组加速求i~j之间的sum,时间O(n^2),空间O(n)
class Solution {
    public int subarraySum(int[] nums, int k) {
        int[] preSum=new int[nums.length+1];
        // preSum[i]为nums[0~i-1]的区间和,preSum[0]初始就为0,空置
        for(int i=1;i<nums.length+1;i++){
            preSum[i]=preSum[i-1]+nums[i-1];
        }
        int count=0;
        for(int i=0;i<nums.length;i++){
            for(int j=i;j<nums.length;j++){
                // i~j之间的和:preSum[j+1]-preSum[i]
                count+= preSum[j+1]-preSum[i]==k ? 1:0;
            }
        }
        return count;
    }
} 
  • 思路3:前缀和+HashMap
      遍历数组nums,计算从第0个元素到当前元素nums[i]的前缀和,用哈希表保存出现过的累积和preSum的次数。如果preSum - k在哈希表中出现过,则代表从当前下标i往前有连续的子数组的和为k。

时间:只需要遍历nums一次–O(n)–用时22ms,击败89.7%
空间:需要用hashmap存储前缀和–O(n)–内存消耗44.9MB,击败48.71%

官方题解:
在这里插入图片描述
代码如下:

// 3.前缀和+HashMap
class Solution {
    public int subarraySum(int[] nums, int k) {
        // key存前缀和,value存对应前缀合出现的次数
        HashMap<Integer,Integer> hashmap=new HashMap<>();
        int preSum=0;
        int count=0;
        hashmap.put(0,1);//这句很重要,原因看下面注释
        for(int i=0;i<nums.length;i++){
            // preSum记录nums[0~i]之间的和
            preSum+=nums[i];
            // preSum[i]-preSum[j-1]=k,包含preSum-k键值对说明nums[j~i]的区间和为k
            // 此时需要看0~i之间有多少次前缀和为preSum[j-1],count加上对应次数即可
            // put(0,1)补上了nums[0~i]区间和为k的情况(preSum=k),此时count+1
            if(hashmap.containsKey(preSum-k)){
                count+=hashmap.get(preSum-k);
            }
            hashmap.put(preSum,hashmap.getOrDefault(preSum,0)+1);
        }
        return count;
    }
} 

力扣 304. 二维区域和检索 - 矩阵不可变(同剑指 Offer II 013. 二维子矩阵的和)

原题链接
在这里插入图片描述

思路相比上一题303需要再复杂一点点,前缀和数组preSum的每一个元素perSum[i][j]为“以原点为左上角起点,到右下角matrix[i-1][j-1]之前矩形区域 ”的累加值。

利用preSum进行区域和计算的方法:
在这里插入图片描述
图片来自labuladong公众号。

代码如下:

//二维前缀和
//时间复杂度:初始化 O(mn),每次检索 O(1),其中m和n分别是矩阵matrix的行数和列数。
//空间O(mn)
class NumMatrix {
    private int[][] preSum;
    public NumMatrix(int[][] matrix) {
        int m=matrix.length,n=matrix[0].length;
        preSum=new int[m+1][n+1];
        for(int i=1;i<=m;i++)
            for(int j=1;j<=n;j++){
                preSum[i][j]=preSum[i][j-1]+preSum[i-1][j]-preSum[i-1][j-1]+matrix[i-1][j-1];
            }
    }
    
    public int sumRegion(int row1, int col1, int row2, int col2) {
        return preSum[row2+1][col2+1]+preSum[row1][col1]-preSum[row1][col2+1]-preSum[row2+1][col1];
    }
}

/**
 * Your NumMatrix object will be instantiated and called as such:
 * NumMatrix obj = new NumMatrix(matrix);
 * int param_1 = obj.sumRegion(row1,col1,row2,col2);
 */

这题和剑指 Offer II 013. 二维子矩阵的和是一样的,可以之后再做这题熟练一下。


力扣 1314. 矩阵区域和

原题链接
在这里插入图片描述
理解题意:首先answer矩阵的每个元素都是在一个和k有关的、mat矩阵一定范围内的矩阵元素和。

比如mat = [1,2,3],[4,5,6],[7,8,9]],k=1;

answer[0][0]就是表示在mat矩阵中,行号在[i-k,i+k]范围内,列号在[j-k,j+k]范围内的元素和,即行号范围[-1,1](实际是[0,1],因为要在mat矩阵范围内),列号范围[-1,1](实际是[0,1])的所有元素合,即answer[0][0]=1+2+4+5=12;

同理,对于每一对i,j,都有对应范围的mat矩阵区域,每个answer[i][j]都是对应区域的元素和。只是需要注意区域范围在mat矩阵内(行:0-mat.length,列:0-mat[0].length)

也就是需要求m*n次二维矩阵的区域元素和,这是不是就可以联想到304. 二维区域和检索 - 矩阵不可变这题,它就是让我们编写程序用于求取指定区域的元素和,所以这题可以直接使用它的代码:

//借用304题现成的代码
class Solution {
    public int[][] matrixBlockSum(int[][] mat, int k) {
        int m=mat.length,n=mat[0].length;
        int[][] answer =new int[m][n];//结果数组
        int x1,y1,x2,y2;
        NumMatrix nummatrix=new NumMatrix(mat);
        for(int i=0;i<m;i++)
            for(int j=0;j<n;j++){//针对每一对i、j找到对应矩阵区域范围
                x1=Math.max(i-k,0);//防止i-k小于数组索引边界
                y1=Math.max(j-k,0);
                x2=Math.min(i+k,m-1);//防止i+k超出数组索引边界
                y2=Math.min(j+k,n-1);
                answer[i][j]=nummatrix.sumRegion(x1,y1,x2,y2);
            }
        return answer;
    }
}
//304题代码
class NumMatrix {
    private int[][] preSum;
    public NumMatrix(int[][] matrix){
        int m=matrix.length,n=matrix[0].length;
        preSum=new int[m+1][n+1];
        //前缀和数组赋值
        for(int i=1;i<=m;i++)
            for(int j=1;j<=n;j++){
                preSum[i][j]=preSum[i-1][j]+preSum[i][j-1]-preSum[i-1][j-1]+matrix[i-1][j-1];
            }
    }
    //用于输出矩阵区域元素和
    public int sumRegion(int row1,int col1,int row2,int col2){
        return  preSum[row2+1][col2+1]+preSum[row1][col1]-preSum[row2+1][col1]-preSum[row1][col2+1];
    }
}

当然,也可以不用这么长的代码:

//合起来写法
class Solution {
    public int[][] matrixBlockSum(int[][] mat, int k) {
        int m=mat.length,n=mat[0].length;
        int[][] preSum=new int[m+1][n+1];//前缀和数组
        int[][] answer =new int[m][n];//结果数组
        int x1,y1,x2,y2;
        //前缀和数组赋值
        for(int i=1;i<=m;i++)
            for(int j=1;j<=n;j++){
                preSum[i][j]=preSum[i-1][j]+preSum[i][j-1]-preSum[i-1][j-1]+mat[i-1][j-1];
            }
        //计算区域和
        for(int i=0;i<m;i++)
            for(int j=0;j<n;j++){
                x1=Math.max(i-k,0);//防止i-k小于数组索引边界
                y1=Math.max(j-k,0);
                x2=Math.min(i+k,m-1);//防止i+k超出数组索引边界
                y2=Math.min(j+k,n-1);
                answer[i][j]=preSum[x2+1][y2+1]+preSum[x1][y1]-preSum[x2+1][y1]-preSum[x1][y2+1];//求区域元素和
            }
        return answer;
    }
}r[i][j]=preSum[x2+1][y2+1]+preSum[x1][y1]-preSum[x2+1][y1]-preSum[x1][y2+1];//求区域元素和
            }
        return answer;
    }
}

这题需要注意的就是题目的理解,以及对i-k、i+k、j-k、j+k的范围用Math.max/min作限定。


力扣 1352. 最后 K 个数的乘积

原题链接

这题可以像求前缀和一样的方式算出前缀积,前缀积 pre[i] 表示前i个数的乘积,最后k个数的乘积就是pre[n]/pre[n-k],不过对0的存在要特别注意,因为除0是不允许的。

//前缀积,add和getProduct时间O(1);空间O(n),n为前缀积list
class ProductOfNumbers {
    List<Integer>list =new ArrayList<>();//记录前缀积
    //初始化
    public ProductOfNumbers() {
        list.add(1);//初始化加入1,方便计算乘积
    }
    
    public void add(int num) {
        if(num==0){//很关键的一步,遇到0直接清空前缀积
            list.clear();
            list.add(1);
            return;
        }//能走到这说明num!=0
        int n=list.size();//方便调用get方法
        list.add(num*list.get(n-1));//保存当前num加入后的前缀积

    }
    
    public int getProduct(int k) {
        int n=list.size();
        //list剩余的实际元素个数不超过k,说明在倒数k个内碰到了0元素
        //导致list清空,倒数k个内有0,返回值必定为0
        if(k>n-1){//因为第一个元素为初始化的1,不计入实际个数
            return 0;
        }//能走到这说明list剩余元素比k多,那直接用公式计算即可
        return list.get(n-1)/list.get(n-1-k);
    }
}

/**
 * Your ProductOfNumbers object will be instantiated and called as such:
 * ProductOfNumbers obj = new ProductOfNumbers();
 * obj.add(num);
 * int param_2 = obj.getProduct(k);
 */

327. 区间和的个数(比较难,需要归并排序知识,先放着,完成315之后再来做这题)


前缀积

力扣 238. 除自身以外数组的乘积(同剑指Offer 66. 构建乘积数组)

原题链接

在评论区看到一个很简单明了的思路举例,来自Carol:
在这里插入图片描述
2023.12.06 二刷

思路:
1.直观的前缀积数组

  • 先从左到右遍历nums,L[i]记录nums[i]左侧所有数乘积,L[0]=1(nums[0]左侧无数,初始化为1);
  • 然后再从右向左遍历,R[i]记录nums[i]右侧所有数乘积,R[n-1]=1(nums[n-1]右侧无数);
  • 最后的res[i]=L[i]*R[i];

时间O(n),空间O(n)

代码如下:

//  1.直观的前缀积数组,时间O(n),空间O(n)
class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n=nums.length;
        int[] L=new int[n];
        int[] R=new int[n];
        int[] res=new int[n];

        L[0]=1;
        // 从左到右遍历,求左边乘积
        for(int i=1;i<n;i++){
            L[i]=L[i-1]*nums[i-1];
        }

        R[n-1]=1;
        // 从右到左遍历,求右边乘积
        for(int i=n-2;i>=0;i--){
            R[i]=R[i+1]*nums[i+1];
        }

        // 从左到右遍历nums,求出每个nums[i]的结果
        for(int i=0;i<n;i++){
            res[i]=L[i]*R[i];
        }
        return res;
    }
}

2.前缀积数组优化

  • 输出数组不被视为额外空间,所以可以用res[i]先从左到右遍历nums,记录每个nums[i]左侧乘积;
  • 再从右到左遍历nums,每个位置最后的结果就是res[i]乘以nums[i]右侧所有数的乘积,这个乘积在向左遍历的过程中可以用一个变量R来维护,每到一个位置更新一次即可。
  • 这样最终只需要O(1)的时间复杂度(除去输出数组使用的空间)

时间O(n),空间O(1)

代码如下:

//2.优化前缀积数组,时间O(n),空间O(1)
class Solution {
    public int[] productExceptSelf(int[] nums) {
        int n=nums.length;
        int[] res=new int[n];

        res[0]=1;
        // 从左到右遍历,res[i]记录nums[i]左侧乘积
        for(int i=1;i<n;i++){
            res[i]=res[i-1]*nums[i-1];
        }

        int R=1;
        // 从右到左遍历,res[i]记录最终结果
        for(int i=n-1;i>=0;i--){
            res[i]=res[i]*R;
            R*=nums[i];
        }
        return res;
    }
}

此题同剑指 Offer 66. 构建乘积数组,做完此题之后,可以到剑指offer里再写一遍回顾思想。



二维数组的不同遍历

LeetCode 59. 螺旋矩阵 II(二维数组花式遍历)

原题链接

这题需要注意的就是对各种边界的识别,以及遍历二维数组过程的技巧。

代码如下:

class Solution {
    public int[][] generateMatrix(int n) {
        int[][] res=new int[n][n];
        int[] dx={0,1,0,-1};//存储x坐标偏移量
        int[] dy={1,0,-1,0};//存储y坐标偏移量
        //①[0,1]->行数不变,列数+1(向右移动);②[1,0]->行数+1,列数不变(向下的状态);
        //③[0,-1]->行数不变,列数-1(向左移动);④[-1,0]->行数-1,列数不变(向上的动作);
        int d=0;//用于在dx、dy内循环遍历(起始按①②③④顺序循环)
        int x=0,y=0;//记录赋值的坐标
        int tmpX=0,tmpY=0;//暂存坐标
        for(int i=1;i<=n*n;i++){
            //先给当前坐标赋值
            res[x][y]=i;
            //记录下一步的位置(原坐标加偏移)
            tmpX=x+dx[d];
            tmpY=y+dy[d];
            //不能直接就给下一步赋值,要先判断下一步有没有超出边界,或者走到存过值的位置
            //若下一步位置是非法访问
            if(tmpX<0||tmpX>=n||tmpY<0||tmpY>=n||res[tmpX][tmpY]!=0){
                d=(d+1)%4;//按下一个偏移量走
                tmpX=x+dx[d];//更正下一步位置
                tmpY=y+dy[d];
            }
            x=tmpX;//实际走下去
            y=tmpY;
        }
        return res;
    }
}

LeetCode 54. 螺旋矩阵(同剑指 Offer 29. 顺时针打印矩阵)

原题链接

2023.06.05 三刷

这题和59题差不多,只不过从向二维数组中填值,变成了遍历二维数组,向外输出值。

在牛客中学到了另一种更简单的解法,见解法2;

代码如下:

// 解法1
class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        int m=matrix.length,n=matrix[0].length;
        List<Integer> res=new ArrayList<>();
        // 用于偏移,改变下标前进的方向
        int[] dx={0,1,0,-1};
        int[] dy={1,0,-1,0};
        // 用于改变下一步dx、dy的下标
        int d=0;
        // 暂存下一步的数组下标,如果非法(超界或已访问过),则改变dx、dy(改变前进方向)
        int tmpX=0,tmpY=0;
        // 实际遍历数组的下标
        int x=0,y=0;

        for(int i=0;i<m*n;i++){
            res.add(matrix[x][y]);
            matrix[x][y]=101;//数据范围在-100~100;设置标志,如果是101就是访问过的
            // 暂存下一步位置,看看是否合法
            tmpX=x+dx[d];
            tmpY=y+dy[d];
            // 如果超界或者已访问过,则改变前进方向
            if(tmpX<0||tmpX>=m||tmpY<0||tmpY>=n||matrix[tmpX][tmpY]==101){
                d=(d+1)%4;
                tmpX=x+dx[d];
                tmpY=y+dy[d];
            }
            // 前进
            x=tmpX;
            y=tmpY;
        }
        return res;
        
    }
}

解法一中给遍历过的数组元素赋值为不可能出现的值,解法2可以规避修改已经遍历过的数组元素这个操作;

// 解法2
class Solution {
    public List<Integer> spiralOrder(int[][] matrix) {
        List<Integer> res = new ArrayList<>();
        int m = matrix.length,n = matrix[0].length;
        int up=0,down=m-1;// 遍历中的上下界(每遍历完一行,就将上/下界收缩)
        int l=0,r=n-1;// 遍历中的左右界(每遍历完一列,就将左/右界收缩)
        while(true){
            // 先向右遍历,范围是左右界
            for(int i=l;i<=r;i++)res.add(matrix[up][i]);
            // 上边界下移,若上边界超过下边界,则遍历完
            if(++up>down)break;
            // 向下
            for(int i=up;i<=down;i++)res.add(matrix[i][r]);
            // 右边界左移,若超出左边界,则遍历完
            if(--r<l)break;
            // 向左
            for(int i=r;i>=l;i--)res.add(matrix[down][i]);
            // 下边界上移, 若超出上边界,则遍历完
            if(--down<up)break;
            // 向上
            for(int i=down;i>=up;i--)res.add(matrix[i][l]);
            // 左边界右移,若超出右边界,则遍历完
            if(++l>r)break;
        }
        return res;
    }
}

LeetCode 48. 旋转图像(找规律)

原题链接

2023.12.07 三刷

思路:
找规律:

  • n维矩阵顺时针旋转90°就相当于原矩阵关于主对角线对称互换之后再逐行反转
  • 如果是逆时针旋转90°,相当于原矩阵关于副对角线对称互换之后逐行反转

代码如下:

class Solution {
    public void rotate(int[][] matrix) {
        int n=matrix.length;
        //以主对角线为轴,进行元素互换
        for(int i=0;i<n-1;i++){
            for(int j=i+1;j<n;j++){
                //利用异或进行原地交换
                matrix[i][j]^=matrix[j][i];
                matrix[j][i]^=matrix[i][j];
                matrix[i][j]^=matrix[j][i];
            }
        }

        //再对互换后的矩阵逐行反转
        for(int[] row:matrix){
            int l=0,r=row.length-1;
            while(l<r){
                row[l]^=row[r];
                row[r]^=row[l];
                row[l]^=row[r];
                l++;
                r--;
            }
        }
    }
}

LeetCode 240. 搜索二维矩阵 II(重点是从哪开始遍历,以及遍历方向)

原题链接

2023.12.07 二刷

思路:
1.从右上角到左下角遍历(最优):
特点:每行从左到右升序排列,每列从上到下升序列,这样左上角最小,右下角最大;

  • 想高效在 matrix 中搜索一个元素,肯定需要从某个角开始,比如说从左上角开始,然后每次只能向右或向下移动,不要走回头路。但是从左上角开始,无论向右还是向下走,元素大小都会增加,到底向右还是向下走是不确定的(动态规划可能可以解决)。
  • 从右上角开始的话,规定只能向左或向下移动。当前元素如果小于target,那么当前所在行就无需考虑,因为该元素左边的一定全都小于当前元素,此时应该向下移动,元素增大;
  • 当前元素如果大于target,那么当前所在列就无须考虑,因为该元素下方元素一定大于当前元素,需要向左移动,元素在减小。
  • 这样的话我们就可以根据当前位置的元素和 target 的相对大小来判断应该往哪移动,不断接近从而找到 target 的位置。

时间O(m+n),空间O(1)

代码如下:

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        int m=matrix.length,n=matrix[0].length;
        int row=0,col=n-1;
        while(row<m&&col>=0){
            if(matrix[row][col]==target)return true;
            else if(matrix[row][col]<target)++row;
            else --col;
        }
        return false;
    }
}

LeetCode 73. 矩阵置零(“原地”对矩阵进行修改)

原题链接

2023.12.07 三刷

思路:

用第0行和第0列来作为flag,只要后面遍历二维数组(从第一行第一列开始)的过程中,遇到为0的元素matrix[i][j],就把该元素下标所指的matrix[i][0]与matrix[0][j]置0,表明这一行/列需要全部置0。

后面再分别遍历第0行与第0列,只要碰到0,就把其所指向的列/行置0。

不过需要注意,第0行与第0列如果一开始就有0,需要预先标记出来,等其flag作用发挥完之后,检查其row0与col0标记是否为1,如果为1,说明其一开始就含0,需要把第0行/列全部置0。

时间O(n^2),空间O(1)

代码如下:

class Solution {
    public void setZeroes(int[][] matrix) {
        int m=matrix.length;
        int n=matrix[0].length;

        int col0=0;//标记第0列是否初始含0
        for(int i=0;i<m;i++){
            if(matrix[i][0]==0)col0=1;
        }

        int row0=0;
        for(int j=0;j<n;j++){
            if(matrix[0][j]==0)row0=1;
        }

        // 遍历数组,找0,置第0行/列对应位置为0
        for(int i=1;i<m;i++)
            for(int j=1;j<n;j++){
                if(matrix[i][j]==0){
                    matrix[i][0]=0;
                    matrix[0][j]=0;
                }                
            }
        //根据第0行/列的情况,重新遍历数组,对元素赋0
        for(int i=1;i<m;i++)
            for(int j=1;j<n;j++){
                if(matrix[i][0]==0||matrix[0][j]==0){
                    matrix[i][j]=0;
                }
            }
        // 最后看看第0行/列是否一开始就含有0
        if(col0==1){
            for(int i=0;i<m;i++){
                matrix[i][0]=0;
            }
        }
        
        if(row0==1){
            for(int j=0;j<n;j++){
                matrix[0][j]=0;
            }
        }
        
    }
}


1.快慢指针

数组中常见的快慢指针通常用于数组原地修改。


LeetCode 26. 删除有序数组中的重复项

原题链接

设置快慢指针,慢指针slow指向最后真正存储数值的nums的位置,快指针用来遍历nums数组;

当快慢指针指向元素不同的时候,慢指针向前一步,然后慢指针前进一步后的位置存储快指针fast指向的元素。

当快慢指针指向元素相同时,快指针向前,慢指针不动;

代码如下:

//快慢指针
class Solution {
    public int removeDuplicates(int[] nums) {
        int slow=0,fast=0,len=nums.length;
        while(fast<len){
        	//不相等时,新组成的nums数组最新的一个元素就是此时fast指向的
            if(nums[slow]!=nums[fast]){
                nums[++slow]=nums[fast];//slow先+1
            }
            fast++;
        }
        return slow+1;
    }
}

LeetCode 83. 删除排序链表中的重复元素(方法同26)

原题链接

设置快慢指针和26基本一样,不过要注意头结点为空的情况,以及最后要将slow指针的next指向空。

//快慢指针写法
class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        if(head==null)return head;
        ListNode slow=head,fast=head;
        while(fast!=null){
            if(slow.val!=fast.val){
                slow.next=fast;//先确定好新的指针链接
                slow=slow.next;//再把slow往后挪
            }
            fast=fast.next;
        }
        slow.next=null;//要断开slow的连接
        return head;
    }
}

还有一种只需要一个cur指针的方法,因为题目要去去重,其实只要把重复的结点跳过去就行:

//cur指针写法
class Solution {
    public ListNode deleteDuplicates(ListNode head) {
        ListNode cur=head;
        //cur或cur.next为空那么就没必要继续去重了(以及走到尾巴了)
        while(cur!=null&&cur.next!=null){
            //遇到cur与cur.next相同情况,跳过cur.next
            if(cur.val==cur.next.val){
                cur.next=cur.next.next;
            }else{
                cur=cur.next;//否则继续走
            }
        }
        return head;
    }
}

LeetCode 27. 移除元素

原题链接

//双指针写法:如果 fast 遇到值为 val 的元素,则直接跳过,否则就赋值给 slow 指针,并让 slow 前进⼀步。
class Solution {
    public int removeElement(int[] nums, int val) {
        int slow=0,fast=0;
        while(fast<nums.length){
            if(nums[fast]!=val){
                nums[slow++]=nums[fast];
            }
            fast++;
        }
        return slow;
    }
}

注意这里和有序数组去重是有区别的,就是先进行slow+1再进行nums[slow]赋值,还是先进行nums[slow]赋值再slow+1的区别。我们这⾥是先给 nums[slow] 赋值然后再给 slow++,这 样可以保证 nums[0…slow-1] 是不包含值为 val 的元素的,最后的结果数组⻓度就是 slow.


LeetCode 283. 移动零

原题链接

2023.05.29四刷

其实可以在27. 移除元素基础上进行,这题可以看作在nums数组中原地删除0,然后再把后面的元素都赋值为 0 即可。

设置count记录nums中不为0的数字的个数,每遇到一个不为0的数,就先让nums[count]=nums[i],再让count+1;全部赋值完之后,把索引count即之后的数字全置0即可。

class Solution {
    public void moveZeroes(int[] nums) {
        int count=0;//记录nums中不为0的数字的个数
        for(int i=0;i<nums.length;i++){
            if(nums[i]!=0){
                nums[count++]=nums[i];
            }
        }
        for(int i=count;i<nums.length;i++)nums[i]=0;
    }
}

AcWing 799. 最长连续不重复子序列

原题链接

思路:
有点类似滑动窗口,设置双指针,r指针用于遍历nums数组,l指针总是落后于r指针。
针对r的每个位置,都要判断l到r这个区间内是否有重复的数字,如果有重复的数字,l指针要向前走一步继续判断,直到l到r区间没有重复数字;如果没有重复,则r向前走一步,继续下一个循环判断。

本来按照暴力的解法,对于每个r指针位置,l指针都要从0到r范围内进行检索,这样的时间复杂度是O(n^2),但是按照前面提到的思路,当r指针向前移动一步后,l指针无需回退到0位置,而是在原来位置向前检索。这样可以保证总的时间复杂度控制在O(n)。l指针之所以不用回退,是因为对于每个r指针位置,l指针都需要保证它自身到r指针位置区间到没有重复数字,如果有重复数字,l指针就要向前移动。所以每个l指针在当次循环中,总是处于它的最左的位置,当r指针在下一次循环中前移后,l指针只有向前移动的可能。

在这个思路中,很重要的一点是如何判断[l,r]区间内有重复的数字。因为每一次循环中都会保证[l,r]之间没有重复数字,在下一个循环中,r指针只会前移一位,遍历到一个新的数,所以如果下一轮循环出现重复数字,必定是nums[r]这个数字重复了。所以只要在每一轮循环开始的时候,判断nums[r]这个数字出现的次数就行。如果nums中每个数字的范围不大,可以直接用一个较大的数组用于每个数字的计数,如果数字范围较大,并且分布比较分散,就可以使用HashMap统计。

import java.util.Scanner;
import java.util.HashMap;

public class Main{
    public static void main(String[] args){
        Scanner sc=new Scanner(System.in);
        int n=sc.nextInt();
        int[] nums=new int[n];
        for(int i=0;i<n;i++)nums[i]=sc.nextInt();
        
        //统计每个数字出现的次数
        HashMap<Integer,Integer> hashmap=new HashMap<>();
        
        int res=0;//存储结果
        for(int l=0,r=0;r<n;r++){
            //循环开始时先检查新遍历到的数有没有在[l,r]中出现过
            //如果在HashMap中不存在该键值,则先设其value为0,再+1
            hashmap.put(nums[r],hashmap.getOrDefault(nums[r],0)+1);
            
            //如果新遍历到的数在hashmap中出现过,也就是[l,r]中存在重复数字,则l需要向前移
            while(hashmap.get(nums[r])>1){
                hashmap.put(nums[l],hashmap.get(nums[l])-1);//并且l对应的数字的value要在hashmap中-1
                l++;
            }
            res=Math.max(res,r-l+1);//用res记录每次循环中[l,r]区间最大长度
        }
        System.out.print(res);
    }
}


AcWing 2816. 判断子序列

原题链接

代码如下:

import java.util.Scanner;

public class Main{
    public static void main(String[] args){
        Scanner sc=new Scanner(System.in);
        int n=sc.nextInt();
        int m=sc.nextInt();
        int[] a=new int[n];
        int[] b=new int[m];
        for(int i=0;i<n;i++)a[i]=sc.nextInt();
        for(int i=0;i<m;i++)b[i]=sc.nextInt();
        
        int i=0,j=0;//i遍历a数组,j遍历b数组
        while(i<n&&j<m){
            //只有当数字匹配上时,i指针向前移动
            if(a[i]==b[j])i++;
            j++;//无论是否匹配上,j指针都必须向前移动
        }
        
        if(i==n)System.out.print("Yes");
        else System.out.print("No");
    }
}

2.左右指针


LeetCode 11. 盛最多水的容器

原题链接

2023.05.29 一刷

思路:转自Alba
  一开始两个指针一个指向开头一个指向结尾,此时容器的底是最大的,接下来随着指针向内移动,会造成容器的底变小,在这种情况下想要让容器盛水变多,就只有在容器的高上下功夫。 那我们该如何决策哪个指针移动呢?我们能够发现不管是左指针向右移动一位,还是右指针向左移动一位,容器的底都是一样的,都比原来减少了 1。这种情况下我们想要让指针移动后的容器面积增大,就要使移动后的容器的高尽量大,所以我们选择指针所指的高较小的那个指针进行移动,这样我们就保留了容器较高的那条边,放弃了较小的那条边,以获得有更高的边的机会。

代码如下:

//双指针(用时4ms,击败60.13%;内存54.5MB,击败5%)
class Solution {
    public int maxArea(int[] height) {
        int maxArea=0;
        int l=0,r=height.length-1;
        while(l<r){
            int curArea=(r-l)*Math.min(height[l],height[r]);
            maxArea=Math.max(curArea,maxArea);
            if(height[l]<=height[r])
                ++l;
            else
                --r;
        }
        return maxArea;
    }
} 

while循环中间还可以通过判断,再跳过一些状态:

代码如下:

//双指针+快速跳过(用时1ms,击败100%;内存54.5MB,击败5.5%)
class Solution {
    public int maxArea(int[] height) {
        int maxArea=0;
        int l=0,r=height.length-1;
        while(l<r){
            int curArea=(r-l)*Math.min(height[l],height[r]);
            maxArea=Math.max(curArea,maxArea);
            //记录下当前最小的高度,后面小于等于这个高度的都不考虑了(因为向内缩之后一定会小于等于当前面积),直接分别向中间移动
            int minH=Math.min(height[l],height[r]);
            while(height[l]<=minH&&l<r)++l;
            while(height[r]<=minH&&l<r)--r;
        }
        return maxArea;
    }
}

LeetCode 977. 有序数组的平方

原题链接

思路:

数组其实是有序的, 只不过负数平方之后可能成为最大数了。
那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。
此时可以考虑双指针法了,i指向起始位置,j指向终止位置。
定义一个新数组res,和nums数组一样的大小,让k指向res数组终止位置。
如果nums[i] * nums[i] <=nums[j] * nums[j] 那么res[k–] = A[j] * A[j–]; 。
如果nums[i] * nums[i] >nums[j] * nums[j] 那么res[k–] = A[i] * A[i++]; 。

代码如下:

class Solution {
    public int[] sortedSquares(int[] nums) {
        int i=0,j=nums.length-1;
        int[] res=new int[nums.length];
        int k=nums.length-1;
        while(i<=j){
            if(nums[i]*nums[i]<=nums[j]*nums[j]){
                res[k--]=nums[j]*nums[j--];
            }else{
                res[k--]=nums[i]*nums[i++];
            }
        }
        return res;
    }
}

AcWing 800. 数组元素的目标和

原题链接

提示思路:i指针从左开始遍历a数组,j指针从右开始遍历b数组

代码如下:

import java.util.Scanner;
public class Main{
    public static void main(String[] args){
        Scanner sc=new Scanner(System.in);
        int n=sc.nextInt();
        int m=sc.nextInt();
        int x=sc.nextInt();
        int[] a=new int[n];
        int[] b=new int[m];
        for(int i=0;i<n;i++)a[i]=sc.nextInt();
        for(int i=0;i<m;i++)b[i]=sc.nextInt();
        
        //i指针从左开始遍历a数组,j指针从右开始遍历b数组
        for(int i=0,j=m-1;i<n;i++){
            //若两数之和大于x,说明b[j]偏大,需要继续减小
            while(j>=0&&(a[i]+b[j])>x)j--;
            if((a[i]+b[j])==x){
                System.out.print(i+" "+j);
                break;
            }
        }
        
    }
} 

LeetCode 15. 三数之和(双指针+剪枝+去重)

原题链接
2023.05.29 三刷

  题目中要求不能包含重复的三元组,所以就不能简单照搬454.四数之和Ⅱ的分组哈希做法

  • 先将数组排序,用i作为索引遍历nums数组,对每一个i,left=i+1,right=nums.length-1;

  • left和right向中间收缩,当sum<0,说明当前三个数太小,nums[i]固定,只能增大left;同理sum>0,减小right。

  • 另外在遍历的时候需要注意三元组的去重。

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-2;i++){
            //三元组第一个数都比0大,后面加上后两个数不可能等于0,所有后面的都不用考虑
            if(nums[i]>0)break;
            //当前数和前一个一样,那么得到的三元组也会和前一个数得到的三元组一样,直接跳过
            if(i>0&&nums[i]==nums[i-1])continue;//去重
            int left=i+1,right=nums.length-1;
            while(left<right){
                int sum=nums[i]+nums[left]+nums[right];
                if(sum==0){
                    //符合条件,加入res,索引向中间移动
                    res.add(Arrays.asList(nums[i],nums[left++],nums[right--]));
                    //如果nums[left]和nums[left-1]一样,得到的三元组也会一样
                    //为了去重,直接跳过当前这个数。但是要在left<right范围内进行
                    while(left<right&&nums[left]==nums[left-1])left++;
                    while(left<right&&nums[right]==nums[right+1])right--;
                }else if(sum<0){
                    left++;
                }else if(sum>0){
                    right--;
                }
            }
        }
        return res;
    }
}

LeetCode 18. 四数之和(双指针将复杂度+去重+剪枝)

原题链接

其实就是在三数之和的基础上,再多一个指针j,三数之和中是nums[i]为确定值,这题里面就用nums[i]+nums[j]作为确定值,然后再利用首尾两个指针left和right向中间收缩。

中间有一些剪枝以及去重操作是需要注意的,可以很好提高代码效率

代码如下:

class Solution {
    public List<List<Integer>> fourSum(int[] nums, int target) {
        List<List<Integer>> res=new ArrayList<>();
        Arrays.sort(nums);
        int n=nums.length;
        for(int i=0;i<n-3;i++){
            //去重
            if(i>0&&nums[i]==nums[i-1])continue;
            //剪枝,当最小的4个数相加都超过,后面肯定找不到符合条件的
            if((long)nums[i]+nums[i+1]+nums[i+2]+nums[i+3]>target)break;
            //剪枝,当当前最大的4个数都小于,当前nums[i]肯定不够,直接用下一个
            if((long)nums[i]+nums[n-3]+nums[n-2]+nums[n-1]<target)continue;
            for(int j=i+1;j<n-2;j++){
                //与i同样道理,去重
                if(j>i+1&&nums[j]==nums[j-1])continue;
                //与i一样的道理,剪枝
                if((long)nums[i]+nums[j]+nums[j+1]+nums[j+2]>target)break;
                if((long)nums[i]+nums[j]+nums[n-2]+nums[n-1]<target)continue;
                int left=j+1,right=n-1;
                while(left<right){
                    //四个10亿相加会爆int
                    long sum=(long)nums[i]+nums[j]+nums[left]+nums[right];
                    if(sum==target){
                        res.add(Arrays.asList(nums[i],nums[j],nums[left++],nums[right--]));
                        //去重
                        while(left<right&&nums[left]==nums[left-1])left++;
                        while(left<right&&nums[right]==nums[right+1])right--;
                    }else if(sum<target){
                        left++;
                    }else if(sum>target){
                        right--;
                    }
                }
            }
        }
        return res;

    }
}

3.二分搜索


有关二分搜索的代码模板选择可以参考另一篇文章,链接在此:二分法模板选择


LeetCode 704. 二分查找

原题链接

这题是很基础很典型的二分法的题目,直接套用模板即可。

代码如下:

class Solution {
    public int search(int[] nums, int target) {
        int l=0,r=nums.length-1;
        while(l<r){
            int mid=l+r+1>>1;
            if(nums[mid]<=target)l=mid;
            else r=mid-1;
        }
        //while结束判断最后的l是不是target下标,不是则返回-1
        return nums[l]==target ? l:-1;
    }
}

LeetCode 74. 搜索二维矩阵

原题链接

2024.05.20 三刷

思路:
典型模版题,只是需要映射。

这题只需要把二维矩阵映射成一维矩阵,然后套用最基础的二分即可。只需要写一个get函数,获取对应位置上二维矩阵的值。

代码如下:

class Solution {
    public boolean searchMatrix(int[][] matrix, int target) {
        int m=matrix.length;
        int n=matrix[0].length;
        int l=0,r=m*n-1;
        while(l<r){
            int mid=l+r+1>>1;
            if(get(matrix,mid)<=target)l=mid;
            else r=mid-1;
        }
        return get(matrix,l)==target;
    }
    //找到二维矩阵对应位置的元素
    public int get(int[][] matrix,int index){
        int n=matrix[0].length;
        int i=index/n;
        int j=index%n;
        return matrix[i][j];
    }
}

LeetCode 162. 寻找峰值

原题链接

可以从题目下面的要求中得到提示:对于所有有效的 i 都有 nums[i] != nums[i + 1],可以通过相邻元素的大小关系去二分搜索逼近峰值。

代码如下:

class Solution {
    public int findPeakElement(int[] nums) {
        int l=0,r=nums.length-1;
        while(l<r){
            int mid=l+r>>1;
            //说明当前最近的峰值在mid右边,向右搜索
            if(nums[mid]<nums[mid+1])
                l=mid+1;
            else//题目要求nums[i]!=nums[i + 1],所以只剩nums[mid]>nums[mid+1]情况
                r=mid;
        }
        return l;
    }
}

LeetCode 852. 山脉数组的峰顶索引(同剑指 Offer II 069. ⼭峰数组的顶部)

原题链接

此题与LeetCode 162. 寻找峰值解法一样,唯一不同在于此题“山峰”唯一,不过用同样的二分法可以逼近山峰。

代码如下:

//根据山脉数组的定义,“山峰”应该是唯一的,因此山峰前后的数值都是单调的
//可以根据arr[mid]与arr[mid+1]之间的关系来确定二分搜索的区间选择
class Solution {
    public int peakIndexInMountainArray(int[] arr) {
        int l=0,r=arr.length-1;
        while(l<r){
            int mid=l+r>>1;
            if(arr[mid]<arr[mid+1])l=mid+1;
            else r=mid;
        }
        return l;
    }
}

LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置

原题链接

利用二分模板,先找目标范围的左边界,然后再用一遍模板找右边界即可。

代码如下:

class Solution {
    public int[] searchRange(int[] nums, int target) {
        
        int[] res=new int[]{-1,-1};//初始化res,假设找不到目标值的返回数组
        if(nums.length==0)return res;//如果数组为空直接返回

        //先找左边界
        int l=0,r=nums.length-1;
        while(l<r){
            int mid=l+r>>1;
            //一直向左边找(最终找到目标值的左边界)
            if(nums[mid]>=target)r=mid;
            else l=mid+1;
        }
        //while循环结束可能找不到target,需要判断一下
        if(nums[l]==target)res[0]=l;
        else 
            return res;//如果最终的nums[l]≠target,就说明数组中不存在target,直接返回

        //恢复二分区间初值,重新找右边界
        l=0;
        r=nums.length-1;
        while(l<r){
            int mid=l+r+1>>1;
            //一直向右边找(最终停在目标值右边界)
            if(nums[mid]<=target)l=mid;
            else r=mid-1;
        }
        res[1]=l;
        
        return res;
    }
}

剑指 Offer 53 - I. 在排序数组中查找数字 I

原题链接

此题和LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置基本一样,只要把目标数target出现的左右边界求出,用右边界索引减去左边界的索引+1即可求出目标数target出现的次数。

代码如下:

class Solution {
    public int search(int[] nums, int target) {
        if(nums.length==0)return 0;
        int l=0,r=nums.length-1;
        int left=0,right=0;
        //向右逼近
        while(l<r){
            int mid=l+r+1>>1;
            if(nums[mid]<=target)l=mid;
            else r=mid-1;
        }
        if(nums[l]!=target)return 0;
        else right=l;

        //向左逼近
        l=0;r=nums.length-1;
        while(l<r){
            int mid=l+r>>1;
            if(nums[mid]>=target)r=mid;
            else l=mid+1;
        }
        left=l;

        return right-left+1;
    }
}

LeetCode 1011. 在 D 天内送达包裹的能力

原题链接

针对每一个运载能力,其实都可以求证在该运载能力下,需要多少天才可以将货物运输完。题目要求的是最低运载能力,就是要求能在days天内运输完所有货物的运载能力。

思路:
相当于在一个运载量范围内,找到能符合条件的最小值,可以考虑采用二分法。

二分搜索的范围就是运载能力的范围

l=最低运力:只确保所有包裹能够被运送,自然也包括重量最大的包裹,此时理论最低运力为 max,max 为数组 weights 中的最大值

r=最高运力:使得所有包裹在最短时间(一天)内运送完成,此时理论最高运力为 sum,sum 为数组 weights 的总和

代码如下:

class Solution {
    //检查在运载能力为mid时,能否在days天内把包裹全运输完
    public boolean check(int[] weights,int carryCapacity,int days){
        int countDay=1;//记录当前是第几天
        int countWeight=0;//记录当前运载重量
        for(int weight:weights){
            if(countWeight+weight>carryCapacity){
                countDay++;
                countWeight=0;
            }
            countWeight+=weight;
        }
        return countDay<=days;

    }
    public int shipWithinDays(int[] weights, int days) {
        int maxWeight=0;//记录最大重量
        int sumWeight=0;//记录总重
        for(int x:weights){
            if(x>maxWeight)maxWeight=x;
            sumWeight+=x;
        }
        //在载重能力区间进行二分搜索
        int l=maxWeight,r=sumWeight;
        while(l<r){
            int mid=l+r>>1;
            //如果当前载重能力可以在days天内运完,可以向更小的运载能力搜索
            if(check(weights,mid,days))r=mid;
            else l=mid+1;
        }
        return l;
    }
}

LeetCode 875. 爱吃香蕉的珂珂(同剑指 Offer II 073. 狒狒吃香蕉)

原题链接

这题解法思路和LeetCode 1011. 在 D 天内送达包裹的能力差不多。

思路:

如果珂珂在h小时内吃掉所有香蕉的最小速度是每小时k个香蕉,则当吃香蕉的速度大于每小时k个香蕉时一定可以在h小时内吃掉所有香蕉,当吃香蕉的速度小于每小时k个香蕉时一定不能在h小时内吃掉所有香蕉。

由于吃香蕉的速度和是否可以在规定时间内吃掉所有香蕉之间存在单调性,因此可以使用二分查找的方法得到最小速度 k。

由于每小时都要吃香蕉,即每小时至少吃 1 个香蕉,因此二分查找的下界是1;由于每小时最多吃一堆香蕉,即每小时吃的香蕉数目不会超过最多的一堆中的香蕉数目,因此二分查找的上界是最多的一堆中的香蕉数目。

代码如下:

class Solution {
    public int check(int[] piles,int speed){
        int hours=0;//记录在速度k下,需要多少小时吃完
        for(int pile:piles){
            //相当于pile/speed的向上取整(比通过取模判断要不要+1更快)
            hours+=(pile-1)/speed+1;
        }
        return hours;
    }
    public int minEatingSpeed(int[] piles, int h) {
        int maxPile=1;//香蕉堆最大的堆的香蕉数量
        for(int pile:piles){
            maxPile=Math.max(maxPile,pile);
        }
        int l=1,r=maxPile;
        while(l<r){
            int mid=l+(r-l)/2;
            //在mid速度下,可以在h小时内吃完,则速度可以保持或者更小
            if(check(piles,mid)<=h)r=mid;
            else l=mid+1;
        }
        return l;
    }
}

其中值得注意的是速度为speed时,吃每一堆的耗时是pile/speed(向上取整),直接调用Math的ceil方法可以向上取整,但是效率会偏慢一些。可以采用(pile-1)/speed+1进行计算,会更快一些。因为java中"/"是向下取整的,当pile能整除时,因为被先-1了,向下取整后会比pile/speed小1,再+1可以补回来;当pile不能被speed整除时,需要向上取整,(pile-1)/speed会得到向下取整的结果,再+1就是向上取整了。


LeetCode 35. 搜索插入位置(同剑指 Offer II 068. 查找插入位置)

原题链接

2024.05.19 三刷

这题是寻找目标值的位置,如果没找到,就返回它应该插入数组的位置。不同于
LeetCode 34. 在排序数组中查找元素的第一个和最后一个位置,34题在循环退出时,只需要判断退出的位置(l/r)是否为target,不是则返回-1。而此题在循环退出时还需要根据具体情况判断退出时的nums[mid]与target的关系:

思路:典型二分查找模板题

习惯用左闭右开区间进行查找:
初始区间:[0,n),左闭右开
定位中点:l+(r-l)/2
每次划分:

  • target<nums[mid]:向左找,[l,mid),因为是左闭右开,右区间mid并不会取到;
  • target>nums[mid]:向右找[mid+1,r),左闭,向右一步要保证取不到mid;
  • target=nums[mid]:找到返回索引

退出循环时l==r,mid=l,就是需要插入的位置

都没找到,就返回最后的l;

套入模板,代码如下:

class Solution {
    public int searchInsert(int[] nums, int target) {
        int l=0,r=nums.length-1;
        while(l<r){
            int mid=l+r+1>>1;
            if(nums[mid]<=target)l=mid;
            else r=mid-1;
        }
        //退出时一定是l=r,此时nums[mid]与target关系还不确定,需要分类讨论
        //比target小,插入位置就是在l右边一步
        if(nums[l]<target)return l+1;
        else return l;//比target大,或者等于target,插入位置都在当前的l上
    }
}

另一种是借助while循环的终止条件以及内部的return条件来简化出循环之后的判断代码,简而言之就是在while内部就把之后需要判断的情况给分开,使得出循环后不必再进行分类讨论。

代码如下:

class Solution {
    public int searchInsert(int[] nums, int target) {
        int l=0,r=nums.length-1;

        /**注意这里退出条件不能还是按照模板的l<r
        如果按照l<r,退出时是l=r,但是l=r时,nums[mid]可能还是不等于target
        这时候分两种情况:
        1.nums[mid]<target,需要往右边搜索一步(mid=l+1),改变的是l指针,插入位置也就是当前mid的右边一步,也就是l
        2.nums[mid]>target,需要往左边一步(mid=r-1),改变的是r指针,插入位置就是当前的mid所在位置,也就是l
        所以当nums[mid]!=target时,要找到插入位置,一定是在l>r的情况下发生的,while循环发生的条件就设置为l<=r.
         */
        while(l<=r){
            int mid=l+r>>1;
            if(nums[mid]==target)return mid;
            else if(nums[mid]<target)l=mid+1;
            else r=mid-1;
        }
        //走到while外面才返回,一定是找不到target,要找插入位置
        return l;
    }
}

剑指 Offer 53 - II. 0~n-1中缺失的数字

原题链接

思路:
长度为n-1的递增排序数组中的所有数字都是唯一的,范围0~n-1内的n个数字中有且只有一个数字不在该数组中。从递增排序数组中找数,典型的二分搜索题目。

对中点nums[mid] (缺失一个数字之后的中点)与mid(完整数组的中点)的关系进行分类讨论:

1.如果nums[mid]==mid,说明缺mid之前的数字都没缺失,缺失的数字在mid右边,l=mid+1;

2.如果缺失的数字在mid处,或者缺失的数字在mid左边,会导致nums[mid]>mid。也就是当nums[mid]>mid时,下一个搜索区间向左走的,并且包括当前mid(因为缺失的数字可能在mid处),也就是r=mid。

代码如下:

class Solution {
    public int missingNumber(int[] nums) {
        int l=0,r=nums.length-1;
        while(l<r){
            int mid=l+r>>1;
            if(nums[mid]==mid)l=mid+1;
            else if(nums[mid]>mid)r=mid;
        }//退出时l=r

        //如果是nums[mid]==mid,缺失数字就在l(mid)的右边一个
        if(nums[l]==l)return l+1;
        else//nums[mid]不可能小于mid,只会发生nums[mid]>mid
            return l;//这时候l=r=mid
    }
}

4.滑动窗口

滑动窗口的核心就是不断调节左右窗口的边界,并保持一个方向移动的趋势。

重点就在于左右窗口边界是如何调整的。


滑动窗口模板(来自labuladong)

模板如下:

/* 滑动窗口算法框架 */
void slidingWindow(String s) {
    Map<Character, Integer> window = new HashMap<>();
    
    int left = 0, right = 0;
    while (right < s.length()) {
        // c 是将移入窗口的字符
        char c = s.charAt(right);
        // 增大窗口
        right++;
        // 进行窗口内数据的一系列更新
        ...

        /*** debug 输出的位置 ***/
        // 注意在最终的解法代码中不要 print
        // 因为 IO 操作很耗时,可能导致超时
        System.out.printf("window: [%d, %d)\n", left, right);
        /********************/
        
        // 判断左侧窗口是否要收缩
        while (window needs shrink) {
            // d 是将移出窗口的字符
            char d = s.charAt(left);
            // 缩小窗口
            left++;
            // 进行窗口内数据的一系列更新
            ...
        }
    }
}

其中两处 … 表示的更新窗口数据的地方,到时候直接往里面填自己的操作就行了。

而且,这两个 … 处的操作分别是扩大和缩小窗口的更新操作,会发现它们操作是完全对称的。

另外,虽然滑动窗口代码框架中有一个嵌套的 while 循环,但算法的时间复杂度依然是 O(N),其中 N 是输入字符串/数组的长度。

模板是c++语言的,转换成java需要以下知识:

  • unordered_map 就是哈希表(字典),相当于 Java 的 HashMap,它的一个方法 count(key) 相当于 Java 的 containsKey(key) 可以判断键 key 是否存在。

  • 可以使用方括号访问键对应的值 map[key]。需要注意的是,如果该 key 不存在,C++ 会自动创建这个 key,并把 map[key] 赋值为 0。所以代码中多次出现的 map[key]++ 相当于 Java 的 map.put(key, map.getOrDefault(key, 0) + 1)。

  • 另外,Java 中的 Integer 和 String 这种包装类不能直接用 == 进行相等判断,而应该使用类的 equals 方法


模板题

LeetCode 209. 长度最小的子数组

原题链接

这题想要使用滑动窗口,要弄懂3个问题:

  • 窗口里装什么
  • 窗口左边界如何动
  • 窗口右边界如何动

很明显,我们需要用到窗口里装的数的总和sum,当sum大于等于target时,就记录下此时的窗口大小,如果这是最小的窗口,就将res赋值为当前窗口大小,同时应该从左边界开始缩小窗口。如果sum小于target,就从窗口右边界扩大窗口。

代码如下:

class Solution {
    public int minSubArrayLen(int target, int[] nums) {
        int start=0,end=0;//窗口起始位置,遍历过程左闭右开
        int sum=0;
        int res=100001;//窗口大小不可能超过数组长度
        int subLenth=0;//实时记录符合要求的窗口大小
        //窗口终止位置为end,用于遍历数组nums
        while(end<nums.length){
            //窗口扩张+右边界前移+更新窗口内数据
            sum+=nums[end++];
            //窗口内数据符合要求,就可以收缩
            while(sum>=target){
                //记录当前窗口内数据
                //本来是end-start+1,但是由于窗口左闭右开(最开始end++了)
                //这时候的end-start就是实际长度
                subLenth=end-start;
                res= res<subLenth ? res:subLenth;
                //窗口左边界缩小
                sum-=nums[start];
                start++;
            }
        }
        if(res==100001)return 0;
        return res;
    }
}

LeetCode 76. 最小覆盖子串(同剑指 Offer II 017. 含有所有字符的最短字符串)

原题链接

2023/12/03 四刷

这题是相对复杂的滑动窗口题,掌握之后,套用模板再做后面的题就会比较容易了。

主要需要解决的问题:
1、什么时候应该移动 right 扩大窗口?窗口加入字符时,应该更新哪些数据?

2、什么时候窗口应该暂停扩大,开始移动 left 缩小窗口?从窗口移出字符时,应该更新哪些数据?

3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

如果一个字符进入窗口,应该增加 window 计数器;如果一个字符将移出窗口的时候,应该减少 window 计数器;当 valid 满足 need 时应该收缩窗口;应该在收缩窗口的时候更新最终结果。

关于窗口边界左闭右开的选择理由:

理论上可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化 left = right = 0 时区间 [0, 0) 中没有元素,但只要让 right 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。如果设置为两端都开的区间,那么让 right 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。

代码如下:

//1.HashMap写法
class Solution {
    public String minWindow(String s, String t) {
    	/*注意:里面value存储的类型是包装类,比较value时不能用==,而是要用equals*/
        Map<Character,Integer> need=new HashMap<>();//存储t串字符,及对应字符出现次数
        Map<Character,Integer> window=new HashMap<>();//存储窗口内字符,及对应出现次数

        /** 最开始要先遍历t串,统计每个字符出现的次数(以键值对形式存储)*/
        for(char c:t.toCharArray()){
            //getOrDefault(c,0)作用:如果c这个键上有值,则获取其value;若无值,则赋0
            //后面补上的+1则表示,遍历到c这个字符,就将c对应的value+1;
            need.put(c,need.getOrDefault(c,0)+1);
        }

        int valid=0;//记录窗口中,符合需求的字符的个数
        int wStart=0,wEnd=0;//窗口边界,左闭右开[start,end),初始[0,0)为空
        int wLen=100001;//记录窗口大小
        int subStart=0;//最后返回的子串的边界(substring,左闭右开)

        /**开始滑动窗口代码 */
        while(wEnd<s.length()){
            /**①窗口扩张,右边界移动 */
            char c=s.charAt(wEnd);//记录新加入窗口内的字符
            wEnd++;//

            /**②接下来更新窗口内数据*/
            //判断当前字符c是不是t串中需要的,如果字符c是t串中需要的
            if(need.containsKey(c)){
                //就要把窗口内对应的字符数量+1
                window.put(c,window.getOrDefault(c,0)+1);
                //加完后需要看这个字符的数量达到要求了没,达到了说明满足要求的字符数量+1
                if(window.get(c).equals(need.get(c)))valid++;
            }

            /**③然后就需要考虑窗口缩小问题了,即当窗口内元素符合要求时,左边界前进 */
            //当valid值达到need中包含的字符数量时,说明窗口已经涵盖了t所有字符了
            while(valid==need.size()){
                //记录下当前窗口大小,如果更小
                //本来wEnd-wStart+1才是窗口长度,但是在最前面wEnd已经向前了(左闭右开)
                //所以这里不用+1就是真实窗口长度
                if(wEnd-wStart<wLen){
                    wLen=wEnd-wStart;//就记录更小的窗口长度
                    subStart=wStart;//并且记录更小字串的起始位置(方便最后返回字串)
                }
                char l=s.charAt(wStart++);//记录下当前窗口左边界字符,然后窗口左边界缩小

                /**④开始更新缩小后的窗口内数据 */
                //窗口内字符可能不是t中需要的,就不用对window特别处理,是t需要的才进行处理
                if(need.containsKey(l)){
                    //只有当window中字符l与need中字符l个数相同,去掉l才会导致valid-1
                    if(window.get(l).equals(need.get(l)))valid--;
                    //要去掉的l字符是t需要的,就要在window中将该字符的value-1
                    window.put(l,window.get(l)-1);
                }
            }
        }

        return wLen == 100001 ? "" : s.substring(subStart,subStart+wLen);
    }
}

此外可以用数组模拟hash方法,思路与HashMap一样。代码如下:

// 2.数组模拟hash,字符集大小为k,时间O(tLen+sLen+k),空间O(k)
class Solution {
    public String minWindow(String s, String t) {
        int sLen=s.length(),tLen=t.length();
        int[] need=new int[128];
        for(int i=0;i<tLen;i++){
            char c=t.charAt(i);
            ++need[c];
        }
        int tCount=0;
        for(int i=0;i<128;i++){
            if(need[i]!=0)++tCount;
        }
        int[] window=new int[128];
        int wStart=0,wEnd=0,wCount=0;
        int subLen=100001,subStart=0;
        while(wEnd<sLen){
            char r=s.charAt(wEnd++);
            if(need[r]!=0){
                ++window[r];
                if(window[r]==need[r])wCount++;
            }
            while(wCount==tCount){
                if(wEnd-wStart<subLen){
                    subStart=wStart;
                    subLen=wEnd-wStart;
                }
                char l=s.charAt(wStart++);
                if(need[l]!=0){
                    if(window[l]==need[l]){
                        --wCount;
                    }
                    --window[l];
                }
            }
            
        }
        return subLen==100001 ? "" : s.substring(subStart,subStart+subLen);
    }
}

LeetCode 567. 字符串的排列(同剑指 Offer II 014. 字符串中的变位词)

原题链接

题目理解:

在s2中找到这样一种字串(连续的):包含s1所有字符,包括重复的字母,这些字母的顺序可以打乱。因此只要在s2找到一段连续的字符串,每种字母的数量以及字串长度和s1相同即可。

上一题是要我们找出这样条件的最短的字串,而这题只要求判断有没有存在这样的字串,所以会更简单一些。

同样套用模板,代码如下:

class Solution {
    //这题试下用数组来模拟hash
    public boolean checkInclusion(String t, String s) {
        int[] need=new int[26];//统计s1每个字母数量,初始全为0
        int[] window=new int[26];//统计窗口内字母数量
        int count=0;//记录need中有几种字母
        //统计s1每个字母个数
        for(char c:t.toCharArray()){
            need[c-'a']++;
        }

        for(int i=0;i<26;i++){
            if(need[i]!=0)++count;//记录need中字母有几种
        }

        int valid=0;//记录窗口(window)内和need值相同的字母个数
        int wStart=0,wEnd=0;//窗口边界,左闭右开

        while(wEnd<s.length()){
            char c=s.charAt(wEnd++);
            /*调整窗口内数据 */
            //首先确定s1中有这个字母
            if(need[c-'a']!=0){
                ++window[c-'a'];//窗口内该字母数量+1
                if(window[c-'a']==need[c-'a'])++valid;//加到和s1一样,能匹上的字母多一个
            }

            //什么时候收缩窗口?
            /*这题滑动窗口大小固定为s1长度,因为符合条件的字串长度一定为s1长度 */
            //只有当滑动窗口的长度超过s1长度时才需要从左收缩一位,这里用while和if都行
            while(wEnd-wStart>t.length()){
                char l=s.charAt(wStart++);
                /**修改窗口数据 */
                //当need有左边界这个字母才会对window和valid修改
                if(need[l-'a']!=0){
                    if(window[l-'a']==need[l-'a'])
                        --valid;
                    --window[l-'a'];
                }
            }
            /**必须在窗口收缩之后判断,如果放在收缩前,长度可能会一直不符合要求*/
            //如果窗口内字母种数和need相同,并且窗口长度和s1一样),就是s1的排列了
            if(valid==count&&wEnd-wStart==t.length())return true;
            
        }
        return false;
    }
}

这题尝试了用数组去模拟hashMap,用时会更快一些。


LeetCode 438. 找到字符串中所有字母异位词(同剑指 Offer II 015. 字符串中的所有变位词)

原题链接

2023.12.01 三刷

这题要找的—异位词,其实和前面两题要找的是一样的,只不过这题要求返回所有异位词的起始位置,套用同样的代码即可。

思路:滑动窗口
1.p的异位词要求长度和pLen一样,且每个字母出现次数也要一样
2.用need数组统计p中每个字母出现的次数,用pCount统计p字符串有多少种字母
3.窗口内是什么?–窗口长度保持和p的长度一致
4.窗口扩张–最开始先扩张到和p的长度一致,然后每次扩张一步。在扩张过程中需要统计窗口内的各字母数量(只有扩展的字母是need中的才能计入window中),当窗口内该种字母数量达到need中需求时,wCount+1,表示窗口内符合异位词字母出现次数的字母种类+1。
5.窗口收缩–窗口大小超过p就需要收缩,被收缩的字母数-1,如果-1后不满足need的数量,那么wCount就需要-1,然后window中对应字母数量-1.
6.最后要求长度相同(pLenwStart),并且符合要求的字母数量相同(pCountwCount)

代码如下:

class Solution {
    public List<Integer> findAnagrams(String s, String p) {
        int sLen=s.length(),pLen=p.length();
        List<Integer> res=new ArrayList<>();
        int[] need=new int[26];
        int[] window=new int[26];
        int count=0;

        for(int i=0;i<pLen;i++)++need[p.charAt(i)-'a'];
        for(int i=0;i<26;i++){
            if(need[i]!=0)count++;
        }

        int wStart=0,wEnd=0;
        int valid=0;//window中能与need数量一样的字母数

        while(wEnd<sLen){
            char c=s.charAt(wEnd++);
            //首先确定加入窗口的这个字符有没有在p中
            if(need[c-'a']!=0){//如果该字符在p中
                ++window[c-'a'];//窗口内该字符数量+1
                if(window[c-'a']==need[c-'a'])++valid;//当数量加到与p中相同
            }
            //符合题目要求的窗口长度一定和p长度相同,当长度超过时,需要从左边缩小
            if(wEnd-wStart>pLen){
                char l=s.charAt(wStart++);
                //左边界字符要在p中,才需要修改窗口中有关异位词的数据
                if(need[l-'a']!=0){
                    //如果移出窗口的字符在窗口中时,该字符数量和p中相同
                    //那么其移出之后,符合条件的字符数量-1
                    if(window[l-'a']==need[l-'a'])
                        --valid;
                    --window[l-'a'];//窗口内该字符数量-1
                }
            }
            //只有长度相同,并且符合要求的字符数量相同时,窗口内才是异位词
            if(wEnd-wStart==pLen&&valid==count)res.add(wStart);
        }
        return res;
    }
}

LeetCode 3. 无重复字符的最长子串(同剑指 Offer 48. 最长不含重复字符的子字符串 与 剑指 Offer II 016. 不含重复字符的最长子字符串)

原题链接

这题用一个hashmap类型的window存储所有字符的个数,当加入的字符重复时,就缩小窗口,将左边界向右移动。当窗口收缩完之后,再统计字串的长度,更新res。(其实用HashSet更方便一点,因为HashSet不允许有重复值)

代码如下:

class Solution {
    public int lengthOfLongestSubstring(String s) {
        int res=0;
        int sLen=s.length();
        Map<Character,Integer> window=new HashMap<>();
        int start=0,end=0;
        while(end<sLen){
            char c=s.charAt(end++);
            window.put(c,window.getOrDefault(c,0)+1);

            while(window.get(c)>1){
                char l=s.charAt(start++);
                window.put(l,window.get(l)-1);
            }
            res=Math.max(res,end-start);
        }
        return res;
    }
}

此外还可以用数组模拟hash的方法,代码如下:

/* 思路:
1.题目只要求长度,可以用maxLen来记录遍历过程中的最大长度;
2.利用滑动窗口,窗口内的字符不重复,那么窗口大小就是不含有重复字符的最大长度
3.如何保证窗口内字符不重复?--可以用数组模拟hash,用来记录窗口内各种字符的数量
4.窗口扩张--模拟hash数组对应+1,窗口长度+1,窗口右边界+1
5.窗口收缩--当前面扩张时进入窗口的字符数量大于1收缩,窗口长度-1,模拟hash数组对应-1,左边界+1.

2023.05.30 三刷
 */

//时间O(n),空间O(字符集大小,一般为128)
class Solution {
    public int lengthOfLongestSubstring(String s) {
        int maxLen=0;//最大长度
        int[] hash=new int[128];
        int l=0,r=0;
        int sLen=s.length();
        while(r<sLen){
            char c=s.charAt(r++);//窗口左闭右开(一开始r++)
            ++hash[c];
            while(hash[c]>1){
                char cc =s.charAt(l++);
                --hash[cc];
            }
            maxLen=maxLen>r-l ? maxLen:r-l;//收缩之后窗口才符合没有重复字符的要求
        }
        return maxLen;
    }
}

LeetCode 904. 水果成篮

原题链接

理解题目:
要求在fruit上找到最长的连续区间,区间满足这样的要求:有且只有两种数字(代表水果种类),题目要求返回这个最长区间的长度。

思路:从连续区间、最长,可以想到采用滑动窗口,需要考虑以下几个问题:
①窗口内装什么:窗口内是水果的种类
②窗口左边界何时缩小:当窗口内部水果种类数目超过2种,就要缩小左边界

需要用一个数组模拟的hash表存储当前窗口内每种水果的数量;
当窗口扩张(右边界向前),判断这种水果数量是不是0,如果是,窗口内部水果种类count+1,
并且hash[fruit[end]]++;不是0则直接hash[fruit[end]]++。
当窗口内count>2时,需要缩小左边界,更新count直到count==2
(注意只有当窗口内hash[fruit[start]]==0的时候count才会-1)

代码如下:时间O(n),空间O(n)

class Solution {
    public int totalFruit(int[] fruits) {
        int n=fruits.length;
        int[] hash=new int[n];//统计窗口内每种水果有几个
        int count=0;//统计窗口内部水果种类数量
        int res=0;
        int start=0,end=0;

        while(end<n){
            //先右边界扩张
            if( hash[fruits[end]]==0)++count;
            ++hash[fruits[end]];
            ++end;

            //再收缩左边界
            while(count>2){
                --hash[fruits[start]];
                //先判断,再start++,要不会越界
                if(hash[fruits[start]]==0)--count;
                start++;
            }//出了while,count一定<=2,可以统计水果数量了
            res = res<end-start ? end-start:res;
        }
        return res;
    }
}

LeetCode 239. 滑动窗口最大值(同剑指 Offer 59 - I. 滑动窗口的最大值)

原题链接

2023.12.03 四刷

思路:

这题无法用简单的滑动窗口得出题目要求的结果,因为针对每一个长度为k的窗口,都需要记录当前该窗口中的最大值,随着窗口的滑动,之前的最大值可能会掉出这个窗口,导致需要重新在这个窗口内重新寻找最大值。所以需要一个数据结构来有序存储窗口内的元素。

可能会想到优先级队列,但是普通的优先级队列在这题里行不通,因为优先级队列出队只按照元素大小,无法根据元素先进先出的规则进行出队(滑动窗口内元素遵循先进先出),想要在这题里用优先级队列还需要一点特殊的处理(见Java PriorityQueue(优先级队列/二叉堆)的使用及题目应用)。

所以,现在需要一种新的队列结构,既能够维护队列元素「先进先出」的时间顺序,又能够正确维护队列中所有元素的最值,这就是「单调队列」结构。

总结一下这个滑动窗口里单调队列需要实现的功能:
void push(int num):
单调队列中存的是窗口内的元素,要保证队头元素总是当前队列中最大的,这就需要可以在队尾插入元素(offerLast())的时候,总是将队尾中小于要插入的num的元素删除(removeLast()),这样就可以保证队列从队头到队尾保持从大到小的状态。
void pop(int num):
需要注意的是,为了保证逆序队列,在入队的时候已经删了一些元素(窗口所有元素并不需要都在队列中,队列只要留存有机会成为最大值的元素即可),因此在后续需要将窗口左边界元素弹出队列(removeLast())时,需要判断这个元素是不是队头元素(peek()),是的话才弹出,否则不操作(不操作是因为这个元素在之前已经弹出了)。

int peek():
最后还需要一个函数用于返回队头元素(return deque.peek());

代码如下:

//单调队列--时间O(n),空间O(k)
class Solution {
    //自己实现针对此题的单调队列
    class Monotonic{
        //用双端队列来实现单调队列(因为队头队尾元素都需要插入删除操作)
        Deque<Integer> deque=new LinkedList<>();
        int peek(){
            return deque.peek();
        }
        void push(int num){
            // 所有队尾小于val的都删除(注意队列非空,否则可能会报错)
            while(!deque.isEmpty()&&deque.getLast()<num)deque.pollLast();
            deque.addLast(num);
        }
        //队列中元素逆序,要删除的左边界元素可能在push的时候就已经被删除了
        //队头元素一定这一批元素中最早进入的
        //要弹出的左边界元素,如果等于队头元素,说明要删的就是队头
        // 如果和队头不同,说明可能之前push的时候就已经被删除了,就不需要操作
        void pop(int num){
            // 注意需要队列非空
            if(!deque.isEmpty()&&num==deque.peek())deque.pollFirst();
        }
    }
    public int[] maxSlidingWindow(int[] nums, int k) {
        int n=nums.length;
        int[] res=new int[n-k+1];
        Monotonic dq=new Monotonic();
        //存入k个(可能实际不到k个,因为被后来的给删除了)
        for(int i=0;i<k;i++)dq.push(nums[i]);
        res[0]=dq.peek();
        //遍历剩下的n-k个
        for(int i=k;i<n;i++){
            //要先把左边界弹出再push,如果左边界是上个窗口最大值,很大
            //不先弹出去直接push,队头里留存的还是上一个窗口的最大值
            dq.pop(nums[i-k]);
            dq.push(nums[i]);//不能先push
            res[i-k+1]=dq.peek();
        }
        return res;
    }
}

还有一种更简洁的写法:

/*
  思路: 遍历数组 L R 为滑窗左右边界 只增不减
        双向队列保存当前窗口中最大的值的数组下标 双向队列中的数从大到小排序,
        新进来的数如果大于等于队列中的数 则将这些数弹出 再添加
        当R-L+1=k 时 滑窗大小确定 每次R前进一步L也前进一步 保证此时滑窗中最大值的
        数组下标在[L,R]中,并将当前最大值记录
  举例: nums[1,3,-1,-3,5,3,6,7] k=3
     1:L=0,R=0,队列【0】 R-L+1 < k
            队列代表值【1】
     2: L=0,R=1, 队列【1】 R-L+1 < k
            队列代表值【3】
     解释:当前数为3 队列中的数为【1】 要保证队列中的数从大到小 弹出1 加入3
          但队列中保存的是值对应的数组下标 所以队列为【1】 窗口长度为2 不添加记录
     3: L=0,R=2, 队列【1,2】 R-L+1 = k ,result={3}
            队列代表值【3,-1】
     解释:当前数为-1 队列中的数为【3】 比队列尾值小 直接加入 队列为【3,-1】
          窗口长度为3 添加记录记录为队首元素对应的值 result[0]=3
     4: L=1,R=3, 队列【1,2,3】 R-L+1 = k ,result={3,3}
            队列代表值【3,-1,-3】
     解释:当前数为-3 队列中的数为【3,-1】 比队列尾值小 直接加入 队列为【3,-1,-3】
          窗口长度为4 要保证窗口大小为3 L+1=1 此时队首元素下标为1 没有失效
          添加记录记录为队首元素对应的值 result[1]=3
     5: L=2,R=4, 队列【4】 R-L+1 = k ,result={3,3,5}
            队列代表值【5】
     解释:当前数为5 队列中的数为【3,-1,-3】 保证从大到小 依次弹出添加 队列为【5】
          窗口长度为4 要保证窗口大小为3 L+1=2 此时队首元素下标为4 没有失效
          添加记录记录为队首元素对应的值 result[2]=5
    依次类推 如果队首元素小于L说明此时值失效 需要弹出
*/
class Solution {
    public int[] maxSlidingWindow(int[] nums, int k) {
        if(nums==null||nums.length<2) return nums;
        // 双向队列 保存当前窗口最大值的数组位置 保证队列中数组位置的数按从大到小排序
        LinkedList<Integer> list = new LinkedList();
        // 结果数组
        int[] result = new int[nums.length-k+1];
        for(int i=0;i<nums.length;i++){
            // 保证从大到小 如果前面数小 弹出
            while(!list.isEmpty()&&nums[list.peekLast()]<=nums[i]){
                list.pollLast();
            }
            // 添加当前值对应的数组下标
            list.addLast(i);
            // 初始化窗口 等到窗口长度为k时 下次移动在删除过期数值
            if(list.peek()<=i-k){
                list.poll();   
            } 
            // 窗口长度为k时 再保存当前窗口中最大值
            if(i-k+1>=0){
                result[i-k+1] = nums[list.peek()];
            }
        }
        return result;
    }
}

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值