《大话数据结构》笔记

第二章 算法

算法:算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。

算法的特性:有穷性、确定性、可行性、输入、输出

算法的设计要求:正确性、可读性、健壮性、高效率和低存储量需求(和上面的特性对比记忆)

算法的度量方法:主要使用的是事前分析估算方法

算法的时间复杂度:也就是算法的时间度量,它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。一般采用大O记法

推导大O阶:

(1)用常数1取代运行时间中的所有加法常数。

(2)在修改后的运行次数函数中,只保留最高阶项。

(3)如果最高阶项存在且其系数不为1,则去除与这个项有乘的系数。

本书提到的复杂度一般为时间复杂度,还有一种空间复杂度

第三章 线性表

线性表:零个或多个数据元素的有限序列。

一般包括的操作:

  • 创建和初始化
  • 重置为空表
  • 根据位序得到数据元素
  • 查找某个元素
  • 获得线性表的长度
  • 插入和删除数据

当传递一个参数给函数时,如果参数需要被改动,则传递指针;如果不需要改动,直接传递参数即可

线性表的两大结构:顺序存储结构,链式存储结构

3.1 顺序存储结构

用一段地址连续的存储单元依次存储线性表的数据元素。

线性表顺序存储结构的定义:

#define MAXSIZE 20

struct SqList
{
	int data[MAXSIZE];
	int length;
};

获得元素操作:

bool GetElem(SqList L, int i, int* e)
{
	if (L.length == 0 || i <1 || i>L.length)
		return false;
	*e = L.data[i - 1];
	return true;
}

插入元素操作:

bool ListInsert(SqList* L, int i, int e)
{
	if (i <1 || i>L->length+1)
		return false;
	if (L->length == MAXSIZE)
		return false;
	for (int k = L->length; k >= i; --k)
	{
		L->data[k] = L->data[k - 1];
	}
	L->data[i - 1] = e;
	L->length++;
	return true;
}

删除元素:

bool ListDelete(SqList* L, int i, int* e)
{
	if (i <1 || i>L->length)
		return false;
	if (L->length == 0)
		return false;
	*e = L->data[i - 1];
	for (int k = i - 1; k < L->length - 1; ++k)
	{
		L->data[k] = L->data[k + 1];
	}
	L->length--;
	return true;
}

线性表顺序存储结构的优缺点:
优点:

  • 无须为表示表中的元素之间的逻辑关系而增加额外的存储关系
  • 可以快速地存取表中任意位置的元素

缺点:

  • 插入和删除操作需要移动大量元素
  • 当线性表长度变化较大时,难以确定存储空间的容量
  • 造成存储空间的“碎片”

3.2 链式存储结构

n个结点链接成一个链表,即为线性表的链式存储结构。

每个结点包括数据域和指针域,第一个结点的存储位置叫做头指针,有时为了方便会在第一个结点前附设一个结点称为头结点。

3.2.1 单链表

单链表的定义:

struct Node
{
	int data;
	Node* next;
};
typedef Node* LinkList;

单链表的读取:

bool GetElem(LinkList L, int i, int* e)
{
	int j = 1;
	LinkList p;
	p = L->next;
	while (p && j<i)
	{
		p = p->next;
		++j;
	}
	if (!p || j > i)
		return false;
	*e = p->data;
	return true;
}

单链表的插入:

bool ListInsert(LinkList* L, int i, int e)
{
	int j = 1;
	LinkList p,s;
	p = *L;
	while (p && j < i)
	{
		p = p->next;
		++j;
	}
	if (!p || j > i)
		return false;

	s = (LinkList)malloc(sizeof(Node));
	s->data = e;
	s->next = p->next;
	p->next = s;
	return true;
}

单链表的删除:

bool ListDelete(LinkList* L, int i, int *e)
{
	int j = 1;
	LinkList p,q;
	p = *L;
	while (p && j < i)
	{
		p = p->next;
		++j;
	}
	if (!p || j > i)
		return false;

	q = p->next;
	p->next = q->next;
	*e = q->data;
	free(q);
	return true;
}

整表创建:

void CreateListTail(LinkList* L, int n)
{
	LinkList p, r;
	int i;
	srand(time(0));
	*L = (LinkList)malloc(sizeof(Node));
	r = *L;
	for (i = 0; i < n; ++i)
	{
		p = (Node*)malloc(sizeof(Node));
		p->data = rand() % 100 + 1;
		r->next = p;
		r = p;
	}
	r->next = NULL;
}

整表删除:

bool ClearList(LinkList* L)
{
	LinkList p, q;
	p = (*L)->next;
	while (p)
	{
		q = p->next;
		free(p);
		p = q;
	}
	(*L)->next = NULL;
	return true;
}

单链表对比顺序存储结构:

  • 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。
  • 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构。

3.2.2 静态链表

用数组描述的链表叫静态链表。

每个下标都对应一个data和一个cur。

操作主要是对cur进行,cur保存后继元素的下标,数据仍然是连续空间保存,利用cur体现链式。

优点:

  • 在插入和删除操作时,只需要修改游标,不需要移动元素,从而改进了在顺序存储结构中插入和删除操作需要移动大量元素的缺点

缺点:

  • 没有解决连续存储分配带来的表长难以确定的问题
  • 失去了链式存储结构随机存取的特性

3.2.3 循环链表

将单链表中终端结点的指针端由空指针指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表。

3.2.4 双向链表

在单链表的每个结点中,再设置一个指向其前驱结点的指针域

struct DulNode
{
	int data;
    DulNode *prior;
    DulNode *next;
}*DuLinkList;

基本操作和单链表类似,需要注意插入操作时候的顺序:

s->prior = p;
s->next = p->next;
p->next->prior = s;
p->next = s;

第四章 栈和队列

4.1 栈

栈(stack)是限定仅在表尾进行插入和删除操作的线性表。

表尾一端称为栈顶(top),另一端称为栈底(bottom)。

栈满足后进先出。

4.1.1 栈的顺序存储结构

栈的结构定义:

struct SqStack
{
    int data[MAXSIZE];
    int top;
};

进栈操作push:

bool Push(SqStack *S,int e)
{
    if(S->top == MAXSIZE-1)
        return false;
    S->top++;
    S->data[S->top] = e;
    return true;
}

出栈操作pop:

bool Pop(SqStack *S,int *e)
{
    if(S->top == -1)
        return false;
    *e=S->data[S->top];
    S->top--;
    return true;
}

4.1.2 两栈共享空间

首先有两个相同类型的栈,各自开辟了数组空间,完全可以用一个数组来存储两个栈,充分利用这个数组占用的内存空间。

关键思路就是:它们在数组的两端,向中间靠拢,将两个栈的栈顶相连接,栈顶指针分别为top1和top2,不断由两边向中间添加元素。

使用这样的数据结构,通常都是当两个栈的空间需求有相反关系时,也就是一个栈增长另一个栈缩短的情况时。

4.1.3 栈的链式存储结构

栈的链式存储结构,简称为链栈。

链栈的结构代码:

struct StackNode
{
    int data;
    StackNode *next;
}*LinkStackPtr;

struct LinkStack
{
    LinkStackPtr top;
    int count;
};

链栈的进栈操作:

bool Push(LinkStack *S,int e)
{
    LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
    s->data = e;
    s->next = S->top;
    S->top = s;
    S->count++;
    return true;
}

出栈操作:

bool Pop(LinkStack *S,int *e)
{
    LinkStackPtr p;
    if(StackEmpty(*S))
        return false;
    *e = S->top->data;
    p = S->top;
    S->top = S->top->next;
    free(p);
    S->count--;
    return true;
}

如果栈的使用过程中元素变化不可预料,最好是使用链栈。

4.1.4 栈的作用

栈的引入简化了程序设计的问题,划分了不同关注层次,使得思考范围缩小,更加聚焦于我们要解决的问题核心。

4.1.5 栈的应用

一、 递归

把一个直接调用自己或通过一系列的调用语句间接地调用自己的函数,称作递归函数。

每个递归定义必须有一个条件,满足时递归不再进行。

二、 四则运算表达式求值

让计算机处理通常的标准表达式,最重要的就是两步:

  • 将中缀表达式转化为后缀表达式(栈用来进出运算的符号)
  • 将后缀表达式进行运算得出结果(栈用来进出运算的数字)

4.2 队列

队列是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。

队列满足先进先出。

4.2.1 循环队列

队列顺序存储存在一些问题,例如:

  • 当队列满了之后,rear指针会移动到数组外面,是不确定的
  • 如果队列前两个元素出队,而后面的元素填满,再向队列添加元素,就会产生数组越界,这种现象叫“假溢出”

循环队列:把这种头尾相接的顺序存储结构称为循环队列。

两个重要的公式:

  • 队列满的条件是:(rear+1)% QueueSize == front
  • 队列的长度:(rear - front + QueueSize)% QueueSize

循环队列的定义:

struct SqQueue
{
    int data[MAXSIZE];
    int front;
    int rear;
};

循环队列初始化:

bool InitQueue(SqQueue *Q)
{
    Q->front = 0;
    Q->rear = 0;
    return true;
}

循环队列求队列长度:

int QueueLength(SqQueue Q)
{
    return (Q.rear - Q.front + MAXSIZE) % MAXSIZE;
}

入队操作:

bool EnQueue(SqQueue *Q,int *e)
{
    if((Q->rear+1)%MAXSIZE == Q->front)
        return false;
    Q->data[Q->rear] = e;
    Q->rear = (Q->rear+1) % MAXSIZE;  
    return true;
}

出队操作:

bool DeQueue(SqQueue *Q,int *e)
{
    if (Q->front == Q->rear)
        return false;
    *e = Q->data[Q->front];
    Q->front = (Q->front+1) % MAXSIZE;
    return true;
}

4.2.2 队列的链式存储结构

链队列:

struct QNode
{
    int data;
    QNode *next;
}*QueuePtr;

struct LinkQueue
{
    QueuePtr front,rear;
};

入队操作:

bool EnQueue(LinkQueue *Q,int e)
{
    QueuePtr s = (QueuePtr)malloc(sizeof(QNode));
    if(!s)
        return false;
    s->data = e;
    s->next = NULL;
    Q->rear->next = s;
    Q->rear = s;
    return true;
}

出队操作:

bool DeQueue(LinkQueue *Q,int *e)
{
    QueuePtr p;
    if(Q->front == Q->rear)
        return false;
    p = Q->front->next;
    *e = p->data;
    Q->front->next = p->next;
    if(Q->rear == p)
        Q->rear = Q->front;
    free(p);
    return true;
}

第五章 串

串是由零个或多个字符组成的有限序列,又叫字符串。

串也有顺序存储结构和链式存储结构。

串这里最重要的也是最难理解的部分是KMP模式匹配算法,KMP主要应用在字符串匹配上。

KMP的主要思想是当出现字符串不匹配时,可以知道一部分之前已经匹配的文本内容,可以利用这些信息避免从头再去做匹配了。

听起来比较复杂,其实核心思想就是建立前缀表,前缀表是记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀。

前缀表是用来回退的,它记录了模式串与主串(文本串)不匹配的时候,模式串应该从哪里开始重新匹配。

前缀表具体通过构建next数组实现,具体代码如下:

void getNext(int* next, const string &T)
{
	//初始化,j指向前缀末尾位置
	int j = 0;
	next[0] = j;
	//i指向后缀末尾位置
	for (int i = 1; i < T.size(); ++i)
	{
		//前缀和后缀不匹配
		while (j > 0 && T[i] != T[j])
		{
			j = next[j - 1];
		}
		//前缀和后缀匹配
		if (T[i] == T[j])
			j++;
		//对next数组进行赋值,构建前缀表
		next[i] = j;
	}
}

