在我看来,编程语言其实就是跟操作系统借用计算及空间资源去实现自己的业务。

数据结构绪论

数据结构历史

计算机的诞生之初,是用于数值运算的工具。
但现实生活中,我们很多情况下不是处理数值计算的问题,所以需要一些更科学有效的手段(比如表、树和图等数据结构)的来处理问题。所以数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。
1968年,美国的高德纳(Donald E. Knuth)教授在其写的《计算机程序设计艺术》第一卷《基本算法》中,较系统的阐述了数据的逻辑结构和存储结构及操作,开创了数据结构的课程体系。

程序设计 = 数据结构 +算法
而程序我认为就是,编程语言(程序)其实就是跟操作系统借用计算及空间资源去实现自己的业务。

基本概念和术语

数据:是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机处理的符号集合。

数据不仅包括整型,浮点等数值类型,还包括字符及声音、图像、视频等非数值类型。
整型、浮点,我们可以进行数值计算。
对应字符数据类型,我们可以进行非数值的处理,而声音、图像、视频等,我们可以通过编码的手段变成字符数据来处理。

数据元素:是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理。也被称为记录

数据项:一个数据元素可以由若干个数据项组成。数据项是数据不可分割的最小单位。

数据对象:是性质相同的数据元素的集合,是数据的子集。

数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。

逻辑结构:是指数据对象中数据元素之间的相互关系。

包括:
集合结构:集合结构中的数据元素除了同输一个集合外,它们之间没有其他关系。
线性结构:线性结构中的数据元素之间是一对一的关系。
树形结构:树形结构中的数据元素之间是一对多的层次关系。
图形结构:图形结构的数据元素是多对多的关系。

物理结构:是指数据在逻辑结构中在计算机中的存储形式。存储结构应正确翻译数据元素之间的逻辑关系,这才是最为关键的。如何存储数据元素之间的逻辑关系,是实现物理结构的重点和难点。

顺序存储结构:是把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系是一致的。
链式存储结构:是把数据元素存放在任意的存储单元里,这组存储单元可以是联系的,也可以不是连续的。该存储并不能反应其逻辑关系,因此需要用一个指针存放数据元素的地址,这样通过地址就可以找到相关数据元素的位置。

数据类型:是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。

简单的说: 数据类型 = 集合 + 操作
这个有点类似于Java的类的定义。

数据类型,这个概念的来源思路是:在计算机中,内存不是无限大的,你如果要计算一个如1+1=2、 3+5=8 这样的整型数字的加减乘除运行,显然不需要开辟很大的适合小数甚至字符运算的内存空间。于是计算机的研究者们就考虑,要对数据进行分类,分出多种数据类型。

在C语言中,按照取值的不同,数据类型可以分为两类:

原子类型:是不可以再分解的基本类型,包括整型、实型、字符等。在Java里,也叫基本类型。
结构类型:由若干个类型(可以是基本类型,也可以不是基本类型)组合而成,是可以再分解的。例如,整型数组是由若干个整型数据组成的。

举个栗子,在C语言中,变量声明的int a,b,这就意味着,在给变量a和b赋值时不能超过int的取值范围,变量a和b之间的运算只能是int类型所允许的运算。

抽象是指取出事务具有的普遍性的本质。它是抽出问题的特征而忽略本质的细节,是对具体事务的一个概括。抽象是一种思考问题的方式,它隐藏了繁杂的细节,只保留实现目标所必须的信息。

抽象数据类型(Abstract Data Type, ADT):我们对与已有的数据类型进行抽象,就有了抽象数据类型,正规的定义:是指一个数学模型及定义在该模型上的一组操作。

抽象数据类型的定义仅取决于它的一组逻辑特性,而与其在计算机内部如何表现和实现无关(这是说这概念与计算机细节无关,不是说是实现该抽象类型时无关),在计算机编程者的角度来看,“抽象”的意义在于数据类型的数学抽象特征。

算法现普遍认可的定义是:算法是解决特定问题求解步骤的描述,在计算机表现为指令的有限序列,并且每条指令表示一个或多个操作。
既然刚刚说抽象这个概念,那就是试着抽象“算法”,它有五个基本特征:输入、输出、有穷性,确定性和可行性。

算法设计的要求:正确性、可读性、健壮性、时间效率高和存储量低

根据上面说的游戏规则,科学家们在翻译现实世界的需求和计算机虚拟过程时,就提炼出一些高效的、不断被验证过的标准流程,这些流程就是我们所说的计算机算法。另外模块化是计算机思维中很重要的思想,而在软件中,那些模块就是一个个算法,因此算法构成了计算机科学的基础

上面说算法都是抽象的,那我们怎么对比算法的好坏呢?
比较容易想到的方法就是对算法进行数据测试,利用计算机的计时功能,来计算不同算的效率是高还是低,这种方法叫:事后统计方法。但缺点有,我们不妨看这里两个场景下,A、B两种算法的速度:

