引入开篇问题:
如何轻松写出正确的链表代码?带着这个问题,开始今天的学习吧!
几个写链表的代码技巧
技巧一:理解指针或引用的含义
C语言有"指针"的概念;Java,Python的"引用"相当于C语言的"指针"。
"指针"和"引用"都是存储所指对象的内存地址。
实际上我们对于指针的理解可以用下面这几句话就可以了:
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
链表常用的表示法:
p ->next = q 表示p结点中的next中的next指针存储了q结点的内存地址。
p -> next = p -> next -> next 表示p结点的next指针存储了p的下下一个结点的内存地址4
技巧二:警惕指针丢失和内存泄漏
不知你们是不是和我一样在写链表代码时候,指针指来指去,一会就不知到指到哪去了。所以在写指针代码时候一定要注意不要带去了指针
你们肯定会问指针是怎么丢弃的?拿单链表的插入操作为例来分析一下吧!
![7b39eed3300439c95d2b570074602a11.png](https://i-blog.csdnimg.cn/blog_migrate/41f46ab674decd10967df85ed4d134ad.png)
如图所示,我们希望在结点a和相邻的结点b之间插入结点x,假设指针p指向结点a。如果将代码写成下面这个样子,就会发生指针丢失和内存泄漏
p -> next = x; // 将 p 的next指针指向 x 结点
x -> next = p -> next; // 将 x 的结点的 next指针指向b结点
初学者经常会在这儿犯错。p->next 指针在完成第一步操作之后,已经不再指向结点 b 了,而是指向结点 x。第 2 行代码相当于将 x 赋值给 x->next,自己指向自己。因此,整个链表也就断成了两半,从结点 b 往后的所有结点都无法访问到了。
对于有些语言来说,比如C语言,内存管理由程序员负责,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,在插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点a的next指针指向结点x,这样就不会丢失指针了,导致内存泄漏。所以,对于刚刚的插入代码,我们只需要把第一行和第二行代码进行颠倒一下就可以了。
x - > next = p - > next;
p - > next = x;
同理,删除链表结点时,也一定记得手动释放内存空间,否则也会出现内存泄漏问题。当然,对于Java这样虚拟机自动管理内存的编程语言来说,这不需要考虑这个问题了。
技巧三:利用哨兵简化实现难度
对于单链表的插入和删除操作,如果我们在p后面插入一个新的结点,下面两行代码搞定
new_node -> next = p -> next;
p - > next = new_node;
但是,当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。我们需要进行下面这些特殊处理,其中head表示链表的头结点。所以,从这段代码,可以看出,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的。
if (head == Null){
head = new_node;
}
再来看单链表结点删除操作。如果要删除结点p的后继结点我们只需要一行代码就可以搞定。
p -> next = p -> next -> next
但是,如果我们要删除链表中的最后一个结点,前面的删除代码就不能工作了。跟插入类似,我们也需要对于这种情况特殊处理。写成以下的代码:
if (head - > next == null) {
head = null;
}
针对链表的插入,删除操作,需要对插入第一个结点和最后一个结点的情况进行特殊处理。
head表示头结点指针指向链表中的第一个结点 head = null 表示链表没有结点
哨兵是为了解决“边界问题”的,不直接参于业务逻辑。如果我们引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。我们也把这种哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫做不带头链表。
带头链表:
哨兵结点是不存储任何数据的。因为哨兵结点一直存在着,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑。
![5ed30d81b5c81eb84d01c0f2ae517648.png](https://i-blog.csdnimg.cn/blog_migrate/53fe10780777ebf8c215d44790aaa76f.png)
实际上,这种利用哨兵简化编程难度的技巧,在插入排序,归并排序,动态规划等都可以用到
下面利用C语言举个例子,不涉及高级语法
示例一:
// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
int find(char* a, int n, char key) {
// 边界条件处理,如果a为空,或者n<=0,说明数组中没有数据,就不用while循环比较了
if(a == null || n <= 0) {
return -1;
}
int i = 0;
// 这里有两个比较操作:i<n和a[i]==key.
while (i < n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
示例二:
// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
// 我举2个例子,你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6} n=6 key = 7
// a = {4, 2, 3, 5, 9, 6} n=6 key = 6
int find(char* a, int n, char key) {
if(a == null || n <= 0) {
return -1;
}
// 这里因为要将a[n-1]的值替换成key,所以要特殊处理这个值
if (a[n-1] == key) {
return n-1;
}
// 把a[n-1]的值临时保存在变量tmp中,以便之后恢复。tmp=6。
// 之所以这样做的目的是:希望find()代码不要改变a数组中的内容
char tmp = a[n-1];
// 把key的值放到a[n-1]中,此时a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循环比起代码一,少了i<n这个比较操作
while (a[i] != key) {
++i;
}
// 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果i == n-1说明,在0...n-2之间都没有key,所以返回-1
return -1;
} else {
// 否则,返回i,就是等于key值的元素的下标
return i;
}
}
这两段代码相信很容易看懂吧,通过比较这两个示例代码,如果当a字符串很长时,达到几万,甚至几十万,是那段代码的运行效果高?答案肯定是示例二。两端代码中执行次数次数最多就是while循环那部分。第二段代码中,通过一个哨兵a[n-1] = key ,成功省掉了一个比较语句i < n,在字符串a很长的时候,累积的节省时间就很明显了。
利用哨兵简化编码
哨兵可以理解为它可以减少特殊情况的判断,比如判空,比如判越界,比如减少链表插入删除中对空链表的判断,比如例子中对i越界的判断。
哨兵的巧妙就是提前将这种情况去除,比如给一个哨兵结点,以及将key赋值给数组末元素,让数组遍历不用判断越界也可以因为相等停下来。
使用哨兵的指导思想应该是将小概率需要的判断先提前扼杀,比如提前给他一个值让他不为null,或者提前预设值,或者多态的时候提前给个空实现,然后在每一次操作中不必再判断以增加效率。
技巧四:重点留意边界条件处理
软件开发中,代码在一些边界或者异常情况下,最容易产生 Bug。要实现没有 Bug 的链表代码,一定要检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行。
常用检查链表代码是否正确的边界条件有:
- 如果链表为空时,代码是否能正常工作?
- 如果链表只包含一个结点,代码是否能正常工作?
- 如果链表只包含两个结点时,代码是否能正常工作?
- 代码逻辑在处理头结点和尾节点时候,代码是否能正常工作?
如果这些边界条件下都没有问题,那基本不也就没有问题了。
当然,边界条件不止针对不同的场景,可能还有特定的边界条件。
写任何代码时,一定要多想想,你的代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!
技巧五:举例画图,辅助思考
找一个具体的例子,把它画下面释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,可以把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:
![c8e91f97954ef44f7978c9cc37819ec3.png](https://i-blog.csdnimg.cn/blog_migrate/78b4780c35847a78925b895567f2d875.jpeg)
看图写代码,是不是就简单多啦?而且,当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 Bug。
技巧六:多写多练,没有捷径
下面5个常见的链表操作。只要把这几个操作都能练熟,虽然说我已经写了不下2遍,但想要完整写出来来还是存在很多Bug的。将在下讲详解这五个例子
- 单链表反转
- 链表中环的检测
- 两个有序的链表合并
- 删除链表倒数第 n 个结点
- 求链表的中间结点
写链表代码是最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生 Bug。链表代码写得好坏,可以看出一个人写代码是否够细心,考虑问题是否全面,思维是否缜密。
解决上讲遗留下的思考题:
回文字符串(提示:输入"aba",输出:True )
假设一:如果字符串时以数组来存储,如何判断是一个回文串?
思路:
- 使用两个指针分别为left和right分别在最头端和最尾端
- 当left < right 时进入到循环体,还得判断字母的大小写,考虑是否有非法字符
代码如下:
class Solution:
def isPalindrome(self, s: str) -> bool:
# 解决此类方法首选指针法,对于标点符号和空格可以省略 使用.isslnum()
# 如果出现大写字母需要把大写字母转化成小写字母
left, right = 0, len(s) - 1
while left < right:
while left < right and not s[left].isalnum():
left += 1
while left < right and not s[right].isalnum():
right -= 1
# 判断大小写
if s[left].lower() != s[right].lower():
return False
left += 1
right -= 1
return Turn
假设二:如果字符串是通过单链表来存储的,如何判断是一个回文串?
思路:
- 使用快慢两个指针找到链表中点,慢指针每次前进一步,快指针每次前进两步。这样当快指针指向末尾时,慢指针指向了中点。
- 在慢指针前进的过程中,同时修改其 next 指针指向上一个元素prev,使得链表前半部分反序。
- 最后比较中点两侧的链表是否相等。
代码如下:
class
解决开篇问题:
解决今天最后一个问题:用python实现一个单向链表的基本操作,我个人觉得我写的代码不是那么好。
#coding=utf-8
##节点实现
# class StrinigNode(object):
# #单链表的结点
# def __init__(self,item):
# #_item存放数据元素
# self.item = item
# #_next是下一个节点的标识
# self.next = None
# from boto.roboto import param
class Node(object):
#节点
def __init__(self,elem):
#elem(元素)保存到节点区
self.elem = elem#用作保存数据
#假设下一个标识为空
self.next = None
# node =Node(100)
class SingleLinkList(object):
#头节点为对象属性,定义在对象中
def __init__(self,node=None):
#1.假设链表中为空链表,则为None
#2.假设在构造中用户传入了头节点,在Node中传入的头节点为100
self.__head = node#头节点为自己内部函数使用,所以为私有的属性
def is_empty(self):
#判断链表是否为空
#分析:当头节点为空时则表示链表为空
return self.__head == None
def length(self):
# 链表的长度
#cur游标,用来移动遍历节点
cur = self.__head #从头节点开始遍历
#count用来记录遍历多少次
count =0
while cur!=None:#在这的条件有两种选择:cur.next!=None/cur!=None
'''为什么不选择cur.next!=None,当在最后一个数值时候指针域为None,在数据域中有数值将会漏掉这一个值
所以还是采用cur!=None
'''
count+=1
cur = cur.next
return count#此方法已经解决了当头节点为空的情况
# count = 1
# while cur.next!= None:
# count+=1
# cur = cur.next
# return count要考虑当指针为零的时候的情况
def travel(self):
# 遍历整个列表
#遍历是与链表的长度判断是相似的,不过要考虑链表是否为空链表
cur = self.__head
while cur!=None:
#那么就打印出链表的值
print(cur.elem,end=',')
#判断游标的下一个指针域
cur = cur.next
def add(self,item):
#列表头部添加元素
node=Node(item)#创建对象
#保证链表的完整性,所以要先从新的元素开始操作
node.next=self.__head
self.__head=node
#1.要考虑链表为空链表的时候,该算法是符合链表为空链表的时候
def append(self,item):
'''
在链表尾部添加一个元素 ,尾插法
node = Node(item)#在头节点传入一个元素
##判断链表是否为空链表
#直接使用empty()方法
if self.is_empty():
self.__head=node
else:
cur = self.__head = None
while cur.next!= None:#当出现头节点为空的时候是没有None值的
cur = cur.next
cur.next=node
'''
"""链表尾部添加元素, 尾插法"""
node = Node(item)
if self.is_empty():
self.__head = node
else:
cur = self.__head
while cur.next != None:
cur = cur.next
cur.next = node
def insert(self,pos,item):
# 在指定位置pos添加节点
#:param pos从0开始 ,par表示元素的前驱,也就是每个数最前面的一个节点
##需要判读头插法和尾插法两种情况
if pos < 0:
self.add(item)#item代表元素
elif pos > (self.length()-1):
#如果>= 是在length最后一个前面进行添加的
self.append(item)
else:
pre = self.__head#
count = 0 #用作计数
while count < (pos-1):#不能包含==号
count = +1
pre = pre.next
#当循环退出后 ,pre指向pos-1位置
node=Node(item)
node.next = pre.next
pre.next = node
def remove(self,item):
# 删除一个例程,指的是删除数据,不是下标
cur = self.__head
pre = None
while cur != None:
if cur.elem == item:#判断你所需要的值是不是你所要的
#再判断此节点是否是头节点
#如果是
if cur == self.__head:
self.__head = cur.next
else:
pre.next = cur.next
break
else:
cur = pre
cur = cur.next
#思考:1.链表为空链表,那么符不符合 2.删除的节点为首节点
# 3.头部只有一个节点 4.尾部节点处理情况
def search(self,item):
#查找节点是否存在
#判断数据是否在链表中,如果在返回True 不在返回False,进行遍历
pre = self.__head #在这记住要判断该链表是否为空
#先进行遍历
while pre != None:
#由于一开始在头节点上有elem元素,所以可以直接进行匹配
if pre.elem == item: #进行判断
return True
#如果没有找到那就继续往下移动进行查找
else:
pre = pre.next
return False
if __name__ == "__main__":
ll = SingleLinkList()
print(ll.is_empty())
print(ll.length())
ll.append(1)
print(ll.is_empty())
print(ll.length())
ll.append(2)
ll.add(8)
ll.append(3)
ll.append(4)
ll.append(5)
ll.append(6)
# 8 1 2 3 4 5 6