可以理解为利用KMP思想对前缀和后缀进行字符串匹配,j还表示子串最长相等前后缀的长度,就可以更好的理解前缀表的含义(前缀表是记录下标i之前(包括i)的字符串中,有多大长度的相同前缀后缀)。

之后利用next数组进行字符串的匹配,具体代码如下:

int strStr(string haystack, string needle)
{
	if (needle.size() == 0) {
		return 0;
	}

	int next[needle.size()];
	getNext(next, needle);

	int j = 0;
	for (int i = 0; i < haystack.size(); ++i)
	{
		while (j > 0 && haystack[i] != needle[j])
		{
			j = next[j - 1];
		}

		if (haystack[i] == needle[j])
			j++;

		if (j == needle.size())
			return (i - needle.size() + 1);
	}
	return -1;
}

可以看到代码的主要实现部分和构建next数组十分类似。

总结而言,KMP算法就是:两个字符串的字符如果在第 j 个位置处不匹配,就看前缀表中 j-1 位置处的值,这个值就是 j 在模式串中所要回溯的索引值。

第六章 树

树是n个结点的有限集。n=0时称为空树。在任意一棵非空树中:

  1. 有且仅有一个特定的称为根的结点;
  2. 当n>1时,其余结点可分为m个互不相交的有限集T1、T2、…、Tm,其中每一个集合本身又是一棵树,并且称为根的子树。

6.1 树的存储结构

6.1.1 双亲表示法

我们假设以一组连续空间存储树的结点,同时在每个结点中,附设一个指示器指示其双亲结点在数组中的位置。

每个结点有数据域data,存储结点的数据信息;还有指针域parent,存储该结点的双亲在数组中的下标。

6.1.2 孩子表示法

把每个结点的孩子结点排列起来,以单链表作存储结构,则n个结点有n个孩子链表,如果是叶子结点则此单链表为空。然后n个头指针又组成一个线性表,采用顺序存储结构,存放进一个一维数组。

具体来说,就是设计两种结点结构:

  • 孩子链表的孩子结点,包括数据域child用来存储某个结点在表头数组中的下标,指针域next用来存储指向某结点下个孩子结点的指针
  • 表头数组的表头结点,包括数据域data存储某结点的数据信息,头指针域firstchild,存储该结点的孩子链表的头指针

6.1.3 孩子兄弟表示法

任意一棵树,它的结点的第一个孩子如果存在就是唯一的,它的右兄弟如果存在也是唯一的。

因此结点结构为:数据域data,指针域firstchild存储该结点的第一个孩子结点的存储地址,指针域rightsib存储该结点的右兄弟结点的存储地址。

由这个表示法引出了接下来的二叉树。

6.2 二叉树

二叉树是n个结点的有限集合,该集合或者为空集,或者由一个根结点和两棵互不相交的、分别称为根结点的左子树和右子树的二叉树组成。

6.2.1 特点

  • 每个结点最多有两棵子树
  • 左子树和右子树是有顺序的,次序不能颠倒
  • 即使树中某结点只有一棵子树,也要区分它是左子树还是右子树

6.2.2 特殊二叉树

  1. 斜树:所有结点都只有左子树或右子树(线性表可以理解为是树一种极其特殊的表现形式)
  2. 满二叉树:如果二叉树中所有分支节点都存在左子树和右子树,并且所有叶子都在同一层上,这样的二叉树称为满二叉树
  3. 完全二叉树:对一棵具有n个结点的二叉树按层序编号,如果编号为i的结点与同样深度的满二叉树中编号为i的结点在二叉树中位置完全相同,则这棵二叉树称为完全二叉树

6.2.3 性质

  1. 在二叉树的第i层至多有2的i-1次方个结点

  2. 深度为k的二叉树至多有2的k次方减1个结点

  3. 对任何一棵二叉树T,如果其终端结点数为n0,度为2的结点数为n2,则n0 = n2 + 1

  4. 具有n个结点的完全二叉树的深度为log以2为底n的对数+1

  5. 如果对一棵有n个结点的完全二叉树的结点按层序编号,对任意结点i有:

    (1). 如果i=1,则结点i是二叉树的根,无双亲;如果i>1,则其双亲是结点i/2

    (2). 如果2i>n,则结点i无左孩子;否则其左孩子是结点2i

    (3). 如果2i+1>n,则结点i无右孩子;否则其右孩子是结点2i+1

6.2.4 存储结构

  1. 顺序存储结构:由于完全二叉树定义的严格,所以用顺序结构也可以表现出二叉树的结构,因此顺序存储结构一般只用于完全二叉树。
  2. 二叉链表:二叉树每个结点最多有两个孩子,所以为它设计一个数据域和两个指针域,指针域分别存放指向左孩子和右孩子的指针。

6.2.5 遍历二叉树

二叉树的遍历是指从根结点出发,按照某种次序依次访问二叉树中的所有结点,使得每个结点被访问一次且仅被访问一次。

  1. 前序遍历:若二叉树为空,则空操作返回,否则先访问根结点,然后前序遍历左子树,再前序遍历右子树。
  2. 中序遍历:若二叉树为空,则空操作返回,否则从根结点开始,中序遍历根结点的左子树,然后是访问根结点,最后中序遍历右子树。
  3. 后续遍历:若二叉树为空,则空操作返回,否则从左到右先叶子后结点的方式遍历访问左右子树,最后访问根结点。
  4. 层序遍历:若二叉树为空,则空操作返回,否则从树的第一层,也就是根结点开始访问,从上而下逐层遍历,在同一层中,按从左到右的顺序对结点逐个访问。

6.2.6 线索二叉树

将指向前驱和后继的指针称为线索,加上线索的二叉链表称为线索链表,相应的二叉树就称为线索二叉树。

为了获知某一结点的lchild是指向左孩子还是前驱,加入两个标志域ltag和rtag,ltag为0时指向该结点的左孩子,为1时指向该结点的前驱,rtag同理,结点结构为:

lchildltagdatartagrchild

如果所用的二叉树需经常遍历或查找结点时需要某种遍历序列中的前驱和后继,那么采用线索二叉链表的存储结构是很好的选择。

6.3 树、森林和二叉树的转换

6.3.1 树转换为二叉树

  1. 加线,在所有兄弟结点之间加一条连线
  2. 去线,只保留与第一个孩子结点的连线,删除与其他孩子结点的连线
  3. 层次调整,以根结点为轴心顺时针旋转

6.3.2 森林转换为二叉树

  1. 把每个树转换为二叉树
  2. 第一棵二叉树不动,从第二棵二叉树开始,依次把后一棵二叉树的根结点作为前一棵树的根结点的右孩子,用线连接起来

6.3.3 二叉树转换为树

  1. 加线,左孩子的n个右孩子结点都作为此结点的孩子,将该结点与他们连接
  2. 去线,删除原二叉树中所有结点与其右孩子的连线
  3. 层次调整

6.3.4 二叉树转换为森林

二叉树的根结点有右孩子就是森林,反之就是树。

  1. 从根结点开始,若右孩子存在,则把与右孩子结点的连线删除
  2. 再查看分离后的二叉树,若右孩子存在,则连线删除,直到所有右孩子连线都删除为止,得到分离的二叉树
  3. 再将每个分离后的二叉树转换为树即可

6.4 哈夫曼树

从树中一个结点到另一个结点之间的分支构成两个结点之间的路径,路径上的分支数目称作路径长度。树的路径长度就是从树根到每一结点的路径长度之和。

带权路径长度WPL最小的二叉树称作哈夫曼树。

构造哈夫曼树的哈夫曼算法:

  1. 根据给定的n个权值{w1,w2,…,wn}构成n棵二叉树的集合F={T1,T2,…,Tn},其中每棵二叉树Ti中只有一个带权为wi的根结点,其左右子树均为空。
  2. 在F中选取两棵根结点的权值最小的树作为左右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为其左右子树上根结点的权值之和。
  3. 在F中删除这两棵树,同时将新得到的二叉树加入F中。
  4. 重复步骤2和3,直到F只含一棵树为止。这棵树便是哈夫曼树。

哈夫曼编码:

以需要编码的字符作为叶子结点,以各个字符在电文中出现的次数或者频率作为相应叶子结点的权值来构造一个哈夫曼树,规定哈夫曼树左分支代表0,右分支代表1,则从根结点到叶子结点所经过的路径分支组成0和1的序列便为该结点对应字符的编码,这就是哈夫曼编码。

第七章 图

图是由顶点的有穷非空集合和顶点之间边的集合组成的,通常表示为G(V,E),其中,G表示一个图,V是图G中顶点的集合,E是图G中边的集合。

7.1 图的定义

图按照有无方向分为有向图和无向图。有向图由顶点和弧构成,无向图由顶点和边构成。弧有弧尾和弧头之分。

图按照边或弧的多少分为稀疏图和稠密图。如果任意两个顶点之间都存在边叫完全图,有向的叫有向完全图。若无重复的边或顶点到自身的边则叫简单图。

图中顶点之间有邻接点、依附的概念。无向图顶点的边数叫做度,有向图顶点分为入度和出度。

图上的边或弧上带权则称为网。

图中顶点间存在路径,两顶点存在路径则说明是连通的,如果路径最终回到起始点则称为环,当中不重复叫简单路径。若任意两顶点都是连通的,则图就是连通图,有向则称强连通图。图中有子图,若子图极大连通则就是连通分量,有向的则称强连通分量。

无向图中连通且n个顶点n-1条边叫生成树。有向图中一顶点入度为0其余顶点入度为1的叫有向树。一个有向图由若干棵有向树构成生成森林。

7.2 图的存储结构

7.2.1 邻接矩阵

图的邻接矩阵存储方式是用两个数组来表示图。一个一维数组存储图中顶点信息,一个二维数组存储图中的边或弧的信息。

图的邻接矩阵存储结构:

typedef int Status;	/* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef char VertexType; /* 顶点类型应由用户定义  */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */
typedef struct
{
	VertexType vexs[MAXVEX]; /* 顶点表 */
	EdgeType arc[MAXVEX][MAXVEX];/* 邻接矩阵,可看作边表 */
	int numNodes, numEdges; /* 图中当前的顶点数和边数  */
}MGraph;

无向网图的创建:

/* 建立无向网图的邻接矩阵表示 */
void CreateMGraph(MGraph *G)
{
	int i,j,k,w;
	printf("输入顶点数和边数:\n");
	scanf("%d,%d",&G->numNodes,&G->numEdges); /* 输入顶点数和边数 */
	for(i = 0;i <G->numNodes;i++) /* 读入顶点信息,建立顶点表 */
		scanf(&G->vexs[i]);
	for(i = 0;i <G->numNodes;i++)
		for(j = 0;j <G->numNodes;j++)
			G->arc[i][j]=GRAPH_INFINITY;	/* 邻接矩阵初始化 */
	for(k = 0;k <G->numEdges;k++) /* 读入numEdges条边,建立邻接矩阵 */
	{
		printf("输入边(vi,vj)上的下标i,下标j和权w:\n");
		scanf("%d,%d,%d",&i,&j,&w); /* 输入边(vi,vj)上的权w */
		G->arc[i][j]=w; 
		G->arc[j][i]= G->arc[i][j]; /* 因为是无向图,矩阵对称 */
	}
}

7.2.2 邻接表

数组和链表相结合的存储方式称为邻接表。具体处理:图中顶点用一个一维数组存储,图中每个顶点的所有邻接点构成一个线性表。

有时为了便于确定顶点的入度或以顶点为弧头的弧,可以建立一个有向图的逆邻接表,即对每个顶点都建立一个链接为该顶点为弧头的表。

结点定义:

typedef int Status;	/* Status是函数的类型,其值是函数结果状态代码,如OK等 */
typedef char VertexType; /* 顶点类型应由用户定义 */
typedef int EdgeType; /* 边上的权值类型应由用户定义 */