场景一:使用1万个数据进行测试,算法A的运行时间是1毫秒,算法B则需要运行10毫秒。
场景二:使用100万个数据测试,算法A的运行时间是10000毫秒,算法B运行6000毫秒。
这时我问你,哪一个算法好?如果单纯从第一个场景作判断,显然是算法A好,但是如果单纯看场景二,似乎算法B更好一点。按照人的思维,可能会说,数量小的时候算法A好,数量大的时候算法B好,然后还津津乐道自己懂得辩证法。计算机则不同,它比较笨,比较直接,不会辩证法,它要求你最好制定一个明确的标准(也就是上面说的五个基本特征的确定性),不要一会儿这样,一会儿那样。所以总结上述缺点有:
1.编写代码和准备数据很耗时间。2.依赖具体的计算环境。3。依赖具体的数据量。

对于方法一来判断算法的好坏好像不太客观,那么我们应该用什么作为标准来评判呢?
在计算机科学发展的早起,其实科学家们对这件事情也不很清楚。1965年哈特马尼斯(Juris Hartmanis)和斯坦恩斯(Richard Stearns)提出了算法复杂度的概念(二人后来因此因此获得了图灵奖),计算机科学家们开始考虑一个公平的、一致的评判算法好坏的方法。不过最早将复杂度严格量化衡量的是著名计算机科学家、算法分析之父高德纳(Don Knuth)。今天,全世界计算机领域都以高德纳的思想为准。

另外我们先看一下函数的渐近增长:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐近快于g(n)。

这个定义用来比较是时间复杂度O(n)。时间复杂度,我们先看这个例子
第一种算法

int i,sum=0,n=100;                                //执行1次
for(i=1;i<=n;i++){                                //执行n+1次
    sum = sum + i;                                //执行了n次
}
prinf("%d",sum);                                //执行1从

则这种算法执行了1+(n+1)+n+1次=2n+3次;
再来看第二种算法

int sum = 0, n = 100;                            //执行1次
sum = (1+n)*n/2;                                //执行1次
printf("%d",sum);                                //执行1次

第二种算法执行了1+1+1=3次。
这两种算法,都是计算1到100的和,去掉头尾和循环判断的开销,那么这两个算法其实就是n次和1次的差距。

测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。运行时间与这个计数成正比。

我们在分析一个算法的运行时间时,重要的是把(对运行时间有消耗)基本操作的数量与输入规模关联起来,即基本操作的数量表示为输入规模的函数。

这里“函数”是指数学上的函数概念,如y=f(x)
假设算法A的函数是4n+8, 和算法B的函数是2n^2+1,和它们对应的阶函数
次数 算法A(4n+8) 算法A'(n) 算法B(2n^2+1) 算法B'(n^2)
n=1 12 1 3 1
n=2 16 2 9 4
n=3 20 3 19 9
n=10 48 10 201 100
n=100 408 100 20001 10000
n=1000 4008 1000 2000001 1000000

根据上面的表格,我们拿算法A与算法B的比较,发现其比较结果跟 算法A'和算法B'的比较结果是一样。再根据函数的渐近增长的定义,我们发现,与最高次项相乘的常数并不重要。

再来看一个例子
次数 算法C(2n^2+3n+1) 算法C'(n^2) 算法D(2n^3+3n+1) 算法D'(n^3)
n=1 6 1 6 1
n=2 15 4 23 8
n=3 28 9 64 27
n=10 231 100 2031 1000
n=100 20231 10000 2000301 1000000

根据上面的表格,我们拿算法C与算法D的比较,发现其比较结果跟 算法C'和算法D'的比较结果是一样。再根据函数的渐近增长的定义,我们发现,最高次项的指数大的,函数随着n的增长,结果也会变得增长特别快。

最后看一个例子
次数 算法E(2n^2) 算法F(3n+1) 算法G(2n^2+3n+1)
--- ---- ----- -----
n=1 2 4 6
n=2 8 7 15
n=5 50 16 66
n=10 200 31 231
n=100 20 000 301 20 301
n=1,000 2 000 000 3001 2003001
n=10,000 200 000 000 30 001 200030001
n=100,000 20 000 000 000 300 001 20000300001
n=1,000,000 2 000 000 000 000 3 000 001 200 000 3000 001

根据上面的表格,我们发现当n值越来越大,3n+1已经没法和2n^2的结果比较,最终几乎可以忽略不计。而且算法E也越来月接近算法G。所以我们可以得出一个结论,判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数

综上关于算法的函数描述,我们可以得出:某个算法,随着n的增大,它会越来越优于另一个算法,或者越来越差于另一算法。

时间复杂度 在进行算法分析,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间亮度,记作:T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度。其中f(n)是问题规模n的某个函数。

分官方的名称,O(1)叫常数阶,O(n)叫线性阶,O(n^2)叫平方阶。

推导大O阶方法:

  1. 用常数1取代运行花四溅中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项。
  3. 如果最高阶项存在且不是1,则去除与这个项相乘、相加的常数。

常见的时间复杂度
图:

clipboard.png

最坏情况与平均情况

最坏情况是一种保证,不可能比它更坏了,我们可以理解为数学上的边界值或极限。在应用中,这是一种最重要的需求,通常,除非特别指定,提到的时间复杂度都是最坏情况的运行时间。
平均情况是所有情况中最有意义的,因为它是期望的运行时间。

