• C++ 炼气期之数组探幽


    1. 数组概念

    变量是内存中的一个存储块,大小由声明时的数据类型决定。

    数组可以认为是变量的集合,在内存中表现为一片连续的存储区域,其特点为:

    • 同类型多个变量的集合。
    • 每一个变量没有自己的名字。
    • 数组会为每一个变量分配一个位置编号 。
    • 可以通过变量在数组中的位置编号(下标)使用变量。

    C++中称数组为复合类型,复合类型指除了基本类型之外或通过基本类型组合而成的新类型。如类、结构体、枚举……

    1.png

    数组是一种数据结构,与栈、队列、树、图……这类数结构不同,数组是实体数据结构,有自己的物理内存描述。栈、队列、树……是抽象数据结构,或者说是一种数据存储思想,没有对应的物理存储方案,需开发者自行设计逻辑存储方案。

    什么时候使用数组?

    在需要保存大量同类型数据的应用场景下可以考虑选择数组。因数组中的变量是相邻的,如同一条藤上的瓜(顺藤摸瓜),访问起来非常方便快捷。

    大部分抽象数据结构的底层都可借助数组来实现。

    连续存储的优点一眼可知,但是连续也会带来新的问题,程序运行过程中,会产生内存碎片,当数组需要的空间较大时,底层逻辑可能无法腾出一大片连续空间来。

    当需要考虑充分利用空间时,链表当是首选。

    下面,通过数组的使用流程,让我们全方面了解数组。

    2. 创建数组

    根据数组在内存中的存储位置,有 2 种创建方式:

    • 静态创建。
    • 动态创建。

    2.1 静态创建

    2.1.1 语法

    创建数组时需要指定 3 方面信息:

    • 数组名数组名就是变量名,只是这个名称指的是一片连续区域。
    • 数据类型:数组用来保存什么样类型的数据。
    • 数组长度:编译器需要根据数组大小开辟空间。
    int num[10]; 
    

    如上语法,创建了可以存储 10 个整型数据的数组。

    创建数组后,怎么访问数组中的变量?

    编译器会为数组中的 10int 存储块从 0开始编号。编号从 0开始,到数组长度-1结束,编号也称为下标。如果需要访问数组中的第一个变量中的数据,则如下代码可实现:

    int num[10]; 
    cout<<num[0]<<endl;
    

    正因为数组的下标属性,数组通常借助循环语法结构进行整体遍历。

    创建数组后是否存在数据?

    遍历一次数组,便可以看到数组中所有的数据信息。

    int num[10]; 
    for(int i=0;i<10;i++){
    	cout<<num[i]<<endl;
    }
    

    输出结果可能会让你摸不着头。这是啥意思?

    1
    0
    4254409
    0
    0
    0
    34
    0
    0
    0
    

    创建数组后,数组中会有数据信息,是内存中相应位置曾经存储过的或由编译器随机生成的数据。对于创建数组初衷(保存自己的数据)的你而言,这都是垃圾数据

    随机数据:每一次运行上述代码,结果可能都不一样。

    所以,必须对数组进行初始化,这样数组中的数据才会有意义。

    2.1.2 初始化

    初始化指创建数组后为数组中的变量指定初始值。

    初始化语法:

    • 创建后通过循环语法结构赋值。
    int num[10];
    for(int i=0; i<10; i++) {
    	num[i]=i*10;
    }
    
    • 单个变量赋值。
    int num[10];
    num[0]=10;
    
    • 边创建边赋值,{}符号可以用来表示数组字面常量。
    //正确
    int num[10]={1,3,4,9};
    

    在赋值时,实际指定的值可以少于数组的长度。如果反过来,如下代码则行不通。

    //错误
    int num[3]={1,3,4,9}; 
    

    上述赋值代码,实际值超过数组创建时的长度约束,语法上不能通过。如果边创建、边赋值分成 2 行,也是不行的。如下代码是错误的。

    int num[3];
    //错误
    num=={1,3,4,9};
    

    如下代码,省略数组长度也是可以的,编译器会根据给定的值判断出数组长度。

    int num[]={1,3,4,9};
    
    • 全部初始化为 0。如下代码,初始化时只指定一个值且为 0 时,这里的语义不是指给数组中的第一个变量赋值,而是为数组中的所有变量指定初始值为 0
    int num[5]={0}; 
    //输出数组所有值
    for(int i=0; i<5; i++) {
    	cout<<num[i]<<endl;
    }
    

    输出结果:

    0
    0
    0
    0
    0
    

    如果用下面的代码进行初始化,语义是:数组的第一个变量赋值为 1,其余变量赋值都为 0

    int num[5]={1,0}; 
    for(int i=0; i<5; i++) {
    	cout<<num[i]<<endl;
    }
    

    输出结果:

    1
    0
    0
    0
    0
    

    理解上述语法的语义后,以此类推,对于下面的代码,想必很容易猜到输出结果:

    int num[5]={1,2,0}; 
    for(int i=0; i<5; i++) {
    	cout<<num[i]<<endl;
    }
    
    • C++11中提供更清晰、简洁、安全的初始化语法。如下语法,是不是很简洁、惊艳。数组和{}之间可以不用等于号,太体贴了,生怕你多敲一个字母,会手痛。且为数组中的每一个变量赋值 0。没有多余的废话。
    int num[5] {};
    for(int i=0; i<5; i++) {
    	cout<<num[i]<<endl;
    }
    

    当然,你一定要加一个等于号让代码符合你曾经的认知也是可以的。

    int num[5]= {};
    

    除此之外,对数组初始化时,禁止类型宿窄转换。如下代码,会有编译警告提示,2.5是浮点类型,存储存到 int类型数组中,是类型缩窄。C++11是禁止的。

    int num[5] ={3,2.5};
    

    2.1.3 越界问题

    C++中使用数组,没有访问越界一说。所谓访问越界,指下标超过数组创建时指定的大小范围。

    越界在Java语言中认定是语法错误。

    int num[5];
    //理论是越界的
    num[6]=20;
    for(int i=0; i<7; i++) {
        //输出了 7 个数据
    	cout<<num[i]<<endl;
    }
    

    上述代码,创建数组时,确定了数组长度为 5,其有效下标应该是0~4。但 num[6]=20能正确执行且循环输出时居然能得到 20

    2.png

    0
    0
    34
    0
    0
    0
    20
    

    C++并不会阻止你的访问超过数组边界,但是,开发者需要从源头上切断这种行为。类似于相邻两家,关系很好,相互之间不设阻隔墙,但不意味着你能随意出入对方家里。

    2.2.4 数组与指针

    数组在内存中的存储结构有 2 个部分:

    • 存储数组数据的内存区域。
    • 存储数组首地址的内存变量。

    3.png

    数组名本质是指针变量,保存着数组的首地址,也是第一个存储位置。

    int num[5]={4,1,8,2,6};
    cout<<"数组的地址:"<<num<<endl;
    //输出结果:
    //数组的地址:0x70fe00 16进制描述的内存地址
    

    如果要得到数组第一个位置的数据,则需要使用*运算符。

    int num[5]={4,1,8,2,6};
    cout<<"数组中的第一个位置的数据:"<<*num<<endl;
    //数组中的第一个位置的数据:4
    

    除了使用*运算符,还可以使用[下标]语法。两者语法上有差异,但是语义是一样的。可以认为[下标]访问语法是指针访问语法的简化版。

    int num[5]={4,1,8,2,6};
    cout<<"数组中的第一个位置的数据:"<<num[0]<<endl;
    //数组中的第一个位置的数据:4
    

    如果要访问其它位置的数据,可以通过移动指针实现。

    Tip: num+1可以让指针移到数组的下一个变量位置。这里的1具体移动多少,由创建数组时指定的数据类型决定,如本文数组是 int类型,1便是移动 4 字节

    int num[5]={4,1,8,2,6};
    cout<<"数组中第二个位置的数据:"<<*(num+1)<<endl;
    //数组中第二个位置的数据:1
    

    当然,完全可以使用[下标]替代。

    int num[5]={4,1,8,2,6};
    cout<<"数组中第二个位置的数据:"<<num[1]<<endl;
    

    指针是C++语言的一大特色,能够让开发者直接操作内存地址(属于直接硬件操作),正因为此原因,编译器无法干涉,所以指针移动的范围只受限于物理内存大小的影响。如下代码能正常运行。

    int num[5]={4,1,8,2,6};
    //完全超界,但人家就是能运行 ,指针的手能伸到天涯海角
    for(int i=0;i<1000000;i++){
    	cout<<*(num+i)<<endl;
    }
    

    了解指针的特性后,也就不会奇怪为什么访问数组时能够越界。使用指针时务必谨慎,需要靠个人行为对之约束。

    2.2.5 小结

    通过静态创建语法创建的数组,称为静态数组,其特点如下:

    • 编译时,就需要为数组指定大小,或说数组大小在编码时就必须给定。
    • 静态创建数组时不能使用 auto关键字。
    //错误语法
    auto  num[5];
    
    • 数组名中保存有数组大小的信息。如下代码可以获取到数组长度。
    int num[10];
    //sizeof得到num实际占用的内存空间,以字节为单位
    int len=sizeof(num)/4;
    cout<<len;
    //输出:10
    
    • 静态数组的数据保存在中,在编译期间进行空间分配,在生命周期结束后自动回收。

    2.2 动态创建

    动态创建:指数组的大小可以在运行时动态指定,除此之外,和静态创建的底层区别在于存储位置的不同,动态创建的数组的数据存储在堆中。

    堆的特点:

    • 开发者可以根据自己的需要提出空间使用申请。
    • 当空间不再需要时,开发者需要手动释放空间。

    先看一下创建语法:

    int *num=new int[10];
    

    代码解释:

    • num是指针变量,用来保存数组的首地址。
    • new是运算符,其作用是在堆中开辟空间,并把空间的首地址返回。
    • int[10],指开辟空间的大小,以及保存什么类型的数据。

    num是首地址,也是数组中第一个位置的地址。

    int *num=new int[10];
    //初始化第一个位置的数据
    *num=10;
    cout<<"第一个位置的数据:"<<*num<<endl;
    

    如下通过对整个数组进行初始化:

    int *num=new int[10];
    for(int i=0; i<10; i++) {
    	*(num+i)=i*10;
    }
    for(int i=0; i<10; i++) {
    	cout<<*(num+i)<<endl;
    }
    

    输出结果:

    0
    10
    20
    30
    40
    50
    60
    70
    80
    90
    

    同样可以使用[下标]语法结构,对访问数组。

    int *num=new int[10];
    for(int i=0; i<10; i++) {
    	num[i]=i*10;
    }
    for(int i=0; i<10; i++) {
    	cout<<num[i]<<endl;
    }
    

    动态数组可以在运行时改变数组的大小。静态创建方式是一锤定音的买卖,一旦确定后,就不能再改变。如下代码是正确的。

    int *num=new int[10];
    num=new int[20];
    

    正因为动态数组的动态性,无法通过代码获得它的长度。

    int *num=new int[10];
    cout<<sizeof(num)/4<<endl; 
    //输出结果:2,并不是数组的长度。
    

    当动态数组的使命结束后,开发者需要使用 delete运算符手动释放数组所占用的空间。

    int *num=new int[10];
    //delete num;语法上可行,会产生不确定行为
    delete [] num;
    

    这里要注意,如果不加[],语法上是没有问题的,但是,会有不确定的因素存在,所以!请务必加[]

    3. 数组性能分析

    得益于数组内存结构的连续性,只要知道数据的位置,便能快速访问到。查询时间度复杂度可以达到O(1)。在查询类的应用场景下,数组存储方案应该成为首选。

    当在数组中插入数据时,需要把数据向后移动为插入的数据挪出位置,且需要在创建数组时预留足够多的空间,否则会有数据丢失的风险。

    //最后一位为预留位置
    int num[10]= {1,2,3,4,5,6,7,8,9,0};
    for(int i=0; i<10; i++) {
    	cout<<num[i]<<"\t";
    }
    cout<<endl;
    int newNum=0;
    int pos=0;
    cout<<"请输入要插入的数据:"<<endl;
    cin>>newNum;
    cout<<"请输入要插入的位置:"<<endl;
    cin>>pos;
    //从插入位置的数据向后移动
    for(int i=9; i>pos-1; i--) {
    	num[i]=num[i-1];
    }
    num[pos-1]=newNum;
    for(int i=0; i<10; i++) {
    	cout<<num[i]<<"\t";
    }
    

    运行结果:

    1       2       3       4       5       6       7       8       9       0
    请输入要插入的数据:
    13
    请输入要插入的位置:
    5
    1       2       3       4       13      5       6       7       8       9
    

    删除数据时,需要把数据删除位置之后的数据向删除位置移动(向前)。

    int num[10]= {1,2,3,4,5,6,7,8,9,10};
    for(int i=0; i<10; i++) {
    	cout<<num[i]<<"\t";
    }
    cout<<endl;
    int pos=0;
    cout<<"请输入要删除的位置:"<<endl;
    cin>>pos;
    //从插入位置的数据向前移动
    for(int i=pos-1; pos<8; i++) {
    	num[i]=num[i+1];
    }
     //最后一位补 0
    num[9]=0;
    for(int i=0; i<10; i++) {
    	cout<<num[i]<<"\t";
    }
    

    运行结果:

    1       2       3       4       5       6       7       8       9       10
    请输入要删除的位置:
    4
    1       2       3       5       6       7       8       9       10      0
    

    在数组中插入、删除数据的时间复杂度为O(n)

    在频繁需要插入、删除的应用场景下,可以选择比数组性能更好的链表。

    4. 总结

    本文介绍了数组的 2 种创建方式,并对数组的操作性能做了简单的分析。数组遍历时是通过底层的指针移动来实现的。指针是C系列语言的一大特点,是一把双刃剑,用的好能御敌千里之外,用的不好!bug满天飞。

  • 相关阅读:
    C++学习笔记——C++ deque和vector的区别
    基于Boost的搜索引擎
    2023大湾区杯粤港澳金融数学建模竞赛思路+模型+代码
    springboot+mybatis-plus
    HTML 标签简写及全称
    无代码开发成员入门教程
    (杭州中科微)全星座定位导航模块GM36的应用推荐及性能指标解析
    南大通用GBase8s 常用SQL语句(241)
    python版本微信每日图文推送------天气预报
    dubbo-admin安装
  • 原文地址:https://www.cnblogs.com/guo-ke/p/16408785.html