一、二分查找法
关于二分查找法的文档见:https://blog.csdn.net/Master_cxc/article/details/91415170
二分查找防止溢出问题:https://blog.csdn.net/Jackie1377/article/details/111001491
1. 最基础二分查找
// 版本一
class Solution {
public:
int search(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
int middle = 0;
while (left <= right) { // 当left==right,区间[left, right]依然有效,所以用 <=
middle = left + ((right - left) >> 1);// 防止溢出 等同于(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;
}
};
// 版本二
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;
}
};
2. 搜索插入位置
(1)暴力穷举法
暴力解题不一定时间消耗就非常高,关键看实现的方式,就像是二分查找时间小号不一定就很低,是一样的。
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的长度
}
};
- 时间复杂度O(n)
- 空间复杂度O(1)
(2)二分查找法
class Solution {
public:
int searchInsert(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1;
int middle = 0;
while(left <= right){
middle = left + ((right - left) >> 2); // 防止溢出,等价于 (left + right) / 2
if(nums[middle] > target){ // 目标值在左侧,[left, middle - 1]
right = middle - 1;
}
else if(nums[middle] < target){ // 目标值在右侧,[middle + 1, right]
left = middle + 1;
}
else{
return middle; // 找到数组中与目标值对应的元素,返回索引
}
}
// 处理以下三种情况
// 目标值在数组所有元素之前
// 目标值在数组所有元素之后
// 目标值插入数组中某个位置
return left; // 或者 return right + 1; 都是可以的
}
};
- 时间复杂度O(log n)
- 空间复杂度O(1)
解题思路:其实这道题无非是在普通的二分查找的基础上将目标值按顺序插入数组,关键在于边界的分析问题,情况可以分为四种:
- 数组中有与目标值相等的元素
- 目标值小于数组所有元素
- 目标值大于数组所有元素
- 目标值插入数组中某个位置
确定了循环不变量之后,解题思路就很清晰了。
3. 在排序数组中查找元素的第一个和最后一个位置
34. 在排序数组中查找元素的第一个和最后一个位置 - 力扣(LeetCode)
思路
这道题目如果基础不是很好,不建议看简短的代码,简短的代码隐藏了太多逻辑,结果就是稀里糊涂把题AC了,但是没有想清楚具体细节!
寻找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}
接下来,再去寻找左边界和有边界。
刚刚接触二分搜索的同学不建议上来就想如果用一个二分来查找左有边界,很容易把自己绕进去,建议扎扎实实的写两个二分分别找左边界和有边界
(1)寻找右边界
// 二分查找,寻找target的右边界(不包括target)
// 如果rightBorder为没有被赋值(即target在数组范围的左边,例如数组[3,3],target为2),为了处理情况一
int getRightBorder(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
int rightBorder = -2; // 记录一下rightBorder没有被赋值的情况
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 { // 当nums[middle] == target的时候,更新left,这样才能得到target的右边界
left = middle + 1;
rightBorder = left;
}
}
return rightBorder;
}
(2)寻找左边界
// 二分查找,寻找target的左边界leftBorder(不包括target)
// 如果leftBorder没有被赋值(即target在数组范围的右边,例如数组[3,3],target为4),为了处理情况一
int getLeftBorder(vector<int>& nums, int target) {
int left = 0;
int right = nums.size() - 1; // 定义target在左闭右闭的区间里,[left, right]
int leftBorder = -2; // 记录一下leftBorder没有被赋值的情况
while (left <= right) {
int middle = left + ((right - left) / 2);
if (nums[middle] >= target) { // 寻找左边界,就要在nums[middle] == target的时候更新right
right = middle - 1;
leftBorder = right;
} else {
left = middle + 1;
}
}
return leftBorder;
}
(3)处理三种情况
左右边界计算完之后,看一下主题代码,这里把上面讨论的三种情况,都覆盖了
// 这道题有三种情况
// 1. 目标值在数组左边或者右边 eg: [3, 3] target=2 || target = 4
// 2. 目标值不存在数组中
// 3. 目标值在数组中,这种情况同情况一一样,都需要获取目标值的左右边界
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 > 1){
return {leftBorder + 1, rightBorder - 1};
}
// 情况二,leftBorder和rightBorder的值是不确定的
return {-1, -1};
}
private:
// 获取左边界
int getLeftBorder(vector<int> nums, int target){
int left = 0;
int right = nums.size() - 1;
int leftBorder = -2; // 初始化为-2,方便记录后续是否被赋值
while(left <= right){
int middle = left + ((right - left) >> 1);
if(nums[middle] >= target){ // 如果target落在左区间
right = middle - 1;
leftBorder = right;
}
else{
left = middle + 1;
}
}
return leftBorder;
}
// 获取右边界
int getRightBorder(vector<int> nums, int target){
int left = 0;
int right = nums.size() - 1;
int rightBorder = -2;
while(left <= right){
int middle = left + ((right - left) >> 1);
if(nums[middle] <= target){ // 如果target落在右区间,我们可以找到
left = middle + 1;
rightBorder = left;
}
else{
right = middle - 1;
}
}
return rightBorder;
}
};
这份代码在简洁性很有大的优化空间,例如把寻找左右区间函数合并一起。
但拆开更清晰一些,而且把三种情况以及对应的处理逻辑完整的展现出来了。
(4)我的解题感想:
- 首先,这道题的难点一是:你很难去想到去寻找目标值在数组的左右边界,而且这个边界是目标值的前一位和后一位,这是难点之一。
- 第二个难点是:二分查找一般是找到目标值我们就退出循环,但这里明显要继续去找其他的索引,怎么解决这个问题,这是难点二。
- 难点三就是对二分查找的理解,二分查找的最高消耗就是当
left <= right
的循环条件被打破,这时right + 1 = left
,而在此之前,left、right、middle指向同一个索引,因此要找到目标值的右边界,只需将rightBorder = left
即rightBorder = middle + 1
,同理,左边界leftBorder = right
即leftBorder = middle - 1
,这很好的解释了上面代码的思想。
4. sqrt(x)
思路:
使用整数的排序属性来减少搜索空间。
自然数就是一个公差为 1 的等差数列。由于题目只要求整数部分,所以只需要找到 某个数 n 的平方大于 x 且 n - 1 的平方小于 x ,那么 n - 1 这个数就是我们要的。即使用二分查找,有两种情况需要分析:
- 数组刚好有一个目标值,target^2 == x;
- 数组中没有对应的目标值,根据二分查找的特点,最后 left == right == middle,最后一个判定条件:
- 如果 middle^2 > x,leftBorder == right == middle - 1;leftBorder 等于 middle的前一位。
- 如果 middle^2 < x,left = middle + 1;这个时候 leftBorder 还是等于right;leftBorder 等于 middle。
代码:
class Solution {
public:
int mySqrt(int x) {
int leftBorder = getLeftBorder(x);
if(leftBorder == 0){
return 0;
}
else{
return leftBorder;
}
}
private:
// 获取左边界
int getLeftBorder(int x){
// x 等于 0 需要做一个单独的判断,不然 left得从0开始,这样会造成下面middle为0的问题
if(x == 0){
return 0;
}
else{
int leftBorder = -2;
int left = 1;
int right = x;
int middle = -1;
while(left <= right){
middle = left + ((right - left) >> 1); // 防止溢出,等价于 (left + right) / 2
if(middle > x / middle){ // 防止溢出,等价于 middle * middle > x
right = middle - 1;
leftBorder = right; // 获取左边界,leftBorder永远在 middle^2 > x 的 middle左边
}
else if(middle == x / middle){
return middle;
}
else{
left = middle + 1;
}
}
return leftBorder;
}
}
};
(1)我的解题感想:
这道题有两个难点:
- 要想到利用 一个有序的数组,然后通过二分查找目标值有点小难度。
- 需要对二分查找的边界条件的理解比较清晰,当数组中没有目标值,循环最后一次条件肯定是 left == right,这个时候无论走的是哪个判定条件,leftBorder 总是能够满足题解。即 x 的算数平方根的整数部分。
5. 有效的完全平方数
class Solution {
public:
bool isPerfectSquare(int num) {
int L = 1, R = num;
int M = 0;
while(L <= R){
M = L + ((R - L) >> 1);
long square =(long) M * M; // 强制类型转换
if(square > num){
R = M - 1;
}
else if(square < num){
L = M + 1;
}
else{
return true;
}
}
return false;
}
};
这道题简单,不写题解。
6. 平方数之和
力扣第 633 题:https://leetcode-cn.com/problems/sum-of-square-numbers/
二、移除元素
1. 移除元素
力扣第 27 题:https://leetcode-cn.com/problems/remove-element/submissions/
双指针法
双指针法(快慢指针法):通过一个块指针和一个慢指针在一个for循环下完成两个for循环的工作。
- 快指针:指向当前要处理的元素
- 指向下一个将要赋值的位置
思路:
- 如果快指针指向的元素不等于val,它一定是输出数组的一个元素,我们就将快指针指向的元素赋值到慢指针位置,然后将快慢指针同时右移。
- 如果快指针指向的元素等于val,它不能输出在数组里,此时慢指针不动,快指针右移一位。
整个过程保持不变的性质是:区间 [0,left) 中的元素都不等于val。当左右指针遍历完输入数组以后,left的值就是输出数组的长度。
// 时间复杂度:O(n)
// 空间复杂度:O(1)
class Solution{
public:
int removeElement(vector<int>& nums, int val){
int slowIndex = 0;
for(int fastIndex = 0; fastIndex < nums.size(); fastIndex++){
if(nums[fastIndex] != val){
nums[slowIndex] = nums[fastIndedx];
slowIndex++;
}
}
return slowIndex;
}
}
2. 删除有序数组中的重复项
力扣第 26 题:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/
思路:
利用双指针法,快指针指向当前要处理的元素,慢指针指向下一个将要赋值的位置。
题目给的是有序数组,这个时候很容易联想到二分查找法,但是这里很明显是要将数组遍历一遍,所以我没有想出时间复杂度小于O(n)的解法,事实也确实没有。
需要注意的是数组的边界问题。
class Solution {
public:
int removeDuplicates(vector<int>& nums) {
if(nums.size() <= 1) return nums.size();
int slowIndex = 0; // 慢指针
for(int fastIndex = 0; fastIndex < nums.size(); fastIndex++){
if(fastIndex >= 1 && nums[fastIndex] == nums[fastIndex - 1]){
continue;
}
nums[slowIndex] = nums[fastIndex];
slowIndex++;
}
return slowIndex;
}
};
3. 移动零
力扣第 283 题:https://leetcode-cn.com/problems/move-zeroes/
还是双指针法,太简单了,需要注意的是题目已经给出了目标值0,所以直接覆盖就行了,没必要进行跟其他元素进行交换,这样虽然复杂度一样,但会增加很多多余的操作。
class Solution {
public:
// 时间复杂度O(n)
// 空间复杂度O(1)
void moveZeroes(vector<int>& nums) {
int left = 0; // left的左边都是非0整数
for(int right = 0; right < nums.size(); right++){
if(nums[right] != 0){
nums[left++] = nums[right]; // left的长度就是非零整数的长度
}
}
for(int i = left; i < nums.size(); i++){
nums[i] = 0;
}
}
};
4. 比较含退格的字符串
力扣第 844 题:https://leetcode-cn.com/problems/backspace-string-compare/submissions/
这道题我没有解出来
思路:
这道题用栈的思路就很简单,难点是使用双指针,链接是题解:https://leetcode-cn.com/problems/backspace-string-compare/solution/shuang-zhi-zhen-bi-jiao-han-tui-ge-de-zi-8fn8/,但是二者的复杂度是一样的,所以还是使用栈比较好。
class Solution{
public:
// 时间复杂度O(n+m)
// 空间复杂度O(1)
bool backspaceCompare(string s, string t) {
return backSpace(s) == backSpace(t);
}
string backSpace(string str) {
string stack;
for(char ch: str) {
if(ch != '#') {
stack.push_back(ch); // 如果 ch != '#' 就入栈
}
else{
stack.pop_back(); // 如果 ch == '#' 就出栈
}
}
return stack;
}
}
三、有序数组
1. 有序数组的平方
思路
数组其实是有序的,只不过负数平方之后可能变成最大的数,那么数组元素平方之后的最大值不是在左边就是在右边(这是必须要想到的点),不可能在左边,这样的话,我们只需要通过比较数组左右两边平方后的元素大小,就可以得到一个有序的数组。此时就可以使用双指针法了。left指向起始位置,right指向终止位置。
但是需要new一个新数组result来保存这个有序的数列(空间换时间),让end指向result的末尾。
- 如果
nums[left] * nums[left] > nums[right] * nums[right]
,那么result[end--] = nums[left] * nums[left]
,left++; - 如果
nums[left] * nums[left] <= nums[right] * nums[right]
,那么result[end--] = nums[right] * nums[right]
,right—;
by the way,这道题我没有做出来,操!
class Solution{
public:
vector<int> sortedSquares(vector<int>& nums){
int length = nums.size();
int end = length - 1; // 指向result的末尾
vector<int> result(length, 0); // 声明一个vector容器变量,并将各个元素初始化为0
for(int left = 0, right = length - 1; left <= right;){ // 这里<=,是因为最后面要处理两个数
if(nums[left] * nums[left] > nums[right] * nums[right]){
result[end--] = nums[left] * nums[left];
left++; // 往前移动一个位置
}
else{
result[end--] = nums[right] * nums[right];
right--; // 往后移动一个位置
}
}
return result; // 返回result数组
}
}
2. 合并两个有序数组
力扣第 88 题:https://leetcode-cn.com/problems/merge-sorted-array/
class Solution {
public:
// 数组 nums1 和 nums2 原本就是有序(升序)的,所以最大值要么在 num1的右侧,要么在num2的右侧
// 因此可以使用双指针法
void merge(vector<int>& nums1, int m, vector<int>& nums2, int n) {
int p1 = m - 1; // 指向数组nums1元素值不为0的尾部
int p2 = n - 1; // 指向数组nums2的尾部
int tail = m + n - 1; // 指向数组nums1的尾部
int cur = 0;
while(p1 >= 0 || p2 >= 0){ // 这里用或很重要,意味着p1 || p2 可以不同时小于零(这是我没有想到的)
if(p1 == -1){
cur = nums2[p2--];
}
else if(p2 == -1){
cur = nums1[p1--];
}
else if(nums1[p1] > nums2[p2]){
cur = nums1[p1--];
}
else{
cur = nums2[p2--];
}
nums1[tail--] = cur;
}
}
};
3. 区间列表的交集
力扣第 986 题:https://leetcode-cn.com/problems/interval-list-intersections/
复杂度分析:
- 时间复杂度:O(M + N),其中M,N分别是数组firstList和数组secondList的长度。
- 空间复杂度:O(M + N),答案中区间数量的上限。
class Solution {
public:
vector<vector<int>> intervalIntersection(vector<vector<int>>& firstList, vector<vector<int>>& secondList) {
// 双指针法
int i = 0; // 指针i,指向firstList数组
int j = 0; // 指针j,指向secondList数组
vector<vector<int>> result; // 结果数组,用来存放交集区间
while(i < firstList.size() && j < secondList.size()){
// left是result数组区间列表的左区间,right是result数组区间的右区间
// 需要先判断left来自哪个数组,同时也需要先判断right来自哪个数组
// 方便后续是对i++,还是对j++
int left = max(firstList[i][0], secondList[j][0]);
int right = min(firstList[i][1], secondList[j][1]);
// 只有left在right的左侧(或重叠)的时候才是交集
if(left <= right){
result.push_back({left, right});
}
// 如果firstList[i][1]比secondList[i][1]大,那么需要对firstList[i][1]进行下一步判断,
// 判断其是否跟secondList的下一个区间列表相交了
if(firstList[i][1] > secondList[j][1]){
j++;
}
else{
i++;
}
}
return result;
}
};
这道题的难点在于:下面这种情况
当 firstList的尾部比secondList的尾部大的时候,是需要对firstList[i][1]保留然后进行下一步判断的,判断其是否大于secondList的下一个区间列表的头部,如果大于说明跟secondList的下一个区间列表相交了,这样的话需要一直重复上面的操作。
四、滑动窗口
1. 长度最小的子数组
力扣第 209 题:https://leetcode-cn.com/problems/minimum-size-subarray-sum/
(1)暴力解法:
- 时间复杂度:O(n^2)
- 空间复杂度:O(1)
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums) {
int sum = 0;
int length = INT_MAX;
for(int i = 0; i < nums.size(); i++){
sum = 0;
for(int j = i; j < nums.size(); j++){
if(nums[j] < target){
sum = sum + nums[j];
}
if(nums[j] >= target){
return 1;
}
if(sum >= target){
if(length > j - i + 1){
length = j - i; // 长度最小的连续子数组的长度保存在length中
break; // 退出当前for循环
}
}
}
}
if(length == INT_MAX){
return 0;
}
else{
return length;
}
}
};
(2)前缀 + 二分查找
class Solution {
public:
int minSubArrayLen(int s, vector<int>& nums) {
int n = nums.size();
if (n == 0) {
return 0;
}
int ans = INT_MAX;
vector<int> sums(n + 1, 0);
// 为了方便计算,令 size = n + 1
// sums[0] = 0 意味着前 0 个元素的前缀和为 0
// sums[1] = A[0] 前 1 个元素的前缀和为 A[0]
// 以此类推
for (int i = 1; i <= n; i++) {
sums[i] = sums[i - 1] + nums[i - 1];
}
for (int i = 1; i <= n; i++) {
int target = s + sums[i - 1];
auto bound = lower_bound(sums.begin(), sums.end(), target);
if (bound != sums.end()) {
ans = min(ans, static_cast<int>((bound - sums.begin()) - (i - 1)));
}
}
return ans == INT_MAX ? 0 : ans;
}
};
难点:
- 因为数组元素非负,所以可以利用累加求和的方式让数组变成一个有序数组。这一点比较难想到。
- 对目标元素不变量的确定,即 target = s + sums[i - 1]。其实每一次循环都去找大于目标值的连续子数组,然后对找到的子数组的长度进行迭代。第 i 次寻找的时候,由于数组的前 i 项元素的和,所以需要减去前 i - 1 项的和,但这时候我们令 s + sums[i - 1],这样就不必执行 sums[i] - sums[i - 1]这样难以实现操作的。我觉得这是难点2.
- 为什么sums数组的长度要确定为 n + 1?
- 原因:因为当遍历完数组nums后,这个时候 i 指向的是最后一个元素,那么这个时候如何判断是否大于或等于target呢?这个时候如果多开一个位置就可以将 nums的最后一个元素也进行统一判断了。即如果 i 指向了 sums.end(),这个数组就不满足条件。
(3)滑动窗口
class Solution {
public:
int minSubArrayLen(int target, vector<int>& nums){
int n = nums.size();
int i = 0; // 滑动窗口的其实地址
int sum = 0; // 滑动窗口的和
int subLength = 0; // 滑动窗口的长度
int result = INT_MAX; // 结果长度,初始化为最大
for(int j = 0; j < n; j++){
if(nums[j] >= target){ // 如果存在一个元素大于或等于target,则直接返回1;
return 1;
}
sum = sum + nums[j];
while(sum >= target){
subLength = j - i + 1;
result = result < subLength ? result : subLength;
sum = sum - nums[i++]; // 滑动窗口的精髓,不断变更i(子数组的其实位置)
}
}
}
}
2. 最长重复子数组
力扣第 718 题:https://leetcode-cn.com/problems/maximum-length-of-repeated-subarray/
解法:滑动窗口
思路及算法
我们注意到之所以两位置会比较多次,是因为重复的子数组在两个数组中的位置可能不同。以 A = [3,6,1,2,4],B = [7,1,2,9]为例,我们最长重复子数组是 [1,2],在A与B中的开始位置不同。但如果我们知道了开始位置,我们就可以根据它们将 A 和 B 进行 [ 对齐 ],即:
A = [3, 6, 1, 2, 4]
B = [7, 1, 2, 9]
↑ ↑
--------------------------------
// 我们可以枚举 A 和 B 的所有对齐方式。对齐的方式有以下两类
A = [0, 1 ,2, 3, 4, 0, 0] --> A固定,B滑动 A = [0, 1, 2, 3, 4, 0, 0]
B = [1, 2, 3, 4, 1, 1, 1] B = [1, 2, 3, 4, 1, 1, 1]
↑
A = [0, 1 ,2, 3, 4, 0, 0] A = [1, 2, 3, 4, 1, 1, 1]
B = [1, 2, 3, 4, 1, 1, 1] --> B固定,A滑动 B = [0, 1, 2, 3, 4, 0, 0]
↑
c++代码实现:
复杂度分析
- 时间复杂度: O((N + M) * min(N, M))
空间复杂度: O(1)
class Solution{
public:
int maxLength(vector<int>& A, vector<int>& B, int addA, int addB, int len){
int result = 0;
int k = 0;
for(int i = 0; i < len; i++){
if(A[addA + i] == B[addB + i]){
k++;
}
else{
k = 0;
}
result = max(result, k);
}
return result;
}
int findLength(vector<int>& nums1, vector<int>& nums2){
int n1 = nums1.size();
int n2 = nums2.size();
int len = 0;
int subLength = 0; // 滑动窗口的长度
int result = 0; // 最终结果
// A固定,B相对A向右滑动
for(int i = 0; i < n1; i++){
len = min(n1 - i, n2); // 防止数组越界,同时n1 - i,是nums2与nums1对齐后需要判断的长度
subLength = maxLength(nums1, nums2, i, 0, len);
result = max(result, subLength);
}
// B固定,A相对B向右滑动
for(int i = 0; i < n2, i++){
len = min(n1, n2 - i);
subLength = maxLength(nums1, nums2, 0, i, len);
result = max(result, subLength);
}
return result;
}
}
3. 无重复字符的最长子串
力扣第 3 题:https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/
思路与算法
因为要寻找字符串 s = “abcabcdd” 的无重复字符的最长子串,所以肯定至少要遍历一遍 s 才能遍历所有可能性,通常来说,找子数组或者子串这类问题,首先想到的就是用双指针来解决。这道题看题来更像滑动窗口,所以可以用滑动窗口的思想来解决。。
- 我们使用指针 i 和指针 right 分别指向枚举子串的起始位置和滑动窗口向右滑动的滑动指针。
- 在每一步操作中,将左指针向右移动一格,表示我们枚举下一个字符作为起始位置,然后我们可以不断地向右移动右指针,但需要保证这两个指针对应的子串中没有重复的字符。在移动结束后,这个子串就对应着以左指针开始的,不包含重复字符的最长子串。我们记录下这个子串的长度。
- 在枚举结束后,我们找到的最长的子串的长度即为答案。
判断重复字符
在上面的流程中,我们还需要使用一种数据结构来判断是否有重复的字符,常用的数据结构为哈希集合(即C++中的 std::unordered_set
,Java 中的 HashSet
,Python 中的 set
,JavaScript 中的 Set
)。在左指针向右移动的时候,我们从哈希集合中移除一个字符,在右指针向右移动的时候,我们往哈希集合添加一个字符。
C++代码:
- 时间复杂度O(n)
- 空间复杂度O(m),m是子串的长度,最长为数组的长度n
class Solution {
public:
int lengthOfLongestSubstring(string s) {
unordered_set<char> occ; // 声明一个哈希集合的变量
int right = -1; // 滑动指针,从数组第一个位置的左侧开始,这样子right+1才能遍历到所有的元素
int ans = 0;
for(int i = 0; i < s.length(); ++i){ // 需要遍历一遍字符串,才能找到最长子串
if(i != 0){
occ.erase(s[i - 1]);
}
while(right + 1 < s.length() && !occ.count(s[right + 1])){ // !occ.count(s[right+1]) 保证我们得到的是一个无重复子串
occ.insert(s[right + 1]);
right++; // 向右滑动
}
ans = max(ans, right - i + 1);
}
return ans;
}
};
4. 水果成篮
力扣第 904 题:https://leetcode-cn.com/problems/fruit-into-baskets/
我的代码:
时间复杂度O(n2)
class Solution {
public:
int totalFruit(vector<int>& fruits) {
unordered_set<int> hashset;
int type = 0; // 水果类型
int right = 0; // 子串右指针
int subLength = 0; // 当前窗口的长度
for(int i = 0; i < fruits.size(); i++){
right = i; // 右指针从子串的起始位置开始滑动
type = 0; // 水果类型重置为0
if(i == right){ // 如果是子串的起始位置,那么第1棵数肯定能被采摘
hashset.insert(fruits[i]);
type++; // 水果类型加1
subLength = max(subLength, right - i + 1);
}
while(type <= 2 && right + 1 < fruits.size()){ // 用right+1是为了防止越界
if(hashset.count(fruits[right + 1])){ // 如果 fruits[right + 1]在前面出现过了,表明是同一棵树,type不用+1
hashset.insert(fruits[right + 1]);
right++;
subLength = max(subLength, right - i + 1);
}
else{
hashset.insert(fruits[right + 1]);
right++;
type++;
if(type <= 2){ // 只有类型不大于2的子串的长度才是我们想要的
subLength = max(subLength, right - i + 1);
}
}
}
hashset.clear(); // 将之前的存入的数全部丢弃
}
return subLength;
}
};
题解:
这道题最关键的问题是:解题人能不能想到用哈希表来解这道题。
我的理解:
因为只有两个篮子,说白了就是找到由两个数值组成的最长且连续的子串,所以就可以通过哈希映射来存储这些键值对(键是fruits的元素,值是这个元素出现的次数)。我们只需要记录某个元素出现的次数,这个特性与哈希映射键值对的特征符合。
接着我们只需要判断哈希映射的size是否大于2即可,如果大于2,我们就让最前面的元素出现的次数减1,然后长度跟着减1,知道这个元素出现的次数为0,那么我们删除这个元素。在整个过程中,我们都要不断迭代子串的长度。
class Solution{
public:
// 时间复杂度O(n),其中n是fruits的长度
// 空间复杂度O(n)
int totalFruit(vector<int>& fruits){
unordered_map<int, int> map;
int len = 0; // 当前子串长度
int result = 0; // 结果
int right = 0; // 滑动指针
for(int i = 0; i < fruits.size(); i++){
map[fruits[i]]++; // 第i个元素出现的次数
len++; // 长度+1
while(map.size() > 2){ // 如果哈希表的长度大于2
map[fruits[right]]--; // 最前面的元素出现的次数减1
if(map[fruits[right]] == 0){
map.erase(fruits[right]);
}
right++; // right的值就是元素的索引
len++;
}
result = result > len ? result : len;
}
return result;
}
}
5. 最小覆盖子串
思路和算法
本问题要求我们返回字符串s中包含字符串t的全部字符的最小窗口。我们成包含t的全部字母的窗口为可行窗口。
我们可以用滑动窗口的思想解决这个问题。在滑动窗口类型的问题中都会有两个指针,一个用于【延伸】现有窗口的 right 指针,和一个用于【收缩】窗口的 left 指针。在任意时刻,只有一个指针运动,而另一个保持静止。我们在 s 上滑动窗口,通过移动 r 指针不断扩张窗口。当窗口包含 t 全部所需的字符后,如果能收缩,我们就收缩窗口直到得到最小窗口。
这道题最关键的一步我觉得是,你要想到去利用 t 中的字符,即通过 t_map[t[right]] 来判断 s 当前的字符是不是有用的字符,这一点十分关键。
class Solution{
public:
unordered_map<char, int> t_map, s_map;
// check函数,用来检查s_map是否涵盖了t_map中的所有字符,且s_map中的字符出现的次数不少于t_map中的
bool check(){
for(const auto &p : t_map){
if(s_map[p.first] < p.second){
return false;
}
}
return true;
}
string minWindow(string s, string t){
for(const auto &c : t){ // 将t中的字符存入到t_map中
t_map[c]++;
}
int left = 0; // 收缩指针
int right = -1; // 扩张指针
int len = INT_MAX; // 窗口长度
int minL = -1; // 指针收缩到窗口长度最小时候的位置
while(right < int(s.size())){
if(t_map.find(s[++right]) != t_map.end()){
s_map[s[right]]++;
}
while(check() && left <= right){
if(len > right - left + 1){ // 更新窗口长度
len = right - left + 1;
minL = left;
}
if(t_map.find(s[left]) != t_map.end()){ // 窗口满足收缩的条件
s_map[s[left]]--; // 把最开始出现的字符跳过,寻找下一个窗口
}
left++; // 收缩窗口
}
}
return minL == -1 ? string() : s.substr(minL, len);
}
};
复杂度分析:
- 时间复杂度:最坏情况下左右指针对 ss 的每个元素各遍历一遍,哈希表中对 ss 中的每个元素各插入、删除一次,对 tt 中的元素各插入一次。每次检查是否可行会遍历整个 tt 的哈希表,哈希表的大小与字符集的大小有关,设字符集大小为 CC,则渐进时间复杂度为 O(C⋅∣s∣+∣t∣)。
- 空间复杂度:这里用了两张哈希表作为辅助空间,每张哈希表最多不会存放超过字符集大小的键值对,我们设字符集大小为 CC ,则渐进空间复杂度为 O(C)O(C)。
五、模拟行为
1. 螺旋矩阵II
思路:
本题并不涉及到什么算法,就是模拟过程,但却十分考察对代码的掌控能力。
要如何画出这个螺旋排列的正方形矩阵呢?这是一个关键问题。
- 关键1:题目给我们一个正整数n,首先我们需要知道如何去画这个螺旋矩阵。第一,要明确的知道这个螺旋矩阵旋转的圈数。我们需要找出这个规律。(比如当n=5时,螺旋矩阵循环的次数为 5 / 2 = 2次)
- 关键2:坚持循环不变量原则(这里使用左闭右开原则)。
模拟顺时针画矩阵的过程
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈画下去。
关键3:需要对特殊情况进行处理。例如:当n为奇数时,需要对最后一个元素n2进行特殊处理。这最后一个元素的位置是螺旋矩阵的中心坐标。
class Solution{
public:
vector<vector<int>> generateMatrix(int n){
vector<vector<int>> Array2D(n, vector<int>(n, 0)); // 定义一个二维数组,并将其元素初始化为0
int circle = n / 2; // 循环的次数,例如n=3,螺旋矩阵循环的次数为1,n=5时,螺旋矩阵循环的次数为2
int x_start = 0, y_start = 0; // 每次循环都重新设置螺旋矩阵的起始坐标
int mid = n / 2; // 中心坐标,只有n为奇数时,中心坐标才会被拿来使用
int count = 1; // 计数器,计1-n2中间的数,并将每个数赋给Array2D中的每个位置
int offset = 1; // 每一圈循环都需要控制每条边的长度,因为n是长度,而数组下表从0开始,所以offset初始化为1
int x, y;
while(circle > 0){ // 螺旋矩阵旋转的次数
x = x_start;
y = y_start;
// 下面4个for循环就是模拟旋转一圈
// 顶部,自左向右
for(; x < x_start + n - offset; x++){
Array2D[y][x] = count++;
}
// 右侧,自上向下
for(; y < y_start + n - offset; y++){
Array2D[y][x] = count++;
}
// 底部,自右向左
for(; x > x_start; x--){
Array2D[y][x] = count++;
}
// 左侧,自下向上
for(; y > y_start; y--){
Array2D[y][x] = count++;
}
circle--; // 循环次数减1
x_start++; // 重新设计起始坐标,每循环一圈,起始坐标x和y都+1
y_start++;
offset += 2; // 经过一圈循环后,offset的值要加2
}
// 如果n是奇数,需要对最后一个元素进行特殊处理
if(n % 2 == 1){
Array2D[mid][mid] = count;
}
return Array2D;
}
};
2. 螺旋矩阵
思路:
没什么好讲的,会上面的螺旋矩阵II,这一道题也会了(一样的思想,变通一下就行了)。主要是找规律,对不在循环内的边界数字进行额外的判断。在注释中我也说的很明白了。
class Solution {
public:
vector<int> spiralOrder(vector<vector<int>>& matrix) {
int m = matrix.size(); // 获得二维数组的行数
int n = matrix[0].size(); // 获得二维数组的列数
vector<int> array(m * n, 0); // 定义一个数组
int circle = min(m, n) / 2; // 螺旋矩阵旋转的圈数
int x_start = 0; // 螺旋矩阵每一圈的起始位置
int y_start = 0;
int x, y;
int count = 0; // array数组当前位置的索引
int offset = 1; // 每一次循环都需要控制每次访问的窗口的长度
int specialNum = max(m, n) - 2 * ((min(m, n) / 2));
// 如果n != m且min(m,n)为奇数,则是一个矩阵,且需要对最后 max(m,n) - 2*((min(m,n)/2)) 个元素进行特殊处理
// 如果n = m且都为奇数,则只需要最后一个元素进行特殊处理
while(circle > 0){
x = x_start;
y = y_start;
// 下面4个for循环表示一圈(循环不变量是每个for循环的窗口都是左闭右开区间)
// 顶部,从左到右
for(; x < x_start + n - offset; x++){
array[count++] = matrix[y][x];
}
// 右侧,从上到下
for(; y < y_start + m - offset; y++){
array[count++] = matrix[y][x];
}
// 底部,从右到左
for(; x > x_start; x--){
array[count++] = matrix[y][x];
}
// 左侧,从下到上
for(; y > y_start; y--){
array[count++] = matrix[y][x];
}
x_start++; // 每经过一圈,起始坐标都往右下角移动一个单元
y_start++;
offset += 2; // offset需要+2
circle--;
}
// 如果m=n=奇数,只需要对最后一个元素进行处理
if(m == n && n % 2 == 1){
array[count++] = matrix[y_start][x_start];
}
else if(n != m && min(m, n) % 2 == 1){
if(n == min(m, n)){ // 如果最小值是n,那么最后需要处理的元素的坐标是垂直向下走的
while(specialNum > 0){ // 需要特殊处理的数量
array[count++] = matrix[y_start][x_start];
y_start++;
specialNum--;
}
}
else if(m == min(m, n)){
while(specialNum > 0){ // 如果最小值是m,那么最后需要处理的元素的坐标是水平向右走的
array[count++] = matrix[y_start][x_start];
x_start++;
specialNum--;
}
}
}
return array;
}
};
六、总结
数组是存放在连续内存空间的相同类型数据的集合。
数组可以方便的通过下标索引的方式获取下标对应的数据。需要注意的两点:
- 数组下标都是从0开始的
- 数组内存空间的地址是连续的
正因为数组的内存空间的地址是连续的,所以我们在删除或者添加一个元素的时候,就难免要移动其他元素的地址。
数组的元素是不能删除的,只能覆盖。
那么二维数组 int [3][4] 在内存中的空间地址是连续的吗?二维数组在内存中不是 3 * 4 的连续地址空间,而是 3 条连续的地址空间组成。
二分法 —> 双指针 —> 滑动窗口 —> 模拟行为
更详细的总结见:数组基础总结(代码随想录)