数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法

1. 数据结构

算法是一种思想,是解决问题的思路。算法关注的是解决问题的思想、思路,没有看到底是处理什么东西(是int还是list还是dict之类的我们都是不关注的)。而接下来要讲的数据结构就是负责这部分 —— 怎么把一堆数据组成起来。


我们如何用Python中的类型来保存一个班的学生信息?如果想要快速的通过学生姓名获取其信息呢?

"""
   问题:我们如何用Python中的类型来保存一个班的学生信息?如果想要快速的通过学生姓名获取其信息呢?
   
   思路:既然是存储数据,Python中可选的数据类型有:
       1. list
       2. dict
       3. set(不可更改且没有重复值 -> 不推荐)
       4. tuple(不可更改 -> 不推荐)
       
   --------------------------------
   
   list:
       [  # list(dict)
           {
               "name": "zhangsan",
               "age": 23,
               "hometown": "beijing"
           }
       ]
       
       T(n) = O(n)
--------------------------------
       
        [  # list(tuple)
           ("name": "zhangsan"),
           ("age": 23),
           ("hometown": "beijing")
       ]
       
       T(n) = O(n)
--------------------------------
   dict:
       {  # dict(dict)
           "zhangsan": {
               "age": 23
               "hometown": "beijing"
           }
       }
       T(n) = O(1)
       
--------------------------------

我们可以发现,不同的数据组织方式有着不同的时间复杂度。数据组织方式就是数据结构。
"""

实际上当我们在思考这个问题的时候,我们已经用到了数据结构。列表和字典都可以存储一个班的学生信
息,但是想要在列表中获取一名同学的信息时,就要遍历这个列表,其时间复杂度为 O ( n ) O(n) O(n),而使用字典存储时,可将学生姓名作为字典的键,学生信息作为值,进而查询时不需要遍历便可快速获取到学生信息,其时间复杂度为 O ( 1 ) O(1) O(1)

我们为了解决问题,需要将数据保存下来,然后根据数据的存储方式来设计算法实现进行处理,那么数据的存储方式不同就会导致需要不同的算法进行处理。我们希望算法解决问题的效率越快越好,于是我们就需要考虑数据究竟如何保存的问题,这就是数据结构

在上面的问题中我们可以选择Python中的列表或字典来存储学生信息。列表和字典就是Python内建帮我们封装好的两种数据结构

1.1 数据类型的概念

数据是一个抽象的概念,将其进行分类后得到程序设计语言中的基本类型。如:intfloatchar等。数据元
素之间不是独立的,存在特定的关系,这些关系便是结构。

数据结构指数据对象中数据元素之间的关系

Python给我们提供了很多现成的数据结构类型,这些系统自己定义好的,不需要我们自己去定义的数据结构叫做Python的内置数据结构,比如列表(list)、元组(tuple)、字典(dict)、集合(set)。而有些数据组织方式,Python系统里面没有直接定义,需要我们自己去定义实现这些数据的组织方式,这些数据组织方式称之为Python的扩展数据结构,比如栈,队列等

库提供的都不是Python给我们的

1.2 算法与数据结构的区别

数据结构只是静态的描述了数据元素之间的关系。

高效的程序需要在数据结构的基础上设计和选择算法

程序=数据结构+算法

程序
数据结构
算法

总结:算法是为数据结构是算法需要处理的问题载体

1.3 抽象数据类型(Abstract Data Type)

抽象数据类型(ADT) 的含义是指一个数学模型以及定义在此数学模型上的一组操作。即把数据类型和数据类型
上的运算捆在一起,进行封装

引入抽象数据类型的目的是把数据类型的表示和数据类型上运算的实现与这些数据类型和运算在程序中的引用隔开,使它们相互独立。

把原有的基本数据跟支持其数据的操作放在一起,形成一个整体,这就是抽象数据类型 —— 有点面向对象的感觉。

最常用的数据运算有五种:

  • 插入
  • 删除
  • 修改
  • 查找
  • 排序

类似于数据库中增删改查

下面我们举个例子:

"""
[  # list(tuple)
   ("name": "zhangsan"),
   ("age": 23),
   ("hometown": "beijing")
]
"""
# 抽象数据类型
class Stus(object):  # 构建了一个Stus类,表示该班级的所有学生,这个class就是一个新的数据类型
    def adds(self):  # 该数据结构的方法1
        pass
    def pop(self):  # 该数据结构的方法2
        pass
    def sort(self):  # 该数据结构的方法3
        pass
    def modify(self):  # 该数据结构的方法4
        pass
    
