变量初始化/虚函数/多态
全局静态变量和函数静态变量的初始化顺序?
- 全局静态变量的初始化顺序:
- 在同一个编译单元中,全局静态变量的初始化顺序按照它们在代码中的出现顺序进行;
- 在不同的编译单元中,全局静态变量的初始化顺序是不确定的,取决于编译器和链接器的实现。
- 函数静态变量的初始化顺序:
- 函数内部的静态变量的初始化顺序与它们在函数内部的声明顺序相同;
- 函数内部的静态变量只会在第一次调用函数时初始化,之后的调用不会再重新初始化;
谈一下你对多态的理解?
多态是指允许不同的子类型的对象对同一消息做出不同的响应,也可简单的概括为“一个接口,多种方法”,C++多态主要是通过虚函数实现的,虚函数允许子类重写。多态有两种,分别是编译时多态和运行时多态,也叫静态多态和动态多态。
-
编译时多态(静态多态,编译阶段,地址早绑定):
静态多态通过函数重载和运算符重载来实现。在编译时,编译器根据调用的函数或运算符的参数类型和个数来确定具体调用哪个函数或运算符,这就是编译时多态。比如我有两个同名的 add 函数,第一个 add 函数的参数类型是 int 型,第二个 add 函数的参数类型是 float 型,那么我传入两个 int 型的参数到 add 函数中,则其回调用第一个 add 函数,返回一个 int 型的结果。 -
运行时多态(动态多态,运行阶段,地址晚绑定):
运行时多态通过继承和虚函数来实现。在运行时,程序根据对象的实际类型来确定调用哪个函数。在 C++ 中,通过在基类中声名虚函数,在派生类中重写这些虚函数,然后通过基类的指针或引用来调用这些函数,就可实现动态多态。
class Fruit{
public:
virtual void fruitName(){cout << "I am ..\n" << endl;}
};
class Apple : public Fruit{
public:
void fruitName(){cout << "I am Apple!\n" << endl;}
};
int main(){
Fruit* fruit1 = new Apple();
fruit1->fruitName(); // print I am Apple!
delete fruit1;
return 0;
}
虚函数表的位置?
在C++中,虚函数表(virtual function table , 简称vtable)适用于实现动态多态的一种机制,它存储了类的虚函数的地址,虚函数表通常是针对每个包含虚函数的类生成的,每个对象的内存中都有一个指向其所属类的虚函数表的指针,,用于在运行时确定调用哪个函数。
虚函数表的存储位置通常是在每个对象的内存布局中,具体位置取决于编译器和操作系统的是实现。一般来说,虚函数表位于对象的内存布局的起始位置或者末尾,但并不是标准规定。虚函数表本身是一个指针数组,其中存储了指向每个虚函数的指针。
+----------------+
| 虚函数表指针 | --> 指向虚函数表的指针
+----------------+
| 其他成员变量 | --> 类的其他成员变量
+----------------+
虚函数表:
+----------------+
| 虚函数指针 1 | --> 指向第一个虚函数的指针
+----------------+
| 虚函数指针 2 | --> 指向第二个虚函数的指针
+----------------+
| ... |
+----------------+
示意图中,一个对象的内存布局中包括了一个指向虚函数表的指针和其他成员变量。虚函数表本身是一个指针数组,每个元素指向对应虚函数的实际实现,当调用一个虚函数时,程序会根据对象的虚函数表指针找到对应的虚函数表,然后根据函数在虚函数表中的索引找到实际的函数进行调用。
运行时刻能把虚函数表拿出来吗?
在程序运行时,虚函数表是存储在内存中的,但是,C++并没有提供直接的语言级别的手段来访问或操作虚函数表。虚函数表的内部结构和存储位置是由编译器决定,不同的编译器和不同的平台可能有不同的实现方式。
虚函数表存储在对象的内存布局中,一般作为对象的第一个成员,位于对象的起始位置处。这样做的目的是为了在运行时能够快速地找到对象所属类的虚函数表,从而实现动态多态性。每个包含虚函数的类都有自己的虚函数表,当对象被创建时,编译器会在对象的内存中插入一个指向类的虚函数表的指针(虚指针),用于在运行时动态绑定。
拿到虚函数表的地址后,是否可以改写虚函数表的内容?
一般情况下,虚函数表是存储在程序的只读数据段中的,这意味着虚函数表的内容是只读的,不能被修改。如果尝试修改只读数据段的内容,通常会导致操作系统抛出段错误,或访问权限错误,从而导致程序异常终止。
基础知识 联系 <=> 区别
指针和引用的区别?
- 指针是一个变量,存储的是一个地址,指向内存的一个单元;引用跟原来的变量是同一个东西,是原变量的别名;
- 引用可以为空,被创建时必须初始化,并且初始化后不能改变;指针可以是空值,可以在任何时候初始化,初始化后可以指向其他的内存单元;
- 指针可以加 const 修饰,但引用不能加 const ;
- 可以有指针的指针,既多级指针,但没有引用的引用,引用只有一级;
- sizeof(引用) 得到的是原变量的大小,sizeof(指针) 得到的是指针本身的大小;
堆区和栈区的区别?
- 申请方式不同:栈是系统自动分配的,堆是程序员主动申请的;
- 申请后的系统响应:栈里分配空间时,如果剩余空间大于申请空间则分配成功,否则分配失败栈溢出;申请堆空间时,在记录空闲地址空间的链表上寻找第一个大于申请空间的节点分配给程序,然后将该节点在空闲链表上删除,大多数系统中该块空间的首地址存放的是本次分配空间的大小,便于释放,将该块空间上的剩余剩余空间再次链接到空闲链表上;
- 栈在内存中是一块连续的空间(向低地址扩展),最大容量已经设定好,通常是64MB,堆在内存中的空间时不连续的(向高地址扩展);
- 申请效率:栈是系统自动分配,申请效率高,但程序员无法控制;堆是由程序员主动申请,效率低,使用起来方便的但是容易产生碎片;
- 栈中存放的是局部变量,函数参数;堆中存放的内容由程序员控制;
new 和 delete 是如何实现的?new 和 malloc 的异同?
- new 和 delete 搭配使用,malloc 和 free 搭配使用;
- malloc/free 是库函数,需要引入头文件 “#include<stdlib.h>”;new/delete 是关键字,需要编译器支持参数;
- malloc 申请空间时需要确定申请的空间大小,new 申请空间时,无需指定分配空间大大小,编译器会根据类型自动计算;
- maloc 申请空间时,返回的是 void* 类型,需要强制转换成对象类型的指针;new 申请空间时,返回的类型是对象的指针类型,无需强制类型转换;
- malloc分配失败时,返回空指针,new 分配失败时,抛出 bad_alloc 异常;
- 自定义类型:new 首先调用 operator new 函数申请空间(底层通过 malloc 实现),然后调用对象的构造函数初始化,最后返回定义类型的指针;delete 先调用析构函数,然后嗲用operator delete 释放空间(底层通过 free )实现。malloc/free 不支持自定义类型的对象的析构和构造;
- 内存区域:new 操作是从自由存储去上为对象动态分配内存,而 malloc 函数从堆上动态分配内存;
自由存储区和堆区的区别
自由存储区(或堆区)是用于动态分配内存的一块较大的、连续的内存区域。与栈区不同,自由存储区的生命周期不受函数的调用和返回限制,可以在程序的任意位置进行内存的分配和释放。主要区别如下:
- 分配方式:自由存储区使用new操作符来分配内存。
- 内存管理:自由存储区的内存分配和释放需要手动控制,开发者需要负责在适当的时候使用delete操作符释放已分配的内存。而堆区的内存管理也是手动进行的,通常使用malloc()和free()函数来分配和释放内存。
- 分配粒度:自由存储区的内存分配粒度更大,可以分配任意大小的内存块,而堆区的内存分配粒度较小,一般以字节为单位。
C和C++的区别?
- C 是面向过程的编程,特点是函数;C++ 是面向对象的编程,特点是类。C++的三大特性是封装、继承、多态;
- C 主要用在嵌入式开发、驱动开发和硬件直接打交道的领域;C++ 可以用于应用层的开发、用户界面开发等和操作系统直接打交道的领域。(应用领域);
- C++ 继承了C的底层操作特性,增加了面向对象的机制,增加了泛型编程、异常处理、运算符重载,还增加了命名空间,避免了命名冲突。(相较于 C 的升级);
深浅拷贝的区别?
- 深拷贝:拷贝指针和指针指向的内容,深拷贝完成后,两个指针指向不同的地址;
- 浅拷贝:只是堆指针的拷贝,不拷贝指针指向的内容,浅拷贝完成后两个指针指向同一个地址;
虚函数和纯虚函数的区别?
- 虚函数和纯虚函数可以定义在同一个类中,含有纯虚函数的类被称为抽象类,而含有虚函数的类不能被称为抽象类;
- 虚函数可以直接被使用,也可以被子类重载后以多态的形式调用,而纯虚函数必须在子类中实现该函数才可以使用,纯虚函数在基类中只有声名而没有定义;
- 虚函数和纯虚函数都可以在子类中被重载,以多态的形式被调用,目的是提供一个统一的接口;
- 虚函数和纯虚函数的定义中不能有static标识符,因为 static 修饰的函数在编译时候要求前期绑定,而虚函数却是动态绑定,而且被两者修饰的函数声明周期也不一样;
- 虚函数必须实现,如果不实现,编译器将报错。虚函数是C++中用于实现多态的机制。核心理念就是通过基类访问派生类定义的接口。包含纯虚函数的类叫做抽象类(也称为接口类),抽象类不能实例化处对象。
重载、重写、同名隐匿的区别?
- 重载:两个或多个函数在同一作用域函数名相同、参数不同,重载不关心函数的返回类型;
- 重写:磊哥函数分别在基类和派生类中,函数名、参数、返回值类型都相同,基类中函数必须为虚函数;
- 同名隐匿:两个函数分别在基类和派生类的作用域中函数名相同,派生类的函数屏蔽了与其同名的基类函数,只要是同名函数,不管参数列表是否相同,基类函数都会被隐藏;
继承和多态的区别与联系?
- 区别:继承是子类使用父类的方法,多态是父类使用子类的方法;
- 继承就是子类继承父类的特征和行为,使得子类具有父类的各种属性和方法,继承是为了重用代码,有效实现代码重用,减少重复代码的出现;
- 多态就是相同的事物,调用其相同的方法,参数也相同时,但表现的行为却不同,多态是为了接口重用,增强接口的扩展性;
struct 和 class 的区别?
- struct 的成员默认是公有的,而类的成员默认是私有的;
- C 中的 struct 不能不含成员函数,而C++中的 struct 可以;
extern 和 static 的区别?
- extern(外部变量):属于变量声明,extern int num 就是告编译器,有一个 int 型的变量 num 定义在其他地方,如果有调用请去其他文件中查找定义;
- static(静态变量):只会在第一次访问时初始化一次,在函数调用结束后,该变量也不会被释放,保留保存的值,直到程序运行结束时系统自动释放;
声明和定义的区别?
- 声明指的是在程序中声明一个变量、函数或类,以便在程序中使用它。声明只是告诉编译器,这个名字存在,但并没有分配内存或定义实现。在C和C++中,可以使用关键字 extern 来显式声明变量或函数;
- 定义指的是实际分配内存并实现变量、函数或类。定义包含了声明,但声明并不包含定义。在定义一个变量或函数时,必须给出其类型、名称和初始值(对于变量)。对于类,定义包括成员变量和成员函数的实现;
- 声明是告诉编译器名字的存在,而定义是为名字分配内存并实现其功能;
strcpy 和 memcpy 的区别?
- strcpy 和 memcpy 都是在C语言和C++语言中用于复制内存块的函数,但它们在使用和效率上有所不同;
- strcpy 用于将一个以null结尾的字符串从源地址复制到目标地址。它会复制整个字符串,包括 null 终止符,直到遇到null为止。如果源字符串长度超过目标地址所分配的内存空间,则会导致内存越界和缓冲区溢出问题;
- memcpy 用于将一段内存块从源地址复制到目标地址,可以复制任意长度的内存块,而不仅限于字符串。memcpy 不会关心内存块中是否有 null 终止符,而只是按照给定的长度复制内存块。因此,使用 memcpy 时需要确保目标地址有足够的内存空间,否则也会导致缓冲区溢出问题;
const 和 define 的区别?
- const 生效于编译的阶段;define 生效于预处理阶段;
- const 定义的常量,在C语言中是存储在内存中、需要额外的内存空间的;define 定义的常量,运行时是直接的操作数,并不会存放在内存中;
- const 定义的常量是带类型的;define 定义的常量不带类型。define 定义的常量不利于类型检查;
内联函数和宏函数的区别?
- 宏定义不是函数,但是使用起来像函数。预处理器用复制宏代码的方式代替函数的调用,省去了函数压栈退栈过程,提高了效率;而内联函数本质上是一个函数,内联函数一般用于函数体的代码比较简单的函数,不能包含复杂的控制语句,while、switch,并且内联函数本身不能直接调用自身;
- 宏函数是在预编译的时候把所有的宏名用宏体来替换,简单的说就是字符串替换 ;而内联函数则是在编译的时候进行代码插入,编译器会在每处调用内联函数的地方直接把内联函数的内容展开,这样可以省去函数的调用的开销,提高效率;
- 宏定义是没有类型检查的,无论对还是错都是直接替换;而内联函数在编译的时候会进行类型的检查,内联函数满足函数的性质,比如有返回值、参数列表等;
野指针和悬空指针的区别?
- 野指针:访问一个已删除或访问受限的内存区域的指针,野指针不能判断是否为NULL来避免。指针没有初始化,释放后没有置空,越界;
- 悬空指针:一个指针的指向对象已被删除;
值传递、指针传递、引用传递的区别?
- 值传递:形参是实参的拷贝,改变形参的值并不会影响外部实参的值。从被调用函数的角度来说,值传递是单向的(实参->形参),参数的值只能传入,不能传出。当函数内部需要修改参数,并且不希望这个改变影响调用者时,采用值传递;
- 指针传递:形参为指向实参地址的指针,当对形参的指向操作时,就相当于对实参本身进行的操作;
- 引用传递:形参相当于是实参的“别名”,对形参的操作其实就是对实参的操作,在引用传递过程中,被调函数的形式参数虽然也作为局部变量在栈中开辟了内存空间,但是这时存放的是由主调函数放进来的实参变量的地址。被调函数对形参的任何操作都被处理成间接寻址,即通过栈中存放的地址访问主调函数中的实参变量。正因为如此,被调函数对形参做的任何操作都影响了主调函数中的实参变量。
++i与i++的区别?
- 复制顺序不同。++i和i++都是分两步完成的。++i 是先加后赋值;i++ 是先赋值后加。既:
// i++
i = i;
i += 1;
// ++i
i += 1;
i = i;
- 效率不同:比如i=2,b=i++就是说b=2,完成之后让i变成3,b=++i就是先让i++变成3,然后b=3,其中++i比i++效率要高些。一般来说在循环域里面,这两者并没有什么很大的区别,但是要注意其生存周期,以及i值在程序流中的变化;
struct 和 union 的区别?
union 是联合体,struct 是结构体:
- 联合体和结构体都是由若干个数据类型不同的数据成员组成。使用时,联合体只有一个有效的成员;而结构体所有的成员都有效;
- 对联合体的不同成员赋值,将会对覆盖其他成员的值,而对于结构体的对不同成员赋值时,相互不影响;
- 联合体的大小为其内部所有变量的最大值,按照最大类型的倍数进行分配大小;结构体分配内存的大小遵循内存对齐原则;
内存对齐
内存对齐是指将数据结构中的每个成员按照一定的规则进行排列,使得每个成员的起始地址相对于该结构的起始地址偏移量为该成员大小的整数倍。这样做的目的是为了让处理器在读取数据时更加高效,因为处理器可以一次性读取多个连续地址上的数据,如果数据不对齐,处理器就需要多次读取,降低了读取速度
基础知识 => 定义/概念
类有哪几种构造方式?
默认构造函数(没有参数),有参构造函数(有参数),拷贝构造函数(形参是本类对象的引用),转换构造函数(形参是其他类型的变量,且只有一个形参)
说一下你对虚函数、纯虚函数、抽象类的理解?
- 虚函数:被 virtual 关键字修饰的成员函数;
- 纯虚函数:纯虚函数在类中声明时,加上 = 0;
包含纯虚函数的类叫做 抽象类(也称为接口类),抽象类不能实例化出对象。 抽象类中只有接口,没有具体的实现方法。 派生类继承抽象类之后只有重写全部纯虚函数,派生类才能实例化为对象。纯虚函数规范了派生类必须重写,接口继承。抽象类对象不能作为函数的参数,不能创建对象,不能作为函数返回类型。可以声明抽象类指针,可以声明抽象类的引用。
虚函数的实现机制
虚函数通过虚函数表来实现。虚函数的地址保存在虚函数表中,在类的对象所在的内存空间中,保存了指向虚函数表的指针(称为“虚表指针”),通过虚表指针可以找到类对应的虚函数表。虚函数表解决了基类和派生类的继承问题和类中成员函数的覆盖问题,当用基类的指针来操作一个派生类的时候,这张虚函数表就指明了实际应该调用的函数;
- 虚函数表存放的内容:类的虚函数的地址;
- 虚函数表建立的时间:编译阶段,即程序的编译过程中会将虚函数的地址放在虚函数表中;
- 虚表指针保存的位置:虚表指针存放在对象的内存空间中最前面的位置,这是为了保证正确取到虚函数的偏移量
你对空类了解多少?
空类声明时编译器不会生成任何成员函数,只会生成1个字节的占位符;
空类定义时编译器会生成 6 个成员函数:缺省的构造函数、拷贝构造函数、析构函数、赋值运算符、两个取址运算符(其中一个是 const 版);
引用会占用内存空间吗?
在大多数编程语言中,引用不会占用内存空间,因为引用只是指向现有的内存空间的别名。在C++中,引用是指向变量的别名,与指针不同的是,引用不能为null或指向其他变量。当程序使用引用时,它们只是将变量名与内存地址关联起来,因此不会产生额外的内存分配。
C++的内存分配?
在C++中,内存分为5个区,分别是:
- 堆区:一般由程序员自动分配,如果程序员没有释放,程序结束时由OS回收;
- 栈区:由编译器自动分配和释放,存放为运行函数分配的局部变量,函数参数,返回数据,返回地址等;
- 全局区(静态 static .bss段 .data段):存放全局变量、静态变量、常量。结束后由系统释放;
- 常量区(文字常量区 .data段):存放常量字符串,程序结束后由系统释放;
- 代码区(.text段):存放函数体(类成员函数和全局区)的二进制代码,不允许修改,但可以执行;
C++ 编译过程?
编译过程分为四个过程:编译(编译预处理、编译、优化),汇编,链接:
- 编译预处理:处理以 # 开头的指令;
- 编译、优化:将源码 .cpp 文件翻译成 .s 汇编代码;
- 汇编:将汇编代码 .s 翻译成机器指令 .o 文件;
- 链接:汇编程序生成的目标文件,即 .o 文件,并不会立即执行,因为可能会出现:.cpp 文件中的函数引用了另一个 .cpp 文件中定义的符号或者调用了某个库文件中的函数。那链接的目的就是将这些文件对应的目标文件连接成一个整体,从而生成可执行的程序 .exe 文件;
链接又有两种,分别是静态链接和动态链接:
- 静态链接
- 代码从其所在的静态链接库中拷贝到最终的可执行程序中,在该程序被执行时,这些代码会被装入到该进程的虚拟地址空间中;
- 静态链接是指将程序中所有需要的代码和库函数都打包成一个完整的可执行文件,这个可执行文件独立运行,不需要依赖其他的库文件。当程序执行时,操作系统会将整个可执行文件加载到内存中并执行,因此静态链接的程序在执行时比较快,但可执行文件会比较大;
- 动态链接
- 码被放到动态链接库或共享对象的某个目标文件中,链接程序只是在最终的可执行程序中记录了共享对象的名字等一些信息。在程序执行时,动态链接库的全部内容会被映射到运行时相应进行的虚拟地址的空间;
- 动态链接是指将程序中需要的库函数等文件打包成一个动态链接库文件(DLL、so等),程序在运行时通过动态链接库中的函数进行调用。因为多个程序可以共享同一个动态链接库,因此可以节省内存和磁盘空间。但由于需要在程序运行时进行动态链接,因此相比于静态链接,动态链接的程序在启动时会稍微慢一些;
- 动态链接还可以实现库文件的动态更新和升级,因为不需要重新编译整个程序,只需要替换动态链接库即可;
explicit 的作用?
用来声明类构造函数是显示调用的,而非隐式调用,可以阻止调用构造函数时进行隐式转换。只可用于修饰单参构造函数,因为无参构造函数和多参构造函数本身就是显示调用的,再加上 explicit 关键字也没有什么意义;
智能指针
C++里面的四个智能指针: auto_ptr, unique_ptr,shared_ptr, weak_ptr 其中后三个是C++11支持,并且第一个已经被C++11弃用。使用时需要引入头文件 “”。
智能指针介绍
智能指针主要用于管理在堆上分配的内存,它将普通的指针封装为一个栈对象。当栈对象的生存周期结束后,会在析构函数中释放掉申请的内存,从而防止内存泄漏。智能指针实质是一个对象,行为表现的却像一个指针。
什么是内存泄漏?
由于疏忽或错误导致的程序未能释放已经不再使用的内存。内存泄漏是指程序在运行过程中,由于疏忽或错误而失去了对该内存的控制,从而造成了内存的浪费。
内存泄漏产生的原因?
- 堆内存泄漏,new 跟 delete 、malloc 和 free 未成对使用;
- 系统资源泄漏, Bitmap, handle,socket 等资源未释放;
- 没有将基类析构函数定义称为虚函数,(使用基类指针或者引用指向派生类对象时)派生类对象释放时将不能正确释放派生对象部分;
- 指针重新赋值,指针重新赋值后原来指向的内存空间丢失,发生了内存泄漏;
auto_ptr
(C++98的方案,C++11已经抛弃)采用所有权模式。
auto_ptr<string> p (new string ("I reigned lonely as a cloud."));
auto_ptr<string> q;
q = p; //auto_ptr不会报错.
此时不会报错,q剥夺了p的所有权,但是当程序运行时访问p将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题!
unique_ptr
unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用。
unique_ptr<string> p (new string ("auto"));
unique_ptr<string> q;
q = p;//此时会报错!!
编译器认为 q = p 非法,避免了 p 不再指向有效数据的问题。尝试复制 p 时会编译期出错,而auto_ptr能通过编译期从而在运行期埋下出错的隐患。因此,unique_ptr比auto_ptr更安全。
unique_ptr常用操作
unique_ptr<T> u1 // 空unique_ptr,可以指向类型为T的对象。u1会使用delete来释放它的指针
unique_ptr<T, D> u2 // u2会使用一个类型为D的可调用对象来释放它的指针
unique_ptr<T, D> u(d) // 空unique_ptr,指向类型为T的对象,用类型为D的对象d替代delete
u = nullptr // 释放u指向的对象,将u置为空
u.release() // u放弃对指针的控制权,返回指针,并将u置为空
u.reset() // 释放u指向的对象
u.reset(q) // 如果提供了内置指针q,另u指向这个对象;否则将u置为空
u.reset(nullptr)
虽然我们不能拷贝或赋值unique_ptr,但可以通过调用 release 或 reset 将指针的所有权从一个(非const)unique_ptr转移给另一个unique_ptr;
shared_ptr
shared_ptr 实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字 share 就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数 use_count() 来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入 unique_ptr,weak_ptr 来构造。当我们调用 release() 时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。
shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。
shared_ptr 的成员函数:
shared_ptr<T>:类模板,用于定义一个shared_ptr对象,其中T表示所指向对象的类型。
shared_ptr<T>::shared_ptr():默认构造函数,创建一个空的shared_ptr对象。
shared_ptr<T>::shared_ptr(T* ptr):构造函数,创建一个shared_ptr对象并初始化为指向ptr所指向的对象。
shared_ptr<T>::shared_ptr(const shared_ptr<T>& other):拷贝构造函数,创建一个新的shared_ptr对象,并与other共享同一对象。
shared_ptr<T>::operator= (const shared_ptr<T>& other):赋值运算符重载,将当前的shared_ptr对象与other共享同一对象。
shared_ptr<T>::reset():重置shared_ptr对象,将其置为空指针。
shared_ptr<T>::get():获取shared_ptr所指向的原始指针。
shared_ptr<T>::use_count():获取当前shared_ptr对象的引用计数,即共享该对象的shared_ptr对象的数量。
shared_ptr<T>::unique():判断当前shared_ptr对象是否是唯一拥有所指对象的智能指针。
shared_ptr<T>::operator bool() const:将shared_ptr对象转换为布尔值,判断其是否为空指针。
shared_ptr<T>::operator* ():重载解引用操作符,返回所指对象的引用。
shared_ptr<T>::operator-> ():重载箭头操作符,返回所指对象的指针。
shared_ptr<T>::swap(shared_ptr<T>& other):交换两个shared_ptr对象的内容。
shared_ptr 的初始化
最安全的分配和使用动态内存的方法是调用一个名为 make_shared 的标准库函数。此函数在动态内存中分配一个对象并初始化它,返回指向此对象的 shared_ptr。与智能指针一样,make_shared 也定义在头文件 memory 中。
// p 指向一个值为42的 int 的 shared_ptr
shared_ptr<int> p = make_shared<int>(42);
// q 指向一个值为"9999999999"的 string
shared_ptr<string> q = make_shared<string>(10,'9');
// p5指向一个只初始化的int
shared_ptr<int> p5 = make_shared<int>();
我们还可以用 new 返回的指针来初始化智能指针,不过接受指针参数的智能指针构造函数是 explicit 的。因此,我们不能将一个内置指针隐式转换为一个智能指针,必须使用直接初始化形式来初始化一个智能指针;
shared_ptr<int> pi = new int (1024); // 错误:必须使用直接初始化形式
shared_ptr<int> p2(new int(1024)); // 正确:使用了直接初始化形式
weak_ptr
share_ptr虽然已经很好用了,但是有一点share_ptr智能指针还是有内存泄露的情况,当两个对象相互使用一个shared_ptr成员变量指向对方,会造成循环引用,使引用计数失效,从而导致内存泄漏。
weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的shared_ptr, weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr;
STL库
各个常见容器
vector-向量容器
- 内部数据结构:一端开口的连续存储的数组形式
- 特点:增加或获取元素效率很高,插入和删除的效率很低
- 操作元素的方式:下标操作符[]
- 迭代器失效:插入和删除元素都会是迭代器失效
- vector 底层原理?
- 数据安排及操作方式与数组非常相似。两者的唯一差别在于空间运用的灵活性;
- 静态空间,一旦配置好了就不能改变了,如果程序需要一个更大的array,只能自己再申请一个更大的array,然后将以前的array中的内容全部拷贝到新的array中;
- 动态开辟的空间,随着元素的加入,它的内部机制会自动扩充空间以容纳新的元素。vector的关键技术在于对大小的控制以及重新分配时的数据移动效率;
- 采用的数据结构是线性的连续空间(泛型的动态类型顺序表),他以两个迭代器start和finish分别指向配置得来的连续空间中目前已将被使用的空间。迭代器end_of_storage指向整个连续的尾部;
- 在增加元素时,如果超过自身最大的容量,vector则将自身的容量扩充为原来的两倍。扩充空间需要经过的步骤:重新配置空间,元素移动,释放旧的内存空间。一旦vector空间重新配置,则指向原来vector的所有迭代器都失效了,因为vector的地址改变了;
- vector 的扩容过程?
- 完全弃用现有的内存空间中,重新申请更大的内存空间;
- 将旧的内存空间中的数据按原有顺序移动到新的内存空间中;
- 最后将旧的内存空间释放;
- vector 的扩容方式为什么是1.5倍或2倍?
假如说我们是以 2 倍方式扩容(1,2,4,8,16),则第 i 次扩容期间所需要的空间总量就是 2i次方,如果第 4 次扩容时总共需要8个元素大小的空间,但是前3次已经释放的空间加起来的总量,刚好是7,而7小于8,不足以我们第4次扩容时所需要的空间,也就是说,如果恰巧以2倍方式扩容,那么每次扩容时前面释放的空间它都不足以支持本次的扩容!!!那么如果是以更高倍数的方式进行扩容,则这个空间它的浪费情况就会更高!!!因此会有以下两个问题:
- 空间浪费的可能比较大;
- 无法使用前面释放的内存空间;
一般情况下,在Windows的VS系列编译器下,是按照1.5倍的方式进行扩容,在Linux的g++中,是按照2倍的方式进行扩容的;
- size、resize、reserve、capacity 的区别?
- size表示当前vector中有多少个元素(即finish – start);
- resize可以改变有效空间的大小,也有改变默认值的功能。capacity的大小也会随着改变。可以有多个参数。创建指定数量的元素并指定vector的存储空间。既分配空间又创建对象;
- reserve是直接扩充到已经确定的大小,可以减少多次开辟、释放空间的问题(优化push_back),从而达到提高效率的目的,其次还可以减少多次要拷贝数据的问题。reserve它只是保证vector中的空间大小(capacity)最少达到参数所指定的大小n。并且它只有一个参数。指定vector的元素总数,不创建对象;
- capacity函数表示它已经分配的内存中可以容纳多少元素(即end_of_storage – start)。即容器在分配新的存储空间能存储的元素总数。返回vector中能存储元素的最大数;
- push_back 和 emplace_back 的区别?
emplace_back() 和 push_back() 的主要区别,就在于底层实现的机制不同。push_back() 向容器尾部添加元素时,首先会创建这个元素,然后再将这个元素拷贝或者移动到容器中(如果是拷贝的话,事后会自行销毁先前创建的这个元素);而 emplace_back() 在实现时,则是直接在容器尾部创建这个元素,省去了拷贝或移动元素的过程;
list-列表容器
- 内部数据结构:双向循环链表
- 特点:增加或获取元素效率很低,插入和删除效率很高
- 操作元素的方式:只能用迭代器
- 迭代器失效:插入,迭代器不会失效。删除,指向被删除节点的迭代器失效
常用函数
list.push_back(elem) 在尾部加入一个数据
list.pop_back() 删除尾部数据
list.push_front(elem) 在头部插入一个数据
list.pop_front() 删除头部数据
list.size() 返回容器中实际数据的个数
list.sort() 排序,默认由小到大
list.unique() 移除数值相同的连续元素
list.back() 取尾部迭代器
list.erase(iterator) 删除一个元素,参数是迭代器,返回的是删除迭代器的下一个位置
deque-双向队列容器
- 内部数据结构:两端开口的连续或分段存储的数组
- 特点:增加或获取元素效率较高,插入和删除效率较高
- 操作元素的方式:下标操作符或迭代器
- 迭代器失效:插入任何元素都会使迭代器失效。删除头和尾元素,指向被删除节点迭代器失效,而删除中间元素会使所有迭代器失效
deque内部实现的是一个双向队列。元素在内存连续存放。随机存取任何元素都在常数时间完成(仅次于vector)。所有适用于vector的操作都适用于deque。在两端增删元素具有较佳的性能(大部分情况下是常数时间)。
-
你了解 deque 的中控器吗?
deque为了维持整体连续的假象,设计一个中控器,其用来记录deque内部每一段连续空间的地址。大体上可以理解为deque中的每一段连续空间分布在内存的不连续空间上,然后用一个所谓的map作为主控,记录每一段内存空间的入口,从而做到整体连续的假象; -
deque 的迭代器是怎么回事?
- deque提供的是一个随机访问迭代器,由于是分段连续空间,其必须记录当前元素所在段的信息,从而在该段连续空间的边缘进行前进或者后退的时候能知道跳跃到的上一个或下一个缓冲区。deque必须完完整整的掌握和控制这些信息,以达到正确的跳跃;
static size_t buffer_size(){
return __deque_buf_size(BufSiz, sizeof(T));
}
//如果n不为0,传回n,表示buffer size 由自己定义
如果n为0,表示buffer_size 采用默认值
如果sz(元素大小) < 512,传回512/sz,如果不小于512 ,传回1
inline size_t __deque_buf_size(size_t n, size_t sz)
{
return n != 0 ? n : (sz < 512 ? size_t(512 / sz) : size_t(1));
}
- 当迭代器处在当前缓冲区的边缘时,一旦前进或者后退,就要考虑超过当前缓冲区的情况,此时需要跳转到下一个缓冲区,这时候需要用到set_node函数:
void set_node(map_pointer new_node)
{
node = new_node; // 跳转到相应缓冲区
first = *new_node; // 更新跳转后缓冲区first信息
last = first + difference_type(buffer_size()); // 更新跳转后缓冲区last的信息
}
- 说一说 deque 的数据结构?
deque维护着一个map,用来记录每个缓冲区的位置。除了map外,deque的数据结构还维护着start和finish两个迭代器,分别指向deque的首尾。此外,他还必须知道map的大小,一旦map提供的节点不足,就需要配置一块更大的map;
常用函数
deque.push_back(elem)在尾部加入一个数据。
deque.pop_back()删除尾部数据。
deque.push_front(elem)在头部插入一个数据。
deque.pop_front()删除头部数据。
deque.size() 返回容器中实际数据的个数。
deque.at(idx)传回索引idx所指的数据,如果idx越界,抛出out_of_range。
map-映射
- 内部数据结构:红黑树
- 特点:键和值分开,前面是键后面是值,键唯一,元素默认按键的升序排列
- 操作元素的方式:迭代器
- 迭代器失效:插入,迭代器不会失效。删除,指向被删除节点的迭代器失效
- map 和 unordered_map 的底层实现?
-
map
map内部实现了一个红黑树(红黑树是非严格平衡的二叉搜索树,而AVL是严格平衡二叉搜索树),红黑树有自动排序的功能,因此map内部所有元素都是有序的,红黑树的每一个节点都代表着map的一个元素。因此,对于map进行的查找、删除、添加等一系列的操作都相当于是对红黑树进行的操作。map中的元素是按照二叉树(又名二叉查找树、二叉排序树)存储的,特点就是左子树上所有节点的键值都小于根节点的键值,右子树所有节点的键值都大于根节点的键值。使用中序遍历可将键值按照从小到大遍历出来; -
unordered_map
unordered_map内部实现了一个哈希表(也叫散列表),通过把关键码值映射到Hash表中一个位置来访问记录,查找时间复杂度可达O(1),其中在海量数据处理中有着广泛应用。因此,元素的排列顺序是无序的;
set-集合
- 内部数据结构:红黑树
- 特点:键和值相等,键唯一,元素默认按升序排列
- 操作元素的方式:迭代器
- 迭代器失效:插入,迭代器不会失效。删除,指向被删除节点的迭代器失效
常见面试问题
讲讲STL的六大组件?
-
容器(Containers):各种数据结构,如 Vector,List,Deque,Set,Map,用来存放数据,STL容器是一种 Class Template ;
-
算法(Algorithms):各种常用算法如Sort,Search,Copy,Erase,从实现的角度来看,STL算法是一种Function Templates ;
-
迭代器(Iterators):扮演容器与算法之间的胶合剂,是所谓的“泛型指针”,共有五种类型,以及其它衍生变化,从实现的角度来看,迭代器是一种将:Operators*,Operator->,Operator++,Operator–等相关操作予以重载的 Class Template。所有STL容器都附带有自己专属的迭代器;
-
仿函数(Functors):行为类似函数,可作为算法的某种策略(Policy),从实现的角度来看,仿函数是一种重载了Operator()的Class 或 Class Template ;
-
适配器(配接器)(Adapters):一种用来修饰容器(Containers)或仿函数(Functors)或迭代器(Iterators)接口的东西,例如:STL提供的 Queue 和 Stack ,虽然看似容器,其实只能算是一种容器配接器,因为 它们的底部完全借助 Deque,所有操作有底层的 Deque 供应。改变 Functor 接口者,称为 Function Adapter ;改变 Container 接口者,称为 Container Adapter;改变 Iterator 接口者,称为Iterator Adapter;
-
配器(Allocators):负责空间配置与管理,从实现的角度来看,配置器是一个实现了动态空间配置、空间管理、空间释放的Class Template ;
说说优先级队列?
优先队列底层数据结构一般以 vector 为底层容器,heap 为处理规则来管理底层容器实现。优先队列(priority_queue)容器与队列一样,只能从队尾插入元素,从队首删除元素。 但是它有一个特性,队列中最大的元素总是位于队首。 出队时,并非按照先进先出的原则进行,而是将当前队列中最大的元素出队。这点类似于给队列里的元素进行了由大到小的顺序排序。元素的比较规则默认按元素值由大到小排序,可以重载“<”操作符来重新定义比较规则。在优先队列中,队首元素一定是当前队列中优先级最高的那一个;
map和unordered_map的优缺点?
-
map优点:有序、基于红黑树实现,查找的时间复杂度为O(nlogn);
-
map缺点:空间占用率比较高,虽然底层是红黑树实现的,提高了运行效率,但是每个节点都要保存父节点和孩子节点和红黑树的性质,使得每一个节点都占用胆量的空间;
-
适用场景:对于有序的结构,小规模的数据量;
-
nordered_map优点:底层是用哈希表实现的,查找效率非常高,时间复杂度为O(1);
-
unordered_map的缺点:哈希表的建立比较费时;
-
适用场景:对于查找问题,使用unordered_map更好,大规模的数据量;
为什么map和set和插入删除效率比其他序列容器高,而且每次insert之后,以前保存的iterator不会失效?
因为存储的是节点,不需要内存拷贝和内存移动。插入操作只是节点指针换来换去,节点内存没有改变,而iterator就像指向节点的指针,内存没变,指向内存de指针也不会变。
为什么 map 和 set 不能像 vector 一样有个 reserve 函数来预分配数据?
因为在map和set内部存储的已经不是元素本身了,而是包含元素的节点。
map 中[ ]与 find 的区别?
- map的下标运算符[ ]的作用是:将关键码作为下标去执行查找,并返回对应的值;如果不存在这个关键码,就将一个具有该关键码和值类型的默认值的项插入这个map;
- map的find函数:用关键码执行查找,找到了返回该位置的迭代器;如果不存在这个关键码,就返回尾迭代器 end ;
hash_map与map的区别?什么时候用hash_map,什么时候用map?
- 构造函数:hash_map需要hash function和等于函数,而map需要比较函数(大于或小于);
- 存储结构:hash_map以hashtable为底层,而map以RB-TREE为底层;
- 总的说来,hash_map 查找速度比 map 快,而且查找速度基本和数据量大小无关,属于常数级别。而 map 的查找速度是 logn 级别。但不一定常数就比 logn 小,而且hash_map 还有 hash function 耗时。如果考虑效率,特别当元素达到一定数量级时,用 hash_map ;考虑内存,或者元素数量较少时,用 map 。
讲一讲set的用法和它的特点?
- 用法:count()-返回某个值元素的个数(set中最多为1)、find()-返回一个指向被查找到元素的迭代器、equal_range()-返回集合中与给定值相等的上下限的两个迭代器;
- 特点:元素不允许有重复,在默认情况下会对元素进行自动排序,数据被组织成一棵红黑树,查找的速度非常快(二分),时间复杂度是O(logn),set中的元素不能被修改,只能删除后再添加;
十大排序算法
冒泡排序
从前往后(或从后往前)两两比较相邻元素的值,若为逆序(即A[I-1]>A[I]),则交换它们,直到序列比较完。我们称它为第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置),关键字最小的元素如气泡一样逐渐向上“漂浮”。最终一个一个排好了位置;
void bubbleSort(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (nums[j] > nums[j+1]) {
swap(nums[j], nums[j+1]);
}
}
}
}
- 时间复杂度:O(N2);
快速排序
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分别进行快速排序,整个排序过程可以递归进行,使整个数据变成有序序列。每一趟中,都至少可以确定一个元素的最终位置。
// 快速排序 注:开始时right = 数组的最大有效下标
void QuickSort(int *arr, int left, int right){
if(left >= right)
return;
int key = arr[left];
int low = left;
int high = right;
while(low < high){
while(low < high && arr[high] >= key)
high--;
arr[low] = arr[high];
while(low < high && arr[low] <= key)
low++;
arr[high] = arr[low];
}
arr[low] = key;
QuickSort(arr, left, low - 1);
QuickSort(arr, low + 1, right);
}
快速排序的优化
-
随机选择基准元素
快速排序最坏情况下的时间复杂度为 O(N2),这种情况通常发生在每次选择的基准元素都是当前子数组的最大或最小值时。为了避免这种情况,我们可以随机选择一个元素作为基准元素,这样每个元素都有相同的概率成为基准元素,从而避免了最坏情况的发生; -
三数取中法选择基准元素
在确定基准元素时,我们可以选择当前子数组的第一个元素、最后一个元素、中间元素中的中位数作为基准元素。这种方式称为三数取中法,可以使得基准元素更加均衡,从而提高排序效率; -
双轴快排
双轴快排是一种基于快速排序的改进算法,它使用两个基准元素而不是一个基准元素进行分区。具体来说,我们先选择两个基准元素p和q,其中p < q,然后将数组分成三部分:小于p的部分、大于q的部分和介于p和q之间的部分。接下来,我们对小于p和大于q的两部分递归进行双轴快排,对介于p和 q之间的部分进行普通的快速排序。双轴快排相比于普通的快速排序,在某些情况下可以提高排序效率;
选择排序
每次从待排序序列中选择最小的元素,与序列的第一个元素交换位置。这样,序列的第一个位置就是最小的元素。然后在剩下的元素中继续执行上述操作,直到整个序列排序完成。
void selectionSort(int arr[], int n) {
for (int i = 0; i < n - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
- 时间复杂度:O(N2);
插入排序
它的基本思想是将一个记录插入到已经排好序的有序表中,从而一个新的、记录数增1的有序表。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动;
插入排序的工作方式像许多人排序一手扑克牌。开始时,我们的左手为空并且桌子上的牌面向下。然后,我们每次从桌子上拿走一张牌并将它插入左手中正确的位置。为了找到一张牌的正确位置,我们从右到左将它与已在手中的每张牌进行比较。拿在左手上的牌总是排序好的,原来这些牌是桌子上牌堆中顶部的牌;
void insertionSort(int arr[], int n) {
for (int i = 1; i < n; i++) {
int key = arr[i];
int j = i - 1;
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = key;
}
}
- 时间复杂度:O(N2);
希尔排序
希尔排序的基本思想是将待排序的元素分成若干个小组,对每个小组进行插入排序,随着排序过程的进行,每个小组的元素个数逐渐增多,但仍然保持有序。最后将所有元素分成一个组,进行插入排序。希尔排序的具体步骤如下:
- 选择一个增量序列d1,d2,…,dk,其中di > dj,dk = 1;
- 对于每个增量di,将序列分成di个子序列,分别对每个子序列进行插入排序;增量逐渐缩小,重复步骤2,直到增量为1。
在实际应用中,希尔排序常常使用一些常见的增量序列,如希尔增量(n/2,n/4,…,1)、Hibbard增量(1,3,7,…,2^k-1)、Sedgewick增量等,以提高排序的效率;
void shellSort(int arr[], int n) {
for (int gap = n / 2; gap > 0; gap /= 2) {
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
- 时间复杂度:O(N2);
归并排序
归并排序是一种基于分治思想的排序算法,它的基本思想是将待排序的序列不断地二分为两个子序列,直到每个子序列只剩下一个元素。然后,将两个子序列归并成一个有序序列,不断地归并,直到最终得到一个有序序列;
void merge(int arr[], int left, int mid, int right) {
int n1 = mid - left + 1;
int n2 = right - mid;
int leftArr[n1], rightArr[n2];
for (int i = 0; i < n1; i++) {
leftArr[i] = arr[left + i];
}
for (int j = 0; j < n2; j++) {
rightArr[j] = arr[mid + 1 + j];
}
int i = 0, j = 0, k = left;
while (i < n1 && j < n2) {
if (leftArr[i] <= rightArr[j]) {
arr[k] = leftArr[i];
i++;
} else {
arr[k] = rightArr[j];
j++;
}
k++;
}
while (i < n1) {
arr[k] = leftArr[i];
i++;
k++;
}
while (j < n2) {
arr[k] = rightArr[j];
j++;
k++;
}
}
void mergeSort(int arr[], int left, int right) {
if (left < right) {
int mid = (left + right) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
- 时间复杂度:O(NlogN);
堆排序
堆排序是一种基于堆数据结构的排序算法,其基本思想是将待排序的元素构造成一个堆,然后依次将堆顶元素与堆底元素交换,再对堆顶元素进行下沉操作,使得交换后的堆仍然保持最大堆或最小堆的性质,重复上述过程直到排序完成。
在堆排序中,首先要构建一个堆,可以使用从下往上的建堆方法,或者使用堆插入的方法。建堆完成后,将堆顶元素与堆底元素交换,然后对堆顶元素进行下沉操作,使得堆顶元素重新满足最大堆或最小堆的性质。交换后的堆除堆顶元素外,仍然满足最大堆或最小堆的性质,继续进行相同的操作,直到排序完成。
堆排序的时间复杂度为O(NlogN),空间复杂度为O(1)。堆排序是一种不稳定的排序算法,因为交换操作会改变相同元素之间的相对位置;
// 堆排序(大根堆) 注:length = 数组的最大有效下标
void HeapAdjust(int *arr, int length, int aj_idx){
arr[0] = arr[aj_idx];
int i = 0;
for(i = aj_idx * 2;i <= length; i *= 2){
if(i < length && arr[i] < arr[i + 1])
i++;
if(arr[0] < arr[i]){
arr[aj_idx] = arr[i];
aj_idx = i;
}
else
break;
}
arr[aj_idx] = arr[0];
}
void BigHeapBuild(int *arr, int length){
for(int i = (length / 2) + 1; i > 0; i--)
HeapAdjust(arr, length, i);
}
void BigHeapSort(int *arr, int length){
// 调整成大根堆
BigHeapBuild(arr, length);
// 堆排序
for(int i = length; i > 1; i--){
arr[0] = arr[i];
arr[i] = arr[1];
arr[1] = arr[0];
HeapAdjust(arr, i - 1, 1);
}
}
计数排序
计数排序的基本思想是对于给定的输入序列中的每一个元素x,确定该序列中值小于x的元素的个数(此处并非比较各元素的大小,而是通过对元素值的计数和计数值的累加来确定)。一旦有了这个信息,就可以将x直接存放到最终的输出序列的正确位置上。例如,如果输入序列中只有17个元素的值小于x的值,则x可以直接存放在输出序列的第18个位置上;
void countingSort(int arr[], int n, int k) {
int count[k + 1] = {0};
int output[n];
for (int i = 0; i < n; i++) {
count[arr[i]]++;
}
for (int i = 1; i <= k; i++) {
count[i] += count[i - 1];
}
for (int i = n - 1; i >= 0; i--) {
output[count[arr[i]] - 1] = arr[i];
count[arr[i]]--;
}
for (int i = 0; i < n; i++) {
arr[i] = output[i];
}
}
- 时间复杂度:O(N);
- 空间复杂度:O(N);
桶排序
桶排序(Bucket Sort)是一种线性时间复杂度的排序算法,它的基本思想是将待排序的元素分配到不同的桶中,每个桶内部进行排序,最后按照桶的顺序依次输出所有元素。桶排序的基本思想如下:
- 确定桶的数量:根据待排序元素的特点,确定需要的桶的数量。
- 将元素分配到桶中:遍历待排序的元素,根据某种映射关系将元素分配到对应的桶中。
- 桶内排序:对每个非空桶内的元素进行排序,可以使用其他排序算法(如插入排序、快速排序等)或递归地使用桶排序。
- 合并桶中元素:按照桶的顺序依次输出所有元素,即可得到有序序列。
void bucketSort(float arr[], int n) {
vector<float> bucket[n];
for (int i = 0; i < n; i++) {
int index = n * arr[i];
bucket[index].push_back(arr[i]);
}
for (int i = 0; i < n; i++) {
sort(bucket[i].begin(), bucket[i].end());
}
int index = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < bucket[i].size(); j++) {
arr[index++] = bucket[i][j];
}
}
}
基数排序
基数排序是一种非比较排序算法,它的基本思想是将待排序的元素分别按照位数的大小进行排序。一般的实现方法是先按照个位数排序,然后按照十位数排序,接着按照百位数排序,直到最高位数排完后,排序完成。具体的实现步骤如下:
- 找出待排序数组中最大的数,确定最大数的位数,作为排序的轮数;
- 对于每一位数,用计数排序或桶排序进行排序;
- 将排序后的数组按照位数依次组合起来,得到最终结果。
基数排序的时间复杂度为O(NK),其中K为最大数的位数,N为数组元素个数。当k比较小的时候,基数排序的效率较高。但是当k比较大时,需要分配较大的桶或计数器,空间复杂度会变高。
void countingSort(int arr[], int n, int exp) {
int count[10] = {0};
int output[n];
for (int i = 0; i < n; i++) {
count[(arr[i] / exp) % 10]++;
}
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
for (int i = n - 1; i >= 0; i--) {
output[count[(arr[i] / exp) % 10] - 1] = arr[i];
count[(arr[i] / exp) % 10]--;
}
for (int i = 0; i < n; i++) {
arr[i] = output[i];
}
}
void radixSort(int arr[], int n) {
int maxVal = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > maxVal) {
maxVal = arr[i];
}
}
for (int exp = 1; maxVal / exp > 0; exp *= 10) {
countingSort(arr, n, exp);
}
}