数组理论基础
数组是存放在连续内存空间上的相同类型数据的集合。数据可以方便的通过下标索引的方式获取到下标对应的数据。
需要注意两点:
- 数组下标都是从0开始的。
- 数组内存空间的地址是连续的,二维数组的地址也是连续的。
正因为数组在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。如果使用C++的话,要注意vector与array的区别,vector的底层实现是array,严格来讲vector是容器,不是数组。数组的元素是不能删的,只能覆盖
![image-20221107103441603](https://uptolimit.top/img/image-20221107103441603.png)
二分查找
题目:给定一个 n 个元素有序的(升序)整型数组 nums
和一个目标值 target
,写一个函数搜索nums
中的 target
,如果目标值存在返回下标,否则返回 -1
示例1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
提示:
- 你可以假设
nums
中的所有元素是不重复的。 n
将在[1, 10000]
之间。nums
的每个元素都将在[-9999, 9999]
之间。
当看到这些关键词:数组有序、无重复元素,可以考虑是不是可以用二分法了,因为一旦有重复元素,使用二分法查找返回的元素下标可能就不是唯一的。
二分查找设计到的边界条件比较简单,但是容易搞混,比如是while(left < right)
还是while(left <= right)
,是right = middle
还是right = middle - 1
呢?
根据循环不变量规则每一次边界的处理都要坚持根据区间的定义来操作。在二分法中,区间的定义一般为两种,左闭右闭即 [ l e f t , r i g h t ] [left, right] [left,right],或者左闭右开即 [ l e f t , r i g h t ) [left, right) [left,right)。根据区间定义的不同,可以把二分法分为两种:
第一种写法
要查找的目标target
在左闭右闭的区间内,也就是
[
l
e
f
t
,
r
i
g
h
t
]
[left,right]
[left,right],区间的定义决定了二分法的代码如何写:
while (left <= right)
要使用<=
,因为left == right
是有意义的,所以要使用<=
if (nums[middle] > target)
时right
要赋值为middle - 1
,因为当前这个nums[middle]
一定不是target
所以接下来要查找的左区间结束下标位置就是middle - 1
。
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;
}
};
第二种写法
要查找的目标target
在左闭右开的区间内,也就是
[
l
e
f
t
,
r
i
g
h
t
)
[left, right)
[left,right),那么二分查找的边界处理方式则截然不同。有如下两点:
while (left < right)
,这里使用<
,因为left == right
在区间 [ l e f t , r i g h t ) [left,right) [left,right)是没有意义的if (nums[middle] > target)
时,right
更新为middle
,因为当前nums[middle]!=target
时,需要继续去左区间查找,而寻找区间为左闭右开区间,下一个查询区间不会去比较nums[middle]
,所以right
更新为middle
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;
}
};
移除元素
题目:给你一个数组 nums
和一个值 val
,你需要 原地 移除所有数值等于 val
的元素,并返回移除后数组的新长度。
不要使用额外的数组空间,你必须仅使用 O ( 1 ) O(1) O(1) 额外空间并 原地 修改输入数组。
元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。
示例1:
**输入**:nums = [3,2,2,3], val = 3
**输出**:2, nums = [2,2]
**解释**:函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。你不需要考虑数组中超出新长度后面的元素。例如,函数返回的新长度为 2 ,而 nums = [2,2,3,3] 或 nums = [2,2,0,0],也会被视作正确答案。
示例2:
输入:nums = [0,1,2,2,3,0,4,2], val = 2
输出:5, nums = [0,1,4,0,3]
解释:函数应该返回新的长度 5, 并且 nums 中的前五个元素为 0, 1, 3, 0, 4。注意这五个元素可为任意顺序。你不需要考虑数组中超出新长度后面的元素。
数据的元素在内存地址中是连续的,不能单独删除数组中的某个元素,只能覆盖。
暴力解法
这个题目暴力的解法就是两层 for 循环,一个 for 循环遍历数组元素 ,第二个 for 循环更新数组。暴力解法的时间复杂度是 O ( n 2 ) O(n^2) O(n2),空间复杂度是 O ( 1 ) O(1) O(1)
// 时间复杂度:O(n^2)
// 空间复杂度:O(1)
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--; // 因为下标i以后的数值都向前移动了一位,所以i也向前移动一位
size--; // 此时数组的大小-1
}
}
return size;
}
};
双指针法(快慢指针法)
双指针法:通过一个快指针和慢指针在一个for循环下完成两个for循环的工作
定义快慢指针
- 快指针:寻找新数组的元素,新数组就是不含有目标元素的数组
- 慢指针:指向更新新数组下标的位置
// 时间复杂度: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 (val != nums[fastIndex]) {
nums[slowIndex++] = nums[fastIndex];
}
}
return slowIndex;
}
};
有序数组的平方
题目:给你一个按 非递减顺序 排序的整数数组 nums
,返回 每个数字的平方 组成的新数组,要求也按 非递减顺序 排序。
示例1:
输入:nums = [-4,-1,0,3,10]
输出:[0,1,9,16,100]
解释:平方后,数组变为 [16,1,0,9,100]
排序后,数组变为 [0,1,9,16,100]
示例2:
输入:nums = [-7,-3,2,3,11]
输出:[4,9,9,49,121]
提示:
1 <= nums.length <= 104
-104 <= nums[i] <= 104
nums
已按 非递减顺序 排序
暴力解法
最直观的解法是每个数平方之后,排个序。时间复杂度为 O ( n + n l o g n ) O(n+nlogn) O(n+nlogn),也可以说是 O ( n l o g n ) O(nlogn) O(nlogn)的复杂度。
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;
}
};
双指针法
数组本身是有序的,只是负数平方之后可能成为最大数。因此数组平方的最大值应该在数组的两端,不是最左边就是最右边,此时考虑双指针法,i
指针指向起始位置,j
指针指向终止位置。
定义一个新数组result
,和A
数组一样的大小,让k
指向result
数组的终止位置。
如果A[i] * A[i] < A[j] * A[j]
,那么result[k--] = A[j] * A[j]
如果A[i] * A[i] >= A[j] * A[j]
,那么result[k--] = A[i] * A[i]
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K9qNmg3L-1667820964197)(https://uptolimit.top/img/977.%E6%9C%89%E5%BA%8F%E6%95%B0%E7%BB%84%E7%9A%84%E5%B9%B3%E6%96%B9.gif)]
class Solution {
public:
vector<int> sortedSquares(vector<int>& A) {
int k = A.size() - 1;
vector<int> result(A.size(), 0);
for (int i = 0, j = A.size() - 1; i <= j;) { // 注意这里要i <= j,因为最后要处理两个元素
if (A[i] * A[i] < A[j] * A[j]) {
result[k--] = A[j] * A[j];
j--;
}
else {
result[k--] = A[i] * A[i];
i++;
}
}
return result;
}
};
长度最小的子数组
题目:给定一个含有 n
个正整数的数组和一个正整数 target
。
找出该数组中满足其和 ≥ target
的长度最小的连续子数组[numsl, numsl+1, ..., numsr-1, numsr]
,并返回其长度**。**如果不存在符合条件的子数组,返回 0
。
示例1:
输入:target = 7, nums = [2,3,1,2,4,3]
输出:2
解释:子数组 [4,3] 是该条件下的长度最小的子数组。
示例2:
输入:target = 4, nums = [1,4,4]
输出:1
示例3:
输入:target = 11, nums = [1,1,1,1,1,1,1,1]
输出:0
暴力解法
使用两个for循环,然后不断的寻找复合条件的子序列,时间复杂度为 O ( n 2 ) O(n^2) O(n2),这种方法容易超时
class Solution {
public:
int minSubArrayLen(int s, vector<int>& nums) {
int result = INT32_MAX; // 最终的结果
int sum = 0; // 子序列的数值之和
int subLength = 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 >= s) { // 一旦发现子序列和超过了s,更新result
subLength = j - i + 1; // 取子序列的长度
result = result < subLength ? result : subLength;
break; // 因为我们是找符合条件最短的子序列,所以一旦符合条件就break
}
}
}
// 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
return result == INT32_MAX ? 0 : result;
}
};
滑动窗口
滑动窗口是数组操作中的另一个重要方法。所谓滑动窗口,就是不断地调节子序列的起始位置和终止位置,从而得到我们想要的结果。在暴力解法中由一个for循环滑动窗口的起始位置,一个for循环滑动窗口的终止位置,用两个for循环完成了不断搜索区间的过程。那么如何用一个for循环来完成这些操作呢?
如果使用一个for循环,那么这个循环的索引一定表示滑动窗口的终止位置,那么如何表示滑动窗口的起始位置呢?这里举例target=7
时,数组[2,3,1,2,4,3]
的查找过程:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-t99yBU9u-1667820964198)(https://uptolimit.top/img/209.%E9%95%BF%E5%BA%A6%E6%9C%80%E5%B0%8F%E7%9A%84%E5%AD%90%E6%95%B0%E7%BB%84.gif)]
从动画中可以看出,滑动窗口也是双指针法的一种,在本题中实现滑动窗口,主要确定如下三点:
- 窗口内是什么?
- 如何移动窗口的起始位置?
- 如何移动窗口的终止位置?
答案是:
- 窗口内是满足其和
≥ target
的长度最小的连续子数组 - 如果当前窗口的值大于
target
了,窗口就要向前移动了 - 窗口的终止位置就是遍历数组的指针,即for循环里面的索引
解题的关键在于窗口的起始位置如何移动,可以发现滑动窗口的精妙之处就在于当前子序列和大小的情况,不断调节子序列的起始位置。从而将
O
(
n
2
)
O(n^2)
O(n2)复杂度降为O(n)
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++) {
sum += nums[j];
// 注意这里使用while,每次更新 i(起始位置),并不断比较子序列是否符合条件
while (sum >= s) {
subLength = (j - i + 1); // 取子序列的长度
result = result < subLength ? result : subLength;
sum -= nums[i++]; // 这里体现出滑动窗口的精髓之处,不断变更i(子序列的起始位置)
}
}
// 如果result没有被赋值的话,就返回0,说明没有符合条件的子序列
return result == INT32_MAX ? 0 : result;
}
};
这里不要认为for循环里面放了一个while就认为复杂度为 O ( n 2 ) O(n^2) O(n2),主要是看每一个元素被操作的次数,每个元素在滑动窗口进来一次,出去一次,被操作两次,所以时间复杂度是 O ( 2 n ) O(2n) O(2n)即为 O ( n ) O(n) O(n)
螺旋矩阵II
题目:给你一个正整数 n
,生成一个包含 1
到 n2
所有元素,且元素按顺时针顺序螺旋排列的 n x n
正方形矩阵 matrix
。
示例1:
![img](https://uptolimit.top/img/spiraln.jpg)
输入:n = 3
输出:[[1,2,3],[8,9,4],[7,6,5]]
示例2:
输入:n = 1
输出:[[1]]
螺旋矩阵需要模拟过程,非常考验对代码的掌控能力,本题中我们依然要坚持循环不变量原则模拟顺时针画矩阵的过程:
- 填充上行从左到右
- 填充右列从上到下
- 填充下行从右到左
- 填充左列从下到上
由外向内一圈一圈这么画下去,可以发现这里的边界条件,如果不然固定规则来遍历就会出错。这么一圈画下来,我们画每条边都要坚持一致的左闭右开,或者左开右闭的原则,这里按照左闭右开的原则画一圈:
![img](https://uptolimit.top/img/20220922102236.png)
这里每一种颜色,代表一条边,我们遍历边的长度,可以看出每一个拐角处的处理规则,是让给新的一条边继续画,这也是坚持了每条边左闭右开的原则。
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;
}
};