typedef struct EdgeNode /* 边表结点  */
{
	int adjvex;    /* 邻接点域,存储该顶点对应的下标 */
	EdgeType info;		/* 用于存储权值,对于非网图可以不需要 */
	struct EdgeNode *next; /* 链域,指向下一个邻接点 */
}EdgeNode;

typedef struct VertexNode /* 顶点表结点 */
{
	VertexType data; /* 顶点域,存储顶点信息 */
	EdgeNode *firstedge;/* 边表头指针 */
}VertexNode, AdjList[MAXVEX];

typedef struct
{
	AdjList adjList; 
	int numNodes,numEdges; /* 图中当前顶点数和边数 */
}GraphAdjList;

无向图的邻接表的创建:

void  CreateALGraph(GraphAdjList *G)
{
	int i,j,k;
	EdgeNode *e;
	printf("输入顶点数和边数:\n");
	scanf("%d,%d",&G->numNodes,&G->numEdges); /* 输入顶点数和边数 */
	for(i = 0;i < G->numNodes;i++) /* 读入顶点信息,建立顶点表 */
	{
		scanf(&G->adjList[i].data); 	/* 输入顶点信息 */
		G->adjList[i].firstedge=NULL; 	/* 将边表置为空表 */
	}
	
	for(k = 0;k < G->numEdges;k++)/* 建立边表 */
	{
		printf("输入边(vi,vj)上的顶点序号:\n");
		scanf("%d,%d",&i,&j); /* 输入边(vi,vj)上的顶点序号 */
		e=(EdgeNode *)malloc(sizeof(EdgeNode)); /* 向内存申请空间,生成边表结点 */
		e->adjvex=j;					/* 邻接序号为j */                         
		e->next=G->adjList[i].firstedge;	/* 将e的指针指向当前顶点上指向的结点 */
		G->adjList[i].firstedge=e;		/* 将当前顶点的指针指向e */               
		
		e=(EdgeNode *)malloc(sizeof(EdgeNode)); /* 向内存申请空间,生成边表结点 */
		e->adjvex=i;					/* 邻接序号为i */                         
		e->next=G->adjList[j].firstedge;	/* 将e的指针指向当前顶点上指向的结点 */
		G->adjList[j].firstedge=e;		/* 将当前顶点的指针指向e */               
	}
}

7.2.3 十字链表(针对有向图的优化)

针对邻接表存在的问题,将邻接表和逆邻接表相结合,得到有向图的一种存储方式:十字链表。

顶点表结点结构:

datafirstinfirstout

firstin代表入边表头指针,指向该顶点的入边表中第一个结点;firstout表示出边表头指针,指向该顶点的出边表中的第一个结点。

边表结点结构:

tailvexheadvexheadlinktaillink

tailvex是指弧起点在顶点表中的下标;headvex是指弧终点在顶点表中的下标;headlink是指入边表指针域,指向终点相同的下一条边;taillink是指边表指针域,指向起点相同的下一条边。

7.2.4 邻接多重表(针对无向图的优化)

对邻接表做边的操作,比如删除某个边,是比较麻烦的。

重新定义边表结点:

ivexilinkjvexjlink

ivex和jvex是与某条边依附的两个顶点在顶点表中的下标;ilink指向依附顶点ivex的下一条边;jlink指向依附顶点jvex的下一条边。这就是邻接多重表结构。

7.2.5 边集数组

边集数组是由两个一维数组构成的。一个是存储顶点的信息;另一个是存储边的信息,这个边数组每个数据元素由一条边的起点下标、终点下标和权组成。

边集数组关注的是边的集合。

7.3 图的遍历

从图中某一顶点出发访遍图中其余顶点,且使每一个顶点仅被访问一次,这一过程就是图的遍历。

7.3.1 深度优先遍历 DFS

深度优先遍历其实就是一个递归的过程,非常类似一棵树的前序遍历。

它从图中某个顶点v出发,访问此顶点,然后从v的未被访问的邻接点出发深度优先遍历图,直至图中所有和v有路径相通的顶点都被访问到。

邻接矩阵的DFS:

#define MAXVEX 9
Boolean visited[MAXVEX]; /* 访问标志的数组 */

/* 邻接矩阵的深度优先递归算法 */
void DFS(MGraph G, int i)
{
	int j;
 	visited[i] = TRUE;
 	printf("%c ", G.vexs[i]);/* 打印顶点,也可以其它操作 */
	for(j = 0; j < G.numVertexes; j++)
		if(G.arc[i][j] == 1 && !visited[j])
 			DFS(G, j);/* 对为访问的邻接顶点递归调用 */
}

/* 邻接矩阵的深度遍历操作 */
void DFSTraverse(MGraph G)
{
	int i;
 	for(i = 0; i < G.numVertexes; i++)
 		visited[i] = FALSE; /* 初始所有顶点状态都是未访问过状态 */
	for(i = 0; i < G.numVertexes; i++)
 		if(!visited[i]) /* 对未访问过的顶点调用DFS,若是连通图,只会执行一次 */ 
			DFS(G, i);
}

对比两个不同存储结构的DFS,对于n个顶点e条边的图来说,邻接矩阵需要O(n^2)的时间,而邻接表所需时间是O(n+e)。对于点多边少的稀疏图来说,邻接表结构使得算法在时间效率上大大提高。

7.3.2 广度优先遍历 BFS

图的广度优先遍历类似于树的层序遍历。

将顶点放在第一层,与他有边的放在第二层,第二层顶点有边的放在第三层,以此类推。

/* 邻接矩阵的广度遍历算法 */
void BFSTraverse(MGraph G)
{
	int i, j;
	Queue Q;
	for(i = 0; i < G.numVertexes; i++)
       	visited[i] = FALSE;
    InitQueue(&Q);		/* 初始化一辅助用的队列 */
    for(i = 0; i < G.numVertexes; i++)  /* 对每一个顶点做循环 */
    {
		if (!visited[i])	/* 若是未访问过就处理 */
		{
			visited[i]=TRUE;		/* 设置当前顶点访问过 */
			printf("%c ", G.vexs[i]);/* 打印顶点,也可以其它操作 */
			EnQueue(&Q,i);		/* 将此顶点入队列 */
			while(!QueueEmpty(Q))	/* 若当前队列不为空 */
			{
				DeQueue(&Q,&i);	/* 将队对元素出队列,赋值给i */
				for(j=0;j<G.numVertexes;j++) 
				{ 
					/* 判断其它顶点若与当前顶点存在边且未访问过  */
					if(G.arc[i][j] == 1 && !visited[j]) 
					{ 
 						visited[j]=TRUE;			/* 将找到的此顶点标记为已访问 */
						printf("%c ", G.vexs[j]);	/* 打印顶点 */
						EnQueue(&Q,j);				/* 将找到的此顶点入队列  */
					} 
				} 
			}
		}
	}
}

DFS和BFS的时间复杂度是一样的。深度优先更适合目标比较明确,以找到目标为主要目的的情况,而广度优先更适合在不断扩大遍历范围时找到相对最优解的情况。

7.4 图的应用

7.4.1 最小生成树

构造连通网的最小代价生成树称为最小生成树。

1.普里姆(Prim)算法

假设N=(V,{E})是连通网,TE是N上最小生成树中边的集合。算法从U={u0}(u0属于V),TE={ }开始。重复执行下述操作:在所有u属于U,v属于V-U的边(u,v)属于E中找一条代价最小的边(u0,v0)并入集合TE,同时v0并入U,直至U=V为止。此时TE中必有n-1条边,则T=(V,{TE})为N的最小生成树。

/* Prim算法生成最小生成树  */
void MiniSpanTree_Prim(MGraph G)
{
	int min, i, j, k;
	int adjvex[MAXVEX];		/* 保存相关顶点下标 */
	int lowcost[MAXVEX];	/* 保存相关顶点间边的权值 */
	lowcost[0] = 0;/* 初始化第一个权值为0,即v0加入生成树 */
			/* lowcost的值为0,在这里就是此下标的顶点已经加入生成树 */
	adjvex[0] = 0;			/* 初始化第一个顶点下标为0 */
	for(i = 1; i < G.numVertexes; i++)	/* 循环除下标为0外的全部顶点 */
	{
		lowcost[i] = G.arc[0][i];	/* 将v0顶点与之有边的权值存入数组 */
		adjvex[i] = 0;					/* 初始化都为v0的下标 */
	}
	for(i = 1; i < G.numVertexes; i++)
	{
		min = GRAPH_INFINITY;	/* 初始化最小权值为∞, */
						/* 通常设置为不可能的大数字如32767、65535等 */
		j = 1;k = 0;
		while(j < G.numVertexes)	/* 循环全部顶点 */
		{
			if(lowcost[j]!=0 && lowcost[j] < min)/* 如果权值不为0且权值小于min */
			{	
				min = lowcost[j];	/* 则让当前权值成为最小值 */
				k = j;			/* 将当前最小值的下标存入k */
			}
			j++;
		}
		printf("(%d, %d)\n", adjvex[k], k);/* 打印当前顶点边中权值最小的边 */
		lowcost[k] = 0;/* 将当前顶点的权值设置为0,表示此顶点已经完成任务 */
		for(j = 1; j < G.numVertexes; j++)	/* 循环所有顶点 */
		{
			if(lowcost[j]!=0 && G.arc[k][j] < lowcost[j]) 
			{/* 如果下标为k顶点各边权值小于此前这些顶点未被加入生成树权值 */
				lowcost[j] = G.arc[k][j];/* 将较小的权值存入lowcost相应位置 */
				adjvex[j] = k;				/* 将下标为k的顶点存入adjvex */
			}
		}
	}
}
2.克鲁斯卡尔(Kruskal)算法

克鲁斯卡尔算法的思想就是站在上帝视角,先把权值最短的边挑出来构建,同时考虑是否会形成环路。

算法需要用到边集数组,边集数组的结构:

typedef struct
{
	int begin;
	int end;
	int weight;
}Edge;   /* 对边集数组Edge结构的定义 */

将邻接矩阵转化为边集数组,并且对它们按权值从小到大排序。

克鲁斯卡尔算法具体代码如下:

/* 生成最小生成树 */
void MiniSpanTree_Kruskal(MGraph G)
{
	int i, j, n, m;
	int k = 0;
	int parent[MAXVEX];/* 定义一数组用来判断边与边是否形成环路 */
	
	Edge edges[MAXEDGE];/* 定义边集数组,edge的结构为begin,end,weight,均为整型 */

	/* 用来构建边集数组并排序********************* */
	for ( i = 0; i < G.numVertexes-1; i++)
	{
		for (j = i + 1; j < G.numVertexes; j++)
		{
			if (G.arc[i][j]<GRAPH_INFINITY)
			{
				edges[k].begin = i;
				edges[k].end = j;
				edges[k].weight = G.arc[i][j];
				k++;
			}
		}
	}
	sort(edges, &G);
	/* ******************************************* */


	for (i = 0; i < G.numVertexes; i++)
		parent[i] = 0;	/* 初始化数组值为0 */

	printf("打印最小生成树:\n");
	for (i = 0; i < G.numEdges; i++)	/* 循环每一条边 */
	{
		n = Find(parent,edges[i].begin);
		m = Find(parent,edges[i].end);
		if (n != m) /* 假如n与m不等,说明此边没有与现有的生成树形成环路 */
		{
			parent[n] = m;	/* 将此边的结尾顶点放入下标为起点的parent中。 */
							/* 表示此顶点已经在生成树集合中 */
			printf("(%d, %d) %d\n", edges[i].begin, edges[i].end, edges[i].weight);
		}
	}
}

/* 查找连线顶点的尾部下标 */
int Find(int *parent, int f)
{
	while ( parent[f] > 0)
	{
		f = parent[f];
	}
	return f;
}

对比两个算法,克鲁斯卡尔算法主要针对边展开,边数少的时候效率高,对于稀疏图有很大优势;而普里姆算法对于稠密图,即边数非常多的情况会更好一些。

