单链表的各项常规操作
对于单链表的头指针,头结点的说明:
单链表也是一种线性表,所以总得有个头有个尾。链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。
这里有个地方要注意,就是对头指针概念的理解,这个很重要。“链表中第一个结点的存储位置叫做头指针”,如果链表有头结点,那么头指针就是指向头结点数据域的指针,参考下图:
- 头结点是为了操作的统一与方便而设立的,放在第一个元素结点之前,其数据域一般无意义(当然有些情况下也可存放链表的长度、用做监视哨等等)。
- 有了头结点后,对在第一个元素结点前插入结点和删除第一个结点,其操作与对其它结点的操作统一了。
- 首元结点也就是第一个元素的结点,它是头结点后边的第一个结点。
- 头结点不是链表所必需的。
头指针理解:
- 在线性表的链式存储结构中,头指针是指链表指向第一个结点的指针,若链表有头结点,则头指针就是指向链表头结点的指针。
- 头指针具有标识作用,故常用头指针冠以链表的名字。
- 无论链表是否为空,头指针均不为空。头指针是链表的必要元素
没有头结点的图示:
下面分别以头插法和尾插法来创建链表:
头插法:注:这里第一个创建的链表不带头结点,只含有头指针
linklist *CreateList_Front() { linklist *head, *p; char ch; head = NULL; printf("依次输入字符数据(‘#’表示输入结束):\n"); ch = getchar(); while(ch != '#') { p = (linklist*)malloc(sizeof(linklist)); p->data = ch; p->next = head; head = p; ch = getchar(); //头插法算法简单 核心就两句p->next = head;head = p; } return head; }
/* 随机产生n个元素的值,建立带表头结点的单链线性表L(头插法) */ void CreateListHead(LinkList *L, int n) { LinkList p; int i; srand(time(0)); /* 初始化随机数种子 */ *L = (LinkList)malloc(sizeof(Node)); (*L)->next = NULL; /* 先建立一个带头结点的单链表 */ for (i=0; i < n; i++) { p = (LinkList)malloc(sizeof(Node)); /* 生成新结点 */ p->data = rand()%100+1; /* 随机生成100以内的数字 */ p->next = (*L)->next; (*L)->next = p; /* 插入到表头 */ } }
其实理解的关键点在于
首次调用p->next = (*L)->next; 即p->next = null,然后让(*L)->next = p,如此此次创建的结点就作为了尾结点,链表输出变为了逆序
尾插法:
linklist *CreateList_End() { linklist *head, *p, *e; char ch; head = NULL; e = NULL; printf("请依次输入字符数据('#'表示输入结束):\n"); ch = getchar(); while(ch != '#') { p = (linklist*)malloc(sizeof(linklist)); p->data = ch; if(head == NULL) //先判断输入的是不是第一个节点 { head = p; } else { e->next = p; //e始终指向输入的最后一个节点 } e = p; ch = getchar(); } if(e != NULL) //如果链表不为空,则最后节点的下一个节点为空 { e->next = NULL; } return head; //尾插法比头插法复杂一些,程序中要做两次判断,分别是判断第一个节点和最后一个节点的判断。且消耗多一个指针变量e。 }
尾插法也很好理解,其实就是每次将新的节点插入到链表的后面,利用输入ch是否为字符#来控制链表创建结束,这里实现的尾插法也不带头结点
下面以带头节点的链表为例具体说明链表的各项操作:增删改查
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <time.h> 4 5 #define OK 1 6 #define ERROR 0 7 #define TRUE 1 8 #define FALSE 0 9 #define MAXSIZE 20 /* 存储空间初始分配量 */ 10 11 //定义节点结构体 12 typedef struct Node{ 13 int data; 14 struct Node* next; 15 }Node; 16 typedef struct Node* LinkList; 17 18 //为统一操作起见,这里采用带有头结点的链表 19 //初始化线性顺序表 20 int InitList(LinkList* L){ 21 *L = (LinkList)malloc(sizeof(Node));//新建一个节点,然后让L指向这个节点 22 if(!(*L)){//存储分配失败 23 return ERROR; 24 } 25 (*L)->next = NULL; 26 return OK; 27 } 28 // 单链表的插入操作,实际上核心只有两部操作:待插节点为e,1. e->next = p->next; 2. p->next = e 29 // 注意有一点要说明,这里的链表带有头结点,初始p指向头结点,而不是首个元素节点,这样下面的代码才合理 30 // 在链表L的第i个位置之前插入新的元素e,并将L的长度加1 31 int ListInsert(LinkList* L, int i, int e){ 32 33 LinkList p, s; 34 p = *L; 35 int j = 1; 36 while(p && j < i){ 37 p = p->next; 38 j++; 39 } 40 if(!p || j > i){ 41 return ERROR; 42 } 43 s = (LinkList)malloc(sizeof(Node)); 44 s->data = e; 45 s->next = p->next; 46 p->next = s; 47 return OK; 48 } 49 50 /** 51 * 删除链表中的第i个元素,这里就涉及到链表是否带有头结点的情况不同来分析 52 */ 53 int ListDelete(LinkList* L, int i, int *e){ 54 55 LinkList p, q; 56 p = *L; 57 int j = 1; 58 //若带有头结点 59 while(p->next && j < i){ 60 p = p->next; 61 j++; 62 } 63 if(!(p->next) || j > i){ 64 return ERROR; 65 } 66 q = p->next; 67 p->next = q->next; 68 *e = q->data; 69 free(q); 70 return OK; 71 } 72 73 74 /* 初始条件:顺序线性表L已存在 */ 75 /* 操作结果:依次对L的每个数据元素输出 */ 76 int ListTraverse(LinkList L) 77 { 78 LinkList p = L->next; 79 while(p) 80 { 81 visit(p->data); 82 p=p->next; 83 } 84 printf("\n"); 85 return OK; 86 } 87 88 89 int visit(int e){ 90 91 printf("-> %d ", e); 92 return OK; 93 } 94 95 int main() 96 { 97 LinkList L; 98 int i, j, k; 99 int e; 100 101 i = InitList(&L); 102 srand((unsigned)time(NULL)); 103 for(j = 1; j <= 10; j++){ 104 i = ListInsert(&L, 1, rand()%100); 105 } 106 ListTraverse(L); 107 108 ListDelete(&L, 4, &e);//删除从首个元素节点算起的第4个节点 109 ListTraverse(L); 110 //printf("Hello world!\n"); 111 return 0; 112 }
运行结果:从运行结果可以看出链表中第4个节点65被删除
从上面的代码可以分析出,单链表的删除操作的时间复杂度是O(N),它必须从链表的头部开始依次遍历,而相对线性表的顺序存储结构而言没有什么优势;但是对于插入元素而言,如果需要在链表中的某个位置插入多个元素,对于数组就需要将插入位置后面的元素全部向后移动,而线性链表则不需要,性能会更好
所以对于插入删除操作比较频繁的情况优先选择链表
下面来实现获取指定位置的元素:
1 //获取L中第i个数据元素的值,并用e返回 2 int getElement(LinkList* L, int i, int* e){ 3 4 LinkList p; 5 p = L->next;//这里依然是带有头节点的链表,让p指向第一个非头结点元素,也即首元节点;这里保持p和首元节点一致 6 j = 1; 7 while(p && j < i){ 8 p = p->next; 9 j++; 10 } 11 if(!p || j >i){ 12 return ERROR; 13 } 14 *e = p->data; 15 return OK; 16 }
实际上就是从首元节点开始遍历列表,当找到第i个元素后赋值返回
置空列表:
1 //置空列表 2 int ClearList(LinkList* L){ 3 LinkList p, q; 4 p = (*L)->next; 5 6 while(p){ 7 q = p->next; 8 free(p); 9 p = q; 10 } 11 (*L)->next = NULL; 12 return OK; 13 }
实际上核心代码就是7~9行,从首元节点开始依次释放每一个节点,最后将头结点的next指向NULL
求单链表的倒数第n个数:
这个思路很简单:只要使用两个指针一前一后,之间间隔元素为n,那么当前面的指针到达链尾时,后面的指针自然就是倒数第n个元素,可以让前面的指针先走n步,然后让两指针同时移动:
1 // 获取单链表倒数第N个结点值 2 Status GetNthNodeFromBack(LinkList L, int n, ElemType *e) 3 { 4 int i = 0; 5 LinkList firstNode = L; 6 while (i < n && firstNode->next != NULL) 7 { 8 //正数N个节点,firstNode指向正的第N个节点 9 i++; 10 firstNode = firstNode->next; 11 printf("%d\n", i); 12 } 13 if (firstNode->next == NULL && i < n - 1) 14 { 15 //当节点数量少于N个时,返回NULL 16 printf("超出链表长度\n"); 17 return ERROR; 18 } 19 LinkList secNode = L; 20 while (firstNode != NULL) 21 { 22 //查找倒数第N个元素 23 secNode = secNode->next; 24 firstNode = firstNode->next; 25 //printf("secNode:%d\n", secNode->data); 26 //printf("firstNode:%d\n", firstNode->data); 27 } 28 *e = secNode->data; 29 return OK; 30 }
这里来看一个算法题:如何快速找到未知长度单链表的中间结点
这里采用标尺法:
思路也很简单,采用两个指针,一个指针是另一个的步进数的二倍,这样当快指针 到达链尾,慢指针就刚好到达链表中间
1 int GetMiddleElem(LinkList* L, int* e){ 2 3 LinkList fast, slow; 4 slow = fast = L; 5 while(fast->next != NULL){ 6 if(fast->next->next != NULL){ 7 //fast每次走两步,slow每次走一步 8 //不算头结点,从首元节点开始,链表节点为奇数则刚好,若为偶数,则最后一步fast只走一步,slow不走即可 9 fast = fast->next->next; 10 slow = slow->next; 11 }else{ 12 fast = fast->next; 13 } 14 //这里当链表长度为偶数时,取得中间元素为前面的,如1、2、3、4、5、6,则取到中间元素为3 15 } 16 *e = slow->data; 17 return OK; 18 }
下面分析单链表是否有环的情况:
有环的定义:
链表的尾节点指向了链接中间的某个节点。比如下图,如果单链表有环,则在遍历时,在通过结点J之后,会重新回到结点D
思路依然很简单,就是采用快慢指针,这样有环的话一定会在某一点相遇:这也是最简单的思路
1 int HasLoop(LinkList* L){ 2 int step1 = 1; 3 int step2 = 2; 4 5 LinkList p, q; 6 p = q = L;//这里有无头结点都没有关系 7 8 while(p != NULL &&q!= NULL && q->next != NULL){ 9 p = p->next; 10 if(q->next != NULL){ 11 q = q->next->next; 12 } 13 if(q == p){ 14 return 1; 15 } 16 } 17 return 0; 18 }
分析到这里,下面要着重分析一下二级指针的问题:
//定义节点结构体 12 typedef struct Node{ 13 int data; 14 struct Node* next; 15 }Node; 16 typedef struct Node* LinkList;
由上面节点结构体的定义可以看出:Node是一个节点,包含数据和指针,LinkList代表指向Node数据类型的指针变量
所以下面的定义是一样的:
Node ** p 和 LinkList * p
初始化链表:
//void init_linkedlist(LinkedList *list) { void init_linkedlist(LinkedList *list) { *list = (LinkedList)malloc(sizeof(Node)); (*list)->next = NULL; }
形参list是一个指向LinkedList数据类型的指针变量,其实也是指向Node数据类型的指针的指针
main调用时:
- LinkedList list, p;
- init_linkedlist(&list);
你会发现传入的是list地址,为何呢?下面来一步步分析:
- LinkedList list
- init_linkedlist(&list);
- void init_linkedlist(LinkedList *L)
- *L = (LinkedList)malloc(sizeof(Node));
首先是main函数中定义一个Node类型的指针,这个指针用list表示,C语言在定义指针的时候也会分配一块内存,一般会占用2个字节或4个字节,现在在大部分的编译器中占用4个字节,这里用4个字节算。在这4个字节的内存中,没有任何值,所以这个指针不指向任何值。也就是说内存中在定义list这个指向Node的指针变量时就会默认分配一块区域,可以理解为这块区域的名字叫list,而内存块中没有任何值
接下来假设上面list分配的内存地址为0x1000,这里 init_linkedlist(&list); 传入的是list的地址,也即0x1000,注意这里是作为形参传入的,在init_linkedlist函数中,编译器首先为形参list重新分配一个临时指针内存块,这块内存在该函数执行完之后会回收掉,这里将&list赋值给L,也就是说L这个变量的值为0x1000,也即编译器为L分配的内存块中存放的就是0x1000,这样由于L内存块中存放的是main函数中list的地址,所以L指向list,然后新建节点分配的内存块赋值给*L,也就是说list指向该新分配的内存块,也即list变量的值就是新分配节点内存块的地址,也即list内存中的值;于是这样在函数init_linkedlist中分配的一段内存也就能在main函数中反映出来了,main函数中list代表的内存块的就指向了新分配的内存,链表初始化完成
最后再讲一个关于删除重复元素的题目:
思路:其实就是采用双重循环,外层循环每遍历一个节点,内层循环就从该节点开始遍历剩下所有节点,并与这个节点比较,若相同就删除
1 LinkList RemoveDupNode(LinkList L)//删除重复结点的算法 2 { 3 LinkList p,q,r; 4 p=L->next; 5 while(p) // p用于遍历链表 6 { 7 q=p; 8 while(q->next) // q遍历p后面的结点,并与p数值比较 9 { 10 if(q->next->data==p->data) 11 { 12 r=q->next; // r保存需要删掉的结点 13 q->next=r->next; // 需要删掉的结点的前后结点相接 14 free(r); 15 } 16 else 17 q=q->next; 18 } 19 p=p->next; 20 } 21 return L; 22 }
注:这里附上一个链表的题目:给定一个链表,不知道头结点,只知道所要删除的节点指针,如何删除该节点?
分析,由于不知道链表的头结点,所以也就没法直接遍历,这里也就没法采用快慢指针的方式定位到删除节点的前向节点,该方法行不通
第二种思路采用删除节点后面的节点依次前移覆盖掉前面一个节点,以赋值的方式将待删除节点给覆盖掉,实现删除的效果;问题:如果待删除节点为尾节点,即链表的最后一个节点,遇到的问题是没法置空,因为待删除节点的前向节点没法获取到,则它的指针域也就没法改变