"""
    上面的Stus这个类是可以保存信息的,这就是一个数据类型。而且我们要定义好这个类(数据类型)支持的方法,
    即便没想好,我们应该也先写出来占位 —— 相当于提供了一个API
"""

2. 顺序表

在程序中,经常需要将一组(通常是同为某个类型的)数据元素作为整体管理和使用,需要创建这种元素组,用变量记录它们,传进传出函数等。一组数据中包含的元素个数可能发生变化(可以增加或前除元素)。

对于这种需求,最简单的解决方案便是将这样一组元素看成一个序列,用元素在序列里的位置和顺序,表示实际应用中的某种有意义的信息,或者表示数据之间的某种关系。

这样的一组序列元素的组织形式,我们可以将其抽象为线性表。一个线性表是某类元素的一个集合,还记录着元素之间的一种顺序关系。

线性表是最基本的数据结构之一,在实际程序中应用非常广泛,它还经带被用作更复杂的数据结构的实现基础。

根据线性表的实际存储方式,分为两种实现模型:

  • 顺序表:将元素顺序地存放在一块连续的存储区里,元素间的顺序关系由它们的存储顺序自然表示。
  • 链表:将元素存放在通过链接构造起来的一系列存储块中。

举个例子:

int = 1, 2, 3, 4, 5

那个这5个整数如何当作一个整体存储起来?

只能使用Python最基本的数据类型,list, tuple, dict, set这些Python封装的高级的数据结构都不可以使用。可以使用的有:int, float, str

所以我们要研究一下它们的本质 —— 因此我们需要引入内存的概念。

2.1 顺序表的形式

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第1张图片


数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第2张图片

图a表示的是顺序表的基本形式,数据元素本身连续存储,每个元素所占的存储单元大小固定相同,元素的下标是其逻辑地址,而元素存储的物理地址(实际内存地址)可以通过存储区的起始地址 L o c ( e 0 ) \mathrm{Loc}(e_0) Loc(e0)加上逻辑地址(第个元素)与存储单元大小 c c c 的乘积计算而得,即:
L o c ( e i ) = L o c ( e 0 ) + c × i \mathrm{Loc}(e_i) = \mathrm{Loc(e_0)} + c\times i Loc(ei)=Loc(e0)+c×i
故,访问指定元素时无需从头遍历,通过计算便可获得对应地址,其时间复杂度为 O ( 1 ) O(1) O(1)

举个例子:ls = [200, 390, 78, 12112]

对于这样一组数,我们可以使用顺序表将它们存储起来。

对于操作系统来说:

  • 如果是32位的,那么一个int占32个bit位(4个Byte字节)
  • 如果是64位的,那么一个int占64个bit位(8个Byte字节)

操作系统在标识计算机内存时,最小寻址单位为Byte(字节)

1 Byte = 8 bit

Li这个变量指向0x23这个地址。如果我们想取Li[0],即Li的起始地址中的内容,所以从0x23开始读取4个Byte;如果取Li[2],即Li的起始地址+2c,即从0x23 + 2 × 4Byte = 0x31这个地址读取4个字节。如果取Li[3],即Li的起始地址+3c,即从0x23 + 3 × 4Byte = 0x35这个地址读取4个字节。

接触到这个概念后,我们也就知道了,为什么在tuple, list中其实元素是从0开始的了。

从现在开始我们就明白了,下标、索引其实就是偏移量的意思


上面的顺序表成立的条件是:每一个数据所占用的字节数是相同的。但是实际情况是,list中的元素不仅仅可以存储int,还可以存储float,甚至是list, tuple, string...。这里面的元素占的字节个数不一定是相同的。因此还使用上面的存在方式,是不行的。


假设现在有这样一组数据:li = [12, “ab”]

上面中:

  • 12:是数字,所有占4个字节
  • "ab"是字符串,占2个字节(其实是3个)

"ab"有两个char,但其实是有3个char(字符)的,还有一个/0没有显示。

一个char占一个Byte

现在我们发现,这些数据占的字节大小已经不统一了,已经不能用上面将的顺序表的方式存储了。那么我们该如何存储呢?

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第3张图片

我们在上面讲的时候发现,每个存储单元(里面放数据的)都会有一个相应的存储编号,我们称它们为地址,代表了数据到底位于内存中的哪里。我们发现,这个地址本身也是由数据组成的,0x··。而且这个地址还有一个特性:无论地址存储的是什么数据,地址所占用的大小是统一的,都会占4个字节。

既然这样,12和"ab"的地址都是4个字节。于是操作系统向内存申请了一个存储单元,其地址为0x100,里面存储的数据是12;又申请了一个存储单元,里面存储了"ab",地址为0x200(这里的地址就不要求是连续的了)。如下图所示:

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第4张图片

