数组基本知识
1.数组是存放在连续内存空间上的相同类型数据的集合。
2.数组内存空间的地址是连续的。
3.数组元素是不能删的,只能覆盖。
4.c++中要注意vector 和 array的区别,vector的底层实现是array,严格来讲vector是容器,不是数组。
vector和array的区别
vector
是 C++ 标准库中的一个容器,其底层实现基于动态数组。array
是一种静态数组,它的大小在编译时就已经确定了。下面是这两者之间的一些主要区别:
- 大小:
array
在声明时需要指定大小,而vector
是动态的,可以在运行时调整大小。 - 内存分配:
array
是静态分配的,内存分配在栈上,而vector
是动态分配的,内存分配在堆上。 - 成员函数:
vector
作为标准库的容器类,提供了很多实用的成员函数,例如push_back()
,pop_back()
等。而array
没有这些成员函数。
区别示例:
#include <iostream>
#include <vector>
#include <array>
int main() {
// 使用 array
std::array<int, 5> arr = {1, 2, 3, 4, 5};
for (int i = 0; i < arr.size(); ++i) {
std::cout << arr[i] << " ";
}
std::cout << std::endl;
// 使用 vector
std::vector<int> vec = {1, 2, 3, 4, 5};
vec.push_back(6); // 添加一个新元素
for (int i = 0; i < vec.size(); ++i) {
std::cout << vec[i] << " ";
}
std::cout << std::endl;
return 0;
}
vector动态拓展的含义
动态扩展:
-
并不是在原空间之后续接新空间,而是找更大的内存空间,然后将原数据拷贝新空间,释放原空间
(因为原有空间后面的内存不能保证是否正在使用)
这种方式使得
vector
能够动态地调整大小以适应数据的变化。然而,这也可能导致一些性能开销,因为在扩展容量时需要拷贝数据和释放内存。为了减少这种开销,可以使用reserve()
成员函数预先分配足够的内存空间,从而减少动态扩展的次数。reserve()
成员函数用法示例:#include <iostream> #include <vector> int main() { std::vector<int> vec1; // 不预分配内存,可能会导致多次扩展 for (int i = 0; i < 10; ++i) { vec1.push_back(i); } // 输出 vec1 的内容 for (int i = 0; i < vec1.size(); ++i) { std::cout << vec1[i] << " "; } std::cout << std::endl; std::vector<int> vec2; // 预先分配足够的内存空间,减少动态扩展次数 vec2.reserve(10); for (int i = 0; i < 10; ++i) { vec2.push_back(i); } // 输出 vec2 的内容 for (int i = 0; i < vec2.size(); ++i) { std::cout << vec2[i] << " "; } std::cout << std::endl; return 0; }
补充:vector容器内部结构图:
常用迭代器是v.begin()
和v.end()
.
- vector容器的迭代器是支持随机访问的迭代器
内存地址空间连续性
在C++中二维数组是连续分布的。也就是二维数组的内存空间地址是连续的。
补充:内存地址相关
0x7ffee4065820
与 0x7ffee4065824
差了一个4,就是4个字节,因为这是一个int型的数组,所以两个相邻数组元素地址差4个字节。
0x7ffee4065828
与 0x7ffee406582c
也是差了4个字节,在16进制里8 + 4 = c,c就是12。
704.二分查找
关于二分查找法维基百科给出的定义是:
二分查找法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
由于每次查找都会将查找范围缩小一半,因此二分查找的时间复杂度是 O(log n)
,其中 n 是数组的长度。
题目分析
前提条件
二分法的前提条件:
1.数组是有序数组
2.数组中没有重复元素
必须满足如上条件才能用二分法。
边界条件
两个边界条件:
1.left<right
or left <=right
?
2.[middle]>target的时候,此时应该更新右边界(升序数组),那么right = middle
or right = middle-1
?
注意:区间的定义就是不变量
两种写法:
1.左闭右闭
-
在闭区间中,left=right是可以实现的。因此此处使用<=。
-
判断的时候包括了middle值,所以查找区间变为middle-1。
class Solution {
public:
int search(vector<int>&nums,int target){
int left = 0;
int right = nums.size()-1; //左闭的写法,下标是size()-1,下标从0开始,[left,right]
while(left<=right){//边界条件注意,左闭右闭,可以相等
int middle = left + ((right-left)/2); //为了防止溢出
if(nums[middle]>target){
right = middle-1; //区间变为[left,middle-1]
}
else if(nums[middle]<target){
left = middle+1;//区间变为[middle+1,right]
}
else{
return middle; //注意是返回下标而不是元素值
}
}
//如果没找到
return -1;
}
};
2.左闭右开
需要注意的点写在注释里
class solution{
int search(vector<int>&nums,int target){
int left = 0;
int right = nums.size();//区间[left,right)
while(left<=right-1){
int middle = left +((right-left)/2);
//注意:左闭右开写法不包含right数值,也就是说下一个搜索的左区间不包含Middle数值。
//因此right=middle即可。本来区间就没有包含middle
if(nums[middle]>target){
right = middle;
}
//左闭右开写法,是包含左区间的。此时middle已经不是要找的值了,因此left要避开middle
else if(nums[middle]<target){
left = middle+1;
}
else{
return middle; //return之后直接结束循环
}
}
return -1;
}
};
vector容器right边界问题
注意:
vector.size()
函数,返回的是容器中元素的个数。但是,容器中元素的下标是元素个数-1。因为下标是从0开始的。
因此,闭合的右边界的写法应该为**right = nums.size()-1
**。
vector.size()
函数返回的是vector
容器中元素的个数,而不是元素个数+1。当你访问一个vector
的元素时,下标从0开始,因此最后一个元素的下标是**vector.size() - 1
**。这就是为什么我们需要用right = nums.size() - 1
来初始化右边界的原因。
溢出问题
Q:取中间值min的公式不应该是(left+right)/2吗?为什么是left+(right-left)/2呢?
A:两个是数学上的恒等式,在计算机里可能两个int相加会溢出,先减除后加确实可以用于更大的数组。主要是为了防止换了条件之后,left+right过大导致溢出。
补充:整型溢出与最大值
为什么整型会产生溢出?什么情况下会溢出?
整型溢出是因为计算机中整数的表示有限。在计算机中,整数通常以固定数量的比特(位)表示,例如 32 位或 64 位。当进行整数运算时,如果结果超出了这个范围,那么就会发生溢出。整型溢出可能导致结果不正确或程序行为异常。
以下是几种可能导致整型溢出的情况:
- 相加:两个较大的正整数相加时,结果超出整数的最大值。
- 相减:一个较小的负整数从正整数中减去时,结果超出整数的最小值。
- 相乘:两个较大的整数相乘时,结果超出整数的最大值或最小值。
- 相除:整数除以零时(虽然这通常不被认为是溢出,但仍可能导致程序崩溃或异常行为)。
#include <iostream>
#include <limits>
int main() {
int max_int = std::numeric_limits<int>::max(); // 获取 int 类型的最大值
int min_int = std::numeric_limits<int>::min(); // 获取 int 类型的最小值
int result1 = max_int + 1; // 加法溢出:结果应该大于最大整数,但实际上会变成最小整数
int result2 = min_int - 1; // 减法溢出:结果应该小于最小整数,但实际上会变成最大整数
std::cout << "max_int: " << max_int << std::endl;
std::cout << "min_int: " << min_int << std::endl;
std::cout << "result1 (max_int + 1): " << result1 << std::endl; // 输出最小整数
std::cout << "result2 (min_int - 1): " << result2 << std::endl; // 输出最大整数
return 0;
}
在这个示例中,我们首先获取 int
类型的最大值和最小值。然后,我们尝试将最大整数加 1,以及将最小整数减 1。这两种操作都会导致整型溢出,分别变成最小整数和最大整数。
64位计算机,整型最大值是多少?整型最大值和什么有关系?
对于 64 位计算机,整数通常以 64 位表示。但是,C++ 标准并没有规定 int
类型的大小必须为 64 位。通常情况下,int
在 64 位计算机上仍然是 32 位,而 long long
类型是 64 位。这里,我们假设您想了解 64 位整数的最大值。
一个 64 位有符号整数的最大值是 2^63 - 1
,即 9,223,372,036,854,775,807
。一个 64 位无符号整数的最大值是 2^64 - 1
,即 18,446,744,073,709,551,615
。
整型的最大值与其位数有关。一个 n 位的有符号整数最大值为 2^(n-1) - 1
,而无符号整数的最大值为 2^n - 1
。这是因为有符号整数的最高位用于表示符号(0 表示正,1 表示负),因此只有 n-1 位用于表示数值。而无符号整数的所有 n 位都用于表示数值。
时间复杂度与空间复杂度
本题目中的时间复杂度是O(logn)
。
解释:
由于二分查找的基本思路是将有序数组分成两部分,比较中间值与目标值,然后确定目标值位于哪一个子区间。每次循环,我们都会将搜索区间减半,从而在log2(n)
次循环后,搜索区间将缩小到1(当搜索区间中只剩下一个元素时,log2(1) = 0
,搜索结束)。
在这段代码中,维护一个左闭右闭的区间[left, right],初始时包含整个数组。每次循环,我们计算中间下标middle,并比较nums[middle]
与目标值target。根据比较结果,我们可以确定目标值位于左区间([left, middle - 1])或右区间([middle + 1, right])。
**每次循环,搜索区间的长度都会减半,**因此总共需要O(logn)
次循环。所以,这段代码的时间复杂度为O(logn)
。
本题目空间复杂度是O(1)。
解释:
在这段代码中没有使用额外的数据结构来存储数据。所有变量(left、right、middle、target)都是在原始输入数组之外的常量空间,所以它们不会随着输入数组大小的增加而增加。
具体来说:
- 不需要额外的数据结构来存储输入数组的部分数据,因此不存在与输入数据规模相关的存储需求。
- 代码中所有变量的生命周期都是在函数内部,而且它们的内存需求是固定的,不会随着输入规模的增加而增加。
因此,这段代码的空间复杂度为 O(1)。
二刷记录
class Solution {
public:
int search(vector<int>& nums, int target) {
int left=0;
int right = nums.size()-1; //左闭右闭写法
while(left<=right){//根据用例判断
int middle = left+(right-left)/2;
//左闭右开写法不包括right数值,因此直接right=middle即可
if(nums[middle]>target){
//大于说明target一定在左侧区间,升序
right = middle-1;
}
else if(nums[middle]<target){
left = middle+1;
}
else
return middle;
}
//如果没找到
return -1;
}
};
27.移除元素
数组内存
由于数组的元素在内存地址中是连续的,因此不能单独删除数组中的某个元素,只能进行覆盖。
c++中,删除了中间元素之后,实际上最后一个空间是空着的,只不过没有做处理而已。
例:删除一个元素之后,最开始的**size()
是5,删除后会变成4。但是并不代表这个空间真的变成了4**,而是计数器,调用了erase之后默认做了–操作,大小返回4,但是实际上物理空间中,最后一个元素仅仅是没有做处理而已。也就是说物理空间是没有释放的。
vector容器接口中,erase()
函数是时间复杂度O(n)
的操作,是要把后面元素整体前移,做覆盖操作的。并不是O(1)。
(注意刷题的时候如果库函数一行就解决了,那就不要用库函数)
题目分析
本题目其实就是实现erase函数的删除过程。元素是不能删除的,只能覆盖。所以暴力解法的思路是for循环遍历数组,遇到了需要删除的元素时,再开一个for循环把后面的元素一个一个向前覆盖。
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]==al){ //发现移除元素之后,后面的元素全部整体前移
for(int j=i+1;j<size;j++){
nums[j-1]=nums[j]; //覆盖操作,注意这里不能是nums[i]=nums[j]
}
i--;//因为是移除所有的数值为val的元素,所以i还需要倒回去检查有没有其他的元素,且此时原来的nums[i]已经被移除了,有了新的nums[i],需要重新检查
//carl给出的解释:因为下标i以后的数值都向前移动了一位,所以i也向前移动一位
size--;
//数组大小对应-1
}
}
return size;//全部遍历结束后,返回数组大小
}
}
注意:覆盖后需要进行i–操作
假设当前位置为i
,我们发现nums[i]
等于val
,然后我们将数组中后面的元素向前移动一位。此时,原来位于i+1
位置的元素已经移到了i
位置。因此,我们需要再次检查i
位置的元素是否等于val
。为了实现这一点,需要使i
保持不变,然后进入下一次循环。在循环的末尾,i
会自增1,所以为了下次循环中仍然检查i
位置的元素,需要i--
操作。
如果不执行i--
,在发现nums[i]
等于val
并将后面的元素向前移动之后,i
会直接增加1,可能导致跳过i+1
位置的元素在向前移动后检查i
位置的元素。这可能导致某些等于val
的元素没有被正确处理。
时间复杂度
暴力解法时间复杂度为O(n^2)。
空间复杂度为O(1)。
2.双指针法
利用双指针思路,节省for循环次数。通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。
-
快指针:寻找新数组的元素 ,新数组就是不含有目标元素的数组
-
慢指针:指向更新 新数组下标的位置
双指针法(快慢指针法)在数组和链表的操作中是非常常见的,很多考察数组、链表、字符串等操作的面试题,都使用双指针法。
代码思路:
**当快指针指向的值,不等于需要删除的元素的时候,就更新新数组;**也就是,**把快指针获取的值,赋给慢指针所在的位置。**但是他们都在一个数组上进行操作,因此不违反题目规则。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O1bRhVub-1683721940684)(F:\C++\笔记\算法刷题.assets\image-20230510170916467.png)]
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){
//slowIndex++; //得到移除之后的长度
nums[slowIndex++]=nums[fastIndex] ; //进行原地修改
//此处为slowIndex++之后才与快指针相等
//注意,本题目中slowInsex慢指针即为移除后的数组长度,返回慢指针数值即可
}
}
return slowIndex;
}
};
注意点:
1.注意 nums[slowIndex++]=nums[fastIndex]
这句代码,是先将nums[fastIndex]
的值赋给nums[slowIndex]
,然后再将slowIndex
加一。
在 C-style 语言(如 C、C++、Java 等)中,对于前缀运算符(如 ++
),会先执行运算符再执行变量赋值操作;对于后缀运算符(如 nums[slowIndex++]
),会先执行变量赋值操作再执行运算符。因此本代码中nums[slowIndex++]
会先执行nums[slowIndex]
的赋值,再对slowIndex
进行++操作。
2.以上写法是元素相对位置不变的写法
时间复杂度
- 时间复杂度: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[fastIndex];
slowIndex++;
}
}
//return nums.size();//一定要注意这里不能返回nums.size(),因为实际上vector的内存没有释放!
return slowIndex;
}
};
二刷注意:
在这道题目中,目标是返回移除指定元素后的数组长度,并且要在原地修改数组。虽然我们通过双指针的方法实现了元素的移除,但实际上 vector
的容量和大小并没有改变。
为了确实改变 vector
的大小,可以使用 nums.resize(slowIndex)
。但是这个操作在本题中并不是必要的,因为题目只要求返回新的长度,而不要求真正地调整 vector
的大小。
基于可以移动元素相对位置的另一种写法
最左侧一个指针,最右侧一个指针,逐渐向中间逼近,这种写法确保了移动更少的元素
class Solution {
public:
int removeElement(vector<int>& nums, int val) {
int leftIndex=0; //从最左侧开始
int rightIndex=nums.size()-1;//从最右侧开始
while(leftIndex<=rightIndex){
//找到左侧区间中,与val相等的下标
while(nums[leftIndex]!=val){
++leftIndex;
}
//找到右侧区间中,与val不相等的下标
while(nums[rightIndex]==val){
--rightIndex;
}
//用右侧区间与val不等的数据,覆盖左边val相等的数据,达到去除val的效果
if(leftIndex<rightIndex){
nums[leftIndex++]=nums[rightIndex--];//注意rightIndex是从最右边开始的,所以是--
}
}
return leftIndex;
//注意,此处leftIndex一定指向了最终数组末尾的下一个元素
}
};
注意点和问题:
1.最终得到的数组长度就是leftInsex
,原因是leftIndex
一定指向了最终数组末尾的下一个元素,而不是数组末尾最后一个元素本身。
当两个指针相遇时,循环结束。此时,leftIndex
指向的是最后一个不为 val
的数字的后一个位置,也就是说,它指向了数组最后一个元素的下一个位置。
2.如果你只在while大循环里写了条件1,那么你需要在小循环中用&&把条件1和条件2都列出,否则可能会出现逻辑错误。例如:
// while大循环里只写了条件1
while (condition1) {
// do something
// 小循环中用&&把条件1和条件2都列出
while (condition1 && condition2) {
// do something else
}
}
小循环必须重复大循环condition1
的原因:
在本例中,即使使用for
循环,由于问题本身的特性,我们仍然需要在内部循环中检查大循环的条件leftIndex <= rightIndex
。这是因为在内部循环中,leftIndex
和rightIndex
的值会发生变化。
也就是说,这道题目是因为leftIndex
和rightIndex
的值会发生变化,所以需要小循环里再次检查,但是实际上的while循环不一定是所有情况都需要重复大循环的条件。
在这个特定问题中,由于leftIndex
和rightIndex
的值会在内部循环中发生变化,为了确保循环的正确性,需要在内部循环的条件中再次检查大循环的条件leftIndex <= rightIndex
。这样可以避免数组越界访问等潜在的错误。
然而,并非所有情况下的while
循环都需要在内部循环中重复大循环的条件。这取决于具体问题和循环逻辑。在某些情况下,内部循环可以独立于外部循环运行,而无需考虑外部循环的条件。在其他情况下,可能需要根据外部循环的状态来调整内部循环的条件,以确保循环的正确性。