新生安排体检,为了 便管理与统一数据,学校特意规定了排队的方式,即按照学号排队,谁在前谁在后,这都是规定好的,因此谁在谁不在,都是很是方便统计的,同窗们就像被一条线(学号)联系起来了,这种组织数据(同窗)的方式咱们能够称做线性表结构ios
线性表:具备零个或多个(具备相同性质,属于同一元素的)数据元素的有限序列算法
若将线性表记为 ( a0 , a1 ,ai -1 ai ,ai +1 , ... , an - 1 , an )编程
注意:i 是任意数字,只为了说明相对位置,下标即其在线性表中的位置)数组
前继和后继:因为先后元素之间存在的是顺序关系,因此除了首尾元素外,每一个元素均含有前驱和后继,简单的理解就是前一个 元素和后一个元素微信
空表:若是线性表中元素的个数 n 为线性表长度,那么 n = 0 的时候,线性表为空curl
首节点、尾节点: 上面表示中的 :a0 称做首节点,an 称做尾节点编程语言
数据类型:一组性质相同的值的集合及定义在此集合上的一些操做的总称函数
抽象数据类型:是指一个数学模型及定义在该模型上的一组操做学习
关于数据类型咱们能够举这样一个例子this
int love = 520;
像这些通常的数据类型一般在编程语言的内部定义封装,直接提供给用户,供其调用进行运算,而抽象数据类型通常由用户本身根据已有的数据类型进行定义
抽象数据类型和高级编程语言中的数据类型其实是一个概念,但其含义要比普通的数据类型更加普遍、抽象
为何说抽象呢?是由于它是咱们用户为了解决实际的问题,与描述显示生活且现实生活中的实体所对应的一种数据类型,我能够定义其存储的结构,也能够定义它所可以,或者说须要进行的一些操做,例如在员工表中,添加或删除员工信息,这两部分就组成了 “员工” 这个抽象的数据类型
大体流程就是:
A:通常用户会编写一个自定义数据类型做为基础类型
固然,咱们在使用抽象数据类型的时候,咱们更加注意数据自己的API描述,而不会关心数据的表示,这些都是实现该抽象数据类型的开发者应该考虑的事情
线性表分为两种——顺序存储结构和链式存储结构,咱们先来学习第一种
顺序存储结构:用一段地址连续的存储单元依次存储线性表的数据元素
例如在一个菜园子中,有一片空地,咱们在其中找一小块种蔬菜,由于土地不够平整疏松因此咱们须要耕地,同时将种子按照必定的顺序种下去,这就是对表的初始化
菜园子能够理解为内存空间,空地能够理解为可使用的内存空间,咱们经过种蔬菜种子的方式,将必定的内存空间所占据,固然,这片空间中你所放置的数据元素都必须是相同类型的 也就是说都得是蔬菜种子,有时候有些种子被虫子咬坏了,咱们就须要移除一些种子,买来之后再在空出来的位置中选地方种好,这也就是增长和删除数元素
从定义中咱们能够知道 这种存储方式,存储的数据是连续的,并且相同类型,因此每个数据元素占据的存储空间是一致的,假设每一个数据 占据 L个存储单元那么咱们能够的出这样的结论公式
$$Loc(a_i) = Loc(a_1) + (i -1)*L$$
#ifndef _LIST_H_ #define _LIST_H_ #include<iostream> using namespace std; class outOfRange{}; class badSize{}; template<class T> class List { public: // 清空线性表 virtual void clear()=0; // 判空,表空返回true,非空返回false virtual bool empty()const=0; // 求线性表的长度 virtual int size()const=0; // 在线性表中,位序为i[0..n]的位置插入元素value virtual void insert(int i,const T &value)=0; // 在线性表中,位序为i[0..n-1]的位置删除元素 virtual void remove(int i)=0; // 在线性表中,查找值为value的元素第一次出现的位序 virtual int search(const T&value)const=0; // 在线性表中,查找位序为i的元素并返回其值 virtual T visit(int i)const=0; // 遍历线性表 virtual void traverse()const=0; // 逆置线性表 virtual void inverse()=0; virtual ~List(){}; }; /*自定义异常处理类*/ class outOfRange :public exception { //用于检查范围的有效性 public: const char* what() const throw() { return "ERROR! OUT OF RANGE.\n"; } }; class badSize :public exception { //用于检查长度的有效性 public: const char* what() const throw() { return "ERROR! BAD SIZE.\n"; } }; #endif
在上面线性表的抽象数据类型中,定义了一些经常使用的方法,咱们能够在其中根据须要,增删函数
有了这样的抽象数据类型List 咱们就能够写出线性表其下的顺序结构和链式结构表的定义写出来
异常语句说明:若是new在调用分配器分配存储空间的时候出现了错误(错误信息被保存了一下),就会catch到一个bad_alloc类型的异常,其中的what函数,就是提取这个错误的基本信息的,就是一串文字,应该是const char*或者string
#ifndef _SEQLIST_H_ #define _SEQLIST_H_ #include "List.h" #include<iostream> using namespace std; //celemType为顺序表存储的元素类型 template <class elemType> class seqList: public List<elemType> { private: // 利用数组存储数据元素 elemType *data; // 当前顺序表中存储的元素个数 int curLength; // 顺序表的最大长度 int maxSize; // 表满时扩大表空间 void resize(); public: // 构造函数 seqList(int initSize = 10); // 拷贝构造函数 seqList(seqList & sl); // 析构函数 ~seqList() {delete [] data;} // 清空表,只需修改curLength void clear() {curLength = 0;} // 判空 bool empty()const{return curLength == 0;} // 返回顺序表的当前存储元素的个数 int size() const {return curLength;} // 在位置i上插入一个元素value,表的长度增1 void insert(int i,const elemType &value); // 删除位置i上的元素value,若删除位置合法,表的长度减1 void remove(int i); // 查找值为value的元素第一次出现的位序 int search(const elemType &value) const ; // 访问位序为i的元素值,“位序”0表示第一个元素,相似于数组下标 elemType visit(int i) const; // 遍历顺序表 void traverse() const; // 逆置顺序表 void inverse(); bool Union(seqList<elemType> &B); };
在构造函数中,咱们须要完成这个空顺序表的初始化,即建立出一张空的顺序表
template <class elemType> seqList<elemType>::seqList(int initSize) { if(initSize <= 0) throw badSize(); maxSize = initSize; data = new elemType[maxSize]; curLength = 0; }
在这里咱们注意区分 initSize 和 curLenght 这两个变量
template <class elemType> seqList<elemType>::seqList(seqList & sl) { maxSize = sl.maxSize; curLength = sl.curLength; data = new elemType[maxSize]; for(int i = 0; i < curLength; ++i) data[i] = sl.data[i]; }
咱们下面来谈一个很是经常使用的操做——插入操做,接着用咱们一开始的例子,学校安排体检,你们自觉的按照学号顺讯排好了队伍,可是迟到的某个学生Z和认识前面队伍中的C同窗,过去想套近乎,插个队,若是该同窗赞成了,这意味着原来C同窗前面的人变成了Z,B同窗后面的人也从C变成了Z同窗,同时从所插入位置后面的全部同窗都须要向后移动一个位置,后面的同窗莫名其妙的就退后了一个位置
咱们来想一下如何用代码实现它呢,而且有些什么须要特别考虑到的事情呢?
template <class elemType> void seqList<elemType>::insert(int i, const elemType &value) { //合法的插入范围为【0..curlength】 if (i < 0 || i > curLength) throw outOfRange(); //表满,扩大数组容量 if (curLength == maxSize) resize(); for (int j = curLength; j > i; j--) //下标在【curlength-1..i】范围内的元素日后移动一步 data[j] = data[j - 1]; //将值为value的元素放入位序为i的位置 data[i] = value; //表长增长 ++curLength; }
既然理解了插入操做,趁热打铁,先认识一下对应的删除操做,这个操做是什么流程呢?仍是上面的例子,插队后的同窗被管理人员发现,不得不离开队伍,这样刚才被迫集体后移的那些同窗就都又向前移动了一步,固然删除位置的先后继关系也发生了改变
与插入相同,它又有什么注意之处呢?
i < 0 || i > curLength- 1
隐性的解决了判断空表的问题template <class elemType> void seqList<elemType>::remove(int i) { //合法的删除范围 if(i < 0 || i > curLength- 1) throw outOfRange(); for(int j = i; j < curLength - 1; j++) data[j] = data[j+1]; --curLength; }
还记得吗,咱们在构造函数中,定义了数组的长度
seqList<elemType>::seqList(int initSize) { 代码内容}
同时咱们将这个初始化的指定参数值作为了 数组的长度
maxSize = initSize;
为何咱们不直接指定构造函数中的参数为 maxSize呢?
从变量名能够看出这是为了说明初始值和最大值不是同一个数据,也能够说是为了扩容作准备,
为何要扩容呢?
数组中存放着线性表,可是若是线性表的长度(数据元素的个数)达到了数组长度会怎么样?很显然咱们已经没有多余的空间进行例如插入这种操做,也称做表满了,因此咱们定义一个扩容的操做,当涉及到可能表满的状况,就执行扩容操做
扩容是否是最好的方式?
虽然数组看起来有一丝不太灵光,可是数组确实也是存储对象或者数据的有效方式,咱们也推荐这种方式,可是因为其长度固定,致使它在不少时候会受到一些限制,就例如咱们上面的表满问题,那么如何解决呢?方法之一就是咱们设置初始值比实际值多一些,可是因为实际值每每会有一些波动,就会致使占用过多的内存空间形成浪费,或者仍发生表满问题,为了解决实际问题,很显然仍是扩容更加符合须要,可是代价就是必定的效率损失
数组就是一个简单的线性序列,这使得元素访问很是快速。可是为这种速度所付出的代价是数组对象的大小被固定,而且在其生命周期中不可改变
咱们看一下扩容的基本原理你就知道缘由了!
扩容思想:
因为数组空间在内存中是必须连续的,所以,扩大数组空间的操做须要从新申请一个规模更大的新数组,将原有数组的内容复制到新数组中,释放原有数组空间,将新数组做为线性表的存储区
因此为了实现空间的自动分配,尽管咱们仍是会首选动态扩容的方式,可是这种弹性显然须要必定的开销
template <class elemType> void seqList<elemType>::resize() { elemType *p = data; maxSize *= 2; data = new elemType[maxSize]; for(int i = 0; i < curLength; ++i) data[i] = p[i]; delete[] p; }
顺序查找值为value的元素第一次出现的位置,只须要遍历线性表中的每个元素数据,依次与指定value值比较
template<class elemType> int seqList<elemType>::search(const elemType & value) const { for(int i = 0; i < curLength; i++) if(value == data[i])return i; return - 1; }
这个就真的很简单了,直接返回结果便可
template<class elemType> elemType seqList<elemType>::visit(int i) const { return data[i]; }
遍历是什么意思呢?遍历其实就是每个元素都访问一次,从头至尾过一遍,因此咱们就能够利用遍历实现查询,或者输出等功能,若是表是空表,就输出信息提示,而且注意遍历的有效范围是[0,最后一个元素 - 1]
template<class elemType> void seqList<elemType>::traverse()const { if (empty()) cout << "is empty" << endl; else { cout << "output element:\n"; //依次访问顺序表中的全部元素 for (int i = 0; i < curLength; i++) cout << data[i] << " "; cout << endl; } }
逆置运算顾名思义 ,就是将线性表中的数据颠倒一下,也就是说首元素和尾元素调换位置,而后就是第二个元素和倒数第二个元素调换,接着向中间以对为单位继续调换,也能够称做收尾对称交换,须要注意的就是循环的次数仅仅是线性表长度的一半而已
template<class elemType> void seqList<elemType>::inverse() { elemType tem; for(int i = 0; i < curLength/2; i++) { //调换的具体方式,能够设置一个中间值 tem = data[i]; //对称的两个数据 data[i] = data[curLength - i -1]; data[curLength - i -1] = tem; } }
如今给出两个线性表,表A和表B,其中的元素均为正序存储,如何能够合并两个表,放于A表中,可是表中的元素仍然保证正序存储
算法思想:咱们分别设置三个指针,分别表明了A B C,C 表明新表,咱们分别让三个指针指向三个表的末尾,将A表和B表的尾元素进行比较,而后将大的移入新A表中,而后将大的元素所在线性表的指针和新表的指针,前移一位 ,这样A和B表继续比较元素大小,重复操做,直到一方表空,将还有剩余的那个表的剩余元素移入新A表中
template<class elemType> bool seqList<elemType>::Union(seqList<elemType> &B) { int m, n, k, i, j; //当前对象为线性表A //m,n分别为线性表A和B的长度 m = this->curLength; n = B.curLength; //k为结果线性表的工做指针(下标)新A表中 k = n + m - 1; //i,j分别为线性表A和B的工做指针(下标) i = m - 1, j = n - 1; //判断表A空间是否足够大,不够则扩容 if (m + n > this->maxSize) resize(); //合并顺序表,直到一个表为空 while (i >= 0 && j >= 0) if (data[i] >= B.data[j]) data[k--] = data[i--]; //默认当前对象,this指针可省略 else data[k--] = B.data[j--]; //将表B中的剩余元素复制到表A中 while (j >= 0) data[k--] = B.data[j--]; //修改表A长度 curLength = m + n; return true; }
优势:
缺点:
线性表长度须要初始定义,经常难以肯定存储空间的容量,因此只能以下降效率的代价使用扩容机制
插入和删除操做须要移动大量的元素,效率较低
还记的这个公式吗?
$$Loc(a_i) = Loc(a_1) + (i -1)*L$$
经过这个公式咱们能够在任什么时候候计算出线性表中任意位置的地址,而且对于计算机所使用的时间都是相同的,即一个常数,这也就意味着,它的时间复杂度为 O(1)
咱们以插入为例子
首先最好的状况是这样的,元素在末尾的位置插入,这样不管该元素进行什么操做,均不会对其余元素产生什么影响,因此它的时间复杂度为 O(1)
那么最坏的状况又是这样的,元素正好插入到第一个位置上,这就意味着后面的全部元素所有须要移动一个位置,因此时间复杂度为 O(n)
平均的状况呢,因为在每个位置插入的几率都是相同的,而插入越靠前移动的元素越多,因此平均状况就与中间那个值的必定次数相等,为 (n - 1) / 2 ,平均时间复杂度仍是 O(n)
读取数据的时候,它的时间复杂度为 O(1),插入和删除数据的时候,它的时间复杂度为 O(n),因此线性表中的顺序表更加适合处理一些元素个数比较稳定,查询读取多的问题
若是文章中有什么不足,或者错误的地方,欢迎你们留言分享想法,感谢朋友们的支持!
若是能帮到你的话,那就来关注我吧!若是您更喜欢微信文章的阅读方式,能够关注个人公众号
在这里的咱们素不相识,却都在为了本身的梦而努力 ❤
一个坚持推送原创开发技术文章的公众号:理想二旬不止