针对在校大学生的C语言入门学习——链表
- 看到标题可能大家会觉得我怎么说话不算,不是说好的这次要给大家带来《学生管理系统》项目吗?其实我没有食言,只是整个项目太大了,一篇文章很难说完。
- 为什么这次要聊一聊链表呢,因为《学生管理系统》肯定是要对学生的数据进行各种操作。既然涉及到了大量的数据,选择一个合适的数据结构来存放数据就是这个项目成功的关键。无论是从实际效果考虑,还是以大家的学习基础考虑,链表都是一个非常不错的选择。
- 今天给大家带来一个我对链表的封装,链表是所有数据结构中最好实现的数据结构。对数据结构的封装也都是围绕增、删、改、查四大功能,下面我们开始。
结构体封装
typedef struct Node
{
void* data;
struct Node* next;
struct Node* pre;
}Node;
typedef struct Link
{
Node* head;
Node* tail;
int size;
}Link;
- Node结构体是链表节点的结构体封装,可以看出我使用的是一个双向链表。data之所以定义成void*类型,因为我的链表可以存放任何类型数据。void*在C语言中是泛型。
- Link结构体是我对链表的封装,每创建一个链表都会有一个Link实例与之对应。在Link中我分别保存了头尾指针和链表的长度。
创建链表
Node* createNode(void* data)
{
Node* node = (Node*)malloc(sizeof(Node));
node->data = data;
node->pre = NULL;
node->next = NULL;
return node;
}
Link* createLink()
{
Link* l = (Link*)malloc(sizeof(Link));
l->head = createNode(NULL);
l->tail = l->head;
l->size = 0;
return l;
}
- createNode函数是创建链表中的一个节点,参数data是这个节点中存放的数据。节点要在堆空间创建。大家看我前面的内容就会知道,堆空间的变量生命周期是自定义的。非常适合保存大量数据。既不会因为栈空间生命周期过短而丢失数据,也不会因为静态空间生命周期过长而浪费内存。每个节点创建出来都会默认把next和pre指针置NULL。野指针是不允许出现的。
- createLink函数是创建一个链表实例,初始时候会有一个无数据的节点作为头节点,head和tail指针都指向这个空头。以后添加新节点的时候会移动tail指针。
- 上图就是我这个双向链表的结构。另外建议大家在写链表的时候多画图,因为链表的编码需要大量的指针操作,稍微不慎就会指向错误。新手写链表时边写边画是一个好办法。
删除链表
void deleteLink(Link* l)
{
Node* node = l->head;
while(node != NULL)
{
Node* t = node;
node = node->next;
free(t->data);
free(t);
}
free(l);
}
- 删除链表的思路就是遍历每个节点,然后逐个释放。但是有一个问题要注意,一旦节点被释放了,那么指向节点的指针就是野指针。我们不能通过野指针移动到下一个节点。所以函数中定义了指针t用来指向要删除的节点。然后先移动,再删除节点。free(t->data);是释放节点中的数据。free(t);是释放当前节点。两个free缺一不可,避免造成内存泄漏。最后释放整个链表实例。
增
void addNode(Link* l, Node* node)
{
l->tail->next = node;
node->pre = l->tail;
l->tail = node;
l->size++;
}
void addItem(Link* l, void* data)
{
Node* node = createNode(data);
addNode(l, node);
}
- addNode函数是向链表中添加一个新的节点(Node实例),参数1是插入链表的指针,参数2是插入节点的指针。这个插入就是向链表尾部插入。我没有做在任意位置插入节点的函数,大家需要的话可以自己编写。需要注意的是向尾部插入节点后一定要改变tail指针的指向,tail始终指向链表中最后一个节点。这个函数因为使用了Node类型,所以我不会在头文件中声明addNode,不让用户使用这个函数。数据结构的封装一定要隐藏自己的结构信息,不给用户胡乱操作的机会。
- addItem函数是我想让用户使用的函数,因为用户只需要传递链表实例指针和数据指针就可以了。
- 注意:C语言是面向过程的语言,想像面向对象语言那样封装的滴水不漏是不可能的,这里我们尽力而为就好了。
查
void* getItem(Link* l, int index)
{
Node* node = l->head->next;
int i;
for(i = 0;i < index;i++)
{
node = node->next;
}
return node->data;
}
- getItem函数是通过遍历链表的方式来获取指定索引位置的数据。这是链表的天然缺陷,因为链表每个元素在内存中不连续,所以没有办法像数组那样以O(1) 的时间复杂度来获取指定索引位置的元素。
- 现在出现了一个很严重的问题,如果用户使用getItem函数遍历链表的话,效率将是灾难性的。举例子,获得第一个数据需要遍历的节点个数是1,访问第二个时需要遍历的节点个数是2,依次类推。每访问一个元素都会把链表从头遍历一遍,反反复复浪费了很多时间。
- 遍历链表最有效的方式就是使用指针遍历,但是我们为了链表结构的安全性,不允许用户获得链表中指向任何节点的指针。问题似乎卡在了这里。好在我们是站在巨人的肩膀上看问题,这个问题早已被大神破解。
-
迭代器遍历
- 迭代器遍历的思路就是把链表的节点指针再进行一层封装,就是迭代器实例。提供一个函数移动迭代器就可以了。请看下面代码。
typedef struct Iterator
{
Node* node;
}Iterator;
Iterator createIterator(Link* l)
{
Iterator iter;
iter.node = l->head->next;
return iter;
}
int iteratorEnd(Iterator iter)
{
return iter.node==NULL;
}
Iterator nextIterator(Iterator iter)
{
Iterator newIter;
newIter.node = iter.node->next;
return newIter;
}
- 通过这段代码你会发现迭代器的操作就是对指针node的操作,只不过这些操作不让用户直接完成,而是调用我提供的接口函数来完成。这样能保证结构安全。下面写一段代码来测试我们目前的链表性能。
#include
#include "link.h"
int main(void)
{
int arr[10] = {
44,44,8,45,21,5,90,45,5,5};
Link* l = createLink();
int i;
for(i = 0;i < 10;i++)
{
int* data = (int*)malloc(sizeof(int));
*data = arr[i];
addItem(l, data);
}
Iterator iter;
for(iter = createIterator(l);
!iteratorEnd(iter);
iter = nextIterator(iter))
{
int* data = (int*)iteratorData(iter);
printf("%d ", *data);
}
printf("\n");
deleteLink(l);
return 0;
}
- 测试代码中我使用int类型作为数据,这样简单又直观。link.h写我们定义过的结构体和函数的声明。测试逻辑是将数组中的数据分别插入链表,然后使用迭代器遍历链表并打印每个数据。以上就是我们查的部分,也是数据结构中最重要最难写的部分。
改
void updateItem(Iterator iter, void *data)
{
free(iter.node->data);
iter.node->data = data;
}
void updateItemByIndex(Link *l, int index, void *data)
{
Iterator iter = createIterator(l);
int i;
for(i = 0;i < index;i++)
{
iter = nextIterator(iter);
}
updateItem(iter, data);
}
- 修改我给用户提供两个接口函数。updateItem以迭代器修改是用在用户遍历链表的时候随时修改。updateItemByIndex以索引修改是方便用户修改指定的元素。修改要注意的是原来的数据一定要释放,然后Node的data指针指向新的数据。下面是一段测试代码。
#include
#include "link.h"
int main(void)
{
int arr[10] = {
44,44,8,45,21,5,90,45,5,5};
Link* l = createLink();
int i;
for(i = 0;i < 10;i++)
{
int* data = (int*)malloc(sizeof(int));
*data = arr[i];
addItem(l, data);
}
int* data = (int*)malloc(sizeof(int));
*data = 100;
updateItemByIndex(l, 0, data);
Iterator iter;
for(iter = createIterator(l);
!iteratorEnd(iter);
iter = nextIterator(iter))
{
int* data = (int*)iteratorData(iter);
if(*data == 45)
{
int* newData = (int*)malloc(sizeof(int));
*newData = 54;
updateItem(iter, newData);
}
}
for(iter = createIterator(l);
!iteratorEnd(iter);
iter = nextIterator(iter))
{
int* data = (int*)iteratorData(iter);
printf("%d ", *data);
}
printf("\n");
deleteLink(l);
return 0;
}
删
Iterator removeItem(Link* l, Iterator iter)
{
Iterator newIter;
newIter.node = iter.node->next;
iter.node->pre->next = iter.node->next;
if(iter.node->next != NULL)
{
iter.node->next->pre = iter.node->pre;
}
free(iter.node->data);
free(iter.node);
l->size--;
return newIter;
}
void removeItemByIndex(Link* l, int index)
{
Iterator iter = createIterator(l);
int i;
for(i = 0;i < index;i++)
{
iter = nextIterator(iter);
}
removeItem(l, iter);
}
- 删除和修改一样,我提供了两个接口函数。removeItem函数是以迭代器删除,用户在遍历时可以使用这种方式删除。removeItemByIndex函数用户可以删除指定索引的数据。需要注意的点比较多,首先是删除节点需要分别free(iter.node->data)和free(iter.node),链表的长度也要减1。迭代器删除节点后原来的迭代器就成为了野指针,所以在删除后我会返回一个指向删除节点下一个节点的迭代器实例。下面测试代码。
#include
#include "link.h"
int main(void)
{
int arr[10] = {
44,44,8,45,21,5,90,45,5,5};
Link* l = createLink();
int i;
for(i = 0;i < 10;i++)
{
int* data = (int*)malloc(sizeof(int));
*data = arr[i];
addItem(l, data);
}
removeItemByIndex(l, 2);
Iterator iter = createIterator(l);
while(!iteratorEnd(iter))
{
int* data = (int*)iteratorData(iter);
if(*data == 45)
{
iter = removeItem(l, iter);
}
else
{
iter = nextIterator(iter);
}
}
for(iter = createIterator(l);
!iteratorEnd(iter);
iter = nextIterator(iter))
{
int* data = (int*)iteratorData(iter);
printf("%d ", *data);
}
printf("\n");
deleteLink(l);
return 0;
}
- 遍历删除时需要注意的问题是如果删除了元素,那么使用removeItem的返回值就已经实现了迭代器的移动,此时不可以再使用nextIterator移动迭代器,会使得遍历跳项。
排序
- 我们还有最后一个任务就是排序。因为链表不支持像数组一样的随机访问元素,所以链表能使用的排序方法很受限制。既然是因为元素不连续而受限制,那我们让其连续不就可以了吗!这里模仿hash映射的原理将链表的每个元素指针映射到一个数组中,然后对数据进行排序,可以使用任何高效的排序方法。排序完成后再根据数组顺序重组链表即可。下面代码我使用快速排序。
void sortArr(Node** arr, int start, int end, int(*cmp)(void*, void*))
{
if(start >= end)
return;
int left = start;
int right = end;
void* mv = arr[(start+end)/2]->data;
while(left < right)
{
while(left<right && cmp(mv, arr[left]->data)==1)
{
left++;
}
while(left<right && cmp(mv, arr[right]->data)!=1)
{
right--;
}
if(left<right)
{
Node* t = arr[left];
arr[left] = arr[right];
arr[right] = t;
}
}
sortArr(arr, start, left-1, cmp);
sortArr(arr, left+1, end, cmp);
}
void sort(Link* l, int(*cmp)(void*, void*))
{
int size = l->size;
Node** arr = (Node**)malloc(sizeof(Node*)*size);
Node* node = l->head->next;
int i;
for(i = 0;i < size;i++)
{
arr[i] = node;
node = node->next;
}
sortArr(arr, 0, size-1, cmp);
l->tail = l->head;
l->size = 0;
for(i = 0;i < size;i++)
{
addNode(l, arr[i]);
}
l->tail->next = NULL;
free(arr);
}
- 快速排序的算法我不多说。排序首先我在堆空间创建一个和链表等长的数组,数组用来存放每个节点的指针。因为数组元素是指针,所以指向这个数组的指针就得定义成二级指针。关于这个问题可以参考我前面的《针对在校大学生的C语言入门学习——高级语法》。
- 因为我的链表是一个泛型链表,所以我不可能知道用户存放的是什么类型的数据。但是排序就得比较,不知道数据类型怎么比较呢?我的解决方案就是定义函数指针。我只是把排序算法写好,至于数据的比较方法有用户来提供。也就是说用户需要自己写比较的函数,然后传递给我的函数指针参数。请看下面测试代码。
#include
#include "link.h"
int cmp(void* num1, void* num2)
{
int* a = num1;
int* b = num2;
if(*a > *b)
{
return 1;
}
else if(*a < *b)
{
return -1;
}
else
{
return 0;
}
}
int main(void)
{
int arr[10] = {
44,44,8,45,21,5,90,45,5,5};
Link* l = createLink();
int i;
for(i = 0;i < 10;i++)
{
int* data = (int*)malloc(sizeof(int));
*data = arr[i];
addItem(l, data);
}
sort(l, cmp);
Iterator iter;
for(iter = createIterator(l);
!iteratorEnd(iter);
iter = nextIterator(iter))
{
int* data = (int*)iteratorData(iter);
printf("%d ", *data);
}
printf("\n");
deleteLink(l);
return 0;
}
总结
- 链表虽然不难,但其数据结构的本质很考验一个程序员的功力。同学们阅读或者编写链表代码时如果思路不能畅通,一定要边写边画。假以时日便能信手拈来。我的链表就封装到这里,后面的学生管理系统看它大显身手吧!本课源码下载点击。
-