早期人们都把计算机理解为数值计算工具,就是感觉计算机当然是用来计算的,所以计算机解决问题,应该是先从具体问题中抽象出一个适当的数据模型,设计出一个解此数据类型的算法,然后再编写程序,得到一个实际的软件。
可现实中,我们更多的不是解决数值计算的问题,而是需要一些更科学有效的手段(比如表、树和图等数据结构)等帮助,才能更好地解决问题。
所以
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。
说到数据结构是什么,我们得先来谈谈什么叫做数据。
数据结构中,有5个基本概念:数据、数据元素、数据项、数据对象和数据结构。
他们之间的关系如下图所示:
具体到代码上,参考如下代码:
//声明一个结构体类型 struct Teacher{ //一种数据结构 char *name; //数据项--名字 char *title; //数据项--职称 int age; //数据项--年龄 }; int main(int argc, const char * argv[]) { struct Teacher t1; //数据元素; struct Teacher tArray[10]; //数据对象; return 0; } 复制代码
是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机处理的符号集合。数据不仅仅包括整型、实数等数值类型,还包括字符及声音、图像、视频等非数值类型类型。
——《大话数据结构》
比如我们平时使用搜索殷勤,有网页、mp3、图片、视频等分类。MP3 就是声音数据
数据的特点:
组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理,也被称为记录。
比如,在人类中,人就是数据元素。
而在动物类中,牛、马、羊、鸡等动物就是动物类的数据元素了。
一个数据元素由若干数据项组成。
比如人这样的数据元素,可以有眼耳鼻舌口这些数据项,也有姓名、年龄、性别、出生地址、电话等数据项。
数据项上数据不可分割的最小单位。
性质相同的数据元素的集合,是数据的子集。
性质相同的意思,是指数据元素具有相同数量和类型的数据项,比如,人都有姓名、生日、性别等相同的数据项。
是相互之间存在一种或多种特定关系的数据元素的集合。
在现实世界中,不同数据元素之间不是独立的,而是存在特定的关系,我们将这些关系称为结构。而在计算机中,数据元素并不是孤立、杂乱无序的,而是具有内在联系的数据集合。数据之间存在的一种或多种特定关系,也就是数据的组织形式。
按照观点的不同,我们把数据结构分为逻辑结构和物理结构。
是指数据对象中数据元素之间的相互关系
逻辑关系按照类别分为线性结构与非线性结构:
线性结构中的数据元素是一对一的关系
非线性结构中的数据元素是一对多或多对多的关系。
是指数据的逻辑结构在计算机中的存储形式。
数据元素的存储形式有两种:顺序存储和链式存储。
把数据元素存放在抵制连续的存储单元里,其数据间的逻辑关系和物理关系是一致的。
如下图所示:
把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。
数据元素的存储关系并不能反映其逻辑关系,因此需要用一个指针存放数据元素的地址,这样通过地址就可以找到相关联数据元素的位置,如图所示:
数据类型:是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。
数据类型是按照值的不同进行划分的。在高级语言中,每个变量、常量的表达式都有各自的取值范围。类型就用来说明变量或表达式的取值范围和所能进行的操作。
在C语言中,按照趣致的不同,数据类型可以分为两类:
抽象是指抽出事物具有的普遍型的本质。我们对已有的数据类型进行抽象,就有了抽象数据类型。
抽象数据类型(Abstract Data Type:ADT):
是指一个数学模型及定义在该模型上的一组操作。
抽象的意义在于数据类型的数字抽象特性。
抽象数据类型体现了程序设计中问题分解、抽象和信息隐藏的特性。
是解决特定问题对求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
什么是算法?算法是描述解决问题的方法。
自唐代以来,历代更有许多专门论述“算法”的专著:
而英文名称“algorithm”来自于9世纪波斯数学家花拉子米(比阿勒·霍瓦里松,波斯语:خوارزمی ,拉丁转写:al-Khwarizmi),因为比阿勒·霍瓦里松在数学上提出了算法这个概念。“算法”原为“algorism”,即“al-Khwarizmi”的音转,意思是“花拉子米”的运算法则,在18世纪演变为“algorithm”。
欧几里得算法被人们认为是史上第一个算法。
算法具有五个基本特征:输入、输出、有穷性、确定性和可行性。
算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反应问题的需求、能够得到问题的正确答案。
大概分为以下四个层次:
以上这四层含义里,层次1 要求最低,而层次4 时最困难的,实际开发中,我们几乎不可能逐一验证所有的输入都能得到正确的结果。
算法设计的另一目的是为了便于阅读、理解和交流。
可读性时算法(也包括实现它的代码)好坏很重要的标志。
当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
设计算法应该尽量满足时间效率高和存储量低的特点。
在生活中,人们都希望花最少的钱,用最短的时间,办最大的事,算法也是一样的思想,最好用最少的存储空间,办成同样的事——就是好的算法。
通过对算法的数据测试,利用计算机的计时功能,来计算不同算法的效率是高还是低。
这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编织的程序的运行时间进行比较,从而确定算法效率的高低。
在计算机程序编制前,依据统计方法对算法进行估算。
我们发现,一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指输入量的多少。
我们看看两种求和的算法:
第一种算法
int i, sum = 0 n = 100; /* 执行 1次*/ for(i = 1; i <= n; i++) /* 执行 n + 1 次*/ { sum += i; /* 执行 n 次*/ } print("%d", sum); /* 执行 1 次*/ 复制代码
第二种算法
int sum = 0, n = 100; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ printf("%d", sum); /* 执行 1次*/ 复制代码
显然,第一种算法,执行了 1 + (n+1) + n + 1 次 = 2n + 3 次
而第二种算法是1+1+1 = 3 次。算法好坏显而易见。
最终,在分析程序的运行时间时,最重要的是吧程序看成是独立于程序设计语言的算法或一系列步骤。
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n) 随n 的变化情况并确定T(n) 的数量级。
算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模 n 的增大,算法执行时间的增长率和 f(n) 的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。其中f(n) 是问题规模 n 的某个函数。
大写O() 来体现算法复杂度的激发,我们称之为大O记法。
上面求和算法的时间复杂度,分别为O(n) 和 O(1)
下面这个算法,就是刚刚的第二个算法(高斯算法)。
int sum = 0, n = 100; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ printf("%d", sum); /* 执行 1次*/ 复制代码
这个算法的运行次函数是 f(n) = 3。根据我们推导大O阶的方法,第一步就是把常数3 改为1,再加上它没有最高阶项,所以这个算法的时间复杂度为O(1)
如果这里的第二行 sum = (1 + n) * n / 2 有10句,会是怎么样?
int sum = 0, n = 100; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ sum = (1 + n) * n/2; /* 执行 1次*/ printf("%d", sum); /* 执行 1次*/ 复制代码
事实上,无论n 为多少,上面的代码就说3次和12次执行的差异。这种与问题的大小无关(n) 的多少,执行时间恒定的算法,我们称之为具有 O(1) 的时间复杂度
我们要分析算法的复杂度,关键就是要分析循环结构的运行情况。
下面这段代码,它的循环的时间复杂度为O(n),因为循环体中的代码须要执行 n 次
int i; for(i = 0; i < n; i++) { / * 时间复杂度为O(1)的程序步骤序列 */ } 复制代码
int count = 1; while (count < n) { count = count * 2; /* 时间复杂度为 O(1) 的程序步骤序列*/ } 复制代码
上面这行代码,由于每次 count 乘以 2 以后,就距离 n 更近了一份。
也就是说,有多少个2 相乘后大于 n,则会推出循环。
由 2x= n 得到 x = log2n 。所以这个循环的时间复杂度为O(logn)。
下面的例子说一个循环嵌套,它的内循环时间复杂度为O(n)
in i,j; for(i = 0; i < n; i++) { for (j = 0; j < n; j++) { / * 时间复杂度为 O(1) 的程序步骤序列*/ } } 复制代码
而对于外层的循环,不过是内部这个时间复杂度 O(n) 的语句,再循环 n 次。所以这段代码的时间复杂度为 O(n2)。
常见的时间复杂度如表所示
执行次数函数 | 阶 | 非正式术语 |
---|---|---|
12 | O(1) | 常数阶 |
2n + 3 | O(n) | 线性阶 |
3n2 + 2n + 1 | O(n2) | 平方阶 |
5 log2n + 20 | O(logn) | 对数阶 |
2n + 3n log2n + 19 | O(nlogn) | nlogn 阶 |
6n3 + 2 n2 + 3n + 4 | O(n3) | 立方阶 |
2 n | O(2n) | 指数阶 |
常用的时间复杂度所消耗的时间从小到大依次是:
O(1) < O(logn) < O(n) < O(nlogn) < O(n2) < O(n3) < O(2n) < O(n!) < O(nn)
我们查找一个由 n 个随机数字数组中的某个数组,最好的情况是第一个数字就是,那么算法的时间复杂度为O(1),但也有可能这个数字就在最后一个位置上待着,那么算法的复杂度为O(n),这是最坏的一种情况了。
最坏情况运行时间是一种保证,那就是运行时间将不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏时间的运行时间。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。也就是说,我们运行一段程序代码时,实习完看到平均运行时间的。可现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量的实验数据后估算出来的。
对算法的分析,一种方法是计算所有情况的平均值,这种时间复杂度的计算方法称为平均时间复杂度。
另一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般在没有特殊说明的情况下,都是指最坏时间复杂度。
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n) = O(ƒ(n)),其中,n 为问题的规模,ƒ(n) 为语句关于 n 所占存储空间的函数。
通常,我们都适用“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。当不用限定词地使用“复杂度”时,通常都是指时间复杂度。