通过上篇的学习,我们可以知道作为为编辑器buffer,他们可以快速的找到编辑器的第一位和最后一位,但是插入和删除某个元素的时候,却是可能很慢。因此为了改进这个问题,我们有必要了解为什么以前的方法会使得插入和删除变得这么慢(因为通常使用编辑器的时候,插入和删除的操作往往更频繁)。
在数组实现的情况下,答案是显而易见的:因为当我们在buffer中插入一些新文本时,必须移动大量字符。例如,假设你尝试补全插入字母表:
当你发现你漏写了字母B的时候,你必须将下一个24个字符的每一个字符向右移动一个位置,以便为丢失的字母留出空间。只要buffer没有太长,现代电脑就可以相对较快地处理这种移动操作;但是即使如此,如果缓冲器中的字符数足够大(假如有100w个),则延迟最终将变得非常明显。
然而,假设在发明现代电脑之前,我们遇到这样的情况,会怎么处理呢?通常为了避免给人的印象是你已经忽略了字母表中一个字母,你可以简单地拿出一支笔,并使用以下符号:
这种编辑符号的一个优点是,它允许你暂停现有的排序规则,说明所有字母按照它们在打印页面上出现的形式按顺序排列。线下方的编辑符号告诉我们,阅读A后,你必须向上移动,读取B,回来,读取C,然后继续按顺序。使用此插入方式的另一个优点也很重要。那就是不管行是多长,你只需要绘制的是新的字符和编辑符号,不需要把数据改动。只是使用铅笔和纸张而已,插入时间不变。
linked list的概念
通过上述的例子,我们可以采用类似的方法来设计编辑buffer的表示,看看是否可以减少数据插入造成的花费。实际上,我们甚至可以推广这个想法,也就是我们的元素不一定就要跟正常序列的数组一样排,我们也可以你只需将缓冲区中每个字母的箭头绘制到跟随它的字母上即可。
我们对比一下,
- 基于数组原始缓冲区内容可以表示如下:
- 基于箭头原始的缓冲区内容可以表示如下:
如果我们需要在字符A之后添加字符B,需要做的就是:
- 将B写到某处
- 从B绘制一个箭头指向到A当前指向的字母(当前是 C)
- 更改从A指向的箭头,指向新加的B,如下所示:
因此要实现这一结构,我们能想到的底层往往就是使用指针实现,因为这样的表示跟指针是很相近的,指针的一大优点在于,它们使一个数据对象能够包含指向第二个对象的指针。因此可以使用此指针来表示排序关系,这与上图中箭头所暗示的顺序关系非常相似。以这种方式使用的指针通常被称为链接(links)。当这样的链接用于创建线性排序的数据结构,其中每个元素指向其后继者时,该结构称为链表(linked list)。
设计linked list结构
如果要将此基于指针的策略应用于编辑器缓冲区的设计,我们需要的是一个链接的字符列表(即一个节点)。因此我们必须将缓冲区中的每个字符与指示列表中下一个字符的指针相关联。 然而,该指针不能是简单的字符指针; 你需要的是指向下一个字符/链接组合的指针。
要使linked-list能工作,必须创建一个单一的结构,其中包含与应用程序相关的数据(在本例中为字符)和指向同一类型的另一个结构的指针,然后用于指示内部排序。应用数据和指针的这种组合成为链表的基本构建块,其通常被称为单元(cell)。 设计的过程如下
- 为了使单元的想法更容易可视化,我们可以从结构图开始。 在编辑器buffer中,单元格有两个组件:一个字符和链接到以下的单元格。 因此,单个单元格可以如下图所示:
- 然后,你可以通过将几个这些单元格链接在一起来表示一系列的字符。例如,字符序列ABC可以被表示为包含以下小单元集合的链表:
- 如果C是序列中的最后一个字符,则需要通过在该单元格的链接字段中添加特殊值来指示该事实,以指示列表中没有其他字符。当用C ++编程时,为此目的通常使用特殊的指针值NULL。然而,在列表结构图中,通常在框中指定带有对角线的NULL值,如前面的示例所示。
为了在C ++中表示这些单元格结构图,你需要定义一个结构类型来保存数据。单元格结构必须包含字符的ch字段和指向下一个单元格的link成员。 这个定义有点不寻常,因为Cell类型是根据自身定义的,因此是一种递归类型(recursive type)。 以下类型定义正确表示单元格的结构:
struct Cell {
char ch;
Cell *link;
};
buffer的链表表示
现在我们考虑用链表来表示我们之前的buffer,首先是将链表中的初始指针设置为buffer,其次我们还要有个指针指向光标的位置。因此EditorBuffer类的数据成员应该包含两个指向Cell对象的指针:一个指针,指示缓冲区启动的位置。另一个指针,指示当前光标位置。
如果有一个包含三个字符的buffer,我们通常会想到用3个cell来表示这三个元素,而此时,cursor字段存放指向下一个单元格的指针(注意最后一个cell元素的指针值为null),也就是说此时只有3个cursor字段,然而情况却是下图所示:
三个字母有四个游标位置,我们只能表示后面的三种情况,无法表示第一种。于是我们考虑加多一个虚拟的cell,使其指向首元素A:
当表示第四种情况的时候,cursor的情况应该是这样的(注意,A下面的指针,对应的是第二种情况下游标的位置):
于是将之前的buffer的私有文件改动一下:
/*
*说明:buffer 数据结构的实现
*--------------------------
*在缓冲区的链表实现中,缓冲区中的字符存储在单元结构中,
*每个单元格结构都包含一个字符和指向链中下一个单元格的指针 。
*为了简化用于维护光标的代码,此实现在列表的开头添加了一个额外的“dummy”单元格。
*该单元格中的字符不被使用,但是在数据结构中提供了一个单元格,
*当游标位于缓冲区的开头时,该值指向游标。
*/
private:
/*
*结构类型:Cell
*--------------------------
*该结构类型在实现中本地使用以将每个单元格存储在链表表示中。
*每个单元格包含一个字符和指向链中下一个单元格的指针
*/
struct Cell{
char ch;
Cell *link;
}
/*实例化变量*/
Cell *start; //指向虚拟的单元格
Cell *cursor; // 指向光标之前的单元格
/* 使得对象复制不合法 */
EditorBuffer(const EditorBuffer & value) { }
const EditorBuffer & operator=(const EditorBuffer & rhs) { return *this; }
链表的插入操作
无论光标位于何处,链表的插入操作都包括以下步骤:
- 为新单元格分配空间,并将指针存储在此单元格中的临时变量cp中的。
- 将要插入的字符复制到新单元格的 ch 成员中。
- 转到缓冲区光标字段指示的单元格,并将其link成员复制到新单元格的link成员。 此操作确保不会丢失超出当前光标位置的字符。
- 更改光标所在单元格中的link成员,使其指向新单元格。
- 更改缓冲区中的光标字段,以便它也指向新的单元格。此操作确保在重复插入操作之后插入下一个字符。
为了更加可视化的说明这个问题,我们举个实际的例子(假设我们想将B插入到下面的序列中):
光标在A和C之间,如图所示。 插入之前的情况如下所示:
第一步,分配一个新单元格,并在变量cp中存储一个指针,指向这个新的单元格,如下所示
第二步,将字符B存储到新单元格的ch中,这将留下以下配置:
第三步,将出现在cursor中的link地址(A下面的小黑点)复制到新单元格的link中。该link字段指向C单元格的指针,因此生成的图形如下所示:
第四步:更改当前单元格中由cusor的link成员,使其指向新分配的单元格的ch值,如下所示:
第五步:更改缓冲区结构中的cursor字段,以便它也指向新的单元格:
第六步:buffer现在具有正确的内容。如果你从缓冲区开始处的dummy单元格中按箭头读取,则沿路径顺序遇到包含A,B,C和D的单元格。此时,函数返回,并释放掉临时指针cp:
函数返回后,显示的结果为:
将上述的步骤转换成C++代码,结果为:
void EditorBuffer::insertCharacter(char ch) {
Cell *cp = new Cell; //创建一个新的单元格,并创建一个指针指向该单元格
cp->ch = ch; //向单元格中赋值
cp->link = cursor->link;// 将游标指向的节点的link值,复制到新的单元格中
cursor->link = cp; //将cp的值,复制到当前游标的link字段中
cursor = cp; //重定位游标
}
链表的删除操作
同样我们图解删除链表中的节点的过程。要删除链接列表中的单元格,只需将其从指针链中删除即可。 我们假设缓冲区的当前内容是:
用表示就是:
假如我们要删除B节点,那么我们只需要改变A单元的指向即可,如下:
用一行代码表示就是:
cursor->link = cursor->link->link;//好好理解,cursor->link就是B单元,B->link就是C单元
完整的删除的C++代码就是:
void EditorBuffer::deleteCharacter() {
if (cursor->link != NULL) {
Cell *oldCell = cursor->link; //定义一个指针,指向要被删除的节点
cursor->link = cursor->link->link;//改变节点的指向
delete oldCell; //释放节点
}
}
通常我们需要一个像oldCell这样的变量,以便在调整link指针时保存指向要释放的单元格的指针的副本。 如果你不保存此值,则再调用delete时将无法引用该单元格。
链表中指针的移动
我们此时拿cursor为实例来演示。其中的两个操作(moveCursorForward和moveCursorToStart)在链表模型中很容易执行。例如,要向前移动光标,你只需要从当前单元格中取出link,并将该指针存储在缓冲区的cursor字段中,使其成为新的当前单元格。完成此操作所需的声明很简单。(实际就是指针往前或者往后移动了一下)
前移
假设,buffer的光标位置如下,我们需要将光标位置往前挪动一位 :
于是我们改变cursor指针的指向:
cursor = cursor->link;
当然,当到达缓冲区的末尾时,我们将将无法向前移动。 moveCursorForward的实现必须检查这种情况,所以完整的方法定义如下所示:
void EditorBuffer::moveCursorForward() {
if (cursor->link != NULL) {
cursor = cursor->link;
}
}
后移
刚刚看了指针的前移,实际上是很简单的,只需要往前挪动指针的方向即可。那么是否可以参考着来往后移动一位呢?很遗憾,答案是否定的。理由就是,我们之所以可以那么轻易的向前移动,是因为我们的单元中已经包含了指向下一个单元的指针。然而我们并没有存放指向前一位的单元的指针(当然也可以这么设计,但是需要增加额外的空间开销)。因此对于上述的指针图,这种限制的效果是你可以从箭头底部的点移动到箭头指向的单元格,但是你永远不能从箭头返回到其上一个。
所以,唯一的办法就是,从start开始,从头开始遍历链表,然后找到当前cursor指向的位置,再进行转换。
要遍历表示缓冲区的列表,首先声明一个指针变量并将其初始化为列表的开头。 也就是;
Cell *cp = start;
要找到光标前面的字符,只要cp的link与cursor的link不匹配,你就继续遍历链表,按照每个link字段将cp从单元移动到单元格。 因此,可以通过在循环中添加以下代码继续执行代码:
Cell *cp = start;
while (cp->link != cursor) {
cp = cp->link;
}
当while循环退出时,cp被设置为光标之前的单元格。 与向前移动一样,你需要保护此循环,避免超过缓冲区的限制,因此moveCursorBackward的完整代码将为:
void EditorBuffer::moveCursorBackward() {
Cell *cp = start; //定义一个指针,指向链表的开始
if (cursor != start) {
while (cp->link != cursor) {//当其指向的link不是当前cursor指向的单元格时
cp = cp->link; //更新cp的值
}
cursor = cp; //最终找到cursor指向的元素之前的位置,复制给当前的cursor
}
}
同样的原理,我们可以利用遍历操作直接将链表重头遍历到尾:
void EditorBuffer::moveCursorToEnd() {
while (cursor->link != NULL) {
cursor = cursor->link;
}
}
回到开头
直接赋指针值即可
void EditorBuffer::moveCursorToStart() {
cursor = start;
}