在前两次博文中,我们由线性表讲到数组,然后又由数组的缺陷提出了指针式链表。但是指针式链表也不是完美无缺的,在某些没有指针数据类型的编程语言中,指针式链表是无法由我们来实现的,但是有时候我们又希望能用上链表,因为链表可以快速的进行插入和删除。这个时候我们就可以使用一种由数组来实现的“链表”——游标数组。其优点是可以快速的插入和删除(由于不需要指针式链表的malloc和free等内存操作,所以使用时游标数组可能会比指针式链表还要快一点),缺点则是继承自数组——固定大小。
综合考虑来说,在C语言中如果要使用链表,我们一般优先考虑指针式,主要原因是链表的游标实现的固定大小不容忽视,而指针式虽然插入删除略慢于游标数组,但整体上更具有适应性。那我们为什么还要讨论游标数组呢?
原因有两个,第一是我觉得游标数组这种“精妙”的思想值得我写一篇博文;第二是我觉得如果能理解游标数组的思想,那么对链表的理解就肯定到位了;第三是我个人认为指针式链表的诞生初衷是数组的固定大小带来的弊端,而不是为了实现更快的插入删除而出现的指针式链表(有些人认为指针式链表的诞生初衷就是想快速插入删除,能够动态变化大小只是次要的“副作用”)。而游标数组支持我的观点的一个例子,它可以快速插入删除(比指针式更快,因为没有内存操作),却不能动态变化大小。
好了,接下来我们就开始讨论游标数组的想法及实现吧。
首先我们要明白的一点就是:游标数组的“基础思想”是链表,目的在于通过数组来模拟“链表”。那么,为了达到模拟或者说“山寨”的目的,我们自然要分析指针式链表的关键点有哪些,即分析出哪些点或概念是我们必须模拟出的,然后分析该如何模拟出那些点或概念。
那么,根据“设计链表的过程”(见http://www.cnblogs.com/mm93/p/6574912.html),我们可以发现,链表实现中很重要的一点就是“每个结点都正确记住了下一个结点的位置”(广义上说,数组型表的元素也“记住了”下一个元素的位置,即自身位置+1,但如果删除元素时我们不移动其后面的元素,则原front元素所“记住的”下一元素位置就是错的,我们希望的是“灵活”地记住正确的位置,而不是“死板”地记住+1),再进一步对这一点分析,很容易发现,正是这一点使得我们的插入、删除变得非常快速。当我们插入某个结点时,需要改变的仅仅是front结点的next以及新结点的next(令它们指向新的正确的“下一结点位置”),而当我们删除结点时,我们只需要改变front结点的next(同理,令它指向新的正确的“下一结点位置”)。
既然这一点是实现快速插入与删除的关键,那我们模拟时显然不能丢下,因此我们先记下这一必须模拟的点,即:
1.每个结点都正确记住了下一个结点的位置。
知道了我们需要模拟的这一点后,我们就可以开始试着动手了。首先来画画图,看看基本的实现概念是怎样的(从删除操作入手):
这是一个普通的数组型线性表表:
如果我们删除了原元素2却不将后面的元素向前移,则会出错:
想要解决这个错误很简单,令元素1添加一个指向新元素2的“指针”变成“结点1”即可:
显而易见的,我们遇上了一个新的小问题,那就是在数组中,我们又该如何让“结点1记住结点2的位置呢”?现在我们是假定没有指针可用的,所以这个问题看起来很麻烦,但稍加思考就可以发现,数组中的元素都是“自带地址”的,那就是它们的下标!于是我们的结点定义很快就出来了,那就是:
struct node { char data; //我们之后都将假定元素类型为char以便与next更好的区分 size_t next; //指向下一元素下标 };
结点的定义解决了,接下来摆在眼前的问题是,我们这个由数组“改”过来的“链表”该怎么对待大小问题呢?毕竟这可是数组的巨大缺陷之一呢!可是正如我们前面所说的:游标数组继承了数组的缺点——固定大小。所以对于这个问题,我们的做法就是“不作为”,不仅如此,我们还要将这个数组定义得很大很大。一方面是防止元素个数超过了数组的极限,另一方面则是我们之后才会说的——将数组当做链表专用内存。我们暂且忽略第二点,只认为我们将数组定义的很大是为了防止表的大小超过数组极限。
#define SIZE 10000 typedef size_t Position; //为了方便形象,我们设定类型别名 typedef struct node Node; Node CursorSpace[SIZE];
接下来让我们先把目光放到插入操作上。假设我们执行插入到表尾的操作,我们就会发现下一个问题,那就是“表尾在哪”?在指针式链表中,这个问题很好解决,只要遍历(遍历就是指将所有结点访问一次)链表直到某个结点的next==NULL时该结点就是最后一个结点。数组中也很好解决,只要用一个size_t记录下线性表中有多少个元素即可,因为数组是“连贯”的,总共N个元素则表尾就在下标为N-1处。那么,游标数组该如何解决这个问题呢?其实也很简单,类似于指针式链表尾元素的next==NULL,我们只要令表尾结点的next指向下标0就好了。但这又带来一个小疑问,如果表尾指向0,那么CursorSpace[0].next又该是什么呢?嗯,这个问题将引出我们之前所说的“将数组当做链表专用内存”的讨论。
既然表尾的next指向0,那么CursorSpace[0]显然不能再作为表中的元素,看起来我们似乎又浪费了数组中的一个位置(之前我们简略讨论了删除操作的实现原理,但我们并没有说被删除的结点如何“回收”,因此我们暂时认为被删除的结点是被永久抛弃了)。然而实际上,我们可以利用这个“废弃”的CursorSpace[0]来做一些更有意义的事情,甚至可以说是因为我们把CursorSpace[0]拿去做更有意义的事情了,才使得它得以空闲出来做表尾next的“NULL”。
那么,CursorSpace[0]要做的更有意义的事情是什么呢?当然是一个很重要的、我们还没有解决的事情喽!回顾插入操作,我们会发现,链表中的插入操作第一步是“分配新结点”,而我们的游标数组中虽然有大量的空位置,但我们还没有用来找到空位置的办法呢!(我们必须得准确地找到空位置,不然将表中的某个结点当做空位置分配给新结点可不是什么好事,这一点在指针式链表中通过malloc实现)现在我们有办法了,就是让CursorSpace[0].next来告诉我们哪儿有空位置,通过它,我们可以实现“在数组中malloc”一般的功能!嗯,不错,但如果我们继续“推进”,很快就会发现下一个问题,当CursorSpace[0].next所指的那个空位置被用掉了,该如何“更新”CursorSpace[0].next使它再次指向一个空位置?这个问题看似麻烦,其实简单,只要让被用掉的原空位置在“临死”前(最近英雄电影看多了……)告诉我们另一个空位置就好了。也就是说,CursorSpace[0]知道一个空位置,而那个空位置又知道另一个空位置,另一个空位置又知道另另一个空位置,以此类推。这样一来,我们就将空位置们“链接”起来了~要做到这一点很也很简单,只要这样的一个初始化程序就好了:
void Init(int N) { for (int i = 0;i < N-1;++i) CursorSpace[i].next = i + 1; CursorSpace[N - 1].next = 0; }
不难理解,初始化后的游标数组中只有负责malloc的第一个元素和空位置,CursorSpace[0]指向了第一个空位置[1],而[1]又指向了[2],以此类推,最终所有空位置都指向了下一个空位置,且不会有两个空位置同时指向一个空位置,[SIZE-1]指向0表示其是最后一个空位置。
接下来,让我们“手动追踪”一下数组的操作变化(红色为表头,黄色为表中元素,黑色为空位置,蓝色为被删除结点,暂不回收)
初始化后,从CursorSpace[0].next到CursorSpace[n-1].next是这样的:
1,2,3,4,5,6,7,8,9,10……SIZE-1
假设我们插入一个元素,那么根据CursorSpace[0].next,开辟的结点是CursorSpace[1],CursorSpace[0].next变为CursorSpace[1].next以指向新的空位置:
2,0,3,4,5,6,7,8,9,10……SIZE-1
我们继续插入一个元素,则:
3,2,0,4,5,6,7,8,9,10……SIZE-1
删除掉表中第一个元素,则:
3,X,0,4,5,6,7,8,9,10……SIZE-1
再插入,则:
4,X,3,0,5,6,7,8,9,10……SIZE-1
实话实说,我觉得上面这个“手动追踪”例子可能会让人更直观地感受一次游标数组的操作,也可能会让人更迷糊,但如果认真走一遍“手动追踪”应该是可以达到前者目的的╮(╯_╰)╭
讲到这儿,差不多也该稍稍总结一下游标数组的特点了,不然估计得出现每一步都懂但最后就是不会的尴尬局面……
游标数组(尚未考虑删除结点的回收)的特点有这么几个:
1.数组中下标为0的CursorSpace[0]保存着一个空位置的下标(下标即游标数组中的“地址”),它可以给我们起到“malloc”的功能
2.我们在程序中定义一个Position类型的head(或别的名字,都行,类似于指针式链表中程序所存储的那个List变量)存储我们“链表”的表头位置,其值为表的第一个结点在数组中的下标
3.从表中第一个结点开始,表中的每一个结点的next都保存着表中下一个结点的下标,除了表尾,表尾结点的next==0
4.为了实现1,数组中的空位置的next均指向“下一个”空位置的下标,除了“最后一个空位置”,“最后一个空位置”的next为0
5.“最后一个空位置”初始化时为CursorSpace[SIZE-1],即数组的最后一个位置
6.当分配掉CursorSpace[0].next所指的位置时,令CursorSpace[0].next=分配掉的结点.next(此处写“分配掉的结点”是为了逻辑简单易懂,真实代码应为CursorSpace[0].next=CursorSpace[CursorSpace[0].next].next)
7.显然地,当分配掉最后一个空结点时,因为最后一个空结点.next为0,所以CursorSpace[0].next=最后一个空结点.next,会令CursorSpace[0].next=0,所以当我们发现CursorSpace[0].next==0时我们认为数组(链表专用内存)已满,“malloc”失败
再次回顾,可以发现,我们先是通过“下标”模拟“指针”从而确定了游标数组的结点形式,也就解决了查找、插入、删除最核心的问题;然后,我们通过利用CursorSpace[0]及其next和所有空结点的next,解决了模拟“malloc”的方法(CursorSpace[0].next及所有空结点的next),而有了模拟“malloc”的方法,自然而然地,我们就找到了实现插入的方法。
#include <stdio.h> #define SIZE 1000 struct node { char data; //使用char作为元素类型以方便区分next size_t next; }; typedef size_t Position; typedef struct node Node; typedef Position Head; //新增的类型别名,用于在程序中保存表头下标 Node CursorSpace[SIZE]; //初始化全局“内存”,但我们不显式调用它,初始化会由Create()完成 void Init(int N) { for (int i = 0;i < N-1;++i) CursorSpace[i].next = i + 1; CursorSpace[N - 1].next = 0; } //用于创建一个新的链表,起到类似于malloc的功能,同时初始化分配到的“头结点” Head Create(char Elem) { //静态变量,用于判断是否已初始化“内存” static bool IsInit = false; if (!IsInit) { Init(SIZE); IsInit = true; } //若CursorSpace[0].next==0则说明“内存”中已没有空位置可分配 if (CursorSpace[0].next == 0) return -1; //反之则说明“内存”中尚有位置 else { Head result; //用于返回,作用相当于“表头指针” result = CursorSpace[0].next; //CursorSpace[0].next总是指向一个“空位置”或0,但此时必然不是0 CursorSpace[0].next = CursorSpace[result].next; //令CursorSpace[0].next指向下一个“空位置”或0(若分配的位置是最后一个空位置) CursorSpace[result].data = Elem; //令新分配的“结点”的数据域保存新元素 CursorSpace[result].next = 0; //令新分配“结点”的next为0表示其是表中最后一个结点 return result; //相当于返回指向新结点的“指针” } } //查找函数,用于返回第N位置上的结点(默认Head中至少有一个结点) Node Find(Head Head, int N, bool *success) { //先用局部变量保存下“头结点”位置 Position temp = Head; if (N <= 0) { (*success) = false; return CursorSpace[0]; //返回值是“随意的”,调用者需要自己对success进行判断来确定是否成功 } //稍微注意一下循环次数 for (int i = 1;i < N;++i) { //若出现CursorSpace[temp].next==0的情况,即未到第N位置却已到表尾,说明N越界 if (CursorSpace[temp].next == 0) { (*success) = false; return CursorSpace[0]; } temp = CursorSpace[temp].next; //类似于指针式链表 } (*success) = true; return CursorSpace[temp]; } //插入函数,接收Head*因为可能要修改Head指向的位置(插入到第1个位置时),N为要插入的位置,Elem为要插入的元素 bool Insert(Head* Head, int N ,char Elem) { //简单地判断是否非法 if ((*Head) <= 0) return false; //若CursorSpace[0].next==0则说明“内存”中已没有空位 if (CursorSpace[0].next == 0) return false; //分配一个空位置并初始化(没有修改next因为时候未到),令CursorSpace[0].next保存下一个空位置(一个空位置的next指向另一个空位置或0) Position newIndex = CursorSpace[0].next; CursorSpace[0].next = CursorSpace[newIndex].next; CursorSpace[newIndex].data = Elem; //若希望的插入位置为1,则特殊对待(需要利用Head*) if (N == 1) { CursorSpace[newIndex].next = (*Head); //注意我们现在修改了新结点的next (*Head) = newIndex; } //若N<0则插入到表末尾 else if (N <= 0) { Position temp = (*Head); for (;CursorSpace[temp].next != 0;++temp); CursorSpace[temp].next = newIndex; CursorSpace[newIndex].next = 0; } //其他情况下我们将结点插入到第N位置上 else { Position temp = (*Head); //注意循环次数,我们停在了第N-1个结点处 for (int i = 1;i < N - 1;++i) { //若还没到N-1处就到了表的末尾,则出错 if (CursorSpace[temp].next == 0) { return false; } temp = CursorSpace[temp].next; } //此处类似指针式链表操作 CursorSpace[newIndex].next = CursorSpace[temp].next; CursorSpace[temp].next = newIndex; } return true; }
在我们开始讨论删除操作之前,我们先给出一个简单的遍历输出函数:
void printCursor(Head h) { Position temp = h; for(int i = 0;i < SIZE; ++i) { if (CursorSpace[temp].next == 0) { printf("%c ", CursorSpace[temp].data); return; } printf("%c", CursorSpace[temp].data); temp = CursorSpace[temp].next; } }
好了,现在我们可以来讨论讨论删除操作了。
乍一看,Delete应该是一项很简单的工作,只要令CursorSpace[front].next=CursorSpace[front].next.next就行了。在指针式链表中,我们必须记得free()来释放结点所占的位置,但在游标数组中这一操作似乎变得可有可无,毕竟我们并没有真的调用过malloc()。但是稍加思考我们就会发现,如果我们没有模拟free()操作,那么其带来的影响和指针式链表Delete时未free()是类似的,都会造成“内存占用浪费”,如果我们在游标数组中Delete时没有实现free(也就是我们之前一直说的“回收”),那么我们的链表其实最多只能累积Insert()SIZE次(即使你delete过某些结点,它们也被永久废弃,而整个数组就只有SIZE大小)。
因此,我们的游标数组必然需要考虑如何模拟free(),否则Delete带来的浪费实在太大了。
其实模拟free()在游标数组中的关键点就是:怎么让被delete的结点插入到“空位置组成的那个链表”里。可能这么说有点绕口,再简单点说就是:怎么让要回收的结点可以被CursorSpace[0].next找到?之前被delete的结点会永久废弃就是因为不论是CursorSpace[0].next还是其它空位置,没有谁的next是指向要回收的结点的,我们要改变的就是这一点,就是要让CursorSpace[0]或其它空结点知道某个结点又“回来了”。
其实解决的方法简单得不行,CursorSpace[0].next不是指向一个空位置吗?那我们就让它指向我们回收的这个“新”空位置(即discard,丢弃的结点的下标):CursorSpace[0].next=discard;那原先的空位置们怎么办?简单啊,在CursorSpace[0].next=discard;之前,先CursorSpace[discard].next=CursorSpace[0].next不就行了?
很快,我们的Delete函数就出炉了:
bool Delete(Head * Head, int N) { //简单地判断是否非法 if ((*Head) <= 0) return false; Position temp = (*Head); //默认N<0时删除尾结点 if (N <= 0) { //若只有一个结点 if (CursorSpace[temp].next == 0) { CursorSpace[temp].next = CursorSpace[0].next; CursorSpace[0].next = temp; (*Head) = 0; return true; } else { //注意循环条件,我们在倒数第二个结点处停下 while (CursorSpace[CursorSpace[temp].next].next != 0) temp = CursorSpace[temp].next; Position discard = CursorSpace[temp].next; CursorSpace[temp].next = CursorSpace[discard].next; //CursorSpace[discard].next就等于0 CursorSpace[discard].next = CursorSpace[0].next; CursorSpace[0].next = discard; return true; } } else if (N == 1) //删除第一个结点 { (*Head) = CursorSpace[temp].next; CursorSpace[temp].next = CursorSpace[0].next; CursorSpace[0].next = temp; return true; } else if (N > 1) { //注意循环次数,我们停在第N-1个结点处 for (int i = 1;i < N - 1;++i) { //若出现CursorSpace[temp].next==0的情况说明链表大小小于N if (CursorSpace[temp].next == 0) return false; temp = CursorSpace[temp].next; } Position discard = CursorSpace[temp].next; CursorSpace[temp].next = CursorSpace[discard].next; CursorSpace[discard].next = CursorSpace[0].next; CursorSpace[0].next = discard; return true; } return false; }
Oh,Yeah!看起来一切都万事大吉了!我们利用数组的下标,模拟了内存的地址;利用数组的一个位置(CursorSpace[0]),我们模拟出了malloc;通过一点小智慧,我们实现了free也即回收;通过这些模拟,我们完成了游标数组!
但是等一等,我们貌似还是没说出“链表专用内存”是个什么玩意儿……哈哈,其实这个东西没什么高大上的!你想想看,我们几乎模拟出了整套链表的内存操作:地址、malloc、free,那这整个数组是不是就相当于一个“专属内存”?一个只能存储指定结点的,专用于链表的“内存”?并且!既然是一个“内存”,就说明它可以支持不仅仅一个链表在其中!这才是我们真正强调“内存”的意义所在哦~
稍加思考就可以发现,其实我们只要不断通过Create()得到一个“头结点”,然后在Insert()和Delete()时给定“头结点”,我们就可以在一个游标数组中保存多个结点结构相同的“链表”!!
是不是瞬间明白了为什么我们说要尽可能的把数组定义得大一点的原因呢?O(∩_∩)O
好了,这下我们是真的把游标数组讲完了orz 希望这篇文章做到了将游标数组浅显易懂的讲解的目的呢~
—————————————————————————————————————————————————————————————
写这篇博文花了挺长时间,期间一直试图将知识点拆散成合适的讲解顺序,并且将各个关键点都讲得浅显易懂,但过程中却经常出现“越想讲简单,就越容易讲详细,越讲详细就越容易讲复杂”的怪圈,希望哪里讲解的不好不对的,能够有人提醒我一下,写完已是深夜,所以结尾略草率,但粗看一遍觉得还是不难的,因此就此打住,发布!
—————————————————————————————————————————————————————————————