数组专题
二分查找
所有二分查找都可以通过举例子来印证你的边界条件是否写对
二分查找思路很简单,细节是魔鬼
很多人喜欢拿整型溢出的 bug 说事儿,但是二分查找真正的坑根本就不是那个细节问题,而是在于到底要给mid
加一还是减一,while 里到底用 <=
还是 <
要是没有正确理解这些细节,写二分肯定就是玄学编程,有没有 bug 只能靠菩萨保佑
我们来深入探究几个最常用的二分查找场景:寻找一个数、寻找左侧边界、寻找右侧边界。而且,我们就是要深入细节,比如不等号是否应该带等号,mid 是否应该加一等等。分析这些细节的差异以及出现这些差异的原因,保证能灵活准确地写出正确的二分查找算法。
二分查找框架
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 ...;
}
寻找一个数(基本的二分搜索)
这个场景是最简单的,肯能也是大家最熟悉的,即搜索一个数,如果存在,返回其索引,否则返回 -1。
lc704.二分查找
class Solution {
public:
int search(vector<int>& nums, int target) {
int left=0,right=nums.size()-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;
else{
//异常处理段
}
}
return -1;
}
};
lc35.寻找插入的位置
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left=0,right=nums.size()-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 left;
}
};
lc278.第一个错误的版本
class Solution {
public:
int firstBadVersion(int n) {
//错误的版本之后的所有版本都是错的
int left=1,right=n;
while(left<right){
int mid=left+(right-left)/2;
if(!isBadVersion(mid)) left=mid+1;
else right=mid;
}
return left;
}
};
lc69.x的平方根
class Solution {
public:
int mySqrt(int x) {
//不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5
//特值判断
if(x==0) return 0;
if(x==1) return 1;
int left=1,right=x/2;
while(left<right){
//mid不加1会造成死循环,把取中间数改为向上取整
int mid=left+(right-left+1)/2;
//改用除法是为了防止乘法溢出
if(mid==x/mid) return mid;
else if(mid>x/mid) right=mid-1;
else left=mid;;
}
return left;
}
};
lc367.有效的完全平方数
class Solution {
public:
bool isPerfectSquare(int num) {
if(num==1) return true;
int left=1,right=num;
while(left<=right){
int mid=left+(right-left)/2;
long square=(long) mid*mid;
if(square==num) return true;
else if(square>num) right=mid-1;
else left=mid+1;
}
return false;
}
};
lc34.在排序数组中查找元素的第一个和最后一个位置(重要)
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
//排序数组:二分,找左边界和右边界
int leftBorder=searchLeftBorder(nums,target);
int rightBorder=searchRightBorder(nums,target);
if(leftBorder==-2||rightBorder==-2) return {-1,-1};
if(rightBorder-leftBorder>1) return {leftBorder+1,rightBorder-1};
return {-1,-1};
}
private:
int searchRightBorder(vector<int>&nums,int target){
int left=0,right=nums.size()-1;
int rightBorder=-2;//记录一下rightBorder没有被赋值的情况
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]>target){
right=mid-1;
}else{//寻找右边界,nums[mid]==target的时候更新left
left=mid+1;
rightBorder=left;
}
}
return rightBorder;
}
int searchLeftBorder(vector<int>&nums,int target){
int left=0,right=nums.size()-1;
int leftBorder=-2;//记录一下leftBorder没有被赋值的情况
while(left<=right){
int mid=left+(right-left)/2;
if(nums[mid]>=target){
right=mid-1;
leftBorder=right;
}else{
left=mid+1;
}
}
return leftBorder;
}
};
1、为什么 while 循环的条件中是 <=,而不是 <?
答:因为初始化 right
的赋值是 nums.length - 1
,即最后一个元素的索引,而不是 nums.length
。
这二者可能出现在不同功能的二分查找中,区别是:前者相当于两端都闭区间 [left, right]
,后者相当于左闭右开区间 [left, right)
,因为索引大小为 nums.length
是越界的。
我们这个算法中使用的是前者 [left, right]
两端都闭的区间。这个区间其实就是每次进行搜索的区间。
什么时候应该停止搜索呢?当然,找到了目标值的时候可以终止:nums[mid]==target
如果没有找到,就需要while循环终止,然后返回-1.那么while循环什么时候应该终止?
搜索区间为空的时候就应该终止
while(left<=right)
的终止条件是left==right+1
,写成区间的形式就是[right+1,right]
或者带个具体的数字进去 [3, 2],可见这时候区间为空,因为没有数字既大于等于 3 又小于等于 2 的吧。所以这时候 while 循环终止是正确的,直接返回 -1 即可。
while(left < right)
的终止条件是 left == right
,写成区间的形式就是 [left, right]
,或者带个具体的数字进去 [2, 2],这时候区间非空,还有一个数 2,但此时 while 循环终止了。也就是说这区间 [2, 2] 被漏掉了,索引 2 没有被搜索,如果这时候直接返回 -1 就是错误的。
移除元素
lc27.移除元素
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
//双指针覆盖
int j=0,n=nums.size();
for(int i=0;i<n;++i){
if(nums[i]!=val){
nums[j++]=nums[i];
}
}
return j;
}
};
双指针优化
如果要移除的元素恰好在数组的开头,例如序列[1,2,3,4,5]
,当 val
为1 时,我们需要把每一个元素都左移一位。题目说:元素的顺序可以改变
方法二避免了需要保留的元素的重复赋值操作。
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int left=0,right=nums.size();
while(left<right){
if(nums[left]==val){
nums[left]=nums[right-1];
right--;
}else{
left++;
}
}
return left;
}
};
lc26.删除有序数组中的重复项
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
if(nums.size()==0) return 0;
int fast=1, slow=1;
while(fast<nums.size()){
if(nums[fast]!=nums[fast-1]){
nums[slow]=nums[fast];
++slow;
}
++fast;
}
return slow;
}
};
class Solution {
public int removeDuplicates(int[] nums) {
int j=0;
for(int i=0;i<nums.length;++i){
if(nums[j]!=nums[i]) nums[++j]=nums[i];
}
return j+1;
}
}
lc283.移动零
class Solution {
public void moveZeroes(int[] nums) {
//原地对数组进行操作
int slow=0,fast=0;
while(fast<nums.length){
if(nums[fast]!=0){
nums[slow]=nums[fast];
slow++;
}
fast++;
}
while(slow<nums.length){
nums[slow++]=0;
}
}
}
lc844.比较含退格的字符串
非常好的一道题,推荐二刷!!!!
方法一:直接模拟
最容易想到的方法是将给定的字符串中的退格符和应当被删除的字符都去除,还原给定字符串的一般形式。然后直接比较两字符串是否相等即可。
具体地,我们用栈处理遍历过程,每次我们遍历到一个字符:
如果它是退格符,那么我们将栈顶弹出;
如果它是普通字符,那么我们将其压入栈中。
c++的语法略微熟悉一点
class Solution {
public:
bool backspaceCompare(string s, string t) {
return built(s)==built(t);
}
string built(string str){
//利用一个栈
string res;
for(char ch:str){
if(ch!='#'){
res.push_back(ch);
}else if(!res.empty()){
//栈可能为空
res.pop_back();
}
}
return res;
}
};
java语言版
class Solution {
public boolean backspaceCompare(String s, String t) {
return built(s).equals(built(t));
}
public String built(String str){
StringBuffer ret=new StringBuffer();
int length=str.length();
for(int i=0;i<length;++i){
char ch=str.charAt(i);
if(ch!='#'){
ret.append(ch);
}else if(ret.length()>0){
ret.deleteCharAt(ret.length()-1);
}
}
return ret.toString();
}
}
遗漏知识点:
1. String、StringBuffer和StringBuilder的区别和联系?
string:
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
private final char value[];
String是被final修饰的类,不能被继承;
String实现了Serializable和Comparable接口,表示String支持序列化和可以比较大小;
String底层是通过char类型的数据实现的,并且被final修饰,所以字符串的值创建之后就不可以被修改,具有不可变性。
总结1:String字符串具有不可变性,当字符串重新赋值时,不会在原来的内存地址进行修改,而是重新分配新的内存地址进行赋值
总结2:当字符串进行拼接时,也不会在原来的内存地址进行修改,而是重新分配新的内存地址进行赋值。
StringBuffer
StringBuffer对象则代表一个字符序列可变的字符串,当一个StringBuffer被创建以后,通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、setLength()
等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法将其转换为一个String对象。
StringBuffer b = new StringBuffer("123");
b.append("456");
// b打印结果为:123456
System.out.println(b);
b对象的内存空间图
所以说StringBuffer对象是一个字符序列可变的字符串,它没有重新生成一个对象,而且在原来的对象中可以连接新的字符串。
StringBuilder
StringBuilder类也代表可变字符串对象。实际上,StringBuilder和StringBuffer基本相似,两个类的构造器和方法也基本相同。不同的是:StringBuffer是线程安全的,而StringBuilder则没有实现线程安全功能,所以性能略高。
StringBuffer是如何实现线程安全的呢?
StringBuffer类中实现的方法:
StringBuilder类中实现的方法:
由此可见,StringBuffer类中的方法都添加了synchronized关键字,也就是给这个方法添加了一个锁,用来保证线程安全。
方法二.双指针
class Solution {
public boolean backspaceCompare(String s, String t) {
//双指针
/*一个字符是否会被删掉,只取决于该字符后面的退格符,而与该字符前面的退格符无关。
因此当我们逆序地遍历字符串,就可以立即确定当前字符是否会被删掉。
*/
int i=s.length()-1,j=t.length()-1;
int skipS=0,skipT=0;
while(i>=0||j>=0){
while(i>=0){
if(s.charAt(i)=='#'){
skipS++;
i--;
}else if(skipS>0){
skipS--;
i--;
}else{
break;
}
}
while(j>=0){
if(t.charAt(j)=='#'){
skipT++;
j--;
}else if(skipT>0){
skipT--;
j--;
}else{
break;
}
}
if(i>=0&&j>=0){
if(s.charAt(i)!=t.charAt(j)){
return false;
}
}else{
if(i>=0||j>=0){
return false;
}
}
i--;
j--;
}
return true;
}
}
有序数组的平方
暴力直接排序(直接这种寄!)
class Solution {
public int[] sortedSquares(int[] nums) {
for(int i=0;i<nums.length;++i){
nums[i]*=nums[i];
}
Arrays.sort(nums);
return nums;
}
}
双指针
好像并不是很好原地修改,用一个数组res接收
class Solution {
public int[] sortedSquares(int[] nums) {
//数组其实是有序的, 只不过负数平方之后可能成为最大数了。
//那么数组平方的最大值就在数组的两端,不是最左边就是最右边,不可能是中间。
int n=nums.length;
int left=0,right=n-1;
int [] res=new int[n];
int index=n-1;
while(left<=right){
if(nums[left]*nums[left]<=nums[right]*nums[right]){
res[index--]=nums[right]*nums[right];
right--;
}else{
res[index--]=nums[left]*nums[left];
left++;
}
}
return res;
}
}
长度最小的子数组(滑窗)
滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)的暴力解法降为O(n)。
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
滑动窗口解题思路:
1.对于滑动窗口窗口题,出发点是追求通解;无非是最小滑窗和最大滑窗之间的区别
2.最小滑窗模板,给定数组nums,定义滑窗的左右边界left,right,求满足某个条件的滑窗的最小长度
while(right<nums.length){
判断[i, j]是否满足条件
while(满足条件){
不断更新结果(注意在while内更新!)
i += 1 ;(最大程度的压缩i,使得滑窗尽可能的小)
}
j += 1;
}
3.最大滑窗模板:给定数组 nums,定义滑窗的左右边界 left, right,求满足某个条件的滑窗的最大长度。
while(right<nums.length){
判断[i, j]是否满足条件
while 不满足条件{
left+= 1 ;(最保守的压缩i,一旦满足条件了就退出压缩i的过程,使得滑窗尽可能的大)
}
不断更新结果(注意在while外更新!)
right += 1;
}
def findSubArray(nums):
N = len(nums) # 数组/字符串长度
left, right = 0, 0 # 双指针,表示当前遍历的区间[left, right],闭区间
sums = 0 # 用于统计 子数组/子区间 是否有效,根据题目可能会改成求和/计数
res = 0 # 保存最大的满足题目要求的 子数组/子串 长度
while right < N: # 当右边的指针没有搜索到 数组/字符串 的结尾
sums += nums[right] # 增加当前右边指针的数字/字符的求和/计数
while 区间[left, right]不符合题意: # 此时需要一直移动左指针,直至找到一个符合题意的区间
sums -= nums[left] # 移动左指针前需要从counter中减少left位置字符的求和/计数
left += 1 # 真正的移动左指针,注意不能跟上面一行代码写反
# 到 while 结束时,我们找到了一个符合题意要求的 子数组/子串
res = max(res, right - left + 1) # 需要更新结果
right += 1 # 移动右指针,去探索新的区间
return res
lc3.无重复字符的最长子串
class Solution {
public int lengthOfLongestSubstring(String s) {
//利用一个队列
HashMap<Character,Integer>map=new HashMap<Character,Integer>();
int res=0,left=0;
for(int right=0;right<s.length();++right){
/**
1、首先,判断当前字符是否包含在map中,如果不包含,将该字符添加到map(字符,字符在数组下标),
此时没有出现重复的字符,左指针不需要变化。此时不重复子串的长度为:i-left+1,与原来的maxLen比较,取最大值;
2、如果当前字符 ch 包含在 map中,此时有2类情况:
1)当前字符包含在当前有效的子段中,如:abca,当我们遍历到第二个a,当前有效最长子段是 abc,我们又遍历到a,
那么此时更新 left 为 map.get(a)+1=1,当前有效子段更新为 bca;
2)当前字符不包含在当前最长有效子段中,如:abba,我们先添加a,b进map,此时left=0,我们再添加b,发现map中包含b,
而且b包含在最长有效子段中,就是1)的情况,我们更新 left=map.get(b)+1=2,此时子段更新为 b,而且map中仍然包含a,map.get(a)=0;
随后,我们遍历到a,发现a包含在map中,且map.get(a)=0,如果我们像1)一样处理,就会发现 left=map.get(a)+1=1,实际上,left此时
应该不变,left始终为2,子段变成 ba才对。
为了处理以上2类情况,我们每次更新left,left=Math.max(left , map.get(ch)+1).
另外,更新left后,不管原来的 s.charAt(i) 是否在最长子段中,我们都要将 s.charAt(i) 的位置更新为当前的i,
因此此时新的 s.charAt(i) 已经进入到 当前最长的子段中!
*/
if(map.containsKey(s.charAt(right))){
left=Math.max(left,map.get(s.charAt(right))+1);
}
//不管是否更新left,都要更新s.charAt(i)的位置
map.put(s.charAt(right),right);
res=Math.max(res,right-left+1);
}
return res;
}
}
lc76.
解题思路:
用i,j表示滑动窗口的左边界和右边界,通过改变i,j来扩展和收缩滑动窗口,可以想象成一个窗口在字符串上游走,当这个窗口包含的元素满足条件,即包含字符串T的所有元素,记录下这个滑动窗口的长度j-i+1,这些长度中的最小值就是要求的结果。
步骤一
不断增加j使滑动窗口增大,直到窗口包含T的所有元素
步骤二
不断增加i使滑动窗口减小,因为要求最小子串,所以将不必要的元素排除在外,使长度减小,直到碰到一个必须包含的元素,这时候不能再扔了,再扔就不满足条件了,记录此时滑动窗口的长度,并保存最小值
步骤三
让i再增加一个位置,这个时候滑动窗口肯定不满足条件了,然后继续从步骤一开始执行,寻找新的满足条件的滑动窗口,如此反复,直到j超出字符S的范围
class Solution {
public String minWindow(String s, String t) {
if(s==null||s.length()==0||t==null||t.length()==0){
return "";
}
int []need=new int[128];
//记录需要的字符的个数
for(int i=0;i<t.length();++i){
need[t.charAt(i)]++;
}
//l是当前的左边界,r是当前右边界,size记录窗口大小,count是需求的字符个数,start是最小覆盖串开始的index
int l=0,r=0,size=Integer.MAX_VALUE,count=t.length(),start=0;
//遍历所有字符
while(r<s.length()){
char c=s.charAt(r);
if(need[c]>0){//需要字符c
count--;
}
need[c]--;//把右边的字符加入窗口
if(count==0){//窗口中已经包含所有的字符
while(l<r&&need[s.charAt(l)]<0){
need[s.charAt(l)]++;
l++;
}
if(r-l+1<size){//当不能右移的时候挑战最小窗口大小,更新最小窗口开始的start
size=r-l+1;
start=l;//记录下最小值时候的开始位置,最后返回覆盖串时候会用到
}
//l向右移动后窗口肯定不能满足了,重新开始循环
need[s.charAt(l)]++;
l++;
count++;
}
r++;
}
return size==Integer.MAX_VALUE? "":s.substring(start,start+size);
}
}
lc904.水果成篮
记录只包含俩种元素的最长子序列,设置俩个基准元素
class Solution {
public int totalFruit(int[] fruits) {
//可以理解为只包含俩种元素的最长连续子序列
int left=0,right=0,res=0;
int ln=fruits[left],rn=fruits[right];//设置俩个基准元素
while(right<fruits.length){
if(fruits[right]==rn||fruits[right]==ln){
res=Math.max(res,right-left+1);
right++;
}else{
left=right-1;
ln=fruits[left];
while(left>=1&&fruits[left-1]==ln) left--;
rn=fruits[right];
res=Math.max(res,right-left+1);
}
}
return res;
}
}
lc1004.最大连续1的个数Ⅲ
class Solution {
public int longestOnes(int[] nums, int k) {
int left=0,right=0;
int res=0;
int zeros=0;
while(right<nums.length){
if(nums[right]==0){
zeros++;
}
right++;
while(zeros>k){
if(nums[left]==0){
//++left;//滑动窗口左边右移
zeros--;
}
left++;
}
res=Math.max(res,right-left);
}
return res;
}
}
注意一些细节上的处理
class Solution {
public int longestOnes(int[] nums, int k) {
int left=0,right=0;
int res=0;
int zeros=0;
while(right<nums.length){
if(nums[right]==0){
zeros++;
}
while(zeros>k){
if(nums[left++]==0){
//++left;//滑动窗口左边右移
zeros--;
}
}
res=Math.max(res,right-left+1);
right++;
}
return res;
}
}
lc209.长度最小的子数组
class Solution {
public int minSubArrayLen(int target, int[] nums) {
int res=Integer.MAX_VALUE;
int left=0;
int sum=0;
for(int right=0;right<nums.length;++right){
sum+=nums[right];
//注意这里使用while,
while(sum>=target){
res=Math.min(res,right-left+1);
sum-=nums[left++];
}
}
return res==Integer.MAX_VALUE?0:res;
}
}
螺旋矩阵
中等模拟题
编程题要根据题意灵活变通,而不是背代码,套模板!!!!
lc54.螺旋矩阵
class Solution {
public List<Integer> spiralOrder(int[][] matrix) {
int m=matrix.length,n=matrix[0].length;
//因为数组已经给出,与Ⅱ不同,直接使用List
//int []res=new int [m*n];
List<Integer> res=new ArrayList<Integer>();
int l=0,r=n-1,t=0,d=m-1;
while(true){
for(int i=l;i<=r;++i) res.add(matrix[t][i]);//从左到右
if(++t>d) break;
for(int i=t;i<=d;++i) res.add(matrix[i][r]);//从上到下
if(--r<l) break;
for(int i=r;i>=l;--i) res.add(matrix[d][i]);//从右到左
if(--d<t) break;
for(int i=d;i>=t;--i) res.add(matrix[i][l]);//从下到上
if(++l>r) break;
}
return res;
}
}
这份代码其实是有漏洞的,没判空,可能测试用例比较少,只通过了23个测试用例
lc59.螺旋矩阵Ⅱ
class Solution {
public int[][] generateMatrix(int n) {
int l=0,r=n-1,t=0,d=n-1;
int [][]res=new int[n][n];
int num=1,target=n*n;
while(num<=target){
for(int i=l;i<=r;++i) res[t][i]=num++;//从左到右
t++;
for(int i=t;i<=d;++i) res[i][r]=num++;//从上到下
r--;
for(int i=r;i>=l;--i) res[d][i]=num++;//从右到左
d--;
for(int i=d;i>=t;--i) res[i][l]=num++;//从下到上
l++;
}
return res;
}
}
这份代码其实是有漏洞的,首先没判空
剑指29.顺时针打印矩阵
和54题相同,就当二刷了
class Solution {
public int[] spiralOrder(int[][] matrix) {
//
if(matrix.length==0) return new int[0];
int m=matrix.length,n=matrix[0].length;
// ArrayList<Integer> res=new ArrayList<Integer>();
int []res=new int[m*n];
int l=0,r=n-1,t=0,d=m-1,x=0;
while(true){
for(int i=l;i<=r;++i) res[x++]=matrix[t][i];//从左到右
if(++t>d) break;
for(int i=t;i<=d;++i) res[x++]=matrix[i][r];//从上到下
if(--r<l) break;
for(int i=r;i>=l;--i) res[x++]=matrix[d][i];//从右到左
if(--d<t) break;
for(int i=d;i>=t;--i) res[x++]=matrix[i][l];//从下到上
if(++l>r) break;
}
return res;
}
}
这里碰到一个bug List<Integer> cannot be converted to int[]
——将一个int[]
数组转化成List<Integer>
类型
具体报错代码:
ArrayList<Integer> res=new ArrayList<Integer>();
ArrayList<Integer> list = new ArrayList<>(Arrays.asList(array));
为什么报格式不匹配呢?
因为Arrays.asList()
是泛型方法,传入的对象必须是对象数组.如果传入的是基本类型的数组,那么此时得到的list只有一个元素,那么就是这个数组的int[]
本身
解决方法
将基本类型数组转换成包装类数组,这里将int[]
换成Integer[]
即可。
泛型应用的常踩坑
1.首先,如果 A is a B , 我们可以把 A 赋值给 B
Object someObject = new Object();
Integer someInteger = new Integer(10);
someObject = someInteger; // OK
Integer 继承自 Object,是它的一个子类型, 所以这样赋值没有问题,这是泛型。
2.Integer 也是一种 Number, Double 也是一种 Number,所以下面这样也是可以的
public void someMethod(Number n) { /* ... */ }
someMethod(new Integer(10)); // OK
someMethod(new Double(10.1)); // OK
也可以使用泛型:
Box<Number> box = new Box<Number>();
box.add(new Integer(10)); // OK
box.add(new Double(10.1)); // OK
3,重点来了
public void boxTest(Box<Number> n) { /* ... */ }
如果这样,我们可以传入 Box 或者 Box 吗
答案是否定的。
Integer 是 Number 的子类,Double 也是 Number 的子类, 但是,Box 和 Box 都不是 Box 的子类,它们的关系是并列的,都是 Object 的子类。