阅读 620

001--数据结构与算法之美(基础)

1.1 数据结构起源

早期人们都把计算机理解为数值计算工具,感觉计算机就是为了解决复杂计算问题.所以计算机解决问题,应该是先从具体问题中抽象出一个适当的数据模型,设计出一个解决此数据模型的算法,然后才开始编写程序,从而实现一个解决问题的软件.

但是,现实开发中,我们不单纯的只是解决数值计算问题,而是需要更加一些高效的手段来解决问题.例如需要更合适的数据结构,树,表,图等数据结构来更好的处理问题. 数据结构是一门研究非数值计算的程序设计问题中操作对象.

数据结构是一门独立的学科, 也可以看出它在计算机专业的地位. 它不是算法的附属品. 好的程序设计 = 数据结构 + 算法.

数据结构和算法是不可分割的. 在你前往高级开发者的路上,数据结构和算法一定是必须啃下来的硬骨头.

数据结构和算法不仅是计算机学生的必修课,更是开发者们职业生涯必修课. 你需要不断的训练与学习来保持对算法的敏感度.

###1.2 基本概念与术语

在谈数据结构之前,我们先来谈谈数据. 正所谓"巧妇难为无米之炊",再强大的计算机,如果没有数据. 也是毫无作用.

数据结构中最基本的5个概念: 数据,数据元素,数据项,数据对象,数据结构;

1.2.1 数据

数据: “是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别,并输入给计算机处理的符号集合。数据不仅仅包括整型、实型等数值类型,还包括字符及声音、图像、视频等非数值类型。”

例如,Mp3就是声音数据,图片就是图像数据. 而这些数据,其实只不过是"符号". 这些符号有2个前提:

  • 可以输入到计算机中
  • 能被计算机程序处理.

对于从后端返回的整型,浮点型数据,可以直接进行数值计算. 对于字符串类型的数据,我们就需要进行非数值的处理. 而对于声音,图像,视频这样的数据,我们需要通过编码的手段将其变成二进制数据进行处理.

1.2.2 数据元素

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

例如,我们生活的圈子里.什么叫数据元素了? 人Person ,汽车Car 等.

1.2.3 数据项

数据项: 一个数据元素可以由若干数据项组成.

比如,Person 数据元素,可以为分解为眼睛,耳朵,鼻子,嘴巴,手臂这些基本的数据项,也可以从另外的角度拆解成姓名,年龄,性别,出生地址,出生日期,联系电话等数据项. 那么你如何拆解数据项, 要看你的项目来定.

数据项是数据不可分割的最小单位. 在我们的课程中,我们把数据定位为最小单位.这样有助于我们更好理解以及解决问题.

1.2.4 数据对象

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

那么什么叫性质相同? 是指数据元素具有相同数量和类型的数项. 类似数组中的元素保持性质一致.

1.2.5 数据结构

结构,简单理解就是关系. 比如分子结构,就是说组成分子原子的排列方式. 不同数据元素之间不是独立的,而是存在特定的关系.我们将这些关系成为结构. 那么数据结构是什么? 数据结构是相互之间存在一种或多种特定关系的数据元素的集合.

代码片段(1)

//
//  main.c
//  001--数据结构基本术语
//
//  Created by CC老师 on 2019/8/12.
//  Copyright © 2019年 CC老师. All rights reserved.
//

/*
 数据: 程序的操作对象,用于描述客观事物.
 数据的特点: 1️⃣ 可以输入到计算机 2️⃣ 可以被计算机处理
 
 数据项: 一个数据元素由若干数据项组成
 数据元素: 组成数据的对象的基本单位
 数据对象: 性质相同的数据元素的集合(类似于数组)
 
 结构: 数据元素之间不是独立的,存在特定的关系.这些关系即是结构;
 数据结构:指的数据对象中的数据元素之间的关系
 */
#include <stdio.h>

//声明一个结构体类型
struct Teacher{     //一种数据结构
    char *name;     //数据项--名字
    char *title;    //数据项--职称
    int  age;       //数据项--年龄
    
};


int main(int argc, const char * argv[]) {
   
    struct Teacher t1;     //数据元素;
    struct Teacher tArray; //数据对象;
    
    t1.age = 18;       //数据项
    t1.name = "CC";    //数据项
    t1.title = "讲师";  //数据项
    
    printf("老师姓名:%s\n",t1.name);
    printf("老师年龄:%d\n",t1.age);
    printf("老师职称:%s\n",t1.title);
    
   
    return 0;
}

