第一章:绪论

数据结构的基本概念

开篇

说到数据结构,就要先来了解数据结构的概念,什么是数据结构呢?数据结构主要用来解决以下两个问题:

  • 如何用程序代码把现实世界的问题信息化
  • 如何用计算机高效地处理这些信息从而创造价值

人类社会的发展,迄今经历了和经历着三个浪潮:第一次浪潮为农业阶段,从约1万年前开始第二次浪潮为工业阶段,从17世纪末开始;第三次浪潮为正在到来的信息化阶段。——阿尔文·托夫勒

下面来看几个现实世界信息化的例子

  • 金钱从现金变成了微信支付宝里的一串数字
  • 饭馆吃饭从以往的排队取餐改为了现在的手机扫码排队
  • 以前交朋友都是现实中的,现在的朋友可以是网络上的

对于以上三个例子,我们可以尝试用程序语言来实现,对于金钱,我们可以设置一个浮点型变量来记录;对于一个排队的队列,我们也可以尝试用数组来表示,而对于微博来说,微博中有很多用户,用户之间的关系非常复杂,无法用已有知识来表示了,这就需要数据结构来帮忙。

在考研408中,一共有四门课程,分别是数据结构,操作系统,计算机组成原理,计算机网络,他们之间的关系如下图所示:
1694546188.jpg

数据结构的基本概念

数据:数据是信息的载体,是描述客观事物属性的数、字符及所有能输入到计算机中并被计算机程序识别和处理的符号的集合。数据是计算机程序加工的原料。
对于计算机来说,能够被计算机识别并记录的就是0和1这样的二进制
数据元素:数据元素是数据的基本单位,通常作为一个整体进行考虑和处理。
数据项:一个数据元素可由若干数据顶组成,数据项是构成数据元素的不可分割的最小单位。
我们要根据实际的业务需求来确定什么是数据元素和数据项。比如说对于一个排队系统来说,一个数据元素对应的就是一桌顾客,对于一桌顾客这样一个数据元素来说,我们需要记录其排队的号码,取号的时间,就餐人数等信息,在这个场景中,排队号码、取号时间、就餐人数分别就是三个数据项。换个例子,微博中,一个人的基本信息就可以看做一个数据元素,每一个人的数据信息又会包含昵称、简介、注册时间等信息,这些信息就是数据项,在这个例子中,注册时间是由年月日三个信息组合合成,我们也可以把这种由多个信息组合而成的项称为组合项。
1694547596.jpg
在学习数据结构之前,我们要先了解一下什么是结构,所谓的结构,就是个元素之间的关系,例如汉字中的左右结构,就是一个在左边,一个在右边,所以个
元素之间的关系就是结构

这里就引入数据结构的概念:**数据结构是相互之间存在一种或多种特定关系的数据元素的集合。**同时这里引入一个数据对象的概念:数据对象是具有相同性质的数据元素的集合,是数据的一个子集。
下面我们区分一下数据结构和数据对象,数据结构是数据间的关系,数据对象则不强调关系,只强调相同性质,下面用一个例子来做区分:
1694547732.jpg
我们以A和B两个门店的排队信息来作区分,A门店的排队信息存在一个先后关系,所以A门店的排队信息以及这种先后关系就是一个数据结构,B门店的排队信息和A门店的排队信息没有关系,但都是其数据元素都具有相同的性质,都用来表示排队信息,所以我们把所有门店的排队的顾客信息看做是一个数据对象,此时A门店和B门店的顾客都是这个数据对象中的数据元素。
讨论一种数据结构时,要关注以下三个方面:

  • 逻辑结构
  • 物理结构(存储结构)
  • 数据的运算

以上三个方面合称为数据结构的三要素

首先来看逻辑结构,逻辑结构就是指数据元素之间的逻辑关系是什么,数据的逻辑结构分为以下四种

  • 集合:各个元素除了同处于同一集合外无其他关系
    1694548386.jpg
  • 线性结构:一对一的关系,除了第一个元素外,其余元素都有唯一的直接前驱,除最后一个元素外,其余元素都有唯一的直接后继
    1694548399.jpg
  • 树形结构:数据元素是一对多的关系
    1694548411.jpg
  • 图状结构(网状结构):数据元素是多对多的关系
    1694548423.jpg

本节课程不讨论集合结构,主要讨论后面三种

