• 线性表——顺序表的实现与讲解(C++描述)


    线性表

    引言

    新生安排体检,为了 便管理与统一数据,学校特地规定了排队的方式,即按照学号排队,谁在前谁在后,这都是规定好的,所以谁在谁不在,都是非常方便统计的,同学们就像被一条线(学号)联系起来了,这种组织数据(同学)的方式我们可以称作线性表结构

    定义

    线性表:具有零个或多个(具有相同性质,属于同一元素的)数据元素的有限序列

    若将线性表记为 ( a0 , a1 ,ai -1 ai ,ai +1 , ... , an - 1 , an )

    • 注意:i 是任意数字,只为了说明相对位置,下标即其在线性表中的位置)

    • 前继和后继:由于前后元素之间存在的是顺序关系,所以除了首尾元素外,每个元素均含有前驱后继,简单的理解就是前一个 元素和后一个元素

    • 空表:如果线性表中元素的个数 n 为线性表长度,那么 n = 0 的时候,线性表为空

    • 首节点、尾节点: 上面表示中的 :a0 称作首节点,an 称作尾节点

    抽象数据类型

    • 数据类型:一组性质相同的值的集合及定义在此集合上的一些操作的总称

    • 抽象数据类型:是指一个数学模型及定义在该模型上的一组操作

    关于数据类型我们可以举这样一个例子

    • 例如:我们常常用到的 整数型 浮点型 数据 这些都是数据的总称,所有符合其性质特征的都可以用其对应数据类型来定义,例如 520是一个满足整数特征的数据,所以可以赋值给 一个int型的变量 int love = 520;

    像这些一般的数据类型通常在编程语言的内部定义封装,直接提供给用户,供其调用进行运算,而抽象数据类型一般由用户自己根据已有的数据类型进行定义

    抽象数据类型和高级编程语言中的数据类型实际上是一个概念,但其含义要比普通的数据类型更加广泛、抽象

    为什么说抽象呢?是因为它是我们用户为了解决实际的问题,与描述显示生活且现实生活中的实体所对应的一种数据类型,我可以定义其存储的结构,也可以定义它所能够,或者说需要进行的一些操作,例如在员工表中,添加或删除员工信息,这两部分就组成了 “员工” 这个抽象的数据类型

    大致流程就是:

    • A:一般用户会编写一个自定义数据类型作为基础类型

    • B:其中一些抽象操作就可以定义为该类型的成员函数,然后实现这些函数

    • C:如果对外的接口在公有域中,就可以通过对象来调用这些操作了

    • 当然,我们在使用抽象数据类型的时候,我们更加注意数据本身的API描述,而不会关心数据的表示,这些都是实现该抽象数据类型的开发者应该考虑的事情

    线性表分为两种——顺序存储结构和链式存储结构,我们先来学习第一种

    顺序存储结构

    什么是顺序存储结构呢?

    顺序存储结构:用一段地址连续的存储单元依次存储线性表的数据元素

    怎么理解这这种存储方式呢?

    例如在一个菜园子中,有一片空地,我们在其中找一小块种蔬菜,因为土地不够平整疏松所以我们需要耕地,同时将种子按照一定的顺序种下去,这就是对表的初始化

    菜园子可以理解为内存空间,空地可以理解为可以使用的内存空间,我们通过种蔬菜种子的方式,将一定的内存空间所占据,当然,这片空间中你所放置的数据元素都必须是相同类型的 也就是说都得是蔬菜种子,有时候有些种子被虫子咬坏了,我们就需要移除一些种子,买来以后再在空出来的位置中选地方种好,这也就是增加和删除数元素

    地址计算方式

    从定义中我们可以知道 这种存储方式,存储的数据是连续的,而且相同类型,所以每一个数据元素占据的存储空间是一致的,假设每个数据 占据 L个存储单元那么我们可以的出这样的结论公式

    $$Loc(a_i) = Loc(a_1) + (i -1)*L$$

    • i 代表所求元素的下标
    • 也就是单位长度乘以对应的个数

    线性表的抽象数据类型

    #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.
    ";
    	}
    };
    
    class badSize :public exception {   //用于检查长度的有效性
    public:
    	const char* what() const throw() {
    		return "ERROR! BAD SIZE.
    ";
    	}
    };
    
    #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 这两个变量

    • 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同学,同时从所插入位置后面的所有同学都需要向后移动一个位置,后面的同学莫名其妙的就退后了一个位置

    我们来想一下如何用代码实现它呢,并且有些什么需要特别考虑到的事情呢?

    • 1、插入元素位置的合法以及有效性
      • 插入的有效范围:[0,curLength] 说明:curLength:当前有效位置
    • 2、检查是否表满,表满不能继续添加,否则发生溢出错误
      • A:不执行操作,报错退出 (为避免可以将数组初始大小设置大一些)
      • B:动态扩容,扩大数组容量 (下例采用)
    • 3、首尾节点的特殊插入情况考虑
    • 4、移动方向
      • 利用循环,从表尾开始逐次移动,如果从插入位置开始,会将后面的未移动元素覆盖掉
    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;	
    
    }
    

    (四) 删除

    既然理解了插入操作,趁热打铁,先认识一下对应的删除操作,这个操作是什么流程呢?还是上面的例子,插队后的同学被管理人员发现,不得不离开队伍,这样刚才被迫集体后移的那些同学就都又向前移动了一步,当然删除位置的前后继关系也发生了改变

    与插入相同,它又有什么注意之处呢?

    • 1、删除元素位置的合法以及有效性

      • 删除的有效范围:[0,curLength - 1]
      • i < 0 || i > curLength- 1隐性的解决了判断空表的问题
    • 2、移动方向

      • 利用循环,从删除元素的位置后开始逐次前移
    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值比较

    • 相同:返回值的位序
      • 注意查询的有效范围
    • 找不到或错误:返回 -1

    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:
    ";
    		//依次访问顺序表中的所有元素
    		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;
    	 
    }
    

    顺序表的优缺点

    优点:

    1. 逻辑与物理顺序一致,顺序表能够按照下标直接快速的存取元素
    2. 无须为了表示表中元素之间的逻辑关系而增加额外的存储空间

    缺点:

    1. 线性表长度需要初始定义,常常难以确定存储空间的容量,所以只能以降低效率的代价使用扩容机制

    2. 插入和删除操作需要移动大量的元素,效率较低

    时间复杂度证明

    读取:

    还记的这个公式吗?

    $$Loc(a_i) = Loc(a_1) + (i -1)*L$$

    通过这个公式我们可以在任何时候计算出线性表中任意位置的地址,并且对于计算机所使用的时间都是相同的,即一个常数,这也就意味着,它的时间复杂度为 O(1)

    插入和删除:

    我们以插入为例子

    • 首先最好的情况是这样的,元素在末尾的位置插入,这样无论该元素进行什么操作,均不会对其他元素产生什么影响,所以它的时间复杂度为 O(1)

    • 那么最坏的情况又是这样的,元素正好插入到第一个位置上,这就意味着后面的所有元素全部需要移动一个位置,所以时间复杂度为 O(n)

    • 平均的情况呢,由于在每一个位置插入的概率都是相同的,而插入越靠前移动的元素越多,所以平均情况就与中间那个值的一定次数相等,为 (n - 1) / 2 ,平均时间复杂度还是 O(n)

    总结:

    读取数据的时候,它的时间复杂度为 O(1),插入和删除数据的时候,它的时间复杂度为 O(n),所以线性表中的顺序表更加适合处理一些元素个数比较稳定,查询读取多的问题

    结尾:

    如果文章中有什么不足,或者错误的地方,欢迎大家留言分享想法,感谢朋友们的支持!

    如果能帮到你的话,那就来关注我吧!如果您更喜欢微信文章的阅读方式,可以关注我的公众号

    在这里的我们素不相识,却都在为了自己的梦而努力 ❤

    一个坚持推送原创开发技术文章的公众号:理想二旬不止

  • 相关阅读:
    又到黄金季节,该跳槽吗?怎么跳?
    分布式事务 6 个技术方案
    15 个 MyBatis 技巧,赶紧收藏吧!
    你的工资被倒挂了吗
    终于知道 Java agent 怎么重写字节码了
    每天的工作,你腻了吗?
    10 分钟轻松学会 Jackson 反序列化自动适配子类
    SpringMVC异步处理的 5 种方式
    Linux Cron 定时任务
    人类简史、软件架构和中台
  • 原文地址:https://www.cnblogs.com/ideal-20/p/11573739.html
Copyright © 2020-2023  润新知