小肥柴慢慢手写数据结构(C篇)(2-3 单链表 SingleLinkedList self版实现3--循环链表与快慢指针
目录
2-10 循环链表
循环链表,顾名思义,就是收尾相连的一个环,借助之前我们已经实现的self版单链表,稍作修改就很容易实现:
(1)保留虚拟头结点,改一个名字“赤道”(Equator),用于标记循环链表起始位置;
(2)收尾相连,意味着最后一个节点不再指向NULL,而是指向Equator。
当然,设计中也可以不包含虚拟头结点。
2-10-1 循环链表的关键
(1)注意环形链表初始化时,将list->next指向自己即可
list->next = list;
(2)在遍历中,不能再将curr != NULL 作为遍历条件,需要修改为
while(curr->next != list) //list 为头结点
(3)其他实现同之前完成的单链表基本一致,只是一些细节上需要把握。
2-10-2 具体代码实现
list.h
typedef int ElementType;
#ifndef _List_H
#define _List_H
struct Node;
typedef struct Node *PrtToNode;
typedef PrtToNode List;
typedef PrtToNode Position;
List createList();
int IsEmpty(List L);
Position Find(ElementType X, List L);
Position FindPrevious(ElementType X, List L);
void Insert(ElementType X, List L, Position P);
void InsertFirst(ElementType X, List L);
void InsertLast(ElementType X, List L);
Position Last(List L);
void Delete(ElementType X, List L);
void PrintList(List L);
#endif /* _List_H */
list.c
#include <stdlib.h>
#include <stdio.h>
#include "list.h"
struct Node{
ElementType Element;
Position Next;
};
List createList(){
Position L = (Position)malloc(sizeof(struct Node));
if(L == NULL){
printf("\ncreate list failed, out of memery!\n");
return NULL;
}
L->Next = L;
return L;
}
int IsEmpty(List L){
return L == NULL ? 1 : L== L->Next;
}
Position Find(ElementType X, List L){
Position Curr = L->Next;
while(Curr != L && X != Curr->Element)
Curr = Curr->Next;
return Curr == L ? NULL : Curr;
}
Position FindPrevious(ElementType X, List L){
Position Prev = L;
while(Prev->Next != L && X != Prev->Next->Element)
Prev = Prev->Next;
return Prev->Next == L ? NULL : Prev;
}
void Insert(ElementType X, List L, Position P){
Position TmpCell = (Position)malloc(sizeof(struct Node));
if(L == NULL)
printf("\ninsert list failed, out of memery!\n");
TmpCell->Next = P->Next;
TmpCell->Element = X;
P->Next = TmpCell;
}
void InsertFirst(ElementType X, List L){
Position TmpCell = (Position)malloc(sizeof(struct Node));
if(TmpCell == NULL)
printf("\ninsert list first failed, out of memery!\n");
TmpCell->Next = L->Next;
TmpCell->Element = X;
L->Next = TmpCell;
}
void InsertLast(ElementType X, List L){
Position TmpCell;
if (L == L->Next){
TmpCell = (Position)malloc(sizeof(struct Node));
if(TmpCell == NULL)
printf("\ninsert list first failed, out of memery!\n");
TmpCell->Next = L->Next;
TmpCell->Element = X;
L->Next = TmpCell;
} else {
Insert(X, L, Last(L));
}
}
Position Last(List L){
if(IsEmpty(L))
return NULL;
Position Curr = L->Next;
while(Curr->Next != L)
Curr = Curr->Next;
return Curr;
}
void Delete(ElementType X, List L){
Position Prev = FindPrevious(X, L);
if(Prev != NULL){
Position TmpCell = Prev->Next;
Prev->Next = TmpCell->Next;
free(TmpCell);
} else
printf("%d doesn't exit", X);
}
void PrintList(List L){
Position Curr = L->Next;
printf("\nEquatorHead->");
while(Curr != L){
printf("[%d]->", Curr->Element);
Curr = Curr->Next;
}
printf("back\n");
}
简单的测试和调用Main.c
#include <stdio.h>
#include <stdlib.h>
#include "list.h"
int main(int argc, char *argv[]) {
List list = createList();
int i;
for(i = 0; i < 3; i++)
InsertFirst(i, list);
for(i = -1; i > -4; i--)
InsertLast(i, list);
printf("\n===============test create && insert functions :===================\n");
PrintList(list);
i = 0;
printf("\n===============test create && delete %d:===================\n", i);
Delete(i, list);
PrintList(list);
i = -3;
printf("\n===============test create && delete %d:===================\n", i);
Delete(i, list);
PrintList(list);
i = 10;
printf("\n===============test create && delete %d:===================\n", i);
Delete(i, list);
PrintList(list);
i = 2;
printf("\n===============test create && delete %d:===================\n", i);
Delete(i, list);
PrintList(list);
}
老样子,实现一些基本功能即可,什么排序,交、并、非逻辑运算,有时间再补上;估计严版教材也有,看过教材理解相关操作,再返回来完成代码也不迟。
2-11 快慢指针 (参考labuladong的算法小抄)
2-11-1 快慢指针的概念
我们之前的遍历操作,都老老实实的使用一个指针,一步一步的next遍历。现在我们设置两个指针fast(快指针)和slow(慢指针),它们都从First节点开始遍历,只是:
(1)slow每次遍历后移动到下一个节点 slow = slow->next
(2)fast每次遍历后移动更快,一次跳两个节点,甚至更多个 fast = fast ->next->next,形象的比喻:fast跑得比slow快!
那么这必然产生一些有趣的现象:
(1)如果是普通单链表:fast必然先于slow完成链表遍历。
(2)如果是循环单链表:可以想象为fast/slow是两个在学校操场跑圈的学生,跑得快的那位必然要超越跑得慢的同学,俗称“套圈”。
基于以上两个有趣的结论,可以轻松解决很多编程问题。
2-11-2 面试经典问题
- 判定链表中是否含有环
借用上面讲的“套圈”理论,先造一个带环的链表模型,然后用快慢指针判断
main.c
int main(int argc, char *argv[]) {
Position last;
printf("\n===============create list :===================\n");
int i;
List list = createList();
Position P = list;
for(i = 0; i < 10; i++){
Insert(i, list, P);
P = Advance(P);
}
PrintList(list);
printf("\nhas circle before ? %d", hasCircle(list));
Position clcP = Find(6, list); //设置节点值为6的节点为环的起点
Position lastP = Last(list); //获取最后一个节点
setNext(lastP, clcP); //将最后一个节点Next指向环的起点,形成环
printf("\nhas circle after ? %d, clcP=%d, lastP=%d", hasCircle(list), Retrieve(clcP), Retrieve(lastP));
return 0;
}
辅助函数list.c
void setNext(Position Curr, Position NextPosition){
Curr->Next = NextPosition;
}
Position Last(List L){
if(IsEmpty(L))
return NULL;
Position P = L->Next;
while(P!=NULL && P->Next != NULL)
P = P->Next;
return P;
}
ElementType Retrieve(Position P){
return P==NULL ? NULL : P->Element;
}
核心策略,快慢指针 list.c
int hasCircle(List L){
Position Slow = L->Next, Fast = L->Next; //有DummyHead
while(Fast != NULL && Fast->Next != NULL){ //Fast的循环条件判别要注意
Fast = Fast->Next->Next; //快指针每次前进两个单位
Slow = Slow->Next; //慢指针每次前进一个单位
if(Fast == Slow) //套圈,证明有环,直接返回true
return 1;
}
return 0;
}
接下来更进一步,思考如何获取检测到的环的起点。
- 已知链表中含有环,返回这个环的起始位置
(1)通过上面的判断环逻辑,能够得到fast和slow的套圈节点,依旧假设fast步进为2,slow步进为1,标记环形起点为s,相遇点为p,设s和p之间步进m次,则如图:
1)在第一次相遇时,假设slow步进了k个节点,那么fast就步进2k个节点,两指针套圈,易有环的长度为2k-k=k。(此处留意一下,可以自己实现求环装部分的节点个数)
2)简单列出数量关系
那么很容易可以得到一个重要的规律:如果从p点出发,步进k-m次之后,必然回到起始点s!
(2)因此,我们可以从相遇时的fast和slow中任意挑选一个指针,让它重新由链表头结点出发每次步进1,同时让剩下的另一个指针从当前位置(也就是相遇点p)出发每次也步进1,则步进k-m次后,两个节点必然相遇,且相遇点就是所求的环起始点s,问题解决(实际上并不用考虑步进的具体次数)。
Position findCircleStart(List L){
Position Slow = L->Next, Fast = L->Next;
while(Fast != NULL && Fast->Next != NULL){
Fast = Fast->Next->Next;
Slow = Slow->Next;
if(Fast == Slow) //快慢指针相遇在p点
break;
}
Slow = L->Next; //将slow拨回链表起点
while(Slow != Fast){
Slow = Slow->Next;
Fast = Fast->Next;
}
return Slow;
}
测试代码,补在之前判断是否存在环的代码后面,默认有环才调用。
printf("\ncircle circle position = %d, clcP=%d, lastP=%d", Retrieve(findCircleStart(list)), Retrieve(clcP), Retrieve(lastP));
- 寻找链表的中点
有了1、2两个范例,自然想到fast每次步进2,slow每次步进1,那么当fast遍历结束时,slow自然跑到中点咯。若链表节点数量为奇数用这个方法,正好得到链表中点;若是偶数,则会在中间偏右的节点上。
Position findMidPosition(List L){
Position Slow = L->Next, Fast = L->Next;
while(Fast != NULL && Fast->Next != NULL){
Fast = Fast->Next->Next;
Slow = Slow->Next;
}
return Slow;
}
- 寻找链表的倒数第 k 个节点
老套路,让fast指针先走k个节点,然后和slow指针一起每次步进1,当fast遍历完毕,那么slow的位置不正好就是倒数第k个节点了吗?
Position findRevKPosition(List L, int k){
Position Slow = L->Next, Fast = L->Next;
int i = 1;
while(i < k){
Fast = Fast->Next;
i++;
}
while(Fast != NULL && Fast->Next != NULL){
Fast = Fast->Next;
Slow = Slow->Next;
}
return Slow;
}
2-12 相关的应用
把快慢指针的使用拓展一下,可以发现很多有趣的解决问题的办法。
2-12-1 左右指针
实际上快慢指针技巧,只是“双指针”技巧其中一个部分,“双指针”另一个常用的技巧就是左右指针了,下面看看一些常见问题:
- 二分查找
int binarySearch(int arr[], int len, int target){
int left = 0, right = len - 1;
while(left <= right){ //这个循环条件在很多排序中都有用到
int mid = (left + right) / 2;
if(arr[mid] == target)
return mid;
else if(arr[mid] < target)
left = mid + 1;
else
right = mid - 1;
}
return -1;
}
实际这种双标记的方法,在很多排序算法中都有用到。
- 两数之和(LeeCode 1. 两数之和 的变形,假定nums序列是从小到大排序的,不然就是另一种解法了)
输入:nums = [2, 7, 11, 15], target = 9
输出: [0, 1] //因为 nums[0] + nums[1] = 2 + 7 = 9
此题使用双指针方法可以避免暴力求解,从两个方向步进靠拢。
int* twoSum(int* arr, int size, int target){
int left = 0, right = size- 1;
int res[] = {-1, -1};
while(left < right){
int sum = arr[left] + arr[right];
if(sum == target){
res[0] = left;
res[1] = right;
break;
} else if(sum < target){
left++;
} else {
right--;
}
}
return res;
}
- 反转数组
void reverse(int* arr, int size){
int left = 0, right = size - 1;
while(left < right){
int tmp = arr[left]; //swap left && right
arr[left] = arr[right];
arr[right] = tmp;
left++;
right--;
}
}
- 滑动窗口(这个需要单独列一个专题大家一起讨论学习,此处不表)
2-12-2 约瑟夫环(LeeCode 剑指 Offer 62. 圆圈中最后剩下的数字)
【问题】0,1,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
这个问题需要谨慎思考再动笔,我大一时就靠自己的笨办法磨出来的。
- 学了循环链表迫不及待的你
按照给定参数初始化一个循环链表,然后挨个数,数到了就删除当前节点。。。但是实际施工还是很麻烦的:
(1)你需要一个计数器,并考虑到虚拟头结点的判断和跨越、重置等逻辑细节;
(2)要删除节点,你需要记录两个指针,前驱prev和当前报数指针,还是要注意虚拟头结点。。。
有的解法一看就很复杂,立马意识到思路不对,没有必要浪费时间。
对应严版教材上有相关的基础功能代码,仅供参考:
c2-2.h 线性表的单链表存储结构
struct LNode
{
ElemType data;
LNode *next;
};
typedef LNode *LinkList; // 另一种定义LinkList的方法
bo2-4.cpp 设立尾指针的单循环链表(存储结构由c2-2.h定义)的12个基本操作
Status InitList_CL(LinkList &L)
{ // 操作结果:构造一个空的线性表L。
L=(LinkList)malloc(sizeof(LNode)); // 产生头结点,并使L指向此头结点
if(!L) // 存储分配失败
exit(OVERFLOW);
L->next=L; // 指针域指向头结点
return OK;
}
Status DestroyList_CL(LinkList &L)
{ // 操作结果:销毁线性表L。
LinkList q,p=L->next; // p指向头结点
while(p!=L) // 没到表尾
{
q=p->next;
free(p);
p=q;
}
free(L);
L=NULL;
return OK;
}
Status ClearList_CL(LinkList &L) // 改变L
{ // 初始条件:线性表L已存在。操作结果:将L重置为空表。
LinkList p,q;
L=L->next; // L指向头结点
p=L->next; // p指向第一个结点
while(p!=L) // 没到表尾
{
q=p->next;
free(p);
p=q;
}
L->next=L; // 头结点指针域指向自身
return OK;
}
Status ListEmpty_CL(LinkList L)
{ // 初始条件:线性表L已存在。
// 操作结果:若L为空表,则返回TRUE,否则返回FALSE。
if(L->next==L) // 空
return TRUE;
else
return FALSE;
}
int ListLength_CL(LinkList L)
{ // 初始条件:L已存在。操作结果:返回L中数据元素个数。
int i=0;
LinkList p=L->next; // p指向头结点
while(p!=L) // 没到表尾
{
i++;
p=p->next;
}
return i;
}
Status GetElem_CL(LinkList L,int i,ElemType &e)
{ // 当第i个元素存在时,其值赋给e并返回OK,否则返回ERROR
int j=1; // 初始化,j为计数器
LinkList p=L->next->next; // p指向第一个结点
if(i<=0||i>ListLength_CL(L)) // 第i个元素不存在
return ERROR;
while(j<i)
{ // 顺指针向后查找,直到p指向第i个元素
p=p->next;
j++;
}
e=p->data; // 取第i个元素
return OK;
}
int LocateElem_CL(LinkList L,ElemType e,Status(*compare)(ElemType,ElemType))
{ // 初始条件:线性表L已存在,compare()是数据元素判定函数。
// 操作结果:返回L中第1个与e满足关系compare()的数据元素的位序。
// 若这样的数据元素不存在,则返回值为0。
int i=0;
LinkList p=L->next->next; // p指向第一个结点
while(p!=L->next)
{
i++;
if(compare(p->data,e)) // 满足关系
return i;
p=p->next;
}
return 0;
}
Status PriorElem_CL(LinkList L,ElemType cur_e,ElemType &pre_e)
{ // 初始条件:线性表L已存在。
// 操作结果:若cur_e是L的数据元素,且不是第一个,则用pre_e返回它的前驱,
// 否则操作失败,pre_e无定义。
LinkList q,p=L->next->next; // p指向第一个结点
q=p->next;
while(q!=L->next) // p没到表尾
{
if(q->data==cur_e)
{
pre_e=p->data;
return TRUE;
}
p=q;
q=q->next;
}
return FALSE;
}
Status NextElem_CL(LinkList L,ElemType cur_e,ElemType &next_e)
{ // 初始条件:线性表L已存在。
// 操作结果:若cur_e是L的数据元素,且不是最后一个,则用next_e返回它的后继,
// 否则操作失败,next_e无定义。
LinkList p=L->next->next; // p指向第一个结点
while(p!=L) // p没到表尾
{
if(p->data==cur_e)
{
next_e=p->next->data;
return TRUE;
}
p=p->next;
}
return FALSE;
}
Status ListInsert_CL(LinkList &L,int i,ElemType e) // 改变L
{ // 在L的第i个位置之前插入元素e
LinkList p=L->next,s; // p指向头结点
int j=0;
if(i<=0||i>ListLength_CL(L)+1) // 无法在第i个元素之前插入
return ERROR;
while(j<i-1) // 寻找第i-1个结点
{
p=p->next;
j++;
}
s=(LinkList)malloc(sizeof(LNode)); // 生成新结点
s->data=e; // 插入L中
s->next=p->next;
p->next=s;
if(p==L) // 改变尾结点
L=s;
return OK;
}
Status ListDelete_CL(LinkList &L,int i,ElemType &e) // 改变L
{ // 删除L的第i个元素,并由e返回其值
LinkList p=L->next,q; // p指向头结点
int j=0;
if(i<=0||i>ListLength_CL(L)) // 第i个元素不存在
return ERROR;
while(j<i-1) // 寻找第i-1个结点
{
p=p->next;
j++;
}
q=p->next; // q指向待删除结点
p->next=q->next;
e=q->data;
if(L==q) // 删除的是表尾元素
L=p;
free(q); // 释放待删除结点
return OK;
}
Status ListTraverse_CL(LinkList L,void(*vi)(ElemType))
{ // 初始条件:L已存在。
// 操作结果:依次对L的每个数据元素调用函数vi()。一旦vi()失败,则操作失败
LinkList p=L->next->next;
while(p!=L->next)
{
vi(p->data);
p=p->next;
}
printf("\n");
return OK;
}
- 才开始学编程只会数组的你
可以按照给定参数初始化一个数组int arr[],全部标记为1,代表索引对应位置的数没有出圈。
(1)while循环数数,计数器cnt++,报数到点了就将当前arr[i]标记为0,再次循环到此位置时,检测标记为0,跳过不计数;
(2)数到末尾,使用%取模返回数组头重新数数;
(3)直到int arr[]中仅有一个数据标记为1时,游戏结束。
实际上这不就是用数组实现的逻辑上的环形缓冲区的简化版本吗?回忆下本科课程里都说过的“消费者—生产者”模式,哈哈哈。
当年我们大一,网络远没有现在发达,就拿着一本C++课程讲义正面硬刚,用标记数组方法解决了此问题,直到后来才知道自己用了环形数组的思想。
- 冷静下来先做数学分析的你
实际上这个问题属于数学问题,记得时班上湖南Z同学说高中数学竞赛里面讲过,参考资料[1]和参考资料[5],可以自己推导下,其中有多种解释,但最后的递推公式都是一样的,注意两个定理
(1)(a+b)%c=((a%c)+(b%c))%c
(2)a%c=(a%c)%c
最后化简有:
可以采用递归和非递归两种模式编码。
int lastRemaining(int n, int m) {
int last = 0;
for (int i = 2; i != n + 1; ++i) {
last = (m + last) % i;
}
return last;
}
2-12 小结
(1)其实这篇帖子,算法部分介绍更多,也是希望大家不要割裂学习数据结构和算法。简单来看,算法在设计实现过程中常常简化数据结构的使用,或者灵活使用数据结构的特性。
(2)解决问题时,不能生搬硬套数据结构,比如约瑟夫环问题,即使没有学过循环链表或者文中介绍的数学原理,仅用简单的数组标记,编程初学者也能解决此问题,只是方法稍微笨拙一些。
下一篇,我们将回归严版数据结构教材,看看在单链表在经典本土学院派教材中有什么值得学习的地方,毕竟我们每次在自己捣鼓数据结构的时候,总是随性而为,思维还是需要阶段性聚焦的。
参考:
[1] LeeCode官方题解和Lucien的清晰解释
[2] 《漫画算法》(小灰)
[3] labuladong的算法小抄(微信公众号,之前知乎账号也给大家了)
[4] 《啊哈,算法》
[5] 算法:约瑟夫环