为什么没有指针?
其实,C语言的强大和优雅毋庸置疑,但是,你必须相信,在与其相仿的那个年代,还有很多其他的思想占据着科学家们的脑子,他们的想法和其他人并不相同,所以没有指针的语言也是有的。
没有指针可用的语言,在读者看来也有很多,比如Java,Python等高级语言,但他们真的没有指针么?想想他们的参数传递,想想他们的数据结构的实现,是不是都有引用
的意思?这就是指针对他们的影响,当然,他们也乐于认同并推广指针的威力,只不过,他们需要我们在更加安全的环境下工作,所以对指针和相关的数据结构进行了更高层次的抽象。
说了有指针,无指针的一大堆,基本上和本文相关的概念就是一个,不用指针
,来用C语言实现数组构成的链表,这也是在模拟其他那些真的没有指针的语言的应用方式。
一个简单的实现获取
样例代码托管位置为
传送门:https://gitee.com/prexer/data-structure-and-algorithms
所有的样例,都可以通过如下方式进行编译和检测:
$ git clone https://gitee.com/prexer/data-structure-and-algorithms.git
$ cd data-structure-and-algorithms/liner_list/static_linked_list
$ make run
数据结构的抽象
基本上,使用数组实现链表的功能,通常叫做静态链表
,而相应的实现方法有人叫做游标实现法
,听起来也很生动,表述也很恰当。
由于我们没有了指针,所以节点Node的数据结构中,就不会存在next成员,取而代之的是loc,表示下一个节点的下标。
typedef int ItemType;
typedef struct NODE {
ItemType data;
int loc;
}Node;
而具体类型的操作函数,我们留在后文讲解,他们可是本文的重头戏。
说一说特性和约束
由于静态链表有一些特殊,所以实现链表的操作需要我们赋予它一些特性:
- 我们让链表的底层是动态数组实现的,什么是动态数组,你可以取看看相关的书籍,基本上就是通过malloc申请一块
看似
连续的空间,只不过它的比在栈上直接申请的内存空间要自由一些,你可以规定它的大小 - 我们让链表的头和尾节点当做特殊节点,真实的存储节点并不使用他们,对于头尾,我们这样规划
- 头节点中
- data 表示数组尾巴节点的下标,这样一下子就可以通过头节点找到尾节点
- loc 表示第一个没有使用的空间位置,如果空间用完了,它应该保存尾巴节点的下标
- 尾节点中
- data 表示表中已经存储数据的个数,思考下,他们只能在插入时加1,在删除时减1,而其他的时候都不应该改变
- loc 保存表中第一个被插入的元素的下标,如果表是空表,那么它应该保存头节点的下标,也就是0
- 头节点中
- 表中的实际使用节点对应的成员描述是:
- data 表示存储的值,就是真实的数据
- loc 表示下一个已经使用节点的下标,如果是尾节点,那么它应该保存头节点的下标,也就是0
施加一层约束:静态表是一个有序表,从小到大
排列
再多说一点,我们思考下,如何判断表是空表?其实对于这个判断,头节点是没有用的,因为它存储的数据只有下一个可用的位置和尾节点的下标,它的loc是会随着表的改动而不断变化的,而尾节点的下标也不会变,所以,一个变化的,一个不变的,根本排不上用场。
所以我们只能找尾节点下功夫,空表的时候,尾节点中存储节点长度的data成员应该是0,表示没有元素,其实已经足够了,如果说使用成员loc再多加一层限制,判断loc存储的值是否为0,也就是没有第一个被使用的节点,也是可以的,但这样就重复了,笔者的实现中就添加了这个重复的设计,这是因为设计之初,总是担心忘记操作尾节点的data而导致的,如果你忘记加一或者减一,那确实是很头疼的。
插入和删除操作的实现和分析
就像笔者讲述的其他链表类数据结构一样,我们先看看实现,然后再根据实现分析和总结一些东西:
static int is_full(Node * head){
int max_loc = head[0].data;
return head[0].loc == max_loc;
}
int insert_static_ll(Node * head, ItemType new_value){
if (is_full(head))
return ERROR;
int max_loc = head[0].data;
int iter_loc = head[max_loc].loc;
int before = iter_loc;
while (iter_loc != 0){
if (head[iter_loc].data >= new_value)
break;
before = iter_loc; // should slow a step. when match the first, before equals iter_loc;
iter_loc = head[iter_loc].loc;
}
int new_loc = alloc_mem(head); // after is_full, be safe. note, head[0].loc is modified.
head[new_loc].data = new_value;
#if 0
if (iter_loc == 0 && head[max_loc].data == 0){ // empty list -> head[max_loc].data == 0 is enough
head[new_loc].loc = 0;
head[max_loc].loc = new_loc;
}else{ // not empty list
if (iter_loc == head[max_loc].loc){ // iter_loc match to first? (before == iter_loc also is valid.)
head[new_loc].loc = head[max_loc].loc;
head[max_loc].loc = new_loc;
}else{ // add to middle or last, 'before is last!
head[before].loc = new_loc;
head[new_loc].loc = iter_loc;
}
}
#endif
if (iter_loc == 0 || iter_loc == head[max_loc].loc){
head[new_loc].loc = head[max_loc].loc;
head[max_loc].loc = new_loc;
}else{ // add to middle or last, 'before is last!
head[before].loc = new_loc;
head[new_loc].loc = iter_loc;
}
head[max_loc].data++;
return OK;
}
static int alloc_mem(Node * head){
int space_loc = head[0].loc;
int next_loc = head[space_loc].loc;
head[0].loc = next_loc;
return space_loc;
}
static void free_mem(Node * head, int d_loc){
head[d_loc].loc = head[0].loc;
head[0].loc = d_loc;
}
int delete_static_ll(Node * head, ItemType d_value){
int max_loc = head[0].data;
int iter_loc = head[max_loc].loc;
int before = iter_loc;
if (iter_loc == 0)
return ERROR;
while (iter_loc != 0){
if (head[iter_loc].data == d_value)
break;
before = iter_loc;
iter_loc = head[iter_loc].loc;
}
if (iter_loc == 0)
return -1;
if (before == iter_loc){ // first?
head[max_loc].loc = head[head[max_loc].loc].loc;
free_mem(head, iter_loc);
}else{
head[before].loc = head[iter_loc].loc;
free_mem(head, iter_loc);
}
head[max_loc].data--;
return OK;
}
都有什么值得推敲的呢?
看完了实现,我们开始思考环节,想想有什么是值得回味的。
真的没有指针么?那你怎么用Node *head
呢?你可能会这么问,不过你看看除了这个必要的指针,还有哪里使用了呢?这是在C语言里实现这个数据结构的事实决定的,毕竟我们用的是C语言实现呀。
前文并没有提到,什么条件能够当满表
的充分条件呢?
其实,我们已经暗中透露了,只要让第一个空闲节点指向最后的尾节点下标就可以了。这样就表示没有可以使用的空间了。
再次加深印象,表的尾巴和表的头节点都是有特殊用途的,一但有节点指向他们,肯定是有特殊用意的。
每个节点依旧是只知道后继节点,没有办法知道前驱几点,这样在我们遍历的时候,很可能新节点需要插入到表的尾巴上,但是这个时候,已经错过了尾巴节点,怎么办呢?其实方法也有一些,这里提供一个方法,就是类似迟滞指针
类似的方法,这里我们并没有指针,所以就有一个对称的叫做迟滞游标
的标量。它就是before,作用和迟滞指针是一样的,就是比正常遍历表的游标iter_loc慢半拍
,这样不伦如何操作,我们都能找到前面的节点了。
alloc_mem
和free_mem
是我们自己构造的函数,因为我们是动态数组实现的链表,申请和删除元素需要特殊处理:
- alloc_mem和free_mem是唯二的改变头节点的位置,只有这两个位置,想象为什么?前面已经提示了。
- alloc_mem的步骤应该这样描述:
- 获取下一个可用的节点下标,并保存
- 将头节点的第一个备用节点下标更新为前面已经保存节点的下一个节点的下标位置
- 将保存的可用节点的下标返回给需要的地方
- 注意不要在这里有更多的操作了,它的操作就是分配空间,至于插入的相关操作需要在插入函数中处理
- free_mem的步骤就是alloc_mem的逆操作:
- 由于备用空间使用
头插法
,所以需要换头,那么,退下来的节点需要让loc保存头节点中的loc - 然后将头节点的loc更新为退下来节点的坐标
- 由于备用空间使用
你们应该看见了,我们是可以根据自己的实现来进一步优化的,就像插入操作一样,但是请注意,这个过程需要在你正确实现功能之后,否则它只会让你更加困惑。
而本文中的插入操作基于如下的事实:
在空表或者在非空表插入第一个节点时,操作方法是一样的
这里有两个要注意的:
- 你应该使用笔者之前说的方法,使用笔在纸上先画出来图,虽然没有指针,但是游标和指针一样,所以看看箭头的不同,就可以区分在第一个结点和非第一个结点插入元素的不同
- 插入操作在完成的时候记得要
更新表的长度
哦
用同样的方法,我们就可以分析删除节点的操作了,不过这里使用的方法和插入差不多,留给读者自己思考下吧。
其他操作并不难,因为你已经轻车熟路了
有两个函数必须提及,一个是构建一个静态链表create_static_ll
,还有一个是销毁链表destroy_static_ll
。
destroy_static_ll函数基本上就是free的包装,这需要保证静态链表的头指针不能修改,你必须记住它,这样才不会内存泄漏。
create_static_ll在申请内存的时候要比实际的大小多申请两个,并且头节点和尾节点初始化的步骤应该按照我们前文思考的那样设计。