// // // // // // // // //
///2013.5.13
// // // // // // // // //
想了很久,
虽然之前在[C++1000个问答]中也讲了很多数据结构,
但总是想到哪里写到哪里,
有时跟数据结构一点边都不沾。
况且能给同学们写博客教程的时间不多了,
实习之后就不大可能再写这些基础教程。
趁着最后一点时间,
帮大家把数据结构系统讲一下,
能让大家到达不至于对其陌生的程度,
我就很欣慰了。
さぁ、いこう。
【衡量一个算法的优劣】
通常来说,
使用O(Big-O)来衡量一个算法的复杂程度。
没有特指的情况下,
O(函数)指代在不同数据量的情况下算法最慢的运行时间的变化(Scale)。
一般有两种通用字母来表示:n与c。
n代表数据元素的数量,
c代表一个常数。
常用的有以下几种(从快到慢):
O(c),
O(log2n),
O(n),
O(nlog2n),
O(n*n),
O(n*n*n),
O(2的n次方)。
【Big-O图示】
【Big-O理解误区】
关于Big-O的有两个常见的理解误区:
1.假设有一个O(n2)的算法,
循环n次之后,
它的复杂程度变为O(n2 + n)。
错,应为O(n2)。
2.假设有一个O(n)算法,
其内部由一个n次迭代组成,
那将它的这n次迭代减少为n/2次,
那它的复杂程度变为O(n/2)。
错,因为O(n)。
大家一定要谨记算法的复杂程度只是根据"数据增加时算法的时间增量"而决定的。
因此增加数据或是乘上某个常数都不会改变算法的Big-O。
【什么是Bit Vector】
Bit Vector就是传说中的位向量了。
其存储的内容只有两种数:0和1
如下所示:
0100010001100010
有人会问,
为啥不用bool数组呢?
其实这是因为计算机内部架构的问题,
即使bool数组也只能存放0和1,
但是其大小却依然是与int数组相同(8或16或32或64位字节)。
正因如此,
我们才引入了位向量这一概念。
【数组与单链表与双链表的各种操作复杂程度】
如图:
【什么是数组的快删】
这个是比较有技巧的一种方法,
正如上图所示,
其运行效率为最快的O(c),
但这种操作有很高的限制:数组元素不能有排序。
假设有一个长度为7的数组a,
我们想删除第五个元素a[4],
那么我们就将最后一个元素a[6]覆盖到a[4]上,
然后将数组总数减一即可。(数组总数需要我们手动记录)
代码如下:
count--;
a[4] = a[count];
尽管限制很高,
但是正因这种技巧有很高的运行效率,
所以也是蛮有用的。
【数组与链表与双向链表的占用空间比较】
你可能会认为如果拥有同等数据量,
这三种容器的内存占据大小是相同的,
但实际上却并非如此。
假设有一个容纳1000个单位的int集合。
以数组,链表,双向链表的形式将分别占用:
4000,8000,12000个字节。
为什么?
因为链表中每一个单位除了拥有数据之外还有一个指针(在32位中占据4字节)
所以它的实际空间占用大小为:1000*4 + 1000*4 = 8000
而双向链表则是有两个指针,
自然又扩大了4000字节,所以占据了12000个字节。
【获取数据的内存访问过程(以数组为例)】
首先我们需要知道计算机中的内存层,如下所示:
一般在内存中,
计算机会从寄存器中获取数据,
但是寄存器本身容量是很小的,
就如上图所示,
也就那么几百个字节(或者稍大)。
怎么办呢?
于是就出现了L(n)级缓存。
内存大小随着n递增的同时,
访问速度也渐渐变慢。
假设我们要获取一个数组的第1200位元素,
那计算机会现在寄存器中查找,
木有的话再在L1缓存中查找,
木有的话再在L2缓存中查找…以此类推。
然后在某一级中找到了这个元素,
那么就在上一级开辟出相应的空间,
再一级一级进行数据移动,
直到将所需数据移至寄存器中。
至于为什么需要讲这种事情,
这是因为,
在极其注重速度与效率的数绝结构中,
是否能正确并有效的访问内存,
决定了你是否能将数据结构运用地淋漓尽致。
【阐释理解内存处理的真实意义】
假设我们有一个简单系统,
它的L1缓存能够存放8个单位数据,
而我们又有一个16个单位的数组。
假设你想访问这个数组的第一个元素,
那么处理器会加载这个数组的前八位到L1缓存中,
在这个时候,
如果你想访问第二~第八个元素,
速度与第一个是一样快的(因为都加载完毕)。
但如果你想访问这个数组的最后一个元素(第十六个元素),
并且你对之前的数据进行了修改,
那么,
处理器就需要先将L1缓存中的8个数据放回到内存中,
然后再加载这个数组的后半段数据到L1缓存。
实际上,
真正的访问数组的过程就是这样。
而并非,
是理论中的能使用"恒等速度"来访问数组任意元素。
与此同时,
我们更要注意这个弊端的最大化缺陷:
当我们使用随机算法访问大型数组时,
整个过程的访问时间被延长数倍。
因此,
你应当记住的是,
争取尽可能的按照顺序来访问数组,
不然,
尽管处理器移动内存的时间虽然不长,
但数亿次(或更多)次移动内存的开销将会令运行效率下降数倍。
【未指定长度的多维数组的声明】
众所周知,
在使用二维数组的时候可以不用声明第一个维度,就像这样:
int a[][10];
那么,
大家有没有想过三维的数组是否也可以省略维数呢?
如果可以,
是a[][][10]还是a[][10][10]呢?
答案是后者。
a[][][10]的做法是不可以的。
也就是说,
N维数组必须指定N-1个(后)维度。
这是因为,
其实数组在内存中只是一堆连在一起的内存,
不管再长的数组本质上都是一维数组,
指定维度只是告诉编译器这个一维数组
要从哪里被切割,以及被切割成几段。
其实省略维数在声明一个数组时意义并不大,
相比之下,
能使这一点更为有用的地方是在函数参数中使用数组。
例如:
int someFunc(int array[][10])
这样子,
我们既可以传进a[3][10],
也可以传进b[10][10]作为实参。
另外说一个小偏方:
不知道大家是否还记得,
在C中,
a[2]还有个写法是*(a + 2);
∵由加法结合律可得,
∴*(a + 2) == *(2 + a),
∴可得a[2] == 2[a]。
不过不推荐大家这么写,
原因有两点:
1.理解性不强,实用价值低。
2.团队合作中,容易让合作者杀人。
【数组与树的意义】
正好提到数组这一节,
顺便把之前对数组的理解与大家分享一下。
假如说我们想要创建一个8行7列的动态二维数组,
我们可以这样做:
char** a = new char*[8];
for(int i = 0;i <= 8;i++)
a[i] = new char[7];
那么,
跟昨天问题一样,
如果我们想要创建动态N维数组怎么办呢?
答案很简单,
模仿上例再一层一层迭代就OK。
但在这里,
我想说的是树的思想在数组中的体现。
假设有这样一个数组——
int a[2][3][4][5][6];
我们创建的顺序是从左到右来创建,
与此类似地,
树的创建不也是从上到下么?
数组a有2个父节点,
每个父节点下有3个子节点,
这3个子节点下又分别有4个子节点,
……以此类推。
像这样,
我们就可以将一个多维数组慢慢地拆解为一个树。
昨天笔者说多维数组本质就是一个长序列,
而但将这个序列竖起来看(意义上),
多维数组又像是一棵树。
如果能够像这样,
在内存层次,
与理解层次,
都对数组发展成较为全面的认识,
那么相信大家对数组运用能力会更上一层。
【阐述内存意义之链表】
在上上次的内容中提到关于数组的内存理解意义。
各位可以看这里回顾一下:http://blog.csdn.net/elezeor/article/details/8922372#t8
那我们这次再简要阐述一下链表的内存意义,
链表在内存中的分布如下图所示:
图中的阴影部分都是非链表内存(有可能是垃圾内存)。
恩…诸位还记得上次在数组时说过的寄存器加载内存的问题么?
简而言之就是调用内存中相距较远的两个元素需要重新加载内存块。
从这幅图来看,
动态链表的情况就是数组最糟糕的情况时的1000倍糟糕程度。
除此之外,还不要忘记链表的创建与删除开销是要大于数组的,
因为多了指针之类的东西。( http://blog.csdn.net/elezeor/article/details/8922372#t6 )
当然,
如果每个元素需要进行大量的操作从而可以忽略移动内存的消耗的话,
那倒没有太大问题。
但如果对元素的操作较少,数量又比较多的话,
动态链表的表现就要比数组糟糕很多。
最简单的一个例子,
比如说游戏中常见的粒子效果,
在制作过程中一般都要指定一个粒子最大数目用来创建数组,
——而不是创建链表。
但链表的随机内存也不是没有好处:
这样遍历节点与随机获取节点的速度是相同的,
这让链表在发展不均衡的数组面前倍儿有面子。
【补充:遍历节点与随机获取节点解释】
假设:有一个500单位大小的寄存器(也许没这么大的,但我们假设)、
第一次到第二次视为遍历。
第二次到第三次视为随机。
如果使用数组,
那么第二次到第三次肯定会比第一次慢,
如果使用链表,
第二次到第三次不一定比第一次慢,
因此,
在此前提下,
链表遍历获取元素(从第1个到第2个)与随机获取元素(第2个到第999个)的时间是有机会相等的。
【高效的Queue : 环形队列】
想象有这样一个问题:
我们想要使用数组来实现一个队列,
在使用前我们先指定数组大小,
然后对其进行各种操作。
我们期望各项操作的速度是相同的,都是O(c),
但是使用数组也就意味着其包含数组的弊端:
Pop时的速度,并非O(c),而是O(n)。
为什么呢?
别忘了,
我们队列的实现是要Pop第一个元素,
因此无论何时这个数组的首元素都不能为空,
——即使是将首元素删除时。
因此我们需要进行移位,
在每一次Pop之后将后面全部元素都向前移动一位,
这样就能保证首元素不会为空。
因此如果删除后数组长度为n,
就要移动n个元素,
由此Pop操作的时间复杂程度为O(n)。
于是,
为了弥补这一问题,
环形队列登场了。
相比普通队列,
环形队列只是增添了一个变量——首元素序号。
没错,
到这里你肯定就明白了:
每次Pop时,
我们不再进行元素移位,
而是将这个首元素序号+1即可,
这样就可以弥补队列数组的缺陷了。
但是,
环形数组存在两个比较重要的问题,
它们是什么呢?
请见下次分解。
【环形队列的两个重要问题】
typedef int Datatype; bool Enqueue(Datatype data) { if(size != count) { array[(count + front)%size] = data; count ++; return true; } return false; }
其中第七行的:(count + front)%size 就是解决这个问题的关键步骤了。
【稀松数据(Sparse Data)】
【基础的哈希表】
【哈希表的冲突】
【数位相加Bug的第二种解决方案】
【字符串的哈希函数】
unsigned long int stringHash(const char* s) { int length = strlen(s); unsigned long int hash = 0; for(int i = 0;i < length;++i) { hash += (i + 1)*s[i]; } return hash; }
比如说"Elezor"这个词,
【哈希碰撞的错误方案】
while(a[hashIndex] != NULL) { hashIndex++; } a[hashIndex] = hashValue;
这种做法就是拉低了整条街(哈希)的智商。
【链表溢值法】
【RTTI之dynamic_cast】
简而言之,
是让编译器在运行时进行类型转换的。
提到RTTI不得不说的就是dynamic_cast关键字。
比如说:
BaseClass *base; base = new ChildClass();
我们想要基类指针base调用子类特有的方法,
base -> doChildMethod();
如果不加任何关键词进行修饰的话,
编译器一般会报错。
于是我们可以使用dynamic_cast来对父类指针进行"转换":
ChildClass* p = 0; p = dynamic_cast<ChildClass*>(base); p -> doChildMethod();
这种转换被称为"向下转换"。
在Java中我们听到过这个词汇,
但那时我们是使用括号来进行转换的:
(ChildClass*)base -> doChildMethod();
其实在C++中也有相同的做法,
并且确实也被广泛使用,
那二者的区别是什么呢?
我们又该如何选择呢?
且听下回分解。
【更为普遍的向下转型】
我思考了一段时间,
(ChildClass*)base -> doChildMethod();
与上述的dynamic_cast相比,
【RTTI之typeid】
#include <typeinfo> typeid(object);
首先最需要强调的一点是:
const std:type_info& info = typeid(std::type_info);
其实对于一般的数据我们并不需要获取其类型,
std::cout << typeid(*ptr).name() << std::endl;
尽管在《Thinking in C++》中它的使用比较广泛,
【RTTI的限制】
【树的遍历之深度优先遍历】
【树的深度优先遍历图示】
【各种遍历方式的用途】
【深度遍历图示】
今天我们用图来表现一下深度遍历的完整顺序,
方便大家加深记忆:
有树如图(图片来自维基百科):
先序遍历的顺序为:
F,B,A,D,C,E,G,I,H
中序遍历的顺序为:
1.中序遍历的窍门是尽最大可能避开右。
2.后序遍历的窍门是尽最大可能避开根。
3.即使只有一个子节点,也是一个分支树。