DAY1:二分查找+移除元素

数组基本知识

1.数组是存放在连续内存空间上的相同类型数据的集合。

2.数组内存空间的地址是连续的。

3.数组元素是不能删的,只能覆盖。

4.c++中要注意vector 和 array的区别,vector的底层实现是array,严格来讲vector是容器,不是数组。

vector和array的区别

vector 是 C++ 标准库中的一个容器,其底层实现基于动态数组array 是一种静态数组,它的大小在编译时就已经确定了。下面是这两者之间的一些主要区别:

  1. 大小:array 在声明时需要指定大小,而 vector 是动态的,可以在运行时调整大小。
  2. 内存分配:array静态分配的,内存分配在栈上,而 vector动态分配的,内存分配在堆上
  3. 成员函数: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++中二维数组是连续分布的。也就是二维数组的内存空间地址是连续的

补充:内存地址相关

0x7ffee40658200x7ffee4065824 差了一个4,就是4个字节,因为这是一个int型的数组,所以两个相邻数组元素地址差4个字节。

0x7ffee40658280x7ffee406582c 也是差了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 位。当进行整数运算时,如果结果超出了这个范围,那么就会发生溢出。整型溢出可能导致结果不正确或程序行为异常。

以下是几种可能导致整型溢出的情况:

  1. 相加:两个较大的正整数相加时,结果超出整数的最大值。
  2. 相减:一个较小的负整数从正整数中减去时,结果超出整数的最小值。
  3. 相乘:两个较大的整数相乘时,结果超出整数的最大值或最小值。
  4. 相除:整数除以零时(虽然这通常不被认为是溢出,但仍可能导致程序崩溃或异常行为)。
#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)都是在原始输入数组之外的常量空间,所以它们不会随着输入数组大小的增加而增加。

具体来说:

  1. 不需要额外的数据结构来存储输入数组的部分数据,因此不存在与输入数据规模相关的存储需求。
  2. 代码中所有变量的生命周期都是在函数内部,而且它们的内存需求是固定的,不会随着输入规模的增加而增加

因此,这段代码的空间复杂度为 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。这是因为在内部循环中,leftIndexrightIndex的值会发生变化

​ 也就是说,这道题目是因为leftIndexrightIndex的值会发生变化,所以需要小循环里再次检查,但是实际上的while循环不一定是所有情况都需要重复大循环的条件。

​ 在这个特定问题中,由于leftIndexrightIndex的值会在内部循环中发生变化,为了确保循环的正确性,需要在内部循环的条件中再次检查大循环的条件leftIndex <= rightIndex。这样可以避免数组越界访问等潜在的错误。

​ 然而,并非所有情况下的while循环都需要在内部循环中重复大循环的条件。这取决于具体问题和循环逻辑。在某些情况下,内部循环可以独立于外部循环运行,而无需考虑外部循环的条件。在其他情况下,可能需要根据外部循环的状态来调整内部循环的条件,以确保循环的正确性。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值