算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作: S(n)=O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
如:算法执行时所需的辅助空间对于输入数据量而言是个常数,则称为算法为原地工作,空间复杂度为O(1)。

线性表

线性表(List):零个或多个数据元素的有限序列。
线性表的抽象数据类型定义:

ADT 线性表(List)
Data
    线性表的数据对象集合为{a1,a2,...,an},每个元素的类型均为DataType.其中出第一个元素a1外,每个元素有且只有一个直接前驱元素,除了最后一个元素an外,每个元素有且只有一个直接后继元素.数据元素之间的关系是一对一的关系.
Operation  
    InitList(*L): 初始化操作,建立一个空的线性表L, 如果变量定义为L *l, 则调用该方法为InitList(l);
    ListEmpty(L): 若线性表为空, 返回true, 否则返回false. 如果变量定义为L *l, 则调用该方法为ListEmpty(*l);
    ClearList(*L): 将线性表清空.
    GetElem(L,i,*e): 将线性表L中的第i个位置元素值返回给e.
    LocateELem(L,e): 在线性表L中查找与给定值e相等的元素,如果查找成功,返回该元素在表中序号表示成功; 否则,返回0表示失败.
    ListInsert(*L,i,e): 在线性表L中的第i个位置插入新元素e.
    ListDelete(*L,i,*e): 删除线性表L中第i个位置元素,并用e返回其值.
    LIstLength(L): 返回线性表L的元素个数

endADT

以上方法是线性表的基本操作, 是最基本的,如果想要线性表的更为复杂的操作,完全可以用这些基本操作的组合来实现.

线性表的顺序存储结构, 指的是用一段地址连续的存储单元依次存储线性表的数据元素.

线性表的链式存储结构,为了表示每个数据元素ai与其直接后继数据元素ai+1之间的逻辑关系,对数据元素ai来说,除了存储其本身的信息之外,还需存储一个指示其后继的信息(即直接后继的存储位置).我们把存储数据元素信息的域称为数据域,把存储直接后继位置的域称为指针域. 指针域中存储的信息称做指针或链.这两部分信息组成数据元素ai的存储映像,称为结点(Node).

哇,这好多概念,数据域,指针域,指针,链,存储映像,结点
n个结点(ai的存储映像)链结成一个链表,即为线性表(a1, a2,...,an)的链式存储结构,因此此链表的每个结点中只包含一个指针域,所以叫做单链表.

链表中的第一个结点的存储位置叫做头指针.
为了方便地对链表操作,会在单链表的第一个结点前附设一个结点,称为头结点,其包含的指针就叫做"头指针",指向第一个结点的存储位置.

单链表结构与顺序存储结构优缺点:

  1. 存储分配方式:
    1.1. 顺序存储结构用一段连续的存储单元一次存储线性表的元素. 对空间可能会有点浪费,或者需要频繁扩展.
    1.2. 单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素. 需额外空间存储指针信息.
  2. 时间性能:
    2.1. 查找,顺序存储结构是O(1),单链表O(n)
    2.1. 插入和删除: 顺序存储结构需要平均移动表长一半的元素,时间为O(n),单链表在找出某位置的指针后,插入和删除时间为O(1)
  3. 空间性能:
    3.1. 顺序存储结构预分配存储空间,分大了,浪费,分小了易发生上溢(扩展)
    3.2. 单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制.

循环链表(circular linked list):将单链表中终端结点的指针端由空指针改为指向头结点,就使整个单链表形成一个环,这种头尾相接的单链表称为单循环链表.

双向链表(double likned list):在单链表的每个节点中,再设置一个指向其前驱结点的指针域.

静态链表:用数组描述的链表叫做静态链表,或者叫游标实现法.

总结

线性表的链表存储结构和顺序存储结构,是后续数据结构(如栈、队列、树、图)的基础。当然术语和概念很重要,要时刻回顾并理解。
这线性表对应于Java的ArraysList、Vector和LinkedList。据我了解,之所以有了ArrayList,还要Vector的存在,除了是历史库之外,原因之一,那估计就是因为C++的vector对象,他们仅仅是数组存储结构的线性表,而list也仅仅是链表存储结构的。感觉是为了适应C++过来搞Java的人吧。这也引申出语言能够火的原因,如很多语言不是凭空诞生的,所以都是参考前人的经验,并且稍微兼容已有的语言,方便这些人员过度过来,让这些人员不太抗拒。 另外一个就是语言的专注的层面,如Java是面向对象,高级语言,那它所涉及的机器知识、操作系统就较少,如内存、CPU(线程)等,可能体会没那么深,相反设计模式、软件工程上,反而比较吃香。而对应的大数据、人工智能,这相对于机器知识要比较多,如何让几台机器配合工作,如何新增机器时,自动加入控制计算机资源等。这就涉及到操作系统、shell脚步比较多,从而与shell脚步比较接近的Python,和c比较接近的go语言就比较火了。这个我想可以专开一篇文章来说说。

参考:《大话数据结构》


电脑杂技集团
208 声望32 粉丝

这家伙好像很懂计算机~