前两篇文章介绍了基于数组的栈跟队列的实现。数组是一种顺序表的结构,所谓顺序表,就是线性表的顺序存储,它用一组地址连续的存储单元,一次存储线性表中的数据,使得逻辑上相邻的两个元素在物理上也相邻。主要特点是表中元素的逻辑顺序跟物理顺序相同。由于物理顺序相同,通过首地址跟元素序号就可以直接在O(1)的时间内找到元素,这种访问方式称为随机访问方式。但是这种存储方式,在插入和删除元素方面又显得复杂很多了,因为它们需要移动大量的元素来为新插入或者删除的元素腾出或者填补空缺。
下面我们通过一个编程任务来体会一下顺序表的基本操作。
简单的编辑缓冲器
无论何时发送短信或编辑其中一个程序的源文件,我们都会使用一个编辑器,它是一个软件应用程序,允许你创建和更改由字符组成的文件(比如windows下的txt文件)。在内部,编辑器保存一系列字符,这个区域通常称为缓冲区(buffer)。编辑器应用程序允许你对缓冲区的内容执行各种操作,其中许多操作仅限于缓冲区中当前的位置,缓冲区中的当前位置通过称为光标(cursor)的符号在屏幕上标记(就是一闪一闪的光标),该符号通常显示在两个字符之间,编辑者在支持哪些操作上有所不同,但所有编辑都支持以下操作:
- 将光标移动到要进行编辑的文本中的点。
- 输入新文本,然后将其插入当前的光标位置。
- 使用DELETE或BACKSPACE键删除字符
在计算的早期,编辑器要简单得多。编辑器无法访问鼠标或复杂的图形显示器,所以它只能响应键盘上输入的命令。例如,使用典型的基于键盘的编辑器,通过键入命令字母I,然后是一系列字符来插入新文本。附加命令执行其他编辑功能,例如在缓冲区中移动光标。通过输入这些命令的正确组合,可以进行任何所需的更改。就如同下表格:
除了I命令(也是插入字符)之外,每个编辑器命令都包含一行在一行中读取的单个字母。我们接下来的任务就是实现这个编辑器。
以下示例运行将说明编辑器的操作以及描述每个操作的注释。 在该会话中,用户首先插入字符axc,然后将缓冲区的内容更正为abc
编辑器程序显示每个命令后缓冲区的状态。 正如你在样品运行中可以看到的那样,程序用下一行用克拉符号(^)标记光标的位置。 这种光标不是你在真正的编辑器中所看到的,但是这个形状的光标可以很容易地看到正在发生的事情。
在我原来的文章中,描述了大量的如何进行类的设计的片段,这里由于分析的是具体实现以及算法复杂度,这里C++编程细节就略过。
接口设计
- buffer.h
#ifndef _buffer_h
#define _buffer_h
/*
*下面的部分为buffer.h类的公开部分,其私有部分我们隐藏在bufferpriv.h文件中
*用#include命令导入,这里面包括了 构造函数,析构函数,以及各种行为
*/
class EditorBuffer {
public:
/*
*构造函数: EditorBuffer
*用法:EditorBuffer buffer
*-------------------------
*作用:建立一个空的编辑器缓存
*/
EditorBuffer();
/*
*析构函数:~EditorBuffer
*用法:常常隐式调用
*-------------------------
*作用:释放这个buffer在堆中占用的空间
*/
~EditorBuffer();
/*
*方法:moveCursorForward()
moveCursorBackward()
*用法:buffer.moveCursorForward();
buffer.moveCursorBackward();
*---------------------------
*这个方法把游标往前或者往后移动一个字符
*特别地,当游标位于buffer的第一号位置或者末尾时,这个方法
*不再起作用
*/
void moveCursorForward();
void moveCursorBackward();
/*
*方法: moveCursorToStart();
moveCursorToEnd();
*用法:buffer. moveCursorToStart()
buffer.moveCursorToEnd()
*-------------------------------
*这个方法把游标移动到缓存的开头或者末尾
*/
void moveCursorToStart();
void moveCursorToEnd();
/*
*方法:insertCharacter(char ch);
*用法:buffer.insertCharacter(char ch);
*-------------------------------------
*在buffer的游标处插入单个字符,随后游标紧跟在被插入的字符
*的后面
*/
void insertCharacter(char ch);
/*
*方法:delecteCharacter
*用法:buffer.delecteCharacter()
*-------------------------------
*立即删除游标后面的单个字符,如果游标在buffer的末尾,那么
*方法将不起作用
*/
void delecteCharacter();
/*
*方法:showContents
*用法:buffer.showContents
*----------------------------
*将buffer中的内容在控制台中显示出来,然后标记
*光标的位置
*/
void showContents();
#include "bufferpriv.h"
};
#endif
基于数组的编辑器缓冲区的底层表示方式类似于CharStack类的底层表示。CharStack类定义了三个实例变量:指向包含元素的动态数组的指针,该数组的容量,和字符数。对于基于数组的缓冲区,我们需要相同的实例变量,我们可以将count变量的名称更改为长度,因为count是说明缓冲区的长度。除了这些变量之外,EditorBuffer类的私有数据还必须包含一个表示当前游标位置的实例变量。因为游标是一个字符位置,您可以通过在EditorBuffer类的私有部分中包含另一个称为cursor的实例变量来表示它。光标的所处位置表示将插入任何新字符的字符位置,因此光标值为0表示缓冲区的开头。类似地,如果光标的值等于length的长度,则光标位于缓冲区的末尾。
下图显示了基于数组的实现的bufferpriv.h文件。给定这个设计,一个缓冲区包含 :
在结构上应该是这样的;
- bufferpriv.h
private:
/* 实例化变量 */
char *array; /* 动态字符数组 */
int capacity; /* 分配数组空间 */
int length; /* buffer中的字符数 */
int cursor; /* 字符之后的游标 */
/* 使得复制buffer合法 */
EditorBuffer(const EditorBuffer & value) { }
const EditorBuffer & operator=(const EditorBuffer & rhs) { return *this; }
/* 私有方法声明 */
void expandCapacity();
Buffer的实现
基于此设计,大多数编辑器操作非常容易实现。可以通过向游标标字段的内容分配新值来实现移动游标的四个操作中的任意一个。
例如,移动到buffer的开始,只需要将值0分配给游标; 将其移动到最后只是将length的值复制到cursor中。我们可以在EditorBuffer类的完整实现中看到这些简单方法的代码,如下所示:
/*
*这个文件利用数组来实现buffer.h中的内容
*/
#include <iostream>
#include <cctype>
#include <string>
#include <cstdlib>
#include "buffer.h"
using namespace std;
/*常数*/
const int INITIAL_CAPACITY = 10; //初始化的容量为10
/*
*函数说明:构造函数和析构函数
*----------------------------
*构造函数初始化私有成员的值,析构函数释放堆中的对象所占的空间
*/
EditorBuffer::EditorBuffer(){
length = 0;
cursor = 0;
capacity = INITIAL_CAPACITY;
array = new char[capacity]; //动态数组分配
}
EditorBuffer::~EditorBuffer(){
delete[] array;
}
/*
*实现移动游标的方法
*/
void EditorBuffer::moveCursorForward(){
if(cursor < length) cursor ++;
}
void EditorBuffer::moveCursorBackward(){
if(cursor > 0) cursor--;
}
void EditorBuffer::moveCursorToStart(){
cursor = 0;
}
void EditorBuffer::moveCursorToEnd(){
cursor = length;
}
/*
*实现的方法:delecteCharacter(); insertCharacter(char ch);
*-----------------------------------
*插入或删除字符的每个功能都必须移动数组中的所有后续字符,
*以便为新插入留出空间,或者关闭删除留下的空间
*/
void EditorBuffer::insertCharacter(char ch){
if(cursor == length) expandCapacity();
/*
*这里我们选择从光标到buffer末尾的这一段字符
*然后我们将前一位的值赋给后一位,实现腾出空
*间的操作,使得ch可以插入
*/
for(int i = length; i > cursor; i--){
array[i] = array[i - 1];
}
array [cursor] = ch; //将ch的值赋值给当前的光标处
cursor++;
length++;//插入后,字符数组的长度相应加1
}
void EditorBuffer::deleteCharacter(){
if(cursor < length){
/*
*这里我们选择从光标 +1 到buffer末尾的这一段字符
*然后我们将后一位的值赋给前一位,实现收缩空
*间的操作
*/
for(int i = cursor + 1; i < length; i++){
array[i - 1] = array[i];
}
length--;//插入后,字符数组的长度相应减1
}
}
/*
*方法 :showContent
*------------------
*该方法打印缓冲区的内容,每个字符之间有
*一个空格,留下下一行插入符号的空间,以指示光标的位置
*/
void EditorBuffer::showContents(){
for(int i = 0; i < length; i++){
cout << " " << array[i];
}
cout << endl;
cout << string(2 * cursor, ' ') << "^" << endl;
}
/*
*方法:expandCapacity()
*---------------------
*这个方法的原理应该记住,我们将原array在堆中的首节点地址赋给我们定义的
*相应类型的 oldArray,此时他们两个指向堆中的同一个数组
*然后将capacity扩大一倍,在堆中建立一个新容量为capacity *2的数组
*之后把原oldArray指向的内容复制给现在扩容后的array
*最后删除oldArray中的内容,完成扩容操作
*/
void EditorBuffer::expandCapacity(){
char * oldArray = array;
capacity *= 2;
array = new char[capacity];
for(int i = 0; i < length; i++){
array[i] = oldArray[i]; //复制
}
delete[] oldArray;
}
重点代码分析:
- 插入字符
void EditorBuffer::insertCharacter(char ch) {
if (cursor == length) expandCapacity();
/*
*这里我们选择从光标到buffer末尾的这一段字符
*然后我们将前一位的值赋给后一位,实现腾出空
*间的操作,使得ch可以插入
*/
for (int i = length; i > cursor; i--) {
array[i] = array[i - 1];
}
array[cursor] = ch; //将ch的值赋值给当前的光标处
cursor++;
length++;//插入后,字符数组的长度相应加1
}
假设我们要在下列字符串中插入字符X:
要在buffer的数组表示中这样做,首先需要确保数组中有空间。如果长度等于容量,则当前分配的数组中没有更多的空间来容纳新字符。在这种情况下,有必要扩展buffer的容量。
要在中间插入一个字符,你需要在光标的当前位置(3)为该字符腾出空间。得到该空间的方法是将剩余的字符的位置向右移动,使缓buffer结构处于以下状态:
对应代码:
for (int i = length; i > cursor; i--) {
array[i] = array[i - 1];
}
该段代码从后开始遍历,然后每一个元素都将前一位的值复制到后一位,用这种方式来完成移动。由于length下标的内容始终未为空,这样lengh前一位往后复制就不会使得数据丢失,因此采用逆序遍历更容易理解。本例中就是从length= 5,cursor= 3开始的,也就是从长度length到光标间的元素都向后移动:
对比删除的代码,这就简单很多了,此时就是将光标后面的所有元素往前移动。这个时候采用正序遍历又会方便很多。
算法复杂度分析
单纯从代码中可以看得出,只是移动游标的四个操作,并没有涉及循环或者递归,只是需要很简单的基本操作就可以,运行时间为O(1)。但是反观插入数据的时候,当我们在Buffer的末尾插入数据的时候,for循环的第二个条件就不符合,也就是说此时执行时间为O(1)。但是一旦我们在buffer头进行插入操作的时候,就会发现,For循环会执行N次,此时最坏的情况算法复杂度为O(N),删除操作可以做类似的分析。
因此上述的几个操作的算法复杂度为:
测试程序
#include <iostream>
#include <cctype>
#include <string>
#include "Buffer.h"
using namespace std;
/*函数声明*/
void executeCommand(EditorBuffer & buffer, string line);
/*主函数*/
int main() {
EditorBuffer buffer;
while (true) {
cout << "*";
string cmd;
getline(cin, cmd);
if (cmd != "") executeCommand(buffer, cmd);
buffer.showContents();
}
return 0;
}
/*
*这个函数执行要执行的命令
*/
void executeCommand(EditorBuffer & buffer, string line) {
switch (toupper(line[0])) {
case 'I': for (int i = 1; i < line.length(); i++) {
buffer.insertCharacter(line[i]);
}
break;
case 'D': buffer.delecteCharacter(); break;
case 'F': buffer.moveCursorForward(); break;
case 'B': buffer.moveCursorBackward(); break;
case 'J': buffer.moveCursorToStart(); break;
case 'Z': buffer.moveCursorToEnd(); break;
case 'Q': exit(0);
default: cout << "Illegal command" << endl; break;
}
}
测试结果: