赞
踩
《数据结构与算法Python语言描述》裘宗燕 笔记系列
该系列笔记结合PPT的内容整理的,方便以后复习,有需要的朋友可以看一下。
源码重新整理了
地址:https://github.com/StarsAaron/DS/tree/master
要理解数据结构处理的问题,需要对计算机内存、存储管理方面重要基本问题有一些了解。
线性表是一种元素集合,其中还记录着元素间的一种顺序关系
作为一种包含元素(可以没有,也可以有许多)的数据结构,通常都需要提供一些“标准”操作,例如:
(1)创建和销毁这种数据结构(的实例)
(2)判断一个数据结构是否空(没有元素)。如果数据结构的容量有限制,还需判断它是否满(不能再加入新元素)
(3)向结构中加入元素或从中删除元素
(4)访问结构里的元素
两种实现线性表的方式:顺序表和链接表
顺序表的基本实现方式
(1)元素顺序存放在一片足够大的连续存储区里。表中首元素存入存储区开始的位置,其余元素依次顺序存放
(2)通过元素在存储区里的“物理位置”表示元素之间的逻辑顺序关系(隐式表示元素间的关系)
元素大小通常可以静态确定(如元素是整数,实数,或包含若干大小确定的元素的复杂结构)
基于各方面考虑,人们提出了两种基本实现模型
(1)将表元素顺序存放在的一大块连续的存储区里,这样实现的表也称为顺序表(或连续表),元素顺序有自然的表示
(2)将表元素存放在通过链接构造起来的一系列存储块里(链接表)
一些操作的复杂性是常量 O(1)。现在特别考虑定位加入和删除操作的复杂性(首端加入删除是它们的特例)
在 n 个元素的顺序表里下标 i 处加入元素,需要移动 n - i 个元素;删除下标为 i 的元素需要移动 n - i - 1个元素
设在位置 i 加入和删除元素的概率分别是 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)
链接表:优点和缺点
优点:
- 表结构是通过一些链接起来的结点形成的,结构很容易调整修改
- 不修改结点里的数据元素,只通过修改链接,就能灵活地修改表的结构和内容。如加入/删除一个或多个元素,翻转整个表,重排元素的顺序,将一个表分解为两个或多个等
- 整个表由一些小存储块构成,较容易安排和管理(不是我们的事)
缺点,一些操作的开销大:
- 基于位置找到表中元素需要线性时间
- 尾端操作需要线性时间(增加尾指针可以将尾端加入元素变为常量操作,但仍不能有效实现尾端删除)
- 找当前元素的前一元素需要从头扫描表元素(双链表可以解决这个问题,但每个结点要付出更多存储代价),应尽量避免
每个元素增加了一个链接域(存储代价),双链表增加两个链接域
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。