对于这两个存储单元,因为存储着不同类型的数据,所以大小也不同,但它们都有各自的地址来标识这两个存储单元。而且二者的地址大小是相同的,都是4个字节。所以说,我们是否可以将存储单元的地址存储起来,用的时候根据这个地址再找对应的数据。这就引出了 —— 元素外置的顺序表。

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第5张图片

如果元素的大小不统一,则须采用图b的元素外置的形式,将实际数据元素另行存储,而顺序表中各单元位置保存对应元素的地址信息(即链接)。由于每个链接所需的存储量相同,通过上述公式,可以计算出元素链接的存储位置,而后顺着链接找到实际存储的数据元素。注意,图b中的 c c c 不再是数据元素的大小,而是存储一个链接地址所需的存储量,这个量通常很小。

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第6张图片

li指向的地址为0x324

  • 找li[0]:1. 0x324 + 4×0 = 0x324 -> 0x100 -> 12
  • 找li[1]:1. 0x324 + 4×1 = 0x328 -> 0x200 -> "ab"
  • 找li[2]:1. 0x324 + 4×2 = 0x332 -> 0x53 -> "1.111"
  • 找li[3]:1. 0x324 + 4×3 = 0x336 -> 0x110 -> 1000

相比与数据内置的顺序表,数据外置的顺序表要多了一步,但好处是存储的元素所占用字节数可以不相同。

上面的例子中:

  • 逻辑地址:0, 1, 2, 3

  • 物理地址:0x324, 0x328, 0x332, 0x336

2.2 顺序表的结构与实现

因为在Python中已经对这一部分进行封装,所以我们不需要考虑使用Python如何实现顺序表,我们看一下如果用别的语言该怎么做。

不需要纠结语言,我们只是看一下顺序表该怎么构建

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第7张图片

一个顺序表的完整信息包括两部分:

  1. 一部分是表中的元素集合 —— 数据区

  2. 另一部分是为实现正确操作而需记录的信息 —— 表头信息

    有关表的整体情况的信息,这部分信息主要包括

    • 元素存储区的容量 —— 最大存储数据(地址)的个数
    • 当前表中已有的元素个数 —— 已经存储数据(地址)的个数

如果我们要存储8个元素,那么应该要8个以上的容量,如果少了,比如我们要了8个容量,但如果有第9个元素。如果再申请一个容量,就不能保证数据区连续了,也就不能用索引来找到对应的地址了。

2.2.1 顺序表的两种基本实现方式

现在数据区和表头信息有了,那么我们该如何将二者结合起来呢?

有两种方式:

  1. 一体式结构
  2. 分离式结构

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第8张图片

这两种方式都是可以的。接下来我们需要考虑这两种方式的优势与劣势。

2.2.2 一体式结构

图a为一体式结构,存储表信息的单元与元素存储区以连续的方式安排在一块存储区里,两部分数据的整体形成一个完整的顺序表对象。

优点:一体式结构整体性强,易于管理。

缺点:由于数据元素存储区域是表对象的一部分,顺序表创建后,元素存储区就固定了

2.2.3 分离式结构

图b为分离式结构,表对象里只保存与整个表有关的信息(即容量和元素个数),实际数据元素存放在另一个独立的元素存储区里,通过链接与基本表对象关联。

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第9张图片

在实际使用时,我们经过会定义一个列表,然后再往里面添加元素。

对于顺序表而已,如果我们容量不够了,只能先申请一个更大的容量,再对整个顺序表进行数据迁移(因为顺序表要求数据必须连续!)

  • 对于一体式结构而言,

    1. 数据区需要迁移
    2. 表头信息也需要迁移
    3. 变量指向的表头起始地址(指向顺序表的起始地址)也需要进行相应的改变
  • 如果使用分离式结构,那么:

    1. 只需要迁移数据区,表头信息不需要迁移

    2. 表头信息中指向数据区的地址进行更换

      变量指向的表头起始地址(指向顺序表的起始地址)不需要进行更换。


我们可以发现,不管是一体式结构还是分离式结构,在追加新元素时,如果容量不够了,需要进行扩充和迁移。那么问题来了 —— 在扩充时究竟要扩充多大呢?

  • 扩充小了 —— 没准还需要扩充
  • 扩充大了 —— 浪费内存资源

2.2.4 元素存储区扩充

采用分离式结构的顺序表,若将数据区更换为存储空间更大的区域,则可以在不改变表对象的前提下对其数据存储区进行了扩充,所有使用这个表的地方都不必修改。只要程序的运行环境(计算机系统)还有空闲存储,这种表结构就不会因为满了而导致操作无法进行。人们把采用这种技术实现的顺序表称为动态顺序表因为其容量可以在使用中动态变化