复制代码

1.3 逻辑结构与物理结构

根据视角不同,我们将数据结构分为2种: 逻辑结构与物理结构;

1.3.1 逻辑结构

逻辑结构: 指的是数据对象中的数据元素之间的相互关系. 逻辑结构分为四种: 集合结构,线性结构,树形结构,图形结构. 具体采用什么的数据结构,是需要根据实际业务需求来进行合理的设计.

  • 集合结构

集合结构: 集合结构中的数据元素除了同属于一个集合外,它们之间没有其他关系. 各个数据元素是"平等"的. 它们的共同属性是:"同属于一个集合".

例如: 动物园,就是一个集合; 河马,熊猫,狮子,老虎,长颈鹿他们就是数据元素. 它们共同的属性就是:"同属于动物这个集合";

  • 线性结构

线性结构: 线性结构中的数据元素之间是一对一的关系.常用的线性结构有:线性表,栈,队列,双队列,数组,串

  • 树型结构

树型结构: 重要的非线性数据结构。树形数据结构可以表示数据表素之间一对多的关系.树型结构中的数据元素之间存在一种一对多的层次关系. 常见的树形结构: 二叉树,B树,哈夫曼树,红黑树等.

  • 图形结构

图形结构: 图形结构的数据元素是多对多的关系. 常见的图形结构: 邻近矩阵,邻接表.

1.3.2 物理结构

物理结构,别称"存储结构". 顾名思义,指的是数据的逻辑结构在计算机的存储形式.

设计好逻辑数据结构之后,数据的存储也是非常重要的. 数据存储结构应该正确反映数据元素之间的逻辑关系.这才是关键! 如何存储数据元素之间的逻辑关系,是实现物理结构的重点和难点.

数据元素的存储结构形式有2种: 顺序存储和链式存储;

  • 顺序存储结构

顺序存储结构: 是指把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系是一致的.

解读:

这样的存储方式,实际非常简单. 可以理解为排队占位. 按照顺序,每个一段空间. 我们所学习且使用的数组就是典型顺序存储结构. 当你在计算机建立一个有6个整型数据的数组时,计算机便在内存中找到一片空地.按照一个整型所占空间大小乘以6.开辟一段连续的内存空间. 然后将数据依顺序放在位置上,依次排放.

  • 链式存储结构

了解到顺序存储结构,如果一切数据都有规律且简单使用顺序存储结构当然是最合适不过的. 但是实际上,总是有复杂的情况发生. 对于经常且时差发生变化的数据结构,使用顺序存储就会非常不科学. 那该如何解决了?

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

显然,链式存储就灵活多了. 数据存储在哪里不重要的,只要有一个指针存放了相应的地址就能找到它了.

总结 逻辑结构是面向问题的,而物理结构就是面向计算机的. 其基本的目标就是将数据以及逻辑关系存储到计算机的内存中.

1.4 抽象数据类型

1.4.1 数据类型

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

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

  • 原子类型: 是不可以在分解的基本数据类型,包含整型,浮点型,字符型等;
  • 结构类型: 由若干类型组合而成,是可以再分解的.例如,整型数组就是由若干整型数据组成的.

1.4.2 抽象数据类型

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

抽象数据类型: 是指一个数学模型以及定义在该模型上的一组操作; 例如,我们在编写计算机绘图软件系统时,经常会使用到坐标. 也就是说,会经常使用x,y来描述横纵坐标. 而在3D系统中,Z深度就会出现. 既然这3个整型数字是始终出现在一起. 那就可以定义成一个Point的抽象数据类型. 它有x,y,z三个整型变量. 这样开发者就非常方便操作Point 数据变量.

抽象数据类型可以理解成实际开发里经常使用的结构体和类; 根据业务需求定义合适的数据类型以及动作.

二.算法

算法: 是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作.

2.1 算法与数据结构的关系

来自网络,侵权删除

什么是算法: 解决特定问题的求解步骤的描述; 而数据结构如果脱离算法,或者算法脱离数据结构都是无法进行的. 所以在大学课程里,即便单独算法课程里也涵盖了数据结构的内容.

程序设计 = 数据结构 + 算法

2.2 两种算法比较