不论是哪种逻辑结构,终归是要存储到计算机中的,物理结构就是讨论计算机如何存储逻辑结构的问题,物理结构主要分为以下四种

  • 顺序存储
  • 链式存储
  • 索引存储
  • 散列存储

我们这里讨论一下线性结构在计算机的四种物理存储形。
对于顺序存储来说,我们只需要将它存在一个数组即可,这样逻辑相邻的两个元素在物理上也相邻,换句话说,元素之间逻辑上的相邻关系就是由物理上的相邻关系来表示的。
对于链式存储来说,逻辑上相邻的元素在物理位置上可以是不相邻的,他们之间借助指针来表示下一个元素的位置,逻辑上的相邻关系借助指针来表示。
索引存储会建立一张索引表,索引表就和目录一样,不记录实际元素,而是记录各个元素的存放位置,还需要保存先后位置,索引表中的每一项称为数据项,一个数据项一般包含关键字和地址两个信息。
散列存储是根据元素的关键字直接计算出存储地址,散列存储又称哈希存储。

在以上四种存储结构中,我们可以把不是顺序存储的存储结构统称为非顺序结构

线性结构的顺序存储线性结构的链式存储线性结构的索引存储
在本章,只需要深入理解下面一段话:
若采用顺序存储,则各个数据元素在物理上必须是连续的:若采用非顺序存储,则各个数据元素在物理上可以是离散的。究其原因是因为顺序存储是借助物理上的先后顺序来表示逻辑上的先后顺序的,如果数据元素在物理内存上不连续,那就无法表示逻辑上的先后顺序,而非顺序存储的逻辑关系都是用指针来表示的,所以在物理空间上可以不连续。其次,数据的存储结构会影响存储空间分配的方便程度,如果采用顺序存储,比如数组,我们要在数据中插入一个元素,要把后面所有的元素都要向后挪一位,会非常麻烦,但如果采用非顺序存储,通过指针来表示逻辑关系,则只需要修改指针即可,相对简单一些。可以看到对于插入操作,非顺序结构相对于顺序结构更简单,但如果是查找操作,如果我们要找到数组的第n个元素,我们可以直接算出来,但对于非顺序存储的元素,找到第n个元素需要从第一个元素开始依次往后找,所以数据的存储结构也会影响数据的运算速度。
以上介绍了数据结构的逻辑结构和物理结构两个方面,下面介绍数据的运算,也就是数据结构三要素中最后一个要素。
数据的运算:施加在数据上的运算包括运算的定义和实现。
例如在一个排队系统中,我们需要队头元素出队的运算,也就是服务员的叫号操作,还需要定义新元素入队的操作,也就是把新客人插入到队尾,还包括计算当前队列长度等一系列运算,这些都称为数据的运算。
在逻辑层面,插入队列就是往队尾插入一个元素,但在具体实现层面,要收到存储结构的约束,顺序存储和非顺序存储的实现方式是不同的,所以运算的定义是针对逻辑结构的,指出运算的功能;运算的实现是针对存储结构的,指出运算的具体操作步骤。
接下来介绍数据类型,数据类型是一个程序员很熟悉的概念,比如int,bool,这些都是数据类型,以bool为例,归根到底,bool只能表示true或false两个值,除了表示值以外,bool还可以进行操作,比如基础的与或非操作,所以我们可以做以下定义:数据类型是一个值的集合和定义在此集合上的一组操作的总称。
数据结构还可以进行细分为原子类型和结构类型,原子类型就是上面提到的bool,他的值不可以分解,但还有一些值是可以再细分的,比如结构体,这种类型就属于结构类型。
以上介绍的数据类型都是很具体的数据类型,我们就可以开始了解抽象数据类型(ADT)了,抽象数据类型可以看做是逻辑结构+数据的运算,当我们在定义一种抽象数据类型的时候,就是在定义一种逻辑结构,以及其数据元素的操作,抽象数据类型的定义如下:是抽象数据组织及与之相关的操作。

算法和算法评价

算法的基本概念