7.4.2 最短路径

对于网图来说,最短路径是指两顶点之间经过的边上权值之和最少的路径,并且我们称路径上的第一个顶点是源点,最后一个顶点是终点。

1.迪杰斯特拉(Dijkstra)算法

这是一个按路径长度递增的次序产生最短路径的算法。

该算法一步一步求出源点和终点之间顶点的最短路径,过程都是基于已经求出的最短路径的基础上,求得更远顶点的最短路径,最终得到想要的结果。

数据结构具体代码:

#define MAXEDGE 20
#define MAXVEX 20
#define GRAPH_INFINITY 65535

typedef int Status;	/* Status是函数的类型,其值是函数结果状态代码,如OK等 */ 


typedef struct
{
	int vexs[MAXVEX];
	int arc[MAXVEX][MAXVEX];
	int numVertexes, numEdges;
}MGraph;

typedef int Patharc[MAXVEX];    /* 用于存储最短路径下标的数组 */
typedef int ShortPathTable[MAXVEX];/* 用于存储到各点最短路径的权值和 */

算法具体代码:

/*  Dijkstra算法,求有向网G的v0顶点到其余顶点v的最短路径P[v]及带权长度D[v] */    
/*  P[v]的值为前驱顶点下标,D[v]表示v0到v的最短路径长度和 */  
void ShortestPath_Dijkstra(MGraph G, int v0, Patharc *P, ShortPathTable *D)
{    
	int v,w,k,min;    
	int final[MAXVEX];/* final[w]=1表示求得顶点v0至vw的最短路径 */
	for(v=0; v<G.numVertexes; v++)    /* 初始化数据 */
	{        
		final[v] = 0;			/* 全部顶点初始化为未知最短路径状态 */
		(*D)[v] = G.arc[v0][v];/* 将与v0点有连线的顶点加上权值 */
		(*P)[v] = -1;				/* 初始化路径数组P为-1  */       
	}

	(*D)[v0] = 0;  /* v0至v0路径为0 */  
	final[v0] = 1;    /* v0至v0不需要求路径 */        
	/* 开始主循环,每次求得v0到某个v顶点的最短路径 */   
	for(v=1; v<G.numVertexes; v++)   
	{
		min=GRAPH_INFINITY;    /* 当前所知离v0顶点的最近距离 */        
		for(w=0; w<G.numVertexes; w++) /* 寻找离v0最近的顶点 */    
		{            
			if(!final[w] && (*D)[w]<min)             
			{                   
				k=w;                    
				min = (*D)[w];    /* w顶点离v0顶点更近 */            
			}        
		}        
		final[k] = 1;    /* 将目前找到的最近的顶点置为1 */
		for(w=0; w<G.numVertexes; w++) /* 修正当前最短路径及距离 */
		{
			/* 如果经过v顶点的路径比现在这条路径的长度短的话 */
			if(!final[w] && (min+G.arc[k][w]<(*D)[w]))   
			{ /*  说明找到了更短的路径,修改D[w]和P[w] */
				(*D)[w] = min + G.arc[k][w];  /* 修改当前路径长度 */               
				(*P)[w]=k;        
			}       
		}   
	}
}
2.弗洛伊德(Floyd)算法

首先定义两个二维数组D和P,D就是网图的邻接矩阵,P主要用来存储路径。

算法代码:

/* Floyd算法,求网图G中各顶点v到其余顶点w的最短路径P[v][w]及带权长度D[v][w]。 */    
void ShortestPath_Floyd(MGraph G, Patharc *P, ShortPathTable *D)
{    
	int v,w,k;    
	for(v=0; v<G.numVertexes; ++v) /* 初始化D与P */  
	{        
		for(w=0; w<G.numVertexes; ++w)  
		{
			(*D)[v][w]=G.arc[v][w];	/* D[v][w]值即为对应点间的权值 */
			(*P)[v][w]=w;				/* 初始化P */
		}
	}
	for(k=0; k<G.numVertexes; ++k)   
	{
		for(v=0; v<G.numVertexes; ++v)  
		{        
			for(w=0; w<G.numVertexes; ++w)    
			{
				if ((*D)[v][w]>(*D)[v][k]+(*D)[k][w])
				{/* 如果经过下标为k顶点路径比原两点间路径更短 */
					(*D)[v][w]=(*D)[v][k]+(*D)[k][w];/* 将当前两点间权值设为更小的一个 */
					(*P)[v][w]=(*P)[v][k];/* 路径设置为经过下标为k的顶点 */
				}
			}
		}
	}
}

求最短路径显示的代码:

printf("各顶点间最短路径如下:\n");    
	for(v=0; v<G.numVertexes; ++v)   
	{        
		for(w=v+1; w<G.numVertexes; w++)  
		{
			printf("v%d-v%d weight: %d ",v,w,D[v][w]);
			k=P[v][w];				/* 获得第一个路径顶点下标 */
			printf(" path: %d",v);	/* 打印源点 */
			while(k!=w)				/* 如果路径顶点下标不是终点 */
			{
				printf(" -> %d",k);	/* 打印路径顶点 */
				k=P[k][w];			/* 获得下一个路径顶点下标 */
			}
			printf(" -> %d\n",w);	/* 打印终点 */
		}
		printf("\n");
	}

如果需要求所有顶点到所有顶点的最短路径问题时,弗洛伊德算法是不错的选择。

7.4.3 拓扑排序

在一个表示工程的有向图中,用顶点表示活动,用弧表示活动之间的优先关系,这样的有向图为顶点表示活动的网,称为AOV网。

设G=(V,E)是一个具有n个顶点的有向图,V中的顶点序列v1,v2,…,vn,满足若从顶点vi到vj有一条路径,则在顶点序列中顶点vi必在顶点vj之前。则称这样的顶点序列为一个拓扑序列。

拓扑排序就是对一个有向图构造拓扑序列的过程。

对AOV网进行拓扑排序的基本思路是:从AOV网中选择一个入度为0的顶点输出,然后删去此顶点,并删除以此顶点为尾的弧,继续重复此步骤,直到输出全部顶点或者AOV网中不存在入度为0的点为止。

结构代码:

/* 邻接表结构****************** */
typedef struct EdgeNode /* 边表结点  */
{
	int adjvex;    /* 邻接点域,存储该顶点对应的下标 */
	int weight;		/* 用于存储权值,对于非网图可以不需要 */
	struct EdgeNode *next; /* 链域,指向下一个邻接点 */
}EdgeNode;

typedef struct VertexNode /* 顶点表结点 */
{
	int in;	/* 顶点入度 */
	int data; /* 顶点域,存储顶点信息 */
	EdgeNode *firstedge;/* 边表头指针 */
}VertexNode, AdjList[MAXVEX];

typedef struct
{
	AdjList adjList; 
	int numVertexes,numEdges; /* 图中当前顶点数和边数 */
}graphAdjList,*GraphAdjList;

算法代码:

/* 拓扑排序,若GL无回路,则输出拓扑排序序列并返回1,若有回路返回0。 */
Status TopologicalSort(GraphAdjList GL)
{    
	EdgeNode *e;    
	int i,k,gettop;   
	int top=0;  /* 用于栈指针下标  */
	int count=0;/* 用于统计输出顶点的个数  */    
	int *stack;	/* 建栈将入度为0的顶点入栈  */   
	stack=(int *)malloc(GL->numVertexes * sizeof(int) );    

	for(i = 0; i<GL->numVertexes; i++)                
		if(0 == GL->adjList[i].in) /* 将入度为0的顶点入栈 */         
			stack[++top]=i;    
	while(top!=0)    
	{        
		gettop=stack[top--];        
		printf("%d -> ",GL->adjList[gettop].data);        
		count++;        /* 输出i号顶点,并计数 */        
		for(e = GL->adjList[gettop].firstedge; e; e = e->next)        
		{            
			k=e->adjvex;            
			if( !(--GL->adjList[k].in) )  /* 将i号顶点的邻接点的入度减1,如果减1后为0,则入栈 */                
				stack[++top]=k;        
		}
	}   
	printf("\n");   
	if(count < GL->numVertexes)        
		return ERROR;    
	else       
		return OK;
}

7.4.4 关键路径

在一个表示工程的带权有向图中,用顶点表示事件,用有向边表示活动,用边上的权值表示活动的持续时间,这种有向图的边表示活动的网,称为AOE网。AOE网中没有入边的顶点称为源点或始点,没有出边的顶点称为终点或汇点。

路径上各个活动所持续的时间之和称为路径长度,从源点到汇点具有最大长度的路径叫关键路径,在关键路径上的活动叫关键活动。

求事件最早发生时间etv的过程就是找拓扑序列,改进的代码:

int *etv,*ltv; /* 事件最早发生时间和最迟发生时间数组 */
int *stack2;   /* 用于存储拓扑序列的栈 */
int top2;	   /* 用于stack2的指针 */

/* 拓扑排序 */
Status TopologicalSort(GraphAdjList GL)
{    /* 若GL无回路,则输出拓扑排序序列并返回1,若有回路返回0。 */    
	EdgeNode *e;    
	int i,k,gettop;   
	int top=0;  /* 用于栈指针下标  */
	int count=0;/* 用于统计输出顶点的个数 */   
	int *stack;	/* 建栈将入度为0的顶点入栈  */   
	stack=(int *)malloc(GL->numVertexes * sizeof(int) );    
	for(i = 0; i<GL->numVertexes; i++)                
		if(0 == GL->adjList[i].in) /* 将入度为0的顶点入栈 */           
			stack[++top]=i;    

	top2=0;    
	etv=(int *)malloc(GL->numVertexes * sizeof(int) ); /* 事件最早发生时间数组 */    
	for(i=0; i<GL->numVertexes; i++)        
		etv[i]=0;    /* 初始化 */
	stack2=(int *)malloc(GL->numVertexes * sizeof(int) );/* 初始化拓扑序列栈 */

	printf("TopologicalSort:\t");
	while(top!=0)    
	{        
		gettop=stack[top--];        
		printf("%d -> ",GL->adjList[gettop].data);        
		count++;        /* 输出i号顶点,并计数 */ 

		stack2[++top2]=gettop;        /* 将弹出的顶点序号压入拓扑序列的栈 */

		for(e = GL->adjList[gettop].firstedge; e; e = e->next)        
		{            
			k=e->adjvex;            
			if( !(--GL->adjList[k].in) )        /* 将i号顶点的邻接点的入度减1,如果减1后为0,则入栈 */                
				stack[++top]=k; 

			if((etv[gettop] + e->weight)>etv[k])    /* 求各顶点事件的最早发生时间etv值 */                
				etv[k] = etv[gettop] + e->weight;
		}    
	}    
	printf("\n");   
	if(count < GL->numVertexes)        
		return ERROR;    
	else       
		return OK;
}

求关键路径的代码:

