第一章 数据结构绪论
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
第2章 算法
算法:解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示为一个或多个操作。
算法具有五个基本特性:输入、输出、有穷性、确认性和可行性。
输入输出:算法具有零个或多个输入;至少一个或多个输出,算法是一定需要输出的。
有穷性:算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
确定性:算法的每一步骤都具有确定的含义,不会出现二义性。算法在一定条件下,只有一套执行路径,相同的输入只能有唯一的输出结果。算法的每个步骤被精确定义而无歧义。
可行性:算法的每一步都必须是可行的,也就是说,每一步都能够通过执行有限次数完成。
正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求。能够得到问题的正确答案。
算法的“正确”通常在用法上有很大的差别,大体分为四个层次。
1.算法程序没有语法错误。
2.算法程序对于合法的输入数据能够产生满足要求的输出结果。
3.算法程序对于非法的输入数据能够得出满足规格说明的结果。
4.算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。
算法的正确性在大部分情况下都不可能用程序来证明,而是用数学方法证明的。
可读性:算法设计的另一目的是为了便于阅读、理解和交流。
健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
时间效率(算法的执行时间)高和存储量低:好的算法还应该具备时间效率高和存储量低的特点。
事后统计方法 | 这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。 |
事前分析估算方法 | 在计算机程序编制前,依据统计方法对算法进行估算。 |
一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
1.算法采用的策略、方法。
2.编译产生的代码质量。
3.问题的输入规模。
4.机器执行指令的速度。
一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是输入量的多少。
测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。运行时间与这个技术成正比。
在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
在分析一个算法的运行时间内,重要的是把基本操作的数量与输入规模关联起来,即基本操作的数量必须表示成输入规模的函数。
输入规模n在没有限制的情况下,只要超过一个数值N,这个函数就总是大于另一个函数,我们称函数是渐近增长的。
函数的渐近增长:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。
判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
某个算法,随着n的增大,它会越来越优于另一算法,或者越来越差于另一算法。(事前估算方法的理论依据)
算法时间复杂度
算法时间复杂度定义:在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
用大写O()来体现算法时间复杂度的记法,我们称之大O记法。
一般情况下,随着n的增大,T(n)增长最慢的算法为最优算法。
O(1)叫常数阶、O(n)叫线性阶、O(n^2)叫平方阶
推导大O阶方法:
1.用常数1取代运行时间中的所有加法常数。(没常数项不予考虑)
2.在修改后的运行次数函数中,只保留最高阶项。(例:(n^2+n)/2,只保留最高阶项,保留n^2/2)
3.如果最高阶项存在且不是1,则去除与这个项相乘的常数。(例n^2/2,去除这个项相乘的常数,最终这段代码的时间复杂度为O(n^2)
得到的结果就是大O阶。
常数阶 | 这个算法的运行次数函数是f(n)=3。根据推导大O阶的方法,第一步就是把常数项3改为1.在保留最高阶项时发现,它根本没有最高阶项,所以这个算法的时间复杂度为O(1)。 这种与问题的大小无关(n的多少),执行时间恒定的算法,我们称之为具有O(1)的时间复杂度,又叫常数阶。 对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着n的变大而发生变化,所以单纯的分支结构(不包含在循环结构中),其时间复杂度也会O(1)。 | |
线性阶 | 分析算法的复杂度,关键就是要分析循环结构的运行情况。 上段代码循环的时间复杂度为O(n),因为循环体中的代码须要执行n次。 | |
对数阶 | 由于每次count乘以2之后,就距离n更近了一分。也就是说,有多少个2相乘后大于n,则会退出循环。由2^x = n ,这个循环的时间复杂度为O(log n) | |
平方阶 | 这段代码的时间复杂度为O(n^2) | 循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数。 |
执行次数函数 | 阶 | 非正式术语 |
12 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n^2+2n+1 | O(n^2) | 平方阶 |
5 +20 | O() | 对数阶 |
2n+3+19 | O() | 阶 |
++3n+4 | O() | 立方阶 |
指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
最坏情况运行时间时一种保证,那就是运行时间将不会再坏了。在应用中,注释一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
平均运行时间也就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个目标元素。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。
一般在没有特殊说明的情况下,都是指最坏时间复杂度。
算法空间复杂度:通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n)=O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。
通常,我们都使用“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。当不用限定词地使用“复杂度”时,通常都是指时间复杂度。显然我们这本书重点要讲的还是算法的时间复杂度的问题。
总结回顾
- 算法的定义:算法是解决特定问题求解步骤的描述,在计算机中为指令的有限序列,并且每条指令表示一个或多个操作。
- 算法的特性:有穷性、确定性、可行性、输入、输出。
- 算法的设计的要求:正确性、可读性、健壮性、高效率和低存储量需求。
- 算法的度量方法:事后统计方法(不科学、不准确)、事前分析估算方法。
- 函数的渐近增长:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n > N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。
- 某个算法,随着n的变大,它会越来越优于另一算法,或者越来越差于另一算法。
- 推导大O阶:
■ 用常数1取代运行时间中的所有加法常数。
■ 在修改后的运行次数函数中,只保留最高阶项。
■ 如果最高阶项存在且不是1,则去除与这个项相乘的常数。
得到的结果就是大O阶。
-
常用的时间复杂度所耗费的时间从小到大依次是:
第 3 章 线性表
线性表:零个或多个数据元素的有限序列。
- 首先它是一个序列。也就是说,元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继。如果一个小朋友去拉两个小朋友后面的衣服,那就不可以排成一队了;同样,如果一个小朋友后面的衣服,被两个甚至多个小朋友拉扯,这其实是在打架,而不是有序排队。
- 然后,线性表强调是有限的,小朋友班级人数是有限的,元素个数当然也是有限的。事实上,在计算机中处理的对象都是有限的,那种无限的数列,只存在于数学的概念中。
- 如果用数学语言来进行定义。可如下:
- 若将线性表记为(a1,…,ai-1,ai,ai+1,…,an),则表中ai-1领先于ai,ai领先于ai+1,称ai-1是ai的直接前驱元素,ai+1是ai的直接后继元素。当i=1,2,…,n-1时,ai有且仅有一个直接后继,当i=2,3,…,n时,ai有且仅有一个直接前驱。如图所示。
- 所以线性表元素的个数n(n≥0)定义为线性表的长度,当n=0时,称为空表。
- 在非空表中的每个数据元素都有一个确定的位置,如a1是第一个数据元素,an是最后一个数据元素,ai是第i个数据元素,称i为数据元素ai在线性表中的位序。
- 在较复杂的线性表中,一个数据元素可以由若干个数据项组成。
- 在较复杂的线性表中,一个数据元素可以由若干个数据项组成。
- 用线性表的定义来说,要相同类型的数据
线性表的抽象数据类型定义如下:
- 对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。
- 比如,要实现两个线性表集合A和B的并集操作。即要使得集合A=A∪B。说白了,就是把存在集合B中但并不存在A中的数据元素插入到A中即可。
- 假设La表示集合A,Lb表示集合B,则实现的代码如下:
线性表的顺序存储结构
顺序存储定义:用一段地址连续的存储单元依次存储线性表的数据元素。
线性表(a1,a2,……,an)的顺序存储示意图如下:
顺序存储方式:既然线性表的每个数据元素的类型都相同,所以可以用C语言(其他语言也相同)的一维数组来实现顺序存储结构,即把第一个数据元素存到数组下标为0的位置中,接着把线性表相邻的元素存储在数组中相邻的位置。
随着数据的插入,我们线性表的长度开始变大,不过线性表的当前长度不能超过存储容量,即数组的长度。
线性表的顺序存储的结构代码。
#define MAXSIZE 20 /*存储空间初始分配量*/
typedef int ElemType; /*ElemType类型根据实际情况而定,这里假设为int*/
typedef struct
{
ElemType data[MAXSIZE]; /*数组存储数据元素,最大值为MAXSIZE*/
int length; /*线性表当前长度*/
}SqList;
这里,我们就发现描述顺序存储结构需要三个属性:
■ 存储空间的起始位置:数组data,它的存储位置就是存储空间的存储位置。
■ 线性表的最大存储容量:数组长度MaxSize。
■ 线性表的当前长度:length。
数据长度与线性表长度区别:数组的长度是存放线性表的存储空间的长度;线性表的长度是线性表中数据元素的个数,随着线性表插入和删除操作的进行,这个量是变化的。
在任意时刻,线性表的长度应该小于等于数组的长度。
地址计算方法:数组从0开始第一个下标,线性表的第i个元素是要存储在数组下标为i-1的位置。
- 用数组存储顺序表意味着要分配固定长度的数组空间,由于线性表中可以进行插入和删除操作,因此分配的数组空间要大于等于当前线性表的长度。
- 存储器中的每个存储单元都有自己的编号,这个编号称为地址。当我们占座后,占座的第一个位置确定后,后面的位置都是可以计算的。
线性表中第i+1个数据元素的存储位置和第i个数据元素的存储位置满足下列关系(LOC表示获得存储位置的函数)。
LOC()=LOC(ai)+c
所以对于第i个数据元素ai的存储位置可以由a1推算得出:
LOC(ai)=LOC(a1)+(i-1)*c
- 对每个线性表位置的存入或者取出数据,对于计算机来说都是相等的时间,也就是一个常数,因此用我们算法中学到的时间复杂度的概念来说,它的存取时间性能为O(1)。我们通常把具有这一特点的存储结构称为随机存取结构。
顺序存储结构的插入与删除:
获得元素操作:
将线性表L中的第i个位置元素值返回就是把数组第i-1下标的值返回即可。来看代码:
#define OK 1 #define ERROR 0 #define TRUE 1 #define FALSE 0 typedef int Status; /*Status是函数的类型,其值是函数结果状态代码,如OK等*/ /*初始条件:顺序线性表L已存在,1≤i≤ListLength(L)*/ /*操作结果:用e返回L中第i个数据元素的值*/ Status GetElem(SqList L,int i,ElemType *e) { if(L.length==0 || i<1 || i>L.length) return ERROR; *e=L.data[i-1]; return OK; }
注意这里返回值类型Status是一个整型,返回OK代表1,ERROR代表0。之后代码中出现就不再详述。
插入操作:
即在线性表L中的第i个位置插入新元素e。
插入算法的思路:
■ 如果插入位置不合理,抛出异常;
■ 如果线性表长度大于等于数组长度,则抛出异常或动态增加容量;
■ 从最后一个元素开始向前遍历到第i个位置,分别将它们都向后移动一个位置;
■ 将要插入元素填入位置i处;
■ 表长加1。
实现代码如下:
删除操作:
删除算法的思路:
■ 如果删除位置不合理,抛出异常;
■ 取出删除元素;
■ 从删除元素位置开始遍历到最后一个元素位置,分别将它们都向前移动一个位置;
■ 表长减1。
实现代码如下:/*初始条件:顺序线性表L已存在,1≤i≤ListLength(L)*/ /*操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1*/ Status ListDelete(SqList *L,int i,ElemType *e) { int k; if (L->length==0) /*线性表为空*/ return ERROR; if (i<1 || i>L->length) /*删除位置不正确*/ return ERROR; *e=L->data[i-1]; if (i<L->length) /*如果删除不是最后位置*/ { for(k=i;k<L->length;k++) /*将删除位置后继元素前移*/ L->data[k-1]=L->data[k]; } L->length--; return OK; }
插入和删除的时间复杂度。
- 先来看最好的情况,如果元素要插入到最后一个位置,或者删除最后一个元素,此时时间复杂度为O(1),因为不需要移动元素的,就如同来了一个新人要正常排队,当然是排在最后,如果此时他又不想排了,那么他一个人离开就好了,不影响任何人。
- 最坏的情况呢,如果元素要插入到第一个位置或者删除第一个元素,此时时间复杂度是多少呢?那就意味着要移动所有的元素向后或者向前,所以这个时间复杂度为O(n)。
- 至于平均的情况,由于元素插入到第i个位置,或删除第i个元素,需要移动n-i个元素。根据概率原理,每个位置插入或删除元素的可能性是相同的,也就说位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的移动次数相等,为(n-1)/2。我们前面讨论过时间复杂度的推导,可以得出,平均时间复杂度还是O(n)。
- 这说明什么?线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是O(1);而插入或删除时,时间复杂度都是O(n)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用。当然,它的优缺点还不只这些……
优点 | 缺点 |
---|---|
|
|
线性表的链式存储结构
线性表链式存储结构定义:线性表的链式存储结构的特点是用一组任意的存储单元存储线性表的数据元素,这组存储单元可以是连续的,也可以是不连续的。
- 现在链式结构中,除了要存数据元素信息外,还要存储它的后继元素的存储地址。
- 因此,为了表示每个数据元素ai与其直接后继数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其直接后继的信息(即直接后继的存储位置)。我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域。指针域中存储的信息称做指针或链。这两部分信息组成数据元素ai的存储映像,称为结点(Node)。
- n个结点(ai的存储映像)链结成一个链表,即为线性表(a1,a2,…,an)的链式存储结构,因为此链表的每个结点中只包含一个指针域,所以叫做单链表。单链表正是通过每个结点的指针域将线性表的数据元素按其逻辑次序链接在一起,如图所示。
- 链表中第一个结点的存储位置叫做头指针,之后的每一个结点就是上一个后继指针指向的位置。
- 线性链表的最后一个结点指针为“空”(通常用NULL或者"^"符号表示)
- 在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息;也可以存储如线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针。
头指针与头结点的异同
线性表链式存储结构代码描述
若线性表为空表,则头结点的指针域为“空”,如下图
改用更方便的存储示意图来表示单链表,如下图
若带有头结点的单链表,如下图
空链表如下图
结点由存放数据元素的数据域存放后继结点地址的指针域组成。假设p是指向线性表第i个元素的指针,则该结点ai的数据域我们可以用p->data来表示,p->data的值是一个数据元素,结点ai的指针域可以用p->next来表示,p->next的值是一个指针。p->next指向谁呢?当然是指向第i+1个元素,即指向ai+1的指针。也就是说,如果p->data=ai,那么p->next->data=ai+1,如下图
单链表读取
对于单链表实现获取第i个元素的数据的操作GetElem,在算法上,相对要麻烦一些。
获得链表第i个数据的算法思路:
1.声明一个结点p指向链表第一个结点,初始化j从1开始;
2.当j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1;
3.若到链表末尾p为空,则说明第i个元素不存在;
4.否则查找成功,返回结点p的数据。
实现代码算法如下:
/*初始条件:顺序线性表L已存在,1≤i≤ListLength(L)*/
/*操作结果:用e返回L中第i个数据元素的值*/
Status GetElem(LinkList L,int i,ElemType *e)
{
int j;
LinkList p; /*声明一结点p*/
p = L->next; /*让p指向链表L的第一个结点*/
j = 1; /*j为计数器*/
while (p && j<i) /*p不为空或者计数器j还没有等于i时,循环继续*/
{
p = p->next; /*让p指向下一个结点*/
++j;
}
if ( !p || j>i )
return ERROR; /*第i个元素不存在*/
*e = p->data; /*取第i个元素的数据*/
return OK;
}
说白了,就是从头开始找,直到第i个元素为止。由于这个算法的时间复杂度取决于i的位置,当i=1时,则不需遍历,第一个就取出数据了,而当i=n时则遍历n-1次才可以。因此最坏情况的时间复杂度是O(n)。
由于单链表的结构中没有定义表长,所以不能事先知道要循环多少次,因此也就不方便使用for来控制循环。其主要核心思想就是“工作指针后移”,这其实也是很多算法的常用技术。
单链表的插入与删除(时间复杂度都是O(n))
单链表的插入
假设存储元素e的结点为s,要实现结点p、p->next和s之间逻辑关系的变化,只需将结点s插入到结点p和p->next之间即可。
根本用不着惊动其他结点,只需要让s->next和p->next的指针做一点改变即可。
s->next=p->next; p->next=s;
解读这两句代码,也就是说让p的后继结点改成s的后继结点,再把结点s变成p的后继结点,如下图
单链表第i个数据插入结点的算法思路:
1.声明一结点p指向链表第一个结点,初始化j从1开始;
2.当j<i时,就遍历链表,让p的指针向后移动,不断指向下一结点,j累加1;
3.若到链表末尾p为空,则说明第i个元素不存在;
4.否则查找成功,在系统中生成一个空结点s;
5.将数据元素e赋值给s->data;
6.单链表的插入标准语句s->next=p->next; p->next=s;
7.返回成功。
实现代码算法如下:
/*初始条件:顺序线性表L已存在,1≤i≤ListLength(L),*/
/*操作结果:在L中第i个位置之前插入新的数据元素e,L的长度加1*/
Status ListInsert(LinkList *L,int i,ElemType e)
{
int j;
LinkList p,s;
p = *L;
j = 1;
while (p && j < i) /* 寻找第i个结点 */
{
p = p->next;
++j;
}
if (!p || j > i)
return ERROR; /*第i个元素不存在*/
s = (LinkList)malloc(sizeof(Node));/*生成新结点(C标准函数)*/
s->data = e;
s->next = p->next; /*将p的后继结点赋值给s的后继*/
p->next = s; /*将s赋值给p的后继*/
return OK;
}
在这段算法代码中,我们用到了C语言的malloc标准函数,它的作用就是生成一个新的结点,其类型与Node是一样的,其实质就是在内存中找了一小块空地,准备用来存放e数据s结点。
单链表的删除
将它的前继结点的指针绕过,指向它的后继结点即可,如下图
实际上就是一步,p->next=p->next->next,用q来取代p->next,即是
q=p->next; p->next=q->next;
单链表第i个数据删除结点的算法思路:
1.声明一结点p指向链表第一个结点,初始化j从1开始;
2.当j<i时,就遍历链表,让p的指针向后移动,不断指向下一个结点,j累加1;
3.若到链表末尾p为空,则说明第i个元素不存在;
4.否则查找成功,将欲删除的结点p->next赋值给q;
5.单链表的删除标准语句p->next=q->next;
6.将q结点中的数据赋值给e,作为返回;
7.释放q结点;
8.返回成功。
实现代码算法如下:
/*初始条件:顺序线性表L已存在,1≤i≤ListLength(L) */
/*操作结果:删除L的第i个数据元素,并用e返回其值,L的长度减1*/
Status ListDelete(LinkList *L, int i, ElemType *e)
{
int j;
LinkList p, q;
p = *L;
j = 1;
while (p->next && j < i) /*遍历寻找第i个元素*/
{
p = p->next;
++j;
}
if (!(p->next) || j > i)
return ERROR; /*第i个元素不存在*/
q = p->next;
p->next = q->next; /*将q的后继赋值给p的后继*/
*e = q->data; /*将q结点中的数据给e*/
free(q); /*让系统回收此结点,释放内存*/
return OK;
}
这段算法代码里,我们又用到了另一个C语言的标准函数free。它的作用就是让系统回收一个Node结点,释放内存。
分析一下刚才我们讲解的单链表插入和删除算法,我们发现,它们其实都是由两部分组成:第一部分就是遍历查找第i个元素;第二部分就是插入和删除元素。
单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个元素,对于顺序存储结构意味着,每一次插入都需要移动n-i个元素,每次都是O(n)。而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。
单链表的整表创建
数组的初始化:声明一个类型和大小的数组并赋值的过程。
创建单链表的过程就是一个动态生成链表的过程。即从“空表”的初始状态起,依次建立各元素结点,并逐个插入链表。
单链表整表创建的算法思路:
1.声明一结点p和计数器变量i;
2.初始化一空链表L;
3.让L的头结点的指针指向NULL,即建立一个带头结点的单链表;
4.循环:
◆ 生成一新结点赋值给p;
◆ 随机生成一数字赋值给p的数据域p->data;
◆ 将p插入到头结点与前一新结点之间。
实现代码算法如下:
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(头插法)*/
void CreateListHead(LinkList *L, int n)
{
LinkList p;
int i;
srand(time(0)); /*初始化随机数种子*/
*L = (LinkList)malloc(sizeof(Node));
(*L)->next = NULL; /*先建立一个带头结点的单链表*/
for (i=0; i<n; i++)
{
p = (LinkList)malloc(sizeof(Node));/*生成新结点*/
p->data = rand()%100+1; /*随机生成100以内的数字*/
p->next = (*L)->next;
(*L)->next = p; /*插入到表头*/
}
}
这段算法代码里,我们其实用的是插队的办法,就是始终让新结点在第一的位置。我也可以把这种算法简称为头插法,如下图
把每次新结点都插在终端结点的后面,这种算法称之为尾插法。
实现代码算法如下:
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(尾插法)*/
void CreateListTail(LinkList *L, int n)
{
LinkList p,r;
int i;
srand(time(0)); /*初始化随机数种子*/
*L = (LinkList)malloc(sizeof(Node));/*为整个线性表*/
r=*L; /*r为指向尾部的结点*/
for (i=0; i<n; i++)
{
p = (Node *)malloc(sizeof(Node)); /*生成新结点*/
p->data = rand()%100+1; /*随机生成100以内的数字*/
r->next=p; /*将表尾终端结点的指针指向新结点*/
r = p; /*将当前的新结点定义为表尾终端结点*/
}
r->next = NULL; /*表示当前链表结束*/
}
注意L与r的关系,L是指整个单链表,而r是指向尾结点的变量,r会随着循环不断地变化结点,而L则是随着循环增长为一个多结点的链表。
r->next=p;的意思,其实就是将刚才的表尾终端结点r的指针指向新结点p,如下图所示,当中①位置的连线就是表示这个意思。
它的意思,就是本来r是在ai-1元素的结点,可现在它已经不是最后的结点了,现在最后的结点是ai,所以应该要让将p结点这个最后的结点赋值给r。此时r又是最终的尾结点了。
循环结束后,那么应该让这个链表的指针域置空,因此有了“r->next=NULL;”,以便以后遍历时可以确认其是尾部。
单链表的整表删除
单链表整表删除的算法思路如下:
1.声明一结点p和q;
2.将第一个结点赋值给p;
3.循环:
◆ 将下一结点赋值给q;
◆ 释放p;
◆ 将q赋值给p。
单链表结构与顺序存储结构优缺点
■ 若线性表需要频繁查找,很少进行插入和删除操作时,宜采用顺序存储结构。若需要频繁插入和删除时,宜采用单链表结构。
■ 当线性表中的元素个数变化较大或者根本不知道有多大时,最好用单链表结构,这样可以不需要考虑存储空间的大小问题。而如果事先知道线性表的大致长度,比如一年12个月,一周就是星期一至星期日共七天,这种用顺序存储结构效率会高很多。
静态链表
静态链表:用数组描述的链表。
我们对数组第一个和最后一个元素作为特殊元素处理,不存数据。我们通常把未被使用的数组元素称为备用链表。而数组第一个元素,即下标为0的元素的cur就存放备用链表的第一个结点的下标;而数组的最后一个元素的cur则存放第一个有数值的元素的下标,相当于单链表中的头结点作用,当整个链表为空时,则为0。如下图
此时“甲”这里就存有下一元素“乙”的游标2,“乙”则存有下一元素“丁”的下标3。而“庚”是最后一个有值元素,所以它的cur设置为0。而最后一个元素的cur则因“甲”是第一有值元素而存有它的下标为1。而第一个元素则因空闲空间的第一个元素下标为7,所以它的cur存有7。
静态链表的插入操作
■ 当我们执行插入语句时,我们的目的是要在“乙”和“丁”之间插入“丙”。调用代码时,输入i值为3。
■ 第4行让k=MAX_SIZE–1=999。
■ 第7行,j=Malloc_SSL(L)=7。此时下标为0的cur也因为7要被占用而更改备用链表的值为8。
■ 第11~12行,for循环l由1到2,执行两次。代码k=L[k].cur; 使得k=999,得到k=L[999].cur=1,再得到k=L[1].cur=2。
■ 第13行,L[j].cur=L[k].cur;因j=7,而k=2得到L[7].cur=L[2].cur=3。这就是刚才我说的让“丙”把它的cur改为3的意思。
■ 第14行,L[k].cur=j;意思就是L[2].cur=7。也就是让“乙”得点好处,把它的cur改为指向“丙”的下标7。
就这样,我们实现了在数组中,实现不移动元素,却插入了数据的操作,如下图
静态链表的删除操作
“甲”现在要走,这个位置就空出来了,也就是,未来如果有新人来,最优先考虑这里,所以原来的第一个空位分量,即下标是8的分量,它降级了,把8给“甲”所在下标为1的分量的cur,也就是space[1].cur=space[0].cur=8,而space[0].cur=k=1其实就是让这个删除的位置成为第一个优先空位,把它存入第一个元素的cur中
静态链表优缺点
循环链表
将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表,简称循环链表(circular linked list)。
循环链表和单链表的主要差异就在于循环的判断条件上,原来是判断p->next是否为空,现在则是p -> next不等于头结点,则循环未结束。
改用指向终端结点的尾指针来表示循环链表,如下图,此时查找开始结点和终端结点都很方便了。
从上图中可以看到,终端结点用尾指针rear指示,则查找终端结点是O(1),而开始结点,其实就是rear->next->next,其时间复杂也为O(1)。
合并之后
双向链表
在单链表的每个结点中,再设置一个指向其前驱结点,在设置一个指向其前驱结点的指针域,一个指向直接后继,另一个指向直接前驱。
第4章 栈与队列
栈与队列:
栈是限定仅在表尾进行插入和删除操作的线性表。
队列是只允许在一端进行插入操作、而在另一端进行删除操作的线性表。
栈
栈(stack)是限定仅在表尾进行插入和删除操作的线性表。
把允许插入和删除的一端称为栈顶(top),另一端称为栈底(bottom),不含任何数据元素的栈称为空栈。栈又称为后进先出(Last In First Out)的线性表,简称LIFO结构。
理解栈的定义需要注意:
首先它是一个线性表,也就是说,栈元素具有线性关系,即前驱后继关系。只不过它是一种特殊的线性表而已。定义中说是在线性表的表尾进行插入和删除操作,这里表尾是指栈顶,而不是栈底。
它的特殊之处就在于限制了这个线性表的插入和删除位置,它始终只在栈顶进行。这也就使得:栈底是固定的,最先进栈的只能在栈底。
栈的插入操作,叫作进栈,也称压栈、入栈。类似子弹入弹夹。栈的删除操作,叫作出栈,也称弹栈。
进栈出栈变化形式
栈对线性表的插入和删除的位置进行了限制,并没有对元素进出的时间进行限制,也就是说,在不是所有元素都进栈的情况下,事先进去的元素也可以出栈,只要保证是栈顶元素出栈就可以。
栈的抽象数据类型
ADT 栈(stack)
Data
同线性表。元素具有相同的类型,相邻元素具有前驱和后继关系。
Operation
InitStack(*S):初始化操作,建立一个空栈S。
DestroyStack(*S):若栈存在,则销毁它。
ClearStack(*S):将栈清空。
StackEmpty(S):若栈为空,返回true,否则返回false。
GetTop(S,*e):若栈存在且非空,用e返回S的栈顶元素。
Push(*S,e):若栈S存在,插入新元素e到栈S中并成为栈顶元素。
Pop(*S,*e):删除栈S中栈顶元素,并用e返回其值。
StackLength(S):返回栈S的元素个数。
endADT
栈的顺序存储结构及实现
栈的顺序存储是线性表顺序存储的简化,简称为顺序栈。
定义一个top变量来指示栈顶元素在数组中的位置
栈的顺序存储结构——进栈操作
栈的插入,即进栈操作,做了如下图的处理
栈的顺序存储结构——出栈操作
两栈共享空间
其实栈的顺序存储还是很方便的,因为它只准栈顶进出元素,所以不存在线性表插入和删除时需要移动元素的问题。不过它有一个很大的缺陷,就是必须事先确定数组存储空间大小,万一不够用了,就需要编程手段来扩展数组的容量,非常麻烦。
如果我们有两个相同类型的栈,我们为它们各自开辟了数组空间,极有可能是第一个栈已经满了,再进栈就溢出了,而另一个栈还有很多存储空间空闲。这又何必呢?我们完全可以用一个数组来存储两个栈,只不过需要点小技巧。
我们的做法如下图,数组有两个端点,两个栈有两个栈底,让一个栈的栈底为数组的始端,即下标为0处,另一个栈为栈的末端,即下标为数组长度n-1处。这样,两个栈如果增加元素,就是两端点向中间延伸。
其实关键思路是:它们是在数组的两端,向中间靠拢。top1和top2是栈1和栈2的栈顶指针,可以想象,只要它们俩不见面,两个栈就可以一直使用。
从这里也就可以分析出来,栈1为空时,就是top1等于-1时;而当top2等于n时,即是栈2为空时,那什么时候栈满呢?
想想极端的情况,若栈2是空栈,栈1的top1等于n-1时,就是栈1满了。反之,当栈1为空栈时,top2等于0时,为栈2满。但更多的情况,其实就是我刚才说的,两个栈见面之时,也就是两个指针之间相差1时,即top1+1==top2为栈满。
栈的链式存储结构及实现
栈的链式存储结构,简称为链栈。
通常对于链栈来说,是不需要头结点的。
栈的链式存储结构——进栈操作
假设元素值为e的新结点是s,top为栈顶指针,
/* 插入元素e为新的栈顶元素 */
Status Push(LinkStack *S, SElemType e)
{
LinkStackPtr s=(LinkStackPtr)malloc(sizeof(StackNode));
s->data=e;
s->next=S->top;/* 把当前的栈顶元素赋值给新结点的直接后继,如图中① */
S->top=s; /* 将新的结点s赋值给栈顶指针,如图中② */
S->count++;
return OK;
}
栈的链式存储结构——出栈操作
假设变量p用来存储要删除的栈顶结点,将栈顶指针下移一位,最后释放p即可。
链栈的进栈push和出栈pop都很简单,没有任何循环操作,时间复杂度均为O(1)。
对比一下顺序栈与链栈,它们在时间复杂度上是一样的,均为O(1)。对于空间性能,顺序栈需要事先确定一个固定的长度,可能会存在内存空间浪费的问题,但它的优势是存取时定位很方便,而链栈则要求每个元素都有指针域,这同时也增加了一些内存开销,但对于栈的长度无限制。所以它们的区别和线性表中讨论的一样,如果栈的使用过程中元素变化不可预料,有时很小,有时非常大,那么最好是用链栈,反之,如果它的变化在可控范围内,建议使用顺序栈会更好一些。
队列的定义
队列(queue)是只允许在一端进行插入操作,而在另一端进行删除操作的线性表。
队列是一种先进先出(First In First Out)的线性表,简称FIFO。允许插入的一端称为队尾,允许删除的一端称为队头。
队列插入数据只能在队尾进行,删除数据只能在队头进行。
循环队列
队列属性存储的不足
我们假设一个队列有n个元素,则顺序存储的队列需建立一个大于n的数组,并把队列的所有元素存储在数组的前n个单元,数组下标为0的一端即是队头。所谓的入队列操作,其实就是在队尾追加一个元素,不需要移动任何元素,因此时间复杂度为O(1),如图4-12-1所示。
图4-12-1
与栈不同的是,队列元素的出列是在队头,即下标为0的位置,那也就意味着,队列中的所有元素都得向前移动,以保证队列的队头,也就是下标为0的位置不为空,此时时间复杂度为O(n),如图4-12-2所示。
图4-12-2
为了改善不足:对头不需要一定在下标为0的位置。
为了避免当只有一个元素时,队头和队尾重合使处理变得麻烦,所以引入两个指针,front指针指向队头元素,rear指针指向队尾元素的下一个位置,这样当front等于rear时,此队列不是还剩一个元素,而是空队列。
假设这个队列的总个数不超过5个,但目前如果接着入队的话,因数组末尾元素已经占用,再向后加,就会产生数组越界的错误,可实际上,我们的队列在下标为0和1的地方还是空闲的。我们把这种现象叫做“假溢出”。
现实当中,你上了公交车,发现前排有两个空座位,而后排所有座位都已经坐满,你会怎么做?立马下车,并对自己说,后面没座了,我等下一辆?
没有这么笨的人,前面有座位,当然也是可以坐的,除非坐满了,才会考虑下一辆。
循环队列定义(解决假溢出)
把队列的这种头尾相接的顺序存储结构称为循环队列。