首先来了解什么是算法,尼古拉斯·沃斯提出过这样一个观点,程序=数据结构+算法,足以见得算法的重要性,数据结构在我们上一节中已经了解过了,也就是在计算机中正确描述并存储现实世界的问题,而算法则是如何高效地处理这些数据以解决实际问题。
算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中的每条指令表示一个或多个操作。
从这个定义可以看出,算法本质是一个有限序列,说大白话就是求解问题的步骤,比如下面这个菜谱:

  1. 西红柿切块
  2. 鸡蛋加料酒打匀
  3. 将锅烧热,倒入鸡蛋翻炒
  4. 倒入西红柿翻炒
  5. 加少许盐、糖
  6. 装盘

这个步骤就是用来做一个西红柿炒蛋的步骤,所以这就是一个西红柿炒蛋的算法。
接下来我们考虑一个真实场景的算法,我这里有一个长度为n的数组,我要把这个数组排序(选择排序),我需要经过以下步骤

  1. 扫描前n个元素,记录值最小的元素,放到第一个位置
  2. 从第二位开始,扫描剩下n-1个元素,记录最小的元素,放到第二个位置
  3. 从第三位开始,扫描剩下n-2个元素,记录最小的元素,放到第三个位置
  4. 从第n-1位开始,扫描剩下2个元素,记录最小的元素,放到第n-1个位置

1c7e20f306ddc02eb4e3a50fa7817ff4.gif
算法并不是必须要用程序语言来描述,以上我们用自然语言描述的那也叫算法,但由于自然语言容易产生二义性,所以更加推荐用伪代码来描述代码,伪代码不是真正的程序设计语言,仅仅是用来描述算法的工具,相比于自然语言更加严谨,相比于程序设计语言更加方便。
前面我们的定义中,代码就是指令序列,但并不是所有的指令序列都可以称为算法,只有满足以下特性的指令序列才可以称为算法:

  1. 有穷性:一个算法必须总在执行有穷步之后结束,且每一步都可在有穷时间内完成。
  2. 确定性:算法中每条指令必须有确切的含义,对于相同的输入只能得出相同的输出
  3. 可行性:算法中描述的操作都可以通过己经实现的基本运算执行有限次来实现。
  4. 输入:一个算法必须有0个或多个输入。
  5. 输出:一个算法必须有1个或多个输出。

程序和算法的区别:程序可以是无穷无尽的,比如微信,打开后只要不关闭,会一直运行,所以微信是不满足有穷性的,他只能称为程序,不能称为算法。

满足以上特性的才能被称为算法,那么我们如何来设计一个优秀的算法呢,换句话说就是我们设计算法的时候应该追求哪些目标呢,一个优秀的算法应该要具备以下特性:

  1. 正确性:算法应该正确地解决问题。
  2. 可读性:算法应该有良好的可读性,即帮助人们理解。
  3. 健壮性:当输入非法数据的时候,算法应该适当的作出处理,而不应该产生莫名其妙的输出结果。
  4. 高效率和低存储需求:也就是效率高,占用内存少。

算法效率的度量

上一节说到好算法的特性,其中前三个特性,正确性,可读性,健壮性,都是很好验证的,比如一个算法只要能正确解决实际问题,那他就满足正确性,一个算法他注释清晰明了,算法结构简单,他就更加具有可读性,一个算法对异常数据处理地比较多,那他健壮性就更好,但高效率和低存储需求看起来是一个很虚的词,比如给你两个算法,你如何评价这两个算法谁的效率高,如何评价谁占用的内存得多,这其实很困难,有的人会说,那我把这两个算法跑一遍,看看任务管理器不就得了,但这是一种事后统计的方式,但是用这样的方式评价算法优劣其实有一些问题,比如算法的运行时间和机器性能相关,算法的时间开销还和编程语言有关,越高级的语言效率就会越低,算法的时间开销还和编译器编译出来的机器语言质量有关,以及有的算法他压根是不能进行事后统计的,比如一些导弹控制算法,我不可能每次调试都要浪费一个导弹,因此我们不能通过事后统计来统计算法的优劣,我们就应该考虑在算法执行之前就预估出其开销,算法的时间复杂度就是用来预估时间开销的,算法的空间复杂度就是用来预估空间开销的。

算法的时间复杂度

算法的时间复杂度是用来预估算法的时间开销(T(n))和问题规模(n)的关系,其中T表示时间,换句话说,我们认为算法的运行时间应该和问题规模有关,那么什么是问题规模,也就是输入的数据量,比如我要把长度为10的数组排序,问题规模就可以看做10,把长度为1000的数组排序,问题规模就可以看做1000,问题规模越大,运行时间也应该越长,算法的时间复杂度,就是用问题规模n来表示出算法的时间开销。
我们来看个例子分析一下:

void loveYou(int n){
    int i = 1;
    while(i<=n){
        i++;
        cout<<"I love you "<<i;
    }
    cout<<"I love you more than "+"n";
}

我们来分析以上代码的语句频度(每一条语句执行的次数),第2行代码执行了一次,第3行代码执行了n+1次,第4行代码执行了n次,第五行代码执行了n次,第七行代码执行了一次,所以该算法时间开销和问题规模的关系为:T(n)=3n+3
这样分析固然是可行的,但是这里有两个问题,就是如果一个算法关于问题规模n的开销算出来是一个很复杂的表达式,我们应该如何快速评估这个算法的效率怎么样,第二个问题是如果代码量非常多,我们还是要靠这样一行一行数吗?
首先来解决第一个问题,也就是简化表达式,其实说得简单点,对于n非常小的时候,不管效率如何,他执行的时间都差不了多少,我们主要研究的是n非常大的时候效率的高低,我们对这个表达式中的n趋于正无穷,可以忽略掉其中低阶的量,例如3n+3,他和3n是等价无穷大,所以我们可以认为他是3n,我们甚至可以再过分一点,我们只关注数量级,我们甚至可以认为3n和n是一个数量级的,如果是 n 2 + 3 n + 1000 n^{2}+3n+1000 n2+3n+1000,当n趋于无穷大时,3n+1000是一个可以被忽略的项,所以可以这个算法的时间复杂度可以近似为 n 2 n^{2} n2,介于此,我们可以用大O表示法来表示一个数量级。
T ( n ) = 2 n 3 + 100 n 2 + 2 n + 10000 = O ( n 3 ) T(n)=2n^{3}+100n^{2}+2n+10000 =O(n^{3}) T(n)=2n3+100n2+2n+10000=O(n3)
大O表示法可以用如下公式来定义: T ( n ) = O ( f ( n ) ) ⇔ lim ⁡ n → ∞ T ( n ) f ( n ) = k   ( k ≠ 0 ) T_{(n)}=O(f(n))\Leftrightarrow \lim _{n \rightarrow \infty}\frac{T(n)}{f(n)}=k\ (k\neq 0) T(n)=O(f(n))limnf(n)T(n)=k (k=0)
至此,我们总结两个规则:多项相加,只保留阶数最高的项,并且系数变为1;多项相乘,均保留
例如: T ( n ) = n 3 + n 2 log ⁡ 2 n = O ( n 3 ) T(n) = n^{3}+n^{2}\log_{2}{n}=O(n^{3}) T(n)=n3+n2log2n=O(n3)
这里可能有的人有疑问,就是为什么我把 n 2 log ⁡ 2 n n^{2}\log_{2}{n} n2log2n直接忽略了,他真的比 n 3 n^{3} n3数量级小吗,那这里如果有兴趣可以去用上面的定义的极限推,或者我们直接记以下结论:
O ( 1 ) < O ( log ⁡ 2 n ) < O ( n ) < O ( n log ⁡ 2 n ) < O ( n 2 ) < O ( n 3 ) < O ( 2 n ) < O ( n 1 ) < O ( n n ) O(1)<O(\log _{2}n)<O(n)<O(n \log _{2}n)<O(n^{2})<O(n^{3})<O(2^{n})<O(n^{1})<O(n^{n}) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n1)<O(nn)
所以我们就解决了第一个问题,即我们在研究算法的时间开销时,并不需要给出详细的表达式,我们只关注数量级,接下来是第二个问题,也就是如果代码量很大,我们是否也只有靠这样一行一行的分析,显然是没有必要的。
我们可以归纳下面几个结论

  1. 顺序执行的代码,只会影响常数项,可以直接忽略,我们只关注循环部分。
  2. 只需要挑循环中的一个基本操作分析他的执行次数即可。
  3. 对于嵌套循环,我们只考虑最内层的代码的循环次数。

接下来做一些练习:

void loveYou(int n){
    int i = 1;
    while(i<=n){
        i*=2;
        cout<<"I love you "<<i;
    }
    cout<<"I love you more than "+"n";
}

