数据结构概述
数据结构是什么
数据结构没有想象得那么复杂,它就教会你一件事:如何有效地存储数据。
在数据结构中,所有能被计算机处理的信息都称为数据,比如数值、字符、图像、音频、视频等。
很多人觉得,存储数据是一件很简单的事情,各个编程语言都提供有存储数据的方法,比如常见的变量、数组等,甚至还可以将数据存储到文件中。如果只是单纯地存储数据,的确不是很难。真正的难点在于,存储数据的同时还能将数据之间的关系也存储起来。
举个简单的例子,每家每户都有家谱或者族谱,记录着一个家族世系繁衍的信息,比如图 1 这张家谱图:
存储这张图时,只存储 {张亮 , 张平 , 张晶 , 张磊 , 张华 , 张群} 这些人名是不行的,还要将他们之间的关系也存储起来,比如张亮是张平的父辈、是张磊的祖辈等等。
再比如,大家肯定用过导航软件(比如高德、腾讯地图等),要想实现精确导航,软件必须存储大量的数据,包括各个省、市、区、县的道路、建筑物、红绿灯等位置信息,以及每个地区的天气信息、高速路况信息等等。
这些信息都是数据,而且数据之间的关系错综复杂,能否正确存储这些关系,直接决定着软件导航的精准度。
类似家谱图、道路信息这样的场景,存储数据本身并不难,真正的难点在于如何存储数据之间的关系。通过学习数据结构,你将 get 到很多存储数据的方案,实现在存储数据的同时,还能正确存储数据之间的关系。
数据的逻辑结构和存储结构(物理结构)
数据结构教我们有效地存储数据,既要存储数据本身,还要存储数据之间的关系。
存储数据本身,也就是将数据存储到内存里。数据在内存中的存储状态,就称为数据的存储结构,也叫物理结构。
数据结构中,将数据之间的关系称为数据的逻辑结构。以下图所示的家谱图为例,数据之间存在很多关系,比如张亮是张平的父辈、是张晶的祖辈等,所有这些关系就构成了数据的逻辑结构。
数据的存储结构
在内存中,数据的存储结构无非有以下两种情况:
1、集中存储:所有数据存储在一整块内存空间中,数据之间紧挨着存放,如下图所示:
2、分散存储:各个数据随机存储在内存空间中,如下图所示:
两种存储结构各有优势,将数据集中存储,方便后续查找数据;将数据分散存储,方便后续增加或者删除数据。
数据结构中,用顺序存储结构(顺序表)实现数据的集中存储,用链式存储结构(链表)实现数据的分散存储。
数据的逻辑结构
数据之间可能存在的关系,有以下 4 种情况:
1、无关系
所谓“无关系”,即数据之间不存在任何关系。例如上图中,{1,2,3,4} 中各个数据之间就没有任何关系。
2、一对一
上图的数据集中,每个数据的左侧有且仅有一个数据与其相邻(除 1 外);同样,每个数据的右侧也只有一个数据与其相邻(除 n 外),所有的数据都是如此,数据之间就是“一对一”的逻辑结构;
3、一对多
上面所说的“家谱图”中,数据之间就是“一对多”的逻辑结构。
以“张平”为例,他的父辈是“张亮”;他有两个孩子,分别是“张晶”和“张磊”。“张平”和其它数据之间就是“一对多”的关系,整个数据集呈现“一对多”的逻辑结构。
4、多对多
{V1,V2,V3,V4} 数据之间就具有“多对多”的逻辑结构。
例如,从 V1 出发可以到达 V2、V3、V4;同样,从 V2、V3、V4 也可以到达 V1。V1 和其它数据之间就是“多对多”的逻辑关系。
多对多关系和一对多关系的区别在于:一对多关系中不存在环路,而多对多关系中存在环路,比如V1->V3->V2->V1就是一个环路。
针对每一种逻辑结构的数据,数据结构都提供了存储它们的方案:
- 查找表存储结构:专门存储无逻辑结构的数据;
- 线性存储结构:专门存储具有“一对一”逻辑结构的数据;
- 树存储结构:专门存储具有“一对多”逻辑结构的数据;
- 图存储结构:专门用来存储具有“多对多”关系的数据;
数据结构到底学什么
数据结构是一门研究数据存储方式的学科,在数据结构看来,数据的存储方式要从以下两个角度来综合分析:
- 物理结构:在内存中,数据可以选择集中存放,也可以选择分散存放;
- 逻辑结构:数据之间的逻辑关系有四种,分别是无关系、“一对一”关系、“一对多”关系和“多对多”关系。
数据的物理结构有 2 种,逻辑结构有 4 种,它们可以随意组合。例如,无关系的数据可以选择集中存放,也可以选择分散存放。针对具有不同物理结构和逻辑结构的数据,数据结构都会给出最恰当的存储方案。学习数据结构,实际上就是学习这些存储数据的方案。
注意,想彻底玩转上面罗列的这些存储方案也是不容易的,除了掌握各个存储方案本身,还要学会在各个存储方案中完成对数据的“增删改查”操作,以及用这些存储方案解决一些常见的实际问题(例如字符串的模式匹配、矩阵转置、最小生成树、最短路径等)。
时间复杂度和空间复杂度
所谓算法,简单理解就是解决问题的方法(方案、思路)。通常情况下,一个问题的解决方法会有很多种,或者说解决这个问题的算法有很多种。
举个简单的例子,对数据集{2,4,5,1,3}做升序排序,解决这个问题的算法就有很多种,比如冒泡排序算法、快速排序算法、归并排序算法、希尔排序算法等。借助任何一种算法,都可以得到{1,2,3,4,5}升序序列。
同一个问题,使用不同的算法,虽然都可以解决问题,但有的算法执行效率高,有的算法执行效率低。就好比拧一个螺母,使用钳子和扳手都可以,但显然扳手拧螺母的效率更高。那么问题就出现了,怎样从众多算法中选择出“最好”的呢?
算法本身是不分“好坏”的,所谓“最好”的算法,指的是最适合当前场景的算法。通常情况下,挑选算法主要考虑两个方面,分别是:
- 执行效率:根据算法编写出的程序,执行时间越短,效率就越高;
- 占用的内存空间:不同算法编写出的程序,执行时占用的内存空间也不相同。如果实际场景中仅能使用少量的内存空间,就要优先选择占用空间最少的算法。
算法只是解决问题的思路,无法直接在计算机上执行。要想知道一个算法确切的执行时间和占用的内存大小,必须根据算法编写出可执行的程序。
当算法的数量较少时(比如 2、3 种),我们确实可以编写出各个算法对应的程序,逐个在机器上运行,记录下各自的执行时间和占用的内存大小,然后挑选出“最好”的算法。但是,如果算法的数量很多(比如 10 种,20 种),真机测试的方法将不再适用,因为将各个算法一一编写成程序的工作量是巨大的,得不偿失。
实际开发中,往往采用 “预先估值”的方法挑选算法。具体来讲,就是分析各个算法的实现过程(步骤),估算出它们各自的运行时间和占用的内存大小,就可以挑选出“最好”的算法。
我们习惯用「时间复杂度」预估算法的执行时间,用「空间复杂度」预估算法占用的内存大小。
时间复杂度
时间复杂度用来预估算法的执行时间。
以解决“求 n 的阶乘(n!)”为例,如下用伪代码描述出了解决此问题的一种算法:
输入 n // 接收 n 的值
p <- 1 // p 的初值置为 1
for i<-1 to n: // i 的值从 1 到 n,每次将 p*i 的值赋值给 p
p <- p * i
Print p // 输出 p 的值
伪代码是一种介于自然语言和编程语言之间,专门用来描述算法的语言。伪代码没有固定的语法,该例我们用<-表示赋值过程,用Print xxx表示输出某个变量的值。
计算一个算法的时间复杂度,需要经过以下 3 个步骤:
1、统计算法中各个步骤的执行次数
整个算法中共有 5 行伪指令,它们各自的执行次数分别是:
输入 n <- 执行 1 次
p <- 1 <- 执行 1 次
for i<-1 to n: <- i 的值从 1 遍历到 n,当 i 的值为 n+1 的时候退出循环,总共执行 n+1 次
p <- p * i <- i 从 1 到 n 的过程,共执行 n 次
Print p <- 执行 1 次
有伪指令执行次数的总和是2*n+4,显然它不是一个固定值,整个表达式的大小取决于 n 的值。
2*n+4可以直接作为算法执行时间的估值,但通常情况下,我们会做进一步地简化,并用规范的格式来表示一个算法的执行时间。
2、简化算法的执行次数
通过统计各个算法中伪指令的执行次数,每个算法的运行时间都可以用类似 2*n+4、3*n2+4*n+5 这样的表达式表示。那么,该如何比较各个表达式的大小呢?
我们可以尝试对每个表达式进行简化,简化的方法是:假设表达式中变量的值无限大,去除表达式中那些对结果影响较小的项。
以 3*n2+4*n+5 为例,简化过程为:
- 当 n 无限大时,3*n2+4*n 与 3*n2+4*n+5 的值非常接近,是否加 5 对表达式的值影响不大,因此表达式可以简化为 3*n2+4*n;
- 当 n 无限大时,3*n2 的值要远远大于 4*n 的值,它们之间类似于 10000 和 1 之间的关系,因此是否加 4*n 对表达式最终的值影响不大,整个表达式可以简化为 3*n2;
- 当 n 无限大时,n2 的值已经超级大,是否乘 3 对最终结果影响不大,整个表达式可以简化为 n2。
基于“n 值无限大”的思想,3*n2+4*n+5 最终就简化成了 n2。同样的道理,2*n+4 可以简化为 n。无论多么复杂的表达式,都可以采用这种方式进行简化。
3、用大O记法表示算法的时间复杂度
除了用 n 外,一些人还可能会用 a、b、c 等字符作为表达式中的变量。为此,人们逐渐达成了一种共识,即都用 n 作为表达式中的变量,并采用大 O 记法表示算法的执行时间。
采用大 O 记法表示算法的执行时间,直接套用如下的格式即可:
O(频度)
频度指的就是简化后的表达式。
采用大 O 记法,2*n+4 可以用O(n)表示,3*n2+4*n+5 可以用O(n2)表示。如果一个算法对应的表达式中没有变量(比如 10,100 等),则用O(1)表示算法的执行时间。
如果一个算法的执行时间最终估算为 O(n),那么该算法的时间复杂度就是 O(n)。如下列举了常用的几种时间复杂度以及它们之间的大小关系:
O(1)< O(logn) < O(n) < O(n2) < O(n3) < O(2n)
O(1)是最小的,对应的算法的执行时间最短,执行效率最高。
空间复杂度
空间复杂度用来估算一个算法执行时占用的内存大小。
执行过程中的程序,占用的内存空间主要包括:
- 程序代码本身所占用的存储空间;
- 如果需要输入输出数据,也会占用一定的存储空间;
- 运行过程中,可能还需要临时申请更多的存储空间。
程序自身占用的存储空间取决于编写的代码量,如果想压缩这部分存储空间,要求我们在实现功能的同时尽可能编写足够短的代码。
程序运行过程中输入输出的数据,往往由要解决的问题而定,即便所用算法不同,程序输入输出所占用的存储空间也是相近的。
事实上,对算法的空间复杂度影响最大的,是程序运行过程中临时申请的内存空间。不同算法编写出的程序,运行时申请的临时存储空间通常会有较大不同。
因此,比较各个算法占用的内存大小,本质上比较的是它们执行过程中额外申请的内存空间的大小。举个简单的例子:
输入 n
A[1...n] <- {1...n} <- 额外申请 n 个空间
根据 n 的值,算法执行时需要申请 n 个整数的内存空间,n 的值越大,额外申请的内存空间就越多。
和时间复杂度一样,空间复杂度也习惯用大 O 记法表示。空间复杂度的估算方法是:
- 如果算法中额外申请的内存空间不受用户输入值的影响(是一个固定值),那么该算法的空间复杂度用O(1)表示;
- 如果随着输入值 n 的增大,算法申请的存储空间成线性增长,则程序的空间复杂度用O(n)表示;
- 如果随着输入值 n 的增大,程序申请的存储空间成 n2 关系增长,则程序的空间复杂度用O(n2)表示;
- 如果随着输入值 n 的增大,程序申请的存储空间成 n3 关系增长,则程序的空间复杂度用O(n3)表示;
多数场景中,挑选 "好" 算法往往更注重的是时间复杂度,空间复杂度只要处于一个合理的范围即可。
数据结构如何自学,效率更高
首先要明确,数据结构教会我们的是解决问题的思想,并不挂靠某一门具体的编程语言。换句话说,在掌握任何一门编程语言的基础上,都可以学习数据结构和算法。
学习的方法可以总结为 6 个字:多动笔、多动手。
所谓“多动笔”,在学习数据结构和算法的过程中,要边学习边画图。因为,对于数据结构中的存储结构来说,尤其是树结构和图结构,存储结构确实比较复杂,仅靠空间想象难免会有纰漏,而通过亲手画图往往能避免很多“坑”。
以学习链表为例,如果我们想象不到它是怎样存储数据,就应该尝试动手将它画出来,如下图所示:
上图是用画图工具制作的,读者在实际画图时,只需要做到心中有数即可。
由此,我们就画了一个存有 {1,2,3,4} 数据的链表。
不仅如此,假设我们想删除存储元素 3 的结点,也可以先通过画图来实现:
如上图所示,整个画图的过程,也是我们思考如何通过程序实现删除指定节点的过程:
- 为了删除存有元素 3 的结点,先要找到它的前驱结点,也就是结点 2,并用一个指针 p 来标记;
- 借助指针 p,可以顺利找到结点 3,因为它最终要被摘除,考虑到该结点占用的空间要手动释放,因此还要用一个指针 q 来标记它;
- 借助指针 p 和 q(也就是图中的第 3 步),就可以成功将目标结点摘下来;
- 最后借助指针 q,可以释放被删除节点所占用的存储空间。
以上就是为了删除存储元素 3 的结点,整个画图的流程。
再次强调,画图的过程是思考如何用程序实现的过程,并不是随意勾画。
除此之外,在学习某些算法时,也可以借助画图来加深自己的理解。甚至在阅读它人实现的代码时,可以边阅读代码边画图,这样可以更快理清代码的实现逻辑。
在通过“多动手”实现理解存储结构和实现逻辑的基础上,初学者还要“多动手”编写实现代码。注意,对于某一种存储结构或者算法,没有 3 遍以上自己独立的实现过程,是很难做到融会贯通的。
另外,很多初学者都存在“当时搞清楚了,过段时间又忘了”的情况。其原因大致有 2 个,一个是当时学的时候就没有彻底搞懂,或者处于“似懂非懂”的状态;另一个原因就是长时间没有再接触过它,所以淡忘了。其中如果是第一种原因导致的,那只能从头再学;对于第二种情况,读者不需要过度懊恼,只要我们再回顾一遍,通常是可以快速回忆起来的。
学习数据结构的重要性
数据结构并不是一门具体的编程语言,它教会我们的是一种思维方式,即如何以更优的方式存储数据。
提升程序员的逻辑思维
具体来讲,对于同一个问题,数据结构往往会教给我们不只一种解决思路。举个例子,假设我们需要从众多数据中查找出符合要求的元素,多数人就只能借助数组这种简单的存储结构来实现,而通过学习数据结构我们会知道,解决此类问题既可以通过构建二叉排序树、平衡二叉树、甚至红黑树、B+/B- 树来解决,还可以借助哈希表解决。
再举一个例子,几乎所有的编程语言中都提供有数组这种存储结构,但如果没学过数据结构,就绝不会想到,数组还能以链表的形式使用(也就是静态链表)。
事实上,数据结构也有众多编程语言无法比肩的优势。无论是 Java、Python、C++、PHP 还是其他编程语言,无时无刻不在更新迭代,而数据结构却永远不会过时,其包含的存储数据的思想,已经近乎将所有可能的情况都囊括其中,能解决 99% 的实际场景中有关数据存储的问题。
能力高低的分水岭
要想学好数据结构,不仅要求学员具备良好的编程基础,还必须具有较强的逻辑分析能力和理解能力,甚至还需要具有一定的空间想象能力,可以这么说,能玩转数据结构的人,其综合实力往往都不差。很多大的互联网公司,更看重的往往不是你精通多少种编程语言,而是综合能力,更确切地说是解决问题的能力。
有些读者可能会问,类似 C++ 可以使用 STL 标准库,Python 代码可以使用 Collections 模块等,很多编程语言都可以使用相应的集成数据结构的框架或者模块,直接拿来用不就可以了吗?
事实上,很多在职程序员在开发过程中,都会套用现有的一些集成数据结构的模块或者框架。要知道,适当的使用是可取的,但不能完全依赖,否则知其然而不知其所以然,即便完成再多的项目,也无非是他人代码的搬运工,个人能力很快会进入瓶颈期,再无提升的空间。
程序性能好坏的评判标准
对于如何评判一个人编程能力的强弱,不同的人有不同的标准,或许是看中他编写代码的可读性,扩展性、是否健壮等等。
我认为,代码执行性能的好坏无疑能成为众多评判标准中的一个。而想编写出性能高的代码,前提是必须知道如何评判代码的性能,这就不得不使用数据结构中评判代码执行性能的时间复杂性和空间复杂度。
对于某些在职的程序员来说,如果觉得数据结构无用,更多可能是因为你接触的都是一些用户量很少、需要处理的数据量也很少的小项目,实际开发中更注重实现具体的功能,产品的性能要求并非那么苛刻。反之,如果你身处像 BAT 这样的大公司,所开发产品的用户量往往是千万级别甚至亿级别,需要处理的数据量也往往是 TB 甚至 PB 级别,这时产品的性能将是首要考虑的因素,而数据结构和算法的意义将会彻底凸显出来。