大家在大学期间,学习C语言; 大多数遇到的第一个算法问题便是: 求解从1-N相加的结果; 而最简单的办法则是使用C语言模拟从1累积到N这个过程,从而得到结果;

这样的解决方案,是算法吗? 我可以肯定告诉大家是的. 这就是算法; 但此时同学们,也许会想到用数学的方式解决问题. 比如,使用等差数列解决.

故事: 使用这样的方式解决数学问题,是在18世纪德国的数学家高斯. 在小学时,被留校. 老师要求孩子们从1累积到100. 当时想出来的办法.

对比以上2种方式,如果不仅仅是累积到100,而是一千. 第一种方式,显然需要计算机循环1千次来模拟数学计算,而第二种方式肯定要比第一种来的快.

到底如何评价一个算法优劣了? 接下来我们花时间来探讨.

2.3 算法定义

什么是算法? 算法就是描述解决问题的方法; 通过刚刚的例子,我们可以知道对于一个给定的问题,是可以有多种算法来解决;

那我想问问,有没有通用算法? 换过头来,有没有包治百病的药?

现实开发中会有成千上万的问题,算法也是千变万化,没有通用的算法可以解决所有问题. 甚至解决一个小问题,很优秀的算法却不一定非常适合它;

每个人对问题的解决方案是不一样的,但是我们可以根据一些参考来评估算法的优劣;

2.4 算法的特性

算法必须具备几个基本特性: 输入,输出,有穷性,确定性和可行性;

  • 输入输出

输入输出,很好理解. 在解决问题时必须有已知条件,当然有些算法可能没有输入. 但是算法至少有一个或多个输出.否则没有输出,没有结果.你用这个算法干吗?

  • 有穷性

有穷性: 指的是算法在执行有限的步骤之后,自动结束而不会出现无限循环,且每一个步骤在可接受的时间内完成.

  • 确定性

确定性: 算法的每一个步骤都具有确定的含义,不能出现二义性; 算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果.

  • 可行性

可行性: 算法的每一步都必须是可行的,换句话说,每一步都能通过执行有限次数完成.

2.5 算法设计要求

  • 正确性

算法的正确性是指算法至少应该具有输入,输出和加工处理无歧义性,能正确反映问题的需求,能够得到问题的正确答案;

正确分为4个层次: (1).算法程序没有语法错误; (2).算法程序对于合法的输入数据能够产生满足要求的输出结果; (3).算法程序对于非法的输入数据能够得出满足规格说明的结果;(4).算法程序对于精心选择的,甚至刁钻的测试数据都有满足要求的输出结果;

  • 可读性

可读性: 算法设计的另一个目的是为了便于阅读,理解和交流;

可读性高有助于人们理解算法,晦涩难懂的算法往往隐含错误,且不容易发现并且难于调试和修改;

注意, 不要犯初学者的错误; 认为代码越少,就越牛逼! 如果有这样的想法, 在团队协作的今天.不再是个人英雄主义的时代!

可读性是算法好坏的很重要的标志!

  • 健壮性

一个好的算法还应该能对输入数据的不合法的情况做出合适的处理.考虑边界性,也是在写代码经常要做的一个处理; 比如,输入时间或者距离不应该是负数;

健壮性: 当输入数据不合法时,算法也能做出相关处理,而不是产生异常和莫名其妙的结果;

  • 时间效率高和存储量低

生活中,人们希望花最少的钱,用最短的时间,办最大的事. 算法也是一样的思想. 用最少的存储空间,花最少的时间,办成同样的事.就是好算法!

2.6 算法效率的度量方法

使用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列的因素:

  1. 算法采用的策略,方法;
  2. 编译产生的代码质量;
  3. 问题的输入规模;
  4. 机器执行指令的速度.

第一条,肯定是算法好坏的根本; 但是第2条以及第4条.都是要看编译器的支持以及硬件性能. 也就是说,抛开这些与计算机硬件,软件有关的因素,一个程序运行时间,依赖于算法的好坏和问题的输入规模. 所谓问题输入规模是指输入量的多少.

一个算法在执行过程中所消耗的时间取决于以下的因素:

  1. 算法所需数据输入时间
  2. 算法编译为可执行程序的时间
  3. 计算机执行每条指令所需的时间
  4. 算法语句中重复执行的次数

1)依赖于输入设备的性能,若是脱机输入,则输入数据的时间可以忽略不计。(2)(3)取决于计算机本身执行的速度和编译程序的性能

