《数据结构与算法Python语言描述》裘宗燕 笔记 第三章 线性表

 

《数据结构与算法Python语言描述》裘宗燕 笔记系列
该系列笔记结合PPT的内容整理的,方便以后复习,有需要的朋友可以看一下。

源码重新整理了

 

地址:https://github.com/StarsAaron/DS/tree/master

要理解数据结构处理的问题,需要对计算机内存、存储管理方面重要基本问题有一些了解。

线性表是一种元素集合,其中还记录着元素间的一种顺序关系

 

作为一种包含元素(可以没有,也可以有许多)的数据结构,通常都需要提供一些“标准”操作,例如:

(1)创建和销毁这种数据结构(的实例)

(2)判断一个数据结构是否空(没有元素)。如果数据结构的容量有限制,还需判断它是否满(不能再加入新元素)

(3)向结构中加入元素或从中删除元素

(4)访问结构里的元素

 

两种实现线性表的方式:顺序表和链接表

 

顺序表的基本实现方式

(1)元素顺序存放在一片足够大的连续存储区里。表中首元素存入存储区开始的位置,其余元素依次顺序存放

(2)通过元素在存储区里的“物理位置”表示元素之间的逻辑顺序关系(隐式表示元素间的关系)

 

元素大小通常可以静态确定(如元素是整数,实数,或包含若干大小确定的元素的复杂结构)

基于各方面考虑,人们提出了两种基本实现模型

(1)将表元素顺序存放在的一大块连续的存储区里,这样实现的表也称为顺序表(或连续表),元素顺序有自然的表示

