文章目录
- 数组的学习
- 双指针技巧(快慢指针)——原地修改数组
- 双指针(左右指针常用算法)
- 双指针技巧(滑动窗口技巧)——最难掌握
- 滑动窗口代码框架:
- 一、[76. 最小覆盖子串](https://leetcode.cn/problems/minimum-window-substring/)
- 二、[567. 字符串的排列](https://leetcode.cn/problems/permutation-in-string/)
- 三、[438. 找到字符串中所有字母异位词](https://leetcode.cn/problems/find-all-anagrams-in-a-string/)
- 四、[3. 无重复字符的最长子串](https://leetcode.cn/problems/longest-substring-without-repeating-characters/)
- 前缀和技巧
- 差分数组
- 二维数组的遍历技巧
- 二分查找
数组的学习
在处理数组和链表相关问题时,双指针技巧是经常用到的,双指针技巧主要分为两类:左右指针和快慢指针。
- 左右指针,就是两个指针相向而行或者相背而行
- 快慢指针,就是两个指针同向而行,一快一慢
在数组中并没有真正意义上的指针,但我们可以把索引当做数组中的指针,这样也可以在数组中施展双指针技巧。
注意:只要数组有序,就应该想到双指针技巧
双指针技巧(快慢指针)——原地修改数组
一、26. 删除有序数组中的重复项
但是下面这个时间复杂度太高,这样用快慢指针就没有意义了
public int removeDuplicates(int[] nums) {
int quick=1;
int slow=0;
int length=nums.length;
while(quick<length){
if(nums[slow]==nums[quick]){
for(int i=quick;i<length-1;i++){
nums[i]=nums[i+1];
}
}else{
slow++;
quick++;
}
}
return slow+1;
}
要高效地使用快慢指针就是,慢指针走在后面,快指针在前面探索,只要快指针找到和慢指针所指元素不相等的元素就先让慢指针前进一步然后在让慢指针所指的值等于快指针所指的值,然后快指针继续往前探索。
这样就保证了nums[0…slow]都是无重复的元素
处理后的数组长度就是slow+1
public int removeDuplicates(int[] nums) {
int quick=0;
int slow=0;
int length=nums.length;
while(quick<length){
if(nums[slow]!=nums[quick]){
slow++;
nums[slow]=nums[quick];
}
quick++;
}
return slow+1;
}
二、83. 删除排序链表中的重复元素
操作链表和操作数组一样,只是对于链表我们操作的是指针,而数组操作的是索引。
但是需要注意的一点是当遍历到尾已经没有不重复元素的时候,我们要手动将链表的小尾巴剪掉
具体例子可看1 1 2 3 3
public ListNode deleteDuplicates(ListNode head) {
if(head==null) return null;
ListNode quick=head;
ListNode slow=head;
while(quick!=null){
if(quick.val!=slow.val){
slow.next=quick;
slow=slow.next;
}
quick=quick.next;
}
// 断开与后面重复元素的连接
slow.next=null;
return head;
}
三、27. 移除元素
除了让你在有序数组/链表中去重,题目还可能让你对数组中的某些元素进行原地删除。
题目要求我们把 nums
中所有值为 val
的元素原地删除,依然需要使用快慢指针技巧:如果 fast
遇到值为 val
的元素,则直接跳过,否则就赋值给 slow
指针,并让 slow
前进一步。
**注意:**这里和有序数组去重的解法有一个细节差异,我们这里是先给 nums[slow]
赋值然后再给 slow++
,这样可以保证 nums[0..slow-1]
是不包含值为 val
的元素的,最后的结果数组长度就是 slow
。
public int removeElement(int[] nums, int val) {
int length=nums.length;
int slow=0;
int quick=0;
while(quick<length){
if(nums[quick]!=val){
nums[slow]=nums[quick];
slow++;
}
quick++;
}
return slow;
}
四、283. 移动零
这题和上一题移除元素一样,只不过在所有的移除完成以后,我们要让congslow位置开始的元素全部等于0(题目让我们将所有 0 移到最后,其实就相当于移除 nums
中的所有 0,然后再把后面的元素都赋值为 0 即可。)
public void moveZeroes(int[] nums) {
int quick=0;
int slow=0;
int length=nums.length;
while(quick<length){
if(nums[quick]!=0){
nums[slow]=nums[quick];
slow++;
}
quick++;
}
while(slow<length){
nums[slow++]=0;
}
}
双指针(左右指针常用算法)
二分查找的双指针特性:
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;
}
一、167. 两数之和 II - 输入有序数组
这道题的解法有点类似二分查找,通过调节 left
和 right
就可以调整 sum
的大小:
public int[] twoSum(int[] numbers, int target) {
int length=numbers.length;
int left=0;
int right=length-1;
while(left<right){
int sum=numbers[left]+numbers[right];
if(sum==target){
//int[] indexs=new int[2];
return new int[]{left+1,right+1};
}else if(sum<target){
left++;
}else if(sum>target){
right--;
}
}
return new int[]{-1,-1};
}
二、344. 反转字符串
一般编程语言都会提供 reverse
函数,其实这个函数的原理非常简单,力扣第 344 题「 反转字符串」就是类似的需求,让你反转一个 char[]
类型的字符数组,具体的实现就是一左一右两个指针相向而行,然后交换。
public void reverseString(char[] s) {
int length=s.length;
int left=0;
int right=length-1;
while(left<right){
char temp=s[left];
s[left++]=s[right];
s[right--]=temp;
}
}
三、5. 最长回文子串
最长回文子串使用的左右指针和之前题目的左右指针有一些不同:之前的左右指针都是从两端向中间相向而行,而回文子串问题则是让左右指针从中心向两端扩展。
找最长的回文子串的关键在于从中间开始找,需要注意的是这个中间会有两种情况:
- 回文子串长度为奇数:中点只有一个 也就是说左右指针的出发时,坐标相同
- 回文子串长度为偶数:中点有两个 也就是说左右指针出发时,坐标相邻
然后问题就变成找以中心为s[i]的回文串,然后每个元素都做一次中心,每次做中心都有以上两种情况
public String palindrome(String s,int left,int right){
while(left>=0&&right<s.length()&&(s.charAt(left)==s.charAt(right))){
left--;
right++;
}
return s.substring(left+1,right);
}
public String longestPalindrome(String s) {
int length=s.length();
String res="";
for(int i=0;i<length;i++){
// 以 s[i] 为中心的最长回文子串
String s1=palindrome(s,i,i);
// 以 s[i] 和 s[i+1] 为中心的最长回文子串
String s2=palindrome(s,i,i+1);
// res = longest(res, s1, s2)
res = res.length() > s1.length() ? res : s1;
res = res.length() > s2.length() ? res : s2;
}
return res;
}
双指针技巧(滑动窗口技巧)——最难掌握
这个算法技巧的思路非常简单,就是维护一个窗口,不断滑动,然后更新答案。
这个算法技巧的时间复杂度是 O(N),比字符串暴力算法要高效得多
滑动窗口很多时候都是在处理字符串相关的问题
该算法的大致逻辑如下:
int left = 0, right = 0;
while (right < s.size()) {
// 增大窗口
window.add(s[right]);
right++;
while (window needs shrink) {
// 缩小窗口
window.remove(s[left]);
left++;
}
}
其实滑动窗口算法难的不是思路,而是各种细节问题,比如说如何向窗口中添加新元素,如何缩小窗口,在窗口滑动的哪个阶段更新结果。即便你明白了这些细节,也容易出 bug,找 bug 还不知道怎么找。
滑动窗口代码框架:
下面提供一个滑动窗口的代码框架,该框架还包括在哪里做输出debug,以后遇到相关的问题,就默写出来如下框架然后改三个地方就行,还不会出 bug
算法复杂度分析:
虽然滑动窗口代码框架中有一个嵌套的 while 循环,但算法的时间复杂度依然是 O(N)
,其中 N
是输入字符串/数组的长度。
为什么呢?
因为字符串/数组中的每个元素都只会进入窗口一次,然后被移出窗口一次,不会说有某些元素多次进入和离开窗口,所以算法的时间复杂度就和字符串/数组的长度成正比
// 注意:java 代码由 chatGPT🤖 根据我的 cpp 代码翻译,旨在帮助不同背景的读者理解算法逻辑。
// 本代码还未经过力扣测试,仅供参考,如有疑惑,可以参照我写的 cpp 代码对比查看。
/* 滑动窗口算法框架 */
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++;
// 进行窗口内数据的一系列更新
...
}
}
}
注意:其中两处 ...
表示的更新窗口数据的地方,到时候你直接往里面填就行了。
而且,这两个 ...
处的操作分别是扩大和缩小窗口的更新操作,等会你会发现它们操作是完全对称的。
写代码时要注意Java 中的 Integer 和 String 这种包装类不能直接用 ==
进行相等判断,而应该使用类的 equals
方法
套模板需要思考以下几个问题:
1、什么时候应该移动 right
扩大窗口?窗口加入字符时,应该更新哪些数据?
2、什么时候窗口应该暂停扩大,开始移动 left
缩小窗口?从窗口移出字符时,应该更新哪些数据?
3、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?
如果一个字符进入窗口,应该增加 window
计数器;如果一个字符将移出窗口的时候,应该减少 window
计数器;当 valid
满足 need
时应该收缩窗口;应该在收缩窗口的时候更新最终结果。
简单来说就是:
- 什么时候应该扩大窗口?
- 什么时候应该缩小窗口?
- 什么时候应该更新答案?
一、76. 最小覆盖子串
这里说的左边不断缩小直到不符合条件的时候停止,那我们就会想到,那怎么找到最短放入符合条件的子串呢?那是因为在移动左边之前先记录了最小的长度,而且确实能达到最优的情况,因为不断移,移到最后的结果就是刚刚好都只有一个。可以细心去体会一下。
真的要自己动手去写一下才知道其中的细节
public String minWindow(String s, String t) {
// 用于记录需要的字符和窗口中的字符及其出现的次数
//注意:这个window只要不是need里面的值都需要记录
Map<Character,Integer> need=new HashMap<>();
Map<Character,Integer> window=new HashMap<>();
int vaild=0;//// 窗口中满足需要的字符个数,
//注意:只有窗口中某值的个数和need中对应的值的个数相等才能++,因为这样才表示完整的包含need中的某个字符,只要vaild==need.size这就说明了已经全部包含
int left=0,right=0;
// 记录最小覆盖子串的起始索引及长度
int start = 0, minLen = 65535;
//1.遍历添加需要的字符以及对应的个数
for(char c:t.toCharArray()){
//need.getOrDefault(c, 0) + 1表示若need中没有则设为0,否则+1
need.put(c,need.getOrDefault(c, 0) + 1);
}
//2.开始移动窗口
while(right<s.length()){
//3.将新的值加入窗口,右指针向右移,扩大窗口
char num1=s.charAt(right);
right++;
//4.更新窗口的值(如果是need中的值才需要更新)
if(need.containsKey(num1)){
window.put(num1,window.getOrDefault(num1, 0) + 1);
//5.某个值全部被包含才vaild++
if(need.get(num1).equals(window.get(num1))){
vaild++;
}
}
//6.开始缩小窗口
//只要vaild还等于need.size说明所有的值已被包含,只是有可能包含多了(即当 valid == need.size() 时,说明 T 中所有字符已经被覆盖,已经得到一个可行的覆盖子串,现在应该开始收缩窗口了,以便得到「最小覆盖子串」。)
while(vaild==need.size()){
//7.记录当前最小字串长度
if(right-left<minLen){
minLen=right-left;
start=left;
}
//8.缩小窗口
//移动 left 收缩窗口时,窗口内的字符都是可行解,所以应该在收缩窗口的阶段进行最小覆盖子串的更新,以便从可行解中找到长度最短的最终结果。
char num2=s.charAt(left);
left++;
if(need.containsKey(num2)){
if(need.get(num2).equals(window.get(num2))){
//9.依然是只有相等的时候才能操作,为什么呢,因为相等说明刚刚好包含,现在窗口缩小把它移走了,说明已经不完全包含need中的所有了,因为vaild--,避免它重新进入循环,更新不正确的最小子串长度。如果不相等那只能说明窗口中的某值的个数大于需要的值了,那此时不会对vaild其影响有能达到最优的条件
vaild--;
}
window.put(num2,window.getOrDefault(num2, 0) - 1);
}
}
}
//10.如果len等于起始值说明没有包含need的子串
return minLen==65535 ? "" :s.substring(start,start+minLen);
}
补充知识:
-
getOrDefault(key,default)作用:如果存在相应的key则返回其对应的value,否则返回给定的默认值。
-
key的值相同,使value的值加一。比如需要统计一个字符串中所含的字母及对应字母的个数。hash.put(c,hash.getOrDefault(c,0)+1); //若没有就是0,若有就是原有值增一。
二、567. 字符串的排列
对于这道题的解法代码,基本上和最小覆盖子串一模一样,只需要改变几个地方:
1、本题移动 left
缩小窗口的时机是窗口大小大于 s1.size()
时,因为排列嘛,显然长度应该是一样的。
2、当发现 valid == need.size()
时,就说明窗口中就是一个合法的排列,所以立即返回 true
。
至于如何处理窗口的扩大和缩小,和最小覆盖子串完全相同。
public boolean checkInclusion(String s1, String s2) {
Map<Character,Integer> need=new HashMap<>();
Map<Character,Integer> window=new HashMap<>();
int vaild=0;
int left=0,right=0;
for(char c: s1.toCharArray()){
need.put(c,need.getOrDefault(c,0)+1);
}
while(right<s2.length()){
char num1=s2.charAt(right);
right++;
if(need.containsKey(num1)){
window.put(num1,window.getOrDefault(num1,0)+1);
if(need.get(num1).equals(window.get(num1))){
vaild++;
}
}
//大于等于时候要收缩,等于的时候判断是否为true
while(right-left>=s1.length()){
if(vaild==need.size())
return true;
char num2=s2.charAt(left);
left++;
if(need.containsKey(num2)){
if(need.get(num2).equals(window.get(num2))){
vaild--;
}
window.put(num2,window.getOrDefault(num2, 0) - 1);
}
}
}
return false;
}
三、438. 找到字符串中所有字母异位词
这个跟上一题很像
只是这个不是返回true,而是记录起始索引
public List<Integer> findAnagrams(String s, String p) {
Map<Character,Integer> need=new HashMap<>();
Map<Character,Integer> window=new HashMap<>();
int right=0,left=0;
int vaild=0;
List<Integer> starts=new ArrayList<>();
for(char c:p.toCharArray()){
need.put(c,need.getOrDefault(c, 0) + 1);
}
while(right<s.length()){
char num1=s.charAt(right);
right++;
if(need.containsKey(num1)){
window.put(num1,window.getOrDefault(num1, 0) + 1);
if(need.get(num1).equals(window.get(num1))){
vaild++;
}
}
while(right-left>=p.length()){
if(vaild==need.size()){
starts.add(left);
}
char num2=s.charAt(left);
left++;
if(need.containsKey(num2)){
if(need.get(num2).equals(window.get(num2))){
vaild--;
}
window.put(num2,window.getOrDefault(num2, 0) - 1);
}
}
}
return starts;
}
四、3. 无重复字符的最长子串
这就是变简单了,连 need
和 valid
都不需要,而且更新窗口内数据也只需要简单的更新计数器 window
即可。
当 window[c]
值大于 1 时,说明窗口中存在重复字符,不符合条件,就该移动 left
缩小窗口了嘛。
唯一需要注意的是,在哪里更新结果 maxLen
呢?我们要的是最长无重复子串,哪一个阶段可以保证窗口中的字符串是没有重复的呢?
这里和之前不一样,要在收缩窗口完成后更新 maxLen
,因为窗口收缩的 while 条件是存在重复元素,换句话说收缩完成后一定保证窗口中没有重复嘛。
public int lengthOfLongestSubstring(String s) {
Map<Character,Integer> window=new HashMap<>();
int right=0,left=0;
int maxLen=0;
while(right<s.length()){
//一直扩大不用考虑什么
char num1=s.charAt(right);
right++;
window.put(num1,window.getOrDefault(num1, 0) + 1);
//只要有重复数字就要收缩窗口
while(window.get(num1)>1){
//不能在这里判断,,这样的话有重复数字的长度也会进入
/*
if(right-left>maxLen){
maxLen=right-left;
}
*/
char num2=s.charAt(left);
left++;
window.put(num2,window.get(num2) - 1);
}
//在这里判断才行
if(right-left>maxLen){
maxLen=right-left;
}
}
return maxLen;
}
前缀和技巧
前缀和技巧适用于快速、频繁地计算一个索引区间内的元素之和。(前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。)
一、303. 区域和检索 - 数组不可变
题目要求实现这样的一个类
class NumArray {
public NumArray(int[] nums) {}
/* 查询闭区间 [left, right] 的累加和 */
public int sumRange(int left, int right) {}
}
对于sumRange函数需要计算并返回一个索引区间之内的元素和,我们第一时间的想法就是用for循环遍历左右区间的元素并加起来就可以了
但是这样的效率很差,因为sumRange
方法会被频繁调用,而它的时间复杂度是 O(N)
,其中 N
代表 nums
数组的长度。
这道题的最优解法是使用前缀和技巧,将 sumRange
函数的时间复杂度降为 O(1)
即不要在 sumRange
里面用 for 循环
核心思路就是我们new一个新的数组 preSum
出来,preSum[i]
记录 nums[0..i-1]
的累加和
根据这个preSum数组,如果我想求索引区间 [1, 4]
内的所有元素之和,就可以通过 preSum[5] - preSum[1]
得出
这样,sumRange
函数仅仅需要做一次减法运算,避免了每次进行 for 循环调用,最坏时间复杂度为常数 O(1)
int[] preSum;
public NumArray(int[] nums) {
preSum=new int[nums.length+1];
preSum[0]=0;
for(int i=1;i<preSum.length;i++){
preSum[i]=preSum[i-1]+nums[i-1];
}
}
public int sumRange(int left, int right) {
return preSum[right+1]-preSum[left];
}
二、304. 二维区域和检索 - 矩阵不可变
这道题的思路和一维数组中的前缀和是非常类似的,我们可以维护一个二维 preSum
数组,专门记录以原点为顶点的矩阵的元素之和,就可以用几次加减运算算出任何一个子矩阵的元素和
前缀和数组是二维数组,那怎么找前缀和的计算规律呢?
我们就以最简单的来找规律,例如preSum[2] [2]
首先要清楚的是preSum[row] [col]是指preSum[2] [2]中第row-1行第col-1列到顶点的矩形元素之和
所以preSum[2] [2]就是matrix中第1行第1列到第0行第0列的矩形元素之和
在这个矩形中可以找到有三个到顶点的矩形,也就是说我们已知三个矩形的前缀和(preSum[1] [2],preSum[2] [1],preSum[1] [1])以及和,显然这个可以帮助我们计算所求矩形的前缀和
如图得出preSum[2] [2]=preSum[1] [2]+preSum[2] [1]+matrix[1] [1]-preSum[1] [1]
**即前缀和的计算规则为:**preSum[i] [j]=preSum[i-1] [j]+preSum[i] [j-1]+matrix[i-1] [j-1]-preSum[i-1] [j-1];
那如何的出(row1,col1,row2,col2)的矩形元素之和呢?
思考方式和找前缀和的计算规则一样,现直接给出结论:
**矩形元素之和:**preSum[row2+1] [col2+1]-preSum[row2+1] [col1]-preSum[row1] [col2+1]+preSum[row1] [col1]
int[][] preSum;
public NumMatrix(int[][] matrix) {
preSum=new int[matrix.length+1][matrix[0].length+1];
for(int i=1;i<preSum.length;i++){
for(int j=1;j<preSum[0].length;j++){
preSum[i][j]=preSum[i-1][j]+preSum[i][j-1]+matrix[i-1][j-1]-preSum[i-1][j-1];
}
}
}
public int sumRegion(int row1, int col1, int row2, int col2) {
return preSum[row2+1][col2+1]-preSum[row2+1][col1]-preSum[row1][col2+1]+preSum[row1][col1];
}
这样,sumRegion
函数的时间复杂度也用前缀和技巧优化到了 O(1),这是典型的「空间换时间」思路。
差分数组
差分数组技巧和前缀和的思想非常类似,差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。
该使用场景具体来说就是:适用于输入一个数组 nums
,然后又要求给区间 nums[2..6]
全部加 1,再给 nums[3..9]
全部减 3,再给 nums[0..4]
全部加 2,再给…一通操作猛如虎,然后问你,最后 nums
数组的值是什么?
对于这个问题,我们能想到的就是用for循环来完成操作,但是这种思路的时间复杂度是 O(N),由于这个场景下对 nums
的修改非常频繁,所以效率会很低下。
因此需要用到差分数组技巧,类似前缀和技巧构造的 prefix
数组,我们先对 nums
数组构造一个 diff
差分数组,diff[i]
就是 nums[i]
和 nums[i-1]
之差
int[] diff = new int[nums.length];
// 构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
那这个差分数组到底要怎么用呢?
具体的用处就是,假如我想给nums[1…3]之间的所有元素加3,那只需要给diff[1]加3,diff[4]减3就能实现
现在画图分析:先对diff操作了然后反推nums数组
如图,我们得出结论:如果想对区间 nums[i..j]
的元素全部加 3,那么只需要让 diff[i] += 3
,然后再让 diff[j+1] -= 3
即可,
**注意:**当 j+1 >= diff.length
时,说明是对 nums[i]
及以后的整个数组都进行修改,那么就不需要再给 diff
数组减 val
了。
使用差分数组的优势:只要花费 O(1) 的时间修改 diff
数组,就相当于给 nums
的整个区间做了修改。多次修改 diff
,然后通过 diff
数组反推,即可得到 nums
修改后的结果。
现在我们把差分数组抽象成一个类,包含 increment
方法和 result
方法:
// 差分数组工具类
class Difference {
// 差分数组
private int[] diff;
/* 输入一个初始数组,区间操作将在这个数组上进行 */
public Difference(int[] nums) {
assert nums.length > 0;
diff = new int[nums.length];
// 根据初始数组构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
diff[i] = nums[i] - nums[i - 1];
}
}
/* 给闭区间 [i, j] 增加 val(可以是负数)*/
public void increment(int i, int j, int val) {
diff[i] += val;
if (j + 1 < diff.length) {
diff[j + 1] -= val;
}
}
/* 返回结果数组 */
public int[] result() {
int[] res = new int[diff.length];
// 根据差分数组构造结果数组
res[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
res[i] = res[i - 1] + diff[i];
}
return res;
}
}
一、1109. 航班预订统计
题目分析:给你输入一个长度为 n
的数组 nums
,其中所有元素都是 0。再给你输入一个 bookings
,里面是若干三元组 (i, j, k)
,每个三元组的含义就是要求你给 nums
数组的闭区间 [i-1,j-1]
中所有元素都加上 k
。请你返回最后的 nums
数组是多少?
具体实现:因为一开始每个航班的订位数都为0,所以差分数组也都为0,然后遍历二维数组,每一个一维数组都是对差分数组的一次操作,所有一维数组都操作完成以后还原每个航班的订位数,也就是根据差分数组还原出一个一维数组
int[] diff;
public void increment(int[] nums,int n){
int i=nums[0];
int j=nums[1];
int temp=nums[2];
diff[i-1]+=temp;
//这里不是j+1,是因为传过来的区间不是从0开始的
if(j<n){
diff[j]-=temp;
}
}
public int[] corpFlightBookings(int[][] bookings, int n) {
diff=new int[n];
int[] eachSeat=new int[n];
for(int i=0;i<bookings.length;i++){
increment(bookings[i],n);
}
eachSeat[0]=diff[0];
for(int j=1;j<diff.length;j++){
eachSeat[j]=diff[j]+eachSeat[j-1];
}
return eachSeat;
}
如果要用上面封装好的出差分数组工具类可以这样写:
int[] corpFlightBookings(int[][] bookings, int n) {
// nums 初始化为全 0
int[] nums = new int[n];
// 构造差分解法
Difference df = new Difference(nums);
for (int[] booking : bookings) {
// 注意转成数组索引要减一哦
int i = booking[0] - 1;
int j = booking[1] - 1;
int val = booking[2];
// 对区间 nums[i..j] 增加 val
df.increment(i, j, val);
}
// 返回最终的结果数组
return df.result();
}
二、1094. 拼车
int[] diff;
public void increment(int[] nums){
int i=nums[1];
int j=nums[2];
int temp=nums[0];
diff[i]+=temp;
if(j<1001){
diff[j]-=temp;
}
}
public boolean carPooling(int[][] trips, int capacity) {
//当且仅当你可以在所有给定的行程中接送所有乘客时,返回 true,否则请返回 false。
//这句话的意思是在每一个位置(站)乘客数量不能超过capacity
//利用差分数组 最后的出来一个每站的乘客人数,然后判断这个数组的每一个元素的值是否符合规范
//但是这道题的关键在于没有车站的总站数,但是有给我们范围0 <= fromi < toi <= 1000,也就是说最多有1001个车站
//而且需要注意这里的车站是从0开始的,然后第j站就下车,这时候就不是j+1才减了
diff=new int[1001];
for(int i=0;i<trips.length;i++){
increment(trips[i]);
}
int[] eachPlatform=new int[1001];
eachPlatform[0]=diff[0];
for(int j=1;j<diff.length;j++){
eachPlatform[j]=diff[j]+eachPlatform[j-1];
if(eachPlatform[j-1]>capacity||eachPlatform[j]>capacity){
return false;
}
}
return true;
}
用上面封装好的差分数组工具类来做就是:
boolean carPooling(int[][] trips, int capacity) {
// 最多有 1001 个车站
int[] nums = new int[1001];
// 构造差分解法
Difference df = new Difference(nums);
for (int[] trip : trips) {
// 乘客数量
int val = trip[0];
// 第 trip[1] 站乘客上车
int i = trip[1];
// 第 trip[2] 站乘客已经下车,
// 即乘客在车上的区间是 [trip[1], trip[2] - 1]
int j = trip[2] - 1;
// 进行区间操作
df.increment(i, j, val);
}
int[] res = df.result();
// 客车自始至终都不应该超载
for (int i = 0; i < res.length; i++) {
if (capacity < res[i]) {
return false;
}
}
return true;
}
总结:
但是在做了这两道用差分数组做的题之后,我觉得用封装好的差分数组类来解题比较好,这样在遇到不同的提的时候对边界值的操作会更清晰,就不会变成上一题是判断j-1,这一题是判断j。这样对与差分数组技巧的记忆无利,我们对边界是怎样的就单独拿出来处理和判断就好了。还有画图是最好判断边界的了!
二维数组的遍历技巧
一、48. 旋转图像
这道题的可以原地翻转(找坐标之间的规律)也可以用另一个二维数组来存,但是我们可以思考一下能不能尝试把矩阵进行反转、镜像对称等操作。
那怎么会有这种想法呢?
因为旋转二维矩阵的难点在于将行
变成列
,将列
变成行
,而只有按照对角线的对称操作是可以轻松完成这一点的,对称操作之后就很容易发现规律了。
因此在经过尝试后发现,最简单的方法就是先根据对角线反转再前后翻转
- 先将
n x n
矩阵matrix
按照左上到右下的对角线进行镜像对称
- 再对矩阵的每一行进行反转
- 结果就是
matrix
顺时针旋转 90 度的结果
public void swap(int[][] nums,int n1,int m1,int n2,int m2){
int temp=nums[n1][m1];
nums[n1][m1]=nums[n2][m2];
nums[n2][m2]=temp;
//return nums;
}
public void rotate(int[][] matrix) {
int length=matrix.length;
int i,j;
//根据主对角线翻转
for(i=0;i<length;i++){
//这里的j要等于i,如果不congi开始,之前交换过的又会被换回去
for(j=i;j<length;j++){
swap(matrix,i,j,j,i);
}
}
//前后交换
for(i=0;i<length;i++){
//这里的j要小于长度的一半,不然交换过的又会被换回去
for(j=0;j<length/2;j++){
swap(matrix,i,j,i,length-j-1);
}
}
}
这是顺时针旋转90度的做法,那逆时针旋转90度也一样吗?
思路是类似的,只要通过另一条对角线镜像对称矩阵,然后前后反转,就得到了逆时针旋转矩阵的结果
二、54. 螺旋矩阵
解题的核心思路是按照右、下、左、上的顺序遍历数组,并使用四个变量圈定未遍历元素的边界
随着螺旋遍历,相应的边界会收缩,直到螺旋遍历完整个数组
public List<Integer> spiralOrder(int[][] matrix) {
int m=matrix.length;
int n=matrix[0].length;
int upperBound=0;
int lowerBound=m-1;
int leftBound=0;
int rightBound=n-1;
List<Integer> result=new ArrayList<>();
int i=0;
//如果list大小大于m*n说明已经遍历完了
//注意一点在移动之前要判断左右边界、上下边界是否重叠,避免多读
while(result.size()<m*n){
//在上边界,从左向右遍历
if(upperBound<=lowerBound){
for(i=leftBound;i<=rightBound;i++){
result.add(matrix[upperBound][i]);
}
}
//上边界下移
upperBound++;
//在右边界,从上到下遍历
if(leftBound<=rightBound){
for(i=upperBound;i<=lowerBound;i++){
result.add(matrix[i][rightBound]);
}
}
//右边界左移,以此类推
rightBound--;
if(upperBound<=lowerBound){
for(i=rightBound;i>=leftBound;i--){
result.add(matrix[lowerBound][i]);
}
}
lowerBound--;
if(leftBound<=rightBound){
for(i=lowerBound;i>=upperBound;i--){
result.add(matrix[i][leftBound]);
}
}
leftBound++;
}
return result;
}
三、59. 螺旋矩阵 II
public int[][] generateMatrix(int n) {
int[][] matrix=new int[n][n];
int upperBound=0;
int lowerBound=n-1;
int leftBound=0;
int rightBound=n-1;
int num=1;
int count=0;
//List<Integer> result=new ArrayList<>();
int i=0;
//如果list大小大于m*n说明已经遍历完了
//注意一点在移动之前要判断左右边界、上下边界是否重叠,避免多读
while(count<n*n){
//在上边界,从左向右遍历
if(upperBound<=lowerBound){
for(i=leftBound;i<=rightBound;i++){
matrix[upperBound][i]=num;
num++;
count++;
}
}
//上边界下移
upperBound++;
//在右边界,从上到下遍历
if(leftBound<=rightBound){
for(i=upperBound;i<=lowerBound;i++){
matrix[i][rightBound]=num;
num++;
count++;
}
}
//右边界左移,以此类推
rightBound--;
if(upperBound<=lowerBound){
for(i=rightBound;i>=leftBound;i--){
matrix[lowerBound][i]=num;
num++;
count++;
}
}
lowerBound--;
if(leftBound<=rightBound){
for(i=lowerBound;i>=upperBound;i--){
matrix[i][leftBound]=num;
num++;
count++;
}
}
leftBound++;
}
return matrix;
}
二分查找
二分查找的使用场景:寻找一个数、寻找左侧边界、寻找右侧边界
二分查找难在细节:比如不等号是否应该带等号,mid
是否应该加一等等
因此在本次的学习中应把握分析这些细节的差异以及出现这些差异的原因
一、二分查找框架
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
注意:计算 mid
时需要防止溢出,代码中 left + (right - left) / 2
就和 (left + right) / 2
的结果相同,但是有效防止了 left
和 right
太大,直接相加导致溢出的情况。
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
二、704. 二分查找(基本的二分搜索——寻找一个数)
public int search(int[] nums, int target) {
int length=nums.length;
int left=0;
int right=length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
return mid;
}else if(nums[mid]>target){
right=mid-1;
}else if(nums[mid]<target){
left=mid+1;
}
}
return -1;
}
探讨细节:
1、为什么 while 循环的条件中是 <=,而不是 <?
答:因为初始化 right
的赋值是 nums.length - 1
,即最后一个元素的索引,而不是 nums.length
。
注意:这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right]
,后者相当于左闭右开区间 [left, right)
,因为索引大小为 nums.length
是越界的。
我们这个算法中使用的是前者 [left, right]
两端都闭的区间。这个区间其实就是每次进行搜索的区间。
具体来说就是:while 循环什么时候应该终止?
要不就是找到了目标值,要不就是搜索区间为空的时候应该终止
while(left <= right)
的终止条件是left == right + 1
,写成区间的形式就是[right + 1, right]
,或者带个具体的数字进去[3, 2]
,可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。while(left < right)
的终止条件是left == right
,写成区间的形式就是[right, right]
,或者带个具体的数字进去[2, 2]
,这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间[2, 2]
被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。
2.为什么 left = mid + 1
,right = mid - 1
?我看有的代码是 right = mid
或者 left = mid
,没有这些加加减减,到底怎么回事,怎么判断?
这也是二分查找的一个难点,不过只要你能理解前面的内容,就能够很容易判断。
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即 [left, right]
。那么当我们发现索引 mid
不是要找的 target
时,下一步应该去搜索哪里呢?
当然是去搜索区间 [left, mid-1]
或者区间 [mid+1, right]
对不对?因为 mid
已经搜索过,应该从搜索区间中去除。
3、此算法有什么缺陷?
至此,你应该已经掌握了该算法的所有细节,以及这样处理的原因。但是,这个算法存在局限性。
比如说给你有序数组 nums = [1,2,2,2,3]
,target
为 2,此算法返回的索引是 2,没错。但是如果我想得到 target
的左侧边界,即索引 1,或者我想得到 target
的右侧边界,即索引 3,这样的话此算法是无法处理的。
这样的需求很常见,你也许会说,找到一个 target
,然后向左或向右线性搜索不行吗?可以,但是不好,因为这样难以保证二分查找对数级的复杂度了。
我们后续的算法就来讨论这两种二分查找的算法。
三、寻找左侧边界的二分搜索
找左边界其实就是让左边界为target
以下是最常见的代码形式,其中的标记是需要注意的细节:
int left_bound(int[] nums, int target) {
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
探讨细节:
1、为什么 while 中是 <
而不是 <=
?
答:用相同的方法分析,因为 right = nums.length
而不是 nums.length - 1
。因此每次循环的「搜索区间」是 [left, right)
左闭右开。
具体来说就是:while(left < right)
终止的条件是 left == right
,此时搜索区间 [left, left)
为空,所以可以正确终止。
注意:
刚才的
right
不是nums.length - 1
吗,为啥这里非要写成nums.length
使得「搜索区间」变成左闭右开呢?因为对于搜索左右侧边界的二分查找,这种写法比较普遍,我就拿这种写法举例了,保证你以后遇到这类代码可以理解。
如果非要用两端都闭的写法反而更简单,在后面写有相关的代码,把三种二分搜索都用一种两端都闭的写法统一起来。
2、为什么没有返回 -1 的操作?如果 nums
中不存在 target
这个值,怎么办?
答:其实很简单,在返回的时候额外判断一下 nums[left]
是否等于 target
就行了,如果不等于,就说明 target
不存在。
注意:left的取值范围
我们得考察一下 left
的取值范围,免得索引越界。假如输入的 target
非常大,那么就会一直触发 nums[mid] < target
的 if 条件,left
会一直向右侧移动,直到等于 right
,while 循环结束。
由于这里 right
初始化为 nums.length
,所以 left
变量的取值区间是闭区间 [0, nums.length]
,那么我们在检查 nums[left]
之前需要额外判断一下,防止索引越界:
while (left < right) {
//...
}
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
3、为什么 left = mid + 1
,right = mid
?和之前的算法不一样?
答:这个很好解释,因为我们的「搜索区间」是 [left, right)
左闭右开,所以当 nums[mid]
被检测之后,下一步应该去 mid
的左侧或者右侧区间搜索,即 [left, mid)
或 [mid + 1, right)
。
4、为什么该算法能够搜索左侧边界?
答:关键在于对于 nums[mid] == target
这种情况的处理:
if (nums[mid] == target)
right = mid;
可见,找到 target 时不要立即返回,而是缩小「搜索区间」的上界 right
,在区间 [left, mid)
中继续搜索,即不断向左收缩,达到锁定左侧边界的目的。
6、能不能想办法把 right
变成 nums.length - 1
,也就是继续使用两边都闭的「搜索区间」?这样就可以和第一种二分搜索在某种程度上统一起来了。
答:当然可以,只要你明白了「搜索区间」这个概念,就能有效避免漏掉元素,随便你怎么改都行。下面我们严格根据逻辑来修改:
- 因为你非要让搜索区间两端都闭,所以
right
应该初始化为nums.length - 1
,while 的终止条件应该是left == right + 1
,也就是其中应该用<=
: - 因为搜索区间是两端都闭的,且现在是搜索左侧边界,所以
left
和right
的更新逻辑如下: - 和刚才相同,如果想在找不到
target
的时候返回 -1,那么检查一下nums[left]
和target
是否相等即可:
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}
四、寻找右侧边界的二分查找
区间为左闭右开的写法:
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
探讨细节:
1、为什么这个算法能够找到右侧边界?
答:类似地,关键点还是这里
if (nums[mid] == target) {
left = mid + 1;
当 nums[mid] == target
时,不要立即返回,而是增大「搜索区间」的左边界 left
,使得区间不断向右靠拢,达到锁定右侧边界的目的。
2、为什么最后返回 left - 1
而不像左侧边界的函数,返回 left
?而且我觉得这里既然是搜索右侧边界,应该返回 right
才对。
答:首先,while 循环的终止条件是 left == right
,所以 left
和 right
是一样的,你非要体现右侧的特点,返回 right - 1
好了。
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在锁定右边界时的这个条件判断:
// 增大 left,锁定右侧边界
if (nums[mid] == target) {
left = mid + 1;
// 这样想: mid = left - 1
因为我们对 left
的更新必须是 left = mid + 1
,就是说 while 循环结束时,nums[left]
一定不等于 target
了,而 nums[left-1]
可能是 target
。
至于为什么 left
的更新必须是 left = mid + 1
,当然是为了把 nums[mid]
排除出搜索区间,这里就不再赘述。
3、为什么没有返回 -1 的操作?如果 nums
中不存在 target
这个值,怎么办?
答:只要在最后判断一下 nums[left-1]
是不是 target
就行了。
类似之前的左侧边界搜索,left
的取值范围是 [0, nums.length]
,但由于我们最后返回的是 left - 1
,所以 left
取值为 0 的时候会造成索引越界,额外处理一下即可正确地返回 -1:
while (left < right) {
// ...
}
// 判断 target 是否存在于 nums 中
// 此时 left - 1 索引越界
if (left - 1 < 0) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left - 1] == target ? (left - 1) : -1;
区间左闭右闭写法:
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 最后改成返回 left - 1
if (left - 1 < 0) return -1;
return nums[left - 1] == target ? (left - 1) : -1;
}
五、逻辑统一
34. 在排序数组中查找元素的第一个和最后一个位置
public int searchLeft(int[] nums,int target){
int length=nums.length;
int left=0;
int right=length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
right=mid-1;
}else if(nums[mid]>target){
right=mid-1;
}else if(nums[mid]<target){
left=mid+1;
}
}
if(left==length) return-1;
return nums[left]==target?left:-1;
}
public int searchRight(int[] nums,int target){
int length=nums.length;
int left=0;
int right=length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
left=mid+1;
}else if(nums[mid]>target){
right=mid-1;
}else if(nums[mid]<target){
left=mid+1;
}
}
if(left-1<0) return-1;
return nums[left-1]==target?left-1:-1;
}
public int[] searchRange(int[] nums, int target) {
int[] result=new int[2];
result[0]=searchLeft(nums,target);
result[1]=searchRight(nums,target);
return result;
}
开始梳理:
第一个,最基本的二分查找算法:
因为我们初始化 right = nums.length - 1
所以决定了我们的「搜索区间」是 [left, right]
,所以决定了 while (left <= right)
,同时也决定了 left = mid+1
和 right = mid-1
因为我们只需找到一个 target 的索引即可,所以当 nums[mid] == target
时可以立即返回
第二个,寻找左侧边界的二分查找:
因为我们初始化 right = nums.length
,所以决定了我们的「搜索区间」是 [left, right)
,所以决定了 while (left < right)
,同时也决定了 left = mid + 1
和 right = mid
因为我们需找到 target 的最左侧索引,所以当 nums[mid] == target
时不要立即返回,而要收紧右侧边界以锁定左侧边界
第三个,寻找右侧边界的二分查找
因为我们初始化 right = nums.length
,所以决定了我们的「搜索区间」是 [left, right)
,所以决定了 while (left < right)
,同时也决定了 left = mid + 1
和 right = mid
因为我们需找到 target 的最右侧索引,所以当nums[mid] == target
时不要立即返回,而要收紧左侧边界以锁定右侧边界
又因为收紧左侧边界时必须 left = mid + 1
,所以最后无论返回 left 还是 right,必须减一
对于寻找左右边界的二分搜索,常见的手法是使用左闭右开的「搜索区间」,我们还根据逻辑将「搜索区间」全都统一成了两端都闭,便于记忆,只要修改两处即可变化出三种写法:
int binary_search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if(nums[mid] == target) {
// 直接返回
return mid;
}
}
// 直接返回
return -1;
}
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定左侧边界
right = mid - 1;
}
}
// 判断 target 是否存在于 nums 中
// 此时 target 比所有数都大,返回 -1
if (left == nums.length) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left] == target ? left : -1;
}
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 别返回,锁定右侧边界
left = mid + 1;
}
}
// 此时 left - 1 索引越界
if (left - 1 < 0) return -1;
// 判断一下 nums[left] 是不是 target
return nums[left - 1] == target ? (left - 1) : -1;
}
六、剑指 Offer 53 - I. 在排序数组中查找数字 I
public int searchLeft(int[] nums,int target){
int length=nums.length;
int left=0;
int right=length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
right=mid-1;
}else if(nums[mid]>target){
right=mid-1;
}else if(nums[mid]<target){
left=mid+1;
}
}
if(left==length) return-1;
return nums[left]==target?left:-1;
}
public int searchRight(int[] nums,int target){
int length=nums.length;
int left=0;
int right=length-1;
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]==target){
left=mid+1;
}else if(nums[mid]>target){
right=mid-1;
}else if(nums[mid]<target){
left=mid+1;
}
}
if(left-1<0) return-1;
return nums[left-1]==target?left-1:-1;
}
public int search(int[] nums, int target) {
int left=searchLeft(nums,target);
int right=searchRight(nums,target);
if(left==-1||right==-1) return 0;
return right-left+1;
}