本文中的部分问题摘选于《程序员面试宝典》,仅供个人学习,不用于任何商业用途。如果涉及版权问题,请联系tgylatitude@qq.com,本人会立即删除。
在某些问题中,加入了个人的理解和看法,并加以举例分析。如有错误,请指出!
1、C++中有了malloc/free,为什么还需要new/delete?
malloc和free是C++/C语言的标准库函数,new/delete是C++的运算符。它们都可以动态申请内存和释放内存。对于非内部数据类型的对象而言,只用malloc/free无法满足动态对象的要求。对象在创建的同时要自动执行构造函数,对象在消亡之前要自动执行析构函数。由于malloc/free是库函数而不是运算符,不在编译器控制权限之内,不能够把执行构造函数和析构函数的任务强加于malloc/free。因此C++语言需要一个能完成动态内存分配和初始化工作的运算符new,以及一个能完成清理与释放内存工作的运算符delete。new/delete不是库函数,而是运算符。
2、句柄和指针的区别和联系是什么?
句柄是一个32位的整数,实际上是Windows在内存中维护的一个对象(窗口等)内存物理地址列表的整数索引。因为Windows的内存管理经常会将当前空闲的对象的内存释放掉,当需要访问时再重新提交到物理内存,所以对象的物理地址是变化的,不允许程序直接通过物理地址来访问对象。程序必须通过该对象的句柄来进行访问(系统根据句柄检索自己维护的对象列表就知道程序向访问的对象及其物理地址了)。
可以把句柄看成一个二级指针即指向指针的指针,Windows是一个以虚拟内存为基础的操作系统,内存管理器经常在内存中移动对象,依次来满足各种应用程序的内存需要。对象被移动意味着它的地址也变化,为此Windows为各应用程序腾出一些内存地址,用来专门登记各应用对象在内存中的变化,而这个地址(存储单元的位置)本身是不变的。Windows内存管理器移动对象在内存中的位置后,把对象新的地址告知这个句柄地址来保存。这样我们只需记住这个句柄地址就可以间接地知道对象具体在内存中的哪个位置。这个地址是在对象装载(load)时由系统分配的,当系统卸载时(unload)又释放给系统。句柄地址(稳定)->记录对象的物理地址(不稳定)->实际对象。HDC是设备描述表句柄,CDC是设备描述表类,用GetSafeHwndheFromHandle可以相互转换。
简单的说,句柄和指针其实是两个截然不同的概念个。Windows系统用句柄来标记系统资源,隐藏系统的信息。只要知道句柄,然后去调用即可。指针则是标记某个物理内存地址。
3、关于this指针
1)对于一个类实例来说,可以看到它的成员函数、成员变量,但是实例本身却看不到。this指针时时刻刻指向这个实例本身。
this指针本质上是一个函数参数(语法层面上的参数),只能在成员函数中使用,全局函数、静态函数中都不能使用this。
2)this在成员函数的开始前构造,在成员的结束后清除。当调用一个类的成员函数时,编译器将类的指针作为函数的this参数传递过去。
class A { public: int func(int p){} }; A a; a.func(10) //编译如下: A::func(&a,10);
3)this指针不占用对象的空间,相当于非静态成员函数的一个隐含的参数,与对象之间没有包含关系,只是当前调用函数的对象被它指向而已。
4)this指针会因编译器不同而有不同的放置位置,可能是堆、栈或者寄存器。
4、指针和引用的区别?
1)非空区别。在任何情况下都不能使用指向空值的引用,即引用在申明时必须初始化。
2)合法性区别。在使用引用之前不需要测试它的合法性,但是指针必须进行测试。
3)可修改区别。引用总是指向在初始化时被指定的对象,以后不能改变。但是指针可以改变其指向的变量。
4)应用区别。在一下情况下应该使用指针:一是考虑到存在不指向任何对象的可能(在这种情况下,能够设置指针为空);二是需要能够在不同的时刻指向不同的对象(在这种情况下,可以改变指针的指向)。如果总是指向一个对象并且一旦指向一个对象后就不会改变指向,那么应该使用引用。
5、const关键字
const的使用一般分为以下4种情况:
int b = 2016; const int *ptr1 = &b;//1) int const *ptr2 = &b;//2) int * const ptr3 = &b;//3) const int * const ptr4 = &b;//4)
1)和2)const位于'*'左边,修饰指针所指向的变量,即指针指向为常量。
*ptr1 = 2017;//error *ptr2 = 2014;//error
不能对内容进行更改。
3)const位于'*'右边,修饰指针本身,即指针本身是常量。定义时必须初始化。
int c = 2017; ptr3 = &c;//error
不能改变指针的指向,但是可以改变其指向的值。
4)指针本身和指向的内容均为常量。
如果类成员函数为const类型,那么在该函数中不能修改类的成员变量。并且const类对象只能调用const修饰的成员函数。
6、const和#define的不同?
- const常量有数据类型,而宏常量没有数据类型。编译器可以对前者进行类型安全检查,对后者只是进行字符替换,没有类型安全检查。
- 有些集成化的调试工具可以对const常量进行调试,但是不能对宏常量进行调试。
7、找出下列程序的错误,并解释原因。
class Demo{ public: Demo() :str(NULL){} ~Demo() { if (str) delete[] str; } char *str; }; int main() { Demo dl; dl.str = new char[32]; strcpy(dl.str, "HelloWorld"); vector<Demo> *vec = new vector<Demo>(); vec->push_back(dl); delete vec; return 0; }
错误:执行vec->push_back(dl)时,会调用Demo的拷贝构造函数,虽然在Demo类中没有定义拷贝构造函数,但是编译器会提供一个默认的拷贝构造函数(浅拷贝)。这样vec->push_back(dl)中就会生成一个临时的变量Demo的实例(假设为demo),该实例是由dl调用默认的拷贝构造函数生成的。这样demo中的str就会与dl中的str指向同一块内存区域。当执行delete vec时,会释放一次demo中str指向的内存。在main函数退出时,dl.str指向的内存也会被释放掉,这样就会两次释放同一块内存,导致程序崩溃。
为了解决这个错误,我们可以自定义一个拷贝构造函数进行深拷贝,程序代码如下:
class Demo{ public: Demo() :str(NULL){} ~Demo() { if (str) delete[] str; } char *str; Demo(const Demo& demo) { if (this->str == demo.str) return; this->str = new char[strlen(demo.str) + 1]; strcpy(this->str, demo.str); } };
8、使用函数指针的例子。
int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int result(int(*fun)(int, int), int a, int b) { int res = fun(a, b); return res; }
有些地方必须使用函数指针才能完成给定的任务,特别是异步操作的回调和其它需要匿名回调的结构。另外像线程的执行和事件的处理,如果缺少了函数指针的支持也是很难完成的。
9、C++中的空类默认产生哪些类成员函数?
对于一个空类,编译器默认产生4个成员函数:默认构造函数、析构函数、拷贝函数和赋值函数。
10、下面的类声明正确吗?Why?
class A{ const int size = 0; };
常量必须在构造函数的初始化成员列表里进行初始化。初始化列表的初始化变量顺序是根据成员变量的声明顺序来执行的。
11、为什么在类继承(多态中)将基类的析构函数用virtual关键字进行修饰?
看下面的程序:
class Base{ public: Base(){ m_int = 0; } //virtual ~Base(){} ~Base(){} private: int m_int; }; class Dervid :public Base{ public: Dervid(){ str = new char[32]; } ~Dervid(){ cout << "HelloWorld "; if (str) delete[] str; } private: char *str; };
如果不将Base的虚构函数用virtual修饰,则只能调用基类的析构函数,其子类的析构函数不会执行。这样子类中申请的str指向的内存就不会释放,会导致内存泄漏。
再举个例子,例如我们玩雷电类型的射击游戏时,有多个不同类型的敌人。当消灭一个某一类型的敌人的时,所有敌人的总数会减一,同时该类型的敌人总数也会减一。这就需要使用virtual关键字,否则只会使所有敌人总数目减一,而该类型敌人的总数目不变,如下代码:
class Enemy{ public: Enemy(){ EnemyCounts++; } //~Enemy(){ EnemyCounts--; } virtual ~Enemy(){ EnemyCounts--; } static void GetEnemyCounts() { cout << "敌人总数:" << EnemyCounts << endl; } private: static int EnemyCounts; }; int Enemy::EnemyCounts = 0; class Enemy1:public Enemy{ public: Enemy1(){ EnemyCounts1++; } ~Enemy1(){ EnemyCounts1--; } static void GetEnemyCounts() { cout << "类型1的敌人总数:" << EnemyCounts1 << endl; } private: static int EnemyCounts1; }; int Enemy1::EnemyCounts1 = 0; class Enemy2 :public Enemy{ public: Enemy2(){ EnemyCounts2++; } ~Enemy2(){ EnemyCounts2--; } static void GetEnemyCounts() { cout << "类型2的敌人总数:" << EnemyCounts2 << endl; } private: static int EnemyCounts2; }; int Enemy2::EnemyCounts2 = 0; void Destory(Enemy *enemy) { delete enemy; Enemy::GetEnemyCounts(); Enemy1::GetEnemyCounts(); Enemy2::GetEnemyCounts(); } int main() { Enemy *pEnemy; Enemy1 *enemy1_1 = new Enemy1(); Enemy1 *enemy1_2 = new Enemy1(); Enemy1 *enemy1_3 = new Enemy1(); Enemy2 *enemy2_1 = new Enemy2(); Enemy2 *enemy2_2 = new Enemy2(); Enemy2 *enemy2_3 = new Enemy2(); Enemy2 *enemy2_4 = new Enemy2(); Destory(enemy1_1); Destory(enemy2_1); return 0; }
运行截图:
图1 用virtual关键字修饰基类析构函数
图2 不用virtual关键字修饰基类析构函数
12、构造函数可以为virtual类型吗?why?
不可以,首先说明一下虚函数是采用虚调用的方法,在程序运行时来决定执行某个具体对象的方法,允许我们调用一个只知道接口而不知道其准确对象类型的函数。但是创建一个对象,必须知道对象的确切类型,所以构造函数不能是virtual类型。
13、可以将每个类成员函数声明为虚函数吗?why?
不能,因为维护虚函数是需要付出代价的。每个虚函数的对象都必须维护一个虚函数表。因此在使用虚函数时会产生一定的系统开销。
14、重载和覆盖有什么不同?
虚函数总是在派生类中被改写,这种改写被称为override(覆盖)。override是指派生类重写基类的虚函数,重写的函数必须有一致的参数表和返回值。overload(重载)是指编写一个与已有函数同名但是参数列表不同的函数。重载不是一种面向对象编程,只是一种语法规则,与多态没有什么直接关系。
15、友员
友员是一种定义在类外部的普通函数,但它需要在类内部进行声明,为了与该类的成员函数加以区分,在声明时前面用关键字friend进行修饰。友员不是成员函数,但是却可以访问类的私有成员变量。友员的作用是提高程序的运行效率,但是破坏了类的封闭性和隐藏性。友员可以是一个函数,成为友元函数;或者是一个类,成为友员类。
16、为什么派生类对象能够对基类成员进行操作?
可以看下面的例子,如图3、4所示,Derived继承于Base,则Derived类对象中就包含了Base类中的成员。我们知道类对象在操作的时候在内部构造时会有一个隐性的this指针。由于Derived是Base的派生类,那么当Derived对象创建的时候,this指针就会覆盖到Base类的范围,所以可以对基类成员进行操作。
图3 图4
17、虚继承
关于虚继承请参考C++对象模型详解和关于虚函数的那些事儿这两篇文章。下面看一个例子:
class A{ char k[3]; public: virtual void aa(){} }; class B :public virtual A{ char j[3]; public: virtual void bb(){} }; class C :public virtual B{ char i[3]; public: virtual void cc(){} }; int main() { cout << "sizeof(A):" << sizeof(A) << endl; cout << "sizeof(B):" << sizeof(B) << endl; cout << "sizeof(C):" << sizeof(C) << endl; return 0; }
运行结果,如图5所示:
图5
1)对于class A比较好理解,由于A中有一个虚函数,则必须有一个对应的虚函数表来基类该函数的入口地址。就需要一个虚指针来指向该虚函数表,由于指针的大小为4,char k[3]所占大小为3。由于字节对齐的原因,最后sizeiof(A)就是char k[3]所占大小4和虚指针所占大小4的总和8。
2)对于class B,由于B是虚继承A,并且B拥有自己的虚函数,所以B必须有一个指向自己的虚函数表,即需要一个虚指针vfptr_B。B还有自己的数据成员char i[3]。所以除了基类A,B自身单独拥有的空间大小为8。由于是虚继承,B和A的数据成员及虚函数表地址需要分开存储。
虚继承的子类,有单独的虚函数表,另外也单独保存一份父类的虚函数表,两部分之间用一个四个字节的0x00000000来作为分界。派生类的内存中,首先是自己的虚函数表,然后是派生类的数据成员,然后是0x0,之后就是基类的虚函数表,之后是基类的数据成员。如果派生类没有自己的虚函数,那么派生类就不会有虚函数表,但是派生类数据和基类数据之间,还是需要0x0来间隔。因此,在虚继承中,派生类和基类的数据,是完全间隔的,先存放派生类自己的虚函数表和数据,中间以0x分界,最后保存基类的虚函数和数据。如果派生类重载了父类的虚函数,那么则将派生类内存中基类虚函数表的相应函数替换。故sizeof(B)就等于B类单独占有空间大小8+分界4字节+A类占有空间大小8的总和20。
3)对于class C,也同class B的分析。sizeof(C)等于C类单独占用空间大小8+分界4字节+sizeof(B)的20个字节大小共计32个字节。
18、四种强制类型转换
1)static_cast<T*>(a)
将地址a转换成类型T,T和a必须是指针、引用、基本数据类型或枚举类型。在运行时转换过程中,不进行类型检查来确保转换的安全性。
class B { ... }; class D : public B { ... }; void f(B* pb, D* pd) { D* pd2 = static_cast<D*>(pb); // 不安全, pb可能只是B的指针 B* pb2 = static_cast<B*>(pd); // 安全的 ... }
2)dynamic_cast<T*>(a)
完成类层次结构中的提升,T必须是一个指针、引用或无类型的指针。a必须是决定一个指针或引用的表达式。
表达式dynamic_cast<T*>(a) 将a值转换为类型为T的对象指针。如果类型T不是a的某个基类型,该操作将返回一个空指针。
class A { ... }; class B { ... }; void f() { A* pa = new A; B* pb = new B; void* pv = dynamic_cast<A*>(pa); // pv 现在指向了一个类型为A的对象 ... pv = dynamic_cast<B*>(pb); // pv 现在指向了一个类型为B的对象 }
3)const_cast<T*>(a)
去掉类型中的常量,除了const或不稳定的变址数,T和a必须是同类型。
表达式const_cast<T*>(a)被用于从一个类中去除以下属性:const、volatile和_unaligned。
class A { ... }; void f() { const A *pa = new A;//const对象 A *pb;//非const对象 //pb = pa; // 这里将出错,不能将const对象指针赋值给非const对象 pb = const_cast<A*>(pa); // 现在OK了 ... }
4)reinterpret_cast<T*>(a)
任何指针都可以转换成其它类型的指针,T必须是一个指针、引用、算术类型、指向函数的指针或指向一个类成员的指针。
表达式reinterpret_cast<T*>(a)能够用于将char*到int*,或者One_class*到Unrelated_class*等类似这样的转换,因此是不安全的。
class A { ... }; class B { ... }; void f() { A* pa = new A; void* pv = reinterpret_cast<A*>(pa); // pv 现在指向了一个类型为A的对象,这可能是不安全的 ... }
19、关键字volatile有什么含义?
一个定义为volatile的变量就是说这个变量可能会被意想不到地改变,这样编译器就不会假设这个变量的值。优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是保存在寄存器内备份。使用例子如下:
1)并行设备的硬件寄存器(如状态寄存器);
2)一个中断服务子程序会访问到的非自动变量;
3)多线程应用中被几个任务共享的变量。
20、关键字static的作用是什么?
1)函数体内static变量的作用范围为该函数体,不同于auto变量,该变量的内存只被分配一次,因此其值在下次调用时仍维持上次的值;
2)在模块内的staic全局变量可以被模块内的所有函数访问,但不能被模块外其它函数访问;
3)在模块内的static函数只能被该模块内的其它函数调用,但不能被模块外其它函数调用;
4)在类中的static成员变量属于整个类所有,对类的所有对象只有一份拷贝;
5)在类中的static成员函数属于整个类所有,该函数不接受this指针,因而只能访问类中的static成员变量。
21、关键字auto的作用
1)自动类型推断
auto自动类型推断,用于从初始化表达式中推断出变量的数据类型。
int main() { // auto a; // 错误,没有初始化表达式,无法推断出a的类型 // auto int a = 10; // 错误,auto临时变量的语义在C++11中已不存在, 这是旧标准的用法。 // 1. 自动帮助推导类型 auto a = 10; auto c = 'A'; auto s("hello"); // 2. 类型冗长 map<int, map<int,int> > map_; map<int, map<int,int>>::const_iterator itr1 = map_.begin(); const auto itr2 = map_.begin(); auto ptr = []() { std::cout << "hello world" << std::endl; }; return 0; }; // 3. 使用模板技术时,如果某个变量的类型依赖于模板参数, // 不使用auto将很难确定变量的类型(使用auto后,将由编译器自动进行确定)。 template <class T, class U> void Multiply(T t, U u) { auto v = t * u; }
2)返回值占位
template <typename T1, typename T2> auto compose(T1 t1, T2 t2) -> decltype(t1 + t2) { return t1+t2; } auto v = compose(2, 3.14); // v's type is double
auto使用的注意事项:
- 用auto声明的变量必须初始化;
- auto不能与其他类型组合连用;
- 函数和模板参数不能被声明为auto;
- 定义在堆上的变量,使用了auto的表达式必须被初始化;
- 以为auto是一个占位符,并不是一个他自己的类型,因此不能用于类型转换或其他一些操作,如sizeof和typeid;
- 定义在一个auto序列的变量必须始终推导成同一类型;
- auto不能自动推导成CV-qualifiers(constant & volatile qualifiers),除非被声明为引用类型;
- auto会退化成指向数组的指针,除非被声明为引用。
auto m;//1 auto int p;//2 void MyFunction(auto para){}//3 template<auto T>//3 int *pp = new auto();//4 auto x = new auto();//4 int value = 123; auto x2 = (auto)value;//5 auto x3 = static_cast<auto>(value);//5 auto x1=5,x2=1.23,x3='a';//6 const int i = 9; auto j = i;//7 auto& k = i;//right,k is const int& k = 100;//error,kis const int int a[9]; auto j = a; cout<<typeid(j).name()<<endl;//print int* auto& k = a; cout<<typeid(k).name()<<endl;//print int[9]
22、heap和stack的区别
栈区(stack):由编译器自动分配和释放,存放函数参数值、局部变量的值等。
堆区(heap):一般由程序员分配和释放,若不释放,程序结束时可由操作系统回收。
全局区(静态区)(static):存放全局变量和静态变量,初始化的全局变量和静态变量放在一块区域,未初始化的放在相邻的另一块区域。程序结束后有系统释放。
文字常量区:常量字符串存放的地方,程序结束后有系统释放。
程序代码区:存放函数体的二进制代码。
int a = 0;//全局初始化区 char *p1;//全局未初始化区 void main() { int b;//栈区 char s[] = "helloworld";//栈区 char *p2;//栈区 char *p3 = "onetwothree";//"onetwothree在常量区,p3在栈区 static int c = 0;//全局(静态)初始化区 p1 = (char*)malloc(sizeof(10)); p2 = (char*)malloc(sizeof(20));//分配的10字节和20字节在堆区 strcpy(p1, "onetwothree");//"onetwothree"在常量区,编译器肯能会将它与p3所指向的"onetwothree"优化成同一个常量 }
1)申请方式
栈是由系统自动分配;堆是由程序员手动分配(malloc或者new)。
2)申请后系统的响应
栈:只要剩余空间大于申请空间,系统就为程序分配内存,否则就会出现异常提示栈溢出。堆:操作系统有一个记录空闲内存地址的链表,当系统接到申请时会遍历该链表,找到第一个空间大于所申请空间的堆节点,然后将该节点从空闲节点链表中删除,并将该节点的空间分配给程序。如果有多余的部分将重新放入空闲链表中。
3)申请大小的限制
栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存区域。所以栈顶的地址和栈的最大容量是系统预先规定好的。堆:堆是向高地址扩展的数据结构,而不是连续的内存区域。这是由于系统是用链表存储空闲内存地址的,自然是不连续的。链表的遍历方向是从低地址到高地址,堆的大小受计算机操作系统有效的虚拟内存的限制 。
4)申请效率
栈:由操作系统分配,速度快。但程序员无法控制。
堆:有程序员手动申请,一般速度比较慢,并且容易产生内存碎片。
5)堆和栈存储的内容
栈:函数调用时,第一个进栈的是主函数中的下一条指令的地址,然后是函数的各个参数从右至左依次压栈,然后是被调用函数中的局部变量。当本次函数调用结束后,局部变量先出栈,然后是参数从左至右,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般在堆的头部用一个字节来存放堆的大小。堆中的内容由程序员安排。
6)存取效率
char s1[] = "aabbccdd";//存放在栈区 char *s2 = "11223344";//"11223344"存放在常量区,s2存放在栈区
在栈上的数组比指针所指向的字符串快。
有一组cpu指令可以实现对进程的内存实现堆栈访问。其中,pop指令实现出栈,push指令实现入栈。cpu的ESP寄存器存放当前线程的栈顶指针,EBP寄存器存放当前线程的栈底指针。cpu的EIP寄存器存放下一个cpu指令存放的内存地址。当cpu执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后执行。对于一个进程的内存空间而言,可以在逻辑上分成3个部分:代码区、静态数据区和动态数据区。动态数据区一般就是“堆栈”即堆区和栈区。
一个堆栈可以通过“基地址”和“栈顶”地址来描述,程序可以通过基地址和偏移量来访问堆栈中的数据。
23、函数调用的过程中堆栈的存储情况
Windows API的调用规则和ANSI C的函数调用规则是不一样的,前者由被调用函数调整堆栈,后者是由调用者调整堆栈。两者通过_stdcall和_cdecl前缀区分。如下代码:
void _stdcall func(int param1, int param2, int param3) { int val1 = param1; int val2 = param2; int val3 = param3; printf("¶m1:0x%08x ", ¶m1); printf("¶m2:0x%08x ", ¶m2); printf("¶m3:0x%08x ", ¶m3); printf("&val1:0x%08x ", &val1); printf("&val2:0x%08x ", &val2); printf("&val3:0x%08x ", &val3); } void main() { func(1, 2, 3); }
运行结果如下图6所示:
图6
Debug模式下,在int变量的前后各增加了4个字节,用于存储调试信息,所以val1、val2和val3之间刚好相差12个字节。当我们把模式设为Release,就会发现栈上连续定义的int变量,地址相差4个字节。
24、C/C++中的野指针
所谓野指针就是指向垃圾内存的指针,这个指针地址不是NULL。如果给一个指针赋值为NULL,那么该指针就是一个空指针,可以用if语句判读。但是对于野指针不能用if语句判断。
野指针的成因:
1)指针变量没有被初始化。任何指针在创建时都不会自动赋值为NULL,那么如果不初始化,它指向的内存地址是不确定的。所以在创建时,应该进行初始化。
char *ptr = NULL; char *str = (char*)malloc(32);
2)指针被释放(free或malloc)之后,没有设置为NULL,误以为是个合法指针。
void function( void ) { char* str = new char[100]; delete[] str; // Do something strcpy( str, "Dangerous!!" ); }
3)指针操作超出了变量的作用范围。
class A { public: void Func(void){ cout << “Func of class A” << endl; } }; void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p是“野指针” }
函数 Test 在执行语句 p->Func() 时 ,对象 a 已经消失,而 p 是指向 a 的,所以 p 就成了 “野指针”。
25、经典排序算法稳定性、时间复杂度和空间复杂度的比较
稳定的排序 | 时间复杂度 | 空间复杂度 |
冒泡排序 | 最差、平均都是O(n2);最好是O(n) | 1 |
插入排序 | 最差、平均都是O(n2);最好是O(n) | 1 |
归并排序 | 最差、平均、最好是O(nlogn) | O(n) |
桶排序 | O(n) | O(k) |
基数排序 | O(dn)(d是常数) | O(n) |
二叉树排序 | O(nlogn) | O(n) |
不稳定的排序 | 时间复杂度 | 空间复杂度 |
选择排序 | 最差、平均都是O(n2) | 1 |
希尔排序 | 1 | |
堆排序 | 最差、平均、最好是O(nlogn) | 1 |
快速排序 | 平均O(nlogn);最坏O(n2) | O(nlogn) |