Python数据结构与算法—算法,数据结构
- 算法
- 引入概念
- 第一次尝试
- 算法概念的提出
- 算法的五大特性
- 第二次尝试
- 第一次尝试和第二次尝试的算法分析
- 算法效率衡量
- 执行时间反应算法效率
- 单靠时间值绝对可信吗?
- 时间复杂度与“大O记法”
- 如何理解“大O记法”
- 最坏时间复杂度
- 时间复杂度的几条基本计算规则
- 常见时间复杂度
- 常见时间复杂度之间的关系
- Python内置类型性能分析
- list的操作测试
- list和dict的内置操作的时间复杂度
- 数据结构
- 引入
- 概念
- 算法与数据结构的区别
- ADT 抽象数据类型(Abstract Data Type)
- 线性表
- 顺序表
- 定义
- 引入——变量类型
- 变量的存储——顺序表
- 顺序表的基本形式
- 元素外置的顺序表
- 顺序表的结构与实现
- 顺序表的结构
- 顺序表的两种实现方式
- 顺序表的操作
- Python中的顺序表
- list的基本实现技术
- 链表
- 为什么需要链表
- 链表的定义
- 如何实现链表
- 单向链表
- 用程序去实现这个数据结构
- 链表与顺序表的对比
- 双向链表
- 引入
- 双向链表解析
- 实现双链表操作
- 单向循环链表
- 代码操作
算法
引入概念
第一次尝试
解决方法一:
算法概念的提出
算法是计算机处理信息的本质,因为计算机程序本质上是一个算法来告诉计算机确切的步骤来执行一个指定的任务。一般地,当算法在处理信息时,会从输入设备或数据的存储地址读取数据,把结果写入输出设备或某个存储地址供以后再调用。
算法是独立存在的一种解决问题的方法和思想。
对于算法而言,实现的语言并不重要,重要的是思想。
算法可以有不同的语言描述实现版本(如C描述、C++描述、Python描述等),我们现在是用Python语言进行描述实现。
算法的五大特性
- 输入:算法具有0个或多个输入
- 输出:算法至少有1个或多个输出
- 有穷性:算法在有限的步骤之后会自动结束而不会无限循环,并且每一个步骤可以在可接受的时间内完成。
- 确实性:算法中的每一步都有确定含义,不会出现二义性。
- 可行性:算法的每一步都是可行的,也就是说每一步都能够执行有限的次数完成。
第二次尝试
解决方法2:
第一次尝试和第二次尝试的算法分析
算法效率衡量
执行时间反应算法效率
对于同一问题,我们给出了两种解决算法,在两种算法的实现中,我们对程序执行的时间进行了测算,发现两段程序执行的时间相差悬殊(214.583347秒相比于0.182897秒),由此我们可以得出结论:实现算法程序的执行时间可以反应出算法的效率,即算法的优劣。
单靠时间值绝对可信吗?
假设我们将第二次尝试的算法程序运行在一台配置古老性能低下的计算机中,情况会如何?很可能运行的时间并不会比在我们的电脑中运行算法一的214.583347秒快多少
单纯依靠运行的时间来比较算法的优劣并不一定是客观准确的!
程序的运行离不开计算机环境(包括硬件和操作系统),这些客观原因会影响程序运行的速度并反应在程序的执行时间上。那么如何才能客观的评判一个算法的优劣呢?
时间复杂度与“大O记法”
“大O记法”:对于单调的整数函数f,如果存在一个整数函数g和实常数c>0,使得对于充分大的n总有f(n)<=c*g(n),就说函数g是f的一个渐近函数(忽略常数),记为f(n)=O(g(n))。也就是说,在趋向无穷的极限意义下,函数f的增长速度受到函数g的约束,亦即函数f与函数g的特征相似。
oncoming:即将到来的,接近的,新兴的
时间复杂度:假设存在函数g,使得算法A处理规模为n的问题示例所用时间为T(n)=O(g(n)),则称O(g(n))为算法A的渐近时间复杂度,简称时间复杂度,记为T(n)
如何理解“大O记法”
对于算法进行特别具体的细致分析虽然很好,但在实践中的实际价值有限。对于算法的时间性质和空间性质,最重要的是其数量级和趋势,这些是分析算法效率的主要部分。而计量算法基本操作数量的规模函数中那些常量因子可以忽略不计。例如,可以认为3n2和100n2属于同一个量级,如果两个算法处理同样规模实例的代价分别为这两个函数,就认为它们的效率“差不多”,都为n2级。
最坏时间复杂度
分析算法时,存在几种可能的考虑:
算法完成工作最少需要多少基本操作,即最优时间复杂度
算法完成工作最多需要多少基本操作,即最坏时间复杂度
算法完成工作平均需要多少基本操作,即平均时间复杂度
对于最优时间复杂度,其价值不大,因为它没有提供什么有用信息,其反映的只是最乐观最理想的情况,没有参考价值。
对于最坏时间复杂度,提供了一种保证,表明算法在此种程度的基本操作中一定能完成工作。
对于平均时间复杂度,是对算法的一个全面评价,因此它完整全面的反映了这个算法的性质。但另一方面,这种衡量并没有保证,不是每个计算都能在这个基本操作内完成。而且,对于平均情况的计算,也会因为应用算法的实例分布可能并不均匀而难以计算。
因此,我们主要关注算法的最坏情况,亦即最坏时间复杂度。
时间复杂度的几条基本计算规则
- 基本操作,即只有常数项,认为其时间复杂度为O(1)
- 顺序结构,时间复杂度按加法进行计算
- 循环结构,时间复杂度按乘法进行计算
- 分支结构,时间复杂度取最大值
- 判断一个算法的效率时,往往只需要关注操作数量的最高次项,其它次要项和常数项可以忽略
- 在没有特殊说明时,我们所分析的算法的时间复杂度都是指最坏时间复杂度
举例说明
常见时间复杂度
常见时间复杂度之间的关系
Python内置类型性能分析
list的操作测试
- 生成列表的4种方式
- 对每一种列表生成方式构造函数
- 构造测算的timer类(参数为要测的函数和导入路径),再去使用timer类的方法timeit去测算传入的函数运行的时间。
注意:
# class timeit.Timer(stmt='pass', setup='pass', timer=<timer function>),注意:第一个参数,stmt='pass',传入的代码语句是一个字符串,因而下面要在函数名外面加上引号,将函数名放到字符串种。
#第二个参数,让测试时前面传入的参数能运行起来,就要设置从哪里调入这个函数。test1函数目前在同一个.py文件中,作为启动文件中的函数,当前文件是启动文件,就是__main__,from __main__ import test1
timer1 = Timer("test1()","from __main__ import test1")
# 使用timer类的方法timeit测算传入的函数的运行时间
timer1.timeit(1000)
# 打印出来
print("+:",timer1.timeit(1000))
可知,尽量少用+操作,+代表直接对两个列表进行加操作,效率低;可用+=
注意函数名不能用test定义,不能用python中已有的名称去定义。所以实际运行时要将代码中的test改为t。
list和dict的内置操作的时间复杂度
数据结构
引入
我们如何用Python中的类型来保存一个班的学生信息? 如果想要快速的通过学生姓名获取其信息呢?
实际上当我们在思考这个问题的时候,我们已经用到了数据结构。列表和字典都可以存储一个班的学生信息,但是想要在列表中获取一名同学的信息时,就要遍历这个列表,其时间复杂度为O(n),而使用字典存储时,可将学生姓名作为字典的键,学生信息作为值,进而查询时不需要遍历便可快速获取到学生信息,其时间复杂度为O(1)。
我们为了解决问题,需要将数据保存下来,然后根据数据的存储方式来设计算法实现进行处理,那么数据的存储方式不同就会导致需要不同的算法进行处理。我们希望算法解决问题的效率越快越好,于是我们就需要考虑数据究竟如何保存的问题,这就是数据结构。
在上面的问题中我们可以选择Python中的列表或字典来存储学生信息。列表和字典就是Python内建帮我们封装好的两种数据结构。
概念
数据是一个抽象的概念,将其进行分类后得到程序设计语言中的基本类型。如:int,float,char等。数据元素之间不是独立的,存在特定的关系,这些关系便是结构。数据结构指数据对象中数据元素之间的关系。
Python给我们提供了很多现成的数据结构类型,这些系统自己定义好的,不需要我们自己去定义的数据结构叫做Python的内置数据结构,比如列表、元组、字典。而有些数据组织方式,Python系统里面没有直接定义,需要我们自己去定义实现这些数据的组织方式,这些数据组织方式称之为Python的扩展数据结构,比如栈,队列等。
算法与数据结构的区别
数据结构只是静态的描述了数据元素之间的关系。
高效的程序需要在数据结构的基础上设计和选择算法。
程序 = 数据结构 + 算法
总结:算法是为了解决实际问题而设计的,数据结构是算法需要处理的问题载体
ADT 抽象数据类型(Abstract Data Type)
抽象数据类型(ADT)的含义是指一个数学模型以及定义在此数学模型上的一组操作。即把数据类型和数据类型上的运算捆在一起,进行封装。引入抽象数据类型的目的是把数据类型的表示和数据类型上运算的实现与这些数据类型和运算在程序中的引用隔开,使它们相互独立。
最常用的数据运算有五种:
插入
删除
修改
查找
排序
线性表
在程序中,经常需要将一组(通常是同为某个类型的)数据元素作为整体管理和使用,需要创建这种元素组,用变量记录它们,传进传出函数等。一组数据中包含的元素个数可能发生变化(可以增加或删除元素)。
对于这种需求,最简单的解决方案便是将这样一组元素看成一个序列,用元素在序列里的位置和顺序,表示实际应用中的某种有意义的信息,或者表示数据之间的某种关系。
这样的一组序列元素的组织形式,我们可以将其抽象为线性表。一个线性表是某类元素的一个集合,还记录着元素之间的一种顺序关系。线性表是最基本的数据结构之一,在实际程序中应用非常广泛,它还经常被用作更复杂的数据结构的实现基础。
根据线性表的实际存储方式,分为两种实现模型:
顺序表,将元素顺序地存放在一块连续的存储区里,元素间的顺序关系由它们的存储顺序自然表示。
链表,将元素存放在通过链接构造起来的一系列存储块中。
顺序表
定义
引入——变量类型
- 现在有5个数字 int 1,2,3,4,5
如何将这一组数字当作一个整体保存在来,不考虑python内置的封装的高级数据结构:list、tuple、dict…,就考虑这些基本数据类型怎么组织,现在要研究在存储时候他的本质是什么概念。 - 引入内存概念
计算机的内存是真正来存放数据,并且直接跟cpu打交道的,直接考虑cpu读取内存
内存就是存储单元,就是一堆存储的小点、一些单位组织在一起,
考虑内存是什么样的模型?
一个内存的基本单元,
内存的基本单位是以一个字节来作为索引单位的
1个字节是8位
这个代表计算机去找数据时候的一个基本位置,
现在告诉计算机有一个数据存的是100,计算机的cpu去哪里找100这个数?从内存中
内存是一个连续的存储单元,
假设100存在了某个位置中
现在要告诉计算机cpu去哪个位置上取出来100.那我需要对这个空间标出来一个位置
现在再让计算机去找100在哪个位置,只需要告诉0x03这个位置,计算机的cpu就可以直接找到0x03这个地址,然后把里面的数据取出来,即100.
这是和硬件有关的,也就是说计算机中的内存是这样一个连续的存储空间,这个连续的存储空间中是由基本的单元存储在一起的,基本的单元就是一个字节,把一个字节作为一个标识,一个字节的8位整体有一个地址标识。告诉计算机去0x01去找时候,那计算机能够找到0x01这个地址所标识的这个存储空间,一下就读出8位来。也就是说计算机在标识时候是按照1个存储单元来进行标识的,那在存数据时候也就需要多个存储单元来并到一起。
- 现在有一个基本类型是整形,那整形要占多少位?占多大?
python内置的封装的不同类型的高级数据结构。
这个类型到底是什么概念?本质是什么?就决定了有一个整形数据的话,我在内存中到底要申请多少个存储单元来把这个数存储起来?
如果是一个整形数据,要占多少个存储单元?
对于32位机器,int型要存储4个字节,即占4个单元
一个int型数字1,看到的是一个1,但是在内存中,要转换为2进制,才能存起来。转换为2进制,存为0000 0001,即存的时候是把一个1转换为一个字节,然后8位放过去。
那现在是一个整形,就不仅是8位了,必须要占4个字节,3组8个0再加上后面0000 0001。见下图:
这个时候是看到一个变量1,真正落实到计算机内部是占了4个存储单元,
- 现在又有一个数据类型,Char,理解为python中的字符串内部的一个一个的字符。这时,一个字符要占多少空间?
一个char占一个字节,一个存储单元 - 类型不同,是不是在存储空间中占的大小不一样?这是变量类型的第一个本质。
- 从取的角度来考虑,假如还是这4个单位,即有4个地址,1次性把这四个单元的所有数据全部取出来了,那对计算机来说,就是取出来32个二进制数据,如何对待这个数据?是把他当作一个整数来对待,还是把他当作4个字符来对待?这也是类型来决定的。
- 如果在存的时候就告诉了,这4个单元存的就是一个整数a,那取的时候就把这4个单元当作一个整体来对待。
那如果在存的时候告诉,这4个单元不是一个整数,而是对应字符,即将1声明为字符,那我在取出来时候,同样是0000 0001,就不能i将其当作整数来对待了,而是将其当作一个字符。
类型决定了计算机取的时候如何对待它。
总结:类型决定了变量类型的2个本质
1.类型不同,存储空间中占的大小不一样
2.类型决定了计算机取的时候如何对待它
之所以说到变量类型的概念,是因为所有高级的数据结构都是由基本的数据类型来构成的。
数据类型就要涉及到里面的数据类型到底是怎么存。
变量的存储——顺序表
有两种形式
图a表示的是顺序表的基本形式,数据元素本身连续存储,每个元素所占的存储单元大小固定相同,元素的下标是其逻辑地址,而元素存储的物理地址(实际内存地址)可以通过存储区的起始地址Loc (e0)加上逻辑地址(第i个元素)与存储单元大小(c)的乘积计算而得,即:Loc(ei) = Loc(e0) + c*i
故,访问指定元素时无需从头遍历,通过计算便可获得对应地址,其时间复杂度为O(1)。
如果元素的大小不统一,则须采用图b的元素外置的形式,将实际数据元素另行存储,而顺序表中各单元位置保存对应元素的地址信息(即链接)。由于每个链接所需的存储量相同,通过上述公式,可以计算出元素链接的存储位置,而后顺着链接找到实际存储的数据元素。注意,图b中的c不再是数据元素的大小,而是存储一个链接地址所需的存储量,这个量通常很小。
图b这样的顺序表也被称为对实际数据的索引,这是最简单的索引结构。
顺序表的基本形式
- 回到原来的问题,现在有1,2,3,三个整数,5个整数我们认为这是一个集合,在存的时候如何去保存,现在要将这三个整数作成一个高级的数据类型,然后将他们存储起来。
底下一个方框等于上面的4个单元 - 现在这三个int变量之间没有关系,每个int变量存储在1个单元里面,共有3个存储单元,3个存储单元之间没有关联。
用什么方式将他们存起来?
最直观的,既然是3个整数,那就连起来连续去存,这样地址就联系起来了,按顺序去存放。 - 现在将这三个整数连续存放起来,再去找时候,怎么找?
- 假如封装好了一个数据结构,L=[1,2,3]这样来表示,存储了3个数据1,2,3,在内存中保存时让他们连续起来放,那对于整个集合来说,有一个起始位置,假定为0x01。现在要找集合中第三个位置的元素是谁,如何去取?
通过第一个位置0x01,再加上后面的8个位置,就可以到达第9个位置,找到第3个整数的位置。 - 在引入一组数据,相同类型时候,去存储,最直观最基本的,就是将他们按照顺序紧靠在一起。这样的数据存储结构就称之为顺序表
顺序表就是按照顺序去存放,而且展开还是表格形式。 - 由上就可知:为什么在一个数据结构中取数据是从0开始的。因为数据存储时0位置就表示不偏移,就是第一个,取第二个数据时就要从0位置开始偏移,于是就是1位置。这就是顺序表的基本布局形式。
采用这样的形式,每个存储单元中存储的是我确切要存储的那个数据。
见下图:外面的深颜色整体是生成的顺序表Li,整体中每一个单元就是具体要存的那个数据。是顺序表的基本形式。
元素外置的顺序表
要存前面例子中的4个整数,在存的时候,从操作系统内存中申请的空间不再去存数据本身,要存一个地址。
为什么这样作?
在顺序表的基本形式中,可以从上到下通过偏移来计算位置,是因为一个数据占用的空间都是相同的,
举例:
列表中存储的数据类型可以是相同的也可以是不同的,可以存整数也可以存字符串,正阳就不可以按照顺序表的基本形式来存储了,每个数据存储单元的大小不统一了。
存储单元有相应的编号,即地址,代表这个内存位于哪里,这个地址本身就是一个数据,在计算机中,这个地址无论你存储的数据是谁,地址这个数据类型是统一的,占用的内存大小是一致的,都占4个字节。
如下图:存储的大小不同,但是都会有一个地址来标识存储位置,这个地址占用的大小是一样的。
现在把这个地址存下来,通过地址去找数据,就引出元素外置的顺序表。
图中,先把每一个具体的数据找一个存储单元存起来,在申请时这样的每一个位置,每一个申请单元分配的地址不同,不是连续的。现在将这样的每一个数据存储后再去找,现在把这四个数据的存储地址0x100,0x200,0x53,0x110这四个地址存起来,作为顺序表的基本存储。
现在为了地址的存储又要申请存储空间,每个存储4个字节,这时申请来的16个字节就是连续的。通过这样的地址再去指向。地址的存储单元,第一个存储单元是0x110,指向第一个存储单元12,第2个地址的存储单元是0x200,指向第二个存储单元,是ab,依次类推。
现在去操作,就是在地址的存储空间上操作,要访问li列表中第一个元素12,先访问地址存储单元的0位置,得到地址存储单元的第一个元素0x100,这对应列表中的第一个元素12.
这样,顺序表是指地址空间,深颜色那里,数据元素在数据表之外。使用元素外置的顺序表就可存储不同数据类型的元素。
顺序表的结构与实现
顺序表的结构
一个顺序表的完整信息包括两部分,一部分是表中的元素集合,另一部分是为实现正确操作而需记录的信息,即有关表的整体情况的信息,这部分信息主要包括元素存储区的容量和当前表中已有的元素个数两项。
申请一块连续的存储空间用来存储数据,一次性申请回来这么一个空间,把数据存储进来。那我们真正在实现这个顺序表时,包括数据区和表头信息。
现在要构造一个顺序表,这个顺序表在一开始就要定义出来。
所有数据必须连续存储的话,就意味着,在构造一个顺序表时,一开始就要把数据存储的所有连续空间一次拿到,即一开始就预估到这个顺序表要存入多少数据。数据是连续的,构造一个顺序表,就要向操作系统要这个存储空间和存储位置,操作系统不知道需要多少,所以一开始就要定义好。
考虑:如何将表头和数据区组织到一起
顺序表的两种实现方式
图a为一体式结构,存储表信息的单元与元素存储区以连续的方式安排在一块存储区里,两部分数据的整体形成一个完整的顺序表对象。
一体式结构整体性强,易于管理。但是由于数据元素存储区域是表对象的一部分,顺序表创建后,元素存储区就固定了。
图b为分离式结构,表对象里只保存与整个表有关的信息(即容量和元素个数),实际数据元素存放在另一个独立的元素存储区里,通过链接与基本表对象关联。
解释:
顺序表的基本结构特征
真实实现时除了存储的真实数据之外,还要加上两个单元,用来存储表头信息,存储表头信息仍然是一个整数。
第一种方式:表头的两个部分和下面的空间是连续存储的。表头部分也有存储地址。
第二种方式:表头和数据区分离,表头和数据区的存储空间和存储位置不是一次性连续申请回来的。表头和数据区不关联,表头区的地址和数据区的地址不关联。
li这个定义的顺序表指向的是表头区(即表头区的地址),表头区不能还要存数据区的地址。0x111代表表头区的其实,顺序表通过0x111找到表头,然后表头又通过0x23找到数据区。
两者比较:
连接的顺序表:通过数据区的地址可以直接跳过表头定位到数据区,访问方便。
分离式顺序表:必须通过访问表头来间接访问数据区,
真正在作数据存储时:
连接的一体式顺序表必须进行整个顺序表的替换,而分离式顺序表由于表头和数据区只是连接关系,可以在不更改表头的情况下将表头所指的数据区更换。而表头就指代顺序表,表头的地址不改变,顺序表的地址也没有改变,其他使用该顺序表的地方也不必改变
元素存储区替换
一体式结构由于顺序表信息区与数据区连续存储在一起,所以若想更换数据区,则只能整体搬迁,即整个顺序表对象(指存储顺序表的结构信息的区域)改变了。
分离式结构若想更换数据区,只需将表信息区中的数据区链接地址更新即可,而该顺序表对象不变。
元素存储区扩充
采用分离式结构的顺序表,若将数据区更换为存储空间更大的区域,则可以在不改变表对象的前提下对其数据存储区进行了扩充,所有使用这个表的地方都不必修改。只要程序的运行环境(计算机系统)还有空闲存储,这种表结构就不会因为满了而导致操作无法进行。人们把采用这种技术实现的顺序表称为动态顺序表,因为其容量可以在使用中动态变化。
扩充的两种策略
每次扩充增加固定数目的存储位置,如每次扩充增加10个元素位置,这种策略可称为线性增长。
特点:节省空间,但是扩充操作频繁,操作次数多。
每次扩充容量加倍,如每次扩充增加一倍存储空间。
特点:减少了扩充操作的执行次数,但可能会浪费空间资源。以空间换时间,推荐的方式。
顺序表的操作
Python中的顺序表
Python中的list和tuple两种类型采用了顺序表的实现技术,具有前面讨论的顺序表的所有性质。
tuple是不可变类型,即不变的顺序表,因此不支持改变其内部状态的任何操作,而其他方面,则与list的性质类似。
list的基本实现技术
Python标准类型list就是一种元素个数可变的线性表,可以加入和删除元素,并在各种操作中维持已有元素的顺序(即保序),而且还具有以下行为特征:
- 基于下标(位置)的高效元素访问和更新,时间复杂度应该是O(1);
为满足该特征,应该采用顺序表技术,表中元素保存在一块连续的存储区中。 - 允许任意加入元素,而且在不断加入元素的过程中,表对象的标识(函数id得到的值)不变。
为满足该特征,就必须能更换元素存储区,并且为保证更换存储区时list对象的标识id不变,只能采用分离式实现技术。
在Python的官方实现中,**list就是一种采用分离式技术实现的动态顺序表。**这就是为什么用list.append(x) (或 list.insert(len(list), x),即尾部插入)比在指定位置插入元素效率高的原因。
在Python的官方实现中,list实现采用了如下的策略:在建立空表(或者很小的表)时,系统分配一块能容纳8个元素的存储区;在执行插入操作(insert或append)时,如果元素存储区满就换一块4倍大的存储区。但如果此时的表已经很大(目前的阀值为50000),则改变策略,采用加一倍的方法。引入这种改变策略的方式,是为了避免出现过多空闲的存储位置。
链表
顺序表的特点
要求存储空间必须连续,而且一旦存储空间不够时就要动态地改变数据区,
有一种数据结构,在扩充存储空间时原有的数据完全不用变,多一个数据就增加一个存储空间。
比如:
构造了一组数据,第一次是只有一个200,申请一个存储空间把200存下来,然后补充一个400,再找一个空间,把400存下来,现在不按照顺序表方式把200和400放到一起。而且在扩增时,构建时也不计划我们构建的这个数据存储区要存多少数据,来一个存一个。现在要将他们封装为一个数据结构,当作一个整体,这样分散的离散的不同的存储空间,在数据地址上没有连续性的概念了,如何将他们关联到一起?
现在找一根线把他们串起来,
链表:元素和元素之间,或节点和节点之间是靠一条链连接起来的,这样的数据结构形式叫做链表。
为什么需要链表
顺序表的构建需要预先知道数据大小来申请连续的存储空间,而在进行扩充时又需要进行数据的搬迁,所以使用起来并不是很灵活。
链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。
链表的定义
链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是不像顺序表一样连续存储数据,而是在每一个节点(数据存储单元)里存放下一个节点的位置信息(即地址)。
如何实现链表
要从200找到400,就必须在200中保存一个400存储单元的位置,那么现在对于原有的数据元素,要扩展,第一个位置保存数据,第二个位置保存地址。
单向链表
单向链表也叫单链表,是链表中最简单的一种形式,它的每个节点包含两个域,一个信息域(元素域)和一个链接域。这个链接指向链表中的下一个节点,而最后一个节点的链接域则指向一个空值。
表元素域elem用来存放具体的数据
链接域next用来存放下一个节点的位置(python中的标识)
变量p指向链表的**头节点(首节点)**的位置,从p出发能找到表中的任意节点。
用程序去实现这个数据结构
- 对于数据结构,用程序写的话,要定义成类。
之前引入的**ADT 抽象数据类型(Abstract Data Type)**的概念
数据结构实现时,一方面要去解决数据的保存问题,另一方面要定义出来对于这样的数据结构支持什么操作。 - 单链表要实现的操作
is_empty() 链表是否为空
length() 链表长度
travel() 遍历整个链表
add(item) 链表头部添加元素
append(item) 链表尾部添加元素
insert(pos, item) 指定位置添加元素
remove(item) 删除节点
search(item) 查找节点是否存在 - python中交换变量值的操作原理
- a = 10,10存储起来到存储空间中,a保存的是10的存储地址,a指向10.因而a和b交换值的时候,只是交换来保存的地址,改变了指向。
变量是一个名字或一个引用,它维护的只是一个地址,这个地址指向不同,就表明a代表的对象不同。
python中没有一个专门的地址变量 - 链表定义为一个类
这个类中包含2个区域,一个是element,元素,一个是下一个节点的位置,next,next=node2,node2指向下一个节点。 - 写代码
实现这个单链表时候,对于单链表的每一个节点,没有数据类型能够保存,这个节点要包含两个元素:1,elem,2,next。
不按照python语言的特殊性,按照所有语言的一般性,把结点抽象出来,封装成一个类,节点类。先来构造这个节点。
# Node
class Node(object):
"""节点"""
def __init__(self,elem):
self.elem = elem
self.next = None
# 在类中实现什么东西?没有思路的话得反着来,定义好这个类之后如何去使用。
# Node代表链表中每个节点的抽象,使用时有一个节点就要产生一个Node类的对象,这样在使用时应保存什么内容?
#要保存数据和下一个链接的地址。这样,设计时,数据放到什么里面去做?构造时就把要保存的数据传进来。
# 比如现在有一个节点要保存100,那就在构造时,代表这个节点保存100这个数的节点,将其放到构造函数中。
# 现在把保存的数据元素放进去那就意味着__init__方法还要接收下来.接着还要把elem保存到对象中。
# 节点对象涉及到2个属性区,元素区和next区,那就要把elem保存到节点中的元素区,不知道节点要放到链表中的哪个位置,所以next区指向谁还不知道,所以先设置为空,不指向任何对象。
# 对于一个节点的初始状态,有了节点对象,数据区有,但是指向谁一开始还不知道,所以先指向none。
# 构造这个节点类之后,节点就有这个Node类来描述。紧接着就要定义一个链表的结构。
node = Node()
node2 = Node()
# 单链表
class SingleLinkList(object):
"""单链表"""
#def __init__(self):
#self.__head = None #这个head仅仅是这个单链表结构维护的。外面使用的人不需知道这个属性。head就是内部函数自己去使用,对外不爆露,即私有,前面加下划线。私有属性
# 私有属性有了,前面链表头初始化什么。建一个空链表,没有任何节点在里面,haed指向空,还没有节点。
# 假设用户使用时先创建了节点,再创建单链表。意思是一上来给一个节点,让这个链表指向这个节点,构造时就要把这个节点也传进来。传进来时head就要指向用户传进来的node。
def __init__(self,node=None): #将上个类Node实例化为node,node的属性在这个类中都可以用。
self.__head = node
# 使用时先构建一个对象node,使用时先保存100这个数,这个节点有了。节点有了之后再去构建这个单链表,构造时把node传进来,表示新建的这个单链表的头节点是刚写的这个node,那么构造函数就可以接收node。假设用户不传这个值,也可以正常用,不穿就认为构造的是空链表,空链表指向None,所以使设置默认参数。
# 考虑单链表要实现那些东西?把节点串联起来。
# 对于链表的数据结构前面列出了链表要实现的那些操作。把节点串起来就相当于add()/append()操作。
# 先把要实现的操作定义进来。这些方法都是具体的对象方法,不是类方法
# 自己定义的链表这个抽象数据结构,要实现这些方法。除了这个方法,还要实现什么?
# 空的链表对象,存数据时要构造节点。第一个节点有了,这个节点作为链表的头节点,这个节点的地址要保存下。如果不保存,这个节点和整个链表就没关系。
# Code对象要挂到单链表对象中,否则节点只是节点,和链表没关系,链表中必须保存一个头节点的地址。得有地址指向第一个节点。这样节点就挂到链表上了。
# 链表中必须存在一个属性,这个属性指向头节点,这个属性是对象属性。
def is_empty(self): # 是否为空,要接收自己
"""链表是否为空"""
return self.__head == None #__head指向的node为空,节点没有,链表即为空
def length(self): #返回链表长度,不需要参数.self不是参数
"""链表长度""" #链表长度就是其中存在多少节点,节点数目
# cur为游标,用来移动遍历节点
cur = self.__head
# count记录数量
count = 0 #__head还没指定对象时,链表为空,count为0。考虑到链表为空,__head指向None的情况。count设定初始值就从最开始的条件来考虑。
while cur != None:
count += 1 #一进入循环体,__head指向一个对象,游标定位到第一个节点,count加1
cur = cur.next #指向现在节点的next域,而不是下一个节点。这样在最后一个节点,next域为None时,刚好数到最后一个节点,而不数下一个节点。
return count
def travel(self): # 遍历链表
"""遍历整个链表"""
cur = self.__head
while cur != None:
print(cur.elem)
cur = cur.next
def add(self,item): #添加一个具体的节点
"""链表头部添加元素,头插法"""
node = Node(item)#要在链表头部添加元素,首先要把数据元素构造为链表节点。
node.next = self.__head # 为保证原链表的所有节点不丢掉,应首先操作新节点的next域,让新节点的next域指向原有的头节点的head
self.__head = node #新的链表形成,现在来考虑特殊情况。特殊情况:一开始链表是一个空链表,__head指向none。node.next = self.head,新节点的next域还是none,self.__head=node,新节点被指定为新链表的头部。
def append(self,item):
"""链表尾部添加元素,尾插法""" # 添加节点,游标走,走到最后一个节点,把新的节点挂上。新的节点将其对象构造出来。上面传入的参数item不是节点,而是一个具体的数据元素,因为要让用户去实现时,不须关心节点如何实现的,只须知道节点要存什么数据就可以,然后把这个数据封装为一个节点存进去。
node = Node(item)# 一上来就要构造这个节点。
if self.is_empty():
self.__head= node
else:
cur = self.__head# 游标往后走,让前一个节点的__next区域指向新的节点就完成了。
while cur.next != None:
cur = cur.next
cur.next = node
def insert(self,pos,item):
"""指定位置添加元素:param pos指下标, 从0开始"""
# insert(2,100),将100添加到2位置,就是1位置之后,2位置之前。达到的效果是1位置指向新的节点,新节点next区域指向后面
# 先改变新节点的next区域,保证原来链表不打断,再去改变原有链表,先找到原来下标位置所指,是遍历
# 游标要指向1位置,而不是2位置,这样才能插到1和2之间,改游标名为pre
# 改变新节点的next区域,指定原链表的2位置(由1位置节点的next区域指定),node.next=pre.next,pre.next=node
if pos <= 0: #在链表最头部添加元素,执行头插法
self.add(item)
elif pos >= (self.length()-1): #pos位置大于链表长度,就在尾部添加
self.append(item)
else:
pre = self.__head # 原本是__head指向头节点,现在由pre指向头节点.让pre游标向后移动
count = 0
while count < (pos-1): #while循环向后移动,移动中如何让其停下来。用数数方式,新定义一个变量去定义数数,count。count跟pos相关,pos是条件。
count += 1
pre = pre.next #pre游标向后移,通过改变指定。循环退出后,pre指向pos-1位置
node = Node(item)
node.next = pre.next
pre.next = node #考虑特殊情况,
def remove(self,item):
"""删除节点""" #不是按具体位置删除,而是删除具体数据,为了找到要删除的数据,还得遍历,遍历要游标,用几个?
#删除就是让被删除数据的前一个节点的next域指向被删除数据的下一个节点,pre.next=cur.next
#按照上面,需要知道cur的前面pre,需用到2个游标
#2个游标如何初始化,cur指向头节点,cur=_head,现在就是头节点,前面节点不存在了,pre=None,紧接着如何移动?
# 先让pre往后移,先让pre指向cur指向的节点,pre由None指向头节点。然后cur=cur.next。达到了cur和pre往后移时cur永远在pre前面。
# 考虑节点在链表中哪个位置,1,理想状态,节点在链表中间。2,链表一开始是空链表,不操作,3,删除链表中首节点,4,链表中仅有一个节点,该节点正好被删除,5,删除尾部,
cur = self.__head
pre = None
while cur != None: # 保证cur遍历完所有
if cur.elem == item:#判断此节点是否为头节点,头节点
if cur == self.__head:
self.__head = cur.next
else:
pre.next = cur.next #节点在链表中间,理想状况
break
else:
pre = cur
cur = cur.next
def search(self,item):
"""查找节点""" #遍历链表去比对其中数据,用游标从头往后去比对
while cur != None: #循环的终止条件。啥时候截至有2种情况:cur.next=None或
if cur.elem == item: #先做判断条件,cur.elem == item,如果没有游标往后移.这个过程是一次一次执行的,所以循环
return Ture #return返回,循环停止
else:
cur = cur.next
return False #看特殊情况,一上来就是空链表。
node = Node(100)
single_obj = SingleLinkList()
对上面代码的解释:
这个cur就是你的self.__head,而self是类中的基础属性都包含的。可以直接使用。就是说直接可以调用。而你的 self.__head = node,相当于把node对象传给__head了,因此,cur就可以使用node的属性。这个也不是直接使用的,是你创建singlelinklis的时候才可以。
代码运行结果:
补充概念:后继节点,即指定节点的下一个节点,
链表与顺序表的对比
对上面的解释
- 链表的过程和顺序表有区别:
链表不能一次性定位到那个位置,为了找到某一个位置的下标,就必须从头往后找才能找到,这是链表自身所决定的。
所以链表的实现代码,再优化也只是减少一些变量,而时间复杂度不会改变。
链表只记录头节点,为了找到其他节点,只能从头往后顺序去走。 - 顺序表换成链表时间复杂度提升,更复杂,为什么还用链表?
因为链表在存储数据时,每个节点对应到内存中,内存是可以分散的,假如说要存入一个巨大的数据,而没有操作系统没有一块连续的内存可以分配,他能把内存中所有分散的只要可用的通过链表可以串起来,用顺序表就达不到这个效果。
链表的缺点是为了存储数据要把存储空间增大(存数据和地址),因而链表在存数据时会多占内存,但是链表占用内存是可以分散的,
顺序表的优点是存取数据时可以一次性定位,缺点是顺序表的存储空间必须是连续的,如果动态改变整个存储区都要改变。而且当保存数据大的时候,如果没有这么多的连续存储空间,顺序表不行。
链表就可以对分散的离散空间进行充分利用,缺点是利用空间同时额外的开销很大,而且存取元素时只能从头往后去遍历。 - 对于插入操作来说,虽然链表和顺序表的时间复杂度都是O(n),但是代表的重复操作不同,对于链表来说n是用在遍历上,而顺序表的n用在了数据搬迁上。
双向链表
引入
单向链表之所以叫单向是因为连接区域都指向后继节点,那么对于后面的节点,就找不到他的前一个节点,只能从头往后找,找到它的前一个节点。所以给单链表进行扩充,之前单向链表的节点只存一个位置区域,指向下一个节点,现在把节点重新构造,还要保存前一个地址。这样节点就有了数据区、后继区、前继区。
双向链表解析
实现双链表操作
class Node(object):
"""双向链表节点"""
def __init__(self, item):
self.item = item
self.next = None
self.prev = None
class DLinkList(object):
"""双向链表"""
def __init__(self):
self._head = None
def is_empty(self):
"""判断链表是否为空"""
return self._head == None #推荐用return self._head is None
def length(self):
"""返回链表的长度"""
cur = self._head
count = 0
while cur != None:
count += 1
cur = cur.next
return count
def travel(self):
"""遍历链表"""
cur = self._head
while cur != None:
print cur.item,
cur = cur.next
print ""
def search(self, item):
"""查找元素是否存在"""
cur = self._head
while cur != None:
if cur.item == item:
return True
cur = cur.next
return False
既然上面的Class DLinkList类中的方法.is_empty,.length,.travel,.search方法和上面的Class SingleLinkList类中的方法一样,那就可以直接继承Class DLinkList类,不用重写了,只写Class DLinkList类中和Class SingleLinkList类中不一样的方法。如下:
class Node(object):
"""双向链表节点"""
def __init__(self, item):
self.item = item
self.next = None
self.prev = None
class DLinkList(SingleLinkList):
"""双向链表"""
def __init__(self):
self._head = None
def add(self, item):
"""头部添加元素"""
# 先创建一个保存item值的节点
node = SingleNode(item)
node.next = self._head
self._head = node
#原有链表中首节点的p,插入新节点后,要让p指向新节点的next,这是双链表和单链表的区别。补充双向链表的p区改变
node.next.prev = node #换种方式实现,self.__head.prev=node,self.__head=node,
class DLinkList(SingleLinkList):
"""双向链表"""
def __init__(self):
self._head = None
def append(self, item):
"""尾部插入元素"""
node = Node(item)
if self.is_empty():
# 如果是空链表,将_head指向node。node的p没有,n也没有,所以不用指
self._head = node
else:
# 移动到链表尾部
cur = self._head
while cur.next != None:
cur = cur.next
#遍历过程一样,只是如何添加的步骤不同
# 将尾节点cur的next指向node
cur.next = node
# 将node的prev指向cur
node.prev = cur
def insert(self, pos, item):
"""在指定位置添加节点"""
if pos <= 0:
self.add(item)
elif pos > (self.length()-1):
self.append(item)
else: #单链表中多一个游标pre,指代前面的节点,但是双链表中单一节点可以找到前后所指的节点,所以有一个游标就可以
cur = self._head
count = 0
while count < pos:# 移动到指定位置
count += 1
cur = cur.next#循环结束,cur指向pos位置
node = Node(item)
node.next = cur
node.prev = cur.prev
cur.prev.next = node
cur.prev = node
def remove(self,item):
"""删除节点"""
cur = self._head
while cur != None:
if cur.elem == item:
if cur == self.__head:#先判断此节点是否是头节点
self.__head = cur.next #头节点
if cur.next:#判断链表是否只有一个节点,不是则执行下面
cur.next.prev = None
else:
cur.prev.next = cur.next
if cur.next: #判断是否为尾节点,不是尾节点执行下面操作。尾节点则仅执行上面操作
cur.next.prev = cur.prev
break
else:
cur = cur.next
单向循环链表
代码操作
class Node(object):
"""节点"""
def __init__(self, item):
self.elem = item
self.next = None
class SinCycLinkedlist(object):
"""单向循环链表"""
def __init__(self,node=None):
self._head = node
if node:
node.next = node
def is_empty(self):
"""判断链表是否为空"""
return self._head == None
def length(self):
"""返回链表的长度"""
# 如果链表为空,返回长度0
if self.is_empty():
return 0
count = 1 #是从0还是从1开始,要验证走一次
cur = self._head
while cur.next != self._head: #条件变了,接着要看前面的count是从几开始
count += 1
cur = cur.next
return count
def travel(self):
"""遍历链表"""
if self.is_empty():
return
cur = self._head
print cur.item,
while cur.next != self._head:#移动过程中要看遍历过程中是否会丢元素
print (cur.elem,end="")
cur = cur.next
# 遍历到最后一个节点,cur.next=self.head,退出循环,最后一个节点没打印,最后手动打印出来
print(cur.elem)
# 最后一步完成,考虑特殊情况:链表中仅有一个节点
def add(self, item): #必须找到尾节点,把尾节点的next指向新节点,所以用到遍历
"""头部添加节点"""
node = Node(item)
if self.is_empty():
self._head = node
node.next = self.__head
else:
cur = self.__head
while cur.next != self._head:
cur = cur.next
node.next = self.__head
self._head = node
cur.next = self.__head
def append(self, item):
"""尾部添加节点"""
node = Node(item)
if self.is_empty():
self._head = node
node.next = self._head
else:
# 移到链表尾部
cur = self._head
while cur.next != self._head:
cur = cur.next
# 将尾节点指向node
cur.next = node
# 将node指向头节点_head
node.next = self._head
def insert(self, pos, item):
"""在指定位置添加节点"""
if pos <= 0:
self.add(item)
elif pos > (self.length()-1):
self.append(item)
else:
node = Node(item)
pre = self._head
count = 0
# 移动到指定位置的前一个位置
while count < (pos-1):
count += 1
pre = pre.next
node.next = pre.next
pre.next = node
def remove(self, item):
"""删除一个节点"""
# 若链表为空,则直接返回
if self.is_empty():
return
cur = self.__head
pre = None #定义好2个游标之后,要遍历,定义好遍历条件
while cur.next != self.__head:
if cur.elem == item:
if cur == self.__head:#先判断是否为头节点。下面为头节点情况
rear = self.__head #找到尾节点
while rear.next != self.__head:
rear = rear.next
self.__head = cur.next
rear.next = self.__head
else: #中间节点
pre.next = cur.next
return #不能用break,break只是跳出循环,而这里是要满足这个条件时退出整个函数
else:
pre = cur
cur = cur.next
# 退出循环,cur指向尾节点
if cur.elem == item:
if cur == self.__head:#链表只有一个节点
self.__head =None
else:
pre.next = self.__head
def search(self,item):
"""查找节点是否存在"""
if self.is_empty():
return False
cur = self._head
if cur.item == item:
return True
while cur.next != self._head:
cur = cur.next
if cur.elem == item:
return True
return False
if __name__ == "__main__":
ll = SinCycLinkedlist()
ll.add(1)
ll.add(2)
ll.append(3)
ll.insert(2, 4)
ll.insert(4, 5)
ll.insert(0, 6)
print "length:",ll.length()
ll.travel()
print ll.search(3)
print ll.search(7)
ll.remove(1)
print "length:",ll.length()
ll.travel()
单向链表、单向循环链表、双向链表都可以按照自己的要求区扩充,比如,双向循环链表,给链表加上顺序表一样的表头信息。可以自己去实现。
如何去用线性表,就要用到栈