扩充的两种策略

  1. 每次扩充增加固定数目的存储位置,如每次扩充增加10个元素位置,这种策略可称为线性增长。

    4 -> 14 -> 24 -> 34 -> 44 -> 54 -> 64 -> ...

    特点:节省空间,但是扩充操作频繁,操作次数多。

  2. 每次扩充容量加倍,如每次扩充增加一倍存储空间。

    4 -> 8 -> 16 -> 32 -> 64 -> 128 -> 256 -> ...

    特点:减少了扩充操作的执行次数,但可能浪费空间资源。以空间换时间,是推荐的方式

2.3 顺序表的操作

2.3.1 增加元素

如图所示,为顺序表添加新元素111有三种方式:

  1. 表尾端加入元素 —— 时间复杂度为 O ( 1 ) O(1) O(1)
  2. 非保序的元素插入(不常见)—— 时间复杂度为 O ( 1 ) O(1) O(1)
  3. 保序的元素插入—— 时间复杂度为 O ( n ) O(n) O(n)

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第10张图片

2.3.2 删除元素

  1. 删除表尾元素 —— 时间复杂度为 O ( 1 ) O(1) O(1)
  2. 非保序的元素删除(不常见) —— 时间复杂度为 O ( 1 ) O(1) O(1)
  3. 保序的元素删除 —— 时间复杂度为 O ( n ) O(n) O(n)

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第11张图片

2.4 Python中的顺序表

Python中的listtuple两种类型采用了顺序表的实现技术,具有前面讨论的顺序表的所有性质。

tuple是不可变类型,即不变的顺序表。因此不支持改变其内部状态的任何操作,而其他方面,则与list的性质类似。

2.4.1 list的基本实现

Python标准类型Iist就是一种元素个数可变的线性表,可以加入和删除元素,并在各种操作中维持已有元素的顺序(即保序),而且还具有以下行为特征:

  • 基于下标(位置)的高效元素访问和更新,时间复杂度应该是 O ( 1 ) O(1) O(1)

    为满足该特征,应该采用顺序表技术,表中元素保存在一块连续的存储区中。

  • 允许任意加入元素,而且在不断加入元素的过程中,表对象的标识(函数id得到的值)不变。

    为满足该特征,就必须能更换元素存储区,并且为保证更换存储区时list对象的标识id不变,只能采用分离式实现技术

在Python的官方实现中,list就是一种采用分离式技术实现的动态顺序表。这就是为什么用list.append(x)(或list.insert(len(list), x),即尾部插入)比在指定位置插入元素效率高的原因

在Python的官方实现中,list实现采用了如下的策略:

  • 在建立空表(或者很小的表)时,系统分配一块能容纳8个元素的存储区(认为是一个int,也就是4个字节);
  • 在执行插入操作(insertappend)时,如果元素存储区满就换一块4倍大的存储区。但如果此时的表已经很大(目前的阈值为50000),则改变策略,采用加1倍的方法。

引入这种改变策略的方式,是为了避免出现过多空闲的存储位置。


这样,我就可以明白part_1中list内置操作的复杂度为什么是那样的了。

