小肥柴慢慢手写数据结构(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 面试经典问题

  1. 判定链表中是否含有环
    借用上面讲的“套圈”理论,先造一个带环的链表模型,然后用快慢指针判断
    在这里插入图片描述

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. 已知链表中含有环,返回这个环的起始位置
    (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. 寻找链表的中点
    有了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;
}
  1. 寻找链表的倒数第 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 左右指针

实际上快慢指针技巧,只是“双指针”技巧其中一个部分,“双指针”另一个常用的技巧就是左右指针了,下面看看一些常见问题:

  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;
}

实际这种双标记的方法,在很多排序算法中都有用到。

  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;
}
  1. 反转数组
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--;
	}
}
  1. 滑动窗口(这个需要单独列一个专题大家一起讨论学习,此处不表)

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. 学了循环链表迫不及待的你
    按照给定参数初始化一个循环链表,然后挨个数,数到了就删除当前节点。。。但是实际施工还是很麻烦的:
    (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;
 }
  1. 才开始学编程只会数组的你
    可以按照给定参数初始化一个数组int arr[],全部标记为1,代表索引对应位置的数没有出圈。
    (1)while循环数数,计数器cnt++,报数到点了就将当前arr[i]标记为0,再次循环到此位置时,检测标记为0,跳过不计数;
    (2)数到末尾,使用%取模返回数组头重新数数;
    (3)直到int arr[]中仅有一个数据标记为1时,游戏结束。
    实际上这不就是用数组实现的逻辑上的环形缓冲区的简化版本吗?回忆下本科课程里都说过的“消费者—生产者”模式,哈哈哈。

当年我们大一,网络远没有现在发达,就拿着一本C++课程讲义正面硬刚,用标记数组方法解决了此问题,直到后来才知道自己用了环形数组的思想。

  1. 冷静下来先做数学分析的你
    实际上这个问题属于数学问题,记得时班上湖南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] 算法:约瑟夫环

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值