440 likes | 574 Views
第一章 数据结构基础. 要想成为一名真正的程序员,数据结构是必备的基础知识。只有学过数据结构,才能真正有效规范地组织程序中的数据。而在实际编程中,有些问题必须通过特定的数据结构才能更方便地解决。因此数据结构是每一个搞计算机的人都应当十分掌握的知识。 要想全面而系统地学习数据结构的知识,这里的介绍显然是不充分的,建议应当找来专门介绍数据结构的书籍学习。如果你只想掌握一般层次的知识,或是已经学过数据结构,只是为了深入地学习本书后续的内容而进行回顾和复习,那么本章的介绍是足够的。. 1.1 什么是数据结构.
E N D
第一章 数据结构基础 • 要想成为一名真正的程序员,数据结构是必备的基础知识。只有学过数据结构,才能真正有效规范地组织程序中的数据。而在实际编程中,有些问题必须通过特定的数据结构才能更方便地解决。因此数据结构是每一个搞计算机的人都应当十分掌握的知识。 • 要想全面而系统地学习数据结构的知识,这里的介绍显然是不充分的,建议应当找来专门介绍数据结构的书籍学习。如果你只想掌握一般层次的知识,或是已经学过数据结构,只是为了深入地学习本书后续的内容而进行回顾和复习,那么本章的介绍是足够的。
1.1 什么是数据结构 • 数据结构就是指计算机内部数据的组织形式和存储方法。我们再熟悉不过的数组就是一种简单而典型的线性数据结构类型。本章中将更加具体地介绍一些常用的数据结构,主要包括:线性结构、树、图。 • 线性结构是最常用,也是最简单的一种数据结构。还有一种常用的数据结构叫做图状结构,简称图结构。图结构中数据元素之间存在着“多对多”的关系,因此图结构较树结构,线性结构要复杂得多。在处理一些复杂的问题中,图结构往往能派上用场。
1.2 顺序表 • 在计算机内部存储一张线性表(线性结构的数表),最为方便简单的就是用一组连续地址的内存单元来存储整张线性表。这种存储结构称为顺序存储结构,这种存储结构下的线性表就叫做顺序表。如图1-1所示,就是顺序表的示意。
1.2.1 顺序表的定义 • 定义一张顺序表也就是在内存中开辟一段连续的存储空间,并给它一个名字来标识。只有定义了一个顺序表,才能利用该顺序表存放数据元素,也才能对该顺序表进行各种操作。 • 有两种定义顺序表的方法,一是静态地定义一张顺序表;二是动态生成一张顺序表。
1.2.2 向顺序表中插入元素 • 下面介绍如何在长度为n的顺序表中的第i个位置插入新元素item。 • 所谓在长度为n的顺序表中的第i个位置插入新元素是指在顺序表第i-1个数据元素和第i个数据元素之间插入一个新元素item。 • 函数InserElem的作用是在顺序表Sqlist中第i个位置上插入元素item,并将顺序表长度加1。其实现过程如下。 • (1)判断插入元素的位置是否合法。一个长度为n的顺序表的可能插入元素的位置是1~n+1,因此如果i<1或者i>n+1或者表满n==MaxSize(因为表的内存大小固定不变)的插入都是非法的。 • (2)将顺序表的i-1以后的元素顺序后移一个元素的位置,即:将顺序表从第i个元素到第n个元素顺序后移一个元素的位置。 • (3)在表的第i个位置(下标为i-1)上插入元素item,并将表长加1。
1.2.3 从顺序表中删除元素 • 下面介绍如何删除长度为n的顺序表中的第i个位置的元素。 • 所谓删除长度为n的顺序表中的第i个位置的元素,就是指将顺序表第i个位置上的元素去掉。 • 函数DelElem的作用是从顺序表Sqlist中删除第i个位置的元素,并将表的长度值减1。其实现过程如下。 • (1)判断要删除的元素是否合法。对于一个长度为n的顺序表,删除元素的合法位置是1~n,因此如果i<1或者i>n都是不合法的。 • (2)将顺序表的第i位置以后的元素依次前移,这样就将第i个元素覆盖掉了,也就起到删除第i个位置元素的作用。 • (3)最后将表长减1。
1.2.4 实例与分析 • 前面介绍了静态顺序表和动态顺序表的定义,创建,插入元素,删除元素等方法。下面通过具体的实例巩固学到的知识。 • 【实例1-1】创建一个静态的顺序表存放整数,大小为10,完成以下的操作: • (1)输入6个整数,打印出顺序表中的内容,并显示表中剩余的空间个数。 • (2)在顺序表中的第3个位置插入元素0,打印出顺序表中的内容,并显示表中剩余的空间个数。 • (3)再试图插入表中第11个位置整数0,程序提示超出范围 • (4)删除表中第6个元素,打印出顺序表中的内容,并显示表中剩余的空间个数。
1.3 链表 • 与顺序表相同,链表也是一种线性表,它的数据的逻辑组织形式是一维的。而与顺序表不同的是,链表的物理存储结构是用一组地址任意的存储单元存储数据的。也就是说,它不像顺序表那样占据一段连续的内存空间,而是将存储单元分散在内存的任意地址上。在链表结构中,存储的每个数据元素记录都存放到幢淼囊桓鼋岬悖node)中,而每个结点之间由指针将其连接在一起,这样就形成了一条如同“链”的结构。
1.3.1 创建一个链表 • 建立一条长度为n的链表的全过程,共分为以下几个步骤。 • (1)用malloc函数在内存的动态存储区中开辟一块大小为sizeof(LNode)的空间,并将其地址赋值给LinkList类型变量p,然后将数据e存入该结点的数据域data,指针域存放NULL。其中数据e由函数Get获得。 • (2)如果指针变量list为空,说明本次生成的结点为第一个结点,所以将p赋值给list,list是LinkList类型变量,只用来指向第一个链表结点,因此它是该链表的头指针,最后要返回。 • (3)如果指针变量list不为空,则说明本次生成的结点不是第一个结点,因此将p赋值给r->next。 • (4)再将p赋值给r,目的是使r再次指向最后的结点,以便生成链表的下一个结点,即:保证r永远指向原先链表的最后一个结点。 • (5)最后将生成的链表的头指针list返回主调函数,通过list就可以访问到该链表的每一个结点,并对该链表进行操作。
1.3.2 向链表中插入结点 • 下面介绍如何在指针q指向的结点后面插入结点。该过程的步骤如下: • (1)先创建一个新结点,并用指针p指向该结点。 • (2)将q指向的结点的next域的值(即q的后继结点的指针)赋值给p指向结点的next域。 • (3)将p的值赋值给q的next域。
1.3.3 从链表中删除结点 • 下面介绍如何从非空链表中删除q所指的结点。在讨论这个问题时,必须考虑以下三种情形: • (1)q所指向的是链表的第一个结点; • (2)q所指向的结点的前驱结点的指针已知; • (3)q所指向的结点的前驱结点的指针未知。
1.3.4 销毁一个链表 • 在链表使用完毕后建议销毁它,因为链表本身会占用内存空间。如果一个系统中使用很多的链表,而使用完毕后又不及时地销毁它,那么这些垃圾空间积累过多,最终可能导致内存的泄漏甚至程序的崩溃。因此应当养成及时销毁不用的链表的习惯。 • 函数destroyLinkList的作用是销毁一个链表list,它包括以下步骤。 • (1)首先将*list的内容赋值给p,这样p也指向链表的第一个结点,成为了链表的表头。 • (2)然后判断只要p不为空(NULL),就将p指向的下一个结点的指针(地址)赋值给q,并应用函数free释放掉p所指向的结点,p再指向下一个结点,如此循环,直到链表为空为止。 • (3)最后将*list的内容置为NULL,这样主函数中的链表list就为空了,防止了list变为野指针。而且链表在内存中也被完全地释放掉了。
1.3.5 实例与分析 • 【实例1-3】编写一个程序,要求:从终端输入一组整数(大于10个数),以0作为结束标志,将这一组整数存放在一个链表中(结束标志0不包括在内),打印出该链表中的值;然后删除该链表中的第5个元素,打印出删除后的结果;最后在内存中释放掉该链表。
1.4 栈 • 栈是一种重要的线性结构。可以这样讲,栈是前面讲过的线性表的一种具体形式。也就是说,栈必须通过顺序表或者链表来实现。顺序表或者链表既可以像前面介绍的那样独立存在,组织和操作数据,同时它们也是一些特殊的数据结构(栈,队列等)的实现的基础,它们的概念更宽泛一些。
1.4.1 栈的定义 • 栈(stack)是一个后进先出(LIFO: last in first out)的线性表,它要求只在表尾进行删除和插入等操作。也就是说,所谓栈其实就是一个线性表(顺序表,链表),但是它在操作上有一些特殊的要求和限制。首先,栈的元素必须先进后出,这与一般的顺序表不同。其次,栈的操作只能限定在这个顺序表的表尾进行。
1.4.2 创建一个栈 • 创建一个栈有两个任务:一是在内存中开辟一段连续的空间,用作栈的物理存储空间;二是将栈顶、栈底地址赋值给sqStack类型变量(对象)的top和base域,并设置stacksize值,以便通过这个变量(对象)对栈进行各种操作。
1.4.3 入栈操作 • 入栈操作又叫压栈操作,就是向栈中存放数据。入栈操作要在栈顶进行,每向栈中压入一个数据,top指针就增1,直到栈满为止。
1.4.4 出栈操作 • 出栈操作就是在栈顶取出数据,栈顶指针随之下移的操作。每当从栈内弹出一个数据,栈的当前容量就减少1。可以重复出栈操作,直到该栈变为空栈为止。
1.4.5 栈的其他操作 • 除了以上介绍的创建栈,入栈,出栈等操作外,对栈还有一些其他的操作。例如:清空一个栈,销毁一个栈,计算栈的当前容量等。其实程序员完全可以根据实际编程的需要来设计这些操作。 • 1.清空一个栈 • 2.销毁一个栈 • 3.计算栈的当前容量
1.4.6 实例与分析 • 【实例1-4】利用栈的数据结构,将二进制数转换为十进制数。 • 分析: • 二进制数是计算机中数据的存储形式。它是由一串0/1编码组成。每个二进制数都可以转换成为相应的十进制数,转换的方法如下: • 一个二进制数要转换为相应的十进制数,就是从最低位起用每一位去乘以对应位的基,也就是说用第i位去乘以2i-1,然后再将每一位的乘积累加,就得到原二进制数对应的十进制表达。 • 由于栈具有后进先出的特性,因此可以用栈很方便地实现二进制转换为十进制。具体做法是,将一串二进制的0/1码从高位到低位顺序入栈,然后再逐一从栈顶取出元素,取出的第i个元素乘以2i-1,再逐一累加在一起,最终得到该二进制数的十进制表达。
1.5.1 队列的定义 • 队列(queue)也是一种重要的线性结构。与栈相同,实现一个队列同样需要顺序表或者链表作为基础,也就是说可以用链表或者顺序表来构造一个队列。但是与栈不同的是,队列是一种先进先出(FIFO: first in first out)的线性表。它要求所有的数据从队列的一端进入,从队列的另一端离开。在队列中,允许插入数据的一端叫做队尾(rear),允许数据离开的一端叫做队头(front)。
1.5.2 创建一个队列 • 创建一个队列要完成两个任务:一是在内存中创建一个头结点,但是该头结点不是用来存放数据的,而是为了操作方便人为添加的。当然也可以不定义这个头结点。二是将队列的头指针和尾指针都指向这个生成的头结点,此时队列中没有任何队列元素,该队列为空队列。不难看出应用这种绞酱唇ǖ亩恿校恿形盏呐卸ㄌ跫褪峭分刚front和尾指针rear都同时指向头结点。
1.5.3 入队列操作 • 入队列操作就是将一个QNode类型的元素从队列的尾部进入队列。每当将一个队列元素插入队列,队列的尾指针都要进行修改(因为元素从队列的尾部进入队列),队头的指针不发生改变。
1.5.4 出队列操作 • 出队列操作是将队列中的元素从队列的头部移出。每当从队列中移出数据时,队头指针不发生改变,但是头结点的next指针发生改变。队尾指针只有在原队列中只有一个元素,即队头等于队尾的情况下才会改变,否则也不改变。
1.5.5 销毁一个队列 • 由于链队列是建立在内存的动态区的,因此当一个队列不再有用时应当把它及时销毁掉,以免过多地占用内存空间。销毁一个队列的方法与销毁一个链表的方法类似,代码如下: • DestroyQueue(LinkQueue *q){ • while(q->front){ • q->rear = q->front->next; • free(q->front); • q->front = q->rear; • } • } • 通过上面的代码可以完整的销毁一个队列,最终q->rear和q->front都为空。
1.5.6 循环队列的概念 • 还有一种用顺序表实现的队列叫做循环队列。所谓循环队列顾名思义就是该队列与传统的链队列不同,队列的空间是可以循环使用的。循环队列一般有固定的容量,与传统的队列相同,队列元素必须从队尾进入队列,必须从队头出队列。
1.5.7 循环队列的实现 • 在实际的内存当中,不可能有像图1-20那样的环形存储区,只有线性的存储单元,因此循环队列实际上是用顺序表模拟出来的逻辑上循环,物理存储空间线性的队列数据结构。下面通过如图1-21所示的循环队列的几种状态来理解循环队列的实现方法和基本操作。 • 1.定义一个循环队列 • 2.初始化一个循环队列 • 3.入队列操作 • 4.出队列操作
1.5.8 实例与分析 • 【实例1-5】实现一个链队列,任意输入一串字符,以@为结束标志,然后将队列中的元素逐一取出,打印在屏幕上。 • 分析: • 这个题目很简单,主要就是考查创建一个链队列的方法,以及队列的基本操作。由于是链队列,队列建立在内存的动态区上,因此可以输入任意个字符,以“@”作为结束标志。 • 队列是一种很有用的线性结构,所有的大型软件系统(例如操作系统,数据库系统等)的实现几乎都离不开队列。例如操作系统中进程的管理,服务器中的进程线程管理等都需要应用队列这种数据结构的支持。因此队列的用途十分广泛,应当认真学好。
1.6 树结构 • 在程序设计中,树结构也是一种经常用到的数据结构。与线性结构不同,树结构采用的是非线性结构组织数据。在实际应用中,许多问题采用非线性的结构来进行描述会更加简单,方便。 • 直观地看来,树结构是以分支关系定义的一种层次结构。也就是说,应用树结构组织起来的数据应当具有层次关系。而具有这类特性的数据在计算机中应用是十分广泛的。例如:操作系统中的文件管理,网络系统中的域名管理,数据库系统中的索引,编译系统中的语法树等数据都是用树形结构组织的。
1.6.1 树的概念 • 用形式化的语言描述,树的定义是这样的:树是由n(n≥0)个结点组成的有穷集合。在任意的一棵非空树中 • (1)有且仅有一个称为根(Root)的结点; • (2)当n>1时,其余的结点分为m(m>0)个互不相交的有限集,T1,T2,……Tm其中,每一个集合本身又是一棵树,并称为根(Root)的子树(SubTree)。 • 直观地讲,树的结构如图1-23所示。
T A B ^ C ^ E ^ ^ F ^ ^ D ^ 1.6.2 树结构的计算机存储形式 • 树结构在计算机中的存储形式很多,这里只介绍最为简单的一种树的存储形式――多重链表表示。在多重链表中,每个结点由一个数据域和若干个指针域组成,其中,每一个指针域的指针指向该结点的一个孩子结点。
1.6.3 二叉树的定义 • 由于二叉树使用的范围最广,最具有代表意义,并且通过一定的方法可以将一棵多叉树转化为二叉树,因此本书重点讨论二叉树。 • 二叉树是一种特殊形式的树结构。前面已经提到,二叉树的特点是每个结点最多有两棵子树,下面给出二叉树更为严格的定义。 • 二叉树(Binary Tree)是这样的树结构:它或者为空,或者由一个根结点加上两棵分别称为左子树和右子树的互不相交的二叉树组成。显然这个定义是递归形式的。
1.6.4 二叉树的遍历 • 从二叉树的定义可知,二叉树宏观上由3部分组成,即根结点,左子树,右子树。因此只要完整地遍历了这3部分,就等于遍历了整个二叉树。根结点很好访问,因为它就是一个结点。关键是如何遍历左子树和右子树。可以把左子树和右子树看成两棵独立的树,因为它们也都是由根结点,左子树,右子树三部分组成,因此遍历它们的方式与遍历原先那棵二叉树的方式是一样的。这样就构成了递归形式的二叉树遍历方法。根据二叉树遍历顺序的不同,对二叉树的遍历有3种方案:先序遍历,中序遍历,后续遍历。 • 1.先序遍历 • 2.中序遍历 • 3.后序遍历
1.6.5 创建二叉树 • 前面讲过了二叉树的遍历,遍历是二叉树最基本的操作。在已知一棵二叉树的根结点指针的前提下,可以通过二叉树遍历的操作访问二叉树中任何一个结点,求任何一个结点的孩子结点,求任何一个结点的双亲结点等。同样,通过二叉树的遍历操作也可以生成结点,从而通过二叉树的遍历创建一棵二叉树。 • 下面介绍按先序序列创建一棵二叉树。如图1-26所示为一棵二叉树的结构示意。
1.6.6 实例与分析 • 【实例1-6】用先序序列创建一棵如图1-26的二叉树,并输出字符’D’位于二叉树的层数。 • 分析: • 解决这个问题首先可以用1.6.5节中介绍的方法创建一个二叉树,然后通过二叉树的遍历访问每一个结点,找到包含字符’D’的结点,并输出它位于二叉树的层数。 • 树结构是一种非常重要的数据结构,特别是解决一些比较复杂的问题,很多地方都要应用到树结构作为数据的存储方式,例如多媒体技术中的哈夫曼树,人工智能领域的决策树等。特别是二叉树的结构更为重要,这不但因为二叉树的应用在树结构中是最为普遍的,而且其他类型的树结构也可以通过特定的算法与二叉树结构相互转化。因此掌握二叉树就显得更加重要。
1.7 图结构 • 图是一种更为复杂的数据结构。在实际的程序设计中,数据元素之间存在着三种关系:一种是“先行后续”的关系,一个数据元素有一个直接前驱和一个直接后继,这种数据的组织结构叫做线性结构;一种是明显的层次关系,每一层上的数据元素可能和下一层中的多个数据元素(孩子)相关,但只和上一层中的一个数据元素(双亲)相关,这种数据的组织结构叫做树结构;还有一种是数据元素之间存在“一对多”或者“多对一”的关系,也就是任意的两个数据元素之间都可以存在着关系,这种数据的组织结构叫做图结构。
1.7.1 图的概念 • 图(graph)是由顶点的非空有限集合V(由N>0个顶点组成)与边的集合E(顶点之间的关系)所构成的。若图G中每一条边都没有方向,则称G为无向图;若图G中每一条边都有方向,则称G为有向图。
1.7.2 图的存储形式 • 最为常见的图的存储方法有两种:邻接矩阵存储方法和邻接表存储方法。 • 邻接矩阵存储方法也称数组存储方法,其核心思想是:利用两个数组来存储一个图。 • 这两个数组一个是一维数组,用来存放图中的数据,一个是二维数组,用来表示图中顶点之间的相互关系,称为邻接矩阵。具体地,一个具有n个顶点的图G,定义一个数组vertex[0,1,……,n-1],将该图中顶点的数据信息分别存放在该数组中对应的数组元素上。例如:顶点v0的数据信息存放在vertex[0]中……顶点vi的数据信息存放在vertex[i]中。当然数组vertex的类型要与图中顶点元素的类型一致。再定义一个二维数组A[0……n-1][0……n-1],A称为邻接矩阵,它存放顶点之间的关系信息。
1.7.3 邻接表的定义 • 前面已经介绍了邻接表的结构特点。从前面的介绍中可以知道,一个邻接表是由一个顺序表和一组链表构成的。顺序表中存放图的顶点信息,链表中存放图的边的信息。第i个单链表中的结点表示依附于顶点vi的边,对于有向图来说,是以顶点vi为尾的边。如图1-32所示,就是图1-29表示的有向图的邻接表存储形式。其中顶点v0有两条边,其出度(射出的弧的条数)为2,一条边指向顶点v1,另一条边指向顶点v3。而顶点v3也有两条边,但其出度为0,因此指向的单链表为空。其余的顶点(v1,v2)所指向的单链表也是这样的构成规律。对于无向图来说,每个顶点指向的单链表就表示附于该顶点边,不考虑边的指向。
1.7.4 图的创建 • 清楚了图的邻接表存储结构,以及如何用代码定义一个邻接表结构,下面就可以应用这些知识创建一个基于邻接表存储结构的图结构。 • 在创建一个图之前,必须先设计好图的逻辑结构。因为图的创建过程比较复杂,因此建议程序员先在纸上画出要创建的图的逻辑结构,再使用邻接表在计算机中创建出图本身。例如要应用邻接表创建一个如图1-34的有向图结构。
1.7.5 图的遍历(1)---深度优先搜索 • 创建了一个图之后就要利用它解决实际问题。同树的遍历一样,图的遍历也是对图的一种最基本的操作。所谓图的遍历就是从图中的某一顶点出发,访遍图中其余顶点,且使每一个顶点只被访问一次。图的遍历操作是求解图的连通性问题,进行拓扑排序,求解最短路经,求解关键路径等运算的基础。 • 目前普遍应用的图的遍历方法有两种。一种是本节要介绍的深度优先搜索遍历,一种是下一节介绍的广度优先搜索遍历。
1.7.6 图的遍历(2)--广度优先搜索 • 广度优先搜索的基本思想是:从图中的指定顶点v出发,先访问顶点v,然后再依次访问v 的各个未被访问的邻接点,然后从这些邻接点出发,按照同样的原则依次访问它们的未被访问的邻接点,如此循环,直到图中的所有与v相通的邻接点都被访问。与深度优先搜索一样,若此时仍然有图中的顶点未被访问,就另选图中的一个没有被访问到的顶点作为起始点,继续广度优先搜索,直到图中的所有顶点都被访问到为止。
1.7.7 实例与分析 • 【实例1-7】用邻接表存储的形式创建一棵如图1-39的无向图,并应用深度优先搜索的方法遍历该图中的每个顶点,打印出每个顶点中包含的数据。