卑微声明:一些题目答案是我从其他博主那看到,觉得讲的不错的,摘过来的。因为题目较多,没有一一把原博文链接贴出,如果侵权,请联系我。360度鞠躬。
渣女声明:一些代码和发言是我自己根据理解写的,错误概不负责啊,哈哈,如果你发现了错误,欢迎留言指正
设计模式
设计模式:是解决软件开发某些特定问题而提出的一些解决方案也可以理解成解决问题的一些思路。通过设计模式可以帮助我们增强代码的可重用性、可扩充性、 可维护性、灵活性好。我们使用设计模式最终的目的是实现代码的 高内聚 和 低耦合。
设计模式主要分三个类型:
创建型:单例模式、抽象工厂、工厂方法、建造模式、原型模式等
结构型:外观模式、适配器模式、代理模式、装饰模式等
行为型:迭代器模式、观察者模式、命令模式等
单例模式的实现 更多参考
-
含义
一个类只有一个实例,提供一个访问它的全局访问点。其特点是只提供唯一一个类的实例,具有全局变量的特点,在任何位置都可以通过接口获取到那个唯一实例;
-
运用场景
- 设备管理器,系统中可能有多个设备,但只有一个设备管理器,用于管理设备驱动;
- 数据池,用来缓存数据的数据结构,需在一处写,多处读取或者多处写,多处读取;
- 实现要点
- 全局只有一个实例:static 特性,同时禁止用户自己声明并定义实例(把构造函数设为 private)
- 线程安全
- 禁止赋值和拷贝(private)
- 用户通过接口获取实例:使用 static 类成员函数
- 实现1:懒汉式
#include<iostream>
using namespace std;
class singleton {
private:
singleton() {
cout << "我构造了啊" << endl;
};
singleton(singleton&) {
};
singleton& operator=(const singleton&) {
};
static singleton* instance_ptr;
public:
~singleton(){
cout << "我析构了啊" << endl;
};
static singleton* get_instance() {
if (instance_ptr == nullptr) {
instance_ptr = new singleton();
}
return instance_ptr;
}
};
singleton* singleton::instance_ptr = nullptr;
int main() {
singleton* a = singleton::get_instance();
singleton* b = singleton::get_instance();
cout << "俩指针指向的内存地址要相同啊" << endl;
cout << a << endl;
cout << b << endl;
delete a;
system("pause");
return 0;
}
//运行结果
我构造了啊
俩指针指向的内存地址要相同啊
000001D51C2D8E80
000001D51C2D8E80
我析构了啊
请按任意键继续. . .
懒汉式的问题:
没有保证线程安全;(给我加锁,加他)
需要手动delete,易发生内存泄漏;
易出现多次delete同一块内存空间,导致运行错误哦。(给我用智能指针,用他)
- 懒汉式改进版1
#include<iostream>
#include<memory>
#include<mutex>
using namespace std;
class singleton {
public:
typedef std::shared_ptr<singleton> Ptr; //简化类型名,Ptr是 std::shared_ptr<singleton>同义词
~singleton() {
};
static Ptr get_instance() { //静态成员函数既可以在类内定义也可以在类外
mtx.lock(); //加锁
if (instance_ptr == nullptr) {
instance_ptr = Ptr(new singleton());
}
mtx.unlock();//释放锁
return instance_ptr;
}
private:
singleton() {
};
singleton(singleton&) {
};
singleton& operator=(const singleton&) {
};
static Ptr instance_ptr; //类变量
static mutex mtx;
};
//必须在类的外部定义和初始化每个静态成员
singleton::Ptr singleton::instance_ptr = nullptr;
mutex singleton::mtx;
问题:用户也必须使用共享指针;加锁增加开销,有时会失效?
- 懒汉式改进版2——静态局部变量
静态局部变量在程序执行到该对象的声明处时被首次初始化,即以后的函数调用不再进行初始化。
并且如果当变量在初始化的时候,并发同时进入声明语句,并发线程将会阻塞等待初始化结束。这样保证了并发线程在获取静态局部变量的时候一定是初始化过的,所以具有线程安全性。
#include<iostream>
using namespace std;
class singleton {
public:
~singleton() {
};
static singleton& get_instance() {
static singleton instance; //变量位于内存的全局区
return instance;
}
private:
singleton() {
};
singleton(singleton&) {
};
singleton& operator=(const singleton&) {
};
};
int main() {
singleton& a = singleton::get_instance();
singleton& b = singleton::get_instance();
system("pause");
return 0;
}
一、基础知识
0 声明和定义的区别
-
如果是指变量的声明和定义
从编译原理上来说,声明是仅仅告诉编译器,有个某类型的变量会被使用,但是编译器并不会为它分配任何内存。而定义会为变量分配内存,可能会为变量赋初值。
变量只能会定义一次,但可以多次声明。
extern int i; //声明i,没有定义
extern int i = 0; //任何包含显示初始化的声明都是定义。
//!在函数体内部初始化由extern修饰的变量会报错
int j; //声明并定义j
- 如果是指函数的声明和定义
声明:一般在头文件里,让编译器知道这个函数的存在。
定义:一般在源文件里,具体就是函数的实现过程 写明函数体。
1 extern 用法
参考1 参考2
修饰符extern用在变量或者函数的声明前,用来说明“此变量/函数是在别处定义的,要在此处引用”。(变量的定义只能出现在一个文件中,而其他用到该变量的文件必须对其进行声明,但不能重复定义。)
-
extern 修饰变量的声明
如果文件 a.c 需要引用 b.c 中变量 int v,就可以在 a.c 中声明 extern int v,然后就可以引用变量 v。
但是需要注意变量的作用域。能够被其他模块以extern修饰符引用到的变量通常是全局变量。另外,extern int v可以放在a.c中的任何地方,不一定非要放在a.c的文件作用域的范围中,可以放在函数体内部,只不过这样只能在函数作用域中引用v罢了。
-
extern 修饰函数的声明
如果文件 a.cpp 需要引用 b.cpp 中的函数,比如在 b.cpp 中原型是 int fun(int mu),那么就可以在 a.cpp 中声明 extern int fun(int mu),然后就能使用 fun 来做任何事情。就像变量的声明一样,extern int fun(int mu)可以放在 a.cpp 中任何地方,而不一定非要放在 a.cpp 的文件作用域的范围中。对其他模块中函数的引用,最常用的方法是包含这些函数声明的头文件。上才艺
// head.h
#pragma once
#include<iostream>
extern int i;
extern char j;
extern void fun();
// head.cpp
#include "head.h"
int i = 7;
char j = 'a';
void fun() {
printf("call fun\n");
}
//main.cpp
#include<iostream>
using namespace std;
extern int i;
extern char j;
extern void fun();
int main() {
cout << i << endl;
cout << j << endl;
fun();
system("pause");
return 0;
}
//输出
7
a
call fun
请按任意键继续. . .
-
extern 修饰符可用于指示 C 或者 C++函数的调用规范。
比如在 C++中调用 C 库函数,就需要在 C++程序中用 extern “C”声明要引用的函数。这是给链接器用的,告诉链接器在链接的时候用 C 函数规范来链接。主要原因是 C++和 C 程序编译完成后在目标代码中命名规则不同。
void foo( int x, int y );
该函数被C编译器编译后在符号库中的名字为_foo,而C++编译器则会产生像_foo_int_int之类的名字(不同的编译器可能生成的名字不同,但是都采用了相同的机制,生成的新名字称为“mangled name”)。_foo_int_int这样的名字包含了函数名、函数参数数量及类型信息,C++就是靠这种机来实现函数重载的。
如果在C++中声明C中定义的函数时,未加extern "C"声明,那么在链接阶段,链接器会从.C文件生成的目标文件中寻找_foo_int_int这样的符号!如果加上了extern "C"声明,那么链接器在为C++代码寻找f(2,3)的调用时,寻找的是未经修改的符号名_foo。所以,可以用一句话概括extern “C”这个声明的真实目的:实现C++与C及其它语言的混合编程。
tip1:
- 通常,在模块的头文件中对本模块提供给其它模块引用的函数和全局变量以关键字extern声明。例如,如果模块B欲引用该模块A中定义的全局变量和函数时只需包含模块A的头文件即可。这样,模块B中调用模块A中的函数时,在编译阶段,模块B虽然找不到该函数,但是并不会报错;它会在连接阶段中从模块A编译生成的目标代码中找到此函数。
//main.cpp
#include<iostream>
#include"head.h" //头文件的形式引用其他模块定义的变量
using namespace std;
int main() {
cout << i << endl;
cout << j << endl;
fun();
system("pause");
return 0;
}
tip2:使用extern和包含头文件来引用函数有什么区别呢?
- extern的引用方式比包含头文件要简洁得多!extern的使用方法是直接了当的,想引用哪个函数就用extern声明哪个函数。这样做的一个明显的好处是,会加速程序的编译(确切的说是预处理)的过程,节省时间。在大型C程序编译过程中,这种差异是非常明显的。
2 static的用法
转载自:原文链接,讲的很详细,有例子
C 语言的 static 关键字有三种用途:
-
静态局部变量
用于函数体内部修饰变量,这种变量的生存期长于该函数。
-
静态全局变量
定义在函数体外,用于修饰全局变量,表示该变量只在本文件可见(文件隔离)。 -
静态函数
准确的说,静态函数跟静态全局变量的作用类似。
C++ 语言的 static 关键字有二种用途:
除了C语言的几种用法外,static在c++中,
- 静态数据成员
用于修饰 class 的数据成员,即所谓“静态成员”。这种数据成员的生存期大于 class 的对象(实体 instance)。静态数据成员是每个 class 有一份,普通数据成员是每个 instance 有一份,因此静态数据成员也叫做类变量,而普通数据成员也叫做实例变量。 - 静态成员函数
用于修饰 class 的成员函数。
- 静态成员函数不能访问非静态(包括成员函数和数据成员),但是非静态可以访问静态。
- 静态成员函数不能被 virtual 修饰。
- 静态成员函数既可以在类内定义也可以在类外。
- 静态数据成员一般定义在类外(除了其类型是字面值常量类型)。
关于静态变量的初始化
初始化只有一次,但是可以多次赋值,在程序开始之前,编译器会为静态变量在全局区分配内存。
- 在 C 中,初始化发生在代码执行之前,编译阶段分配好内存之后,就会进行初始化,所以我们看到在 C语言中无法使用变量对静态局部变量进行初始化,在程序运行结束,变量所处的全局内存会被全部回收。
- 而在C++中,初始化是在执行相关代码时才会进行初始化,主要是由于C++引入对象后,要进行初始化必须执行相应构造函数和析构函数,在构造函数或析构函数中经常会需要进行某些程序中需要进行的特定操作,并非简单地分配内存。所以C++标准规定为全局或静态对象是有首次用到时才会进行构造,并通过atexit()来管理。在程序结束,按照构造顺序反方向进行逐个析构。所以在 C++中是可以使用变量对静态局部变量进行初始化的。
const
-
const修饰变量
阻止一个变量被改变,可以使用 const 关键字,const对象一旦创建后其值就不能改变,所以const对象必须初始化。默认情况下,const对象被设定为仅在文件内有效,如果想在多个文件中共享const对象,必须在变量的定义前添加extern关键字。类型转换符 const_cast 可以将const类型变量转换为非 const 类型;
! 如果类的数据成员是const或者引用,因为必须初始化,所以需要在构造函数的初始化列表中初始化。!
-
const修饰引用
常量引用:不能修改它所绑定的对象的值
const int c = 2;//顶层const
int& b = c; //错误,试图让一个非常量引用指向常量对象
const int& a = p; // 声明引用的const都是底层const
a = 3; //错误,试图修改常引用绑定的对象
- const修饰指针
(1)const int* (2)const p = &a;
// (1)常量指针,底层const表示指针所指对象是一个常量
// (2)指针常量,顶层const表示指针本身是常量
(1) int const *p1 或者 const int *p1 两种情况中 const 修饰*p1,所以理解为*p1 的值不可以改变,即不可以给*p1 赋值改变 p1 指向变量的值,但可以通过给 p 赋值不同的地址改变这个指针指向。底层指针表示指针所指向的变量是一个常量。
(2) int *const p2 中 const 修饰 p2 的值,所以理解为 p2 的值不可以改变,即 p2 只能指向固定的一个变量地址,但可以通过*p2 读写这个变量的值。顶层指针表示指针本身是一个常量
- const修饰类的成员函数
1const Stock& Stock::topval (2const Stock & s) 3const
(1)const修饰类成员函数的返回值
有时候必须指定其返回值为 const 类型,以使得其返回值不为“左值”。
(2)const修饰函数的形参
确保在函数内部不修改传递的参数的值,只有引用传递和指针传递可以用是否加 const 来重载。一个拥有顶层 const 的形参无法和另一个没有顶层 const 的形参区分开来。
(3)const修饰类成员函数
表明此函数为常成员函数,不能修改类的成员变量。类的常对象只能访问类的常成员函数。(非常成员函数不能保证不修改类的数据成员)
Tip:
(1)const 成员函数可以访问非const对象的非const数据成员、const数据成员,
也可以访问 const 对象内的所有数据成员;(啥都能访问就是呗)
(2)非 const 成员函数可以访问非const对象的非const数据成员、const数据成员,
但不可以访问const对象的任意数据成员;
6 define、const、typedef、inline 使用方法
6.1 const 与#define 的区别
#define MAXTIME 1000 //简单的文本替换
#define max(x,y) (x)>(y)?(x):(y);
#define Add(a,b) a+b; //2*add(3+1)*2 被替换成 2*3+1*2 错误
#define pin (int*); //pin a,b 替换成 int *a,b; a是int型指针,b是int型变量
- const 定义的常量 是变量 带类型,而#define 定义的只是个常数 不带类型;
- define 只在预处理阶段起作用,进行简单的文本替换,而 const 在编译、链接过程中起作用;
- define 预处理后,占用代码段空间,const 占用数据段空间;
- const 不能重定义,而 define 可以通过#undef 取消某个符号的定义,进行重定义;
- define 只是简单的字符串替换没有类型检查,并且在字符替换中可能会产生边界效应,发生意想不到的错误(如代码所示)。而 const 是有数据类型的,是要进行判断的,可以避免一些低级错误;
- const定义常量时大小写是敏感的,而define可通过第三个参数(true)来指定大小写是否敏感
- define 独特功能,比如可以用来防止文件重复引用。
6.2 #define 和别名 typedef 的区别
#include<iostream>
using namespace std;
typedef double d; //d是double的同义词
typedef d a, *b; //a是double的同义词,b是double*的同义词
typedef char* p1; //p1是char*的同义词
#define p0 char*
int main(){
p1 e, f;
p0 g, h;
cout << typeid(e).name() << endl; //char * __ptr64
cout << typeid(f).name() << endl; //char * __ptr64
cout << typeid(g).name() << endl; //char * __ptr64
cout << typeid(h).name() << endl; //char
system("pause");
return 0;
}
- 执行时间不同,typedef 在编译阶段有效,有类型检查的功能;#define 是宏定义,发生在预处理阶段,不进行类型检查;
- 功能差异,typedef 用来定义类型的别名,定义与平台无关的数据类型,与 struct的结合使用等。#define 不只是可以为类型取别名,还可以定义常量、变量、编译开关等。
- 作用域不同,#define 没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用。而 typedef 有自己的作用域。(与变量一样,有相似的作用域)
#include<iostream>
using namespace std;
void fun() {
typedef char* p1; //在此函数作用域有效
#define p0 char*
}
int main(){
p1 e; //编译失败,未定义标识符p1
p0 g;
cout << typeid(e).name() << endl;
cout << typeid(g).name() << endl;
system("pause");
return 0;
}
6.3 define 与 inline 的区别
- #define 是关键字,inline 是函数;
- 宏定义在预处理阶段进行文本替换,inline 函数在编译阶段进行替换;
- 内联函数采用的是值传递,而宏定义采用的是对等替换
- inline 函数有类型检查,相比宏定义比较安全;
typedef简化复杂的声明和定义
3 指针和引用的区别
两者的区别是要看你在哪个角度理解
- 从汇编的角度来看,两者并没有什么区别。因为引用底层实际上是通过指针实现的;引用本质上是一个指针常量,在内存中也需要为引用开辟了一个指针型的内存单元。
- 从c++程序员的角度来看,使用上两者有如下区别
(1)引用在定义的时候必须进行初始化,并且不能够改变。指针在定义的时候不一定要初始化,并且指向的空间可变。
(2) 引用访问一个变量是直接访问,而指针访问一个变量是间接访问。
(3)作为参数时也不同,传指针的实质是传值,传递的值是指针的地址;传引用的实质是传地址,传递的是变量的地址。
(4)有多级指针,但是没有多级引用。
(5)指针和引用的自增运算结果不一样。(指针是指向下一个空间,引用时引用的变量值加 1)
(6) sizeof 引用得到的是所指向的变量(对象)的大小,而 sizeof 指针得到的是指针本身的大小。
既然引用底层通过指针实现,引用能做的指针也能做,那为什么引入引用呢。参考
- 用指针的使用经常犯得错:1,操作空指针,2,操作野指针,3,不知不觉改变了指针的值,而后还以为该指针正常。如果我们要正确的使用指针,我们不得不人为地保证这三个条件。而引用的提出就解决了这个问题。
- 引用区别于指针的特性是 :1,不存在空引用(保证不操作空指针),2,必须初始化(保证不是野指针),3,一个引用永远指向他初始化的那个对象(保证指针值不变)。人为保证变为了编译器来保证,更加安全。
4 指针参数传递和引用参数传递
//指针传递
void f(int* p) {
printf("\n%x", &p); //形参指针的地址
printf("\n%x", p); //形参指针值,即传入的a的地址
printf("\n%x",*p);
int g = 6;
p = &g; //改变形参指针值,即改变指针的指向。对实参指针值不影响
printf("\n%x", &p);
printf("\n%x", p);
printf("\n%x", *p);
}
void main()
{
int a = 4;
int* b = &a;
printf("%x",&b); //指针的地址
printf("\n%x\n", b); //实参指针的值,变量a的地址
f(b);
printf("\n%x\n", a);
system("pause");
}
//引用传递
void f(int& p)
{
printf("\n%x", &p); //形参引用绑定到实参对象
printf("\n%x", p);
p = 6;
printf("\n%x", &p);
printf("\n%x", p);
}
void main()
{
int a = 4;
printf("%x",&a); //变量的地址
printf("\n%x", a); //变量的值
f(a);
printf("\n%x\n", a);
system("pause");
}
- 引用参数传递,形参和实参地址相同,对形参改变,实参也改变。
- 指针参数传递,形参指针和实参指针地址不同,实参指针把指针值(即指针指向的对象的地址)传递给形参指针,即两个指针指向同一对象。改变形参指针值(即改变形参指针指向的对象,注意不是解引用改变对象的值)不会对实参指针的指向和实参指针指向的对象造成影响。但因为两个指针都指向同一对象,形参指针可通过解引用改变指向对象的值(*p = 6)。(ps:自己的理解,错了概不负责,哈哈)。
7 重载、重写、重定义
1.定义区别:
- 重载是指不同的函数使用相同的函数名,但是函数的参数个数或类型不同。调用的时候根据函数的参数来区别不同的函数。静态多态
- 覆盖(也叫重写)是指在派生类中对基类中的虚函数(注意是虚函数)重新实现。即函数名和参数都一样,只是函数的实现体不一样。动态多态。
2.类的关系区别
- 覆盖是子类和父类之间的关系,是垂直关系;重载是同一个类中方法之间的关系,是水平关系。
3.规则上的不同
- 重载的规则:
①必须具有不同的参数列表。
②可以有不同的访问修饰符。
③可以抛出不同的异常。 - 重写方法的规则:
①参数列表必须完全与被重写的方法相同,否则不能称其为重写而是重载。
②返回的类型必须一直与被重写的方法的返回类型相同,否则不能称其为重写而是重载。
③访问修饰符的限制一定要大于被重写方法的访问修饰符。
④重写方法一定不能抛出新的检查异常或者比被重写方法申明更加宽泛的检查型异常。
重定义(也成隐藏)例子讲解
(1)不在同一个作用域(分别位于派生类与基类) ;
(2)函数名字相同;
(3)返回值可以不同;
(4)参数不同时。此时,不论有无 virtual 关键字,基类的函数将被隐藏(注意别与重载以及覆盖混淆)。
(5)参数相同时,但是基类函数没有 virtual关键字。此时,基类的函数被隐藏(注意别与覆盖混淆) 。
8 深拷贝和浅拷贝
-
浅拷贝——仅仅是指向被复制的内存地址,如果原地址中对象被改变了,那么浅拷贝出来的对象也会相应改变。
-
深拷贝 —-在计算机中开辟了一块新的内存地址用于存放复制的对象
-
在某些状况下,类内成员变量需要动态开辟堆内存,如果实行浅拷贝,也就是把对象里的值完全复制给另一个对象,如 A=B。这时,如果 B 中有一个成员变量指针已经申请了内存,那 A 中的那个成员变量也指向同一块内存。这就出现了问题:当 B把内存释放了(如:析构),这时 A 内的指针就是野指针了,出现运行错误。(c++ primer P453)
-
Mat拷贝举例,深拷贝 b = a.clone();a.copyTo(b); 浅拷贝b = a;和 b(a);
9 c++代码到最终可执行文件的过程
源代码——>预处理——>编译——>优化——>汇编——>链接——>可执行文件 参考
- 预处理 gcc -E
读取 源程序,对其中的伪指令(以#开头的指令)和特殊符号进行处理。包括宏定义替换、条件编译指令、头文件包含指令、特殊符号。 所完成的基本上是对源程序的“替代”工作。生成一个没有宏定义、没有条件编译指令、没有特殊符号的输出文件。.i 预处理后的 c 文件,.ii 预处理后的 C++文件。 - 编译阶段 -S
通过词法分析和语法分析,在确认所有的指令都符合语法规则之后,将其翻译成等价的中间代码表示或汇编代码。.s 文件 - 汇编 -C
把汇编语言代码翻译成目标机器指令的过程。生成的目标文件(二进制文件)是与源程序在逻辑上等效的机器语言代码。.o 目标文件。 - 链接
将一个或多个由编译器或汇编器生成的目标文件外加库链接为一个可执行文件。 现在的大多数操作系统都提供静态链接和动态链接这两种链接方式。
由汇编程序生成的目标文件并不能立即就被执行,其中可能还有许多没有解决的问题。
例如,某个源文件中的函数可能引用了另一个源文件中定义的某个符号(如变量或者函数调用等);在程序中可能调用了某个库文件中的函数,等等。因此需要链接器将有关的目标文件彼此相连接,使得所有的这些目标文件成为一个能够被操作系统装入执行的统一整体
10 函数指针
-
定义
函数指针指向的是函数而非对象,函数指针指向的是特殊的数据类型,函数的类型是由其返回的数据类型和其参数列表共同决定的,而函数的名称则不是其类型的一部分。 -
声明和赋值
要声明一个指向函数的指针,只需要用指针替换函数名即可。一个具体函数的名字,如果后面不跟调用符号(即括号),则该名字就是该函数的指针(注意:大部分情况下,可以这么认为,但这种说法并不很严格)。
-
通过改变函数指针的指向,可以实现不同的函数调用
11 int
在c/c++中,没有规定long和int的长度具体是多少,只是规定long的长度不小于int的长度。其具体长度跟系统和编译器有关。
//在我的电脑上 64位操作系统,vs2015
int: 4 byte
long: 4 byte
long long: 8 byte
char: 1 byte
float: 4 byte
double: 8 byte
二、内存管理
1 c/c++的内存分配
- 栈区(stack)— 由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
- 堆区(heap) — 一般由程序员分配释放new/delete,若程序员不释放,程序结束时可能OS(操作系统)回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
- 全局区(静态区)(static)— 全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
- 文字常量区 —常量字符串就是放在这里的。程序结束后由系统释放。
- 程序代码区 —存放函数体的二进制代码。
2 堆和栈的区别
- 管理方式:对于栈来讲,是由编译器自动管理,无需我们手工控制;
对于堆来说,释放工作由程序员控制,容易产生 memory leak。 - 空间大小:一般来讲在 32 位系统下,堆内存可以达到 4G 的空间,从这个角度来看堆内存几乎是没有什么限制的。但是对于栈来讲,一般都是有一定的空间大小的,例如,在 VC6 下面,默认的栈空间大小是 1M(好像是,记不清楚了)。当然,我们可以修改: 打开工程,依次操作菜单如下:Project->Setting->Link,在 Category 中选中 Output,然后在 Reserve 中设定堆栈的最大值和 commit。 注意:reserve 最小值为 4Byte;commit 是保留在虚拟内存的页文件里面,它设置的较大会使栈开辟较大的值,可能增加内存的开销和启动时间。
- 碎片问题:对于堆来讲,频繁的 new/delete 势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈来讲,则不会存在这个问题,因为栈是先进后出的队列,他们是如此的一一对应,以至于永远都不可能有一个内存块从栈中间弹出,在他弹出之前,在他上面的后进的栈内容已经被弹出。
- 生长方向:对于堆来讲,生长方向是向上的,也就是向着内存地址增加的方向;对于栈来讲,它的生长方向是向下的,是向着内存地址减小的方向增长。
- 分配方式:堆都是动态分配的,没有静态分配的堆。栈有 2 种分配方式:静态分配和动态分配。静态分配是编译器完成的,比如局部变量的分配。动态分配由 alloca 函数进行分配,但是栈的动态分配和堆是不同的,它的动态分配是由编译器进行释放,无需我们手工实现。
- 分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是 C/C++函数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法(具体的算法可以参考数据结构/操作系统)在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。
3 动态内存
new/delect、野指针、内存泄漏、智能指针
除了全局区和栈区,每个程序还拥有一个内存池,被称为自由空间或堆区,程序用堆区存储动态分配的对象。动态对象的生存期由程序控制,只有当显示地被释放时,才会销毁。在c++中,动态内存的管理式通过一对运算符来完成的:
- new:在动态内存中为对象分配空间并返回一个指向该对象的指针,我们可以选择对对象进行初始化;
- delete:接受一个动态对象的指针,销毁该对象,并释放与之关联的内存。
int *p = new int(3); delete p; int *p1 = new int[5]; delete[] p1;
3.1 new和malloc的区别
- new/delete 是 C++关键字,需要编译器支持。malloc/free 是库函数,需要头文件支持;
int *p1 = (int*)malloc(sizeof(int));
=> 根据传入字节数开辟内存,没有初始化
int *p2 = new int(0);
=> 根据指定类型int开辟一个整形内存,初始化为0
int *p3 = (int*)malloc(sizeof(int)*100);
=> 开辟400个字节的内存,相当于包含100个整形元素的数组,没有初始化
int *p4 = new int[100]();
=> 开辟400个字节的内存,100个元素的整形数组,元素都初始化为0
- malloc和new都是在堆上开辟内存的。malloc只负责开辟内存,没有初始化功能,需要用户自己初始化;new不但开辟内存,还可以进行初始化。对于类类型来说,所谓初始化,就是调用相应的构造函数。
- 使用 new 申请内存分配时无须指定内存块的大小,编译器会根据类型信息自行计算。malloc 则需要显式地指出所需内存的尺寸。
- new 操作符内存分配成功时,返回的是对象类型的指针,类型严格与对象匹配,无须进行类型转换,故 new 是符合类型安全性的操作符。而 malloc 内存分配成功则是返回 void * ,需要通过强制类型转换将 void*指针转换成我们需要的类型。
- new 内存分配失败时,会抛出 bac_alloc 异常,需要捕获异常才能判断内存开辟成功或失败。malloc 分配内存失败时返回 NULL。
- malloc开辟的内存永远是free来释放的;而new单个元素内存,用的是delete,如果new[]数组,用的是delete[]来释放内存的。
- new 会先调用 operator new 函数,申请足够的内存(通常底层使用 malloc 实现)。然后调用类型的构造函数,初始化成员变量,最后返回自定义类型指针。delete 先调用析构函数,然后调用 operator delete 函数释放内存(通常底层使用 free 实现)。malloc/free 是库函数,只能动态的申请和释放内存,无法强制要求其做自定义类型对象构造和析构工作。
3.2 delete和delete[]的区别
delete 释放new分配的 单个对象指针 指向的内存
delete[] 释放new分配的 对象数组指针 指向的内存,数组中的对象按逆序逐一调用destructor析构函数进行销毁!!
-
对于像int/char/long/int*/struct等等简单数据类型,由于对象没有destructor,所以用delete 和delete [] 是一样的!
-
但是如果是C++对象数组就不同了!使用delete的时候只调用了pbabe[0]的析构函数,而使用了delete[]则调用了数组中所有对象的析构函数。
-
你一定会问,反正不管怎样都是把存储空间释放了,有什么区别。
答:关键在于调用析构函数上。此程序的类没有使用操作系统的系统资源(比如:Socket、File、Thread等),所以不会造成明显恶果。如果你的类使用了操作系统资源,单纯把类的对象从内存中删除是不妥当的,因为没有调用对象的析构函数会导致系统资源不被释放,(1)如果是Socket则会造成Socket资源不被释放,最明显的就是端口号不被释放,系统最大的端口号是65535(216 _ 1,因为还有0),如果端口号被占用了,你就不能上网了,呵呵。(2)如果File资源不被释放,你就永远不能修改这个文件,甚至不能读这个文件(除非注销或重器系统)。(3)如果线程不被释放,这它总在后台运行,浪费内存和CPU资源。这些资源的释放必须依靠这些类的析构函数。所以,在用这些类生成对象数组的时候,用delete[]来释放它们才是王道。而用delete来释放也许不会出问题,也许后果很严重,具体要看类的代码了.
3.3 重载new和delete
参考、参考
如何让类只支持静态对象创建和只支持动态对象创建?
前者重载new和delete为private属性,后者把构造和析构函数设为protected,由子类动态创建。
本菜鸡不确保以下代码的正确性。告辞
3.3.1 全局operator new和operator delete重载
#include<iostream>
#include<vector>
#include<cstdlib>
#include<cstdio>
#include<string>
using namespace std;
void* operator new(size_t sz) {
cout << "my reload operator new: " <<sz<< endl;
void* m = malloc(sz);
if (!m) {
cout << "my reload operator new error" << endl;
}
return m;
}
void operator delete(void* m) {
cout << "my reload operator free" << endl;
free(m);
}
class A {
public:
A() {
cout << "构造函数" << endl;
}
~A() {
cout << "析构函数" << endl;
}
};
int main()
{
cout << "----------- test 1 -----------" << endl;
int* m = new int(6);
delete(m);
cout << "----------- test 2 -----------" << endl;
char* c = new char[3](); //注意! new char[3] 不会默认初始化,这样strlen(c)长度不固定
cout << sizeof(c) << endl; //没有返回3,而是8,因为sizeof测量的是指针的大小不是数组的大小
cout << strlen(c) << endl;
char tem[] = "iloveyou";
cout << sizeof(tem) << endl;
cout << strlen(tem) << endl;
memcpy(c, tem, 2); // c最多存储两个字符
for (int i = 0;i < strlen(c);i++)
cout << c[i] << " ";
cout << endl;
delete[]c;
cout << "----------- test 3 -----------" << endl;
A* a = new A();
delete a;
cout << "----------- test 4 -----------" << endl;
A* aa = new A[2]();
delete []aa;
_CrtDumpMemoryLeaks(); //定位内存泄漏,经测试,这三种重载均没有内存泄漏
system("pause");
return 0;
}
/*输出
----------- test 1 -----------
my reload operator new: 4
my reload operator free
----------- test 2 -----------
my reload operator new: 3
8
0
9
8
i l
my reload operator free
----------- test 3 -----------
my reload operator new: 1
构造函数
析构函数
my reload operator free
----------- test 4 -----------
my reload operator new: 10 //????10
构造函数
构造函数
析构函数
析构函数
my reload operator free
请按任意键继续. . .
*/
3.3.2 为类重载operator new和operator delete
class A {
public:
A() {
cout << "构造函数" << endl;
}
~A() {
cout << "析构函数" << endl;
}
//--------------------增加的部分down-----------------------------
void* operator new(size_t sz) {
cout << " class A's operator new: " << sz << endl;
void* m = malloc(sz);
return m;
}
void operator delete(void* m) {
cout << " class A's operator delete "<< endl;
free(m);
}
//--------------------增加的部分up-----------------------------
};
/*输出
----------- test 1 -----------
my reload operator new: 4
my reload operator free
----------- test 2 -----------
my reload operator new: 3
8
0
9
8
i l
my reload operator free
----------- test 3 -----------
class A's operator new: 1 //单个类对象的new调用的重载的类的operator new
构造函数
析构函数
class A's operator delete
----------- test 4 -----------
my reload operator new: 10 //类对象数组依然使用全局的operator new
构造函数
构造函数
析构函数
析构函数
my reload operator free
请按任意键继续. . .
*/
3.3.3 为类的对象数组重载new和delete
在3.3.2中虽然为类重载了operato new和operator delete,在创建类的单个对象时,new调用重载的operator new,但是创建类的对象数组时,依然调用全局的operator new用来为这个数组分配足够的内存。对此,我们可以通过为这个类重载运算符的数组版本,即operator new[]和operator delete[],来控制对象数组的内存分配。
class A {
public:
A() {
cout << "构造函数" << endl;
}
~A() {
cout << "析构函数" << endl;
}
void* operator new(size_t sz) {
cout << " class A's operator new: " << sz << endl;
void* m = malloc(sz);
return m;
}
void operator delete(void* m) {
cout << " class A's operator delete "<< endl;
free(m);
}
//----------------新增--------------
void* operator new[](size_t sz) {
cout << " class A's operator new[]: " << sz << endl;
void* m = malloc(sz);
return m;
}
void operator delete[](void* m) {
cout << " class A's operator delete[] " << endl;
free(m);
}
//---------------新增----------------
};
/*输出
----------- test 1 -----------
my reload operator new: 4
my reload operator free
----------- test 2 -----------
my reload operator new: 3
8
0
9
8
i l
my reload operator free
----------- test 3 -----------
class A's operator new: 1
构造函数
析构函数
class A's operator delete
----------- test 4 -----------
class A's operator new[]: 10 //new对象数组时调用的类的operator new[]
构造函数
构造函数
析构函数
析构函数
class A's operator delete[]
请按任意键继续. . .
*/
野指针
野指针:指向内存被释放的内存或者没有访问权限的内存的指针。
悬空指针:是指针最初指向的内存已经被释放了,以至于该指针仍旧指向已经回收的内存地址。
- 成因
① 指针变量没有被初始化。
它的缺省值是随机的。因此,指针变量在创建的同时应当被初始化,要么将指针设置为 NULL,要么让它指向合法的内存。例如
char *p = NULL;
char *str = new char(100);
② 指针 p 被 free 或者 delete 之后,没有置为 NULL;
③ 指针操作超越了变量的作用范围。 - 如何避免野指针
① 对指针进行初始化
② 指针用完后释放内存,将指针赋 NULL。
内存泄漏
内存泄漏是指由于疏忽或错误造成了程序未能释放掉不再使用的内存的情况。内存泄漏并非指内存在物理上消失,而是应用程序分配某段内存后,由于设计错误,失去了对该段内存的控制。
后果
- 发生一次小的内存泄漏可能不被注意,但泄漏大量内存的程序将会出现各种证照:性能下降到内存逐渐用完,导致另一个程序失败;
如何排除
- 使用工具软件 BoundsChecker,BoundsChecker 是一个运行时错误检测工具,它主
要定位程序运行时期发生的各种错误;
调试运行 DEBUG 版程序,运用以下技术:CRT(C run-time libraries)、运行时函
数调用堆栈、内存泄漏时提示的内存分配序号(集成开发环境 OUTPUT 窗口),综合
分析内存泄漏的原因,排除内存泄漏。
解决方法
- 使用智能指针管理堆内存
检查、定位内存泄漏
- 在 main 函数最后面一行,加上一句_CrtDumpMemoryLeaks()。调试程序,自然关闭程序让其退出,查看输出:输出这样的格式{453}normal block at 0x02432CA8,868 bytes long。被{}包围的 453 就是我们需要的内存泄漏定位值,868 bytes long 就是说这个地方有 868 比特内存没有释放。
#define _CRTDBG_MAP_ALLOC
#include <crtdbg.h>
#include <stdlib.h>
void main()
{
int* p = new int(5);
_CrtDumpMemoryLeaks();
system("pause");
}
/*
Detected memory leaks!
Dumping objects ->
{156} normal block at 0x0000029129C210F0, 4 bytes long.
Data: < > 05 00 00 00
*/
- 定位代码位置
在 main 函数第一行加上_CrtSetBreakAlloc(453);意思就是在申请 453 这块内存的
位置中断。然后调试程序,程序中断了,查看调用堆栈。
智能指针
使用new和delete管理动态内存存在的三个常见问题:
(1)忘记delete释放内存,内存泄漏。
(2)使用已经释放掉的对象。通过在释放内存后将指针置为空,有时可以检测出这种错误。
(3)同一块内存释放两次。自由空间可能被破坏。
为了更容易也更安全的使用动态内存,c++11标准库提供了智能指针类型来管理动态对象。智能指针的行为类似于常规指针,重要的区别是它负责自动释放所指向的对象。
unique_ptr、shared_ptr 和 weak_ptr。
-
unique_ptr 独占所指向对象,也就是说某个时刻只能有一个 unique_ptr 指向一个给定对象((通过禁止拷贝语义、只有移动语义来实现))。当它被销毁时它所指向的对象也被销毁.
-
shared_ptr允许多个指针指向同一对象,shared_ptr 会维护一个引用计数(use_count),只有最后一个 shared_ptr 也被销毁时,这个对象才真的被销毁。
-
week_ptr是一种不控制所指向对象生存期的智能指针,它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr. weak_ptr 只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少.
4 内存对齐
why 内存对齐??
- 平台原因(移植原因) 不理解看这
不是所有的硬件平台都能访问任意地址上的任意数据的;
某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常 - 性能原因:
数据结构(尤其是栈)应该尽可能地在自然边界上对齐。
原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;而对齐的内存访问仅需要一次访问。(??)
内存对齐原则
- 分配内存的顺序是按照声明的顺序。
- 每个变量相对于起始位置的偏移量必须是该变量类型大小的整数倍,不是整数倍空出内存,直到偏移量是整数倍为止。
- 最后整个结构体的大小必须是里面变量类型最大值的整数倍。
添加了#pragma pack(n)后规则就变成了下面这样:
- 偏移量要是 n 和当前变量大小中较小值的整数倍
- 整体大小要是 n 和最大变量大小中较小值的整数倍
- n 值必须为 1,2,4,8…,为其他值时就按照默认的分配规则
struct A {
char a1; short a2; char a3; char a4;
long a5; char a6; int* a7; char a8;
};
struct B{
char a1; char a2; char a3;
};
int main() {
A a;
cout << sizeof(A) << endl;
printf("%x\n", &a.a1);
printf("%x\n", &a.a2);
printf("%x\n", &a.a3);
printf("%x\n", &a.a4);
printf("%x\n", &a.a5);
printf("%x\n", &a.a6);
printf("%x\n", &a.a7);
printf("%x\n", &a.a8);
cout<<&a.a8 - &a.a1<<endl;
system("pause");
return 0;
}
/*输出
24
10ff72c
10ff72e
10ff730
10ff731
10ff734
10ff738
10ff73c
10ff740
20
请按任意键继续. . .
*/
char long short int* int
在32位操作系统上 1 4 2 4 4 sizeof(A)=24 sizeof(B)=32
在64位操作系统上 1 4 2 8 4 sizeof(A)=32 sizeof(B)=3
窃以为
三、类
手写string类
#include<iostream>
using namespace std;
class mystring {
public:
//1 构造函数1
mystring(int _cap = 10):capacity(_cap), size(0){
cout << "构造函数1" << endl;
s = new char[capacity];
}
//1 构造函数2
mystring(const char a[]){
cout << "构造函数2" << endl;
capacity = 2 * strlen(a); //原字符数组长度的两倍
size = strlen(a);
s = new char[capacity];
for (int i = 0;i < size;i++)
s[i] = a[i];
}
//2 拷贝构造函数
mystring(const mystring& src) {
cout << "拷贝构造函数" << endl;
capacity = src.capacity;
size = src.size;
s = new char[capacity];
for (int i = 0;i < size;i++)
s[i] = src.s[i];
}
//3 拷贝赋值运算符
mystring& operator = (const mystring& src) {
cout << "拷贝赋值运算符" << endl;
delete[] s; //删除原指针指向的数组
capacity = src.capacity;
size = src.size;
s = new char[capacity];
for (int i = 0;i < size;i++)
s[i] = src.s[i];
return *this; //注意返回值
}
//4 析构函数
~mystring(){
cout << "析构函数" << endl;
delete[] s;
s = nullptr;
}
//一些接口函数
void print() {
for (int i = 0;i < size;i++)
cout << s[i];
cout << endl;
}
private:
char* s;
int capacity;
int size;
};
int main() {
char c[] = "zlf";
mystring s0; //调用构造函数1
mystring s1(c); //调用构造函数2
mystring s2(s1); //调用拷贝构造函数
mystring s3 = s2; //调用拷贝构造函数 (注意)
mystring s4; //调用构造函数1
s4 = s3; //调用拷贝赋值运算符
system("pause");
return 0;
}
//输出
构造函数1
构造函数2
拷贝构造函数
拷贝构造函数
构造函数1
拷贝赋值运算符
请按任意键继续. . .
空类
- 空类会默认添加哪些东西
- Empty(); // 缺省构造函数
- Empty( const Empty& ); // 拷贝构造函数
- ~Empty(); // 析构函数
- Empty& operator=( const Empty& ); // 赋值运算符
- 空类的大小
编译器自动为空类分配一个字节大小,保证每个实例均有独一无二的内存地址。
带有虚函数的 C++类大小不为 1,因为每一个对象会有一个 vptr 指向虚函数表,具体大小根据指针大小确定;
类对象的大小
- 类的非静态成员变量大小,静态成员不占据类的空间,成员函数也不占据类的空间大小;
- 内存对齐另外分配的空间大小,类内的数据也是需要进行内存对齐操作的;
- 虚函数的话,会在类对象插入 vptr 指针,加上指针大小;
- 当该该类是某类的派生类,那么派生类继承的基类部分的数据成员也会存在在派生类中的空间中,也会对派生类进行扩展。
虚函数,虚表指针,虚表
-
存在虚函数的类都有一个一维的虚函数表叫做虚表。每一个类的对象都有一个指向虚表开始的虚指针。虚表是和类对应的,虚表指针是和对象对应的。
-
如果基类有虚函数,每一个派生类都有虚表。
-
虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类有3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
-
派生类的虚表中 虚函数地址的 排列顺序 和基类的虚表中虚函数地址排列顺序相同。
-
含有虚函数的类,其对象内存布局的最开始8个字节就是一个虚表指针(64位编译器)
虚函数的代价
- 带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚函数实现的指针,增大类;
- 带有虚函数的类的每一个对象,都会有一个指向虚表的虚指针,会增加对象的空间大小;
- 不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定采用哪种函数,虚函数不能是内敛函数。
类什么时候会析构?
- 对象生命周期结束,被销毁时;
- delete 指向对象的指针时,或 delete 指向对象的基类类型指针,而其基类析构函数是虚函数时;
- 对象 i 是对象 o 的成员,o 的析构函数被调用时,对象 i 的析构函数也被调用。
访问说明符
- 类的访问说明符,派生访问说明符
class A {
public: //类的用户可访问,派生类可访问
void pub_men() {};
private: //类的用户不可访问,派生类不可访问
void priv_men() {};
protected: //类的用户不可访问,派生类可访问
char prot_men() {};
};
class Pub_Derv :public A { //公有继承
/*可以理解为基类中的成员在派生类中如下
public:
void pub_men() {}; //所以 a可访问 ,Pub_Derv的派生类可访问
private:
void priv_men() {}; //所以 a不可访问 ,Pub_Derv的派生类不可访问
protected:
char prot_men() {}; //所以 a不可访问 ,Pub_Derv的派生类不可访问
*/
};
class Priv_Derv :private A { //私有继承,默认继承级别
/*可以理解为基类中的成员在派生类中如下
private:
void pub_men() {}; //所以 b不可访问 ,Priv_Derv的派生类不可访问
private:
void priv_men() {}; //所以 b不可访问 ,Priv_Derv的派生类不可访问
private:
char prot_men() {}; //所以 b不可访问 ,Priv_Derv的派生类不可访问
*/
};
class Prot_Derv :protected A { //受保护的继承
/*可以理解为基类中的成员在派生类中如下
protected :
void pub_men() {}; //所以 c不可访问
private:
void priv_men() {}; //所以 c不可访问
protected :
char prot_men() {}; //所以 c不可访问
*/
};
int main()
{
Pub_Derv a;
a.pub_men(); //可以访问
//a.priv_men(); //无权访问
//a.prot_men(); //无权访问
Priv_Derv b;
//b.pub_men(); //无权访问
//b.priv_men(); //无权访问
//b.prot_men(); //无权访问
Prot_Derv c;
//c.pub_men(); //无权访问
//c.priv_men(); //无权访问
//c.prot_men(); //无权访问
system("pause");
return 1;
}
- 派生类(派生类的成员函数)和派生类的友元,能不能访问基类中的成员,完全取决于基类中该成员的访问级别,与派生访问说明符无关。
- 派生访问说明符,是控制派生类的用户和派生类的派生类对基类成员的访问权限。
- 派生类向基类的转换
为避免饶舌,假设基类为B,派生类为D
- !!只有当D公有的继承B时,才允许用户代码使用派生类向基类的转换!!
A* P = &a; //对
p = &b; //错
p = &c; //错
- !!无论 D以什么方式继承B,D的成员函数和友元函数,都能使用派生类向基类的转换!!
- !!如果D 公有或者受保护的继承B,D的 派生类成员和友元,可以使用D向B的转换。!!
四、数据结构与算法
0 c++标准库是什么
标准库就是c++标准里面规定了的,任何编译器都必须要提供的库。编译器会有配套的标准库实现,不需要你另外去下载。
c++标准库分为两个部分:
(1)标准函数库:这个库由通用的、独立的、不属于任何类的函数组成,函数库继承自c语言。
(2)面向对象类库:这个库是类及其相关函数的集合。
- 标准函数库
(1)输入/输出 I/O
(2)字符串和字符处理
(3)数学
#include<cstring>
#include<string.h>
int strlen(const char *p);//求p指向字符串的长度,不包括‘\0’
char* strcat(char* p1,const char* p2);//将p2连接到p1的末尾,p1的长度要够容纳p2
#include<math>
#include<math.h>
int abs(int x);
double power(double x,double,y);
double sin(double x);
double ceil(double x);//不小于x的最小整数
double floor(double x);//不大于x的最大整数
(4) 时间、日期和本地化
(5)动态分配
(6)宽字符函数
(7)其他
- 面向对象类库
(1)标准的 C++ I/O 类 (2)String 类
(3)数值类 (4)STL 容器类
(5)STL 算法 (6)STL 函数对象
(7)STL 迭代器 (8)STL 分配器
(9)本地化库 (10)异常处理类
(11)杂项支持库
1 数据结构的理解
数据结构就是按照一定的逻辑结构组成的一批数据,使用某种存储结构将这批数据存储于计算机中,并在这些数据上定义了一个运算集合。
数组是连续的内存存储结构,链表是不连续的内存存储结构。(这两个是最基本的存储结构)。数据结构的内核要么是用数组实现,要么是用链表实现,要么就是数组+链表实现。
数据的逻辑结构可以分为两大类:一类是线性结构,另一类是非线性结构。
(1)线性结构:
- 数组
- 链表
- 栈:内核可以是数组或链表
- 队列:内核可以是数组或链表
(2)非线性结构:
- 树:包括堆、二叉搜索树、B-/B+ Tree、AVL树、红黑树、二叉树、哈夫曼树。内核也是数组或者链表
- 图:内核可以是数组或链表或者数组+链表实现
- 散列表
2 数组和链表什么区别,应用场景
2.1 数组和链表的区别
(1)数组:
- 数组是连续的内存存储结构;
- 它的优点:因为数据是连续存储的,内存地址连续,所以在查找数据的时候效率比较高;
- 它的缺点:在存储之前,我们需要申请一块连续的内存空间,并且在编译的时候就必须确定好它的空间的大小。在运行的时候空间的大小是无法随着你的需要进行增加和减少而改变的,当数据量比较大的时候,有可能会出现越界的情况,数据比较小的时候,又有可能会浪费掉内存空间。在改变数据个数时,增加、插入、删除数据效率比较低。
(2)链表:
- 链表是不连续的内存存储结构
- 链表是动态申请内存空间,不需要像数组需要提前申请好内存的大小,链表只需在用的时候申请就可以,根据需要来动态申请或者删除内存空间,对于数据增加和删除以及插入比数组灵活。
- 因为链表的内存存储不连续,查询的效率比较低,并且需要存储指针将链表节点连起来,所以内存消耗比较大。
2.2 链表和数组使用场景
(1)数组应用场景:
- 数据量比较少;
- 数据查询的操作频繁;数组更容易实现,任何高级语言都支持;构建的线性表较稳定。
(2)链表应用场景:
- 对线性表的长度或者规模难以估计;
- 频繁做插入删除操作;
- 构建动态性比较强的线性表。
3 排序算法
4 红黑树
4.1 平衡
-
平衡二叉查找树的严格定义:满足二叉查找树的特点,并且任意节点的左右子树高度相差不能大于1。
-
平衡二叉查找树设计的初衷是,解决普通二叉树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。所以,平衡二叉查找树中平衡的意思,其实就是让整棵树左右看起来比较对称,不会出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对低一些,相应的插入删除查找等操作的效率高一些。
-
AVL树严格符合平衡二叉树的定义,左右子树高度相差不超过1,是高度平衡的二叉查找树。但是很多平衡二叉树并没有严格符合平衡二叉树的定义,比如红黑树,他从根节点到各叶子节点的最长路径,有可能比最短路径大一倍。因此红黑树不是严格意义上的平衡二叉查找树,它的高度近似log2n,插入、删除、查找的时间复杂度都是O(logn)。
4.2 平衡二叉查找树有哪些:
- Treap(树堆)、Splay Tree(伸展树)、AVL树、红黑树
4.3 为什么工程上喜欢用红黑树这种平衡二叉查找树
- Treap(树堆)、Splay Tree(伸展树)在绝大部分情况下,他们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。
- AVL树是一种高度平衡的二叉树,所以查找的效率非常高,但是,AVL树为了维持这种高度的平衡,需要付出更多的代价。每次插入、删除都要做调整,比较复杂、耗时。所以,对于有频繁的插入、删除的数据结合,使用AVL树的代价就有点高了。
- 红黑树只是做到了近似平衡,并不严格,所以在维护平衡的成本上,要比AVL树低,所以红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用。更倾向于这种性能稳定的平衡二叉查找树。
4.4 平衡调整
一棵合格的红黑树需要满足以下几点:
- 根节点为黑色
- 每个叶子节点都是黑色的空节点,也就是说叶子节点不存储数据
- 任何相邻的节点都不能是红色,也就是说红色节点要被黑色节点隔开
- 每一个节点,从该节点到其可达的叶子节点的所有路径,都包含相同数目的黑色节点
在插入删除的过程中,第三点第四点要求可能被破坏,平衡调整实际上就是要把被破坏的第三点第四点恢复过来。调整的过程包含两种基础的操作:左右旋转和改变颜色。
左右旋太复杂了,老子不看了,哼
5 散列表
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表是数组的一种扩展,由数组演化而来。
5.1 散列函数设计的基本要求:
-
散列函数计算得到的散列值是一个非负整数(数组下标从0开始,所以散列值要非负)
-
如果 key1=key2,那hash(key1)==hash(key2)
-
如果 key1!=key2,那hash(key1)!=hash(key2)
对于第三点,实际上我们无法找到完美的无冲突的散列函数,即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。
5.2 散列冲突的解决
1 开放寻址法: 线性探测、 二次探测 、双重散列
- 线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2…….
- 二次探测探测为hash(key)+0,hash(key)+1^2,hash(key)+22, hash(key)+32……
- 双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函,依次类推,直到找到空闲的存储位置
- 优缺点:数组存储,可以有效利用CPU缓存加速查询,删除数据需要标记,在查找时遇到删除的数据继续,冲突的代价高。
- 应用:当数据量比较小、装载因子小的时候,适合采用开放寻址法。例如Java 中的ThreadLocalMap
2 链表法
- 当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。
- 当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。对于散列比较均匀的散列函数来说,理论上讲,链表长度k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。
- 优缺点:内存利用率高,链表结点在需要时创建,不需要事先申请;对装载因子容忍度大;将链表改造为其他高效的动态数据结构,如跳表、红黑树,即使极端情况下,查找速度也不过是O(logn)
- 应用:适合存储大对象、大数据量的散列表。Java中的HashMap
6 map和unordered_map
map, set底层都是红黑树,key值无重复,自动排序
unordered_map,unordered_set底层是散列表,key值不重复,不排序
对于自定义的数据类型作为key,使用时
- map的key需要定义operator<。
- unordered_map需要定义hash_value函数并且重载operator==
哈希表最大的优点,就是把数据的存储和查找消耗的时间大大降低,时间复杂度为O(1);
而代价仅仅是消耗比较多的内存。
哈希表的查询时间虽然是O(1),但是并不是unordered_map查询时间一定比map短,因为实际情况中还要考虑到数据量,而且unordered_map的hash函数的构造速度也没那么快,所以不能一概而论,应该具体情况具体分析。