表,栈和队列是计算机科学中最简单和最基本的三种底层数据结构。事实上,每个有意义的程序都将明晰地至少使用一种这样的数据结构,而栈则在程序中老是要间接地用到,无论你在程序中是否作了声明。git
在计算机软件编程中,咱们会接触到诸如整型,浮点型,字符型,布尔型等基本数据类型,也有一些更为复杂的复合数据类型,如数组,字典(散列表),元组等。若是咱们抛开这些数据类型具体实现,进一步抽象,给出更通常的定义,即每一种数据类型实际是一些特定操做
的集合。咱们称这些操做的集合
为抽象数据类型
(abstract data type, ADT
)。ADT 是数学意义上的抽象,它不约束各个操做的具体实现,对于每种 ADT 并不存在什么法则来告诉咱们必需要有哪些操做,这只是一个设计决策。算法
表是一种形如 的数据结构。咱们说这个表的大小是
。咱们称大小为
的表为空表(empty list)。对于除空表之外的任何表,咱们说
后继
(或继
以后)并称
前驱
。定义表ADT的操做集合一般包含:数据库
咱们最容易想到实现表的方法就是数组。使用数组实现表的各项操做,显而易见的时间复杂度是:编程
咱们不难发现,当插入和删除个元素时,须要花费
的时间,运行慢且表的大小必须事先已知。所以当须要具备插入和删除操做时,一般不使用简单数组来实现。数组
为了不插入和删除的线性开销,咱们须要容许表能够不连续存储,不然表的部分或所有须要总体移动。所以,这种状况下更好的实现方式是链表(linked list)。markdown
链表由一系列没必要在内存中相连的结构组成。每个结构均含有表元素和指向包含该元素后继元的结构的指针。咱们称之为Next
指针。最后一个单元的Next
指针指向Null
。链表分为:单链表,双链表,循环链表。数据结构
链表的 C 语言实现:函数
#include <stdlib.h> struct Node { int Element; Node* Next; }; int IsEmpty(Node* L) { return L->Next == NULL; } int IsLast(Node* P, Node* L) { return P->Next == NULL; } Node* Find(int x, Node* L) { Node* P; P = L->Next; while(P != NULL && P->Element != x) P = P->Next; return P; } Node* FindPrevious(int x, Node* L) { Node* P; P = L; while(P->Next != NULL && P->Next->Element != x) P = P->Next; return P; } void Delete(int x, Node* L) { Node* P; Node* TmpCell; P = FindPrevious(x, L); if (!IsLast(P, L)) { TmpCell = P->Next; P->Next = TmpCell->Next; free(TmpCell); } } void Insert(int x, Node* L, Node* P) { Node* TmpCell; TmpCell = (Node*)malloc(sizeof(struct Node)); if (TmpCell != NULL) printf("Out of space!!!"); TmpCell->Element = x; TmpCell->Next = P->Next; P->Next = TmpCell; } void DeleteList(Node* L) { Node* P; Node* Tmp; P = L->Next; L->Next = NULL; while(P != NULL) { Tmp = P->Next; free(P); P = Tmp; } } 复制代码
栈(stack)是限制插入和删除只能在一个位置上进行的表,该位置是表的末端,叫作栈的顶(top)。对栈的基本操做有Push
(进栈)和Pop
(出栈),前者至关于插入,后者则是删除最后插入的元素。最后插入的元素能够经过使用Top
操做在执行Pop
以前读取。post
因为栈是一个表,所以任何实现表的方法都能实现栈。一般使用数组是一个较为简便的方法。spa
和栈同样,队列(queue)也是表。不一样的是,使用队列时插入在一端进行而删除则在另外一端进行。
队列的基本操做是Enqueue
(入队),它是在表的末端(rear 队尾)插入一个元素,还有 Dequeue
(出队),它是删除(或返回)在表的开头(front 对头)的元素。
对于大量的输入数据,链表的线性访问时间太慢,不宜使用。而“树”大部分操做的运行时间平均为。
一课树是个节点
条边的集合,其中的一个节点叫作根。
路径
从节点到
的路径(path)定义为节点
的一个序列,使得对于
,节点
是
的父亲。这个路径的长(length)为该路径上的边的条数,即
。从每个节点到它本身都有一条长为
的路径。从根到每一个节点有且仅有一条路径。
深度
对于任意节点,
的深度(depth)为从根到
的惟一路径的长。所以,根的深度为
。
高度
的高(height)是从
到一片树叶的最长路径的长。所以,全部树叶的高度都是
。
祖先(ancestor)和后裔(descendant)
若是存在从到
的一条路径,那么
是
的一位祖先而
是
的一个后裔。若是
,那么
是
的一位真祖先(
proper ancestor
)而是
的一个真后裔(
proper descendant
)。
将每一个节点的全部儿子都放在树节点的链表中。FirstChild
是指向第一个儿子的指针,NextSibling
指向下一个兄弟节点。
typedef struct TreeNode *PtrToNode; struct TreeNode { ElementType Element; PtrToNode FirstChild; PtrToNode NextSibling; } 复制代码
先序遍历(preorder traversal)
在先序遍历中,对节点的处理工做是在它的诸儿子节点被处理以前进行的。例如:打印目录树形结构图,先打印父节点,再递归打印子节点。
后序遍历(postorder traversal)
在后序遍历中,在一个节点处的工做是在它的诸儿子节点被计算后进行的。例如:计算目录所占磁盘空间,在获得父节点占用空间前,须要先递归计算子节点所占用的磁盘空间,最后才能逐级向上获得根节点的磁盘总占用空间。
中序遍历(inorder traversal)
用于二叉树。遍历顺序:左子树,节点,右子树。
在二叉树中,每一个节点最多只有两个儿子。
二叉树的平均深度为,而对于特殊类型的二叉树,如二叉查找树(binary search tree),其平均深度是
。
由于一棵二叉树最多有两个儿子,因此咱们能够用指针直接指向它们。树节点的声明在结构上相似于双链表的声明。在声明中,一个节点就是由Key信息加上两个指向其余节点的指针(Left 和 Right)组成的结构。
typedef struct TreeNode *PtrToNode; typedef struct PtrToNode Tree; struct TreeNode { ElementType Element; Tree Left; Tree Right; } 复制代码
二叉树有许多与搜索无关的重要应用。二叉树的主要用处之一是在编译器的设计领域。如二元表达式树。
二叉树的一个重要的应用是它们在查找中的使用。使二叉树成为二叉查找树的性质是,对于树中的每一个节点,它的左子树全部关键字的值小于
,而它右子树中全部关键字值大于
的关键字值。
操做集合:
AVL(Adelson-Velskii 和 Landis)树是带有平衡条件的二叉查找树。这个平衡条件必需要容易保持,并且它必须保证树的深度是。最简单的想法是要求左右子树具备相同的高度。另外一种平衡条件是要求每一个节点都必需要有相同高度的左子树和右子树。虽然这种平衡条件保证了树的深度小,可是它太严格,难以使用,须要放宽条件。
一棵AVL树是其每一个节点的左子树和右子树的高度最多差1的二叉查找树。
单旋转
双旋转
伸展树保证从空树开始任意连续次对树的操做最多花费
的时间。
虽然迄今为止咱们所看到的查找树都是二叉树,可是还有一种经常使用的查找树不是二叉树。这种树叫作B-树(B-tree)。
阶为的B-树是一棵具备下列结构特性的树:
B-树实际用于数据库系统,在那里树被存储在物理的磁盘上而不是主存中。通常来讲,对磁盘的访问要比任何的主存操做慢几个数量级。若是咱们使用M阶B-树,那么磁盘访问的次数是
散列表的实现经常叫作散列(hashing)。散列是一种用于以常数平均时间执行插入,删除和查找的技术。可是,那些须要元素间任何排序信息的操做将不会获得有效的支持。所以,诸如 FindMin,FindMax 以及以线性时间按排序顺序将整个表进行打印的操做都是散列所不支持的。
理想的散列表数据结构只不过是一个包含关键字(key)的具备固定大小的数组。典型状况下,一个关键字就是一个带有相关值(例如工资信息)的字符串。咱们把表的大小记做Table-Size
,并将其理解为散列数据结构的一部分而不只仅是浮动于全局的某个变量。一般的习惯是让表从0
到Table-Size - 1
变化。
每一个关键字被映射到从0
到Table-Size - 1
这个范围中的某个数,而且被放到适当的单元中。这个映射就叫作散列函数
(hash function
)。理想状况下它应该运算简单
而且应该保证任何两个不一样的关键字映射到不一样的单元。不过,这是不可能的,由于单元的数目是有限的,而关键字其实是用不完的。所以,咱们寻找一个散列函数,该函数要在单元之间均匀
地分配关键字。这就是散列的基本想法。剩下的问题则是选择一个函数,决定当两个关键字散列到同一个值的时候(称为冲突collision
)应该作什么以及如何肯定散列表的大小。
若是输入的关键字是整数,则通常合理的方法就是直接返回“Key mod TableSize”
(关键字对表大小取模)的结果,除非Key
碰巧具备某些不理想的性质。例如,若表的大小是10
,而关键字都以0
为个位,这意味全部关键字取模运算的结果都是0
(都能被10整除)。这种状况,好的办法一般是保证表的大小是素数(也叫质数,只能被1和自身整除)。当输入的关键字是随机的整数时,散列函数不只算起来简单
并且关键字的分配也很均匀
。
一般,关键字是字符串;在这种情形下,散列函数须要仔细地选择。
一种方法是把字符串中字符的ASCII
码值加起来。如下该方法的C语言实现。
typedef unsigned int Index; Index Hash(const char *Key, int TableSize) { unsigned int HashVal = 0; while(*Key != '\0') HashVal += *Key++; return HashVal % TableSize; } 复制代码
这种方法实现起来简单并且能很快地计算出答案。不过,若是表很大,则函数将不会很好地分配关键字。例如,设(10007是素数),并设全部的关键字至多8个字符长。因为
char
型量的值最可能是127
,所以散列函数只能取在0和1016之间,其中。这显然不是一种均匀分配。
第二种方法。假设Key
至少有两个字符外加NULL
结束符。值27
表示英文字母表的字母个数外加一个空格,而。该函数只考查前三个字符,假如它们是随机的,而表的大小像前面那样仍是
10007
,那么咱们就会获得一个合理的均衡分配。
Index Hash(const char *Key, int TableSize) { return (Key[0] + 27*Key[1] + 729*Key[2]) % TableSize; } 复制代码
但是不巧的是,英文并非随机的。虽然3个字符(忽略空格)有种可能组合,但查验词汇量足够大的联机词典却揭示:3个字母的不一样组合数实际只有
2851
。即便这些组合没有冲突,也不过只有表的28%
被真正散列到。所以,虽然很容易计算,可是当散列表足够大的时候这个函数仍是不合适的。
一个更好的散列函数。这个散列函数涉及到关键字中的全部字符,而且通常能够分布得很好。计算公式以下:
它根据Horner
法则计算一个(32的)多项式。例如,计算的另外一种方式是借助于公式
进行。
Horner
法则将其扩展到用于次多项式。
Index Hash(const char *Key, int TableSize) { unsigned int HashVal = 0; while(*Key != '\0') /* 1 */ HashVal = (HashVal << 5) + *Key++; /* 2 */ return HashVal % TableSize; /* 3 */ } 复制代码
这里之因此用 32 替代27,是由于用32做乘法不是真的去乘,而是移动二进制的5位。为了运算更快,程序第2行的加法能够用按位异或来代替。虽然就表的分布而言未必是最好的,但确实具备及其简单的优势。若是关键字特别长,那么该散列函数计算起来将会花费过多的时间,不只如此,前面的字符还会左移出最终的结果。这种状况,一般的作法是不使用全部字符。此时关键字的长度和性质将影响选择。
解决了关键字均匀映射的问题,剩下的主要编程细节是解决冲突的消除问题。若是当一个元素被插入时另外一个元素已经存在(散列值相同),那么就产生了冲突,这种冲突须要消除。解决这种冲突的方法有几种。最简单的两种是:分离连接法和开放定址法。
分离连接法是将散列到同一个值的全部元素保留到一个表中。为了方便起见,这些表都有表头,实现方法与表ADT
相同。若是空间很紧,则更可取的方法是避免使用这些表头。
类型声明:
#ifndef _HashSep_H struct ListNode; typedef struct ListNode *Position; struct HashTbl; typedef struct HashTbl *HashTable; HashTable InitializeTable(int TableSize); void DestroyTable(HashTable H); ElementType Retrieve(Position P); #endif struct ListNode { ElementType Element; Position Next; }; typedef Position List; struct HashTbl { int TableSize; List *TheLists; }; 复制代码
初始化函数:
HashTable InitializeTable(int TableSize) { HashTable H; int i; if (TableSize < MinTableSize) { Error("Table size too small"); return NULL; } H = malloc(sizeof(struct HashTbl)); if (H == NULL) FatalError("out of space!!!"); H->TableSize = NextPrime(TableSize); /* 1 设置素数大小 */ H->TheLists = malloc(sizeof(List) * H->TableSize); /* 2 */ if (H->TheLists == NULL) FatalError("Out of space!!!"); /** * 分配链表表头 * * 给每个表设置一个表头,并将 Next 指向 NULL。若是不用表头,如下代码可省略。 */ for(i = 0; i < H->TableSize; i++) { H->TheLists[i] = malloc(sizeof(struct ListNode)); /* 3 */ if (H->TheLists[i] == NULL) FatalError("Out of space!!!"); else H->TheLists[i]->Next = NULL; } return H; } 复制代码
以上程序低效之处是标记为3处malloc
执行了H->TableSize
次。这能够经过循环开始以前调用一次malloc
操做:
H->TheLists = malloc(sizeof(struct ListNode) * H->TableSize); 复制代码
Find 操做:
Position Find(ElementType Key, HashTable H) { Position P; List L; L = H->TheLists[ Hash(Key, H->TableSize) ]; P = L->Next; while(P != NULL && P->Element != Key) /* ElementType 为 int时比较。字符串比较使用 `strcmp` */ P = P->Next; return P; } 复制代码
注意,判断
P->Element != Key
,这里适用于整数。字符串比较用strcmp
替换。
Insert 操做:
void Insert(ElementType Key, HashTable H) { Position Pos, NewCell; List L; Pos = Find(Key, H); if (Pos == NULL) { NewCell = malloc(sizeof(struct ListNode)); if(NewCell == NULL) FatalError("Out of space!!!"); else { L = H->TheLists[ Hash(Key, H->TableSize) ]; NewCell->Next = L->Next; NewCell->Element = Key; L->Next = NewCell; } } } 复制代码
若是在散列中诸例程中不包括删除操做,那么最好不要使用表头。由于这不只不能简化问题并且还要浪费大量的空间。
类型声明:
#ifndef _HashQuad_H typedef unsigned int Index; typedef Index Position; struct HashTbl; typedef struct HashTbl *HashTable; HashTable InitializeTable(int TableSize); void DestroyTable(HashTable H); Position Find(ElementType Key, HashTable H); void Insert(ElementType Key, HashTable H); ElementType Retrieve(Position P, HashTable H); HashTable Rehash(HashTable H); #endif enum KindOfEntry { Legitimate, Empty, Deleted }; struct HashEntry { ElementType Element; enum KindOfEntry Info; }; typedef struct HashEntry Cell; struct HashTbl { int TableSize; Cell *TheCells; }; 复制代码
初始化开放定址散列表:
HashTable InitializeTable(int TableSize) { HashTable H; int i; if (TableSize < MinTableSize) { Error("Table size too small"); return NULL; } /* 给散列表分配内存 */ H = malloc(sizeof(struct HashTbl)); if (H == NULL) FatalError(Out of space!!!); H->TableSize = NextPrime(TableSize); /* 大于 TableSize 的第一个素数 */ /* 给数组全部单元分配内存 */ H->TheCells = malloc(sizeof(Cell) * H->TableSize); if(H->TheCells == NULL) FatalError("Out of space!!!"); for (i = 0; i < H->TableSize; i++) H->TheCells[i].Info = Empty; return H; } 复制代码
Find 操做:
Position Find(ElementType Key, HashTbl H) { Position CurrentPos; int CollisionNum; CollisionNum = 0; CurrentPos = Hash(Key, H->TableSize); while(H->TheCells[CurrentPos].Info != Empty && H->TheCells[CurrentPos].Element != Key) /* 这里可能须要使用 strcmp 字符串比较函数 !! */ { CurrentPos += 2 * ++CollisionNum - 1; if (CurrentPos >= H->TableSize) CurrentPos -= H->TableSize; } return CurrentPos; } 复制代码
Insert 操做:
void Insert(ElementType Key, HashTable H) { Position Pos; Pos = Find(Keu, H); if (H->TheCells[Pos].Info != Legitimate) { H->TheCells[Pos].Info = Legitimate; H->TheCells[Pos].Element = Key; /* 字符串类型须要使用 strcpy 函数 */ } } 复制代码
散列有着丰富的应用。编译器使用散列表跟踪源代码中声明的变量。这种数据结构叫作符号表(symbol table)
。散列表是这种问题的理想应用,由于只有Insert
和Find
操做。标识符通常都不长,所以其散列函数可以迅速被算出。
散列表常见的用途也出如今为游戏编写的程序中。当程序搜索游戏的不一样的行时,它跟踪经过计算机基于位置的散列函数而看到的一些位置。若是一样的位置再出现,程序一般经过简单移动变换来避免昂贵的重复计算。游戏程序的这种通常特色叫作变换表(transposition table)
。
另一个用途是在线拼写检验程序。若是错拼检测(与纠正错误相比)更重要,那么整个词典能够被预先散列,单词则能够在常数时间内被检测。散列表很适合这项工做,由于以字母顺序排列单词并不重要;而以它们在文件中出现的顺序显示出错误拼写固然是能够接受的。
队列是一种先进先出的表ADT,正常来讲,先入队的元素,会先出队,意味没有那个元素是特殊的,拥有“插队”的优先权。这种平等,并不试用全部场景。有时,咱们但愿队列中某类元素拥有比其余元素更高的优先级,以便能提早获得处理。所以,咱们须要有一种新的队列来知足这样的应用,这样的队列叫作“优先队列(priority queue)”。
优先队列容许至少两种操做:Insert(插入) ,以及 DeleteMin(删除最小者)。Insert 操做等价于 Enqueue(入队),而 DeleteMin 则是队列中 Dequeue(出队) 在优先队列中的等价操做。DeleteMin 函数也变动它的输入。
在表头以执行插入操做,并遍历该链表以删除最小元,这又须要
时间。另外一种作法是,始终让表保持排序状态;这使得插入代价高昂(
)而DeleteMin花费低廉(
)。基于DeleteMin的操做次数从很少于插入操做次数的事实,前者也许是更好的办法。
使用二叉查找树,Insert 和 DeleteMin 这两种操做的平均运行时间都是。
实现优先队列更加广泛的方法是二叉堆
,以致于当堆
(heap)这个词不加修饰地使用时通常都是指该数据结构(优先队列)的这种实现。所以,咱们单独说堆时,就是指二叉堆。同二叉查找树同样,堆也有两个性质,即结构性
和堆序性
。正如AVL树同样,对堆的一次操做可能破坏这两个性质的一个,所以,堆的操做必需要到堆的全部性质都被知足时才能终止。事实上这并不难作到。
堆是一棵被彻底填满的二叉树,有可能的例外是在底层,底层上的元素从左到右填入。这样的树称为彻底二叉树(complete binary tree)。由于彻底二叉树颇有规律,因此它能够用一个数组表示而不须要指针。对于数组任意位置上的元素,其左儿子在位置
上,右儿子在左儿子后的单元
上,它的父亲则在位置
上。所以,不只指针这里不须要,并且遍历该树所须要的操做也极简单,在大部分计算机上运行极可能很是快。
一个堆数据结构由一个数组,一个表明最大值的整数以及当前的堆大小组成。
优先队列声明:
#ifndef _BinHeap_H struct HeapStruct; typedef struct HeapStruct *PriorityQueue; PriorityQueue Initialize(int MaxElements); void Destroy(PriorityQueue H); void MakeEmpty(PriorityQueue H); void Insert(ElementType X, PriorityQueue H); ElementType DeleteMin(PriorityQueue H); ElementType FindMin(PriorityQueue H); int IsEmpty(PriorityQueue H); int IsFull(PriorityQueue H); #endif struct HeapStruct { int Capacity; int Size; ElementType *Elements; } 复制代码
使操做被快速执行的性质是堆序
(heap order)性。因为咱们想要快速地找出最小元,所以,最小元应该在根上。若是咱们考虑任意子树也应该是一个堆,那么任意节点就应该小于它的全部后裔。
PriorityQueue Initialize(int MaxElements) { PriorityQueue H; if (MaxElements < MinPQSize) Error("Priority queue size is too small"); H = malloc(sizeof(struct HeapStruct)); if (H == NULL) FatalError("Out of space!!!"); H->Elements = malloc((MaxElements + 1) * sizeof(ElementType)); if (H->Elements == NULL) FatalError("Out of space!!!"); H->Capacity = MaxElements; H->Size = 0; H->Elements[0] = MinData; return H; } 复制代码
根据堆序性质,最小元老是能够在根处找到。所以,咱们以常数时间完成附加运算FinMin。
void Insert(ElementType X, PriorityQueue H) { int i; if (IsFull(H)) { Error("Priority queue is full"); return; } for (i = ++H->Size; H->Elements[i / 2] > X; i /= 2) H->Elements[i] = H->Elements[i / 2]; H->Elements[i] = X; } 复制代码
ElementType DeleteMin(PriorityQueue H) { int i, Child; ElementType MinElement, LastElement; if (IsEmpty(H)) { Error("Priority queue is empty"); return H->Elements[0]; } MinElement = H->Elements[1]; LastElement = H->Elements[H->Size--]; for(i = 1; i * 2 <= H->Size; i = Child) { Child = i * 2; if (Child != H->size && H->Elements[Child + 1] < H->Elements[Child]) Child++; if (LastElement > H->Elements[ Child ]) H->Elements[i] = H->Elements[Child]; else break; } H->Elements[i] = LastElement; return MinElement; } 复制代码