习惯上将算法语句重复执行的次数作为算法的时间量度

//x=x+1; 执行1次
void add(int x){
    x = x+1;
}

//x=x+1; 执行n次
void add2(int x,int n){
    for (int i = 0; i < n; i++) {
        x = x+1;
    }
}

//x=x+1; 执行n*n次
void add3(int x,int n){
    for (int i = 0; i< n; i++) {
        for (int j = 0; j < n ; j++) {
            x=x+1;
        }
    }
}
复制代码

我们在分析一个算法的运行时,重要的是把基本操作的数量与输入规模关联起来,即是基本操作的数量必须表示成输入规模的函数;

随着n值的越来越大,算法在时间效率上的差异会越来越大;

2.7 算法时间复杂度

2.7.1 算法时间复杂度定义

在进行算法分析时,语句的总执行次数T(n)是关于问题规模n的函数. 进而分析T(n)随着n变化情况并确定T(n)的数量级. 算法的时间复杂度,也就是算法的时间量度. T(n) = O(f(n)).

“它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。”

大写O( )来体现算法时间复杂度的记法,我们称之为大O记法。

刚刚的3个求和算法时间复杂度分别为O(1),O(n),O(n^2);

2.7.2 推导大O阶方法
  • 用常数1取代运行时间中所有加法常数;
  • 在修改后的运行次数函数中,只保留最高阶项;
  • 如果在最高阶项存在且不是1,则去除与这个项相乘的常数;
2.7.3 常数阶
//1+1+1 = 3
void testSum1(int n){
    int sum = 0;                //执行1次
    sum = (1+n)*n/2;            //执行1次
    printf("testSum2:%d\n",sum);//执行1次
}
复制代码

这个算法的运行次数函数是f(n) = 3;根据我们大O时间复杂度表示,第一步先把常数项改成1. 在保留最高阶时发现,它根本没有最高阶. 所以这个算法的视觉复杂度为O(1);

//1+1+1+1+1+1+1 = 7
void testSum2(int n){
    int sum = 0;                //执行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("testSum2:%d\n",sum);//执行1次
    
}
复制代码

事实上,无论常数n是多少.以上的代码执行3次还是7次的差异,执行时间恒定.我们的都称之为具有O(1)的时间复杂度.又称为"常数阶";

2.7.4 线性阶

线性阶的循环结构会复杂很多. 要确定某个算法的阶次,我们常常需要先确定某个特定语句或某个语句集的运行次数. 因此,我们要分析算法的复杂度,关键就是要分析循环结构的运行情况.

void add2(int x,int n){
    for (int i = 0; i < n; i++) {
        x = x+1;
    }
}
复制代码

这段代码的循环的视觉复杂度为O(n).

2.7.5 对数阶
int count = 1;
while(count < n){
	count = count * 2;
}
复制代码

count = count * 2 ; 每次执行这句代码,就会距离n更近一步; 也就是说, 有多少个2相乘后大于n,则会退出循环.

所以,这个循环时间复杂度为: O(logn).

2.7.6 平方阶
/x=x+1; 执行n*n次
void add3(int x,int n){
    for (int i = 0; i< n; i++) {
        for (int j = 0; j < n ; j++) {
            x=x+1;
        }
    }
}
复制代码

以上代码的循环次数为O(n^2);

//n+(n-1)+(n-2)+...+1 = n(n-1)/2 = n^2/2 + n/2
void testSum4(int n){
    int sum = 0;
    for(int i = 0; i < n;i++) //执行n次
        for (int j = i; j < n; j++) { //执行n-i次
            sum += j;
        }
    printf("textSum4:%d",sum);
}
复制代码

由于当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

i = 0,循环执行次数是 n 次。
i = 1,循环执行次数是 n-1 次。
i = 2,循环执行次数是 n-2 次。
...
i = n-1,循环执行的次数是 1 次。

换算成: 

result = n + (n - 1) + (n - 2) … + 1

被加数递减,抽象为一个等差数列求n项和的问题,公差为1,带入公式,Sn = n(a1 + an ) ÷2

result = (n(n+1))/2 =  (n^2+n)/2 = (n^2)/2 + n/2
复制代码

