c语言实现一个链表
一、基础研究
我们在这里要理解和实现一种最基本的数据结构:链表。首先看看实现的程序代码:
List .h:
事实上我们观察list.h发现前面一部分是数据结构的定义和函数的声明,后面一部分是函数的实现。我们仅仅观察前面一部分就可以知道这个链表的结构是怎么实现的了。
程序将处理的对象分成了三类:线性表、结点和元素,分别定义了它们的数据类型和操作函数,对线性表有创建、撤销、清空操作,对元素有追加、加入、删除、取操作,对结点有取、遍历、创建操作,每一个操作都用一个子函数来实现。它们全部被封装进了头文件list.h,这是对共性的封装。
我们用m.c对list.h进行测试:
执行结果如下:
m.c首先创建了一个字符数组来装载要存入线性表中的元素,再定义了显示线性表的函数showlist和显示单个元素的函数putelement。在主函数中首先调用CreateList函数创建一个线性表,如果创建失败会提示错误并返回,如果成功则调用ListAppend函数将字符数组里的内容放进线性表中,再调用showlist函数显示字符串。之后我们调用ListInsert函数向链表中插入一个元素结点并显示,再调用ListDelete函数删除之前插入的元素,并显示字符串。其中CreateList函数、ListInsert函数、ListDelete函数都是在list.h中的函数,是有关链表本身的操作,是共性,而showlist函数和putelement函数是在c文件中实现的,它们的功能是个性,是需求。showlist函数是调用TraverseList函数遍历链表,并对每个元素用putelement函数进行处理,而putelement函数是将该元素打印出来。为什么在TraverseList函数里要将遍历链表和处理函数分开呢?这里也是将共性和个性分离开,很多时候我们都需要遍历链表,但是不一定每一次都要用同一个函数来处理。那么就把个性也用函数封装起来。
将list.h的第一个语句typedef char EleType改为typedef int EleType,再用m1.c测试:
运行结果为:
这里把链表元素由字符型改成整形,只需要再在m.c里进行极小的改动,就可以实现相关功能。
再将list.h的第一个语句typedef char EleType改为typedef struct{char a;int b;} EleType,再用m2.c测试:
执行结果为:
这里要处理的链表元素为结构体,所以我们要定义一个结构体变量,并进行初始化,之后再插入链表中,然后做一些修改,则可以实现相关功能。
我们可以发现List里只有一个数据项“ChainNode *head”,为什么还要定义这个数据类型?同样地,我们用typedef char EleType定义了线性表存储的元素类型,其实只是将char取名为EleType而已,为什么要取这个别名而不是直接用char呢?我们在编写程序的过程中,需要一些符号来帮助我们认识、理解、记忆变量的名字,这些符号最好是有特殊含义、能让我们联想起它的功能的,如果元素的类型就用char表示,那么在定义和使用元素时很容易把它与别的变量弄混,会造成程序的可读性降低。而且如果链表的元素变成了int型,我们只需要将typedef char EleType改成typedef int EleType就可以了,这样使程序易于修改和扩展。同样地,List里只有一个数据项“ChainNode *head”,但是我们还要将它封装在一个List数据类型中,也是考虑到了程序的扩展性和可读性。而且如果我们在这里只定义一个头指针的话,表达不出定义线性表的意思,是线性表里面包括头结点,这个结点可以用一个头指针指向,所以头指针可以代表一个线性表,但是它们不是一个层次的东西,我们要将线性表的属性都封装起来才能更好的对它进行操作,这个属性是我们抽象出来的,我们同样可以抽象出更多的线性表的属性添加进来以方便实现更多功能。现在我们向线性表中添加一个tail指针,使它指向链表的最后一个结点,那么首先要修改线性表的定义:
修改创建线性表的函数CreateList,因为创建线性表后只有一个头结点,所以head和tail指针都指向这个结点:
撤销线性表时要将头尾两个指针都释放:
因为我们要提高ListAppend的速度,而加入元素是在线性表尾端加入的,所以我们用tail指针加入会更快:
这样我们不用改动线性表的程序m.c就可以实现了,因为这里我们把共性和个性分离开了,使每一个函数的功能单一,独立性高,与外部的隔绝性好。也就是我们从外部看,不用管一个函数的功能是怎么实现的,而只需要知道它的参数是什么,功能是什么,返回值是什么,这样就保证了我们要改动程序只需要改动较小的部分。
为什么要使用一个头结点呢?因为线性表有为空的情况,这时如果没有头结点,我们加入元素就没有地方存放结点的地址,而且我们写函数时还要专门对第一个元素进行处理。这样容易出错,也会使程序变得更加复杂。
程序中实现的链表里的元素类型都是固定的,怎么实现一个链表使它的元素类型为任意类型呢?要在链表里结点的数据空间存放任意类型的数据是不可能的,因为每个节点定义时的大小都是固定的。我们可以这样实现:在结点里的数据空间存放指针,指针指向每一个元素处的空间,这个空间的大小可以是任意的,根据我们定义的数据类型而改变,用malloc函数动态分配内存。
但是现在的问题是我们不知道用户传入的数据大小是多少,像printf函数一样用类型说明符只能实现基本数据类型而不能实现用户自定义类型,而用户用结构体定义的自定义大小可以为任意大小,甚至理论上是无穷大的。之前我以为要实现链表的每一个元素的类型都可以是不一样的,但是后来发现应该实现的是元素类型都是一样的,但是这个类型是由用户决定的而不是提前先规定好的。
因为不知道数据大小,所以我们要在线性表中加入一个数据项int datasize以表示数据大小,并在main函数中创建线性表时用sizeof计算数据大小并传给datasize;
我们将ListAppend函数、ListInsert函数实现为不定函数,这样它们接受的参数类型就没有限制了:
因为我们传入ListAppend函数的链表数据是一个局部变量,保存在栈段中,并且在函数返回后会被释放,所以要另外开辟空间来存储它。这里&lp表示传入的线性表lp在栈中的地址,&lp+1表示下一个参数,即我们要添加的数据在栈中的地址。我们用malloc函数创建一个传入数据大小的空间并将它的地址赋给指针target。然后用memcpy函数将数据从战中转移到target指向的我们动态开辟的空间中。Memcpy函数的原型为:void *memcpy( void *dest, const void *src, size_t count);即从指针src指向的空间拷贝count个字节到指针dest指向的空间里。
之后修改NewChainCode函数、GetElement函数、CreateList函数就可以了,这也体现了各个函数的独立性,否则我们可能就要修改整个程序了。
这里一定要注意的是,我们在一个指针进行赋值之后,一定要对它进行判断,如果是0则返回,这样可以使程序更安全、更容易调试。
现在我们就可以在c文件里定义数据结构而不用更改头文件的内容了。我们用m1.c进行测试:
结果是正确的,注意在用CreateList创建线性表时一定要先用sizeof计算传入的数据大小。
二、扩展研究
1、这个程序有什么特色?表现了一种什么样的程序设计思想?
答:这个程序将共性抽象开并封装到头文件里,我们可以很清楚地看到头文件list.h里封装的都是链表的数据结构和方法,我们在c文件里只需要将数据传入并用自定义的方法(比如输出)来进行操作就可以了。这个程序的结构非常清楚:共性的抽象、个性的实现,每一个函数实现一个功能,函数与函数之间没有联系,这样就可以保证一个函数出问题不会影响到其它函数。这个list.h头文件完全可以看成一个模块,调用它就能实现链表的相关功能,这是结构化的思想。
三、研究总结
程序设计需要综合的能力和视野。这个程序头文件里的函数其实和java里的类很像,每一个需求都是由专门的函数来实现的,函数与函数之间没有联系,只与调用的函数传递数据,这样我们只需要考虑单个函数的功能怎么实现就够了。而这样首先要把问题细化为一个个小需求来实现,这需要我们在程序设计时先对问题有清楚的认识和深度的思考分析。确定每一个函数的功能、参数、返回值,然后再来实现函数,这时就是编程的细节问题了,相对程序设计要简单得多。我们要更多地思考怎么来进行程序设计,而不是具体的技术细节。