/* 求关键路径,GL为有向网,输出G的各项关键活动 */
void CriticalPath(GraphAdjList GL) 
{    
	EdgeNode *e;    
	int i,gettop,k,j;    
	int ete,lte;  /* 声明活动最早发生时间和最迟发生时间变量 */        
	TopologicalSort(GL);   /* 求拓扑序列,计算数组etv和stack2的值 */ 
	ltv=(int *)malloc(GL->numVertexes*sizeof(int));/* 事件最早发生时间数组 */   
	for(i=0; i<GL->numVertexes; i++)        
		ltv[i]=etv[GL->numVertexes-1];    /* 初始化 */        
	
	printf("etv:\t");   
	for(i=0; i<GL->numVertexes; i++)        
		printf("%d -> ",etv[i]);    
	printf("\n"); 

	while(top2!=0)    /* 出栈是求ltv */    
	{        
		gettop=stack2[top2--];        
		for(e = GL->adjList[gettop].firstedge; e; e = e->next)        /* 求各顶点事件的最迟发生时间ltv值 */        
		{            
			k=e->adjvex;            
			if(ltv[k] - e->weight < ltv[gettop])               
				ltv[gettop] = ltv[k] - e->weight;        
		}   
	}    
	
	printf("ltv:\t");   
	for(i=0; i<GL->numVertexes; i++)        
		printf("%d -> ",ltv[i]);    
	printf("\n"); 

	for(j=0; j<GL->numVertexes; j++)        /* 求ete,lte和关键活动 */        
	{            
		for(e = GL->adjList[j].firstedge; e; e = e->next)            
		{                
			k=e->adjvex;                
			ete = etv[j];        /* 活动最早发生时间 */                
			lte = ltv[k] - e->weight; /* 活动最迟发生时间 */               
			if(ete == lte)    /* 两者相等即在关键路径上 */                    
				printf("<v%d - v%d> length: %d \n",GL->adjList[j].data,GL->adjList[k].data,e->weight);
		}        
	}
}

图的内容非常多而且复杂,这里只简单总结书中内容,更多资料可查阅相关书籍如《算法导论》等。

第八章 查找

查找就是根据给定的某个值,在查找表中确定一个其关键字等于给定值的数据元素(或记录)。

8.1 查找概论

查找表是由同一个类型的数据元素(或记录)构成的集合。

关键字是数据元素中某个数据项的值,又称为键值。

若此关键字可以唯一地标志一个记录,则称此关键字为主关键字。

对于可以识别多个数据元素(或记录)的关键字,称为次关键字。

查找表按照操作方式分为两大种:静态查找表和动态查找表

  • 静态查找表:只做查找操作的查找表。
  • 动态查找表:在查找过程中同时插入查找表中不存在的数据元素,或者从查找表中删除已经存在的某个数据元素。

8.2 顺序表查找(静态查找表)

顺序查找:又叫线性查找,就是从表中第一个记录开始,逐个进行记录的关键字和给定值比对,若相等则查找成功,反之失败。

顺序查找的算法代码:

/* 无哨兵顺序查找,a为数组,n为要查找的数组个数,key为要查找的关键字 */
int Sequential_Search(int *a,int n,int key)
{
	int i;
	for(i=1;i<=n;i++)
	{
		if (a[i]==key)
			return i;
	}
	return 0;
}

由于上述算法每次循环都需要判断 i 是否越界,因此优化算法设置一个哨兵解决问题:

/* 有哨兵顺序查找 */
int Sequential_Search2(int *a,int n,int key)
{
	int i;
	a[0]=key;//哨兵
	i=n;
	while(a[i]!=key)
	{
		i--;
	}
	return i;
}

顺序查找的时间复杂度为O(n),当n很大时,查找效率低。

8.3 有序表查找(静态查找表)

8.3.1 折半查找

折半查找:又叫二分查找,前提是线性表有序且线性表必须顺序存储。基本思想:在有序表中,取中间记录作为比较对象,若给定值与中间记录的关键字相等,查找成功;若小于,则在中间记录的左半区继续查找;若大于,则在右半区继续查找,不断重复直到查找成功,否则查找失败。

算法实现:

/* 折半查找 */
int Binary_Search(int *a,int n,int key)
{
	int low,high,mid;
	low=1;	/* 定义最低下标为记录首位 */
	high=n;	/* 定义最高下标为记录末位 */
	while(low<=high)
	{
		mid=(low+high)/2;	/* 折半 */
		if (key<a[mid])		/* 若查找值比中值小 */
			high=mid-1;		/* 最高下标调整到中位下标小一位 */
		else if (key>a[mid])/* 若查找值比中值大 */
			low=mid+1;		/* 最低下标调整到中位下标大一位 */
		else
		{
			return mid;		/* 若相等则说明mid即为查找到的位置 */
		}
		
	}
	return 0;
}

二分查找的时间复杂度为O(logn)。

8.3.2 插值查找(对二分查找的改进)

插值查找:根据要查找的关键字key与查找表中最大最小记录的关键字比较后的查找方法,其核心就在于插值的计算公式(key - a[low]) / (a[high] - a[low])。

算法代码与二分查找基本一样:

/* 插值查找 */
int Interpolation_Search(int *a,int n,int key)
{
	int low,high,mid;
	low=1;	/* 定义最低下标为记录首位 */
	high=n;	/* 定义最高下标为记录末位 */
	while(low<=high)
	{
		mid=low+ (high-low)*(key-a[low])/(a[high]-a[low]); /* 插值 */
		if (key<a[mid])		/* 若查找值比插值小 */
			high=mid-1;		/* 最高下标调整到插值下标小一位 */
		else if (key>a[mid])/* 若查找值比插值大 */
			low=mid+1;		/* 最低下标调整到插值下标大一位 */
		else
			return mid;		/* 若相等则说明mid即为查找到的位置 */
	}
	return 0;
}

时间复杂度为O(logn),对于关键字分布比较均匀的查找表来说,插值查找算法的平均性能比二分查找好得多,反之如果分布极端不均匀,插值查找不是很合适。

8.3.3 斐波那契查找

斐波那契查找利用黄金分割原理。

斐波那契数列:0,1,1,2,3,5,8,13,21,34…

具体代码:

/* 斐波那契查找 */
int Fibonacci_Search(int *a,int n,int key)
{
	int low,high,mid,i,k=0;
	low=1;						/* 定义最低下标为记录首位 */
	high=n;						/* 定义最高下标为记录末位 */
	while(n>F[k]-1)
		k++;
	for (i=n;i<F[k]-1;i++)
		a[i]=a[n];
	while(low<=high)
	{
		mid=low+F[k-1]-1;
		if (key<a[mid])
		{
			high=mid-1;		
			k=k-1;
		}
		else if (key>a[mid])
		{
			low=mid+1;		
			k=k-2;
		}
		else
		{
			if (mid<=n)
				return mid;		/* 若相等则说明mid即为查找到的位置 */
			else 
				return n;
		}
	}
	return 0;
}

时间复杂度为O(logn),就平均性能来说,斐波那契查找要优于折半查找,但是如果是最坏情况,比如key=1,那始终都在左侧长半区查找,效率低于二分查找。

折半查找是加法和除法运算,插值查找是复杂的四则运算,而斐波那契查找只有加减法,在海量数据查找中效率会有差异。

三种有序表的查找本质是分隔点的选择不同,根据实际情况选择使用。

8.4 线性索引查找

索引就是把一个关键字与它对应的记录相关联的过程。

线性索引就是将索引项集合组织为线性结构,也称为索引表。

主要介绍三种线性索引:稠密索引、分块索引和倒排索引。

8.4.1 稠密索引

稠密索引是指在线性索引中,将数据集中的每个记录对应一个索引项。

对于稠密索引这个索引表来说,索引项一定是按照关键码有序排列的。

索引表就相当于一个备忘录,记录每个数据的指针,数据可以没有顺序的杂乱放置,但是备忘录中的记录需要按关键码排序好。

缺点:如果数据量庞大,对于内存有限的计算机来说,需要反复访问磁盘,查找性能反而下降了。

8.4.2 分块索引

分块有序,是把数据集的记录分成了若干块,并且这些块需要满足以下两个条件:

  • 块内无序
  • 块间有序

分块索引的索引项结构分三个数据项:

  • 最大关键码:存储每一块最大关键字
  • 块长:存储块中的记录个数
  • 块首指针:存储指向块首数据元素的指针

分块索引表查找分两步:

  1. 在分块索引表中查找要查关键字所在的块。
  2. 根据块首指针找到对应的块,在块中顺序查找关键码。

分块索引在兼顾了对细分块不需要有序的情况下,大大增加了整体查找的速度,所以普遍被用于数据库表查找等技术的应用当中。

8.4.3 倒排索引

索引项的通用结构:

  • 次关键码
  • 记录号表

其中记录号表存储具有相同次关键字的所有记录的记录号(可以是指向记录的指针或者是该记录的主关键字)。这样的索引方法就是倒排索引。

倒排索引根据属性的值查找记录,不是由记录来确定属性值,因此称为倒排。

优点:查找记录非常快。
缺点:这个记录号不定长。

8.5 二叉排序树(动态查找表)

二叉排序树,又称为二叉查找树。它或者是一棵空树,或者是具有下列性质的二叉树:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值。
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。
  • 它的左右子树也分别为二叉排序树。

首先提供一个二叉树的结构

/* 二叉树的二叉链表结点结构定义 */
typedef  struct BiTNode	/* 结点结构 */
{
	int data;	/* 结点数据 */
	struct BiTNode *lchild, *rchild;	/* 左右孩子指针 */
} BiTNode, *BiTree;

二叉排序树的查找操作:

/* 递归查找二叉排序树T中是否存在key, */
/* 指针f指向T的双亲,其初始调用值为NULL */
/* 若查找成功,则指针p指向该数据元素结点,并返回TRUE */
/* 否则指针p指向查找路径上访问的最后一个结点并返回FALSE */
Status SearchBST(BiTree T, int key, BiTree f, BiTree *p) 
{  
	if (!T)	/*  查找不成功 */
	{ 
		*p = f;  
		return FALSE; 
	}
	else if (key==T->data) /*  查找成功 */
	{ 
		*p = T;  
		return TRUE; 
	} 
	else if (key<T->data) 
		return SearchBST(T->lchild, key, T, p);  /*  在左子树中继续查找 */
	else  
		return SearchBST(T->rchild, key, T, p);  /*  在右子树中继续查找 */
}

二叉排序树的插入操作:

/*  当二叉排序树T中不存在关键字等于key的数据元素时, */
/*  插入key并返回TRUE,否则返回FALSE */
Status InsertBST(BiTree *T, int key) 
{  
	BiTree p,s;
	if (!SearchBST(*T, key, NULL, &p)) /* 查找不成功 */
	{
		s = (BiTree)malloc(sizeof(BiTNode));
		s->data = key;  
		s->lchild = s->rchild = NULL;  
		if (!p) 
			*T = s;			/*  插入s为新的根结点 */
		else if (key<p->data) 
			p->lchild = s;	/*  插入s为左孩子 */
		else 
			p->rchild = s;  /*  插入s为右孩子 */
		return TRUE;
	} 
	else 
		return FALSE;  /*  树中已有关键字相同的结点,不再插入 */
}

二叉排序树的删除操作:

删除结点三种情况:

  • 叶子结点
  • 仅有左或右子树的结点
  • 左右子树都有的结点
/* 若二叉排序树T中存在关键字等于key的数据元素时,则删除该数据元素结点, */
/* 并返回TRUE;否则返回FALSE。 */
Status DeleteBST(BiTree *T,int key)
{ 
	if(!*T) /* 不存在关键字等于key的数据元素 */ 
		return FALSE;
	else
	{
		if (key==(*T)->data) /* 找到关键字等于key的数据元素 */ 
			return Delete(T);
		else if (key<(*T)->data)
			return DeleteBST(&(*T)->lchild,key);
		else
			return DeleteBST(&(*T)->rchild,key);
		 
	}
}
/* 从二叉排序树中删除结点p,并重接它的左或右子树。 */
Status Delete(BiTree *p)
{
	BiTree q,s;
	if((*p)->rchild==NULL) /* 右子树空则只需重接它的左子树(待删结点是叶子也走此分支) */
	{
		q=*p; *p=(*p)->lchild; free(q);
	}
	else if((*p)->lchild==NULL) /* 只需重接它的右子树 */
	{
		q=*p; *p=(*p)->rchild; free(q);
	}
	else /* 左右子树均不空 */
	{
		q=*p; s=(*p)->lchild;
		while(s->rchild) /* 转左,然后向右到尽头(找待删结点的前驱) */
		{
			q=s;
			s=s->rchild;
		}
		(*p)->data=s->data; /*  s指向被删结点的直接前驱(将被删结点前驱的值取代被删结点的值) */
		if(q!=*p)
			q->rchild=s->lchild; /*  重接q的右子树 */ 
		else
			q->lchild=s->lchild; /*  重接q的左子树 */
		free(s);
	}
	return TRUE;
}