Operation Big-O Efficiency 说明
index[] O ( 1 ) O(1) O(1) 取索引
index assignment O ( 1 ) O(1) O(1) 索引处赋值
append O ( 1 ) O(1) O(1) 在末尾追加
pop() O ( 1 ) O(1) O(1) 从list末尾弹出一个元素
pop(i) O ( n ) O(n) O(n) 弹出指定索引位置的元素(涉及到元素移动,所以最坏时间复杂度为 O ( n ) O(n) O(n)
insert(i, item) O ( n ) O(n) O(n) 在指定索引位置插入元素(涉及到元素移动,所以最坏时间复杂度为 O ( n ) O(n) O(n)
del O ( n ) O(n) O(n) 删除整个list(存储区可以一次性删除掉,但存储区内地址所对应的数据需要一个一个删除,所以复杂度为 O ( n ) O(n) O(n)
iteration O ( n ) O(n) O(n) 遍历(遍历肯定是 O ( n ) O(n) O(n)
contains(in) O ( n ) O(n) O(n) 判断元素是否存在(查找)(需要遍历)
get slice[x:y] O ( k ) O(k) O(k) 切片操作 (其中 k = y − x k = y -x k=yx)
del slice O ( n ) O(n) O(n) 按切片的方式删除元素(删除位置还需要填充,所以为 O ( n ) O(n) O(n)
set slice O ( n + k ) O(n+k) O(n+k) 切片赋值(删除指定切片的元素为 O ( n ) O(n) O(n),填充的复杂度为 O ( k ) O(k) O(k)
reverse O ( n ) O(n) O(n) 翻转list
concatenate O ( k ) O(k) O(k) 对两个list进行拼接(其实就是list_1 + list_2), O ( k ) O(k) O(k)中的 k k k表示第二个list中的元素个数
sort O ( n log ⁡ n ) O(n\log^n) O(nlogn) 进行升序/降序排序(它的复杂度和它采用的排序算法有关)
multiply O ( n k ) O(nk) O(nk) list乘法扩充元素个数

因为是大O记法,所以考虑的是最坏复杂度。

对于顺序结构表,插入和删除元素位置的不同所对应的时间复杂度不同。

3. 链表(Linked list)

3.0 链表和顺序表的区别

顺序表的最大特点:要求存储空间必须连续,而且容量一旦不够就需要涉及到动态改变数据区。

那么有没有一种数据结构能够在数据区扩充时原有的数据完全不用变

链表就可以实现这样的效果。

3.1 为什么需要链表

顺序表的构建需要预先知道数据大小来申请连续的存储空间,而在进行扩充时又需要进行数据的搬迁,所以使用起来并不是很灵活。

链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。

3.2 链表的定义

链表(Linked list)是一种常见的基础数据结构,是一种线性表,但是不像顺序表一样连续存储数据,而是在每一个节点(数据存储单元)里存放下一个节点的位置信息(即地址)

在这里插入图片描述

顺序表和链表有一个统称 —— 线性表

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第12张图片


那么链表应该如何实现呢?

既然每个节点会存放下一个节点的位置信息(地址),那么就不能像上图那样,里面仅仅存着数据的地址,而应该也要保存下一个节点的地址。

即每个节点不仅仅包含数据区,也要包含链接区。

  • 数据区:保存数据的地址
  • 链接取:保存下一个节点的地址

如下图所示:

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第13张图片

3.1 单向链表

单向链表也叫单链表,是链表中最简单的一种形式,它的每个节点包含两个:

  1. 一个信息域(元素域)
  2. 一个链接域

这个链接域指向链表中的下一个节点,而最后一个节点的链接域则指向一个空值

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第14张图片

  • 表元素域elem用来存放具体的数据。
  • 链接域next用来存放下一个节点的位置(python中的标识)
  • 变量p指向链表的头节点(首节点)的位置,从p出发能找到表中的任意节点。

⊥ \perp 在数据结构中指的是 最后的尾节点,它保存的下一个节点链接域为空

  • 第一个节点叫头节点(首节点)
  • 最后一个节点叫尾节点

3.2 链表用Python实现

3.2.0 [前置知识] Python中变量名的意义

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第15张图片

之前我们对Python中的变量有歧义,认为存储区的名字叫变量名,里面存储着值。但实际上,Python和线性表类似,变量名仅仅保存着数据的地址,而非数据本身

这种特性也就决定了,Python可以是使用a, b = b, a这样"奇怪"的语法。

数据存储区不变,仅仅指向变了

函数、类都是一样的:

数据结构与算法_part_2 —— 自己创建一个List类,并实现各种方法_第16张图片

这就是Python与其他语言的区别。比如在C语言中,在构建一个变量时必须指明这个变量是什么数据类型,如果是一个整型,就必须在前面加上int关键字:int a = 10。此时a就代表了一个真正的存储空间,名称为a,里面存储的数据为10。一旦这样定义好后,变量a中只能存储整数,不能存其他数据类型。

但在Python中,对于变量的声明是隐式的,不去使用类型关键字。Python之所以可以隐式声明变量,如a = 10,是因为变量a中保存的是地址,这个地址指向什么,也就意味着a里面的数据是什么。

3.2.1 节点分析

每一个节点应该使用class来实现。其中应该有两个类变量:

  1. elem:保存着数据
  2. next:保存着下一个节点的地址

如果有两个节点:

node1:
	elem = 10
	next = node2  # node1中的next变量指向node2

node2:
	elem = 20
	next = None

一定要明白为什么可以这么做 —— Python变量的本质和特性

在Python中没有一个特殊的数据类型表示地址,直接用指向即可。


因此我们可以将节点抽象为一个节点类

class Node():
    """单链表的节点"""
    def __init__(self, elem):
        # 存放数据元素
        self.elem = elem
        
        """
            next保存下一个节点的标识
                但现在我们并不知这个节点放在了那个位置,因此我们将其初始化为None
                不指向任何对象
        """
        self.next = None
        
    """
        这里我们没有使用Python中的特殊性,因为Python的元组就可以做到这件事情:
        (elem, next)
        这里为了通用性,就不使用元组的方式
    """

3.2.2 单链表分析

定义好Node之后,我们需要将Node串联起来,这个时候还需要一个类 —— SingleLinkedList。并且这个类还要实现一个方法(确切来说是实现一些对象方法):

single_obj = SingleLinkedList()
single_obj.add(10)
class SingleLinkedList():
    """单链表
            1. 把节点串联起来
            2. 支持一系列操作(应该是对象的方法,而不是类的方法)
                1. is_empty():链表是否为空
                2. length():链表长度
                3. travel():遍历整个链表
                4. add(item):链表头部添加元素
                5. append(item):链表尾部添加元素
                6. insert(pos, item):指定位置添加元素
                7. remove(item):删除节点
                8. search(item):查找节点是否存在
    """
    def __init__(self, node=None):
        """头节点是不向外部暴露的,因此为私有属性
            如果指定node,那么头节点就确定了
            如果不指定node,代表链表为空(里面没有任何节点)
        """
        self._head = node
    
    def is_empty(self):
        """链表是否为空"""
        pass
    
    def length(self):
        """链表长度"""
        pass
    
    def travel(self):
        """遍历整个链表"""
        pass
    
    def add(self, item):
        """链表头部添加元素"""
        pass
    
    def append(self, item):
        """链表尾部添加元素"""
        pass
    
    def insert(self, pos, item):
        """指定位置添加元素"""
        pass
    
    def remove(self, item):
        """删除节点"""
        pass
    
    def search(self, item):
        """查找节点是否存在"""
        pass

3.2.3 单链表类对象的创建

# 创建链表对象
sll = SingleLinkedList()

# 创建节点对象
node = Node(100)

3.2.4 单链表可以实现的操作

  1. is_empty():链表是否为空
  2. length():链表长度
  3. travel():遍历整个链表
  4. add(item):链表头部添加元素
  5. append(item):链表尾部添加元素
  6. insert(pos, item):指定位置添加元素
  7. remove(item):删除节点
  8. search(item):查找节点是否存在

3.2.5 单链表操作实现

class Node():
    """单链表的节点"""
    def __init__(self, elem):
        # 存放数据元素
        self.elem = elem
        
        """
            next保存下一个节点的标识
                但现在我们并不知这个节点放在了那个位置,因此我们将其初始化为None -> 不指向任何对象
        """
        self.next = None
        

class SingleLinkedList():
    """单链表
            1. 把节点串联起来
            2. 支持一系列操作(应该是对象的方法,而不是类的方法)
                1. is_empty():链表是否为空
                2. length():链表长度
                3. travel():遍历整个链表
                4. add(item):链表头部添加元素
                5. append(item):链表尾部添加元素
                6. insert(pos, item):指定位置添加元素
                7. remove(item):删除节点
                8. search(item):查找节点是否存在
    """
    def __init__(self, node=None):
        """头节点是不向外部暴露的,因此为私有属性
            如果指定node,那么头节点就确定了
            如果不指定node,代表链表为空(里面没有任何节点)
        """
        self.__head = node
    
    def is_empty(self):
        """链表是否为空
            只要self._head指向None,那么意味着链表为空
            如果不指向None,则意味着链表不为空
        """
        return self.__head == None
    
    def length(self):
        """链表长度
            current游标用来移动,遍历节点
        """
        current = self.__head  # cur指向的就是节点node
        # count记录节点的个数
        count = 0  # count初始化
        while current:  # 当current不为None
            count += 1
            current = current.next  # 移动游标(指针)
        return count
    
    def travel(self):
        """遍历整个链表"""
        cur = self.__head  # cur指向的就是节点node
        print("链表内容: [", end="")
        while cur:
            print(f"{cur.elem}", end=" ")
            cur = cur.next
        print("]", end="\n")
    
    def add(self, item):
        """链表头部添加元素 —— 头插法
            原本是self.__head指向node_1
            现在想往最前面插入一个node <=> 在self.__head和node_1中间插入一个node
            如果我们先让self.__head指向node,那么直接就导致node_1及其后面的节点被
            丢弃了。
            因此
                1. 我们需要先让node指向node_1 —— node.next = self.__head(node_1)
                2. 再让self.__head指向node —— self.__head = node
            这样就正常了
            
            当然我们还需要考虑特殊情况 —— 空链表
                如果是空链表,那么
                    1. node.next = self.__head -> node.next = None -> 没问题
                    2. self.__head = node -> 没问题
                所以这样写是可以应对空链表的特殊情况的
        """
        node = Node(item)
        node.next = self.__head  # 第一步
        self.__head = node  # 第二步
    
    def append(self, item):
        """链表尾部添加元素 —— 尾插法"""
        node = Node(item)
        
        # 判断当前链表是否为空链表
        if self.is_empty():
            self.__head = node
        else:
            cur = self.__head
            # cur.next: 如果cur==None,就会报错
            while cur.next:  # 循环退出时,cur就指向了最后一个node
                cur = cur.next  # cur指向的就是节点node
            cur.next = node  # 将新的节点链接到上一个节点
    
    def insert(self, pos, item):
        """指定位置添加元素
           :param
                pos: 指定的位置。从0开始
                item: 添加的数据
            e.g.
                self.insert(2, 400)则应该在索引1之后,索引2之前添加元素
            ------
            要考虑pos的特殊情况
                1. pos >= 0:  认为和0是一样的效果 -> 头插法
                2. pos > 链表的长度: 就是尾插法
        """
        # 第一种情况
        if pos <= 0:
            self.add(item)
        # 第二种情况(不能有等号)
        elif pos > self.length() - 1:
            self.append(item)
        # 第三种情况
        else:
            node = Node(item)
            prior = self.__head  # prior初始化 —— 将第一个节点的地址给prior
            count = 0
            while count < pos - 1:  # 使用计数的方式让遍历停下来
                count += 1
                prior = prior.next
            # 当循环退出后,prior指向pos-1位置 -> 开始替换
            node.next = prior.next
            prior.next = node
        
    
    def remove(self, item):
        """删除节点: 删除具体的数据
            特殊情况:
                1. 空链表 -> 没问题
                2. 删除的节点恰巧是首节点(因该直接操作self.__head而不是cur)
                3. 链表中只有一个节点,且删除的数据就是该节点中的数据 -> 没问题
                4. 将尾节点删除 -> 没问题
        """
        cur = self.__head  # 指向头节点
        prior = None  # 头节点的前一个为None
        while cur:
            if cur.elem == item:
                # 先判断此节点是否为首节点
                if prior == None:  # 如果prior为None,则cur指向首节点
                # if cur == self.__head:  # 这样也可以判断是否首节点
                    self.__head = cur.next
                else:
                    prior.next = cur.next
                break
            else:
                prior = cur
                cur = cur.next

    
    def search(self, item):
        """查找节点是否存在: 就是查找指定的节点node是否在链表中
            存在:True
            不存在:False
            
            满足cur==None的特殊情况
        """
        cur = self.__head
        while cur:
            if cur.elem == item:
                return True
            else:
                cur = cur.next
        return False
    
        
        
"""
    有一个节点我们就应该构造一个节点对象
        1. 数据
        2. 下一个节点对象
"""

if __name__ == "__main__":
    # 创建一个空链表
    single_linked_list = SingleLinkedList()
    print(f"链表是否为空: {single_linked_list.is_empty()}")
    print(f"链表的长度: {single_linked_list.length()}")
    
    # 往链表中追加元素
    single_linked_list.append(1)
    print(f"链表是否为空: {single_linked_list.is_empty()}")
    print(f"链表的长度: {single_linked_list.length()}")
    
    # 往链表中追加元素
    single_linked_list.append(2)
    single_linked_list.append(3)
    single_linked_list.append(4)
    single_linked_list.append(5)
    print(f"链表是否为空: {single_linked_list.is_empty()}")
    print(f"链表的长度: {single_linked_list.length()}")
    
    # 往链表的头部添加元素
    single_linked_list.add(100)
    
    # 遍历链表
    single_linked_list.travel()
    
    # 在指定位置添加元素
    single_linked_list.insert(pos=-2, item=9)
    single_linked_list.travel()
    single_linked_list.insert(pos=5, item=321)
    single_linked_list.travel()
    single_linked_list.insert(pos=100, item=123456)
    single_linked_list.travel()
    
    # 删除元素
    single_linked_list.remove(9)
    single_linked_list.travel()
    single_linked_list.remove(123456)
    single_linked_list.travel()

结果:

链表是否为空: True
链表的长度: 0
链表是否为空: False
链表的长度: 1
链表是否为空: False
链表的长度: 5
链表内容: [100 1 2 3 4 5 ]
链表内容: [9 100 1 2 3 4 5 ]
链表内容: [9 100 1 2 3 321 4 5 ]
链表内容: [9 100 1 2 3 321 4 5 123456 ]
链表内容: [100 1 2 3 321 4 5 123456 ]
链表内容: [100 1 2 3 321 4 5 ]

补充知识:将cur.next所指向的节点称为【后继节点】

3.3 链表与顺序表的对比

链表失去了顺序表随机读取的优点,同时链表由于增加了节点的指针域,
空间开销比较大,但对存储空间的使用要相对灵活

链表与顺序表的各种操作复杂度如下所示:

操作 链表 顺序表
访问元素(在链表中为search()方法) O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
在头部插入/删除元素 O ( 1 ) O(1) O(1) O ( n ) O(n) O(n)
在尾部插入/删除元素 O ( n ) O(n) O(n) O ( 1 ) O(1) O(1)
在中间插入/删除元素 O ( n ) O(n) O(n) O ( n ) O(n) O(n)

注意:二者虽然表面看起来复杂度都是 O ( n ) O(n) O(n),但是链表和顺序表在插入和删除时进行的是完全不同的操作。

  • 链表的主要耗时操作是遍历查找,删除和插入操作本身的复杂度是 O ( 1 ) O(1) O(1)
  • 顺序表查找很快,主要耗时的操作是拷贝覆盖。因为除了目标元素在尾部的特殊情况,顺序表进行插入和删除时需要对操作点之后的所有元素进行前后移位操作,只能通过拷贝和覆盖的方法进行。

对于【在中间插入/删除元素】这个操作,虽然二者的时间复杂度都为 O ( n ) O(n) O(n),但链表的主要开销在于遍历数据;而顺序表的主要开销在于数据迁移


:我们之前的代码是否还有优化空间?

:细节部分可以优化,但是时间复杂度决定了大局

3.4 为什么要使用链表?

:既然原有顺序表的时间复杂度比链表要低,那么我们为什么还要使用链表呢?

:链表的存储特点是分散。假如我们在内存中存储一个特别大的数据,如果使用顺序表进行存储,那么很有可能内存没有一块特别大的连续空间去存储,这时链表就起到作用了。它可以将内存中所有分散、可用的空间通过链表串起来。


3.5 链表和顺序表的优缺点

3.5.1 顺序表

优点

  1. 时间复杂度比链表低
  2. 存取元素时的时间复杂度为 o ( 1 ) o(1) o(1) -> 利于查找
  3. 速度快
  4. 占用内存小

缺点

  1. 存储空间必须是连续的
  2. 不能利用内存中分散的空间
  3. 容量不够了需要进行数据迁移

3.5.2 链表

优点

  1. 存储空间没有限制,可以充分利用内存中分散的空间
  2. 容量不够时不需要进行数据迁移,灵活性更高

缺点

  1. 不光要存储数据的地址,还有存储下一个节点的地址 -> 内存占用大
  2. 遍历操作多 -> 时间复杂度高

注意:二者虽然表面看起来复杂度都是 O ( n ) O(n) O(n),但是链表和顺序表在插入和删除时进行的是完全不同的操作。

  • 链表的主要耗时操作是遍历查找,删除和插入操作本身的复杂度是 O ( 1 ) O(1) O(1)
  • 顺序表查找很快,主要耗时的操作是拷贝覆盖。因为除了目标元素在尾部的特殊情况,顺序表进行插入和删除时需要对操作点之后的所有元素进行前后移位操作,只能通过拷贝和覆盖的方法进行。

对于【在中间插入/删除元素】这个操作,虽然二者的时间复杂度都为 O ( n ) O(n) O(n),但链表的主要开销在于遍历数据;而顺序表的主要开销在于数据迁移


:我们之前的代码是否还有优化空间?

:细节部分可以优化,但是时间复杂度决定了大局

3.4 为什么要使用链表?

:既然原有顺序表的时间复杂度比链表要低,那么我们为什么还要使用链表呢?

:链表的存储特点是分散。假如我们在内存中存储一个特别大的数据,如果使用顺序表进行存储,那么很有可能内存没有一块特别大的连续空间去存储,这时链表就起到作用了。它可以将内存中所有分散、可用的空间通过链表串起来。


3.5 链表和顺序表的优缺点

3.5.1 顺序表

优点

  1. 时间复杂度比链表低
  2. 存取元素时的时间复杂度为 o ( 1 ) o(1) o(1) -> 利于查找
  3. 速度快
  4. 占用内存小

缺点

  1. 存储空间必须是连续的
  2. 不能利用内存中分散的空间
  3. 容量不够了需要进行数据迁移

3.5.2 链表

优点

  1. 存储空间没有限制,可以充分利用内存中分散的空间
  2. 容量不够时不需要进行数据迁移,灵活性更高

缺点

  1. 不光要存储数据的地址,还有存储下一个节点的地址 -> 内存占用大
  2. 遍历操作多 -> 时间复杂度高

你可能感兴趣的:(面试题,Python,Python相关,python,数据结构,算法)