那我们采用大O阶方法,第一条,没有加法常数不予考虑; 第二条,只保留最高阶项,因此保留 (n^2) /2; 第三条,去除这个相乘的常数,也就是去除1/2. 最终这段代码的时间复杂度为O(n^2);

2.8 常用的时间复杂度

思考: 求得以下函数的时间复杂度

指数阶O( 2^n ) 和 阶乘阶 O(n!) 等除非是非常小的n值,否则哪怕n只有100,都会造成噩梦般的运行时间. 所以这种不切实际的算法时间复杂度,一般都不会考虑且讨论.

2.9 最坏情况与最好情况

例如,大家在查找一个n个随机数字数组中的某个数字,最好的情况是第一个数字就是, 那么算法的时间的复杂度为O(1). 但也有可能这个数字就在最后一个位置上,也就是算法时间复杂度为O(n). 这是最坏的情况了.

最坏的情况运行时间是一种保证, 那就是运行时间将不会比这更坏了. 在应用中,这是一种最重要的需求,通常除非特别指定,我们提到的运行时间都是最坏情况下的运行时间.

而,从平均运行时也就是从概率的角度来看,这个数字在每个位置的可能性都是相同的. 所以平均的查找时间为n/2次后会发现这个目标元素.

平均运行时间是所有情况中最有意义的,因为它是期望的运行时间. 现实中,平均运行时间都是通过一定数量的分析估算出来.

对于算法的分析,一种方法就是计算所有情况的平均值,这种时间复杂度的计算的方法称为平均时间复杂度. 另一种方法是计算最坏的情况下时间复杂度. 这种方法称为最坏时间复杂度. 一般没有特殊情况下,都是指最坏时间复杂度.

2.10 算法空间复杂度

算法设计有一个重要原则,即空间/时间权衡原则(space/time tradeoff)

算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记做: S(n) = n(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数.

一般情况下, 一个程序在机器上执行时,除了需要寄存本身所用的指令,常数,变量和输入数据外,还需要一些对数据进行操作的辅助存储空间. 其中,对于输入数据所占的具体存储量取决于问题本身,与算法无关. 这样**==只需要分析该算法在实现时所需要的辅助空间就可以了==**.

如果算法执行时所需要的辅助空间相对于输入数据量是一个常数,则成这个算法原地工作,辅助空间为O(1).

 程序空间计算因素:
 1. 寄存本身的指令
 2. 常数
 3. 变量
 4. 输入
 5. 对数据进行操作的辅助空间
 
 在考量算法的空间复杂度,主要考虑算法执行时所需要的辅助空间.
 空间复杂度计算:

 问题: 数组逆序,将一维数组a中的n个数逆序存放在原数组中.
 
复制代码

简单理解一下下面的程序段算法空间的复杂度;

int n = 5;
    int a[10] = {1,2,3,4,5,6,7,8,9,10};
    
    //算法实现(1)
    /*
    算法(1),仅仅通过借助一个临时变量temp,与问题规模n大小无关,所以其空间复杂度为O(1);
    */
    int temp;
    for(int i = 0; i < n/2 ; i++){
        temp = a[i];
        a[i] = a[n-i-1];
        a[n-i-1] = temp;
    }

    for(int i = 0;i < 10;i++)
    {
        printf("%d\n",a[i]);

    }
    
    
    //算法实现(2)
    /*
     算法(2),借助一个大小为n的辅助数组b,所以其空间复杂度为O(n).
    */
    int b[10] = {0};
    for(int i = 0; i < n;i++){
        b[i] = a[n-i-1];
    }
    for(int i = 0; i < n; i++){
        a[i] = b[i];
    }
    for(int i = 0;i < 10;i++)
    {
        printf("%d\n",a[i]);
        
    }
    
复制代码
  • 算法(1),仅仅通过借助一个临时变量temp,与问题规模n大小无关,所以其空间复杂度为O(1);

  • 算法(2),借助一个大小为n的辅助数组b,所以其空间复杂度为O(n).

注意,算法的空间复杂度指的并不是整个算法在内存占用空间,而是指的是该算法在实现时所需要的辅助空间就可以

对一个算法,其时间复杂度和空间复杂度往往会互相影响. 当追求一个较好的时间空间复杂度时,可能会导致占用较多的存储空间. 即可能会使用空间复杂度的性能变差.反之亦然. 不过,通常情况下,鉴于运算空间较为充足,人们都以算法时间空间复杂度作为算法优先的衡量指标.