二叉排序树以链接的方式存储,方便进行插入和删除的操作。

二叉排序树的查找性能取决于二叉排序树的形状,如果比较平衡,那查找的时间复杂度为O(logn),但是如果是斜树,那时间复杂度就变为O(n)。

8.6 平衡二叉树(AVL树)

平衡二叉树,是一种二叉排序树,其中每一个结点的左子树和右子树的高度差至多等于1。

将二叉树上结点的左子树高度减去右子树高度的值称为平衡因子BF,平衡二叉树上所有结点的平衡因子只可能是-1、0、1。

距离插入结点最近的,且平衡因子的绝对值大于1的结点为根的子树,称为最小不平衡子树。

平衡二叉树就是在二叉排序树创建过程中保证它的平衡性,一旦发现不平衡马上处理,当最小不平衡子树根结点的平衡因子BF大于1就右旋,小于-1就左旋。插入结点后,最小不平衡子树的BF与它的子树的BF符号相反时,就需要对结点先进行一次旋转以使得符号相同后,再反向旋转一次才能够完成平衡操作。

平衡二叉树的实现:

结点结构:

/* 二叉树的二叉链表结点结构定义 */
typedef  struct BiTNode	/* 结点结构 */
{
	int data;	/* 结点数据 */
	int bf; /*  结点的平衡因子 */ 
	struct BiTNode *lchild, *rchild;	/* 左右孩子指针 */
} BiTNode, *BiTree;

右旋操作:

/* 对以p为根的二叉排序树作右旋处理, */
/* 处理之后p指向新的树根结点,即旋转处理之前的左子树的根结点 */
void R_Rotate(BiTree *P)
{ 
	BiTree L;
	L=(*P)->lchild; /*  L指向P的左子树根结点 */ 
	(*P)->lchild=L->rchild; /*  L的右子树挂接为P的左子树 */ 
	L->rchild=(*P);
	*P=L; /*  P指向新的根结点 */ 
}

左旋操作:

/* 对以P为根的二叉排序树作左旋处理, */
/* 处理之后P指向新的树根结点,即旋转处理之前的右子树的根结点0  */
void L_Rotate(BiTree *P)
{ 
	BiTree R;
	R=(*P)->rchild; /*  R指向P的右子树根结点 */ 
	(*P)->rchild=R->lchild; /* R的左子树挂接为P的右子树 */ 
	R->lchild=(*P);
	*P=R; /*  P指向新的根结点 */ 
}

左平衡旋转处理:

#define LH +1 /*  左高 */ 
#define EH 0  /*  等高 */ 
#define RH -1 /*  右高 */ 

/*  对以指针T所指结点为根的二叉树作左平衡旋转处理 */
/*  本算法结束时,指针T指向新的根结点 */
void LeftBalance(BiTree *T)
{ 
	BiTree L,Lr;
	L=(*T)->lchild; /*  L指向T的左子树根结点 */ 
	switch(L->bf)
	{ /*  检查T的左子树的平衡度,并作相应平衡处理 */ 
		 case LH: /*  新结点插入在T的左孩子的左子树上,要作单右旋处理 */ 
			(*T)->bf=L->bf=EH;
			R_Rotate(T);
			break;
		 case RH: /*  新结点插入在T的左孩子的右子树上,要作双旋处理 */ 
			Lr=L->rchild; /*  Lr指向T的左孩子的右子树根 */ 
			switch(Lr->bf)
			{ /*  修改T及其左孩子的平衡因子 */ 
				case LH: (*T)->bf=RH;
						 L->bf=EH;
						 break;
				case EH: (*T)->bf=L->bf=EH;
						 break;
				case RH: (*T)->bf=EH;
						 L->bf=LH;
						 break;
			}
			Lr->bf=EH;
			L_Rotate(&(*T)->lchild); /*  对T的左子树作左旋平衡处理 */ 
			R_Rotate(T); /*  对T作右旋平衡处理 */ 
	}
}

右平衡旋转处理:

/*  对以指针T所指结点为根的二叉树作右平衡旋转处理, */ 
/*  本算法结束时,指针T指向新的根结点 */ 
void RightBalance(BiTree *T)
{ 
	BiTree R,Rl;
	R=(*T)->rchild; /*  R指向T的右子树根结点 */ 
	switch(R->bf)
	{ /*  检查T的右子树的平衡度,并作相应平衡处理 */ 
	 case RH: /*  新结点插入在T的右孩子的右子树上,要作单左旋处理 */ 
			  (*T)->bf=R->bf=EH;
			  L_Rotate(T);
			  break;
	 case LH: /*  新结点插入在T的右孩子的左子树上,要作双旋处理 */ 
			  Rl=R->lchild; 			/*  Rl指向T的右孩子的左子树根 */ 
			  switch(Rl->bf)
			  { 						/*  修改T及其右孩子的平衡因子 */ 
				case RH: (*T)->bf=LH;
						 R->bf=EH;
						 break;
				case EH: (*T)->bf=R->bf=EH;
						 break;
				case LH: (*T)->bf=EH;
						 R->bf=RH;
						 break;
			  }
			  Rl->bf=EH;
			  R_Rotate(&(*T)->rchild); /*  对T的右子树作右旋平衡处理 */ 
			  L_Rotate(T); /*  对T作左旋平衡处理 */ 
	}
}

算法主函数:

/*  若在平衡的二叉排序树T中不存在和e有相同关键字的结点,则插入一个 */ 
/*  数据元素为e的新结点,并返回1,否则返回0。若因插入而使二叉排序树 */ 
/*  失去平衡,则作平衡旋转处理,布尔变量taller反映T长高与否。 */
Status InsertAVL(BiTree *T,int e,Status *taller)
{  
	if(!*T)
	{ /*  插入新结点,树“长高”,置taller为TRUE */ 
		 *T=(BiTree)malloc(sizeof(BiTNode));
		 (*T)->data=e; (*T)->lchild=(*T)->rchild=NULL; (*T)->bf=EH;
		 *taller=TRUE;
	}
	else
	{
		if (e==(*T)->data)
		{ /*  树中已存在和e有相同关键字的结点则不再插入 */ 
			*taller=FALSE; return FALSE;
		}
		if (e<(*T)->data)
		{ /*  应继续在T的左子树中进行搜索 */ 
			if(!InsertAVL(&(*T)->lchild,e,taller)) /*  未插入 */ 
				return FALSE;
			if(*taller) /*   已插入到T的左子树中且左子树“长高” */ 
				switch((*T)->bf) /*  检查T的平衡度 */ 
				{
					case LH: /*  原本左子树比右子树高,需要作左平衡处理 */ 
							LeftBalance(T);	*taller=FALSE; break;
					case EH: /*  原本左、右子树等高,现因左子树增高而使树增高 */ 
							(*T)->bf=LH; *taller=TRUE; break;
					case RH: /*  原本右子树比左子树高,现左、右子树等高 */  
							(*T)->bf=EH; *taller=FALSE; break;
				}
		}
		else
		{ /*  应继续在T的右子树中进行搜索 */ 
			if(!InsertAVL(&(*T)->rchild,e,taller)) /*  未插入 */ 
				return FALSE;
			if(*taller) /*  已插入到T的右子树且右子树“长高” */ 
				switch((*T)->bf) /*  检查T的平衡度 */ 
				{
					case LH: /*  原本左子树比右子树高,现左、右子树等高 */ 
							(*T)->bf=EH; *taller=FALSE;	break;
					case EH: /*  原本左、右子树等高,现因右子树增高而使树增高  */
							(*T)->bf=RH; *taller=TRUE; break;
					case RH: /*  原本右子树比左子树高,需要作右平衡处理 */ 
							RightBalance(T); *taller=FALSE; break;
				}
		}
	}
	return TRUE;
}

构建为平衡二叉树,此时查找的时间复杂度为O(logn),而插入和删除也为O(logn)。

8.7 多路查找树(B树)

多路查找树,其每一个结点的孩子数可以多于两个,且每一个结点处可以存储多个元素。

介绍四种特殊的形式:2-3树、2-3-4树、B树和B+树。

8.7.1 2-3树

2-3树:其中每一个结点都具有两个孩子或三个孩子。

一个2结点包括一个元素和两个孩子,一个3结点包含一小一大两个元素和三个孩子,且所有叶子都在同一层次上。

插入有三种情况:

  • 对于空树,插入一个2结点即可
  • 插入结点到一个2结点的叶子上,将其升级为3结点即可
  • 往3结点中插入一个新元素,需要将其拆分,且将树中两元素或插入元素的三者中选择其一向上移动一层。

删除同样有三种情况:

  • 所删除元素位于一个3结点的叶子结点上,直接删除即可
  • 所删除元素位于一个2结点的叶子结点上,分四种情形:
    1. 此结点的双亲也是2结点,且拥有一个3结点的右孩子,进行旋转操作
    2. 此结点的双亲也是2结点,且拥有一个2结点的右孩子,进行旋转操作
    3. 此结点的双亲也是3结点,进行旋转操作
    4. 当前树是一个满二叉树,减少层数
  • 所删除的元素位于非叶子的分支结点,将树按中序遍历后得到此元素的前驱或后继元素让他们补位即可。

8.7.2 2-3-4树

2-3-4树是2-3树的概念扩展,包括了4结点的使用。

一个4结点包括小中大三个元素和4个孩子。

其他类似2-3树。

8.7.3 B树

B树是一种平衡的多路查找树,2-3树和2-3-4树都是B树的特例。结点最大的孩子数目称为B树的阶。

一个m阶的B树具有如下属性:

  • 如果根结点不是叶结点,则其至少有两棵子树。
  • 每一个非根的分支结点都有k-1个元素和k个孩子。每一个叶子结点n都有k-1个元素
  • 所有叶子结点都位于同一层次
  • 所有分支结点包含关键字、指向子树根结点的指针和关键字的个数

在B树上查找的过程是一个顺指针查找结点和在结点中查找关键字的交叉过程。

B树的插入和删除方式与2-3树和2-3-4树类似。

B树的数据结构就是为内外存的数据交互准备的。

8.7.4 B+树

中序遍历B树的过程中存在一些问题,在原有B树的基础上加上了新的元素组织方式,这就是B+树。

B+树是应文件系统所需而出的一种B树的变形树。

一棵m阶的B+树和m阶的B树差异在于:

  • 有n棵子树的结点中包含有n个关键字。
  • 所有的叶子结点包含全部关键字的信息,及指向含这些关键字记录的指针,叶子结点本身依关键字的大小自小而大顺序链接。
  • 所有分支结点可以看成是索引,结点中仅含有其子树中的最大(或最小)关键字。

B+树的结构适合带有范围的查找。

B+树的插入、删除过程也都和B树类似,只不过插入和删除的元素都是在叶子结点上进行。

8.8 散列表查找(哈希表)

8.8.1 概述

散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。

对应关系f称为散列函数,又称为哈希函数。

采用散列技术将记录存储在一块连续的存储空间中,这块连续存储空间称为散列表或哈希表。

散列过程就是两步:

  1. 在存储时,通过散列函数计算记录的散列地址,并按此散列地址存储该记录。
  2. 当查找记录时,我们通过同样的散列函数计算记录的散列地址,并按此散列地址访问该记录。

散列技术既是一种存储方法,也是一种查找方法。

散列技术最适合的求解问题是查找与给定值相等的记录。

缺点:同样的关键字,他能对应很多记录的情况,就不适合用散列技术;散列表也不适合范围查找;冲突。

两个关键字不同,但是对应存储位置却相同,这种现象称为冲突,并把这两个关键字称为这个散列函数的同义词。

