来自代码随想录的刷题路线:代码随想录
数组:
数组是存放在连续内存空间上的相同类型数据的集合。
二分查找:
先确定是左闭右闭区间还是左闭右开,将全部情况考虑周全。
二分查找的前提条件
- 数组为有序。
- 数组中无重复元素,因为有重复元素时,返回的元素下标可能不是唯一的。
二分法第一种写法:左闭右闭
第一种写法,我们定义 target 是在一个在左闭右闭的区间里,也就是[left, right] (这个很重要非常重要)。
区间的定义这就决定了二分法的代码应该如何写,因为定义target在[left, right]区间,所以有如下两点:
- while (left <= right) 要使用 <= ,因为left == right是有意义的,所以使用 <=
- if (nums[middle] > target) right 要赋值为 middle - 1,因为当前这个nums[middle]一定不是target,那么接下来要查找的左区间结束下标位置就是 middle - 1
例如在数组:1,2,3,4,7,9,10中查找元素2,如图所示:
代码如下:(详细注释)
// 版本一
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <=
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
if (nums[middle] > target) {
right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,所以[middle + 1, right]
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
}
};
- 时间复杂度:O(log n)
- 空间复杂度:O(1)
二分法第二种写法:左闭右开
如果说定义 target 是在一个在左闭右开的区间里,也就是[left, right) ,那么二分法的边界处理方式则截然不同。
有如下两点:
- while (left < right),这里使用 < ,因为left == right在区间[left, right)是没有意义的
- if (nums[middle] > target) right 更新为 middle,因为当前nums[middle]不等于target,去左区间继续寻找,而寻找区间是左闭右开区间,所以right更新为middle,即:下一个查询区间不会去比较nums[middle]
在数组:1,2,3,4,7,9,10中查找元素2,如图所示:(注意和方法一的区别)
代码如下:(详细注释)
// 版本二
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size(); // 定义target在左闭右开的区间里,即:[left, right)
while (left < right) { // 因为left == right的时候,在[left, right)是无效的空间,所以使用 <
int middle = left + ((right - left) >> 1);
if (nums[middle] > target) {
right = middle; // target 在左区间,在[left, middle)中
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,在[middle + 1, right)中
} else { // nums[middle] == target
return middle; // 数组中找到目标值,直接返回下标
}
}
// 未找到目标值
return -1;
}
};
- 时间复杂度:O(log n)
- 空间复杂度:O(1)
总结
区间的定义就是不变量,那么在循环中坚持根据查找区间的定义来做边界处理,就是循环不变量规则。
35.搜索插入位置
要在数组中插入目标值,无非是这四种情况。
- 目标值在数组所有元素之前
- 目标值等于数组中某一个元素
- 目标值插入数组中的位置
- 目标值在数组所有元素之后
暴力解法:
暴力解题 不一定时间消耗就非常高,关键看实现的方式,就像是二分查找时间消耗不一定就很低,是一样的。
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
for (int i = 0; i < nums.size(); i++) {
// 分别处理如下三种情况
// 目标值在数组所有元素之前
// 目标值等于数组中某一个元素
// 目标值插入数组中的位置
if (nums[i] >= target) { // 一旦发现大于或者等于target的num[i],那么i就是我们要的结果
return i;
}
}
// 目标值在数组所有元素之后的情况
return nums.size(); // 如果target是最大的,或者 nums为空,则返回nums的长度
}
};
用二分查找,四种情况的目标值都应该等于left:
//假定为左闭右闭区间
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int n = nums.size();
int left = 0;
int right = n - 1; // 定义target在左闭右闭的区间里,[left, right]
while (left <= right) { // 当left==right,区间[left, right]依然有效
int middle = left + ((right - left) / 2);// 防止溢出 等同于(left + right)/2
if (nums[middle] > target) {
right = middle - 1; // target 在左区间,所以[left, middle - 1]
} else if (nums[middle] < target) {
left = middle + 1; // target 在右区间,所以[middle + 1, right]
} else { // nums[middle] == target
return middle;
}
}
// 分别处理如下四种情况
// 目标值在数组所有元素之前 return left
// 目标值等于数组中某一个元素 return middle;
// 目标值插入数组中的位置 [left, right],return left
// 目标值在数组所有元素之后的情况 [left, right], 因为是右闭区间,所以 return left
return left;
}
};
34.在排序数组中查找元素的第一个和最后一个位置
寻找target在数组里的左右边界,有如下三种情况:
- 情况一:target 在数组范围的右边或者左边,例如数组{3, 4, 5},target为2或者数组{3, 4, 5},target为6,此时应该返回{-1, -1}
- 情况二:target 在数组范围中,且数组中不存在target,例如数组{3,6,7},target为5,此时应该返回{-1, -1}
- 情况三:target 在数组范围中,且数组中存在target,例如数组{3,6,7},target为6,此时应该返回{1, 1}
这三种情况都考虑到,说明就想的很清楚了。
接下来,在去寻找左边界,和右边界了。
采用二分法来去寻找左右边界,为了让代码清晰,我分别写两个二分来寻找左边界和右边界。
刚刚接触二分搜索的同学不建议上来就想用一个二分来查找左右边界,很容易把自己绕进去,建议扎扎实实的写两个二分分别找左边界和右边界
class Solution {
public:
vector<int> searchRange(vector<int>& nums, int target) {
int leftBorder = getLeftBorder(nums, target);
int rightBorder = getRightBorder(nums, target);
// 情况一
if (leftBorder == -2 || rightBorder == -2) return {-1, -1};
// 情况三
if (rightBorder - leftBorder >= 0) return {leftBorder, rightBorder};
// 情况二
return {-1, -1};
}
private:
int getRightBorder(vector<int>& nums, int target){
int right = nums.size() - 1;
int left = 0;
int mid;
int rightBorder = -2;// 记录一下rightBorder没有被赋值的情况
while(left <= right){
mid = (left + right) / 2;
if(nums[mid] > target){
right = mid - 1;
}
else if(nums[mid] < target){
left = mid + 1;
}
else{// 寻找右边界,nums[middle] == target的时候更新
left = mid + 1;
rightBorder = mid;
}
}
return rightBorder;
}
int getLeftBorder(vector<int>& nums, int target){
int right = nums.size() - 1;
int left = 0;
int mid;
int leftBorder = -2;
while(left <= right){
mid = (left + right) / 2;
if(nums[mid] < target){
left = mid + 1;
}
else if(nums[mid] > target){
right = mid - 1;
}
else{
right = mid - 1;
leftBorder = mid;
}
}
return leftBorder;
}
};
这份代码在简洁性很有大的优化空间,例如把寻找左右区间函数合并一起。
但拆开更清晰一些,而且把三种情况以及对应的处理逻辑完整的展现出来了。
总结
初学者建议大家一块一块的去分拆这道题目,正如本题解描述,想清楚三种情况之后,先专注于寻找右区间,然后专注于寻找左区间,左右根据左右区间做最后判断。
69.x的平方根
//其实就是寻找满足mid * mid <= x的右边界!
class Solution {
public:
int mySqrt(int x) {
int l = 0;
int r = x;
int ans = -1;
while(l <= r){
int mid = (l + r) / 2;
if((long long)mid * mid <= x){ //转成long long是防止数据溢出
ans = mid;
l = mid + 1;
}
else{
r = mid - 1;
}
}
//只有 能找到 这一种情况
return ans;
}
};
367. 有效的完全平方数
这题没啥好说的,标准二分查找,左边界为0,右边界为num
class Solution {
public:
bool isPerfectSquare(int num) {
bool ans = false;
int l = 0;
int r = num;
while(l <= r){
int mid = (l + r) / 2;
if((long long)mid * mid < num){
l = mid + 1;
}
else if((long long)mid * mid > num){
r = mid - 1;
}
else{
ans = true;
break;
}
}
return ans;
}
};
双指针:
- 定义两个快慢指针,需要搞清楚两个指针指向的是什么含义,将全部情况考虑到位,多多画图推演。
- 当要找某个元素时,应考虑不是该元素要怎么处理。
27.移除元素
暴力解法:两个for循环
//暴力解法:两层for
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int size = nums.size();
for(int i = 0; i < size; i++){
if(nums[i] == val){
for(int j = i + 1; j < size; j++){
nums[j - 1] = nums[j];
}
i--;
size--;
}
}
return size;
}
};
双指针解法
其实是实现erase库函数的功能,时间复杂度是O(n)
思路:
定义快慢指针
- 快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
- 慢指针:指向更新 新数组下标的位置
// 时间复杂度:O(n)
// 空间复杂度:O(1)
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
//定义快慢指针
int fast;
int slow = 0;
for(fast = 0; fast < nums.size(); fast++){
//当遇到不是目标元素时,将快指针指向的值赋给慢指针,同时移动一步慢指针
//当slow和fast值不同时,slow指向的元素一定等于val
if(nums[fast] != val){
nums[slow] = nums[fast];
slow++;
}
}
return slow; //慢指针指向的就是数组的新长度
}
};
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int j = nums.size() - 1;
for (int i = 0; i <= j; i++) {
if (nums[i] == val) {
swap(nums[i], nums[j]);
i--; //当nums[j]==val时i后退一步可以再判断一次
j--;
}
}
return j + 1;
}
};
/**
* 相向双指针方法,基于元素顺序可以改变的题目描述改变了元素相对位置,确保了移动最少元素
* 时间复杂度:O(n)
* 空间复杂度:O(1)
*/
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int leftIndex = 0;
int rightIndex = nums.size() - 1;
while (leftIndex <= rightIndex) {
// 找左边等于val的元素
while (leftIndex <= rightIndex && nums[leftIndex] != val){
++leftIndex;
}
// 找右边不等于val的元素
while (leftIndex <= rightIndex && nums[rightIndex] == val) {
-- rightIndex;
}
// 将右边不等于val的元素覆盖左边等于val的元素
if (leftIndex < rightIndex) {
nums[leftIndex++] = nums[rightIndex--];
}
}
return leftIndex; // leftIndex一定指向了最终数组末尾的下一个元素
}
};
26.删除游戏数组中的重复项
思路:
- 暴力解法是再创建一个新数组,当遇到数组中没有的元素时则把这个元素装进新数组,但是题目要求原地,所以不能使用该方法。
- 使用双指针法来遍历数组。
- 指针slow:记录不重复元素的位置
- 指针fast用于遍历数组
当nums[fast]与nums[slow]不相等时,将nums[fast]复制到nums[slow+1]的位置,并增加slow的值。
遍历完成后,slow的值加1即为去重后的数组长度。
//双指针算法:自己要画图推演一下
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
int n = nums.size();
//特殊情况判定
if (n == 0) {
return 0;
}
int slow = 0;
for (int fast = 1; fast < n; fast++) {
if (nums[fast] != nums[slow]) {
//如果fast只比slow大1,就是本身赋给本身
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1;
}
};
283.移动零
思路:
定义两个快慢指针,慢指针用于记录下一个非零元素该存放的位置(需要交换时都指向零元素),for循环快指针用于向前寻找非零元素,一旦找到就将快慢指针所指向的元素进行互换,并使慢指针向前移动一位。
//双指针解法
class Solution {
public:
void moveZeroes(vector<int>& nums) {
int slow = 0;
for(int fast = 0; fast < nums.size(); fast++){
if(nums[fast] != 0){
int temp = nums[fast];
nums[fast] = nums[slow];
nums[slow] = temp;
slow++;
}
}
}
};
844.比较含退格的字符串
思路:
-
这道题不用说把字符串数组进行覆盖等操作,只需要从后往前移动指针,当找到#就继续往前找,找到字符后再往前移动指针(相当于不拿这个字符与另一个字符进行比较)
-
准备两个指针 i, j 分别指向字符串s,t 的末位字符,再准备两个变量 skipS,skipT 来分别存放 s,t 字符串中的 # 数量。
-
从后往前遍历 s,所遇情况有三,
-
- 若当前字符是 #,则 skipS 自增 1;
- 若当前字符不是 #,且 skipS 不为 0,则skipS 自减 1;
- 若当前字符不是 #,且 skipS 为 0,则代表当前字符不会被消除,我们可以用来和 s中的当前字符作比较。
-
若对比过程出现 s, t 当前字符不匹配,则遍历结束,返回false,若 s,t 都遍历结束,且都能一一匹配,则返回 true。
//使用双指针逆序同时遍历两个字符串
//比较过滤掉退格需要删除的元素后两个字符串是否相等
class Solution {
public:
bool backspaceCompare(string s, string t) {
int i = s.length() - 1;
int j = t.length() - 1;
int skipS = 0, skipT = 0; //用于记录当前是否有待删除的元素
while(i >= 0 || j >= 0){//若其中一个字符串还没遍历完则不停止
//s字符串
while(i >= 0){
//if语句的意思就是当遇到#号时就要找到下一个字符与他抵消
//if一旦判定成功,则之后一定就会判定else if去抵消这个#
//之所以分成if和else if是用于处理可能连续出现多个#的情况
if(s[i] == '#'){
skipS++;
i--;
}
else if(skipS > 0){
skipS--;
i--;
}
else{ //skip == 0 && s[i] != '#'
//即当前字符不会被消除,可以拿来和t中的当前字符作比较
break;
}
}
//t字符串 与s同理
while(j >= 0){
if(t[j] == '#'){
skipT++;
j--;
}
else if(skipT > 0){
skipT--;
j--;
}
else{ //skip == 0 && s[j] != '#'
break;
}
}
//看对应位置的元素是否相等
if(i >= 0 && j >= 0){
if(s[i] != t[j]){
return false;
}
}
else{ //可能是两个都小于零也有可能是只有一个字符串小于零
//只有一个字符串小于零的话就证明他们的可比较长度不一样 不可能相等
if(i >= 0 || j >= 0){
return false;
}
}
//控制遍历
i--;
j--;
}
//能正常结束走出循环的一定是全部匹配成功的情况
return true;
}
};
图解步骤如下:
977.有序数组的平方
给你一个按 非递减顺序 排序的整数数组 nums,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
暴力解法:
思路就是先求平方再排序,但是两个for会超时。可以改成快速排序之类的。
//暴力解法 先求平方再排序
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
for(int i = 0; i < nums.size(); i++){
nums[i] = nums[i] * nums[i];
}
for(int i = 0; i < nums.size() - 1; i++){
for(int j = i + 1; j < nums.size(); j++){
if(nums[j] < nums[i]){
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
}
return nums;
}
};
class Solution {
public:
vector<int> sortedSquares(vector<int>& A) {
for (int i = 0; i < A.size(); i++) {
A[i] *= A[i];
}
sort(A.begin(), A.end()); // 快速排序
return A;
}
};
双指针:
思路:
为什么会想到用双指针?因为数组本来是有序的,但是平方之后负数的位置要改变,所以最大值肯定是再最左边或者最右,所以可以建立双指针指向头尾逐步向中间合拢的操作来解答。
所以思路就是,建立一个与nums相同的新数组,从新数组最右边开始填充;设立双指针i j,分别指向nums数组的头尾,每次都比较两个指针指向的数谁大,把大的数填充到新数组后移动该指针指向下一个数,直到两个指针相遇。
最后返回新数组即可。
//双指针法 指向nums的头尾
//比较头尾的大小 把大的装进新数组的尾部然后再移动其中一个指针
class Solution {
public:
vector<int> sortedSquares(vector<int>& nums) {
vector<int> result(nums.size(), 0);//长度与nums一致 且从零开始
int k = nums.size() - 1; //指向新数组的尾部 用于逆向填充该数组
int i = 0;
int j = k;
while(i <= j){
//左边的大
if(nums[i] * nums[i] > nums[j] * nums[j]){
result[k] = nums[i] * nums[i];
k--;
i++;
}
//右边的大或者相等
else{
result[k] = nums[j] * nums[j];
k--;
j--;
}
}
return result;
}
};
滑动窗口:
209.长度最小的子数组
暴力解法:
两个for循环,第一个for表示子数组的左边界,第二个表示右边界。时间复杂度很明显是O(n^2)。
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
//因为是要找最小的子数组长度,直接设为最大,条件判断好写一点
int result = INT32_MAX;
int sum; //计算子数组的和
int childLength = 0; //子数组长度
for(int i = 0; i < nums.size(); i++){ //i:子数组的左边界
sum = 0;
for(int j = i; j < nums.size(); j++){ //j:子数组的右边界
sum += nums[j];
if(sum >= target){
childLength = j - i + 1;
if(childLength < result){ //对更小的数组长度进行更新
result = childLength;
}
break; //找到了最短的直接跳出j循环
}
}
}
if(result == INT32_MAX){
return 0;
}
return result;
}
};
滑动窗口法(双指针)
所谓滑动窗口,就是不断的调节子序列的起始位置和终止位置,从而得出我们要想的结果。
滑动窗口用一个for循环来完成这个操作。
首先要思考 如果用一个for循环,那么应该表示 滑动窗口的起始位置,还是终止位置。
如果只用一个for循环来表示 滑动窗口的起始位置,那么如何遍历剩下的终止位置?
此时难免再次陷入 暴力解法的怪圈。
所以 只用一个for循环,那么这个循环的索引,一定是表示 滑动窗口的终止位置。
那么问题来了, 滑动窗口的起始位置如何移动呢?
这里还是以题目中的示例来举例,s=7, 数组是 2,3,1,2,4,3,来看一下查找的过程:
最后找到 4,3 是最短距离。
其实从动画中可以发现滑动窗口也可以理解为双指针法的一种!只不过这种解法更像是一个窗口的移动,所以叫做滑动窗口更适合一些。
在本题中实现滑动窗口,主要确定如下三点:
- 窗口内是什么?
- 如何移动窗口的起始位置?
- 如何移动窗口的结束位置?
窗口就是 满足其和 ≥ s 的长度最小的 连续 子数组。
窗口的起始位置如何移动:如果当前窗口的值大于s了,窗口就要向前移动了(也就是该缩小了)。
窗口的结束位置如何移动:窗口的结束位置就是遍历数组的指针,也就是for循环里的索引。
解题的关键在于 窗口的起始位置如何移动,如图所示:
可以发现滑动窗口的精妙之处在于根据当前子序列和大小的情况,不断调节子序列的起始位置。从而将O(n^2)暴力解法降为O(n)。
C++代码如下:
class Solution {
public:
int minSubArrayLen(int s, vector<int>& nums) {
//因为是要找最小的子数组长度,直接设为最大,条件判断好写一点
int result = INT32_MAX;
int sum = 0; // 滑动窗口数值之和
int i = 0; // 滑动窗口起始位置
int subLength = 0; // 滑动窗口的长度
for (int j = 0; j < nums.size(); j++) { //j指向终止位置
sum += nums[j];
// 注意这里使用while,每次更新 i(起始位置),并不断比较子序列是否符合条件
//当总和大于target时便先固定终止位置,不断向前移动起始位置 缩小范围
//直到sum < s
while (sum >= s) {
subLength = (j - i + 1); // 取子序列的长度
result = result < subLength ? result : subLength;
sum -= nums[i++]; // 这里体现出滑动窗口的精髓之处,不断变更i(子序列的起始位置)
}
}
// 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
return result == INT32_MAX ? 0 : result;
}
};
- 时间复杂度:O(n)
- 空间复杂度:O(1)
一些录友会疑惑为什么时间复杂度是O(n)。
不要以为for里放一个while就以为是O(n^2)啊, 主要是看每一个元素被操作的次数,每个元素在滑动窗后进来操作一次,出去操作一次,每个元素都是被操作两次,所以时间复杂度是 2 × n 也就是O(n)。
904.水果成篮(未完成)
最小滑窗模板:给定数组 nums,定义滑窗的左右边界 i, j,求满足某个条件的滑窗的最小长度。
while j < len(nums):
判断[i, j]是否满足条件
while 满足条件:
不断更新结果(注意在while内更新!)
i += 1 (最大程度的压缩i,使得滑窗尽可能的小)
j += 1
最大滑窗模板:给定数组 nums,定义滑窗的左右边界 i, j,求满足某个条件的滑窗的最大长度。
while j < len(nums):
判断[i, j]是否满足条件
while 不满足条件:
i += 1 (最保守的压缩i,一旦满足条件了就退出压缩i的过程,使得滑窗尽可能的大)
不断更新结果(注意在while外更新!)
j += 1
需要用到哈希表
76.最小覆盖子串(未完成)
模拟过程:
59.螺旋矩阵||
这道题目可以说在面试中出现频率较高的题目,本题并不涉及到什么算法,就是模拟过程,但却十分考察对代码的掌控能力。
要如何画出这个螺旋排列的正方形矩阵呢?
求解本题依然是要坚持循环不变量原则。
模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈这么画下去。
可以发现这里的边界条件非常多,在一个循环中,如此多的边界条件,如果不按照固定规则来遍历,那就是一进循环深似海,从此offer是路人。
这里一圈下来,我们要画每四条边,这四条边怎么画,每画一条边都要坚持一致的左闭右开,或者左开右闭的原则,这样这一圈才能按照统一的规则画下来。
那么我按照左闭右开的原则,来画一圈,大家看一下:
这里每一种颜色,代表一条边,我们遍历的长度,可以看出每一个拐角处的处理规则,拐角处让给新的一条边来继续画。
class Solution {
public:
vector<vector<int>> generateMatrix(int n) {
vector<vector<int>> res(n, vector<int>(n, 0)); // 使用vector定义一个二维数组
int startx = 0, starty = 0; // 定义每循环一个圈的起始位置
int loop = n / 2; // 每个圈循环几次,例如n为奇数3,那么loop = 1 只是循环一圈,矩阵中间的值需要单独处理
int mid = n / 2; // 矩阵中间的位置,例如:n为3, 中间的位置就是(1,1),n为5,中间位置为(2, 2)
int count = 1; // 用来给矩阵中每一个空格赋值
int offset = 1; // 需要控制每一条边遍历的长度,每次循环右边界收缩一位
int i,j;
while (loop --) {
i = startx;
j = starty;
// 下面开始的四个for就是模拟转了一圈
// 模拟填充上行从左到右(左闭右开)
for (j = starty; j < n - offset; j++) {
res[startx][j] = count++;
}
// 模拟填充右列从上到下(左闭右开)
for (i = startx; i < n - offset; i++) {
res[i][j] = count++;
}
// 模拟填充下行从右到左(左闭右开)
for (; j > starty; j--) {
res[i][j] = count++;
}
// 模拟填充左列从下到上(左闭右开)
for (; i > startx; i--) {
res[i][j] = count++;
}
// 第二圈开始的时候,起始位置要各自加1, 例如:第一圈起始位置是(0, 0),第二圈起始位置是(1, 1)
startx++;
starty++;
// offset 控制每一圈里每一条边遍历的长度
offset += 1;
}
// 如果n为奇数的话,需要单独给矩阵最中间的位置赋值
if (n % 2) {
res[mid][mid] = count;
}
return res;
}
};
- 时间复杂度 O(n^2): 模拟遍历二维矩阵的时间
- 空间复杂度 O(1)