(一)回顾线性结构,对比单链表和线性结构
回顾线性结构:
线性结构的增、删,如果经常涉及到首部元素的添加删除,则会使得效率由O(1)来到O(n)。
而在末尾添加元素则偶尔会设计数组扩容问题,也会影响效率。
但是数组的内部遍历访问效率为O(1),因为底层连续存储和索引的设计。
对比单链表结构:
单链表访问、遍历的效率方面由于没有索引和连续存储的原理,不如线性结构。
单链表的强就强在,线性结构的劣势之处:
单链表设计了root(头)和整个链表的next(尾),二者使得单链表可以快速获取’首、尾’地址。
这使得单链表在首 | 尾 处插入、删除"操作时能快速精确定位内存地址,达到O(1)的效率。
(二)、单链表的具体分析
0.操作前找到前驱节点的重要性
注:单链表的插入、删除操作使用中,我们只注重两个对象,即前驱节点和新加入的节点,而不需要注重间接受到影响的第三者,操作过程不涉及第三者。
1.new_node对象是必然的:
在单链表的底层实现中:各个方法里都写了new_node=Node(value)
插入操作显然需要先实例化这么一个对象。
2.前驱对象,对单链表"插入、删除"操作的必要前提就是我们已知前驱对象的引用。
解释为什么用不到第三者:
使用单链表时候,从来不会用到插入或删除位置的后继对象,
因为前驱对象.next这种表达方式再或者前驱对象.next.next
这两种写法,总有一种可以表达出间接影响到的对象。
因此,两个对象足矣,根本不需要第三者的出现,
只需要理解node并熟练使用node里面的next所表达的含义
1.单链表结构的构成,是如何设计的:
从整体上看:单链表是由一个个node利用next链接起来的一个不连续的表。
单链表自身设计结构来看(设计了"哨兵节点和尾指针")
单链表设计了属于LinkList自身的‘root和next’:
(1.)哨兵节点:单链表对象内部有root(哨兵节点,或叫’虚拟头节点’),用于简化链表头部位置的操作,它等价于一个只有next指针,而不设置value的node,只要熟练使用哨兵节点,通过root.next可以方便地实现头部的操作
(2.)链表自身的尾指针:含有一个属于LinkList而不属于Node的’next’,持续更新记录LinkList尾部最后一个节点,每次尾部的操作之后更新。
<1>Node的构成
1.整个链表由多个Node节点组成
2.Node里面有value用于存放值,有next用于存放下一个Node的地址。
各个Node以如下方式链接起来(并非是代码,只是生动描述这种链接关系)
node1:(value)next---- node2地址
node2:(value)next---- node3地址
node3:(value)next.... ...地址
<2>LinkList的构成
(1)哨兵节点root:
1.哨兵节点:
LinkList对象的构造方法内部会设置一个哨兵节点root,
目的是简化位于链表位于头部的操作。
具体做法示例:
class LinkList():
def __init__(self):
self.root=Node()
self.size=0
self.next=None
哨兵节点的创建惯例:
设计一个,哨兵节点root,通过在构造方法中,
先将它赋值为一个空的实例对象,
它里面不存放任何的value,
只需要注重root哨兵节点的next对象的更改。
方便理解:我们可以把哨兵节点想象成一个类似value=0的空块,永远放在
LinkList的开头位置,这样画图分析也时候就很好理解)
如下图:这就是使用哨兵节点root的生动示例,
可以看到,在"涉及头部的相关操作"时候,
它十分有用,可以快速从链表中找到对应头部位置。
(2)next:记录整个链表的末尾节点
2.LinkList的next------整个链表的next:
具体做法示例:
class LinkList():
def __init__(self):
self.root=Node()
self.size=0
self.next=None
上面的代码解释:是对于next的初始化的步骤
LinkList的next值,next在构造方法时候先置空,
涉及链表末尾的记录将会持续更新。
2.单链表为什么能在"首尾"插入删除时,具有O(1)效率
单链表在首,尾,插入删除操作的效率是O(1),
这O(1)的效率发生在特定情景下:已知了‘前驱节点的引用’,
即可直接访问前驱节点。(已知内存的位置直接访问的手段永远是最快的操作)
那么我们对root和next的引用已知,所以首尾的操作来到了O(1)效率
单链表删除原理(下面讲的是通用的删除原理,不是’头、尾’特殊位置处)
单链表删除操作原理示例:
node1:(value)next---- node2地址
node2:(value)next---- node3地址这里改为node4地址
node3:(value)next… node4地址
node4:(value)next---- …地址
已知node2的地址,引用node2,把node2的next改为node4的地址,即删除了node3
单链表插入原理(也是通用原理,不是’头/尾’处)
单链表插入操作原理示例:
node1:(value)next---- node2地址
node2:(value)next---- node3地址这里改为node2.5地址
新增:node2.5(value)next----node3地址
node3:(value)next… node4地址
node4:(value)next---- …地址
这是插入的原理,但是我们往往不在中间插入。
原因:单链表不是连续存储也没有索引,只能从头开始遍历找到你所修改位置的前驱,
只有头插和尾插才是推荐的选择,因为我们单链表的设计
LinkList自带的root和next决定了头尾具有着高效率。
单链表插入存在逻辑顺序(原链表断裂)
第一次思考插入操作:
已知node2的地址,引用node2,把node2的next改为要插入的node的地址,然后将新插入的node的next也链接上原来剩余的部分。
思考:但是这里存在顺序问题,node3的地址怎么知道呢?回想到node3的地址在node2的next有记录,可是被先一步被更改了,应该调整一下第一次思考中的顺序
第二次思考时候:(解决第一次思考中的链表中途断裂问题。)
先把插入的node中的next链接上暂时未被更改的前驱节点中的next,再去更改前驱节点的next为插入的node的地址。
简单记忆为:
插入操作中,单链表中不要'先一步对"前驱节点的next做修改,先一步对其做修改会导致原链表断裂。
值得一提的是,如果你理解了此处的原链表断裂的事情,
那你每次进行插入、删除操作的时候,都会额外留意插入、删除其中的顺序问题
单链表的’头插’和’尾插’(最为推荐,效率最高且实现逻辑简单)
单链表的头插和尾插逻辑变得好编写
1.不存在单链表的断裂问题
2.在头插的插入逻辑中不必先一步判断链表此时是否为空状态
3.单链表的劣势:
设计到遍历链表则来到O(n)的效率
单链表的访问操作,需要根据单链表的结构中"从一个中找到下一个",
一个一个遍历的模式,使得链表的遍历访问来到O(n)的效率。
因此单链表不适合获取元素,
而更适合在已知前驱位置的情况下(如首、尾),插入新节点,或者删除旧节点。
(三)单链表涉及的ADT及其实现:
Node的ADT以及实现
# 从链表结构开始,很多的结构都要先实现一个Node
# Node的ADT及实现
from abc import ABC,abstractmethod
class NodeADT(ABC):
'''这个ADT类是对Node的基本构成进行练习'''
@abstractmethod
def __init__(self,value,next):
'''使它用value就能实例化Node'''
pass
@abstractmethod
def get_value(self):
return
@abstractmethod
def set_value(self,value):
pass
@abstractmethod
def get_next(self):
return
@abstractmethod
def set_next(self,next):
pass
class Node(NodeADT):
'''实现Node'''
def __init__(self,value=None,next=None):
'''使之用value即可实现node的实例化'''
self.value=value
self.next=None
def get_value(self):
return self.value
def set_value(self,value):
self.value=value
def get_next(self):
return self.next
def set_next(self,next):
self.next=next
LinkList(单链表)的ADT以及实现
了解哨兵节点root的设计(虚拟头节点)
关于哨兵节点root的设计:
1.哨兵节点root的初始化:将root初始化为一个空Node()
class LinkList():
def __init__(self):
self.root=Node()
self.size=0
self.next=None
2.哨兵节点的灵活使用:root.next
def append_head(self,value):
new_node=Node(value)
self.next=self.root.next
self.root.next=new_node
LinkList的ADT
# LinkList(单链表)的ADT及实现
class LinkListADT(ABC):
'''单链表的ADT的构思'''
@abstractmethod
def __init__(self):
'''需要先用Node实例化空对象作为链表,链表类的每一个方法内都要新建对应的Node然后用node对象进行相关操作'''
pass
@abstractmethod
def append(self,value): # append默认是加在链表尾部
'''默认,即在尾插,插前是否为空,是空还要对LinkList的root和next进行更新'''
pass
@abstractmethod
def append_head(self,value):
'''在头插,如果插的时候是空还要对LinkList的root和next进行更新'''
pass
@abstractmethod
def __iter__(self):
'''从头遍历到尾,并输出链表的实际内容'''
yield
@abstractmethod
def delete(self):
'''在尾删'''
pass
LinkList的具体实现:
# LinkList的实现
class LinkList(LinkListADT):
'''单链表的LinkList的实现'''
def __init__(self):
self.root=Node()
self.size=0
self.next=None
def append(self,value): # append默认加在尾部
'''在尾插,value是用于创建新node的'''
new_node=Node(value)
'''这里做一个if判断,末尾添加时候先判断是否整个单链表为空,非空时候时候,挂在next.next,因自占一处'''
if not self.next:
self.root.next=new_node #先挂上(由于空,挂在头部)新节点
self.next=new_node # 再更新,链表尾部的指针。
else:
self.next.next=new_node #先挂上(由于非空,挂在尾部)新节点
self.next=new_node #再更新,链表尾部的指针。
self.size+=1 #这里顺便将链表的size +1
'''
你也可以改写成,把if else相同的两行移出来。
if not self.next:
self.root.next=new_node
else:
self.next.next=new_node
self.next=new_node
self.size+=1
'''
def append_head(self,value):
'''头插,先插上,再去更新链表的指针'''
new_node=Node(value)
new_node.next=self.root.next
self.root.next=new_node
'''
另外,如果已经理解了原链表断裂问题,那么你会理解上面这两行的顺序问题。
'''
if not self.next:
self.next=new_node.next
'''这里的if就是将链表自身的next值仍旧是None进行更新'''
def __iter__(self):
'''从头遍历到尾,引入一个current变量,并利用它输出链表的实际内容'''
current=self.root.next
# 先将current值赋值为头root.next
while current is not self.next:
#current此时获取到的root.next不与link7list的next指向同一个对象,那么就意味着单链表内不为空
yield current.value
# yield类似return,在专门将迭代的对象一个个输出时候使用。
current=current.next
# current指向下一个值
yield current.value
#单链表为空时候也设置输出
def delete(self,value):
'''在尾删'''
if not self.root.next:
return "1.先判断链表为空"
'''2.设置current变量用于遍历'''
current=self.root.next
# 在delete中同样需要先遍历找到对应位置
while current.next and current.next.value != value:
#有下个节点不为空,且下个节点不是要找的值时候,就一直遍历,如果找到就自动跳出while
current = current.next
if current.next.value==value:
# 如果找到了要删除的节点
current.next = current.next.next
# 将它的next跳到下一个,即修改完成
return "删除成功"
(四)什么是伪代码,如何利用伪代码
1.伪代码是什么:
伪代码是用类似自然语言的代码来体现一个小部分功能的逻辑,
它可以体现对于功能实现的基本逻辑,而不用真的实现它。
伪代码是解释复杂算法的好工具,可以让我们迅速理解一个算法的核心,
让程序员对于一个算法具备核心的理解,而不拘泥于语言,在团队中为其它成员讲解伪代码时候也很实用。
2.利用伪代码:
<1.> 用于练习分解、分析复杂问题的过程:
Procedure SolveComplexProblem()
Step 1: 分析问题,明确目标
Step 2: 分解问题,列出所有子问题
Step 3: 逐个解决子问题
Step 4: 合并子问题的解决方案,得到最终结果
EndProcedure
<2.> 用于抽象出一个算法实现细节的具体步骤,通过编写伪代码来简化和概括代码的逻辑,再逐渐思考细节如何实现:
Procedure AbstractAlgorithm()
Step 1: 初始化
Step 2: 执行核心逻辑
Step 3: 处理边界情况
Step 4: 返回结果
EndProcedure
<3.> 在阅读源码时候,可以将其转换成伪代码加深自己理解,或用伪代码作为算法的注释。
# 源码中的伪代码注释
def calculate_sum(numbers):
# 初始化总和为0
total_sum = 0
# 遍历每个数字并累加
for num in numbers:
total_sum += num
# 返回总和
return total_sum
# 对应的伪代码
Procedure CalculateSum(numbers)
Initialize total_sum to 0
For each number in numbers do
Add number to total_sum
EndFor
Return total_sum
EndProcedure
(五)、为什么学习单链表
单链表貌似只有首尾的插入删除具有效率优势,但存在一些遍历的缺陷。
后面会学习双链表和更复杂的树形结构之类的内容,
之所以学习单链表,是为了理解好后续的复杂数据结构打下坚实的基础,
单链表里面我们清楚地学习了Node的相关知识、指针、利用指针进行插入删除遍历等的操作。
单链表的自身使用不多,但是基于单链表,经常使用的各种数据结构:
《双链表\循环链表\栈和队列\图和树\哈希表》
以上这些经典的数据结构,都或多或少的使用到了单链表的知识,因此在这一节学好单链表是必要的。