8.8.2 散列函数的构造方法

好的散列函数有两个原则:

  1. 计算简单
  2. 散列地址分布均匀
1.直接定址法

取关键字的某个线性函数值为散列地址。

优点:简单,均匀,也不会有冲突。

缺点:需要事先知道关键字的分布情况,适合查找表较小且连续的情况。

2.数字分析法

抽取方法,使用关键字的一部分来计算散列存储位置的方法。

适合处理关键字位数比较多的情况,事先知道关键字的分布且关键字的若干位分布较均匀。

3.平方取中法

对关键字平方再取中间的三位用作散列地址。

适合不知道关键字的分布,而位数又不是很多的情况。

4.折叠法

将关键字从左到右分割成位数相等的几部分,然后将这几部分叠加求和,并按散列表表长,取最后几位作为散列地址。

折叠法事先不需要知道关键字的分布,适合关键字位数较多的情况。

5.除留余数法

最常用的构造散列函数的方法。

f(key) = key mod p mod是取模的操作。

根据经验,若散列表表长为m,通常p为小于或等于表长(最好接近m)的最小质数或不包含小于20质因子的合数。

6.随机数法

选择一个随机数,取关键字的随机函数值为它的散列地址。

当关键字的长度不相等时,采用这个方法构造散列函数是比较合适的。

具体应该根据不同情况采用不同的散列函数,这里给出一些参考因素:

  1. 计算散列地址所需的时间
  2. 关键字的长度
  3. 散列表的大小
  4. 关键字的分布情况
  5. 记录查找的频率

8.8.3 处理散列冲突的方法

1.开放定址法

开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。

fi(key) = (f(key) + di) MOD m (di = 1,2,3,…,m-1)

解决冲突的开放定址法称为线性探测法。

第二种方法:增加平方运算的目的是为了不让关键字都聚集在某一块区域,这种为二次探测法。

第三种方法:在冲突时,对于位移量di采用随机函数计算得到,称为随机探测法。

2.再散列函数法

对于散列表,事先准备多个散列函数,每当发生散列地址冲突时,就换一个散列函数计算。

这种方法能够使关键字不产生聚集,但是同时增加了计算时间。

3.链地址法

将所有关键字为同义词的记录存储在一个单链表中,这个表称为同义词子表,在散列表中只存储所有同义词子表的头指针。

链地址法对于可能会造成很多冲突的散列函数来说,提供了绝对不会出现找不到地址的保障。同时也带来查找时需要遍历单链表的性能损耗。

4.公共溢出区法

为所有冲突的关键字建立了一个公共的溢出区来存放。

如果有冲突数据很少的情况下,公共溢出区的结构对查找性能来说还是不错的。

8.8.4 散列表查找

首先定义一个散列表的结构:

#define SUCCESS 1
#define UNSUCCESS 0
#define HASHSIZE 12 /* 定义散列表长为数组的长度 */
#define NULLKEY -32768 

typedef int Status;	/* Status是函数的类型,其值是函数结果状态代码,如OK等 */ 

typedef struct
{
   int *elem; /* 数据元素存储基址,动态分配数组 */
   int count; /*  当前数据元素个数 */
}HashTable;

int m=0; /* 散列表表长,全局变量 */

对散列表进行初始化:

/* 初始化散列表 */
Status InitHashTable(HashTable *H)
{
	int i;
	m=HASHSIZE;
	H->count=m;
	H->elem=(int *)malloc(m*sizeof(int));
	for(i=0;i<m;i++)
		H->elem[i]=NULLKEY; 
	return OK;
}

定义散列函数:

/* 散列函数 */
int Hash(int key)
{
	return key % m; /* 除留余数法 */
}

对散列表进行插入操作:

/* 插入关键字进散列表 */
void InsertHash(HashTable *H,int key)
{
	int addr = Hash(key); /* 求散列地址 */
	while (H->elem[addr] != NULLKEY) /* 如果不为空,则冲突 */
	{
		addr = (addr+1) % m; /* 开放定址法的线性探测 */
	}
	H->elem[addr] = key; /* 直到有空位后插入关键字 */
}

通过散列表查找:

/* 散列表查找关键字 */
Status SearchHash(HashTable H,int key,int *addr)
{
	*addr = Hash(key);  /* 求散列地址 */
	while(H.elem[*addr] != key) /* 如果不为空,则冲突 */
	{
		*addr = (*addr+1) % m; /* 开放定址法的线性探测 */
		if (H.elem[*addr] == NULLKEY || *addr == Hash(key)) /* 如果循环回到原点 */
			return UNSUCCESS;	/* 则说明关键字不存在 */
	}
	return SUCCESS;
}

散列表查找取决于以下因素:

  • 散列函数是否均匀
  • 处理冲突的方法
  • 散列表的装填因子,填入表中的记录个数 / 散列表长度

第九章 排序

9.1 排序的基本概念与分类

排序:假设含有n个记录的序列,其相应的关键字,需要确定一种排列,使其相应的关键字满足非递减(或非递增)关系,即使得序列成为一个按关键字有序的序列,这样的操作就称为排序。

排序的稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,A1=A2,且A1在A2之前,而在排序后的序列中,A1仍在A2之前,则称这种排序算法是稳定的;否则称为不稳定的。

内排序是在排序过程中,待排序的所有记录全部被放置在内存中。
外排序是由于排序的记录个数太多,不能同时放在内存中,整个排序过程需要在内外存之间多次交换数据才能进行。
这里主要介绍内排序方法。

对于内排序,排序算法的性能主要受三个方面的影响:

  1. 时间性能,比较和移动次数尽可能少
  2. 辅助空间
  3. 算法的复杂性

内排序分为插入排序、交换排序、选择排序和归并排序。

排序用到的结构:

#define MAXSIZE 10000  /* 用于要排序数组个数最大值,可根据需要修改 */
typedef struct
{
	int r[MAXSIZE+1];	/* 用于存储要排序数组,r[0]用作哨兵或临时变量 */
	int length;			/* 用于记录顺序表的长度 */
}SqList;

交换函数:

/* 交换L中数组r的下标为i和j的值 */
void swap(SqList *L,int i,int j) 
{ 
	int temp=L->r[i]; 
	L->r[i]=L->r[j]; 
	L->r[j]=temp; 
}

9.2 冒泡排序

冒泡排序是一种交换排序,它的基本思想:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。

冒泡排序算法初级版:

/* 对顺序表L作交换排序(冒泡排序初级版) */
void BubbleSort0(SqList *L)
{ 
	int i,j;
	for(i=1;i<L->length;i++)
	{
		for(j=i+1;j<=L->length;j++)
		{
			if(L->r[i]>L->r[j])
			{
				 swap(L,i,j);/* 交换L->r[i]与L->r[j]的值 */
			}
		}
	}
}

正宗冒泡排序:

/* 对顺序表L作冒泡排序 */
void BubbleSort(SqList *L)
{ 
	int i,j;
	for(i=1;i<L->length;i++)
	{
		for(j=L->length-1;j>=i;j--)  /* 注意j是从后往前循环 */
		{
			if(L->r[j]>L->r[j+1]) /* 若前者大于后者(注意这里与上一算法的差异)*/
			{
				 swap(L,j,j+1);/* 交换L->r[j]与L->r[j+1]的值 */
			}
		}
	}
}

冒泡排序优化:增加一个标记变量flag

/* 对顺序表L作改进冒泡算法 */
void BubbleSort2(SqList *L)
{ 
	int i,j;
	Status flag=TRUE;			/* flag用来作为标记 */
	for(i=1;i<L->length && flag;i++) /* 若flag为true说明有过数据交换,否则停止循环 */
	{
		flag=FALSE;				/* 初始为False */
		for(j=L->length-1;j>=i;j--)
		{
			if(L->r[j]>L->r[j+1])
			{
				 swap(L,j,j+1);	/* 交换L->r[j]与L->r[j+1]的值 */
				 flag=TRUE;		/* 如果有数据交换,则flag为true */
			}
		}
	}
}

时间复杂度为O(n^2)。

9.3 简单选择排序

简单选择排序法就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i个记录交换。

/* 对顺序表L作简单选择排序 */
void SelectSort(SqList *L)
{
	int i,j,min;
	for(i=1;i<L->length;i++)
	{ 
		min = i;						/* 将当前下标定义为最小值下标 */
		for (j = i+1;j<=L->length;j++)/* 循环之后的数据 */
        {
			if (L->r[min]>L->r[j])	/* 如果有小于当前最小值的关键字 */
                min = j;				/* 将此关键字的下标赋值给min */
        }
		if(i!=min)						/* 若min不等于i,说明找到最小值,交换 */
			swap(L,i,min);				/* 交换L->r[i]与L->r[min]的值 */
	}
}

时间复杂度为O(n^2)。但是性能略优于冒泡排序。

9.4 直接插入排序

直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。

/* 对顺序表L作直接插入排序 */
void InsertSort(SqList *L)
{ 
	int i,j;
	for(i=2;i<=L->length;i++)
	{
		if (L->r[i]<L->r[i-1]) /* 需将L->r[i]插入有序子表 */
		{
			L->r[0]=L->r[i]; /* 设置哨兵 */
			for(j=i-1;L->r[j]>L->r[0];j--)
				L->r[j+1]=L->r[j]; /* 记录后移 */
			L->r[j+1]=L->r[0]; /* 插入到正确位置 */
		}
	}
}

时间复杂度为O(n^2)。直接插入排序法比冒泡和简单选择排序的性能要好一些。

9.5 希尔排序

希尔排序是对直接插入排序的改进。

基本有序:就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。

跳跃分割:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。

/* 对顺序表L作希尔排序 */
void ShellSort(SqList *L)
{
	int i,j,k=0;
	int increment=L->length;
	do
	{
		increment=increment/3+1;/* 增量序列 */
		for(i=increment+1;i<=L->length;i++)
		{
			if (L->r[i]<L->r[i-increment])/*  需将L->r[i]插入有序增量子表 */ 
			{ 
				L->r[0]=L->r[i]; /*  暂存在L->r[0] */
				for(j=i-increment;j>0 && L->r[0]<L->r[j];j-=increment)
					L->r[j+increment]=L->r[j]; /*  记录后移,查找插入位置 */
				L->r[j+increment]=L->r[0]; /*  插入 */
			}
		}
		printf("	第%d趟排序结果: ",++k);
		print(*L);
	}
	while(increment>1);

}

时间复杂度为O(n^3/2),优于直接排序。

增量序列的最后一个增量值必须等于1才行

9.6 堆排序

堆排序是对简单选择排序的改进。

堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。

堆排序就是利用堆进行排序,基本思想:将待排序的序列构造成一个大顶堆。此时,整个序列最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值。如此反复执行,便能得到一个有序序列。

堆排序代码:

/*  对顺序表L进行堆排序 */
void HeapSort(SqList *L)
{
	int i;
	for(i=L->length/2;i>0;i--) /*  把L中的r构建成一个大顶堆 */
		 HeapAdjust(L,i,L->length);

	for(i=L->length;i>1;i--)
	{ 
		 swap(L,1,i); /* 将堆顶记录和当前未经排序子序列的最后一个记录交换 */
		 HeapAdjust(L,1,i-1); /*  将L->r[1..i-1]重新调整为大顶堆 */
	}
}

第一个循环就是将待排序序列构建成一个大顶堆,第二个循环就是逐步将每个最大值的根结点与末尾元素交换,再调整其成为大顶堆。

堆函数的代码:

