目录
计算机的应用已不再局限于科学计算,而更多地用于控制、管理及数据处理等非数值计算的处理工作。与此相应,计算机加工处理的对象由纯粹的数值发展到字符、表格和图像等各种具有一定结构的数据,这就给程序设计带来一些新的问题。为了编写出一个好的程序,必须分析待处理的对象的特性以及各处理对象之间存在的关系。这就是“数据结构”这门学科形成和发展的背景。
1.1 什么是数据结构?
计算机解决具体问题步骤:抽象成数学模型——设计求解算法——编写程序——测试修改——最终解答。
然而很多非数值计算问题无法用数学方程加以描述:如
- 图书馆书目检索系统自动化问题——索引表,计算机查询。(查号系统自动化、仓库账目管理等)文档管理系统的数学模型中,计算机处理的对象之间存在最简单线性关系——线性的数据结构。
- 计算机和人对弈问题——把一个棋盘格局派生的几个格局都画在一张图上,则可以得到一个倒生长的“树”,根是最初的棋盘格局,叶是可能出现的结局,过程就是树杈。“树”也是一种数据结构。
- 多岔路口交通灯管理问题——保持正常秩序不碰撞而又最大车流。交通道路问题数学模型为:“图”的数据结构。
程序设计 = 数据结构 + 算法。
数据结构就是数据元素互相之间的一种或多种特定关系的集合。
数据结构不仅涉及计算机硬件(特别是编码理论、存储装置、存取方法等),还和软件研究有更密切关系,无论是编译程序还是操作系统都涉及数据在存储器中的分配问题。在研究信息检索时也必须考虑如何组织数据,以便查找和存取数据元素更为方便。
1.2 基本概念和术语
- 数据:是对客观事物的符号表示,在计算机科学中是指所有能输人到计算机中并被计算机程序处理的符号的总称。整数、实数、字符串;图像、声音等都可以通过编码而归之于数据的范畴。
- 数据元素:是数据的基本单位,在计算机程序中通常作为一个整体进行考虑和处理。有时,一个数据元素可由若干个数据项组成,数据项是数据的不可分割的最小单位。
- 数据对象:是性质相同的数据元素的集合,是数据的一个子集。整数数据对象是集合N= {0,±1,±2,…}。
- 数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
在任何问题中,数据元素都不是孤立存在的,而是在它们之间存在着某种关系,这种数据元素相互之间的关系称为结构。根据数据元素之间关系的不同特性,通常有下列4类基本结构:
- 集合 结构中的数据元素之间除了“同属于一个集合”的关系外,别无其他关系;
- 线性结构 结构中的数据元素之间存在一个对一个的关系;
- 树形结构 结构中的数据元素之间存在一个对多个的关系;
- 图状结构或网状结构 结构中的数据元素之间存在多个对多个的关系。
数据结构的形式定义为:数据结构是一个二元组:Data_Structure=(D,S)
其中:D是数据元素的有限集,S是D上关系的有限集。
上述数据结构的定义仅是对操作对象的一种数学描述,换句话说,是从操作对象抽象出来的数学模型。
数据结构分为:逻辑结构和物理结构。
- 结构定义中的“关系"描述的是数据元素之间的逻辑关系,因此又称为数据的逻辑结构。(上面4种基本结构)
- 数据结构在计算机中的表示(又称映像)称为数据的物理结构,又称存储结构。它包括数据元素的表示和关系的表示。
在计算机中表示信息的最小单位是二进制数的一位,叫做位(bit)。在计算机中,我们可以用一个由若干位组合起来形成的一个位串表示一个数据元素(如用一个字长的位串表示一个整数,用8位二进制数表示一个字符等),通常这个位串为元素(element)或结点(node)。当数据元素由若干数据项组成时,位串中对应于各个数据项的子位串称为数据域(data field)。
因此,元素或结点可看成是数据元素在计算机中的映像。
数据元素之间的关系在计算机中有两种不同的表示方法:顺序映像和非顺序映像,并由此得到两种不同的存储结构:顺序存储结构和链式存储结构。
- 顺序映像的特点是借助元素在存储器中的相对位置来表示数据元素之间的逻辑关系。
- 非顺序映像的特点是借助指示元素存储地址的指针(pointer)表示数据元素之间的逻辑关系。
用两个字长的洧川表示一个实数,用地址相邻的的4个字长的位串表示一个复数,z1 = 3.0-2.3i;z2 = -0.7+4.8i。链式虚部用指针0415表示虚部和实部关系,0415是虚部的存储地址。
数据的逻辑结构和物理结构是密切相关的两个方面,任何一个算法的设计取决于选定的数据(逻辑)结构,而算法的实现依赖于采用的存储结构。
书中在高级程序语言层次上讨论数据结构操作,不能直接以内存地址来描述存储结构,借用语言中的“数据类型”来描述:如一维数组——描述顺序存储结构;指针——描述链式存储结构;C语言看成执行C指令和C数据类型的虚拟处理器,书中的存储结构是数据类型在C处理器终中的表示,为虚拟存储结构。
数据类型(data type):数据类型是一个值的集合和定义在这个值集上的一组操作的总称。例如C的整型变量,值集为某个区间上的整数(区间大小依赖于不同的机器),定义在其上的操作为加、减、乘、除和取模等算术运算。
按“值”的不同特性,高级程序语言中的数据类型可分为两类:
- 一类是非结构的原子类型。原子类型的值是不可分解的,例如C语言中的基本类型(整型、实型、子符型和枚举类型)指针类型和空类型。
- 另一类是结构类型。结构类型的值是由若干成分按某种结构组成的,因此是可以分解的,并且它的成分可以是非结构的也可以是结构的。数组的值由若干分量组成,每个分量可以是整数,也可以是数组等。
在某种意义上,数据结构可以看成是“一组具有相同结构的值”,则结构类型可以看成由一种数据结构和定义在其上的一组操作组成。
书中涉及的抽象数据类型ADT定义、表示、具体实现见书7-13页。
抽象数据类型的标准格式
ADT 抽象数据类型名
Data
数据元素之间逻辑关系的定义
Operation
操作
endADT
1.3 算法和算法分析
算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作;此外,一个算法还具有下列5个重要特性:
- 有穷性:一个算法必须总是(对任何合法的输入值)在执行有穷步之后结束,且每一步都可在有穷时间内完成。
- 确定性:算法中每一条指令必须有确切的含义,读者理解时不会产生二义性。并且,在任何条件下,算法只有唯一的一条执行路径,即对于相同的输入只能得出相同的输出。
- 可行性:一个算法是能行的,即算法中描述的操作都是可以通过已经实现的基本运算执行有限次来实现的。
- 输入:一个算法有零个或多个的输入,这些输入取自于某个特定的对象的集合。
- 输出:一个算法有一个或多个的输出,这些输出是同输入有着某些特定关系的量。
1.3.1 算法设计的要求
通常设计一个“好”的算法应考虑达到以下目标。
- 正确性(correctness)算法应当满足具体问题的需求,得到正确答案。它至少应当包括对于输人、输出和加工处理等的明确的无歧义性的描述。
- 算法程序没有语法错误。
- 算法程序对于合法输入能够产生满足要求的输出。
- 算法程序对于非法输入能够产生满足规格的说明。
- 算法程序对于故意刁难的测试输入都有满足要求的输出结果。
- 可读性(readability) 算法主要是为了人的阅读与交流,其次才是机器执行。可读性好有助于人对算法的理解;晦涩难懂的程序易于隐藏较多错误,难以调试和修改。
- 健壮性(robustness) 当输入数据非法时,算法也能适当地做出反应或进行处理,而不会产生莫明其妙的输出结果。
- 效率与低存储量需求 通俗地说,效率指的是算法执行的时间。对于问一个问题如果有多个算法可以解决,执行时间短的算法效率高。存储量需求指算法执行过程中所需要的最大存储空间。效率与低存储量需求这两者都与问题的规模有关。
1.3.2 算法效率的度量
算法执行时间需通过依据该算法编制的程序在计算机上运行时所消耗的时间来度量。而度量一个程序的执行时间通常有两种方法。
(1) 事后统计的方法:因为很多计算机内部都有计时功能,有的甚至可精确到毫秒级,不同算法的程序可通过一组或若干组相同的统计数据以分辨优劣。 但这种方法有两个缺陷:
- 一是必须先运行依据算法编制的程序;
- 二是所得时间的统计量依赖于计算机的硬件、软件等环境因素,有时容易掩盖算法本身的优劣。
(2) 事前分析估算的方法:一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 依据的算法选用何种策略;
- 问题的规模,例如求100以内还是1000以内的素数;
- 书写程序的语言,对于同一个算法,实现语言的级别越高,执行效率就越低;
- 编译程序所产生的机器代码的质量;
- 机器执行指令的速度。
显然,同一个算法用不同的语言实现,或者用不同的编译程序进行编译,或者在不同的计算机上运行时,效率均不相同。这表明使用绝对的时间单位衡量算法的效率是不合适的。撇开这些与计算机硬件、软件有关的因素,可以认为一个特定算法“运行工作量”的大小,只依赖于问题的规模(通常用整数量n表示),或者说,它是问题规模的函数。
一个算法是由控制结构(顺序、分支和循环3种)和原操作(指固有数据类型的操作)构成的,则算法时间取决于两者的综合效果。为了便于比较同一问题的不同算法,通常的做法是,从算法中选取一种对于所研究的问题(或算法类型)来说是基本操作的原操作,以该基本操作重复执行的次数作为算法的时间量度。
例如,在如下所示的两个 NxN矩阵相乘的算法中,“乘法”运算是“矩阵相乘问题”的基本操作。整个算法的执行时间与该基本操作(乘法)重复执行的次数n成正比,记作T(n)=O(n^3)。
for(i=1; i<=n; ++i)
for(j=1; j<=n; ++j){
c[i][j] = 0;
for (k=1; k<=n; ++k)
c[i][j] += a[i][k] * b[k][j];
}
一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数f(n),算法的时间量度记作
T(n) = O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称做算法的渐近时间复杂度(asymptotic time complexity),简称时间复杂度。
显然,被称做问题的基本操作的原操作应是其重复执行次数和算法的执行时间成正比的原操作,多数情况下它是最深层循环内的语句中的原操作,它的执行次数和包含它的语句的频度相同。语句的频度(frequency count)指的是该语句重复执行的次数,例如,在下列3个程序段中:
(a){++x; s=0;}
(b)for (i=1; i<=n; ++i) {++x; s+=x;}
(c)for (j=1; j<=n; ++j)
for (k=1; k=n; ++k) {++x; s+=x;}
含基本操作“x增1”的语句的频度分别为1、n和n^2,则这3个程序段的时间复杂度分别为O(1)、O(n)和O(n^2),分别称为常量阶、线性阶和平方阶。算法还可能呈现的时间复杂度有对数阶O(log n)、指数阶O(2^n)等。不同数量级时间复杂度的性状如图1.7所示。从图中可见,我们应该尽可能选用多项式阶O(n^k)的算法,而不希望用指数阶的算法。
如何分析一个算法的时间复杂度,即如何推导大0阶呢?
- 用常数1取代运行时间中的所有加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且不是1,则去除与这个项相乘的常数。
- 得到的最后结果就是大0阶。
一般情况下,对一个问题(或一类算法)只需选择一种基本操作来讨论算法的时间复杂度即可,有时也需要同时考虑几种基本操作,甚至可以对不同的操作赋予不同权值,以反映执行不同操作所需的相对时间,这种做法便于综合比较解决同一问题的两种完全不同的算法。
由于算法的时间复杂度考虑的只是对于问题规模n的增长率,则在难以精确计算基本操作执行次数(或语句频度)的情况下,只需求出它关于n的增长率或阶即可。例如,在下列程序段中:
for (i=2; i<n; ++ i)
for (j=2; j<= i-1; ++j){++x; a[i][j] = x;}
语句++x的执行次数关于n的增长率为n^2,它是语句频度表达式(n-1)(n-2)/2中增长最快的项。
int i,j,n = 100;
for (i=0; i<n; i++)
{
for(j=i; j<n; j++)
{.....}
}
分析:i=0,内循环n次,i=1,内n-1次,......i= n-1时,内循环1次,总的为 n+(n-1)+(n-2)+...+1 = n(n+1)/2 = n^2/2+n/2,为 n^2 阶 O(n^2).
int i = 1,n = 100;
while(i<n){
i = i*2;
}
每次i*2之后,离n更进一步,假设有x个2相乘后大于或等于n,则退出循环。于是2^x = n。得到x = log(2)n ,时间复杂度O(logn).
1.3.3 函数调用的时间复杂度分析
int i,j;
for(i = 0; i<n; i++){
function(i);
}
void function(int count){
printf("%d",count);
}
function的时间复杂度是O(1),整体时间复杂度是循环的次数O(n)。
int i,j;
for(i = 0; i<n; i++){
function(i);
}
void function(int count){
int j;
for(j=count;j<n;j++){
printf("%d",count);
}
}
function内部的循环次数随着count增加(接近n)而减少,算法时间复杂度O(n^2) 。
常用的时间复杂度所耗费的时间从小到大依次是:
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < O(n^3) < O(2^n) < O(n! ) < O(n^n).
有的情况下,算法中基本操作重复执行的次数还随问题的输入数据集不同而不同。例如在下列起泡排序的算法中:
void bubble_sort( int a[ ],int n){
//将a中整数序列重新排列成自小至大有序的整数序列。
for (i=n-1, change=TRUE; i>=1&&change; --i){
change = FALSE;
for (j=0; j<i; ++j)
if (a[j] > a[j+1]){
a[j]←→a[j+1];
change=TRUE;
}
}
}//bubble- sort
“交换序列中相邻两个整数”为基本操作。当a中初始序列为自小至大有序,基本操作的执行次数为0;当初始序列为自大至小有序时,基本操作的执行次数为n(n-1)/2。对这类算法的分析,一种解决的办法是计算它的平均值,即考虑它对所有可能的输入数据集的期望值,此时相应的时间复杂度为算法的平均时间复杂度。如假设a中初始输入数据可能出现 n! 种的排列情况的概率相等,则起泡排序的平均时间复杂度Turg(n)=O(n^2)。
然而,在很多情况下,各种输人数据集出现的概率难以确定,算法的平均时间复杂度也就难以确定。因此,另一种更可行也更常用的办法是讨论算法在最坏情况下的时间复杂度,即分析最坏情况以估算算法执行时间的一个上界。例如,上述起泡排序的最坏情况为a中初始序列为自大至小有序,则起泡排序算法在最坏情况下的时间复杂度T(n)=O(n^2)。在本书以后各章中讨论的时间复杂度,除特别指明外,均指最坏情况下的时间复杂度。
实践中我们可以把事前估算和事后统计两种办法结合起来使用。以两个矩阵相乘为例,若上机运行两个10×10 的矩阵相乘,执行时间为12ms,则由算法的时间复杂度T(n)=O(n^3)可估算两个31×31的矩阵相乘所需时间大致为(31/10)^3 · 12ms≈358ms。
1.3.4 算法的存储空间需求
类似于算法的时间复杂度,书中以空间复杂度(space complexity)作为算法所需存储空间的量度,记作S(n) =O(f(n))。 其中n为问题的规模(或大小)。
一个上机执行的程序除了需要存储空间来寄存本身所用指令、常数、变量和输入数据外,也需要一些对数据进行操作的工作单元和存储一些为实现计算所需信息的辅助空间。若输入数据所占空间只取决于问题本身,和算法无关,则只需要分析除输入和程序之外的额外空间,否则应同时考虑输入本身所需空间(和输入数据的表示形式有关)。若额外空间相对于输入数据量来说是常数,则称此算法为原地工作。又如果所占空间量依赖于特定的输入,则除特别指明外,均按最坏情况来分析。
内容来源参考:
严蔚敏,吴伟民.数据结构(C语言版).[M].北京: 清华大学出版社.
【C语言描述】《数据结构和算法》_哔哩哔哩_bilibili鱼C 小甲鱼 【C语言描述】《数据结构和算法》【C语言描述】《数据结构和算法》_哔哩哔哩_bilibili