(2)将表元素存放在通过链接构造起来的一系列存储块里(链接表

 

一些操作的复杂性是常量 O(1)。现在特别考虑定位加入和删除操作的复杂性(首端加入删除是它们的特例)

在 个元素的顺序表里下标 处加入元素,需要移动 n - i 个元素;删除下标为 的元素需要移动 n - i - 1个元素

设在位置 加入和删除元素的概率分别是 pi 和 pi’

加入操作平均移动次数 

删除操作平均移动次数

考虑平均复杂性时要考虑实例分布,依赖于实际情况

如果各种情况平均分布,要维持顺序,平均时间复杂性是 O(n) 坏情况是首端加入/删除,要求维持顺序时复杂性也是 O(n)

 

访问操作

(1)不需要扫描表的操作,复杂性都是 O(1)

(2)扫描表内容操作复杂性 O(n)。如根据元素值检索,累计元素值等

 

顺序实现(顺序表)总结

优点:

(1) O(1) 时间(随机,直接)按位置存取元素

(2) 元素存储紧凑,除表元素存储外只需 O(1) 空间存放辅助信息

 

顺序表的模型包括两部分内容

(1) 一个元素存储区,存放表中的实际元素

(2) 若干单元,存放一个表的全局信息(容量,表元素个数)

 

一个数据结构应该具有一个对象的整体形态。对顺序表,就是要把这两块信息组织关联起来。表的全局信息只需常量存储,存在两种组织方式

一体式实现:

分离式实现:通过链接联系

缺点: 

(1) 需要连续的大块存储区存放表中的元素,可能有大量空闲单元 

(2) 加入删除操作时通常要移动许多元素

(3) 需要考虑元素存储区的大小(有时事先很难估计)

 

链接表:单链表,双链表

实现线性表的另一方式是基于链接结构,用链接显式地表示元素之间的顺序关联。基于链接技术实现的线性表称为链接表或链表

 

实现链接表的基本想法:把表元素分别存储在一批独立的存储块(称为结点)里

   o 保证从一个元素的结点可找到与其相关的下一个元素的结点

   o 在结点里用链接的方式显式记录元素(结点)之间的关联

 

这样,只要知道表中第一个结点,就能顺序找到表里其他元素

 

链接表有多种不同的组织方式。

 

单链表(下面简称链表)结点的形式(链接域保存下一结点的标识)

在单链表里,与表里的 n 个元素对应的 n 个结点通过链接形成一条结点链。从表里的任一个结点都可以找到保存下一个元素的结点

 

基本操作:

创建空表: O(1)

删除表:在 Python 里是 O(1)。当然存储管理也需要时间

判断空表: O(1)

 

加入元素(都需要加一个 T(分配) 的时间):

首端加入元素: O(1)

尾端加入元素: O(n),因为需要找到表的最后结点

定位加入元素: O(n),平均情况和最坏情况

删除元素:

首端删除元素: O(1);尾端删除: O(n)

定位删除元素: O(n),平均情况和最坏情况

其他删除通常需要扫描整个表或其一部分, O(n) 操作

其他操作,如果需要扫描整个表或其一部分,都是 O(n) 操作。如

求表的长度(表中元素个数)

定位表中的元素;等等

 

一类典型的表操作是扫描整个表,对表中每个元素做同样的工作(即遍历操作)。例如,输出所有的元素值,将它们累积到一个变量里等。

这种工作可以通过一个循环完成

遍历操作的复杂性应该是 O(n) * T(元素操作)

 

循环单链表

单链表只能支持首端元素加入/删除和尾端加入的 O(1) 实现,如果希望高效实现两端的加入/删除,就必须修改结点的设计

结点间双向链接不仅支持两端的高效操作,一般结点操作也更方便

 

class LCList:  # class of Circular Linked List
    def __init__(self):
        self.rear = None   # 表对象只有一个 rear 域
    def isEmpty(self):
        return self.rear is None
    def prepend(self, elem):  # add element in the front end
        p = LNode(elem, None)
        if self.rear is None:
            p.next = p  # 表中第一个结点建立初始的循环链接
            self.rear = p
        else:
            p.next = self.rear.next  # 链在尾结点之后,就是新的首结点
            self.rear.next = p
    def append(self, elem):  # add element in the rear end
        self.prepend(elem)  # 直接调用前段加入操作
        self.rear = self.rear.next  # 修改 rear 使之指向新的尾结点
    def pop(self):  # pop out head element
        if self.rear is None:
            raise ValueError
        p = self.rear.next
        if self.rear is p:  # rear 等于其 next,说明表中只有一个结点
            self.rear = None  # 弹出唯一结点后 rear 置空
        else:
            self.rear.next = p.next  #一般情况,删去一个结点
        return p.elem
    # 简单输出所有元素的方法
    def printall(self):
        p = self.rear.next
        while True:
            print(p.elem)
            if p is self.rear:
                break
            p = p.next

 

支持首尾端高效操作的双向链接表(双链表)结构:

从任一结点出发,可以直接找到前后的相邻结点。而对单链表,找后面一个结点很方便,而找前一结点就必须从表头开始逐一检查

每个结点需要增加了一个前向引用域,指向前一结点(存储开销),与前面一样,增加了尾结点引用,才能实现高效的尾端操作

 

# 定义一个新结点类
class LDNode(LNode):  # class of Double Linked Nodes
    def __init__(self, prev, elem, nxt):
        LNode.__init__(self, elem, nxt)
        self.prev = prev  # 扩充了一个前一结点指针
# 双链表
class LDList(LList1):  # class of Double Linked List
    def __init__(self):
        LList1.__init__(self)  # 基于带尾结点引用的单链表派生一个双链表类
    # 加入元素的一对操作
    def prepend(self, elem):
        p = LDNode(None, elem, self.head)
        self.head = p
        if self.rear is None:  # insert in empty list
            self.rear = p
        else:  # otherwise, create the prev reference
            p.next.prev = p
    # 加入元素的一对操作
    def append(self, elem):  # 与 prepend 对称
        p = LDNode(self.rear, elem, None)
        self.rear = p
        if self.head is None:  # insert in empty list
            self.head = p
        else:  # otherwise, create the next reference
            p.prev.next = p
    # 弹出(删除)元素
    def pop(self):
        if self.head is None:
            raise ValueError
        e = self.head.elem
        self.head = self.head.next  # 删除当前首结点
        if self.head is None:
            self.rear = None  # 如果删除后表空,把 rear 也置空
        else:
            self.head.prev = None  # 首结点的 prev 链接置空
        return e
    # 与 pop 对称
    def poplast(self):
        if self.head is None:
            raise ValueError
        e = self.rear.elem
        self.rear = self.rear.prev
        if self.rear is None:
            self.head = None  # 删除后表空,把 head 也置空
        else:
            self.rear.next = None
        return e

 

循环双链表

 

让表尾节点的next域指向表首节点,让表首节点的prev域指向尾节点。

首尾两端的元素加入/删除操作O(1)复杂度

链表元素反转

反转一个表顺序的元素,算法通常用两个下标,逐对交换元素位置

- 在双链表上很容易采用该方法实现表元素反转,留作个人练习

- 在单链表上也可以实现,但单链表不支持从后向前找结点,只能每次从头开始找,算法需要 O(n2) 时间。自己想想有别的办法吗?

 

注意:对顺序表,修改表元素顺序的方法只有一种:在表中搬动元素;而对链表却有两种方法:在结点之间搬动元素,或修改链接关系

- 在单链表里通过搬元素方式实现元素反转,不方便,且效率低

- 下面考虑基于修改链接的方法,看看有什么可能性

 

首先:表前端加入和删除元素或结点是最方便的操作, O(1)

- 如果不断在前端加入结点,最早放进去的结点在最后(是尾结点)

- 从表的前端取下结点,最后取下的是尾结点

- 结合这两个过程,很容易做出一个高效的反转算法

 

def rev(self):
    p = None
    while self.head is not None:
        q = self.head
        self.head = q.next  # 摘先原来的首结点
        q.next = p
        p = q  # 将刚摘下的结点加入 p 引用的结点序列
    self.head = p  # 反转后的结点序列已经做好,重置表头链接

 

链表元素排序

把一组物品按顺序排列是在真实世界里经常需要做的工作

- 数据处理中也经常需要将数据排序

- 如 lst 的值是 list 对象, lst.sort() 完成 lst 对象中元素的排序工作;

sorted(lst) 生成一个新表,其元素是 lst 元素的排序结果

 

用链接表存储数据,也常需要考虑其中元素的排序问题

- 排序是重要的数据处理问题,人们提出了许多算法(后面考虑)

- 下面考虑一种简单的排序算法:插入排序

 

插入排序的基本想法:

- 操作中维护一个排好序的片段,初始时该段只包含一个元素

- 每次从未排序部分取出一个元素,加入已排序片段中正确位置,保持该片段的正确排序状态;所有元素都加入排序片段时工作完成

- 通常在表的前部积累排好序的片段

 

一个对 list 的元素排序的函数

 

def listSort(lst):
    for i in range(1, len(lst)):  # seg [0:0] is sorted
        x = lst[i]
        j = i
        while j > 0 and lst[j - 1] > x:  # moving one by one
            lst[j] = lst[j - 1]  # in reversed-order
            j -= 1
        lst[j] = x

 

 

单链表排序

下面考虑单链表元素的排序

- 注意:只有 next 链接,不能从后向前找结点(找元素)

- 存在完成排序的两种可能做法:移动元素,或调整链接

- 下面分别考虑采用这两种技术实现的算法

 

先考虑基于移动元素的单链表排序

注意,为有效实现,算法中只能按从头到尾的方向检查和处理,做法仍然是每次拿出一个元素,在已排序序列中找到正确位置插入

 

def sortm(self):
    if self.head is None:
        return
    crt = self.head.next  # 从首结点之后开始处理
    while crt is not None:
        x, p = crt.elem, self.head
        while p is not crt and p.elem <= x:   # 跳过小元素
            p = p.next
        while p is not crt:   # 倒换大元素,完成元素插入的工作
            y = p.elem
            p.elem = x
            x = y
            p = p.next
        crt.elem = x  # 最后的回填
        crt = crt.next

 

 

通过调整链接关系完成排序工作

基本的处理模式与前一算法类似,但这里不在结点之间移动表元素,而是把被处理结点取下来接到正确位置,逐步完成排序工作

 

# 通过调整链接关系完成排序
def sort(self):
    if self.head is None:
        return
    last = self.head  # 初始,排序段只有一个结点
    crt = last.next
    while crt is not None:  # 循环,一次处理一个结点
        p = self.head
        q = None  # 设置扫描指针初值
        while p is not crt and p.elem <= crt.elem:
            q = p
            p = p.next  # 顺序更新两个扫描指针
        if p is crt:  # p 是 crt 时不用修改链接,设置 last 到下一结点 crt
            last = crt
        else:
            last.next = crt.next  # 取下当前结点
            crt.next = p # 接好后链接
            if q is None:
                self.head = crt   # 作为新的首结点
            else:
                q.next = crt  # 或者接在表中间
        crt = last.next  # 无论什么情况, crt 总是 last 的下一结点
    # end of class LList

 

实例: Josephus问题

问题:设有 n 个人围坐一圈,现在从第 k 个人开始报数,报到第 m 的人退出。然后继续报数,直至所有人退出。输出出列人顺序编号

 

下面考察基于几种想法和数据结构的解决方法

 

方法1:Josephus问题:数组算法

基于 Python list 和固定大小的“数组”

- 初始:建立一个包含 n 个人(的编号)的 list

- 找到第 k 个人,从那里开始,处理过程中,通过把相应表元素修改为 0 表示人已不在位

- 反复做:

    o 数 m 个(尚在坐的)人

    o 把表示第 m 个人的元素修改为 0

    注意:数到 list 最后元素之后转到下标为 0 的元素继续

 

def JosephusA(n, k, m):
    people = list(range(1, n + 1))
    num, i = 0, k - 1
    for num in range(n):
        count = 0
        while count < m:
            if people[i] > 0:
                count += 1
            if count == m:
                print(people[i], end="")
                people[i] = 0
            i = (i + 1) % n
        if num < n - 1:
            print(", ", end="")
        else:
            print("")
    return

 

这里的主要麻烦是表下标的计数和元素计数脱节

复杂度不容易分析,内循环的总迭代次数是i加1,与m和n有关,m影响找到下一个元素得到操作步数。

 

方法2:Josephus问题:连续表算法

现在考虑另一算法:将保存人员编号的 list 按照表的方式处理

-  算出应该退出的人之后,将其从表里删除

-  计算过程中表越来越短,用 num 表示表的长度,每退出一人,表长度 num 减一

-  至表长度为 0 时工作结束

 

由于表里全是有效元素

-  元素计数与下标计数统一了

-  下标更新可以用 i = (i + m - 1) % num 统一描述

 

def JosephusL(n, k, m):
    people = list(range(1, n + 1))
    if k < 1 or k > n:
        raise ValueError
    num, i = n, k - 1
    for num in range(n, 0, -1):
        i = (i + m - 1) % num
        print(people.pop(i), end="")
        if num > 1:
            print(", ", end="")
        else:
            print("")
    return

 

貌似线性时间算法,但不是

- pop 操作需要线性时间 O(n)

- 算法复杂性是 O(n^2)

 

方法3:Josephus问题:循环链表算法

考虑基于循环单链表实现一个算法

- 从形式看,循环单链表很好地表现了围坐一圈的人

- 顺序地数人头,可以自然地反映为在循环表中沿 next 链扫描

- 一个人退出,可以用删除相应结点的操作模拟。这样做之后,就可以沿着原方向继续数人头

 

算法应分为两个阶段:

- 建立包含指定个数(和内容)的结点的循环单链表,可以通过从空表出发逐个在尾部加入元素的方式完成

- 循环计数,并删去需要退出的结点

 

具体实现可以有多种方式,例如:为原循环单链表增加一个循环数数的函数,然后写一段程序建立所需的循环单链表,并完成操作

 

下面实现基于循环单链表类,派生出一个专门的类,用其初始化方法完成全部工作

 

class Josephus(LCList):
    def turn(self, m):
        for i in range(m):
            self.rear = self.rear.next
    def __init__(self, n, k, m):
        LCList.__init__(self)
        for i in range(n):
            self.append(i + 1)
        self.turn(k - 1)
        while not self.isEmpty():
            self.turn(m - 1)
            print(self.pop(), end="")
            if not self.isEmpty():
                print(", ", end="")
            else:
                print("")

 

这里采用另一种实现方法

- 从 LCList 派生出一个类

- 用这个类的初始化函数完成所有工作

 

派生类 Josephus 增加了新方法 turn,它将循环表对象的 rear 指针沿 next 移 m 步

初始化函数调用基类 LCList的初始化函数建立一个空表

第一个循环建立包含 n 个结点的循环表,复杂度为O(n)

最后的循环逐个弹出结点并输出结点里保存的编号,移动m位置复杂度为O(1)

 

算法复杂性 O(m*n)

 

链接表:优点和缺点

优点:

- 表结构是通过一些链接起来的结点形成的,结构很容易调整修改

- 不修改结点里的数据元素,只通过修改链接,就能灵活地修改表的结构和内容。如加入/删除一个或多个元素,翻转整个表,重排元素的顺序,将一个表分解为两个或多个等

- 整个表由一些小存储块构成,较容易安排和管理(不是我们的事)

 

缺点,一些操作的开销大:

- 基于位置找到表中元素需要线性时间

- 尾端操作需要线性时间(增加尾指针可以将尾端加入元素变为常量操作,但仍不能有效实现尾端删除)

- 找当前元素的前一元素需要从头扫描表元素(双链表可以解决这个问题,但每个结点要付出更多存储代价),应尽量避免

 

每个元素增加了一个链接域(存储代价),双链表增加两个链接域

  • 4
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

繁星点点-

请我喝杯咖啡呗

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值