自1946年世界上第一台计算机问世以来,计算机科学和技术都得到了飞速发展,与此同时,计算机的应用也从最初的科学计算逐步拓展到了人类社会的各个领域。计算机的处理对象不仅仅是简单的数字,而是已发展到包括字符、表格、图形、图像、声音等各种非数值数据。要开发出一种结构合理、性能良好的软件,编写出一个"好"的程序,不仅需要至少掌握一种适合的计算机高级语言或软件开发工具,还必须会分析待处理对象的特性以及待处理对象之间存在的关系。这就是"数据结构"作为一门独立课程的发展背景。
在计算机发展的初期,人们使用计算机的主要目的是处理数值计算问题。使用计算机解决一个具体问题时,一般需要经过下列几个步骤:首先要从具体问题中抽象出一个适当的数学模型,然后设计或选择一个求解此数学模型的算法,最后编出程序进行调试、测试,直至得到最终的解答。由于当时所涉及的运算对象是简单的整型、实型或布尔等类型的数据,所以程序设计者的主要精力集中于程序设计的技巧上,而无需重视数据结构。随着计算机应用领域的扩大和软、硬件的发展,非数值计算问题显得越来越重要。据统计,处理非数值计算性问题占用了90%以上的计算机运行时间,这类问题涉及的数据结构更为复杂,数据元素之间的相互关系一般无法用数学方程式加以描述。显然,解决这类问题的关键不再是数学分析和计算方法,而是要设计出合适的数据结构,才能有效地解决问题。
著名的计算机科学家沃思(N. Wirth)教授曾提出:算法+数据结构=程序。这里的数据结构指的是数据的逻辑结构和存储结构,而算法则是对数据运算的描述。(由此可见,程序设计的实质是针对实际问题选择一种好的数据结构和设计一个好的算法,而好的算法在很大程度上取决于描述实际问题的数据结构。要设计出一个"好"的程序,就必须有好的算法,而好的算法必须建立在研究数据的特性及数据之间存在的关系的基础之上。这些正是"数据结构"这门课程所要研究的内容。
到底什么是数据结构呢?先通过一个例子来说明有关数据结构的概念。
【例1.1】图书馆信息检索系统
当我们根据书名查找某本书的有关情况的时候,或者根据作者或出版社查找有关书籍的时候,或者根据书刊号查找作者和出版社等有关情况的时候,只要建立了相关的数据结构,按照某种算法编写了相关程序,就可以实现计算机的自动检索。若使用计算机处理上述图书检索问题,首先就要建立一张图书基本信息表,列在每一行上的是一本书的信息,一般包括登录号、书名、作者、分类号、出版社和出版时间等项,其中登录号是唯一的,如表1.1所示。
表中的数据元素(一行)可按登录号、书名、作者等建立相应的索引表,这些表构成的文件就是图书目录检索的数学模模型,计算机的主要操作是按某个特定要求(如书名、作者)对书目文档进行查询检索,诸如此类的问题还有各种查号系统、仓库管理系统、长务处理等。这类问题中的处理对象之间都是一种最简单的线性关系,它们所对应的数学模型称为线性的数据结构。
【例1.2】图的着色问题。图的着色问题是由地图的着色问题引申而来的:用m种颜色为地图着色,使地图的每个区域着一种颜色,且相邻区域的颜色不同。如果把一个区域收缩为一个顶点,将相邻两个区域用一条边相连接,就可以把一个区域图抽象为一个平面图和一个区域邻接关系图,如图1.1所示。
19世纪50年代,英国学者提出了任何地图都可以用4种颜色来着色的4着色猜想问题。过了100多年,这个问题才由美国学者在计算机上予以证明,这就是著名的4色定理。如在图1.1中,颜色用数字表示,字母表示区域,则图中表示了不同区域的不同着色情况。
再例如,家族的血统关系、博奕树问题(人一机下棋)、计算机的文件系统等都是一种树形结构,而城市之间的交通网络、工程管理中的活动安排以及多叉路口交通灯管理等问题是图形结构的。它们都是一种非线性的数据结构。
由此可见,描述这类非数值计算问题的数学模型不再是数学方程,而是诸如表、树和图之类的数据结构。简单来说,数据结构是研究非数值计算的程序设计问题中计算机的操作对象以及它们之间的关系和操作的一门课程。具体地说,数据结构指的是数据元素之间的逻辑结构、存储结构及其数据的抽象运算,即按某种逻辑关系组织起来的一组数据,再按一定的存储表示方式把它们存储在计算机的存储器中,并在这些数据上定义一个运算的集合,这就叫做一个数据结构。
数据结构是计算机软件和计算机应用专业的核心课程之一,在众多的计算机系统软件和应用软件中都要用到各种数据结构。因此,仅掌握几种计算机语言是难以应付众多复杂的课题的,要想有效地使用计算机,还必须学习数据结构的有关知识。
本节将对一些与数据结构有关的基本概念和常用术语给予描述和定义,以便于后续章节的学习。
数据(data)是描述客观事物的数、字符以及能输入计算机中并被计算机处理的符号的集合。例如,一个代数方程的求解程序中所使用的数据是整数和实数,而一个文本编辑程序使用的数据是字符串。随着计算机的发展以及计算机应用领域的扩大,数据的含义也随之拓展了。例如,当今计算机可以处理的图形、图像、声音等也都属于数据的范畴。
数据元素(data element)是数据的基本单位。如前例中且录卡片表中的一张卡片(表格中的一行)、树中的一个结点、图中的一个顶点等都是数据元,。有时一个数据元素可由若干个数据项(也称为字段、域、属性)组成,数据项是具有独立含义的最小标识单位,如图书卡片信息中的登录号、书名、作者等。
数据对象(data object)是具有相同性质的数据元素的集合,是数据的一个子集。例如,大写字母数据对象就是集合{‘A’, ‘B’, …,‘Z’}。
数据结构(data structure)是带有结构的数据元素的集合。结构指的是数据元素之间的相互关系,即数据的组织形式,结构中的数据元素称为结点。虽然至今没有一个关于数据结构的标准定义,但它一般包括以下三个方面的内容:
数据元素之间的逻辑(或抽象)关系,也称为数据的逻辑结构。
数据的逻辑结构是从逻辑关系上描述数据的,它与数据元素的存储结构无关,是独立于计算机的。因此,数据的逻辑结构可以看作是从具体问题抽象出来的数学模型。如第1.1节表1.1中数据元素之间的逻辑关系就是一种相邻关系:对表中任一个结点,与它相邻且在它前面的结点称为直接前趋,这种直接前趋最多只有一个;与表中任一个结点相邻且在其后面的结点称为直接后继,最多也只有一个。表中只有第一个结点没直接前趋,称之为开始结点;也只有最后一个结点没有直接后继,称之为终端结点。例如,表中的"操作系统"所在结点的直接前趋结点和直接后继结点分别是"数据结构"和"数据库原理"所在的结点,这种结点之间的关系就构成了图书目录卡片表的逻辑结构。数据的逻辑结构又可分为线性结构和非线性结构两大类。
线性结构的特征是:数据元素(结点)之间存在着一对一的关系,且结构中仅有一个开始结点和一个终端结点,其余结点都是仅有一个直接前趋和一个直接后继。表1.1所示就是一个典型的线性结构。本书第2章和第3章介绍的都是线性结构。
非线性结构的特征是:数据元素之间存在着一对多或多对多的关系,即一个结点可能有多个直接前趋和多个直接后继。该结构包括树形结构、图形结构和网状结构等。本书的第5~7章介绍的都是非线性结构。
数据元素及其关系在计算机内的存储方式,称为数据的存储结构(物理结构)。例如,如果向量中元素按其逻辑关系"顺序"存储,就称为"顺序储结构";如果向量中各元素是通过"指针"连接存储在内存中,就称为"链式存储结构"。
数据的存储结构是数据在计算机中的存储表示(映像),亦称为数据的物理结构。它包括数据元素和关系的表示,是依赖于计算机语言的。数据的存储结构可以用以下四种基本的存储方法实现:
顺序存储方法是把逻辑上相邻的结点存储在物理位置上也相邻的连续存储单元里,由此得到的存储结构称为顺序存储结构。它通常是借助于程序设计语言的数组来描述的。该方法主要应用于线性数据结构,但非线性的数据结构也可通过某种线性化的方法来实现顺序存储。
链接存储方法是用一组不一定连续的存储单元存储逻辑上相邻的元素,元素间的逻辑关系是由附加的指针域表示的,由此得到的存储结构称为链式存储结构。它通常是借助于程序设计语言中的指针来描述的。
索引存储方法通常是在存储元素信息的同时,还建立附加的索引表。表中的索引项一般形式是:(关键字,地址)。关键字是能唯一标识一个元素的一个数据项或多个数据项的组合。
散列存储方法的基本思想是根据元素的关键字直接计算出该元素的存储地址。
无论怎样定义数据结构,都应该将数据的逻辑结构、存储结构及运算这三方面看成一个整体。因此,存储结构是数据结构不可缺少的一个方面。
同一种逻辑结构,采用不同的存储方法可以得到不同的存储结构。选择何种存储结构来表示相应的逻辑结构,要视具体的应用系统要求而定,而主要考虑的还是运算方便及算法的时间和空间上的要求。
数据的运算是定义在数据的逻辑结构上的,每种逻辑结构都有一个运算的集合,最常用的运算有:检索、插入、删除、更新、排序等。数据运算是数据结构不可分割的一个方面,在给定了数据的逻辑结构和存储结构之后,按定义的运算集合及其运算性质的不同,可能导致完全不同的数据结构。
若对线性表的插入、删除运算限制在表的一端进行,则该线表称为栈;
若对线性表的插入运算限制在表的一端,而删除运算限制在表的另一端,则该线性表称为队列。
数据类型(data type)是和数据结构密切相关的一个概念。所谓数据类型,是一个值的集合和定义在这个值集上的一组操作的总称。在使用高级程序设计语言编写的程序中,每个变量、常量或表达式都有一个它所属的数据类型。数据类型规定了在程序执行期间变量或表达式可能的取值范围以及在这些值上所允许的操作运算。例如,C语言中的整数类型,就给出了一个整型量的取值范围(取于不同的机器或编译系统),定义了对整型量可施加的加、减、乘、除和取模算术运算。
在高级程序设计语言中,按"值"的不同特性,可将数据类型分为两类:一类是其值不可分解的称为原子类型(或非结构类型),例如C语言中的基本类型(整型、实型、字符型和枚举类型)以及指针类型和空类型等简单类型;另一类则是结构类型,其值可由若干个分量(或成分)按某种结构组成,它的分量可以是非结构型的,也可以是结构型的,例如C语言中数组、结构等类型。通常,数据类型可以看作是程序设计语言中已实现的数据结构。
抽象数据类型(Abstract Data Type, ADT)是20世纪70年代提出的一种新概念,它是抽象数据的组织和与之相关的操作。一个ADT可以看作是定义了相关操作运算的一个数学模型。例如,集合与集合的并、交、差运算就可以定义为一个抽象数据类型。
抽象数据类型可以看作是描述问题的模型,它独立于具体实现。它的特点是将数据定义和数据操作封装在一起,使得用户程序只能通过在ADT中定义的某种操作来访问其中的数据,从而实现信息的隐藏性。这种抽象数据类型类似于C++中的类。
作为一个例子,看一个"圆"数据类型的描述。我们知道,要表示一个圆,一般应包括圆心的位置和半径的大小。如果只关心圆的面积,那么这个抽象数据类型中就只需要有表示半径的数据。假设要设计一个圆(Circle)抽象数据类型,它包括计算面积(area)周长(circumfereCircle)的操作。Circle的抽象数据类型描述如下:
由于本书是以C语言为基础来描述算法的,而C语言中没有提供"类"这一数据类型,所以无法实现抽象数据类型,因此我们将不采用ADT的形式来描述数据结构。但只需要记住,ADT实际上等价于我们定义的数据的逻辑结构以及在逻辑结构上定义的抽象操作。
前文中已经阐述过:研究数据结构的目的在于更好地进行程序设计。而程序设计离不开数据的运算,这种运算的过程(或解题的方法)通常称为算法。例如,要用计算机求解一个已知3个坐标点a(x1, y1)、b(x2, y2)、c(x3, y3)所构成的三角形的面积。首先要根据实际问题找出求解三角形面积的相关计算公式(抽象出数学模型),然后再逐步求解计算。比如要计算面积,就必须先求边长,求边长的公式为:
求三角形面积的公式为
在有了这些公式(模型)之后,就要给出求解问题的过程(又叫解题的方法或步骤),这就是所谓的算法。该问题的算法描述如下:
(1)输入三角形的3个坐标点a、b和c
(2)计算三条边长及边长和的一半。
(3)计算三角形的面积area。
(4)输出三角形的边长和面积。
再根据算法的描述编写相应的程序代码,并上机调试运行直至得出正确结果。
从上述的实例可以看出,算法是对问题求解步骤的一种描述,为解决某一问题而采取的方法和步骤。。通俗地说,一个算法就是一种解题的方法。严格地说,算法是由若干条指令组成的有穷序列,其中每条指令表示一个或多个操作。
此外,算法还必须满足以下五个准则:
(1)输入 算法开始前必须给算法中用到的变量初始化,一个算法的输入可以包含零个或多个数据。
(2)输出 算法至少有一个或多个输出。
(3)有穷性 算法中每一条指令的执行次数都是有限的,而且每一步都在有穷时间内完成,即算法必须在执行有限步后结束。
(4)确定性 算法中每一条指令的含义都必须明确,无二义性。
(5)可行性 算法是可行的,即算法中描述的操作都可以通过有限次的基本运算来实现。
显然一个程序如果对任何输入都不会陷入无限循环,则它就是一个算法。算法的含义与程序十分相似,但二者是有区别的:程序必须依赖于计算机程序语言,而一个算法可用自然语言、计算机程序语言、数学语言或约定的符号语言来描述。
例如,上述求解三角形面积的算法就是用中文语言描述的。目前最常用的描述算法的语言有两种,一种是用类Pascal,另一种是类C,类似于C语言,而又不完全等同C语言。类C语言借助于C语言的语法结构,辅之以自然语言的叙述,使得用它编写的算法既具有良好的结构,又不拘泥于具体程序语言的某些细节。因此,类C语言使得算法易读、易写。
为了方便上机验证算法,提高读者的实际程序设计能力,本书中多采用C语言描述算法,书中每个算法基本上都是一个C函数,但也有极少数函数中用到C++的一些知识,比如在算法描述中用到了C++的行注释符"//",有的C语言编译器不一定能调试通过,但在Visual C环境下可调试通过。不过,在使用时要特别注意,在调试运行算法时,必须要加上一些相关的类型及变量说明或函数等。例如,要编写一个求n!(n阶乘)的算法,实际上就是一个C语言函数:
算法的常用表示方式:
• 自然语言:直接用英语、汉语或其它语言来描述。
• 计算机语言:用某种语言格式来描述,如C语言。
• 伪代码:介于自然语言和计算机语言之间的文字和符号来描述。
• 流程图:描述算法的逻辑图,分为传统流程图和N-S流程图。
求解一个问题可能有多种不同的算法,而算法的好坏直接影响程序的执行效率,且不同算法之间的运行效率相差巨大。
【例1.3】百钱买百鸡问题
公元5世纪末,我国古代数学家张丘建在他撰写的《算经》中提出了这样一个问题:"鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一。百钱买百鸡,问鸡翁、鸡母、鸡雏各几何?"公鸡数为a,母鸡数为b,小鸡数为c,根据题意可得如下的方程式:
分析:设公鸡数为a,母鸡数为b,小鸡数为c,根据题意壳得如下方程:
算法:根据以上得出得数学模型,如果使用通常的解析法很难求解,但使用穷举法很容易实现。具体实现算法如下:
上述算法是三重循环,主要执行时间取决于第三重循环的循环体的执行次数,外循环每执行一次,内循环就需要执行101次,所以整个算法需要执行101×101 ×101 (约100多万)次。对于计算机来说,解决这样一个简单的问题,执行的时间是不能容忍的。因此,这个算法不是一个好的算法。
其实,对上述算法是完全可以改进的。比如,公鸡5元一只,百元钱全买公鸡最多也只能够买20只,同样全买母鸡也只能买33只,而小鸡只能是用买公鸡、母鸡剩余的钱来买。所以,上述算法可改为:
该算法只有两重循环,内层循环体仅需要执行21×34-714次,与前一算法的100余万次相比差距相当大。因此,设计一个好的算法,对提高程序的执行效率是至关重要的。
那么,又如何来评价这些算法的优劣,进而从中选择好的算法呢?显然,算法的"正确性"是首先要考虑的。所谓一个算法的正确性,是指对一切合法的输入数据,该算法经过有限时间的执行都能得到正确的结果。此外,应主要考虑如下几点:
(1)执行算法所耗费的时间,即时间复杂性。
(2)执行算法所耗费的存储空间,主要是辅助空间,即空间复杂性。
(3)算法应易于理解、易于编程,易于调试等,即可读性和可操作性。
在以上点中,最主要的是时间复杂性。一个算法所耗费的时间应该是算法中每条语句的执行时间之和,而每条语句的执行时间就是该语句的执行次数(也称频度)与该语句执行一次所需时间的乘积。但不同的计算机系统执行一次基本操作的时间是千差万别的,不能用一个统一的量来衡量。一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数f(n),算法的时间量度记为: T(n) =O(f(n))。
【例1.4】求两个n阶矩阵的乘积C= A x B,其算法的基本操作部分如下:
其中,语句(1)的循环控制变量i要增加到n+1,测试i=n+1成立时,循环才会终止,因此它的频度为n+1,但它的循环体却只能执行n次。语句(2)作为语句(1)的循环内语句应执行n次,但语句(2)本身要执行n+1次,所以语句(2)的频度为n (n+1),同理可得语句(3)、语句(4)和语句(5)的频度分别为n2,n2 (n+1)和n3次。因此,该算法中所有语句的频度之和为:
T(n)=(n+1)+n(n+1)+n2+n2(n+1)+n3=2n3+3n2+2n+1
耗费时间T(n)是矩阵阶数n的函数。一般情况下,将算法所要求解问题的输入量称为问题的规模,并用一个正整数n来表示。例如,以上矩阵乘积问题的规模就是矩阵的阶数n。一个算法的时间复杂度(时间复杂性)T(n)就是该算法的时间耗费,它是该算所求问题模n的函数。当问题规模n趋向无空大时,我们把时间复杂度T(n)的数量级(阶)称为算法的渐近时间复杂度。
例如,矩阵乘积算法的时间复杂度T(n),当n足够大时, T(n)与n3之比是一个不为零的常数,则称T(n)和n3是同阶的,或者说T(n)和n3的数量级相同,可记为T(n) =O(n3)。这时,我们称T(n) =O(n3)是矩阵乘积算法的渐近时间复杂度。
假如将算法中基本操作的重复执行次数看成是问题规模n的某个函数f(n),算法的渐近时间复杂度记作: T(n) =O(f (n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,其中f(n)一般为算法中频度最大的语句频度。在分析算法时,往往对算法的时间复杂度和渐近时间复杂度不予区分,而经常是将渐近时间复杂度T(n)=O(f(n))简称为时间复杂度。例如,矩阵乘积算法的时间复杂度一般是看T(n)=O(n3),这里的f(n)=n3就是该算法中语句(5)的频度。
【例1.5】求下面程序段的算法时间复杂度。
分析:由于算法的时间复杂度考虑的只是对于问题规模n的增长率,则在难精确计算基本操作执行次数(或语句频度)的情况下,只需要求出它关于n的增长率或阶即可。因此,上述语句x=x+1执行次数关于n的增长率为n2,它是语句频度表达式(n-1) (n-2) /2中增长最快的项,所以该程序段的算法时间复杂度为O(n2)。
如果一个算法的执行时间是一个与问题规模n无关的常数,即使是一个较大的常数,该算法的时间复杂度都为常数阶,记作T(n) =O (1)。例如:
由于x、y都是常数,所以运行的次数可数得出来,总的运行次数也是常数。对于任何一个常数的时间复杂度,我们都表示成1,即O(1),因此,该程序段描述的算法其时间复杂度为O(1)。
算法的时间复杂度通常具有O(1)、O(n)、O(log2n)、O(nlog2n)、O(n2)、O(n3)、O(n3)、O(2n)和O(n!)等形式,按数量级递增排列,依次为:常数阶O(1)、对数阶O (log2n)、线性阶O(n)、线性对数价O(nlog2n)、平方阶O(n2)、立方阶O(n3)、…k次方阶O(nk)、指数阶O(2n)和阶乘阶O(n!。
类似于时间复杂度,一个算法的空间复杂度S(n)定义为该算法所耗费的存储空间,它是对一个算法在运行过程中临时占用存储空间大小的度量,是问题规模n的函数。一个算法在计算机存储器上所占用的存储空间,包括存储算法本身所占用的存储空间、算法的输入输出数据所占用存储空间和算法在运行过程中临时占用存储空间这三个方面。
程序执行的时间成本不会随着执行数据规模的增长而成增长趋势(即不会发生变化),这个时间成本是一个常量级的时间复杂度,标记成O(1)表示。
程序执行的时间成本随着执行数据规模的增长而成稳定线性增长趋势(即线性增长),这个时间成本是一个线性级的时间复杂度,标记成O(N)表示(类似于y=kx线性函数)。
程序执行的时间成本随着执行数据规模的增长而成指数级增长趋势,这个时间成本是一个指数级的时间复杂度,标记成O(N2)表示。
注:O(log n)与O(1)在图表中重合,O(log n)比O(1)稍微偏上一点点。
程序执行的空间成本不会随着执行数据规模以及时间的增长而成增长趋势(即不会发生变化),这个空间使用成本是一个常量级的空间复杂度,标记成O(1)表示。
程序执行的空间成本随着执行数据规模以及时间的增长而成稳定线性增长趋势(即线性增长),这个空间使用成本是一个线性级的空间复杂度,标记成O(N)表示。
著名的瑞士计算机科学家沃思教授曾提出:算法+数据结构=程序。他不仅指出了数据结构与算法在计算机科学中的地位,同时也指出了算法与数据结构的密切关系。事实上,在用计算机解决实际问题的过程中,数据结构与算法是相辅相成、缺一不可的两个方面:数据结构是算法处理的对象,也是设计算法的基础,一个具体问题的数据在计算机中往往可以采用多种不同的数据结构来表示:另一方面,一个实际问题的计算过程常常有多种可用的算法。因此,选择么样的数据结构和算法就成为实现应用程序过程中最重要的一个课题。
算法分析是本章的一个重点,也是一个难点。算法的好坏直接影响程序的运行效率,而程序的运行效率又直接影响实际应用系统的使用以及它的生存周期。因此,要深刻理解和掌握算法分析的思想、方法以及时间复杂度的度量等概念。
本章的主要概念包括:数据、数据元素、数据结构、逻辑结构、存储结构;算法、算法设计、算法分析、时间复杂度等。