C++开发工程师基础面试题
- C++基础
- 1、C和C++的区别
- 2、C++中指针和引用的区别
- 3、结构体struct和共同体union(联合)的区别
- 结构体为什么要内存对齐呢?
- 结构体内存对齐规则
- 如何让结构体按照指定的对齐参数进行对齐?
- 4、#define和const的区别
- 5、重载overload,覆盖override,重写overwrite,这三者之间的区别
- 6、new、delete、malloc、free之间的关系
- 7、delete和delete[]的区别
- 8、STL库用过吗?常见的STL容器有哪些?算法用过几个?
- 9、const知道吗?解释一下其作用
- 10、虚函数是怎么实现的
- 11、堆和栈的区别
- 12、关键字static的作用
- 13、STL中map和set的原理(关联式容器)
- 14、#include
C++基础
1、C和C++的区别
1)C是面向过程的语言,是一个结构化的语言,考虑如何通过一个过程对输入进行处理得到输出;C++是面向对象的语言,主要特征是“封装、继承和多态”。封装隐藏了实现细节,使得代码模块化;派生类可以继承父类的数据和方法,扩展了已经存在的模块,实现了代码重用;多态则是“一个接口,多种实现”,通过派生类重写父类的虚函数,实现了接口的重用。
2)C和C++动态管理内存的方法不一样,C是使用malloc/free,而C++除此之外还有new/delete关键字。
3)C++支持函数重载,C不支持函数重载
4)C++中有引用,C中不存在引用的概念
2、C++中指针和引用的区别
1)指针是一个新的变量,存储了另一个变量的地址,我们可以通过访问这个地址来修改另一个变量;
引用只是一个别名,还是变量本身,对引用的任何操作就是对变量本身进行操作,以达到修改变量的目的
2)引用只有一级,而指针可以有多级
3)指针传参的时候,还是值传递,指针本身的值不可以修改,需要通过解引用才能对指向的对象进行操作
引用传参的时候,传进来的就是变量本身,因此变量可以被修改
-
引用必须被初始化,指针不必。
-
引用初始化以后不能被改变,指针可以改变所指的对象。
-
不存在指向空值的引用,但是存在指向空值的指针。
3、结构体struct和共同体union(联合)的区别
结构体:将不同类型的数据组合成一个整体,是自定义类型
共同体:不同类型的几个变量共同占用一段内存
1)结构体中的每个成员都有自己独立的地址,它们是同时存在的;
共同体中的所有成员占用同一段内存,它们不能同时存在;
2)sizeof(struct)是内存对齐后所有成员长度的总和,sizeof(union)是内存对齐后最长数据成员的长度.
3). 结构和联合都是由多个不同的数据类型成员组成, 但在任何同一时刻, 联合中只存放了一个被选中的成员(所有成员共用一块地址空间), 而结构的所有成员都存在(不同成员的存放地址不同)。
4). 对于联合的不同成员赋值, 将会对其它成员重写, 原来成员的值就不存在了, 而对于结构的不同成员赋值是互不影响的。
结构体为什么要内存对齐呢?
内存对齐是一种典型的空间换时间以提高效率的方式。
若无内存对齐情况下,按照连续存储时,1234 5678作为8字节,在结构体中,char c会存储在1号位上,而int i会存储在2345位上,而CPU在读取在访问c的时候,每次访问4个字节,没有什么问题,会先拿出1—4,再拿出5—8,但是int i被切割开了,仍需要做字节切割及字节拼接,效率很低。
而进行内存对齐时,将char c存放在1号位,再偏移3个字节,将int i存储在5–8号位,这样CPU进行访问的时候,不必做字节上的拼接和切割,效率会大大提高。
结构体内存对齐规则
1)第一个成员在与结构体偏移量为0的地址处。
2)其他成员变量要对齐到对齐数的整数倍的地址处。
注意:对齐数 = 编译器默认的一个对齐数与该成员大小的较小值。
VS中默认的对齐数为8,gcc中的对齐数为4
3)结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取最小)的整数倍。
4)如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。
#include <iostream>
#include <string>
#include <vector>
using namespace std;
struct S1
{ char j = 4;};//最大对齐数为1,结果为1,char占1
struct S2
{ char j;int i = 8,e;};//最大对齐数为4,结果为12,char为1,偏移三个字节,2个int占8
struct S3
{ char j = 4;int i = 8;double b;};//最大对齐数为8,结果为16,char与int共占8,double占8
struct S4
{ char j; double b, hours, payRate, grossPay;};//最大对齐数为8,结果为40,char占8,4个double占32
struct S5
{ S4 s;char j;};//最大对齐数为8,结果为48,S4占40,char占8
struct S6
{ S4 s;char j;int i = 8;};//最大对齐数为8,结果为48,S4占40,char与int共占8
struct S7
{ S5 s;int i;};//最大对齐数为8,结果为56,S5占48,int占8,
struct S8
{ S2 s; double i;};//最大对齐数为8,结果为24,S2占12,补至16,double占8
int main() {
//VS中默认的对齐数为8,gcc中的对齐数为4
printf("S1=%d\n",sizeof(S1));
printf("S2=%d\n", sizeof(S2));
printf("S3=%d\n", sizeof(S3));
printf("S4=%d\n", sizeof(S4));
printf("S5=%d\n", sizeof(S5));
printf("S6=%d\n", sizeof(S6));
printf("S7=%d\n", sizeof(S7));
printf("S8=%d\n", sizeof(S8));
printf("char=%d\n", sizeof(char));
printf("int=%d\n", sizeof(int));
printf("float=%d\n", sizeof(float));
printf("long=%d\n", sizeof(long));
printf("double=%d\n", sizeof(double));
printf("string=%d\n", sizeof(string));
}
输出
S1=1
S2=12
S3=16
S4=40
S5=48
S6=48
S7=56
S8=24
char=1
int=4
float=4
long=4
double=8
string=40
如何让结构体按照指定的对齐参数进行对齐?
#pragma pack(4) // 指定默认对齐数
4、#define和const的区别
1)#define定义的常量没有类型,所给出的是一个立即数;const定义的常量有类型名字,存放在静态区域
2)处理阶段不同,#define定义的宏变量在预处理时进行替换,可能有多个拷贝,const所定义的变量在编译时确定其值,只有一个拷贝。
3)#define定义的常量是不可以用指针去指向,const定义的常量可以用指针去指向该常量的地址
4)#define可以定义简单的函数,const不可以定义函数
5、重载overload,覆盖override,重写overwrite,这三者之间的区别
1)overload,将语义相近的几个函数用同一个名字表示,但是参数和返回值不同,这就是函数重载
特征:相同范围(同一个类中)、函数名字相同、参数不同、virtual关键字可有可无
2)override,派生类覆盖基类的虚函数,实现接口的重用
特征:不同范围(基类和派生类)、函数名字相同、参数相同、基类中必须有virtual关键字(必须是虚函数)
3)overwrite,派生类屏蔽了其同名的基类函数
特征:不同范围(基类和派生类)、函数名字相同、参数不同或者参数相同且无virtual关键字
6、new、delete、malloc、free之间的关系
new/delete,malloc/free都是动态分配内存的方式
1)malloc对开辟的空间大小严格指定,而new只需要对象名
2)new为对象分配空间时,调用对象的构造函数,delete调用对象的析构函数
既然有了malloc/free,C++中为什么还需要new/delete呢?
因为malloc/free是库函数而不是运算符,不能把执行构造函数和析构函数的功能强加于malloc/free
7、delete和delete[]的区别
delete只会调用一次析构函数,而delete[]会调用每个成员的析构函数
用new分配的内存用delete释放,用new[]分配的内存用delete[]释放
8、STL库用过吗?常见的STL容器有哪些?算法用过几个?
STL包括两部分内容:容器和算法
容器即存放数据的地方,比如array, vector,分为两类,序列式容器和关联式容器
序列式容器,其中的元素不一定有序,但是都可以被排序,比如vector,list,queue,stack,heap, priority-queue, slist
关联式容器,内部结构是一个平衡二叉树,每个元素都有一个键值和一个实值,比如map, set, hashtable, hash_set
算法有排序,复制等,以及各个容器特定的算法
迭代器是STL的精髓,迭代器提供了一种方法,使得它能够按照顺序访问某个容器所含的各个元素,但无需暴露该容器的内部结构,它将容器和算法分开,让二者独立设计。
9、const知道吗?解释一下其作用
const修饰类的成员变量,表示常量不可能被修改
const修饰类的成员函数,表示该函数不会修改类中的数据成员,不会调用其他非const的成员函数
10、虚函数是怎么实现的
每一个含有虚函数的类都至少有有一个与之对应的虚函数表,其中存放着该类所有虚函数对应的函数指针(地址),
类的示例对象不包含虚函数表,只有虚指针;
派生类会生成一个兼容基类的虚函数表。
11、堆和栈的区别
1)栈 stack 存放函数的参数值、局部变量,由编译器自动分配释放
堆heap,是由new分配的内存块,由应用程序控制,需要程序员手动利用delete释放,如果没有,程序结束后,操作系统自动回收
2)因为堆的分配需要使用频繁的new/delete,造成内存空间的不连续,会有大量的碎片
3)堆的生长空间向上,地址越大,栈的生长空间向下,地址越小
12、关键字static的作用
1)函数体内: static 修饰的局部变量作用范围为该函数体,不同于auto变量,其内存只被分配一次,因此其值在下次调用的时候维持了上次的值
2)模块内:static修饰全局变量或全局函数,可以被模块内的所有函数访问,但是不能被模块外的其他函数访问,使用范围限制在声明它的模块内
3)类中:修饰成员变量,表示该变量属于整个类所有,对类的所有对象只有一份拷贝
4)类中:修饰成员函数,表示该函数属于整个类所有,不接受this指针,只能访问类中的static成员变量
注意和const的区别!!!const强调值不能被修改,而static强调唯一的拷贝,对所有类的对象
13、STL中map和set的原理(关联式容器)
map和set的底层实现主要通过红黑树来实现
红黑树是一种特殊的二叉查找树
1)每个节点或者是黑色,或者是红色
2)根节点是黑色
3) 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
4)如果一个节点是红色的,则它的子节点必须是黑色的
5)从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。
特性4)5)决定了没有一条路径会比其他路径长出2倍,因此红黑树是接近平衡的二叉树。
14、#include<file.h> #include “file.h” 的区别
前者是从标准库路径寻找
后者是从当前工作路径
15、什么是内存泄漏?面对内存泄漏和指针越界,你有哪些方法?
动态分配内存所开辟的空间,在使用完毕后未手动释放,导致一直占据该内存,即为内存泄漏。
方法:malloc/free要配套,对指针赋值的时候应该注意被赋值的指针是否需要释放;使用的时候记得指针的长度,防止越界
16、定义和声明的区别
声明是告诉编译器变量的类型和名字,不会为变量分配空间
定义需要分配空间,同一个变量可以被声明多次,但是只能被定义一次
17、C++文件编译与执行的四个阶段
1)预处理:根据文件中的预处理指令来修改源文件的内容
2)编译:编译成汇编代码
3)汇编:把汇编代码翻译成目标机器指令
4)链接:链接目标代码生成可执行程序
18、STL中的vector的实现,是怎么扩容的?
vector使用的注意点及其原因,频繁对vector调用push_back()对性能的影响和原因。
vector就是一个动态增长的数组,里面有一个指针指向一片连续的空间,当空间装不下的时候,会申请一片更大的空间,将原来的数据拷贝过去,并释放原来的旧空间。当删除的时候空间并不会被释放,只是清空了里面的数据。对比array是静态空间一旦配置了就不能改变大小。
vector的动态增加大小的时候,并不是在原有的空间上持续新的空间(无法保证原空间的后面还有可供配置的空间),而是以原大小的两倍另外配置一块较大的空间,然后将原内容拷贝过来,并释放原空间。在VS下是1.5倍扩容,在GCC下是2倍扩容。
在原来空间不够存储新值时,每次调用push_back方法都会重新分配新的空间以满足新数据的添加操作。如果在程序中频繁进行这种操作,还是比较消耗性能的。
19、STL中unordered_map和map的区别
map是STL中的一个关联容器,提供键值对的数据管理。底层通过红黑树来实现,实际上是二叉排序树和非严格意义上的二叉平衡树。所以在map内部所有的数据都是有序的,且map的查询、插入、删除操作的时间复杂度都是O(logN)。
unordered_map和map类似,都是存储key-value对,可以通过key快速索引到value,不同的是unordered_map不会根据key进行排序。unordered_map底层是一个防冗余的哈希表,存储时根据key的hash值判断元素是否相同,即unoredered_map内部是无序的。
20、C++的内存管理
在C++中,内存被分成五个区:栈、堆、自由存储区、静态存储区、常量区
栈:存放函数的参数和局部变量,编译器自动分配和释放
堆:new关键字动态分配的内存,由程序员手动进行释放,否则程序结束后,由操作系统自动进行回收
自由存储区:由malloc分配的内存,和堆十分相似,由对应的free进行释放
全局/静态存储区:存放全局变量和静态变量
常量区:存放常量,不允许被修改
21、 构造函数为什么一般不定义为虚函数?而析构函数一般写成虚函数的原因 ?
1、构造函数不能声明为虚函数
1)因为创建一个对象时需要确定对象的类型,而虚函数是在运行时确定其类型的。而在构造一个对象时,由于对象还未创建成功,编译器无法知道对象的实际类型,是类本身还是类的派生类等等
2)虚函数的调用需要虚函数表指针,而该指针存放在对象的内存空间中;若构造函数声明为虚函数,那么由于对象还未创建,还没有内存空间,更没有虚函数表地址用来调用虚函数即构造函数了
2、析构函数最好声明为虚函数
首先析构函数可以为虚函数,当析构一个指向派生类的基类指针时,最好将基类的析构函数声明为虚函数,否则可以存在内存泄露的问题。
如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除指向派生类的基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全。
22、静态绑定和动态绑定的介绍
静态绑定和动态绑定是C++多态性的一种特性
1)对象的静态类型和动态类型
静态类型:对象在声明时采用的类型,在编译时确定
动态类型:当前对象所指的类型,在运行期决定,对象的动态类型可变,静态类型无法更改
2)静态绑定和动态绑定
静态绑定:绑定的是对象的静态类型,函数依赖于对象的静态类型,在编译期确定
动态绑定:绑定的是对象的动态类型,函数依赖于对象的动态类型,在运行期确定
只有虚函数才使用的是动态绑定,其他的全部是静态绑定
23、 引用是否能实现动态绑定,为什么引用可以实现
可以。因为引用(或指针)既可以指向基类对象也可以指向派生类对象,这一事实是动态绑定的关键。用引用(或指针)调用的虚函数在运行时确定,被调用的函数是引用(或指针)所指的对象的实际类型所定义的。
24、深拷贝和浅拷贝的区别
深拷贝和浅拷贝可以简单的理解为:如果一个类拥有资源,当这个类的对象发生复制过程的时候,如果资源重新分配了就是深拷贝;反之没有重新分配资源,就是浅拷贝。
25、 什么情况下会调用拷贝构造函数(三种情况)
系统自动生成的构造函数:普通构造函数和拷贝构造函数 (在没有定义对应的构造函数的时候)
生成一个实例化的对象会调用一次普通构造函数,而用一个对象去实例化一个新的对象所调用的就是拷贝构造函数
调用拷贝构造函数的情形:
1)用类的一个对象去初始化另一个对象的时候
2)当函数的参数是类的对象时,就是值传递的时候,如果是引用传递则不会调用
3)当函数的返回值是类的对象或者引用的时候
举例:
#include <iostream>
#include <string>
using namespace std;
class A{
private:
int data;
public:
A(int i){ data = i;} //自定义的构造函数
A(A && a); //拷贝构造函数
int getdata(){return data;}
};
//拷贝构造函数
A::A(A && a){
data = a.data;
cout <<"拷贝构造函数执行完毕"<<endl;
}
//参数是对象,值传递,调用拷贝构造函数
int getdata1(A a){
return a.getdata();
}
//参数是引用,引用传递,不调用拷贝构造函数
int getdata2(A &a){
return a.getdata();
}
//返回值是对象类型,会调用拷贝构造函数
A getA1(){
A a(0);
return a;
}
//返回值是引用类型,会调用拷贝构造函数,因为函数体内生成的对象是临时的,离开函数就消失
A& getA2(){
A a(0);
return a;
}
int main(){
A a1(1);
A b1(a1); //用a1初始化b1,调用拷贝构造函数
A c1=a1; //用a1初始化c1,调用拷贝构造函数
int i=getdata1(a1); //函数形参是类的对象,调用拷贝构造函数
int j=getdata2(a1); //函数形参类型是引用,不调用拷贝构造函数
A d1=getA1(); //调用拷贝构造函数
A e1=getA2(); //调用拷贝构造函数
return 0;
}
26、 C++的四种强制转换
类型转化机制可以分为隐式类型转换和显示类型转化(强制类型转换)
(new-type) expression
new-type (expression)
隐式类型转换比较常见,在混合类型表达式中经常发生;四种强制类型转换操作符:
static_cast、dynamic_cast、const_cast、reinterpret_cast
1)static_cast :编译时期的静态类型检查
static_cast < type-id > ( expression )
该运算符把expression转换成type-id类型,在编译时使用类型信息执行转换,在转换时执行必要的检测(指针越界、类型检查),其操作数相对是安全的
2)dynamic_cast:运行时的检查
用于在集成体系中进行安全的向下转换downcast,即基类指针/引用->派生类指针/引用
dynamic_cast是4个转换中唯一的RTTI操作符,提供运行时类型检查。
dynamic_cast如果不能转换返回NULL
源类中必须要有虚函数,保证多态,才能使用dynamic_cast(expression)
3)const_cast
去除const常量属性,使其可以修改 ; volatile属性的转换
4)reinterpret_cast
通常为了将一种数据类型转换成另一种数据类型
27、调试程序的方法
windows下直接使用vs的debug功能
linux下直接使用gdb,我们可以在其过程中给程序添加断点,监视等辅助手段,监控其行为是否与我们设计相符
28、extern“C”作用
extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言的进行编译,而不是C++的。
29、typdef和define区别
#define是预处理命令,在预处理是执行简单的替换,不做正确性的检查
typedef是在编译时处理的,它是在自己的作用域内给已经存在的类型一个别名
typedef (int*) pINT;
#define pINT2 int*
效果相同?实则不同!实践中见差别:pINT a,b;的效果同int *a; int *b;表示定义了两个整型指针变量。而pINT2 a,b;的效果同int *a, b;表示定义了一个整型指针变量a和整型变量b。
30、volatile关键字在程序设计中有什么作用
用volatile修饰的变量,线程在每次使用变量的时候,都会读取变量修改后的最的值。
volatile是“易变的”、“不稳定”的意思。volatile是C的一个较为少用的关键字,它用来解决变量在“共享”环境下容易出现读取错误的问题。
31、引用作为函数参数以及返回值的好处
对比值传递,引用传参的好处:
1)在函数内部可以对此参数进行修改
2)提高函数调用和运行的效率(所以没有了传值和生成副本的时间和空间消耗)
如果函数的参数实质就是形参,不过这个形参的作用域只是在函数体内部,也就是说实参和形参是两个不同的东西,要想形参代替实参,肯定有一个值的传递。函数调用时,值的传递机制是通过“形参=实参”来对形参赋值达到传值目的,产生了一个实参的副本。即使函数内部有对参数的修改,也只是针对形参,也就是那个副本,实参不会有任何更改。函数一旦结束,形参生命也宣告终结,做出的修改一样没对任何变量产生影响。
用引用作为返回值最大的好处就是在内存中不产生被返回值的副本。
但是有以下的限制:
1)不能返回局部变量的引用。因为函数返回以后局部变量就会被销毁
2)不能返回函数内部new分配的内存的引用。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一 个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak
3)可以返回类成员的引用,但是最好是const。因为如果其他对象可以获得该属性的非常量的引用,那么对该属性的单纯赋值就会破坏业务规则的完整性。
32、纯虚函数
纯虚函数是只有声明没有实现的虚函数,是对子类的约束,是接口继承
包含纯虚函数的类是抽象类,它不能被实例化,只有实现了这个纯虚函数的子类才能生成对象
普通函数是静态编译的,没有运行时多态
33、什么是野指针
野指针不是NULL指针,是未初始化或者未清零的指针,它指向的内存地址不是程序员所期望的,可能指向了受限的内存
成因:
1)指针变量没有被初始化
2)指针指向的内存被释放了,但是指针没有置NULL
3)指针超过了变量了的作用范围,比如b[10],指针b+11
33、线程安全和线程不安全
线程安全就是多线程访问时,采用了加锁机制,当一个线程访问该类的某个数据时,进行保护,其他线程不能进行访问直到该线程读取完,其他线程才可以使用,不会出现数据不一致或者数据污染。
线程不安全就是不提供数据访问保护,有可能多个线程先后更改数据所得到的数据就是脏数据。
34、C++中内存泄漏的几种情况
内存泄漏是指己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
1)类的构造函数和析构函数中new和delete没有配套
2)在释放对象数组时没有使用delete[],使用了delete
3)没有将基类的析构函数定义为虚函数,当基类指针指向子类对象时,如果基类的析构函数不是virtual,那么子类的析构函数将不会被调用,子类的资源没有正确释放,因此造成内存泄露
4)没有正确的清楚嵌套的对象指针
35、栈溢出的原因以及解决方法
1)函数调用层次过深,每调用一次,函数的参数、局部变量等信息就压一次栈
2)局部变量体积太大。
解决办法大致说来也有两种:
1> 增加栈内存的数目;增加栈内存方法如下,在vc6种依次选择Project->Setting->Link,在Category中选择output,在Reserve中输入16进制的栈内存大小如:0x10000000
2> 使用堆内存;具体实现由很多种方法可以直接把数组定义改成指针,然后动态申请内存;也可以把局部变量变成全局变量,一个偷懒的办法是直接在定义前边加个static,呵呵,直接变成静态变量(实质就是全局变量)
36、C++标准库vector以及迭代器
每种容器类型都定义了自己的迭代器类型,每种容器都定义了一队命名为begin和end的函数,用于返回迭代器。
迭代器是容器的精髓,它提供了一种方法使得它能够按照顺序访问某个容器所含的各个元素,但无需暴露该容器的内部结构,它将容器和算法分开,让二者独立设计。
37、C++ 11有哪些新特性
C++11不仅包含核心语言的新机能,而且扩展了C++的标准程序库(STL),并入了大部分的C++ Technical Report 1(TR1)程序库。C++11包括大量的新特性:包括lambda表达式,类型推导关键字auto、decltype,和模板的大量改进。
auto
C++11中引入auto第一种作用是为了自动类型推导
auto的自动类型推导,用于从初始化表达式中推断出变量的数据类型。通过auto的自动类型推导,可以大大简化我们的编程工作
decltype
decltype实际上有点像auto的反函数,auto可以让你声明一个变量,而decltype则可以从一个变量或表达式中得到类型,有实例如下:
nullptr
nullptr是为了解决原来C++中NULL的二义性问题而引进的一种新的类型,因为NULL实际上代表的是0,
lambda表达式类似Javascript中的闭包,它可以用于创建并定义匿名的函数对象,以简化编程工作。Lambda的语法如下:
函数对象参数mutable或exception声明->返回值类型{函数体}
38、C++中vector和list的区别
vector和数组类似,拥有一段连续的内存空间。vector申请的是一段连续的内存,当插入新的元素内存不够时,通常以2倍重新申请更大的一块内存,将原来的元素拷贝过去,释放旧空间。因为内存空间是连续的,所以在进行插入和删除操作时,会造成内存块的拷贝,时间复杂度为o(n)。
list是由双向链表实现的,因此内存空间是不连续的。只能通过指针访问数据,所以list的随机存取非常没有效率,时间复杂度为o(n); 但由于链表的特点,能高效地进行插入和删除。
vector拥有一段连续的内存空间,能很好的支持随机存取,因此vector::iterator支持“+”,“+=”,“<”等操作符。
list的内存空间可以是不连续,它不支持随机访问,因此list::iterator则不支持“+”、“+=”、“<”等
vector::iterator和list::iterator都重载了“++”运算符。
总之,如果需要高效的随机存取,而不在乎插入和删除的效率,使用vector;
如果需要大量的插入和删除,而不关心随机存取,则应使用list。
39、C语言的函数调用过程
函数的调用过程:
1)从栈空间分配存储空间
2)从实参的存储空间复制值到形参栈空间
3)进行运算
形参在函数未调用之前都是没有分配存储空间的,在函数调用结束之后,形参弹出栈空间,清除形参空间。
数组作为参数的函数调用方式是地址传递,形参和实参都指向相同的内存空间,调用完成后,形参指针被销毁,但是所指向的内存空间依然存在,不能也不会被销毁。
当函数有多个返回值的时候,不能用普通的 return 的方式实现,需要通过传回地址的形式进行,即地址/指针传递。
传值:传值,实际是把实参的值赋值给行参,相当于copy。那么对行参的修改,不会影响实参的值 。
传址: 实际是传值的一种特殊方式,只是他传递的是地址,不是普通的赋值,那么传地址以后,实参和行参都指向同一个对象,因此对形参的修改会影响到实参。
40、C++中的基本数据类型及派生类型
1)整型 int
2)浮点型 单精度float,双精度double
3)字符型 char
4)逻辑型 bool
5)控制型 void
基本类型的字长及其取值范围可以放大和缩小,改变后的类型就叫做基本类型的派生类型。派生类型声明符由基本类型关键字char、int、float、double前面加上类型修饰符组成。
类型修饰符包括:
short 短类型,缩短字长
long 长类型,加长字长
signed 有符号类型,取值范围包括正负值
unsigned 无符号类型,取值范围只包括正值
41、友元函数和友元类
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。
通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。
友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
1)友元函数
有元函数是可以访问类的私有成员的非成员函数。它是定义在类外的普通函数,不属于任何类,但是需要在类的定义中加以声明。
friend 类型 函数名(形式参数);
一个函数可以是多个类的友元函数,只需要在各个类中分别声明。
2)友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
friend class 类名;
使用友元类时注意:
(1) 友元关系不能被继承。
(2) 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
(3) 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明
42、C++线程中的几种锁机制
线程之间的锁有:互斥锁、条件锁、自旋锁、读写锁、递归锁。一般而言,锁的功能越强大,性能就会越低。
1)互斥锁
互斥锁用于控制多个线程对他们之间共享资源互斥访问的一个信号量。也就是说是为了避免多个线程在某一时刻同时操作一个共享资源。例如线程池中的有多个空闲线程和一个任务队列。任何是一个线程都要使用互斥锁互斥访问任务队列,以避免多个线程同时访问任务队列以发生错乱。
在某一时刻,只有一个线程可以获取互斥锁,在释放互斥锁之前其他线程都不能获取该互斥锁。如果其他线程想要获取这个互斥锁,那么这个线程只能以阻塞方式进行等待。
头文件:<pthread.h>
类型:pthread_mutex_t,
函数:pthread_mutex_init(pthread_mutex_t * mutex, const phtread_mutexattr_t * mutexattr);//动态方式创建锁,相当于new动态创建一个对象
pthread_mutex_destory(pthread_mutex_t *mutex)//释放互斥锁,相当于delete
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//以静态方式创建锁
pthread_mutex_lock(pthread_mutex_t *mutex)//以阻塞方式运行的。如果之前mutex被加锁了,那么程序会阻塞在这里。
pthread_mutex_unlock(pthread_mutex_t *mutex)
int pthread_mutex_trylock(pthread_mutex_t * mutex);//会尝试对mutex加锁。如果mutex之前已经被锁定,返回非0,;如果mutex没有被锁定,则函数返回并锁定mutex
//该函数是以非阻塞方式运行了。也就是说如果mutex之前已经被锁定,函数会返回非0,程序继续往下执行。
2)条件锁
条件锁就是所谓的条件变量,某一个线程因为某个条件为满足时可以使用条件变量使改程序处于阻塞状态。一旦条件满足以“信号量”的方式唤醒一个因为该条件而被阻塞的线程。最为常见就是在线程池中,起初没有任务时任务队列为空,此时线程池中的线程因为“任务队列为空”这个条件处于阻塞状态。一旦有任务进来,就会以信号量的方式唤醒一个线程来处理这个任务。这个过程中就使用到了条件变量pthread_cond_t。
头文件:<pthread.h>
类型:pthread_cond_t
函数:pthread_cond_init(pthread_cond_t * condtion, const phtread_condattr_t * condattr);//对条件变量进行动态初始化,相当于new创建对象
pthread_cond_destory(pthread_cond_t * condition);//释放动态申请的条件变量,相当于delete释放对象
pthread_cond_t condition = PTHREAD_COND_INITIALIZER;//静态初始化条件变量
pthread_cond_wait(pthread_cond_t * cond, pthread_mutex_t * mutex);//该函数以阻塞方式执行。如果某个线程中的程序执行了该函数,那么这个线程就会以阻塞方式等待,直到收到pthread_cond_signal或者pthread_cond_broadcast函数发来的信号而被唤醒。
注意:pthread_cond_wait函数的语义相当于:首先解锁互斥锁,然后以阻塞方式等待条件变量的信号,收到信号后又会对互斥锁加锁。
为了防止“虚假唤醒”,该函数一般放在while循环体中。例如
pthread_mutex_lock(mutex);//加互斥锁
while(条件不成立)//当前线程中条件变量不成立
{
pthread_cond_wait(cond, mutex);//解锁,其他线程使条件成立发送信号,加锁。
}
...//对进程之间的共享资源进行操作
pthread_mutex_unlock(mutex);//释放互斥锁
pthread_cond_signal(pthread_cond_t * cond);//在另外一个线程中改变线程,条件满足发送信号。唤醒一个等待的线程(可能有多个线程处于阻塞状态),唤醒哪个线程由具体的线程调度策略决定
pthread_cond_broadcast(pthread_cond_t * cond);//以广播形式唤醒所有因为该条件变量而阻塞的所有线程,唤醒哪个线程由具体的线程调度策略决定
pthread_cond_timedwait(pthread_cond_t * cond, pthread_mutex_t * mutex, struct timespec * time);//以阻塞方式等待,如果时间time到了条件还没有满足还是会结束
3)自旋锁
前面的两种锁是比较常见的锁,也比较容易理解。下面通过比较互斥锁和自旋锁原理的不同,这对于真正理解自旋锁有很大帮助。
假设我们有一个两个处理器core1和core2计算机,现在在这台计算机上运行的程序中有两个线程:T1和T2分别在处理器core1和core2上运行,两个线程之间共享着一个资源。
首先我们说明互斥锁的工作原理,互斥锁是是一种sleep-waiting的锁。假设线程T1获取互斥锁并且正在core1上运行时,此时线程T2也想要获取互斥锁(pthread_mutex_lock),但是由于T1正在使用互斥锁使得T2被阻塞。当T2处于阻塞状态时,T2被放入到等待队列中去,处理器core2会去处理其他任务而不必一直等待(忙等)。也就是说处理器不会因为线程阻塞而空闲着,它去处理其他事务去了。
而自旋锁就不同了,自旋锁是一种busy-waiting的锁。也就是说,如果T1正在使用自旋锁,而T2也去申请这个自旋锁,此时T2肯定得不到这个自旋锁。与互斥锁相反的是,此时运行T2的处理器core2会一直不断地循环检查锁是否可用(自旋锁请求),直到获取到这个自旋锁为止。
从“自旋锁”的名字也可以看出来,如果一个线程想要获取一个被使用的自旋锁,那么它会一致占用CPU请求这个自旋锁使得CPU不能去做其他的事情,直到获取这个锁为止,这就是“自旋”的含义。
当发生阻塞时,互斥锁可以让CPU去处理其他的任务;而自旋锁让CPU一直不断循环请求获取这个锁。通过两个含义的对比可以我们知道“自旋锁”是比较耗费CPU的
头文件:<linux\spinlock.h>
自旋锁的类型:spinlock_t
相关函数:初始化:spin_lock_init(spinlock_t *x);
spin_lock(x); //只有在获得锁的情况下才返回,否则一直“自旋”
spin_trylock(x); //如立即获得锁则返回真,否则立即返回假
释放锁:spin_unlock(x);
spin_is_locked(x)// 该宏用于判断自旋锁x是否已经被某执行单元保持(即被锁),如果是, 返回真,否则返回假。
注意:自旋锁适合于短时间的的轻量级的加锁机制。
4)读写锁
说到读写锁我们可以借助于“读者-写者”问题进行理解。首先我们简单说下“读者-写者”问题。
计算机中某些数据被多个进程共享,对数据库的操作有两种:一种是读操作,就是从数据库中读取数据不会修改数据库中内容;另一种就是写操作,写操作会修改数据库中存放的数据。因此可以得到我们允许在数据库上同时执行多个“读”操作,但是某一时刻只能在数据库上有一个“写”操作来更新数据。这就是一个简单的读者-写者模型。
43.什么是“引用”?申明和使用“引用”要注意哪些问题?
答:引用就是某个目标变量的“别名”(alias),对应用的操作与对变量直接操作效果完全相同。申明一个引用的时候,切记要对其进行初始化。引用声明完毕后,相当于目标变量名有两个名称,即该目标原名称和引用名,不能再把该引用名作为其他变量名的别名。声明一个引用,不是新定义了一个变量,它只表示该引用名是目标变量名的一个别名,它本身不是一种数据类型,因此引用本身不占存储单元,系统也不给引用分配存储单元。不能建立数组的引用。
44.将“引用”作为函数参数有哪些特点?
(1)传递引用给函数与传递指针的效果是一样的。这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
(2)使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
(3)使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
45.在什么时候需要使用“常引用”?
如果既要利用引用提高程序的效率,又要保护传递给函数的数据不在函数中被改变,就应使用常引用。常引用声明方式:const 类型标识符 &引用名=目标变量名;
例1
int a ;
const int &ra=a;
ra=1; //错误
a=1; //正确
例2
string foo( );
void bar(string & s);
那么下面的表达式将是非法的:
bar(foo( ));
bar(“hello world”);
原因在于foo( )和"hello world"串都会产生一个临时对象,而在C++中,这些临时对象都是const类型的。因此上面的表达式就是试图将一个const类型的对象转换为非const类型,这是非法的。引用型参数应该在能被定义为const的情况下,尽量定义为const 。
10.将“引用”作为函数返回值类型的格式、好处和需要遵守的规则?
格式:类型标识符 &函数名(形参列表及类型说明){ //函数体 }
好处:在内存中不产生被返回值的副本;(注意:正是因为这点原因,所以返回一个局部变量的引用是不可取的。因为随着该局部变量生存期的结束,相应的引用也会失效,产生runtime error!
注意事项:
(1)不能返回局部变量的引用。这条可以参照Effective C++[1]的Item 31。主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指"的引用,程序会进入未知状态。
(2)不能返回函数内部new分配的内存的引用。这条可以参照Effective C++[1]的Item 31。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak。
(3)可以返回类成员的引用,但最好是const。这条原则可以参照Effective C++[1]的Item 30。主要原因是当对象的属性是与某种业务规则(business rule)相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。
(4)流操作符重载返回值申明为“引用”的作用:
流操作符<<和>>,这两个操作符常常希望被连续使用,例如:cout << “hello” << endl; 因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。可选的其它方案包括:返回一个流对象和返回一个流对象指针。但是对于返回一个流对象,程序必须重新(拷贝)构造一个新的流对象,也就是说,连续的两个<<操作符实际上是针对不同对象的!这无法让人接受。对于返回一个流指针则不能连续使用<<操作符。因此,返回一个流对象引用是惟一选择。这个唯一选择很关键,它说明了引用的重要性以及无可替代性,也许这就是C++语言中引入引用这个概念的原因吧。
赋值操作符=。这个操作符象流操作符一样,是可以连续使用的,例如:x = j = 10;或者(x=10)=100;赋值操作符的返回值必须是一个左值,以便可以被继续赋值。因此引用成了这个操作符的惟一返回值选择。
#include<iostream.h>
int &put(int n);
int vals[10];
int error=-1;
void main()
{
put(0)=10; //以put(0)函数值作为左值,等价于vals[0]=10;
put(9)=20; //以put(9)函数值作为左值,等价于vals[9]=20;
cout<<vals[0];
cout<<vals[9];
}
int &put(int n)
{
if (n>=0 && n<=9 ) return vals[n];
else { cout<<“subscript error”; return error; }
}
(5)在另外的一些操作符中,却千万不能返回引用:±*/ 四则运算符。它们不能返回引用,Effective C++[1]的Item23详细的讨论了这个问题。主要原因是这四个操作符没有side effect,因此,它们必须构造一个对象作为返回值,可选的方案包括:返回一个对象、返回一个局部变量的引用,返回一个new分配的对象的引用、返回一个静态对象引用。根据前面提到的引用作为返回值的三个规则,第2、3两个方案都被否决了。静态对象的引用又因为((a+b) == (c+d))会永远为true而导致错误。所以可选的只剩下返回一个对象了。
46. 动态链接库和静态链接库的区别
静态链接库与动态链接库都是共享代码的方式,如果采用静态链接库,无论愿不愿意,lib指令都全部被直接包含在最终生成的EXE文件中了。但是若使用DLL, 该DLL不必被包含在最终的EXE文件中,EXE文件执行时可以动态的引用和卸载这个EXE独立的DLL文件。静态链接库和动态链接库的另外的一个区别在于静态链接库中不能再包含其他的动态链接库和静态链接库,而动态链接库中可以在包含其它的动态或静态链接库。动态库就是在需要调用其中的函数时,根据函数映射表中找到该函数然后调用堆栈中执行。如果当前工程中有多处对dll文件中同一个函数调用,那么执行时,这个函数只会留下一份拷贝。但是如果有多处对lib文件中同一个函数的调用,那么执行时,该函数在当前程序的执行空间里留下多份拷贝,而且是一处调用就产生一份拷贝。但是如果有多处对lib文件中同一个函数的调用,那么执行时,该函数将在当前程序的执行空间里留下多份拷贝,而且是一处调用就产生一份拷贝。
47用户态与内核态的区别和切换
当一个任务执行系统调用而陷入内核代码中执行时,我们就称进程处于内核状态。此时处理器处于特权机最高的内核代码。当进程处于内核态时,执行的内核代码会使用当前的内核栈。每个进程都有自己的内核栈。当进程执行用户自己的代码时,则称其处于用户态。即此时处理器在特权级最低的用户代码中运行。当正在执行用户程序而突然中断时,此时用户程序也可以象征性的处于进程的内核态。因为中断处理程序将使用当前的内核态。
内核态与用户态是操作系统的两种运行级别, cpu有三种运行模式,ring0级别最发哦,ring3级别最低。Linux进程的4GB空间,3G-4G部分大家是共享的,是内核态的地址空间,这里存放着整个内核代码和所有的内核模块,以及内核所维护的数据。用户运行一个程序,改程序创建的进程开始运行在用户态的,如果要执行文件操作,网络数据发送等操作,必须通过write,send等系统调用,这些系统会调用内核中的代码来完成操作,这是,必须切换到ring0,然后进入3GB-4GB中的内核地址空间去执行这些代码完成操作,完成后,切换Ring3,回到用户态。这样,用户态的程序就不能随意操作1内核地址空间,具有一定的安全保护作用。
用户态到内核态的3种方式: a 系统调用 b 异常 c 外围设备的中断。
48.系统调用和库函数的区别
系统调用是通向操作系统本身的接口,是面向底层硬件的。通过系统调用,可以使得用户态运行的进程和硬件设备进行交互,是操作系统留个应用程序的一个接口。库函数是把函数放到库里,供别人使用的一种方式,库函数可分为两类,一类是编译器特定的库函数,一类是C语言标准规定的库函数。
49. 虚拟内存和物理内存的区别
每个进程的4G空间只是虚拟内存空间,每次访问内存空间的某个地址,都需要把地址翻译成为实际物理内存地址; 所有进程共享同一物理内存,每个进程只把自己目前需要的虚拟内存空间映射并存储到物理内存上。进程通过页表来知道是否在物理页面上。页表中的某一个表项分为两个部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址。当进程访问某个虚拟地址,去看页表,如果发现对应的数据不在物理内存中,则却也异常。
50. 哈希表的底层实现
把带有关键字i的元素存储到表的第i个位置,使其可以直接访问。关键字索引的算法不是比较关键字值进行比较,而是以关键字值作为数组的下表索引。我们对关键字索引搜索算法进行扩展,得到散列搜索算法。
51.子类析构时要调用父类的析构函数吗?
析构函数调用的次序是先派生类的析构后基类的析构,也就是说在基类的的析构调用的时候,派生类的信息已经全部销毁了。定义一个对象时先调用基类的构造函数、然后调用派生类的构造函数;析构的时候恰好相反:先调用派生类的析构函数、然后调用基类的析构函数。
52.多态,虚函数,纯虚函数
多态:是对于不同对象接收相同消息时产生不同的动作。C++的多态性具体体现在运行和编译两个方面:在程序运行时的多态性通过继承和虚函数来体现;
在程序编译时多态性体现在函数和运算符的重载上;
虚函数:在基类中冠以关键字 virtual 的成员函数。 它提供了一种接口界面。允许在派生类中对基类的虚函数重新定义。
纯虚函数的作用:在基类中为其派生类保留一个函数的名字,以便派生类根据需要对它进行定义。作为接口而存在 纯虚函数不具备函数的功能,一般不能直接被调用。
从基类继承来的纯虚函数,在派生类中仍是虚函数。如果一个类中至少有一个纯虚函数,那么这个类被称为抽象类(abstract class)。
抽象类中不仅包括纯虚函数,也可包括虚函数。抽象类必须用作派生其他类的基类,而不能用于直接创建对象实例。但仍可使用指向抽象类的指针支持运行时多态性。
53.将“引用”作为函数返回值类型的格式、好处和需要遵守的规则?
格式:类型标识符 &函数名(形参列表及类型说明){ //函数体 }
好处:在内存中不产生被返回值的副本;(注意:正是因为这点原因,所以返回一个局部变量的引用是不可取的。因为随着该局部变量生存期的结束,相应的引用也会失效,产生runtime error!
注意事项:
(1)不能返回局部变量的引用。这条可以参照Effective C++[1]的Item 31。主要原因是局部变量会在函数返回后被销毁,因此被返回的引用就成为了"无所指"的引用,程序会进入未知状态。
(2)不能返回函数内部new分配的内存的引用。这条可以参照Effective C++[1]的Item 31。虽然不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。例如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memory leak。
(3)可以返回类成员的引用,但最好是const。这条原则可以参照Effective C++[1]的Item 30。主要原因是当对象的属性是与某种业务规则(business rule)相关联的时候,其赋值常常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。
(4)流操作符重载返回值申明为“引用”的作用:
流操作符<<和>>,这两个操作符常常希望被连续使用,例如:cout << “hello” << endl; 因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。可选的其它方案包括:返回一个流对象和返回一个流对象指针。但是对于返回一个流对象,程序必须重新(拷贝)构造一个新的流对象,也就是说,连续的两个<<操作符实际上是针对不同对象的!这无法让人接受。对于返回一个流指针则不能连续使用<<操作符。因此,返回一个流对象引用是惟一选择。这个唯一选择很关键,它说明了引用的重要性以及无可替代性,也许这就是C++语言中引入引用这个概念的原因吧。
赋值操作符=。这个操作符象流操作符一样,是可以连续使用的,例如:x = j = 10;或者(x=10)=100;赋值操作符的返回值必须是一个左值,以便可以被继续赋值。因此引用成了这个操作符的惟一返回值选择。
#include<iostream.h>
int &put(int n);
int vals[10];
int error=-1;
void main()
{
put(0)=10; //以put(0)函数值作为左值,等价于vals[0]=10;
put(9)=20; //以put(9)函数值作为左值,等价于vals[9]=20;
cout<<vals[0];
cout<<vals[9];
}
int &put(int n)
{
if (n>=0 && n<=9 ) return vals[n];
else { cout<<“subscript error”; return error; }
}
(5)在另外的一些操作符中,却千万不能返回引用:±*/ 四则运算符。它们不能返回引用,Effective C++[1]的Item23详细的讨论了这个问题。主要原因是这四个操作符没有side effect,因此,它们必须构造一个对象作为返回值,可选的方案包括:返回一个对象、返回一个局部变量的引用,返回一个new分配的对象的引用、返回一个静态对象引用。根据前面提到的引用作为返回值的三个规则,2、3两个方案都被否决了。静态对象的引用又因为((a+b) == (c+d))会永远为true而导致错误。所以可选的只剩下返回一个对象了。
54. 描述内存分配方式以及它们的区别?
1) 从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static 变量。
2) 在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集。
3) 从堆上分配,亦称动态内存分配。程序在运行的时候用malloc 或new 申请任意多少的内存,程序员自己负责在何时用free 或delete 释放内存。动态内存的生存期由程序员决定,使用非常灵活,但问题也最多。
一个 C、C++程序编译时内存分为 5 大存储区:堆区、栈区、全局区、文字常量区、程序代码区。
55.分别写出BOOL,int,float,指针类型的变量a 与“零”的比较语句。
BOOL : if ( !a ) or if(a)
int : if ( a == 0)
float : const EXPRESSION EXP = 0.000001
if ( a < EXP && a >-EXP)
pointer : if ( a != NULL) or if(a == NULL)
56.全局变量和局部变量有什么区别?是怎么实现的?操作系统和编译器是怎么知道的?
生命周期不同:全局变量随主程序创建和创建,随主程序销毁而销毁;局部变量在局部函数内部,甚至局部循环体等内部存在,退出就不存在;
使用方式不同:通过声明后全局变量程序的各个部分都可以用到;局部变量只能在局部使用;分配在栈区。
操作系统和编译器通过内存分配的位置来知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载。局部变量则分配在堆栈里面 。
57.死锁产生的条件, 以及如何避免死锁,银行家算法,产生死锁后如何解决
产生死锁的四个必要条件(互请不循):
(1) 互斥条件:一个资源每次只能被一个进程使用。
(2) 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
(3) 不剥夺条件:进程已获得的资源,在末使用完之前,不能强行剥夺。
(4) 循环等待条件:若干进程之间形成一种头尾相接的【循环等待资源】关系。
避免死锁:
(1).按同一顺序访问对象。(注:避免出现循环)
(2).避免事务中的用户交互。(注:减少持有资源的时间,较少锁竞争)
(3).保持事务简短并处于一个批处理中。(注:同(2),减少持有资源的时间)
(4).使用较低的隔离级别。(注:使用较低的隔离级别(例如已提交读)比使用较高的隔离级别(例如可序列化)持有共享锁的时间更短,减少锁竞争)
(5).使用基于行版本控制的隔离级别:
银行家算法:银行家算法是一个避免死锁的著名算法,它是以银行借贷系统的分配策略为基础,判断并保证系统的安全运行。
一句话:当一个进程申请使用资源的时候,银行家算法通过【先 试探 分配给该进程资源】,然后【通过安全性算法判断分配后的系统是否处于安全状态】,若不安全则试探分配作废,让该进程继续等待。
当一进程提出资源申请时,银行家算法执行下列步骤以决定是否向其分配资源:
1)检查该进程所需要的资源是否已超过它所宣布的最大值。
2)检查系统当前是否有足够资源满足该进程的请求。
3)系统试探着将资源分配给该进程,得到一个新状态。
4)执行安全性算法,若该新状态是安全的,则分配完成;若新状态是不安全的,则恢复原状态,阻塞该进程。
假设资源P1申请资源,银行家算法先试探的分配给它(当然先要看看当前资源池中的资源数量够不够),【若申请的资源数量小于等于Available,然后接着判断分配给P1后剩余的资源,能不能使进程队列的某个进程执行完毕】,【若没有进程可执行完毕,则系统处于不安全状态】(即此时没有一个进程能够完成并释放资源,随时间推移,系统终将处于死锁状态)。
若有进程可执行完毕,则假设回收已分配给它的资源(剩余资源数量增加),把这个进程标记为可完成,并继续判断队列中的其它进程,若所有进程都可执行完毕,则系统处于安全状态,并根据可完成进程的分配顺序生成安全序列(如{P0,P3,P2,P1}表示将申请后的剩余资源Work先分配给P0–>回收(Work+已分配给P0的A0=Work)–>分配给P3–>回收(Work+A3=Work)–>分配给P2–>······满足所有进程)。
58.变量的声明和定义有什么区别
为变量分配地址和存储空间的称为定义,不分配地址的称为声明。一个变量可以在多个地方声明,
但是只在一个地方定义。加入 extern 修饰的是变量的声明,说明此变量将在文件以外或在文件后面部分定义。说明:很多时候一个变量,只是声明不分配内存空间,直到具体使用时才初始化,分配内存空间,
如外部变量。
59.sizeof 和 strlen 的区别
sizeof 和 strlen 有以下区别:
1 sizeof 是一个操作符,strlen 是库函数。
2 sizeof 的参数可以是数据的类型,也可以是变量,而 strlen 只能以结尾为‘\0‘的字符串作参数。
3 编译器在编译时就计算出了 sizeof 的结果。而 strlen 函数必须在运行时才能计算出来。并且 sizeof 计算的是数据类型占内存的大小,而 strlen 计算的是字符串实际的长度。
4 数组做 sizeof 的参数不退化,传递给 strlen 就退化为指针了。
注意:有些是操作符看起来像是函数,而有些函数名看起来又像操作符,这类容易混淆的名称一定要加以区分,否则遇到数组名这类特殊数据类型作参数时就很容易出错。最容易混淆为函数的操作符就是 sizeof。
60.C 语言的关键字 static 和 C++ 的关键字 static 有什么区别
在 C 中 static 用来修饰局部静态变量和外部静态变量、函数。而 C++中除了上述功能外,还用来定义类的成员变量和函数。即静态成员和静态成员函数。
注意:编程时 static 的记忆性,和全局性的特点可以让在不同时期调用的函数进行通信,传递信息,而 C++的静态成员则可以在多个对象实例间进行通信,传递信息。
61.一个指针可以是 volatile 吗?
可以,因为指针和普通变量一样,有时也有变化程序的不可控性。常见例:子中断服务子程序修改一个指向一个 buffer 的指针时,必须用 volatile 来修饰这个指针。
说明:指针是一种普通的变量,从访问上没有什么不同于其他变量的特性。其保存的数值是个整型数据,和整型变量不同的是,这个整型数据指向的是一段内存地址。
62.a 和&a 有什么区别
请写出以下代码的打印结果,主要目的是考察 a 和&a 的区别。
#include<stdio.h>
void main( void )
{
int a[5]={1,2,3,4,5};
int *ptr=(int *)(&a+1);
printf("%d,%d",*(a+1),*(ptr-1)); return;
}
输出结果:2,5。
注意:数组名 a 可以作数组的首地址,而&a 是数组的指针。思考,将原式的 int *ptr=(int *)(&a+1); 改为 int *ptr=(int *)(a+1);时输出结果将是什么呢?
63.简述 strcpy、sprintf 与 memcpy 的区别?
三者主要有以下不同之处:
(1) 操作对象不同,strcpy 的两个操作对象均为字符串,sprintf 的操作源对象可以是多种数据类型,目的操作对象是字符串,memcpy 的两个对象就是两个任意可操作的内存地址,并不限于何种数据类型。
(2) 执行效率不同,memcpy 最高,strcpy 次之,sprintf 的效率最低。
(3) 实现功能不同,strcpy 主要实现字符串变量间的拷贝,sprintf 主要实现其他数据类型格式到字符串的转化,memcpy 主要是内存块间的拷贝。
说明:strcpy、sprintf 与 memcpy 都可以实现拷贝的功能,但是针对的对象不同,根据实际需求,来选择合适的函数实现拷贝功能。
64.C++的空类有哪些成员函数
1). 缺省构造函数。
2).缺省拷贝构造函数。
3). 缺省析构函数。
4). 缺省赋值运算符。
5).缺省取址运算符。
6).缺省取址运算符 const。
注意:有些书上只是简单的介绍了前四个函数。没有提及后面这两个函数。但后面这两个函数也是空类的默认函数。另外需要注意的是,只有当实际使用这些函数的时候,编译器才会去定义它们。
65.谈谈你对拷贝构造函数和赋值运算符的认识?
拷贝构造函数和赋值运算符重载有以下两个不同之处:
(1) 拷贝构造函数生成新的类对象,而赋值运算符不能。
(2) 由于拷贝构造函数是直接构造一个新的类对象,所以在初始化这个对象之前不用检验源对象是否和新建对象相同。而赋值运算符则需要这个操作,另外赋值运算中如果原来的对象中有内存分配要先把内存释放掉
注意:当有类中有指针类型的成员变量时,一定要重写拷贝构造函数和赋值运算符,不要使用默认的。
66.用 C++设计一个不能被继承的类
template <typename T> class A
{
friend T; private:
A() {}
~A() {}
};
class B : virtual public A<B>
{ public:
B() {}
~B() {}
};
class C : virtual public B
{ public:
C() {}
~C() {}
};
void main( void )
{
B b; //C c;
return;
}
注意:构造函数是继承实现的关键,每次子类对象构造时,首先调用的是父类的构造函数,然后才是自己的。
67.简述多态实现的原理
编译器发现一个类中有虚函数,便会立即为此类生成虚函数表 vtable。虚函数表的各表项为指向对应虚函数的指针。编译器还会在此类中隐含插入一个指针 vptr(对 vc 编译器来说,它插在类的第一个位置上)指向虚函数表。调用此类的构造函数时,在类的构造函数中,编译器会隐含执行 vptr 与 vtable 的关联代码,将 vptr 指向对应的 vtable,将类与此类的 vtable 联系了起来。另外在调用类的构造函数时,指向基础类的指针此时已经变成指向具体的类的 this 指针,这样依靠此 this 指针即可得到正确的 vtable,。
如此才能真正与函数体进行连接,这就是动态联编,实现多态的基本原理。
注意:一定要区分虚函数,纯虚函数、虚拟继承的关系和区别。牢记虚函数实现原理,因为多态
C++面试的重要考点之一,而虚函数是实现多态的基础。
68.链表和数组有什么区别
数组和链表有以下几点不同:
(1) 存储形式:数组是一块连续的空间,声明时就要确定长度。链表是一块可不连续的动态空间,长度可变,每个结点要保存相邻结点指针。
(2) 数据查找:数组的线性查找速度快,查找操作直接使用偏移地址。链表需要按顺序检索结点,效率低。
(3) 数据插入或删除:链表可以快速插入和删除结点,而数组则可能需要大量数据移动。
(4) 越界问题:链表不存在越界问题,数组有越界问题。
说明:在选择数组或链表数据结构时,一定要根据实际需要进行选择。数组便于查询,链表便于插入删除。数组节省空间但是长度固定,链表虽然变长但是占了更多的存储空间。
69.怎样把一个单链表反序?
递归实现:
List *reverse( List *oldList, List *newHead = NULL )
{
List *next = oldList-> next; //记录上次翻转后的链表
oldList-> next = newHead; //将当前结点插入到翻转后链表的开头
newHead = oldList; //递归处理剩余的链表
return ( next==NULL )? newHead: reverse( t, newHead );
}
循环算法实现:
List reverse(List n)
{
if(!n) //判断链表是否为空,为空即退出。
{
return n;
}
list cur = n.next; //保存头结点的下个结点
list pre = n;
list tmp; //保存头结点
pre.next = null; //头结点的指针指空,转换后变尾结点
while ( NULL != cur.next ) //循环直到 cur.next 为空
{
tmp = cur;
}
tmp.next = pre;
pre = tmp;
cur = cur.next;
}
return tmp; //f 返回头指针
}
70.typedef 和 define 有什么区别
(1) 用法不同:typedef 用来定义一种数据类型的别名,增强程序的可读性。define 主要用来定义常量,以及书写复杂使用频繁的宏。
(2) 执行时间不同:typedef 是编译过程的一部分,有类型检查的功能。define 是宏定义,是预编译的部分,其发生在编译之前,只是简单的进行字符串的替换,不进行类型的检查。
(3) 作用域不同:typedef 有作用域限定。define 不受作用域约束,只要是在 define 声明后的引用都是正确的。
(4) 对指针的操作不同:typedef 和 define 定义的指针时有很大的区别。
注意:typedef 定义是语句,因为句尾要加上分号。而 define 不是语句,千万不能在句尾加分号。
71.extern 有什么作用
extern 标识的变量或者函数声明其定义在别的文件中,提示编译器遇到此变量和函数时在其它模块中寻找其定义。
72.流操作符重载为什么返回引用
在程序中,流操作符>>和<<经常连续使用。因此这两个操作符的返回值应该是一个仍旧支持这两个操作符的流引用。其他的数据类型都无法做到这一点。
注意:除了在赋值操作符和流操作符之外的其他的一些操作符中,如+、-、*、/等却千万不能返回引用。因为这四个操作符的对象都是右值,因此,它们必须构造一个对象作为返回值。
73.简述指针常量与常量指针区别
指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。常量指针是指定义了一个指针,这个指针指向一个只读的对象,不能通过常量指针来改变这个对象的值。
指针常量强调的是指针的不可改变性,而常量指针强调的是指针对其所指对象的不可改变性。
注意:无论是指针常量还是常量指针,其最大的用途就是作为函数的形式参数,保证实参在被调用函数中的不可改变特性。
74.如何避免“野指针”
“野指针”产生原因及解决办法如下:
(1) 指针变量声明时没有被初始化。解决办法:指针声明时初始化,可以是具体的地址值,也可让它指向 NULL。
(2) 指针 p 被 free 或者 delete 之后,没有置为 NULL。解决办法:指针指向的内存空间被释放后指针应该指向 NULL。
(3) 指针操作超越了变量的作用范围。解决办法:在变量的作用域结束前释放掉变量的地址空间并且让指针指向 NULL。
注意:“野指针”的解决方法也是编程规范的基本原则,平时使用指针时一定要避免产生“野指针”,在使用指针前一定要检验指针的合法性。
75.谈谈你对面向对象的认识
面向对象可以理解成对待每一个问题,都是首先要确定这个问题由几个部分组成,而每一个部分其实就是一个对象。然后再分别设计这些对象,最后得到整个程序。传统的程序设计多是基于功能的思想来进行考虑和设计的,而面向对象的程序设计则是基于对象的角度来考虑问题。这样做能够使得程序更加的简洁清晰。
说明:编程中接触最多的“面向对象编程技术”仅仅是面向对象技术中的一个组成部分。发挥面向对象技术的优势是一个综合的技术问题,不仅需要面向对象的分析,设计和编程技术,而且需要借助必要的建模和开发工具。
76.gcc和g++的区别
gcc和g++都是GNU(组织)的一个编译器。
误区一:gcc只能编译c代码,g++只能编译c++代码
两者都可以,但是请注意:
1.后缀为.c的,gcc把它当作是C程序,而g++当作是c++程序;后缀为.cpp的,两者都会认为是c++程序,注意,虽然c++是c的超集,但是两者对语法的要求是有区别的。C++的语法规则更加严谨一些。
2.编译阶段,g++会调用gcc,对于c++代码,两者是等价的,但是因为gcc命令不能自动和C++程序使用的库联接,所以通常用g++来完成链接,为了统一起见,干脆编译/链接统统用g++了,这就给人一种错觉,好像cpp程序只能用g++似的。
误区二:gcc不会定义__cplusplus宏,而g++会
实际上,这个宏只是标志着编译器将会把代码按C还是C++语法来解释,如上所述,如果后缀为.c,并且采用gcc编译器,则该宏就是未定义的,否则,就是已定义。
误区三:编译只能用gcc,链接只能用g++
严格来说,这句话不算错误,但是它混淆了概念,应该这样说:编译可以用gcc/g++,而链接可以用g++或者gcc -lstdc++。因为gcc命令不能自动和C++程序使用的库联接,所以通常使用g++来完成联接。但在编译阶段,g++会自动调用gcc,二者等价。
77.C++中的inline关键字用法介绍
nline 关键字用来定义一个类的内联函数,引入它的主要原因是用它替代C中表达式形式的宏定义。
表达式形式的宏定义一例:
#define ExpressionName(Var1,Var2) ((Var1)+(Var2))*((Var1)-(Var2))
为什么要取代这种形式呢?
1. 首先谈一下在C中使用这种形式宏定义的原因,C语言是一个效率很高的语言,这种宏定义在形式及使用上像一个函数,但它使用预处理器实现,没有了参数压栈,代码生成 等一系列的操作,因此,效率很高,这是它在C中被使用的一个主要原因。
2. 这种宏定义在形式上类似于一个函数,但在使用它时,仅仅只是做预处理器符号表中的简单替换,因此它不能进行参数有效性的检测,也就不能享受C++编译器严格类型检查的好处,另外它的返回值也不能被强制转换为可转换的合适的类型,这样,它的使用就存在着一系列的隐患和局限性。
3. 在C++中引入了类及类的访问控制,这样,如果一个操作或者说一个表达式涉及到类的保护成员或私有成员,你就不可能使用这种宏定义来实现(因为无法将this指针放在合适的位置)。
4. inline 推出的目的,也正是为了取代这种表达式形式的宏定义,它消除了它的缺点,同时又很好地继承了它的优点。
计算机网络基础
1. C/C++内存有哪几种类型?
C中,内存分为5个区:堆(malloc)、栈(如局部变量、函数参数)、程序代码区(存放二进制代码)、全局/静态存储区(全局变量、static变量)和常量存储区(常量)。此外,C++中有自由存储区(new)一说。
全局变量、static变量会初始化为零,而堆和栈上的变量是随机的,不确定的。
2. 堆和栈的区别?
1).堆存放动态分配的对象——即那些在程序运行时分配的对象,比如局部变量,其生存期由程序控制;
2).栈用来保存定义在函数内的非static对象,仅在其定义的程序块运行时才存在;
3).静态内存用来保存static对象,类static数据成员以及定义在任何函数外部的变量,static对象在使用之前分配,程序结束时销毁;
4).栈和静态内存的对象由编译器自动创建和销毁。
3. 堆和自由存储区的区别?
总的来说,堆是C语言和操作系统的术语,是操作系统维护的一块动态分配内存;自由存储是C++中通过new与delete动态分配和释放对象的抽象概念。他们并不是完全一样。
从技术上来说,堆(heap)是C语言和操作系统的术语。堆是操作系统所维护的一块特殊内存,它提供了动态分配的功能,当运行程序调用malloc()时就会从中分配,稍后调用free可把内存交还。而自由存储是C++中通过new和delete动态分配和释放对象的抽象概念,通过new来申请的内存区域可称为自由存储区。基本上,所有的C++编译器默认使用堆来实现自由存储,也即是缺省的全局运算符new和delete也许会按照malloc和free的方式来被实现,这时藉由new运算符分配的对象,说它在堆上也对,说它在自由存储区上也正确。
4. 程序编译的过程?
程序编译的过程中就是将用户的文本形式的源代码(c/c++)转化成计算机可以直接执行的机器代码的过程。主要经过四个过程:预处理、编译、汇编和链接。具体示例如下。
一个hello.c的c语言程序如下。
#include <stdio.h>
int main()
{
printf("happy new year!\n");
return 0;
}
5. 计算机内部如何存储负数和浮点数?
负数比较容易,就是通过一个标志位和补码来表示。
对于浮点类型的数据采用单精度类型(float)和双精度类型(double)来存储,float数据占用32bit,double数据占用64bit,我们在声明一个变量float f= 2.25f的时候,是如何分配内存的呢?如果胡乱分配,那世界岂不是乱套了么,其实不论是float还是double在存储方式上都是遵从IEEE的规范的,float遵从的是IEEE R32.24 ,而double 遵从的是R64.53。更多可以参考浮点数表示。
无论是单精度还是双精度在存储中都分为三个部分:
1). 符号位(Sign) : 0代表正,1代表为负
2). 指数位(Exponent):用于存储科学计数法中的指数数据,并且采用移位存储
3). 尾数部分(Mantissa):尾数部分
其中float的存储方式如下图所示:
在这里插入图片描述
而双精度的存储方式如下图:
在这里插入图片描述
6. 函数调用的过程?
如下结构的代码,
int main(void)
{
...
d = fun(a, b, c);
cout<<d<<endl;
...
return 0;
}
调用fun()的过程大致如下:
main()========
1).参数拷贝(压栈),注意顺序是从右到左,即c-b-a;
2).保存d = fun(a, b, c)的下一条指令,即cout<<d<<endl(实际上是这条语句对应的汇编指令的起始位置);
3).跳转到fun()函数,注意,到目前为止,这些都是在main()中进行的;
fun()=====
4).移动ebp、esp形成新的栈帧结构;
5).压栈(push)形成临时变量并执行相关操作;
6).return一个值;
7).出栈(pop);
8).恢复main函数的栈帧结构;
9).返回main函数;
main()========
。。。
7. 左值和右值
不是很严谨的来说,左值指的是既能够出现在等号左边也能出现在等号右边的变量(或表达式),右值指的则是只能出现在等号右边的变量(或表达式)。举例来说我们定义的变量 a 就是一个左值,而malloc返回的就是一个右值。或者左值就是在程序中能够寻值的东西,右值就是一个具体的真实的值或者对象,没法取到它的地址的东西(不完全准确),因此没法对右值进行赋值,但是右值并非是不可修改的,比如自己定义的class, 可以通过它的成员函数来修改右值。
8. 什么是内存泄漏?面对内存泄漏和指针越界,你有哪些方法?你通常采用哪些方法来避免和减少这类错误?
用动态存储分配函数动态开辟的空间,在使用完毕后未释放,结果导致一直占据该内存单元即为内存泄露。
1). 使用的时候要记得指针的长度.
2). malloc的时候得确定在那里free.
3). 对指针赋值的时候应该注意被赋值指针需要不需要释放.
4). 动态分配内存的指针最好不要再次赋值.
5). 在C++中应该优先考虑使用智能指针.
9.进程间如何通信?
答:信号、信号量、消息队列、共享内存
10.在网络编程中涉及并发服务器,使用多进程与多线程的区别?
(1)线程执行开销小,但不利于资源管理和保护;进程则相反,进程可跨越机器迁移。
(2)多进程时每个进程都有自己的内存空间,而多线程间共享内存空间;
(3)线程产生的速度快,线程间通信快、切换快;
(4)线程的资源利用率比较好;
(5)线程使用公共变量或者资源时需要同步机制。
11.请描述进程和线程的区别?
(1)进程是程序的一次执行,线程是进程中的执行单元;
(2)进程间是独立的,这表现在内存空间、上下文环境上,线程运行在进程中;
(3)一般来讲,进程无法突破进程边界存取其他进程内的存储空间;而同一进程所产生的线程共享内存空间;
(4)同一进程中的两段代码不能同时执行,除非引入多线程。
12.请讲述堆和栈的区别。
(1)申请方式不同。栈上有系统自动分配和释放;堆上有程序员自己申请并指明大小;
(2)栈是向低地址扩展的数据结构,大小很有限;堆是向高地址扩展,是不连续的内存区域,空间相对大且灵活;
(3)栈由系统分配和释放速度快;堆由程序员控制,一般较慢,且容易产生碎片。
13.请阐述HTTPS建立连接过程?
HTTPS是在HTTP的基础上和ssl/tls证书结合起来的一种协议,保证了传输过程中的安全性,减少了被恶意劫持的可能.很好的解决了解决了http的三个缺点(被监听、被篡改、被伪装)
建立连接:当客户在浏览器中输入网址的并且按下回车,浏览器会在浏览器DNS缓存,本地DNS缓存,和Hosts中寻找对应的记录,如果没有获取到则会请求DNS服务来获取对应的ip
当获取到ip后,tcp连接会进行三次握手建立连接
tcp的三次挥手和四次挥手
三次握手(建立连接)
第一次:建立连接时,客户端发送SYN包(syn=j)到服务器,并进入SYN_SEND状态,等待服务器确认;
第二次:服务器收到SYN包,向客户端返回ACK(ack=j+1),同时自己也发送一个SYN包(syn=k),即SYN+ACK包,此时服务器进入SYN_RCVD状态;
第三次:客户端收到服务器的SYN+ACK包,向服务器发送确认包ACK(ack=k+1),此包发送完毕,客户端和服务器进入ESTABLISHED状态,完成三次握手。
完成三次握手,客户端与服务器开始传送数据,也就是ESTABLISHED状态。
三次握手保证了不会建立无效的连接,从而浪费资源。
四次挥手(断开连接)
第一次: TCP客户端发送一个FIN,用来关闭客户到服务器的数据传送。
第二次:服务器收到这个FIN,它发回一个ACK,确认序号为收到的序号加1。和SYN一样,一个FIN将占用一个序号。
第三次:服务器关闭客户端的连接,发送一个FIN给客户端。
第四次:客户端发回ACK报文确认,并将确认序号设置为收到序号加1。
HTTP请求过程
1.建立连接完毕以后客户端会发送响应给服务端
2.服务端接受请求并且做出响应发送给客户端
3.客户端收到响应并且解析响应响应给客户
1.在使用HTTPS是需要保证服务端配置正确了对应的安全证书
2.客户端发送请求到服务端
3.服务端返回公钥和证书到客户端
4.客户端接收后会验证证书的安全性,如果通过则会随机生成一个随机数,用公5.钥对其加密,发送到服务端
6.服务端接受到这个加密后的随机数后会用私钥对其解密得到真正的随机数,随后用这个随机数当做私钥对需要发送的数据进行对称加密
7.客户端在接收到加密后的数据使用私钥(即生成的随机值)对数据进行解密并且解析数据呈现结果给客户
8.SSL加密建立
14.请画出OSI和TCP/IP协议栈的对应关系。
15.请阐释TCP滑动窗口协议原理和过程。
TCP协议里窗口机制有2种:一种是固定的窗口大小;另一种是滑动的窗口。
这个窗口大小就是我们一次传输几个数据。对所有数据帧按顺序赋予编号,发送方在发送过程中始终保持着一个发送窗口,只有落在发送窗口内的帧才允许被发送;同时接收方也维持着一个接收窗口,只有落在接收窗口内的帧才允许接收。这样通过调整发送方窗口和接收方窗口的大小可以实现流量控制。
接下来我们要把重心放在网络传输的可靠性上面。一起来看TCP协议,它是如何解决网络传输不可靠的问题?这其中有个很关键的部分,就是我们的滑动窗口协议。
注意:下面的内容不仅仅只是讲解了“TCP滑动窗口机制与原理”,也阐述了“TCP固定窗口协议与TCP滑动窗口协议的区别”,以及“TCP固定窗口协议与TCP滑动窗口协议的优缺点”。
滑动窗口协议:
1.TCP协议的使用
2.维持发送方/接收方缓冲区
缓冲区是用来解决网络之间数据不可靠的问题,例如丢包,重复包,出错,乱序。
在TCP协议中,发送方和接受方通过各自维护自己的缓冲区。通过商定包的重传机制等一系列操作,来解决不可靠的问题。
问题一:如何保证次序?
问:在我们滑动窗口协议之前,我们如何来保证发送方与接收方之间,每个包都能被收到。并且是按次序的呢?
答:TCP固定窗口协议。
发送方发送一个包1,这时候接收方确认包1。
发送包2,确认包2。
就这样一直下去,直到把数据完全发送完毕,这样就结束了。那么就解决了丢包,出错,乱序等一些情况!
同时也存在一些问题。
问题:吞吐量非常的低。我们发完包1,一定要等确认包1。我们才能发送第二个包。
问题二:如何提高吞吐量?
问:那么我们就不能先连发几个包等他一起确认吗?这样的话,我们的速度会不会更快,吞吐量更高些呢?
答:TCP滑动窗口协议。
如图,这个就是我们把两个包一起发送,然后一起确认。可以看出我们改进的方案比之前的好很多,所花的时间只是一个来回的时间。接下来,我们还有一个问题:改善了吞吐量的问题。
问题三:如何实现最优解?
问:我们每次需要发多少个包过去呢?发送多少包是最优解呢?
我们能不能把第一个和第二个包发过去后,收到第一个确认包就把第三个包发过去呢?而不是去等到第二个包的确认包才去发第三个包。这样就很自然的产生了我们"滑动窗口"的实现。
在图中,我们可看出灰色1号2号3号包已经发送完毕,并且已经收到Ack。这些包就已经是过去式。
4、5、6、7号包是黄色的,表示已经发送了。但是并没有收到对方的Ack,所以也不知道接收方有没有收到。
8、9、10号包是绿色的。是我们还没有发送的。这些绿色也就是我们接下来马上要发送的包。
可以看出我们的窗口正好是11格。后面的11-16还没有被读进内存。要等4号-10号包有接下来的动作后,我们的包才会继续往下发送。
可以看到4号包对方已经被接收到,所以被涂成了灰色。“窗口”就往右移一格,这里只要保证“窗口”是7格的。 我们就把11号包读进了我们的缓存。进入了“待发送”的状态。8、9号包已经变成了黄色,表示已经发送出去了。接下来的操作就是一样的了,确认包后,窗口往后移继续将未发送的包读进缓存,把“待发送“状态的包变为”已发送“。
丢包情况
有可能我们包发过去,对方的Ack丢了。也有可能我们的包并没有发送过去。从发送方角度看就是我们没有收到Ack。
发生的情况:一直在等Ack。
如果一直等不到的话,我们也会把读进缓存的待发送的包也一起发过去。但是,这个时候我们的窗口已经发满了。所以并不能把12号包读进来,而是始终在等待5号包的Ack。
问:如果我们这个Ack始终不来怎么办呢?
答:这时候我们有个解决方法:超时重发。
超时重发:这里有一点要说明:这个Ack是要按顺序的。必须要等到5的Ack收到,才会把6-11的Ack发送过去。这样就保证了滑动窗口的一个顺序。
这时候可以看出5号包已经接受到Ack,后面的6、7、8号包也已经发送过去已Ack。窗口便继续向后移动。
总结:
从我们为了增加网络的吞吐量,想讲数据包一起发送过去,这时候便产生了“滑动窗口”这种协议。有了“滑动窗口”这个概念,我们又解决了其中出现的一些问题。例如丢包,我们又通过重发的机制去解决了。
16.请阐释ARQ协议的原理和过程。
ARQ协议:即自动重传请求(Automatic Repeat-reQuest),是OSI模型中数据链路层和传输层的错误纠正协议之一。它通过使用确认和超时这两个机制,在不可靠服务的基础上实现可靠的信息传输。
如果发送方在发送后一段时间之内没有收到确认帧,它通常会重新发送。
ARQ包括停止等待ARQ协议和连续ARQ协议,拥有错误检测(Error Detection)、正面确认(Positive Acknowledgment)、超时重传(Retransmission after Timeout)和 负面确认及重传(Negative Acknowledgment and Retransmission)等机制。
ARQ模型响应有两种,UNA(此编号前所有包已收到,如TCP)和ACK(该编号包已收到) 。
(1)停止等待ARQ协议
要想弄明白为什么TCP要使用连续ARQ协议,首先需要弄清楚停止等待ARQ协议的原理。
TCP 连接是全双工的连接,也就是说在通信的时候,双方既是发送方,也是接收方。下面为了简化问题,只考虑一方发送,一方接受的情况。其中,A作为发送方,B作为接收方。
1.无差错情况
A发送分组M1,发送完就暂停发送,等待B的确认。B收到M1就向A发送确认。A在收到了对M1的确认后,就再发送下一个分组M2。依次下去发送剩余的数据…如下图所示:
2.出现差错
如果A发送的过程中出现差错,B在接收M1时检测出了差错,就丢弃M1,其他什么都不做(也不会通知A收到有差错的分组)。又或者A传送的过程中分组丢失了,以上这两种情况下,B不会发送任何信息。
既然说它是可靠传输协议,那自然有它可靠的方法:如果发生以上的情况,A只要超过了一段时间仍然没有收到确认,就认为刚才发送的分组丢失了,所以它会重传刚刚的发送过的分组,也就是所谓的超时重传。
超时重传的原理也很简单:发送方发送完一个分组后,就会设置一个超时计时器,如果超时计时器到期之前没有收到接收方发来的确认信息,则会重发刚发送过的分组;如果收到确认信息,则撤销该超时计时器。如下图所示:
这里应该注意的是:
①既然发送方发送的分组可能丢失或者有差错,可能需要重传,那么它必须暂时保留已发送的分组副本,只有收到确认后,才清除这个副本。
②分组和确认分组信息都应该有各自的编号,用来标示每一个分组和确认信息。(这样才知道需要发送哪个分组,收到了哪个分组的确认信息)
③超时计时器设置的时间应该略长于分组传送往返时间。
3.确认丢失和确认延迟
没有正常进行通信,除了发送方出现问题外,接收方同时也可能存在问题。
例如,如果A发送了M1分组,到达B,B发送了M1确认信息,但由于网络原因,该确认信息丢失。那么这个时候,A在超时重传时间内,没有收到B的确认信息,而且它并不知道是自己的分组有差错、丢失,还是B发生的确认丢失了。因此,A会在超时重传过后,重传M1分组。
接收方B会采取这两个行动:
①B会丢弃M1分组,不向上层交付。(B之前已经收到过M1分组了)
②向A发送确认(因为A重发了,肯定重传时间内没有收到确认信息)
还有可能是另一种情况,就是B发送了确认,没有丢失,但是延迟了。也就是说,B发送的确认在A超时计时器过期后才到达。 这种情况下,A收到确认信息后会丢弃,然后重传刚才的分组,B收到后,丢弃重复的分组,并重传确认信息。
根据上述的确认和重传机制,我们就可以在不可靠的网络上实现可靠的传输。
4.信道利用率
停止等待ARQ协议的优点是简单,但也有很严重的问题,就是信道利用率太低。如下图所示:
信道利用率U = TD / (TD + RTT + TA)
(2)连续ARQ协议
由于停止等待ARQ协议信道利用率太低,所以需要使用连续ARQ协议来进行改善。这个协议会连续发送一组数据包,然后再等待这些数据包的ACK。
发送方采用流水线传输。流水线传输就是发送方可以连续发送多个分组,不必每发完一个分组就停下来等待对方确认。如下图所示:
连续ARQ协议通常是结合滑动窗口协议来使用的,发送方需要维持一个发送窗口,如下图所示:
图(a)是发送方维持的发送窗口,它的意义是:位于发送窗口内的5个分组都可以连续发送出去,而不需要等待对方的确认,这样就提高了信道利用率。
连续ARQ协议规定,发送方每收到一个确认,就把发送窗口向前滑动一个分组的位置。例如上面的图(b),当发送方收到第一个分组的确认,就把发送窗口向前移动一个分组的位置。如果原来已经发送了前5个分组,则现在可以发送窗口内的第6个分组。
接收方一般都是采用累积确认的方式。也就是说接收方不必对收到的分组逐个发送确认。而是在收到几个分组后,对按序到达的最后一个分组发送确认。如果收到了这个分组确认信息,则表示到这个分组为止的所有分组都已经正确接收到了。
累积确认的优点是容易实现,即使确认丢失也不必重传。
但缺点是,不能正确的向发送方反映出接收方已经正确收到的所有分组的信息。
比如发送方发送了前5个分组,而中间的第3个分组丢失了,这时候接收方只能对前2个发出确认。而不知道后面3个分组的下落,因此只能把后面的3个分组都重传一次,这种机制叫Go-back-N(回退N),表示需要再退回来重传已发送过的N个分组。
17.进程间通信的方式有哪些?
进程间通信的方式有:共享内存, 管道 ,Socket ,消息队列 , DDE等。
18.SendMessage和PostMessage有什么区别
SendMessage将指定的消息发送到一个或多个窗口。此函数为指定的窗口调用窗口程序,直到窗口程 序处理完消息再返回。而函数PostMessage不同,将一个消息寄送到一个线程的消息队列后立即返回。
19.阐述一下Windows消息处理机制?
C vs C++
1. C和C++的区别?
1). C++是C的超集;
2). C是一个结构化语言,它的重点在于算法和数据结构。C程序的设计首要考虑的是如何通过一个过程,对输入(或环境条件)进行运算处理得到输出(或实现过程(事务)控制),而对于C++,首要考虑的是如何构造一个对象模型,让这个模型能够契合与之对应的问题域,这样就可以通过获取对象的状态信息得到输出或实现过程(事务)控制。
2. int fun() 和 int fun(void)的区别?
这里考察的是c 中的默认类型机制。
在c中,int fun() 会解读为返回值为int(即使前面没有int,也是如此,但是在c++中如果没有返回类型将报错),输入类型和个数没有限制, 而int fun(void)则限制输入类型为一个void。
在c++下,这两种情况都会解读为返回int类型,输入void类型。
3. const 有什么用途
主要有三点:
1).定义只读变量,或者常量(只读变量和常量的区别参考下面一条);
2).修饰函数的参数和函数的返回值;
3).修饰函数的定义体,这里的函数为类的成员函数,被const修饰的成员函数代表不能修改成员变量的值,因此const成员函数只能调用const成员函数;
4).只读对象。只读对象只能调用const成员函数。
class Screen {
public:
const char cha; //const成员变量
char get() const; //const成员函数
};
const Screen screen; //只读对象
4. 在C中用const 能定义真正意义上的常量吗?C++中的const呢?
不能。c中的const仅仅是从编译层来限定,不允许对const 变量进行赋值操作,在运行期是无效的,所以并非是真正的常量(比如通过指针对const变量是可以修改值的),但是c++中是有区别的,c++在编译时会把const常量加入符号表,以后(仍然在编译期)遇到这个变量会从符号表中查找,所以在C++中是不可能修改到const变量的。
补充:
1). c中的局部const常量存储在栈空间,全局const常量存在只读存储区,所以全局const常量也是无法修改的,它是一个只读变量。
2). 这里需要说明的是,常量并非仅仅是不可修改,而是相对于变量,它的值在编译期已经决定,而不是在运行时决定。
3).c++中的const 和宏定义是有区别的,宏是在预编译期直接进行文本替换,而const发生在编译期,是可以进行类型检查和作用域检查的。
4).c语言中只有enum可以实现真正的常量。
5). c++中只有用字面量初始化的const常量会被加入符号表,而变量初始化的const常量依然只是只读变量。
6). c++中const成员为只读变量,可以通过指针修改const成员的值,另外const成员变量只能在初始化列表中进行初始化。
下面我们通过代码来看看区别。
同样一段代码,在c编译器下,打印结果为*pa = 4, 4
在c++编译下打印的结果为 *pa = 4, 8
int main(void)
{
const int a = 8;
int *pa = (int *)&a;
*pa = 4;
printf("*pa = %d, a = %d", *pa, a);
return 0;
}
另外值得一说的是,由于c++中const常量的值在编译期就已经决定,下面的做法是OK的,但是c中是编译通不过的。
int main(void)
{
const int a = 8;
const int b = 2;
int array[a+b] = {0};
return 0;
}
5. 宏和内联(inline)函数的比较?
1). 首先宏是C中引入的一种预处理功能;
2). 内联(inline)函数是C++中引用的一个新的关键字;C++中推荐使用内联函数来替代宏代码片段;
3). 内联函数将函数体直接扩展到调用内联函数的地方,这样减少了参数压栈,跳转,返回等过程;
4). 由于内联发生在编译阶段,所以内联相较宏,是有参数检查和返回值检查的,因此使用起来更为安全;
5). 需要注意的是, inline会向编译期提出内联请求,但是是否内联由编译期决定(当然可以通过设置编译器,强制使用内联);
6). 由于内联是一种优化方式,在某些情况下,即使没有显示的声明内联,比如定义在class内部的方法,编译器也可能将其作为内联函数。
7). 内联函数不能过于复杂,最初C++限定不能有任何形式的循环,不能有过多的条件判断,不能对函数进行取地址操作等,但是现在的编译器几乎没有什么限制,基本都可以实现内联。
更多请参考inline关键字
6. C++中有了malloc / free , 为什么还需要 new / delete?
1). malloc与free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可用于申请动态内存和释放内存。
2). 对于非内部数据类型的对象而言,光用maloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。
由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以一个能完成清理与释放内存工作的运算符delete。注意new/delete不是库函数。
最后补充一点体外话,new 在申请内存的时候就可以初始化(如下代码), 而malloc是不允许的。另外,由于malloc是库函数,需要相应的库支持,因此某些简易的平台可能不支持,但是new就没有这个问题了,因为new是C++语言所自带的运算符。
int *p = new int(1);
特别的,在C++中,如下的代码,用new创建一个对象(new 会触发构造函数, delete会触发析构函数),但是malloc仅仅申请了一个空间,所以在C++中引入new和delete来支持面向对象。
#include <cstdlib>
class Test
{
...
}
Test* pn = new Test;
Test* pm = (Test*)malloc(sizeof(Test));
7. C和C++中的强制类型转换?
C中是直接在变量或者表达式前面加上(小括号括起来的)目标类型来进行转换,一招走天下,操作简单,但是由于太过直接,缺少检查,因此容易发生编译检查不到错误,而人工检查又及其难以发现的情况;而C++中引入了下面四种转换:
1). static_cast
a. 用于基本类型间的转换
b. 不能用于基本类型指针间的转换
c. 用于有继承关系类对象间的转换和类指针间的转换
2). dynamic_cast
a. 用于有继承关系的类指针间的转换
b. 用于有交叉关系的类指针间的转换
c. 具有类型检查的功能
d. 需要虚函数的支持
3). reinterpret_cast
a. 用于指针间的类型转换
b. 用于整数和指针间的类型转换
4). const_cast
a. 用于去掉变量的const属性
b. 转换的目标类型必须是指针或者引用
在C++中,普通类型可以通过类型转换构造函数转换为类类型,那么类可以转换为普通类型吗?答案是肯定的。但是在工程应用中一般不用类型转换函数,因为无法抑制隐式的调用类型转换函数(类型转换构造函数可以通过explicit来抑制其被隐式的调用),而隐式调用经常是bug的来源。实际工程中替代的方式是定义一个普通函数,通过显式的调用来达到类型转换的目的。
class test{
int m_value;
...
public:
operator int() //类型转换函数
{
return m_value;
}
int toInt() //显示调用普通函数来实现类型转换
{
return m_value
}
};
int main()
{
...
test a(5);
int i = a;
...
return 0;
}
8. static 有什么用途
1). 静态(局部/全局)变量
2). 静态函数
3). 类的静态数据成员
4). 类的静态成员函数
9. 类的静态成员变量和静态成员函数各有哪些特性?
静态成员变量
1). 静态成员变量需要在类内声明(加static),在类外初始化(不能加static),如下例所示;
2). 静态成员变量在类外单独分配存储空间,位于全局数据区,因此静态成员变量的生命周期不依赖于类的某个对象,而是所有类的对象共享静态成员变量;
3). 可以通过对象名直接访问公有静态成员变量;
4). 可以通过类名直接调用公有静态成员变量,即不需要通过对象,这一点是普通成员变量所不具备的。
class example{
private:
static int m_int; //static成员变量
};
int example::m_int = 0; //没有static
cout<<example::m_int; //可以直接通过类名调用静态成员变量
静态成员函数
1). 静态成员函数是类所共享的;
2). 静态成员函数可以访问静态成员变量,但是不能直接访问普通成员变量(需要通过对象来访问);需要注意的是普通成员函数既可以访问普通成员变量,也可以访问静态成员变量;
3). 可以通过对象名直接访问公有静态成员函数;
4). 可以通过类名直接调用公有静态成员函数,即不需要通过对象,这一点是普通成员函数所不具备的。
class example{
private:
static int m_int_s; //static成员变量
int m_int;
static int getI() //静态成员函数在普通成员函数前加static即可
{
return m_int_s; //如果返回m_int则报错,但是可以return d.m_int是合法的
}
};
cout<<example::getI(); //可以直接通过类名调用静态成员变量
10. 在C++程序中调用被C编译器编译后的函数,为什么要加extern“C”?
C++语言支持函数重载,C语言不支持函数重载,函数被C++编译器编译后在库中的名字与C语言的不同,假设某个函数原型为:
void foo(int x, int y);
该函数被C编译器编译后在库中的名字为 _foo, 而C++编译器则会产生像: _foo_int_int 之类的名字。为了解决此类名字匹配的问题,C++提供了C链接交换指定符号 extern “C”。
11. 头文件中的 ifndef/define/endif 是干什么用的? 该用法和 program once 的区别?
相同点:
它们的作用是防止头文件被重复包含。
不同点
1). ifndef 由语言本身提供支持,但是 program once 一般由编译器提供支持,也就是说,有可能出现编译器不支持的情况(主要是比较老的编译器)。
2). 通常运行速度上 ifndef 一般慢于 program once,特别是在大型项目上, 区别会比较明显,所以越来越多的编译器开始支持 program once。
3). ifndef 作用于某一段被包含(define 和 endif 之间)的代码, 而 program once 则是针对包含该语句的文件, 这也是为什么 program once 速度更快的原因。
4). 如果用 ifndef 包含某一段宏定义,当这个宏名字出现“撞车”时,可能会出现这个宏在程序中提示宏未定义的情况(在编写大型程序时特性需要注意,因为有很多程序员在同时写代码)。相反由于program once 针对整个文件, 因此它不存在宏名字“撞车”的情况, 但是如果某个头文件被多次拷贝,program once 无法保证不被多次包含,因为program once 是从物理上判断是不是同一个头文件,而不是从内容上。
12. 当i是一个整数的时候++i和i++那个更快一点?i++和++i的区别是什么?
答:理论上++i更快,实际与编译器优化有关,通常几乎无差别。
//i++实现代码为:
int operator++(int)
{
int temp = *this;
++*this;
return temp;
}//返回一个int型的对象本身
// ++i实现代码为:
int& operator++()
{
*this += 1;
return *this;
}//返回一个int型的对象引用
i++和++i的考点比较多,简单来说,就是i++返回的是i的值,而++i返回的是i+1的值。也就是++i是一个确定的值,是一个可修改的左值,如下使用:
cout << ++(++(++i)) << endl;
cout << ++ ++i << endl;
可以不停的嵌套++i。
这里有很多的经典笔试题,一起来观摩下:
int main()
{
int i = 1;
printf("%d,%d\n", ++i, ++i); //3,3
printf("%d,%d\n", ++i, i++); //5,3
printf("%d,%d\n", i++, i++); //6,5
printf("%d,%d\n", i++, ++i); //8,9
system("pause");
return 0;
}
首先是函数的参数入栈顺序从右向左入栈的,计算顺序也是从右往左计算的,不过都是计算完以后再进行的压栈操作:
对于第1个printf,首先执行++i,返回值是i,这时i的值是2,再次执行++i,返回值是i,得到i=3,将i压入栈中,此时i为3,也就是压入3,3;
对于第2个printf,首先执行i++,返回值是原来的i,也就是3,再执行++i,返回值是i,依次将3,5压入栈中得到输出结果
对于第3个printf,首先执行i++,返回值是5,再执行i++返回值是6,依次将5,6压入栈中得到输出结果
对于第4个printf,首先执行++i,返回i,此时i为8,再执行i++,返回值是8,此时i为9,依次将i,8也就是9,8压入栈中,得到输出结果。
上面的分析也是基于VS搞的,不过准确来说函数多个参数的计算顺序是未定义的(the order of evaluation of function arguments are undefined)。笔试题目的运行结果随不同的编译器而异。
数组、指针 & 引用
1. 指针和引用的区别?
相同点:
1). 都是地址的概念;
2). 都是“指向”一块内存。指针指向一块内存,它的内容是所指内存的地址;而引用则是某块内存的别名;
3). 引用在内部实现其实是借助指针来实现的,一些场合下引用可以替代指针,比如作为函数形参。
不同点:
1). 指针是一个实体,而引用(看起来,这点很重要)仅是个别名;
2). 引用只能在定义时被初始化一次,之后不可变;指针可变;引用“从一而终”,指针可以“见异思迁”;
3). 引用不能为空,指针可以为空;
4). “sizeof 引用”得到的是所指向的变量(对象)的大小,而“sizeof 指针”得到的是指针本身的大小;
5). 指针和引用的自增(++)运算意义不一样;
6). 引用是类型安全的,而指针不是 (引用比指针多了类型检查)
7). 引用具有更好的可读性和实用性。
2. 引用占用内存空间吗?
如下代码中对引用取地址,其实是取的引用所对应的内存空间的地址。这个现象让人觉得引用好像并非一个实体。但是引用是占用内存空间的,而且其占用的内存和指针一样,因为引用的内部实现就是通过指针来完成的。
比如 Type& name; <===> Type* const name。
int main(void)
{
int a = 8;
const int &b = a;
int *p = &a;
*p = 0;
cout<<a; //output 0
return 0;
}
3. 三目运算符
在C中三目运算符(? :)的结果仅仅可以作为右值,比如如下的做法在C编译器下是会报错的,但是C++中却是可以是通过的。这个进步就是通过引用来实现的,因为下面的三目运算符的返回结果是一个引用,然后对引用进行赋值是允许的。
int main(void)
{
int a = 8;
int b = 6;
(a>b ? a : b) = 88;
cout<<a; //output 88
return 0;
}
4. 指针数组和数组指针的区别
数组指针,是指向数组的指针,而指针数组则是指该数组的元素均为指针。
数组指针,是指向数组的指针,其本质为指针,形式如下。如 int (*p)[10],p即为指向数组的指针,()优先级高,首先说明p是一个指针,指向一个整型的一维数组,这个一维数组的长度是n,也可以说是p的步长。也就是说执行p+1时,p要跨过n个整型数据的长度。数组指针是指向数组首元素的地址的指针,其本质为指针,可以看成是二级指针。
类型名 (*数组标识符)[数组长度]
指针数组,在C语言和C++中,数组元素全为指针的数组称为指针数组,其中一维指针数组的定义形式如下。指针数组中每一个元素均为指针,其本质为数组。如 int p[n], []优先级高,先与p结合成为一个数组,再由int说明这是一个整型指针数组,它有n个指针类型的数组元素。这里执行p+1时,则p指向下一个数组元素,这样赋值是错误的:p=a;因为p是个不可知的表示,只存在p[0]、p[1]、p[2]…p[n-1],而且它们分别是指针变量可以用来存放变量地址。但可以这样 p=a; 这里p表示指针数组第一个元素的值,a的首地址的值。
类型名 *数组标识符[数组长度]
C++特性
1. 什么是面向对象(OOP)?面向对象的意义?
Object Oriented Programming, 面向对象是一种对现实世界理解和抽象的方法、思想,通过将需求要素转化为对象进行问题处理的一种思想。其核心思想是数据抽象、继承和动态绑定(多态)。
面向对象的意义在于:将日常生活中习惯的思维方式引入程序设计中;将需求中的概念直观的映射到解决方案中;以模块为中心构建可复用的软件系统;提高软件产品的可维护性和可扩展性。
2. 解释下封装、继承和多态?
1). 封装:
封装是实现面向对象程序设计的第一步,封装就是将数据或函数等集合在一个个的单元中(我们称之为类)。
封装的意义在于保护或者防止代码(数据)被我们无意中破坏。
2). 继承:
继承主要实现重用代码,节省开发时间。
子类可以继承父类的一些东西。
a. 公有继承(public)
公有继承的特点是基类的公有成员和保护成员作为派生类的成员时,它们都保持原有的状态,而基类的私有成员仍然是私有的,不能被这个派生类的子类所访问。
b. 私有继承(private)
私有继承的特点是基类的公有成员和保护成员都作为派生类的私有成员,并且不能被这个派生类的子类所访问。
c. 保护继承(protected)
保护继承的特点是基类的所有公有成员和保护成员都成为派生类的保护成员,并且只能被它的派生类成员函数或友元访问,基类的私有成员仍然是私有的。
3. 什么时候生成默认构造函数(无参构造函数)?什么时候生成默认拷贝构造函数?什么是深拷贝?什么是浅拷贝?默认拷贝构造函数是哪种拷贝?什么时候用深拷贝?
1). 没有任何构造函数时,编译器会自动生成默认构造函数,也就是无参构造函数;当类没有拷贝构造函数时,会生成默认拷贝构造函数。
2). 深拷贝是指拷贝后对象的逻辑状态相同,而浅拷贝是指拷贝后对象的物理状态相同;默认拷贝构造函数属于浅拷贝。
3). 当系统中有成员指代了系统中的资源时,需要深拷贝。比如指向了动态内存空间,打开了外存中的文件或者使用了系统中的网络接口等。如果不进行深拷贝,比如动态内存空间,可能会出现多次被释放的问题。是否需要定义拷贝构造函数的原则是,是类是否有成员调用了系统资源,如果定义拷贝构造函数,一定是定义深拷贝,否则没有意义。
4. 构造函数和析构函数的执行顺序?
构造函数
1). 首先调用父类的构造函数;
2). 调用成员变量的构造函数;
3). 调用类自身的构造函数。
析构函数
对于栈对象或者全局对象,调用顺序与构造函数的调用顺序刚好相反,也即后构造的先析构。对于堆对象,析构顺序与delete的顺序相关。
5. C++的编译环境
如下图所示,C++的编译环境由如下几部分构成:C++标准库、C语言兼容库、编译器扩展库及编译模块。
在这里插入图片描述
#include<iostream> //C++标准库,不带".h"
#include<string.h> //C语言兼容库,由编译器厂商提供
值得注意的是,C语言兼容库功能上跟C++标准库中的C语言子库相同,它的存中主要为了兼容C语言编译器,也就是说如果一个文件只包含C语言兼容库(不包含C++标准库),那么它在C语言编译器中依然可以编译通过。
C++中智能指针的设计和使用
智能指针指的是一种用起来像指针但又无需关心内存管理的一种机制。
普通指针的问题
C/C++中指针极为常见,但普通指针最大的问题就在于需要程序员记住分配内存后要手动释放,否则就会有内存泄漏或者引用野指针的风险。
比如有这样一个简单的类:
class person {
public:
person() {}
~person(){ cout<<"delete person."<<endl; }
void display(){
cout<<"This is person"<<endl;
}
};
如果在堆上分配该对象的话,那么在使用完后必须调用delete释放内存:
person* p = new person();
...
delete p; //使用完后手动释放内存
那么有没有一种更好的机制来自动管理在堆上分配的对象呢?像下面这样:
smart_pointer p(new person());
p->display();
// 无需手动调用delete释放内存
答案是肯定的。
让指针更智能
指针本质上就是一个内存地址,因此普通指针根本就无法分辨什么时候该指针指向的内存使用完毕,
普通指针需要更聪明一点。
什么情况下一块内存就可以释放掉了? 很简单,没人使用的时候就可以释放掉了。那么该怎么知道有没有人在使用一块内存?答案同样很简单,只需要记个数就可以了。
不知道大家去游乐园公园之类园区时有没有注意过,这些地方通常会显示园区里现在有多少人,有人进去这个数字就加1,有人出来这个数字就减一,什么时候管理员就可以关门了?当然是园区里没人的时候。
智能指针本质上就是园区里显示人数的指示牌,只不过在这里不叫人数,而是叫引用计数,Reference counting。
带有引用计数的指针:智能指针
普通指针本身并不能告诉我们是否指向的内存还有没有用,因此需要额外信息,这里的额外信息就是引用计数,将引用计数和智能指针组合在一起就是智能指针:
template<typename T>
class smart_pointer
{
T* data; // 智能指针指向的内存
int count = 0; // 引用计数
};
创建智能指针时如果传入的是内存地址,那么我们需要将引用计数设置为1,因为这是智能指针首次指向某个内存地址,智能指针构造函数如下:
smart_pointer(T* per){
data = per;
count = 1;
}
当然智能指针的构造函数也可以传入另一个智能指针,这时我们必须在原有引用计数的基础上加一,因为此时多了一个对该内存的引用,智能指针拷贝构造如下:
smart_pointer(const smart_pointer<T>& p){
data = p.data;
count=p.count;
++count;
}
当智能指针使用完毕被析构时就好比有人离开园区,这时必须判断引用计数是否为0,如果为0那么我们应该关闭园区:
~smart_pointer(){
if ((--count) == 0) // 没有人在园区了
delete data;
}
当然,智能指针首先用起来必须和普通指针一样,因此我们需要重载指针的引用与解引用:
T& operator*(){
return *data;
}
T* operator->(){
return data;
}
最后,不要忘了指针可以被赋值,因此智能指针也应该支持赋值,这样我们需要重载赋值运算符。由于指针被赋值后会指向新的内存,因此在智能指针指向新的内存前必须离开当前所在的园区:
smart_pointer operator=(const smart_pointer& p)
{
if(this == &p)
return *this; // 是自己人
if ((--count) == 0)
delete data; // 没有人在这个园区了
data = p.data; //
count = p.count; // 进到新园区
++count; //
return *this;
}
void test_smartpointer(){
smart_pointer<person> p(new person("tom",30));
p->display();
smart_pointer<person> q = p;
q->display();
smart_pointer<person> m(q);
m->display();
}
使用智能指针的原因至少有三点:
1)智能指针能够帮助我们处理资源泄露问题;
2)它也能够帮我们处理空悬指针的问题;
3)它还能够帮我们处理比较隐晦的由异常造成的资源泄露。
智能指针简单实现
基于引用计数的智能指针可以简单实现
#include <iostream>
using namespace std;
template<class T>
class SmartPtr
{
public:
SmartPtr(T *p);
~SmartPtr();
SmartPtr(const SmartPtr<T> &orig); // 浅拷贝
SmartPtr<T>& operator=(const SmartPtr<T> &rhs); // 浅拷贝
private:
T *ptr;
// 将use_count声明成指针是为了方便对其的递增或递减操作
int *use_count;
};
template<class T>
SmartPtr<T>::SmartPtr(T *p) : ptr(p)
{
try
{
use_count = new int(1);
}
catch (...)
{
delete ptr;
ptr = nullptr;
use_count = nullptr;
cout << "Allocate memory for use_count fails." << endl;
exit(1);
}
cout << "Constructor is called!" << endl;
}
template<class T>
SmartPtr<T>::~SmartPtr()
{
// 只在最后一个对象引用ptr时才释放内存
if (--(*use_count) == 0)
{
delete ptr;
delete use_count;
ptr = nullptr;
use_count = nullptr;
cout << "Destructor is called!" << endl;
}
}
template<class T>
SmartPtr<T>::SmartPtr(const SmartPtr<T> &orig)
{
ptr = orig.ptr;
use_count = orig.use_count;
++(*use_count);
cout << "Copy constructor is called!" << endl;
}
// 重载等号函数不同于复制构造函数,即等号左边的对象可能已经指向某块内存。
// 这样,我们就得先判断左边对象指向的内存已经被引用的次数。如果次数为1,
// 表明我们可以释放这块内存;反之则不释放,由其他对象来释放。
template<class T>
SmartPtr<T>& SmartPtr<T>::operator=(const SmartPtr<T> &rhs)
{
// 《C++ primer》:“这个赋值操作符在减少左操作数的使用计数之前使rhs的使用计数加1,
// 从而防止自身赋值”而导致的提早释放内存
++(*rhs.use_count);
// 将左操作数对象的使用计数减1,若该对象的使用计数减至0,则删除该对象
if (--(*use_count) == 0)
{
delete ptr;
delete use_count;
cout << "Left side object is deleted!" << endl;
}
ptr = rhs.ptr;
use_count = rhs.use_count;
cout << "Assignment operator overloaded is called!" << endl;
return *this;
}
测试:
#include <iostream>
#include "smartptr.h"
using namespace std;
int main()
{
// Test Constructor and Assignment Operator Overloaded
SmartPtr<int> p1(new int(0));
p1 = p1;
// Test Copy Constructor
SmartPtr<int> p2(p1);
// Test Assignment Operator Overloaded
SmartPtr<int> p3(new int(1));
p3 = p1;
return 0;
}
1、写出完整版的strcpy函数
char * strcpy( char *strDest, const char *strSrc )
{
assert( (strDest != NULL) && (strSrc != NULL) );
char *address = strDest;
while( (*strDest++ = * strSrc++) != ‘\0’ );
return address;
}
要点:
使用assert断言函数,判断参数是否为NULL;
遇’\0’则停止赋值;
返回新的字符串的首地址。
2、指出代码错误
void Test( void )
{
char *str = (char *) malloc( 100 );
strcpy( str, "hello" );
free( str );
... //省略的其它语句
}
错误有二:
使用malloc分配内存后,应判断是否分配成功;
free之后,应置str为NULL,防止变成野指针。
PS:malloc函数
malloc函数是一种分配长度为num_bytes字节的内存块的函数,可以向系统申请分配指定size个字节的内存空间。malloc的全称是memory allocation,中文叫动态内存分配,当无法知道内存具体位置的时候,想要绑定真正的内存空间,就需要用到动态的分配内存。
第一、malloc 函数返回的是 void * 类型。
对于C++,如果你写成:p = malloc (sizeof(int)); 则程序无法通过编译,报错:“不能将 void* 赋值给 int * 类型变量”。
所以必须通过 (int *) 来将强制转换。而对于C,没有这个要求,但为了使C程序更方便的移植到C++中来,建议养成强制转换的习惯。
第二、函数的实参为 sizeof(int) ,用于指明一个整型数据需要的大小。
在Linux中可以有这样:malloc(0),这是因为Linux中malloc有一个下限值16Bytes,注意malloc(-1)是禁止的;但是在某些系统中是不允许malloc(0)的。
malloc 只管分配内存,并不能对所得的内存进行初始化,所以得到的一片新内存中,其值将是随机的。
3、分别给出BOOL,int,float,指针变量 与“零值”比较的 if 语句
BOOL型变量:if(!var)
int型变量: if(var==0)
float型变量:
const float EPSINON = 0.00001;
if ((x >= - EPSINON) && (x <= EPSINON)
指针变量:if(var==NULL)
4、以下为Windows NT下的32位C++程序,请计算sizeof的值
void Func ( char str[100] )
{
sizeof( str ) = ?
}
void *p = malloc( 100 );
sizeof ( p ) = ?
sizeof( str ) = 4
sizeof ( p ) = 4
Func ( char str[100] )函数中数组名作为函数形参时,在函数体内,数组名失去了本身的内涵,仅仅只是一个指针;在失去其内涵的同时,它还失去了其常量特性,可以作自增、自减等操作,可以被修改。
但是数组名在不作形参时,仍然代表整个数组,这时的sizeof应返回数组长度。
sizeof返回的单位是字节。对于结构体,sizeof返回可能会有字节填充。结构体的总大小为结构体最宽基本类型成员大小的整数倍。
5、写一个“标准”宏MIN,这个宏输入两个参数并返回较小的一个。另外,当你写下面的代码时会发生什么事?
least = MIN(*p++, b);
答案:
#define MIN(A,B) ((A) <= (B) ? (A) : (B))
四个注意点:
宏定义中,左侧为宏名和参数,右侧为宏的实现;
在宏的实现中,所有参数应用括号括起来;
整个宏的实现的外面也要用括号括起来;
最后没有分号。
写下如上代码会导致p自增两次。
6、ifndef、extern的用法
条件指示符#ifndef 的最主要目的是防止头文件的重复包含和编译。
extern修饰变量的声明。
如果文件a.c需要引用b.c中变量int v,就可以在a.c中声明extern int v,然后就可以引用变量v。
这里需要注意的是,被引用的变量v的链接属性必须是外链接(external)的,也就是说a.c要引用到v,不只是取决于在a.c中声明extern int v,还取决于变量v本身是能够被引用到的。
7、请说出static和const关键字尽可能多的作用
static:
-
修饰普通变量,修改变量的存储区域和生命周期,使变量存储在静态区,在main函数运行前就分配了空间,如果有初始值就用初始值初始化它,如果没有初始值系统用默认值初始化它。在每次调用时,其值为上一次调用后改变的值,调用结束后不释放空间。此变量只在声明变量的文件内可见。
-
修饰普通函数,表明函数的作用范围,仅在定义该函数的文件内才能使用。在多人开发项目时,为了防止与他人命令函数重名,可以将函数定义为static。
-
修饰成员变量,修饰成员变量使所有的对象只保存一个该变量,而且不需要生成对象就可以访问该成员。
-
修饰成员函数,修饰成员函数使得不需要生成对象就可以访问该函数,但是在static函数内不能访问非静态成员。
const:
-
修饰变量,说明该变量不可以被改变;
-
修饰指针,分为指向常量的指针和指针常量;
-
常量引用,经常用于形参类型,即避免了拷贝,又避免了函数对值的修改;
-
修饰成员函数,说明该成员函数内不能修改成员变量。
8、请说一下C/C++ 中指针和引用的区别?
1.指针有自己的一块空间,而引用只是一个别名;
2.使用sizeof看一个指针的大小是4,而引用则是被引用对象的大小;
3.指针可以被初始化为NULL,而引用必须被初始化且必须是一个已有对象 的引用;
4.作为参数传递时,指针需要被解引用才可以对对象进行操作,而直接对引用的修改都会改变引用所指向的对象;
5.可以有const指针,但是没有const引用;
6.指针在使用中可以指向其它对象,但是引用只能是一个对象的引用,不能被改变;
7.指针可以有多级指针(**p),而引用只有一级;
8.指针和引用使用++运算符的意义不一样;
9.如果返回动态内存分配的对象或者内存,必须使用指针,引用可能引起内存泄露。
9、给定三角形ABC和一点P(x,y,z),判断点P是否在ABC内,给出思路并手写代码
根据面积法,如果P在三角形ABC内,那么三角形ABP的面积+三角形BCP的面积+三角形ACP的面积应该等于三角形ABC的面积。
10、野指针是什么?
野指针就是指向一个已删除的对象或者未申请访问受限内存区域的指针。
11、为什么析构函数必须是虚函数?为什么C++默认的析构函数不是虚函数?
将可能会被继承的父类的析构函数设置为虚函数,可以保证当我们new一个子类,然后使用基类指针指向该子类对象,释放基类指针时可以释放掉子类的空间,防止内存泄漏。
C++默认的析构函数不是虚函数是因为虚函数需要额外的虚函数表和虚表指针,占用额外的内存。而对于不会被继承的类来说,其析构函数如果是虚函数,就会浪费内存。因此C++默认的析构函数不是虚函数,而是只有当需要当作父类时,设置为虚函数。
PS:C++类的六个默认成员函数:
构造函数:一个特殊的成员函数,名字与类名相同,创建类类型对象的时候,由编译器自动调用,在对象的生命周期内只且调用一次,以保证每个数据成员都有一个合适的初始值。
拷贝构造函数:只有单个形参,而且该形参是对本类类型对象的引用(常用const修饰),这样的构造函数称为拷贝构造函数。拷贝构造函数是特殊的构造函数,创建对象时使用已存在的同类对象来进行初始化,由编译器自动调用。
析构函数:与构造函数功能相反,在对象被销毁时,由编译器自动调用,完成类的一些资源清理和收尾工作。
赋值运算符重载:对于类类型的对象我们需要对‘=’重载,以完成类类型对象之间的赋值。
取址操作符重载:函数返回值为该类型的指针,无参数。
const修饰的取址运算符重载。
12、C++中析构函数的作用?
析构函数与构造函数对应,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统会自动执行析构函数。
析构函数名也应与类名相同,只是在函数名前面加一个位取反符,例如stud( ),以区别于构造函数。它不能带任何参数,也没有返回值(包括void类型)。只能有一个析构函数,不能重载。
如果用户没有编写析构函数,编译系统会自动生成一个缺省的析构函数(即使自定义了析构函数,编译器也总是会为我们合成一个析构函数,并且如果自定义了析构函数,编译器在执行时会先调用自定义的析构函数再调用合成的析构函数),它也不进行任何操作。所以许多简单的类中没有用显式的析构函数。
如果一个类中有指针,且在使用的过程中动态的申请了内存,那么最好显示构造析构函数在销毁类之前,释放掉申请的内存空间,避免内存泄漏。
类析构顺序:1)派生类本身的析构函数;2)对象成员析构函数;3)基类析构函数。
13、map和set有什么区别,分别又是怎么实现的?
map和set都是C++的关联容器,其底层实现都是红黑树(RB-Tree)。由于 map 和set所开放的各种操作接口,RB-tree 也都提供了,所以几乎所有的 map 和set的操作行为,都只是转调 RB-tree 的操作行为。
map和set区别在于:
(1)map中的元素是key-value(关键字—值)对:关键字起到索引的作用,值则表示与索引相关联的数据;Set与之相对就是关键字的简单集合,set中每个元素只包含一个关键字。
(2)set的迭代器是const的,不允许修改元素的值;map允许修改value,但不允许修改key。其原因是因为map和set是根据关键字排序来保证其有序性的,如果允许修改key的话,那么首先需要删除该键,然后调节平衡,再插入修改后的键值,调节平衡,如此一来,严重破坏了map和set的结构,导致iterator失效,不知道应该指向改变前的位置,还是指向改变后的位置。所以STL中将set的迭代器设置成const,不允许修改迭代器的值;而map的迭代器则不允许修改key值,允许修改value值。
(3)map支持下标操作,set不支持下标操作。map可以用key做下标,map的下标运算符[ ]将关键码作为下标去执行查找,如果关键码不存在,则插入一个具有该关键码和mapped_type类型默认值的元素至map中,因此下标运算符[ ]在map应用中需要慎用,const_map不能用,只希望确定某一个关键值是否存在而不希望插入元素时也不应该使用,mapped_type类型没有默认值也不应该使用。如果find能解决需要,尽可能用find。
14、C++中类成员的访问权限有哪些?
C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员。
private和protected的区别是,子类的对象也可以访问protected,但只有本类的对象可以访问private。子类的对象也可以访问private,但只有本类的对象可以访问protected。
15、C++中struct和class的区别?
在C++中,可以用struct和class定义类,都可以继承。区别在于:struct的默认继承权限和默认访问权限是public,而class的默认继承权限和默认访问权限是private。
16、一个C++源文件从文本到可执行文件经历的过程?
对于C++源文件,从文本到可执行文件一般需要四个过程:
预处理阶段:对源代码文件中文件包含关系(头文件)、预编译语句(宏定义)进行分析和替换,生成预编译文件。
编译阶段:将经过预处理后的预编译文件转换成特定汇编代码,生成汇编文件
汇编阶段:将编译阶段生成的汇编文件转化成机器码,生成可重定位目标文件
链接阶段:将多个目标文件及所需要的库连接成最终的可执行目标文件
17、include头文件的顺序以及双引号””和尖括号<>的区别?
Include头文件的顺序:对于include的头文件来说,如果在文件a.h中声明一个在文件b.h中定义的变量,而不引用b.h。那么要在a.c文件中引用b.h文件,并且要先引用b.h,后引用a.h,否则汇报变量类型未声明错误。
双引号和尖括号的区别:编译器预处理阶段查找头文件的路径不一样。
对于使用双引号包含的头文件,查找头文件路径的顺序为:
当前头文件目录
编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径
对于使用尖括号包含的头文件,查找头文件的路径顺序为:
编译器设置的头文件路径(编译器可使用-I显式指定搜索路径)
系统变量CPLUS_INCLUDE_PATH/C_INCLUDE_PATH指定的头文件路径
18、malloc的原理,另外brk系统调用和mmap系统调用的作用分别是什么?
Malloc函数用于动态分配内存。为了减少内存碎片和系统调用的开销,malloc其采用内存池的方式,先申请大块内存作为堆区,然后将堆区分为多个内存块,以块作为内存管理的基本单位。当用户申请内存时,直接从堆区分配一块合适的空闲块。Malloc采用隐式链表结构将堆区分成连续的、大小不一的块,包含已分配块和未分配块;同时malloc采用显示链表结构来管理所有的空闲块,即使用一个双向链表将空闲块连接起来,每一个空闲块记录了一个连续的、未分配的地址。
当进行内存分配时,Malloc会通过隐式链表遍历所有的空闲块,选择满足要求的块进行分配;当进行内存合并时,malloc采用边界标记法,根据每个块的前后块是否已经分配来决定是否进行块合并。
Malloc在申请内存时,一般会通过brk或者mmap系统调用进行申请。其中当申请内存小于128K时,会使用系统函数brk在堆区中分配;而当申请内存大于128K时,会使用系统函数mmap在映射区分配。
19、C++的内存管理是怎样的?
在C++中,虚拟内存分为代码段、数据段、BSS段、堆区、文件映射区以及栈区六部分。
代码段:包括只读存储区和文本区,其中只读存储区存储字符串常量,文本区存储程序的机器代码。
数据段:存储程序中已初始化的全局变量和静态变量
bss 段:存储未初始化的全局变量和静态变量(局部+全局),以及所有被初始化为0的全局变量和静态变量。
堆区:调用new/malloc函数时在堆区动态分配内存,同时需要调用delete/free来手动释放申请的内存。
映射区:存储动态链接库以及调用mmap函数进行的文件映射
栈区:使用栈空间存储函数的返回地址、参数、局部变量、返回值
20、如何判断内存泄漏?
内存泄漏通常是由于调用了malloc/new等内存申请的操作,但是缺少了对应的free/delete。为了判断内存是否泄露,我们一方面可以使用linux环境下的内存泄漏检查工具Valgrind,另一方面我们在写代码时可以添加内存申请和释放的统计功能,统计当前申请和释放的内存是否一致,以此来判断内存是否泄露。
21、什么时候会发生段错误?
段错误通常发生在访问非法内存地址的时候,具体来说分为以下几种情况:
使用野指针
试图修改字符串常量的内容
22、new和malloc的区别?
1、new分配内存按照数据类型进行分配,malloc分配内存按照指定的大小分配;
2、new返回的是指定对象的指针,而malloc返回的是void*,因此malloc的返回值一般都需要进行类型转化。
3、new不仅分配一段内存,而且会调用构造函数,malloc不会。
4、new分配的内存要用delete销毁,malloc要用free来销毁;delete销毁的时候会调用对象的析构函数,而free则不会。
5、new是一个操作符可以重载,malloc是一个库函数。
6、malloc分配的内存不够的时候,可以用realloc扩容。扩容的原理?new没用这样操作。
7、new如果分配失败了会抛出bad_malloc的异常,而malloc失败了会返回NULL。
8、申请数组时: new[]一次分配所有内存,多次调用构造函数,搭配使用delete[],delete[]多次调用析构函数,销毁数组中的每个对象。而malloc则只能sizeof(int) * n。
23、A* a = new A; a->i = 10;在内核中的内存分配上发生了什么?
1)A *a:a是一个局部变量,类型为指针,故而操作系统在程序栈区开辟4/8字节的空间(0x000m),分配给指针a。
2)new A:通过new动态的在堆区申请类A大小的空间(0x000n)。
3)a = new A:将指针a的内存区域填入栈中类A申请到的地址的地址。即*(0x000m)=0x000n。
4)a->i:先找到指针a的地址0x000m,通过a的值0x000n和i在类a中偏移offset,得到a->i的地址0x000n + offset,进行*(0x000n + offset) = 10的赋值操作,即内存0x000n + offset的值是10。
24、一个类,里面有static,virtual,之类的,来说一说这个类的内存分布?
1、static修饰符
1)static修饰成员变量
对于非静态数据成员,每个类对象都有自己的拷贝。而静态数据成员被当做是类的成员,无论这个类被定义了多少个,静态数据成员都只有一份拷贝,为该类型的所有对象所共享(包括其派生类)。所以,静态数据成员的值对每个对象都是一样的,它的值可以更新。
因为静态数据成员在全局数据区分配内存,属于本类的所有对象共享,所以它不属于特定的类对象,在没有产生类对象前就可以使用。
2)static修饰成员函数
与普通的成员函数相比,静态成员函数由于不是与任何的对象相联系,因此它不具有this指针。从这个意义上来说,它无法访问属于类对象的非静态数据成员,也无法访问非静态成员函数,只能调用其他的静态成员函数。
Static修饰的成员函数,在代码区分配内存。
2、C++继承和虚函数
C++多态分为静态多态和动态多态。静态多态是通过重载和模板技术实现,在编译的时候确定。动态多态通过虚函数和继承关系来实现,执行动态绑定,在运行的时候确定。
动态多态实现有几个条件:
(1) 虚函数;
(2) 一个基类的指针或引用指向派生类的对象;
基类指针在调用成员函数(虚函数)时,就会去查找该对象的虚函数表。虚函数表的地址在每个对象的首地址。查找该虚函数表中该函数的指针进行调用。
每个对象中保存的只是一个虚函数表的指针,C++内部为每一个类维持一个虚函数表,该类的对象的都指向这同一个虚函数表。
虚函数表中为什么就能准确查找相应的函数指针呢?因为在类设计的时候,虚函数表直接从基类也继承过来,如果覆盖了其中的某个虚函数,那么虚函数表的指针就会被替换,因此可以根据指针准确找到该调用哪个函数。
3、virtual修饰符
如果一个类是局部变量则该类数据存储在栈区,如果一个类是通过new/malloc动态申请的,则该类数据存储在堆区。
如果该类是virutal继承而来的子类,则该类的虚函数表指针和该类其他成员一起存储。虚函数表指针指向只读数据段中的类虚函数表,虚函数表中存放着一个个函数指针,函数指针指向代码段中的具体函数。
如果类中成员是virtual属性,会隐藏父类对应的属性。
25、静态变量什么时候初始化?
静态变量存储在虚拟地址空间的数据段和bss段,C语言中其在代码执行之前初始化,属于编译期初始化。而C++中由于引入对象,对象生成必须调用构造函数,因此C++规定全局或局部静态对象当且仅当对象首次用到时进行构造。
26、TCP怎么保证可靠性?
TCP保证可靠性:
(1)序列号、确认应答、超时重传
数据到达接收方,接收方需要发出一个确认应答,表示已经收到该数据段,并且确认序号会说明了它下一次需要接收的数据序列号。如果发送发迟迟未收到确认应答,那么可能是发送的数据丢失,也可能是确认应答丢失,这时发送方在等待一定时间后会进行重传。这个时间一般是2*RTT(报文段往返时间)+一个偏差值。
(2)窗口控制与高速重发控制/快速重传(重复确认应答)
TCP会利用窗口控制来提高传输速度,意思是在一个窗口大小内,不用一定要等到应答才能发送下一段数据,窗口大小就是无需等待确认而可以继续发送数据的最大值。如果不使用窗口控制,每一个没收到确认应答的数据都要重发。
使用窗口控制,如果数据段1001-2000丢失,后面数据每次传输,确认应答都会不停地发送序号为1001的应答,表示我要接收1001开始的数据,发送端如果收到3次相同应答,就会立刻进行重发;但还有种情况有可能是数据都收到了,但是有的应答丢失了,这种情况不会进行重发,因为发送端知道,如果是数据段丢失,接收端不会放过它的,会疯狂向它提醒…
(3)拥塞控制
如果把窗口定的很大,发送端连续发送大量的数据,可能会造成网络的拥堵(大家都在用网,你在这狂发,吞吐量就那么大,当然会堵),甚至造成网络的瘫痪。所以TCP在为了防止这种情况而进行了拥塞控制。
慢启动:定义拥塞窗口,一开始将该窗口大小设为1,之后每次收到确认应答(经过一个rtt),将拥塞窗口大小*2。
拥塞避免:设置慢启动阈值,一般开始都设为65536。拥塞避免是指当拥塞窗口大小达到这个阈值,拥塞窗口的值不再指数上升,而是加法增加(每次确认应答/每个rtt,拥塞窗口大小+1),以此来避免拥塞。
将报文段的超时重传看做拥塞,则一旦发生超时重传,我们需要先将阈值设为当前窗口大小的一半,并且将窗口大小设为初值1,然后重新进入慢启动过程。
快速重传:在遇到3次重复确认应答(高速重发控制)时,代表收到了3个报文段,但是这之前的1个段丢失了,便对它进行立即重传。
然后,先将阈值设为当前窗口大小的一半,然后将拥塞窗口大小设为慢启动阈值+3的大小。
这样可以达到:在TCP通信时,网络吞吐量呈现逐渐的上升,并且随着拥堵来降低吞吐量,再进入慢慢上升的过程,网络不会轻易的发生瘫痪。
27、红黑树和AVL树的定义,特点,以及二者区别
平衡二叉树(AVL树):
平衡二叉树又称为AVL树,是一种特殊的二叉排序树。其左右子树都是平衡二叉树,且左右子树高度之差的绝对值不超过1。一句话表述为:以树中所有结点为根的树的左右子树高度之差的绝对值不超过1。将二叉树上结点的左子树深度减去右子树深度的值称为平衡因子BF,那么平衡二叉树上的所有结点的平衡因子只可能是-1、0和1。只要二叉树上有一个结点的平衡因子的绝对值大于1,则该二叉树就是不平衡的。
红黑树:
红黑树是一种二叉查找树,但在每个节点增加一个存储位表示节点的颜色,可以是红或黑(非红即黑)。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,因此,红黑树是一种弱平衡二叉树,相对于要求严格的AVL树来说,它的旋转次数少,所以对于搜索,插入,删除操作较多的情况下,通常使用红黑树。
性质:
-
每个节点非红即黑
-
根节点是黑的;
-
每个叶节点(叶节点即树尾端NULL指针或NULL节点)都是黑的;
-
如果一个节点是红色的,则它的子节点必须是黑色的。
-
对于任意节点而言,其到叶子点树NULL指针的每条路径都包含相同数目的黑节点;
区别:
AVL 树是高度平衡的,频繁的插入和删除,会引起频繁的rebalance,导致效率下降;红黑树不是高度平衡的,算是一种折中,插入最多两次旋转,删除最多三次旋转。
28、map和unordered_map优点和缺点
对于map,其底层是基于红黑树实现的,优点如下:
1)有序性,这是map结构最大的优点,其元素的有序性在很多应用中都会简化很多的操作
2)map的查找、删除、增加等一系列操作时间复杂度稳定,都为logn
缺点如下:
1)查找、删除、增加等操作平均时间复杂度较慢,与n相关
对于unordered_map来说,其底层是一个哈希表,优点如下:
查找、删除、添加的速度快,时间复杂度为常数级O©
缺点如下:
因为unordered_map内部基于哈希表,以(key,value)对的形式存储,因此空间占用率高
Unordered_map的查找、删除、添加的时间复杂度不稳定,平均为O©,取决于哈希函数。极端情况下可能为O(n)
29、Top(K)问题
1、直接全部排序(只适用于内存够的情况)
当数据量较小的情况下,内存中可以容纳所有数据。则最简单也是最容易想到的方法是将数据全部排序,然后取排序后的数据中的前K个。
这种方法对数据量比较敏感,当数据量较大的情况下,内存不能完全容纳全部数据,这种方法便不适应了。即使内存能够满足要求,该方法将全部数据都排序了,而题目只要求找出top K个数据,所以该方法并不十分高效,不建议使用。
2、快速排序的变形 (只使用于内存够的情况)
这是一个基于快速排序的变形,因为第一种方法中说到将所有元素都排序并不十分高效,只需要找出前K个最大的就行。
这种方法类似于快速排序,首先选择一个划分元,将比这个划分元大的元素放到它的前面,比划分元小的元素放到它的后面,此时完成了一趟排序。如果此时这个划分元的序号index刚好等于K,那么这个划分元以及它左边的数,刚好就是前K个最大的元素;如果index > K,那么前K大的数据在index的左边,那么就继续递归的从index-1个数中进行一趟排序;如果index < K,那么再从划分元的右边继续进行排序,直到找到序号index刚好等于K为止。再将前K个数进行排序后,返回Top K个元素。这种方法就避免了对除了Top K个元素以外的数据进行排序所带来的不必要的开销。
3、最小堆法
这是一种局部淘汰法。先读取前K个数,建立一个最小堆。然后将剩余的所有数字依次与最小堆的堆顶进行比较,如果小于或等于堆顶数据,则继续比较下一个;否则,删除堆顶元素,并将新数据插入堆中,重新调整最小堆。当遍历完全部数据后,最小堆中的数据即为最大的K个数。
4、分治法
将全部数据分成N份,前提是每份的数据都可以读到内存中进行处理,找到每份数据中最大的K个数。此时剩下NK个数据,如果内存不能容纳NK个数据,则再继续分治处理,分成M份,找出每份数据中最大的K个数,如果M*K个数仍然不能读到内存中,则继续分治处理。直到剩余的数可以读入内存中,那么可以对这些数使用快速排序的变形或者归并排序进行处理。
5、Hash法
如果这些数据中有很多重复的数据,可以先通过hash法,把重复的数去掉。这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间。处理后的数据如果能够读入内存,则可以直接排序;否则可以使用分治法或者最小堆法来处理数据。
30、栈和堆的区别,以及为什么栈要快?
堆和栈的区别:
堆是由低地址向高地址扩展;栈是由高地址向低地址扩展
堆中的内存需要手动申请和手动释放;栈中内存是由OS自动申请和自动释放,存放着参数、局部变量等内存
堆中频繁调用malloc和free,会产生内存碎片,降低程序效率;而栈由于其先进后出的特性,不会产生内存碎片
堆的分配效率较低,而栈的分配效率较高
栈的效率高的原因:
栈是操作系统提供的数据结构,计算机底层对栈提供了一系列支持:分配专门的寄存器存储栈的地址,压栈和入栈有专门的指令执行;而堆是由C/C++函数库提供的,机制复杂,需要一系列分配内存、合并内存和释放内存的算法,因此效率较低。
31、写个函数在main函数执行前先运行
__attribute((constructor))void before()
{
printf("before main\n");
}
32、extern“C”的作用?
C++调用C函数需要extern C,因为C语言没有函数重载。
33、STL迭代器删除元素
1.对于序列容器vector,deque来说,使用erase(itertor)后,后边的每个元素的迭代器都会失效,但是后边每个元素都会往前移动一个位置,但是erase会返回下一个有效的迭代器;
2.对于关联容器map set来说,使用了erase(iterator)后,当前元素的迭代器失效,但是其结构是红黑树,删除当前元素的,不会影响到下一个元素的迭代器,所以在调用erase之前,记录下一个元素的迭代器即可。
3.对于list来说,它使用了不连续分配的内存,并且它的erase方法也会返回下一个有效的iterator。
34、vector和list的区别与应用有哪些?
1、概念:
1)Vector
连续存储的容器,动态数组,在堆上分配空间
底层实现:数组
两倍容量增长:
vector 增加(插入)新元素时,如果未超过当时的容量,则还有剩余空间,那么直接添加到最后(插入指定位置),然后调整迭代器。
如果没有剩余空间了,则会重新配置原有元素个数的两倍空间,然后将原空间元素通过复制的方式初始化新空间,再向新空间增加元素,最后析构并释放原空间,之前的迭代器会失效。
性能:
访问:O(1)
插入:在最后插入(空间够):很快
在最后插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
在中间插入(空间够):内存拷贝
在中间插入(空间不够):需要内存申请和释放,以及对之前数据进行拷贝。
删除:在最后删除:很快
在中间删除:内存拷贝
适用场景:经常随机访问,且不经常对非尾节点进行插入删除。
2、List
动态链表,在堆上分配空间,每插入一个元数都会分配空间,每删除一个元素都会释放空间。
底层:双向链表
性能:
访问:随机访问性能很差,只能快速访问头尾节点。
插入:很快,一般是常数开销
删除:很快,一般是常数开销
适用场景:经常插入删除大量数据
2、区别:
1)vector底层实现是数组;list是双向 链表。
2)vector支持随机访问,list不支持。
3)vector是顺序内存,list不是。
4)vector在中间节点进行插入删除会导致内存拷贝,list不会。
5)vector一次性分配好内存,不够时才进行2倍扩容;list每次插入新节点都会进行内存申请。
6)vector随机访问性能好,插入删除性能差;list随机访问性能差,插入删除性能好。
3、应用
vector拥有一段连续的内存空间,因此支持随机访问,如果需要高效的随即访问,而不在乎插入和删除的效率,使用vector。
list拥有一段不连续的内存空间,如果需要高效的插入和删除,而不关心随机访问,则应使用list。
35、STL里resize和reserve的区别?
resize():改变当前容器内含有元素的数量(size()),eg: vectorv; v.resize(len);v的size变为len,如果原来v的size小于len,那么容器新增(len-size)个元素,元素的值为默认为0.当v.push_back(3);之后,则是3是放在了v的末尾,即下标为len,此时容器是size为len+1;
reserve():改变当前容器的最大容量(capacity),它不会生成元素,只是确定这个容器允许放入多少对象,如果reserve(len)的值大于当前的capacity(),那么会重新分配一块能存len个对象的空间,然后把之前v.size()个对象通过copy construtor复制过来,销毁之前的内存。
36、源码到可执行文件的过程?
1)预编译
主要处理源代码文件中的以“#”开头的预编译指令。处理规则见下
1、删除所有的#define,展开所有的宏定义。
2、处理所有的条件预编译指令,如“#if”、“#endif”、“#ifdef”、“#elif”和“#else”。
3、处理“#include”预编译指令,将文件内容替换到它的位置,这个过程是递归进行的,文件中包含其他文件。
4、删除所有的注释,“//”和“/**/”。
5、保留所有的#pragma 编译器指令,编译器需要用到他们,如:#pragma once 是为了防止有文件被重复引用。
6、添加行号和文件标识,便于编译时编译器产生调试用的行号信息,和编译时产生编译错误或警告是能够显示行号。
2)编译
把预编译之后生成的xxx.i或xxx.ii文件,进行一系列词法分析、语法分析、语义分析及优化后,生成相应的汇编代码文件。
1、词法分析:利用类似于“有限状态机”的算法,将源代码程序输入到扫描机中,将其中的字符序列分割成一系列的记号。
2、语法分析:语法分析器对由扫描器产生的记号,进行语法分析,产生语法树。由语法分析器输出的语法树是一种以表达式为节点的树。
3、语义分析:语法分析器只是完成了对表达式语法层面的分析,语义分析器则对表达式是否有意义进行判断,其分析的语义是静态语义——在编译期能分期的语义,相对应的动态语义是在运行期才能确定的语义。
4、优化:源代码级别的一个优化过程。
5、目标代码生成:由代码生成器将中间代码转换成目标机器代码,生成一系列的代码序列——汇编语言表示。
6、目标代码优化:目标代码优化器对上述的目标机器代码进行优化:寻找合适的寻址方式、使用位移来替代乘法运算、删除多余的指令等。
3)汇编
将汇编代码转变成机器可以执行的指令(机器码文件)。 汇编器的汇编过程相对于编译器来说更简单,没有复杂的语法,也没有语义,更不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译过来,汇编过程有汇编器as完成。经汇编之后,产生目标文件(与可执行文件格式几乎一样)xxx.o(Windows下)、xxx.obj(Linux下)。
4)链接
将不同的源文件产生的目标文件进行链接,从而形成一个可以执行的程序。链接分为静态链接和动态链接:
1、静态链接:
函数和数据被编译进一个二进制文件。在使用静态库的情况下,在编译链接可执行文件时,链接器从库中复制这些函数和数据并把它们和应用程序的其它模块组合起来创建最终的可执行文件。
空间浪费:因为每个可执行程序中对所有需要的目标文件都要有一份副本,所以如果多个程序对同一个目标文件都有依赖,会出现同一个目标文件都在内存存在多个副本;
更新困难:每当库函数的代码修改了,这个时候就需要重新进行编译链接形成可执行程序。
运行速度快:但是静态链接的优点就是,在可执行程序中已经具备了所有执行程序所需要的任何东西,在执行的时候运行速度快。
2、动态链接:
动态链接的基本思想是把程序按照模块拆分成各个相对独立部分,在程序运行时才将它们链接在一起形成一个完整的程序,而不是像静态链接一样把所有程序模块都链接成一个单独的可执行文件。
共享库:就是即使需要每个程序都依赖同一个库,但是该库不会像静态链接那样在内存中存在多分,副本,而是这多个程序在执行时共享同一份副本;
更新方便:更新时只需要替换原来的目标文件,而无需将所有的程序再重新链接一遍。当程序下一次运行时,新版本的目标文件会被自动加载到内存并且链接起来,程序就完成了升级的目标。
性能损耗:因为把链接推迟到了程序运行时,所以每次执行程序都需要进行链接,所以性能会有一定损失。
37、tcp握手为什么两次不可以?为什么不用四次?
两次不可以:tcp是全双工通信,两次握手只能确定单向数据链路是可以通信的,并不能保证反向的通信正常
不用四次:
本来握手应该和挥手一样都是需要确认两个方向都能联通的,本来模型应该是:
1.客户端发送syn0给服务器
2.服务器收到syn0,回复ack(syn0+1)
3.服务器发送syn1
4.客户端收到syn1,回复ack(syn1+1)
因为tcp是全双工的,上边的四部确认了数据在两个方向上都是可以正确到达的,但是2,3步没有没有上下的联系,可以将其合并,加快握手效率,所有就变成了3步握手。
常见数据结构与算法
数据结构
数组
数组是一种最简单和最广泛使用的数据结构,其它数据结构比如堆栈和队列都源自数组。
下图是一个大小为 4 的简单数组,包含几个元素( 1 , 2 , 3,4)。
数组每个数据元素会被分配一个正的数值,叫作“索引”,它对应该元素在数组中的位置。大部分编程语言都将初始索引定义为 0.
以下是两种数组:
一维数组(如上所示)
多维数组(数组的数组)
数组的基本操作:
Insert——在给定索引位置插入一个元素
Get——返回给定索引位置的元素
Delete——删除给定索引位置的元素
Size——获取数组内所有元素的总数
常问的数组面试问题
1.找到数组中第二小的元素
2.找到数组中第一个没有重复的整数
3.合并两个分类数组
4.重新排列数组中的正值和负值
堆栈
我们都熟悉很有名的撤销(Undo)选项,它几乎存在每个应用程序中。有没有想过它是如何工作的?其思路就是,按照最后的状态排列在先的顺序将工作的先前状态(限于特定数字)存储在内存中。这只用数组是无法实现的,因此堆栈就有了用武之地。
可以把堆栈看作一堆垂直排列的书籍。为了获得位于中间位置的书,你需要拿掉放在它上面的所有书籍。这就是 LIFO(后进先出)方法的工作原理。
这是一个包含三个数据元素(1,2 和 3)的堆栈图像,其中3位于顶部,首先把它删除:
堆栈的基本操作:
Push——在顶部插入元素
Pop—— 从堆栈中删除后返回顶部元素
isEmpty——如果堆栈为空,则返回 true
Top ——返回顶部元素,但不从堆栈中删除
常见的堆栈面试问题
使用堆栈计算后缀表达式
对堆栈中的值进行排序
检查表达式中的括号是否平衡
队列
与堆栈类似,队列是另一种线性数据结构,以顺序方式存储元素。堆栈和队列之间唯一的显着区别是,队列不是使用 LIFO 方法,而是应用 FIFO 方法,这是 First in First Out(先入先出)的缩写。
队列的完美现实例子:一列人在售票亭等候。如果有新人来,他们是从末尾加入队列,而不是在开头——站在前面的人将先买到票然后离开队列。
下图是一个包含四个数据元素(1,2,3 和 4)的队列,其中 1 位于顶部,首先把它删除:
队列的基本操作:
Enqueue() —— 向队列末尾插入元素
Dequeue() —— 从队列头部移除元素
isEmpty() —— 如果队列为空,则返回 true
Top() —— 返回队列的第一个元素
常问的队列面试问题
1.使用队列来实现堆栈
2.颠倒队列中前 k 个元素的顺序
3.使用队列生成从 1 到 n 的二进制数
链表
链表是另一个重要的线性数据结构,刚一看可能看起来像数组,但在内存分配,内部结构以及如何执行插入和删除的基本操作方面有所不同。
链表就像一个节点链,其中每个节点包含数据和指向链中后续节点的指针等信息。有一个头指针,指向链表的第一个元素,如果列表是空的,那么它只指向 null 或不指向任何内容。
链表用于实现文件系统,哈希表和邻接表。下图是链表内部结构的直观展示:
几种类型的链表:
1.单链表(单向)
2.双链表(双向)
链表的基本操作:
InsertAtEnd —— 在链表末尾插入指定元素
InsertAtHead —— 在链表头部插入指定元素
Delete —— 从链表中删除指定元素
DeleteAtHead —— 删除链表的第一个元素
Search —— 返回链表中的指定元素
isEmpty —— 如果链表为空,返回 true
常问的链表面试问题
1.翻转列表
2.检测链表中的循环
3.返回链表中倒数第 n 个节点
4.移除链表中的重复值
图
图就是一组节点,以网络的形式互相连接。节点也被称为顶点(vertices)。一对(x,y)就叫做一个边,表示顶点 x 和顶点 y 相连。一个边可能包含权重/成本,显示从顶点 x 到 y 所需的成本。
图的类型:
1.无向图
2.有向图
在编程语言中,图可以表示为两种形式:
邻接矩阵
邻接列表
常见的图遍历算法:
广度优先搜索
深度优先搜索
常问的图面试问题:
实现广度优先搜索和深度优先搜索
检查一个图是否为树
计算一张图中的边的数量
找到两个顶点之间的最短路径
树
树是一种层级数据结构,包含了连接它们的顶点(节点)和边。树和图很相似,但二者有个很大的不同点,即树中没有循环。
树广泛应用在人工智能和复杂的算法中,为解决各种问题提供高效的存储机制。
下图是一个简单的树,以及在树型数据结构中所用的基本术语:
几种类型的树:
N 叉树
平衡树
二叉树
二叉搜索树
平衡二叉树
红黑树
2-3 树
其中,二叉树和二叉搜索树是最常用的树。
常问的树面试问题:
找到一个二叉树的高度
找到一个二叉搜索树中第 k 个最大值
找到距离根部“k”个距离的节点
找到一个二叉树中给定节点的祖先(ancestors)
字典树
字典树,也叫“前缀树”,是一种树形结构,在解决字符串相关问题中非常高效。其提供非常快速的检索功能,常用于搜索字典中的单词,为搜索引擎提供自动搜索建议,甚至能用于IP路由选择。
下面展示了“top”“thus”和“their”这三个词是如何存储在字典树中的:
这些单词以从上到下的方式存储,其中绿色节点“p”,“s”和“r”分别表示“top”,“thus”和“their”的末尾。
常见的字典树面试问题:
计算字典树中的总字数
打印存储在字典树中的所有单词
使用字典树对数组的元素进行排序
使用字典树从字典中形成单词
构建一个T9字典
哈希表
散列是一个用于唯一标识对象并在一些预先计算的唯一索引(称为“密钥”)存储每个对象的过程。因此,对象以“键值”对的形式存储,这些项的集合被称为“字典”。可以使用该键值搜索每个对象。有多种不同的基于哈希的数据结构,但最常用的数据结构是哈希表。
哈希表通常使用数组实现。
哈希数据结构的性能取决于以下三个因素:
哈希函数
哈希表的大小
碰撞处理方法
下图展示了如何在数组中映射哈希。该数组的索引是通过哈希函数计算的。
常问的哈希面试问题:
找到数组中的对称对
追踪遍历的完整路径
查看一个数组是否为另一个数组的子集
检查给定数组是否不相交
以上就是你在准备编程面试前需要掌握的8种数据结构。
在上面的 8 种数据结构中,每种结构都有对应的面试问题.
算法
冒泡排序
#include <stdio.h>
#define LEN 10 //数组长度
void main( void )
{
int ARRAY[10] = { 0, 6, 3, 2, 7, 5, 4, 9, 1, 8 }; //待排序数组
printf( "\n" );
for( int a = 0; a < LEN; a++ ) //打印数组内容
{
printf( "%d ", ARRAY[a] );
}
int i = 0; int j = 0;
bool isChange; //设定交换标志
for( i = 1; i < LEN; i++ )
{ //最多做 LEN-1 趟排序
isChange = 0; //本趟排序开始前,交换标志应为假
for( j = LEN-1; j >= i; j-- ) //对当前无序区 ARRAY[i..LEN]自下向上扫描
{
if( ARRAY[j+1] < ARRAY[j] )
{ //交换记录
ARRAY[0] = ARRAY[j+1]; //ARRAY[0]不是哨兵,仅做暂存单元
ARRAY[j+1] = ARRAY[j];
ARRAY[j] = ARRAY[0];
isChange = 1; //发生了交换,故将交换标志置为真
}
}
printf( "\n" );
for( a = 0; a < LEN; a++) //打印本次排序后数组内容
{
printf( "%d ", ARRAY[a] );
}
if( !isChange )
{
break;
} //本趟排序未发生交换,提前终止算法
printf( "\n" ); return;
}
选择排序
直接插入排序
#include<iostream.h>
void main( void )
{
int ARRAY[10] = { 0, 6, 3, 2, 7, 5, 4, 9, 1, 8 };
int i,j;
for( i = 0; i < 10; i++)
{
cout<<ARRAY[i]<<" ";
}
cout<<endl;
for( i = 2; i <= 10; i++ ) //将 ARRAY[2],…,ARRAY[n]依次按序插入
{
if(ARRAY[i] < ARRAY[i-1]) //如果 ARRAY[i]大于一切有序的数值,
//ARRAY[i]将保持原位不动
{
ARRAY[0] = ARRAY[i]; //将 ARRAY[0]看做是哨兵,是 ARRAY[i]的副本 j = i - 1;
do{ //从右向左在有序区 ARRAY[1..i-1]中 //查找 ARRAY[i]的插入位置
ARRAY[j+1] = ARRAY[j]; //将数值大于 ARRAY[i]记录后移 j-- ;
}while( ARRAY[0] < ARRAY[j] );
ARRAY[j+1]=ARRAY[0]; //ARRAY[i]插入到正确的位置上
}
}
for( i = 0; i < 10; i++)
{
cout<<ARRAY[i]<<" ";
}
cout<<endl;
}
所有为简化边界条件而引入的附加结点(元素)均可称为哨兵。引入哨兵后使得查找循环条件的时间大约减少了一半,对于记录数较大的文件节约的时间就相当可观。类似于排序这样使用频率非常高的算法,要尽可能地减少其运行时间。所以不能把上述算法中的哨兵视为雕虫小技。
堆排序
void createHeep(int ARRAY[], int sPoint, int Len) //生成大根堆
{
while ((2 * sPoint + 1) < Len)
{
int mPoint = 2 * sPoint + 1;
if ((2 * sPoint + 2) < Len)
{
if (ARRAY[2 * sPoint + 1] < ARRAY[2 * sPoint + 2])
{
mPoint = 2 * sPoint + 2;
}
}
if (ARRAY[sPoint] < ARRAY[mPoint]) //堆被破坏,需要重新调整
{
int tmpData = ARRAY[sPoint]; //交换 sPoint 与 mPoint 的数据
ARRAY[sPoint] = ARRAY[mPoint];
ARRAY[mPoint] = tmpData;
sPoint = mPoint;
}
else
{
break; //堆未破坏,不再需要调整
}
}
return;
}
void heepSort(int ARRAY[], int Len) //堆排序
{
int i = 0;
for (i = (Len / 2 - 1); i >= 0; i--) //将 Hr[0,Lenght-1]建成大根堆
{
createHeep(ARRAY, i, Len);
}
for (i = Len - 1; i > 0; i--)
{
int tmpData = ARRAY[0]; //与最后一个记录交换
ARRAY[0] = ARRAY[i];
ARRAY[i] = tmpData;
createHeep(ARRAY, 0, i); //将 H.r[0..i]重新调整为大根堆
}
return;
}
int main(void)
{
int ARRAY[] = { 5, 4, 7, 3, 9, 1, 6, 8, 2 };
printf("Before sorted:\n"); //打印排序前数组内容
for (int i = 0; i < 9; i++)
{
printf("%d ", ARRAY[i]);
}
printf("\n");
heepSort(ARRAY, 9); //堆排序
printf("After sorted:\n"); //打印排序后数组内容
for (i = 0; i < 9; i++)
{
printf("%d ", ARRAY[i]);
}
printf("\n");
}
基数排序
#include <stdio.h>
#include <malloc.h>
#define LEN 8
typedef struct node //队列结点
{
int data;
struct node * next;
}node, *QueueNode;
typedef struct Queue //队列
{
QueueNode front;
QueueNode rear;
}Queue, *QueueLink;
QueueLink CreateNullQueue(QueueLink &Q) //创建空队列
{
Q = NULL;
Q = (QueueLink)malloc(sizeof(Queue));
if (NULL == Q)
{
printf("Fail to malloc null queue!\n");
return NULL;
}
Q->front = (QueueNode)malloc(sizeof(node));
Q->rear = (QueueNode)malloc(sizeof(node));
if (NULL == Q->front || NULL == Q->rear)
{
printf("Fail to malloc a new queue's fornt or rear!\n");
return NULL;
}
Q->rear = NULL;
Q->front->next = Q->rear;
return Q;
}
int lenData(node data[], int len) //计算队列中各结点的数据的最大位数
{
int m = 0;
int temp = 0;
int d;
for (int i = 0; i < len; i++)
{
d = data[i].data;
while (d > 0)
{
d /= 10;
temp++;
}
if (temp > m)
{
m = temp;
}
temp = 0;
}
return m;
}
QueueLink Push(QueueLink &Q, node node) //将数据压入队列
{
QueueNode p1, p;
p = (QueueNode)malloc(sizeof(node));
if (NULL == p)
{
printf("Fail to malloc a new node!\n");
return NULL;
}
p1 = Q->front;
while (p1->next != NULL)
{
p1 = p1->next;
}
p->data = node.data;
p1->next = p;
p->next = Q->rear;
return NULL;
}
node Pop(QueueLink &Q) //数据出队列
{
node temp;
temp.data = 0;
temp.next = NULL;
QueueNode p;
p = Q->front->next;
if (p != Q->rear)
{
temp = *p;
Q->front->next = p->next;
free(p);
p = NULL;
}
return temp;
}
int IsEmpty(QueueLink Q)
{
if (Q->front->next == Q->rear)
{
return 0;
}
return 1;
}
int main(void)
{
int i = 0;
int Max = 0; //记录结点中数据的最大位数
int d = 10;
int power = 1;
int k = 0;
node Array[LEN] = { { 450, NULL }, { 32, NULL }, { 781, NULL }, { 57, NULL }, 组
{ 145, NULL }, { 613, NULL }, { 401, NULL }, { 594, NULL } };
//队列结点数
QueueLink Queue[10];
for (i = 0; i < 10; i++)
{
CreateNullQueue(Queue[i]); //初始化队列数组
}
for (i = 0; i < LEN; i++)
{
printf("%d ", Array[i].data);
}
printf("\n");
Max = lenData(Array, LEN); //计算数组中关键字的最大位数
printf("%d\n", Max);
for (int j = 0; j < Max; j++) //按位排序
{
if (j == 0) power = 1;
else power = power *d;
for (i = 0; i < LEN; i++)
{
k = Array[i].data / power - (Array[i].data / (power * d)) * d;
Push(Queue[k], Array[i]);
}
for (int l = 0, k = 0; l < d; l++) //排序后出队列重入数组
{
while (IsEmpty(Queue[l]))
{
Array[k++] = Pop(Queue[l]);
}
}
for (int t = 0; t < LEN; t++)
{
printf("%d ", Array[t].data);
}
printf("\n");
}
return 0;
}
队列为基数排序的实现提供了很大的方便,适当的数据机构可以减少算法的复杂度,让更多的算法实现更容易。