数据机构与算法学习(四)- 链表
数组和链表是两个非常基础、非常常用的数据结构。
两者的区别:从底层的存储结构来看,数组需要一块连续的内存空间来存储,对内存的要求比较高。而链表恰恰相反,它并不需要一块连续的内存空间,它通过“指针”将一组零散的内存块串联起来使用。
三种常见的链表结构:单链表、双链表和循环链表。
单链表
概念定义:
我们把内存块称为链表的节点。记录下一个节点的指针叫做后继指针next。把第一个节点叫做头节点。把最后一个节点叫做尾节点。
插入、删除数据操作,只需要考虑相邻节点的指针改变,对应的事件复杂度为 O(1).由于链表的特点随机访问就不是很高效了,需要进行遍历,时间复杂度为O(n).
循环链表
循环链表是一种特殊的单链表。与单链表唯一的区别就在尾节点。单链表指向空,循环链表指向头节点。优点是从链尾到链头比较方便。当腰处理的数据具有环型结构特点时,就特别适合采用循环链表,比如著名的约瑟夫问题。
双向链表
支持两个方向,每个节点的指针不止有一个后继指针next,还有一个前驱指针prev指向前面的节点。双向链表可以支持O(1)的时间复杂度的情况下找到前驱节点。
删除操作:第一种, 删除节点中“值等于某个给定值”的结点; 这种情况的单链表与双链表都要做遍历O(n)的时间复杂度。第二种,删除给定指针指向的结点。这种情况对于单链表双链表就比较有优势了,单链表需要重新遍历 而双链表不需要。
插入操作亦是如此。这是一种用空间换时间的设计思想。双链表需要两个指针,空间占用较大。
链表代码的写作技巧
技巧一:理解指针或引用的含义
将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。
技巧二:警惕指针丢失和内存泄漏
插入节点或者删除节点时,一定要注意操作的顺序。
删除节点时,也一定要记得手动释放内存空间。
技巧三:利用哨兵简化实现难度
单链表的插入
new_node->next = p->next;
p->next = new_code;
如果向一个空链表中插入第一个节点,上面的逻辑不能用了,需要特殊处理。
if(head == null){ head = new_code; }
删除节点
p->next = p->next->next;
如果删除的是最后一个节点,需要特殊处理。
if(head->next == null){ head = null; }
可以看出, 针对链表的插入、删除操作,需要对插入第一个节点和删除最后一个节点的情况进行特殊处理。这样的代码实现起来就会很繁琐不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?
这里引入哨兵来解决问题。如果我们引入哨兵节点,在任何时候,不管链表是不是空,head指针都会一致指向这个哨兵节点。我们也把这种有哨兵节点的链表叫带头链表,相反,没有哨兵节点的链表就叫做不带头链表。哨兵节点一致存在,所以插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点,都可以统一为相同的代码实现逻辑了。
技巧四:重点留意边界条件处理
- 如果链表为空时,代码能正常工作?
- 如果链表只包含一个节点时,代码能否正常工作?
- 如果链表只包含两个节点时,代码能够正常工作?
- 代码逻辑在处理头节点和尾节点的时候,能否正常工作?
技巧五:举例画图,辅助思考
使用举例法和画图法。将它画在纸上,把各种情况都举一个例子,画出来。
技巧六:多写多练,没有捷径
把一下几个操作都能写熟练。
- 单链表反转
- 链表中环的检测
- 两个有序的链表合并
- 删除链表倒数第n个节点
- 求链表的中间节点