/* 已知L->r[s..m]中记录的关键字除L->r[s]之外均满足堆的定义, */
/* 本函数调整L->r[s]的关键字,使L->r[s..m]成为一个大顶堆 */
void HeapAdjust(SqList *L,int s,int m)
{ 
	int temp,j;
	temp=L->r[s];
	for(j=2*s;j<=m;j*=2) /* 沿关键字较大的孩子结点向下筛选 */
	{
		if(j<m && L->r[j]<L->r[j+1])
			++j; /* j为关键字中较大的记录的下标 */
		if(temp>=L->r[j])
			break; /* rc应插入在位置s上 */
		L->r[s]=L->r[j];
		s=j;
	}
	L->r[s]=temp; /* 插入 */
}

堆排序的时间复杂度为O(nlogn)。堆排序是一种不稳定的排序方法。它不适合待排序序列个数较少的情况。

9.7 归并排序

归并排序就是利用归并的思想实现排序的方法。原理:假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到n/2个长度为2或1的有序子序列;再两两归并,…,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。

递归实现归并排序:

/* 对顺序表L作归并排序 */
void MergeSort(SqList *L)
{ 
 	MSort(L->r,L->r,1,L->length);
}
/* 递归法 */
/* 将SR[s..t]归并排序为TR1[s..t] */
void MSort(int SR[],int TR1[],int s, int t)
{
	int m;
	int TR2[MAXSIZE+1];
	if(s==t)
		TR1[s]=SR[s];
	else
	{
		m=(s+t)/2;				/* 将SR[s..t]平分为SR[s..m]和SR[m+1..t] */
		MSort(SR,TR2,s,m);		/* 递归地将SR[s..m]归并为有序的TR2[s..m] */
		MSort(SR,TR2,m+1,t);	/* 递归地将SR[m+1..t]归并为有序的TR2[m+1..t] */
		Merge(TR2,TR1,s,m,t);	/* 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t] */
	}
}

Merge函数的实现:

/* 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n] */
void Merge(int SR[],int TR[],int i,int m,int n)
{
	int j,k,l;
	for(j=m+1,k=i;i<=m && j<=n;k++)	/* 将SR中记录由小到大地并入TR */
	{
		if (SR[i]<SR[j])
			TR[k]=SR[i++];
		else
			TR[k]=SR[j++];
	}
	if(i<=m)
	{
		for(l=0;l<=m-i;l++)
			TR[k+l]=SR[i+l];		/* 将剩余的SR[i..m]复制到TR */
	}
	if(j<=n)
	{
		for(l=0;l<=n-j;l++)
			TR[k+l]=SR[j+l];		/* 将剩余的SR[j..n]复制到TR */
	}
}

归并排序的时间复杂度为O(nlogn),空间复杂度为O(n+logn)。

归并排序是一种稳定的排序算法,是一种比较占用内存,但却效率高且稳定的算法。

非递归实现归并排序:

/* 对顺序表L作归并非递归排序 */
void MergeSort2(SqList *L)
{
	int* TR=(int*)malloc(L->length * sizeof(int));/* 申请额外空间 */
    int k=1;
	while(k<L->length)
	{
		MergePass(L->r,TR,k,L->length);
		k=2*k;/* 子序列长度加倍 */
		MergePass(TR,L->r,k,L->length);
		k=2*k;/* 子序列长度加倍 */       
	}
}

MergePass函数的实现:

/* 将SR[]中相邻长度为s的子序列两两归并到TR[] */
void MergePass(int SR[],int TR[],int s,int n)
{
	int i=1;
	int j;
	while(i <= n-2*s+1)
	{/* 两两归并 */
		Merge(SR,TR,i,i+s-1,i+2*s-1);
		i=i+2*s;        
	}
	if(i<n-s+1) /* 归并最后两个序列 */
		Merge(SR,TR,i,i+s-1,n);
	else /* 若最后只剩下单个子序列 */
		for(j =i;j <= n;j++)
			TR[j] = SR[j];
}

使用归并排序时,尽量考虑用非递归方法。

9.8 快速排序

快速排序是冒泡排序的升级,都属于交换排序类。

基本思想:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。

/* 对顺序表L作快速排序 */
void QuickSort(SqList *L)
{ 
	QSort(L,1,L->length);
}
/* 对顺序表L中的子序列L->r[low..high]作快速排序 */
void QSort(SqList *L,int low,int high)
{ 
	int pivot;
	if(low<high)
	{
			pivot=Partition(L,low,high); /*  将L->r[low..high]一分为二,算出枢轴值pivot */
			QSort(L,low,pivot-1);		/*  对低子表递归排序 */
			QSort(L,pivot+1,high);		/*  对高子表递归排序 */
	}
}

Partition函数的作用就是先选取一个关键字,想尽办法将它放到一个位置,使得它左边的值都比他小,右边的值都比他大,我们将这样的关键字称为枢轴。

/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
int Partition(SqList *L,int low,int high)
{ 
	int pivotkey;

	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
	while(low<high) /*  从表的两端交替地向中间扫描 */
	{ 
		 while(low<high&&L->r[high]>=pivotkey)
			high--;
		 swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
		 while(low<high&&L->r[low]<=pivotkey)
			low++;
		 swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
	}
	return low; /* 返回枢轴所在位置 */
}

时间复杂度最优为O(nlogn),最坏为O(n^2);空间复杂度平均为O(logn)。

快速排序是一种不稳定的排序方法。

优化方案:

  1. 优化选取枢轴

    三数取中,取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数。

    int pivotkey;
    
    int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */  
    if (L->r[low]>L->r[high])			
        swap(L,low,high);	/* 交换左端与右端数据,保证左端较小 */
    if (L->r[m]>L->r[high])
        swap(L,high,m);		/* 交换中间与右端数据,保证中间较小 */
    if (L->r[m]>L->r[low])
        swap(L,m,low);		/* 交换中间与左端数据,保证左端较小 */
    
    pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
    
  2. 优化不必要的交换

    int Partition1(SqList *L,int low,int high)
    { 
    	int pivotkey;
    
    	int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */  
    	if (L->r[low]>L->r[high])			
    		swap(L,low,high);	/* 交换左端与右端数据,保证左端较小 */
    	if (L->r[m]>L->r[high])
    		swap(L,high,m);		/* 交换中间与右端数据,保证中间较小 */
    	if (L->r[m]>L->r[low])
    		swap(L,m,low);		/* 交换中间与左端数据,保证左端较小 */
    	
    	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
    	L->r[0]=pivotkey;  /* 将枢轴关键字备份到L->r[0] */
    	while(low<high) /*  从表的两端交替地向中间扫描 */
    	{ 
    		 while(low<high&&L->r[high]>=pivotkey)
    			high--;
    		 L->r[low]=L->r[high];
    		 while(low<high&&L->r[low]<=pivotkey)
    			low++;
    		 L->r[high]=L->r[low];
    	}
    	L->r[low]=L->r[0];
    	return low; /* 返回枢轴所在位置 */
    }
    
  3. 优化小数组时的排序方案

    如果数组非常小,其实快速排序反而不如直接插入排序来得更好,因此改进QSort函数

    void QSort1(SqList *L,int low,int high)
    { 
    	int pivot;
    	if((high-low)>MAX_LENGTH_INSERT_SORT)
    	{
    		pivot=Partition1(L,low,high); /*  将L->r[low..high]一分为二,算出枢轴值pivot */
    		QSort1(L,low,pivot-1);		/*  对低子表递归排序 */
    		QSort1(L,pivot+1,high);		/*  对高子表递归排序 */
    	}
    	else
    		InsertSort(L);
    }
    
  4. 优化递归操作

    对QSort实施尾递归优化:

    /* 尾递归 */
    void QSort2(SqList *L,int low,int high)
    { 
    	int pivot;
    	if((high-low)>MAX_LENGTH_INSERT_SORT)
    	{
    		while(low<high)
    		{
    			pivot=Partition1(L,low,high); /*  将L->r[low..high]一分为二,算出枢轴值pivot */
    			QSort2(L,low,pivot-1);		/*  对低子表递归排序 */
    			low=pivot+1;	/* 尾递归 */
    		}
    	}
    	else
    		InsertSort(L);
    }
    

选取一个关键字,想尽办法将它放到一个位置,使得它左边的值都比他小,右边的值都比他大,我们将这样的关键字称为枢轴。

/* 交换顺序表L中子表的记录,使枢轴记录到位,并返回其所在位置 */
/* 此时在它之前(后)的记录均不大(小)于它。 */
int Partition(SqList *L,int low,int high)
{ 
	int pivotkey;

	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
	while(low<high) /*  从表的两端交替地向中间扫描 */
	{ 
		 while(low<high&&L->r[high]>=pivotkey)
			high--;
		 swap(L,low,high);/* 将比枢轴记录小的记录交换到低端 */
		 while(low<high&&L->r[low]<=pivotkey)
			low++;
		 swap(L,low,high);/* 将比枢轴记录大的记录交换到高端 */
	}
	return low; /* 返回枢轴所在位置 */
}

时间复杂度最优为O(nlogn),最坏为O(n^2);空间复杂度平均为O(logn)。

快速排序是一种不稳定的排序方法。

优化方案:

  1. 优化选取枢轴

    三数取中,取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数。

    int pivotkey;
    
    int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */  
    if (L->r[low]>L->r[high])			
        swap(L,low,high);	/* 交换左端与右端数据,保证左端较小 */
    if (L->r[m]>L->r[high])
        swap(L,high,m);		/* 交换中间与右端数据,保证中间较小 */
    if (L->r[m]>L->r[low])
        swap(L,m,low);		/* 交换中间与左端数据,保证左端较小 */
    
    pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
    
  2. 优化不必要的交换

    int Partition1(SqList *L,int low,int high)
    { 
    	int pivotkey;
    
    	int m = low + (high - low) / 2; /* 计算数组中间的元素的下标 */  
    	if (L->r[low]>L->r[high])			
    		swap(L,low,high);	/* 交换左端与右端数据,保证左端较小 */
    	if (L->r[m]>L->r[high])
    		swap(L,high,m);		/* 交换中间与右端数据,保证中间较小 */
    	if (L->r[m]>L->r[low])
    		swap(L,m,low);		/* 交换中间与左端数据,保证左端较小 */
    	
    	pivotkey=L->r[low]; /* 用子表的第一个记录作枢轴记录 */
    	L->r[0]=pivotkey;  /* 将枢轴关键字备份到L->r[0] */
    	while(low<high) /*  从表的两端交替地向中间扫描 */
    	{ 
    		 while(low<high&&L->r[high]>=pivotkey)
    			high--;
    		 L->r[low]=L->r[high];
    		 while(low<high&&L->r[low]<=pivotkey)
    			low++;
    		 L->r[high]=L->r[low];
    	}
    	L->r[low]=L->r[0];
    	return low; /* 返回枢轴所在位置 */
    }
    
  3. 优化小数组时的排序方案

    如果数组非常小,其实快速排序反而不如直接插入排序来得更好,因此改进QSort函数

    void QSort1(SqList *L,int low,int high)
    { 
    	int pivot;
    	if((high-low)>MAX_LENGTH_INSERT_SORT)
    	{
    		pivot=Partition1(L,low,high); /*  将L->r[low..high]一分为二,算出枢轴值pivot */
    		QSort1(L,low,pivot-1);		/*  对低子表递归排序 */
    		QSort1(L,pivot+1,high);		/*  对高子表递归排序 */
    	}
    	else
    		InsertSort(L);
    }
    
  4. 优化递归操作

    对QSort实施尾递归优化:

    /* 尾递归 */
    void QSort2(SqList *L,int low,int high)
    { 
    	int pivot;
    	if((high-low)>MAX_LENGTH_INSERT_SORT)
    	{
    		while(low<high)
    		{
    			pivot=Partition1(L,low,high); /*  将L->r[low..high]一分为二,算出枢轴值pivot */
    			QSort2(L,low,pivot-1);		/*  对低子表递归排序 */
    			low=pivot+1;	/* 尾递归 */
    		}
    	}
    	else
    		InsertSort(L);
    }
    
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值