根据我们总结的结论,我们只需要关注最内层的循环,在最内层循环里面挑一个语句即可,我们假设第五行代码执行了x次,由于每次迭代i都要乘以2,当执行了x次后,i的值就是 2 x 2^{x} 2x,根据循环判断条件,当 2 x = i > n 2^{x}=i>n 2x=i>n时,就会结束循环,我们对两边同取对数,可以算出 x > log ⁡ 2 n x\gt \log_{2}{n} x>log2n时,循环结束,即运行了 log ⁡ 2 n + 1 \log_{2}{n}+1 log2n+1次就会结束,数量级为 O ( log ⁡ 2 n ) O(\log_{2}{n}) O(log2n)

void loveYou(int flag[],int n){//flag数组的长度为n
    cout << "I am iron man"<< endl;
    for(int i = 0;i<n;i++){
        if(flag[i]==n){
            cout << "I love You "<< n;
            break;
        }        
    }
    
}

以上代码是在找到flag数组中值为n的数,找到后输出并结束代码,我们会发现一个问题,就是输入的flag数组的状态会影响执行效率,比如n这个数正好在第一个,那么找到第一个就是这个数,执行效率就会很高,但如果是在最后一个,需要找到最后一个才能找到,执行效率就会降低,因此我们就需要考虑不同的情况,最好的情况就是元素n在第一个位置,循环内只需要循环一次,所以时间复杂度为O(1),此时这个复杂度为最好时间复杂度,最坏的情况就是元素n在最后一个位置,这个情况下循环需要执行n次,时间复杂度变为O(n),这个情况下就是最坏时间复杂度,如果我们将所有情况下的加权平均值,我们就可以算出平均时间复杂度,我们假设n出现在每一个位置的概率均为1/n,加权平均值为(1/n)+(2/n)+(3/n)+…,即(1+2+3+4+…+n)/n = (1+n)/2,解出依旧为 O ( n ) O(n) O(n)
从本例可以看出,算法的复杂度不仅和输入规模有关,还可能和输入数据的状态有关,我们需要考虑不同的情况,一般情况下我们只会考虑算法的最坏时间复杂度和平均时间复杂度,而不会着重考虑最好时间复杂度。

对于一些多层嵌套循环算法,我们可以巧用级数去估计

算法的空间复杂度

上一节的时间复杂度是衡量算法时间开销的,而空间复杂度则是用来衡量空间开销的,即空间开销和问题规模n的关系。

void loveYou(int n){
    int i = 1;
    while(i<=n){
        i++;
        cout<<"I love you "<<i;
    }
    cout<<"I love you more than "+"n";
}

我们还是以这个例子为例,不管问题规模n怎么变化,我始终都只需要一个int变量i,我不需要在内存中存储其他多余信息,所以这个算法的空间复杂度S(n)就是O(1)

时间复杂度的T为Time,这里的S为Space

对于这种空间复杂度为常数阶的代码,我们称其可以原地工作
再考虑以下代码

void test(int n){
    int flag[n];
    int i;
    //...
}

在这个算法中,我们在内部定义了一个flag数组,这个数组的长度为问题规模n,此时需要占用的空间就是n+1个int型空间,此时这个算法的空间复杂度就和问题规模n有关系了,空间复杂度也可以用大O表示法来表示,即在这个算法中,S(n)=O(n)

在分析空间复杂度的时候,只需要关注存储空间大小与问题规模相关的变量

再看下面的例子

void test(int n){
    int flag[n][n];
    int other[n];
    int i;
    //...
}

在这个例子中,我们定义了一个n*n的数组和一个1维数组,占用的空间为 n 2 + n + 1 n^{2}+n+1 n2+n+1个int型变量,根据大O表示法即为 O ( n 2 ) O(n^{2}) O(n2)
除了在算法内直接定义变量导致的内存开销,在递归调用时,也会导致内存空间的变化。

void love(int n){
    int a,b,c;
    if(n<1){
        love(n-1);
    }
    cout << "I love you "<< n;
}

这就是一个递归调用的例子,这个函数会递归调用n次,每一次递归调用会定义3个变量,所以一共会有3n个变量,即空间复杂度为O(n),再看下一个例子

void love(int n){
    int flag[n];
    if(n<1){
        love(n-1);
    }
    cout << "I love you "<< n;
}

这个例子中每一次递归要占用的空间为n,但是每次n都要减一,所以一共会占用1+2+3+4+5+…+n个内存,大O表示法即为O(n2)

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值