数据结构——绪论及线性表


| 这个作业属于哪个班级 | C语言--网络2011/2012 |
| ---- | ---- | ---- |
| 这个作业的地址 | DS博客作业01--线性表 |
| 这个作业的目标 | 学习数据结构基本概念、时间复杂度、顺序表、单链表、有序表的结构设计及运算操作 |
| 姓名 | 骆锟宏 |

0.PTA得分截图

在这里插入图片描述

1.本周学习总结(5分)

1.1 绪论(1分)

1.1.1 数据结构和数据类型

1.1.1.1 数据结构

1.1.1.1.1 基本概念
  • 首先数据包含广义的数据和计算机中的数据。
    • 广义的数据可以包括符号、文字、音乐、图像、视频等。
    • 计算机中的数据:1.所有能被输入到计算机中,能被计算机处理的符号的集合。2.计算机处理的信息的某种特定的符号表示形式
  • 数据结构的定义:相互之间存在一种或者多种特定关系数据元素的集合。 这一种或者多种特定 关系 我们称其为结构
    而数据元素又可以认为是数据项的集合。 (数据结构->数据元素->数据项)[从大到小的关系]
    • 数据元素是数据结构中讨论的基本单位,比如在学生档案管理系统中,一个学生的档案信息,就可以称为一个数据元素。
    • 数据项是数据结构中讨论的最小单位,其不可再往下分割,比如在学生档案管理系统中的一个学生的档案信息下,会有
      编号、姓名、班级等项目,而这些项目就可以称为一个一个的数据项。
1.1.1.1.2 逻辑结构
  • 集合:数据元素除了同属于同一个集合这层关系之外,没有其他的关系。

  • 线性结构{线性表、栈、队列、串、数组}:节点与节点之间的关系是一对一的关系。

  • 非线性结构{树形结构和图形结构}

    • 树形结构的节点与节点之间的关系是一对多的关系。
    • 图形结构的节点与节点之间的关系是多对多的关系。

  • 逻辑结构的描述:需要包括两个集合,可以使用二元组来描述,其中每个关系用序偶(<e1,e2>)的形式来描述。

    • 一个集合是数据元素的集合D
    • 另一个集合是D中元素的关系的集合R,也就是数据元素之间关系的集合。(用序偶来表示)。


以上是线性结构和树形结构的描述例子。

1.1.1.1.3 存储结构
  • 数据的存储结构是其逻辑结构在计算机中的存储形式。
  • 存储是要注意的两个要点:
    • 首先是存储数据元素
    • 其次是存储数据元素之间的关系
  • 我们学习的两种基本的存储结构
    • 顺序存储结构:逻辑上相邻物理位置上也相邻的存储结构,比如数组。
    • 链式存储结构:逻辑上相邻但物理位置上不要求相邻的存储结构,借用附加的指针来表示数据之间的逻辑关系,比如链表。
      • 链式结构设计时,各结点之间的存储单元的地址一定不连续,但是单个结点内部的空间是连续的。
1.1.1.1.4 逻辑结构和存储结构的关系
  • 存储结构是逻辑结构在计算机上的存储形式
  • 逻辑结构唯一但存储结构不唯一,同一个逻辑结构可以对应不同的储存结构。
  • 相同的操作,比如选择法排序,用不同的存储结构,比如数组和链表,它们的具体的实现过程是不一样的。
1.1.1.1.5 数据的运算
  • 五种基本的运算类型:插入(增),删除(删),修改(改),查找(查),排序(排)。

1.1.1.2 数据类型

  • 定义:一组性质相同的值的集合,以及定义在这组值上的操作的总称。
  • 语言本身会自带一些基础数据类型,比如C语言的int、char、数组、指针等等。
  • 结构体会被归类为自定义数据类型。
  • 还有一些比较复杂和高级的数据类型便是我们接下来要学习的链表、ADT等。
1.1.1.2.1 ADT(Abstract Data Type|抽象数据类型)
  • 抽象数据类型指的是从求解问题的数学模型中抽象出来的数据逻辑结构和运算,而不考虑计算机的具体实现。
  • 一般用三元组表示:(D,S,P)
    • 三元组是一个数据对象中有3个数据元素。三元组中数据元素的数据类型,可以是整型数、字符、浮点数或者更加复杂的数据类型。
  • 包含三个重要的基本元素:1.数据对象(D) 2.数据关系(S) 3.基本操作(P)
  • 描述结构形式:
ADT抽象数据类型名
{数据对象:数据对象声明
 数据关系:数据关系声明
 基本运算:基本运算声明
}ADT抽象数据类型名
  • 一个老师的例子:

  • 抽象数据类型的特点
    • 数据抽象:-用ADT描述:强调数据对象特征、功能、以及它和外部用户的接口。在头文件实现 .h
    • 数据封装:-将实体的外部特性和其内部实现细节分离,并且对外部用户隐藏其内部实现细节。在cpp文件中实现 .cpp
    • 在main文件中实现数据的输入输出。

1.1.2 算法及其基本概念及算法分析(两个复杂度)

1.1.2.1 算法及其基本概念

  • 算法是对特定问题求解步骤的一种描述。
  • 程序设计:数据结构+算法;
  • 算法的5个特征:
    • 可行性,能在计算机上执行。
    • 确定性,每个解题步骤无歧义。
    • 有穷性,有限的步骤解决问题。
    • 输入,一般需要输入数据。
    • 输出,至少要有一个输出。
  • 衡量算法优劣的标准:
    • 正确性:能满足具体问题的要求。
    • 可读性:易读,易懂,易理解。
    • 健壮性(容错性):当输入的数据非法时,算法能够做出反应或者进行处理。
    • 时间效率高和存储量低(时空复杂度):1.执行的时间少,2.所需存储空间小。

1.1.2.2 算法分析

1.1.2.2.1 时间复杂度
  • 频度的概念:指原算法中所有操作的执行次数。
  • 算法执行所需的时间 (大约等于) 基本运算所需的时间 * 运算次数。
    • 基本运算:最深层循环内的语句被视为基本运算。
  • 算法的执行时间可以由其 基本运算的执行次数 (常用T(n)来表示)来计量。
  • 基本运算的执行次数是问题规模(常用 n 来表示)的某个函数记为:T(n) = O(f(n))
  • 只求出T(n)的最高项,忽略其较低项系数与常数,这样既可以简化T(n)的计算,又能够合理地反映出n很大的情况下,
    算法的运算效率(时间效能)。
    • 由于这个特性,会产生一个比较特殊的点就是 O(logN) 这种表述并没有错,因为对数可以通过换底公式替换掉底数,
      此时分母就会是常数,就可以被省略了,所以在算法时间复杂度的计算中,如果与对数有关,我们一般不考虑底数的影响。
      详细见文章相关解释链接
1.1.2.2.2 空间复杂度
  • 关键是找到算法执行过程中所需要的辅助空间的数量。因为计算一个程序的空间复杂度,其实就是考虑一个程序在进行算法分析的过程当中临时变量所占的空间的大小。
  • 区分普通算法和递归算法对于空间复杂度计算的不同之处:不同之处在于,递归算法当中使用了递归栈,所以递归算法需要考虑到递归深度的问题,表面上看起来,递归算法好像没有使用新的临时变量,但实际上,递归函数对自身的调用,就好像在栈中多进入了一个元素,而这个元素也要占用空间,这个空间也是临时的,所以符合空间复杂度分析的范畴。

1.1.3 时间复杂度的常见类型及算法复杂度分析实例。

1.1.3.1 常见的几种时间复杂度类型的分析。

  • 常数阶(O(1))
    • 当基本运算的运算次数是常数时,表示其不会随着问题规模的增大而增大,因此可以认为其与问题规模无关,表示为O(1);
    • 一般情况下的基本特点就是没有循环,或者即使有循环的话,循环界限也是常数而不是变量。
  • 对数阶 (O(logN))
    • 例子:(这是一个典型的对数阶的例子)

      对数阶是比较复杂的一种时间复杂度计算的类型,要解决这个问题,我们常用的方法有两种:
    • 第一种,通过列举来找出具体的数据关系。
    • 第二种,通过列数学关系式来求解。
    • 需要了解的最核心的要点是本质上,这是一个求解出基本运算的执行次数与问题规模的函数关系的问题,但是问题规模n是很抽象的数据,
      所以我们靠模拟不断改变的变量i的数值来不断拟合或者说趋近n,通过找出这个过程中基本运算次数的执行次数和变量i之间的函数关系从而得到结论。
    • 这样提问题就行:问自己,当最内层循环运行了一次后,循环变量当下的值是多少(也有可能是最初的值),不断列出来,直到找到规律。
  • 线性阶 (O(n))
    • 基本运算次数包含n且最高次为一次的算法,其时间复杂度就是O(n),常见的形式是T(n)不为常数的单层循环算法。
  • 线性阶乘对数阶 (O(NlogN))
    • 涉及到乘的这种问题无疑就是和循环嵌套有关了,这种情况的典型例子就是前两种类型的循环嵌套后得到的算法类型。
  • 平方阶(O(n^2))
    • 直观来说就是基本运算次数包含n且最高次为二次的算法,形式变化多样具体问题具体分析,但基础模型仍是循环嵌套。
  • 立方阶(O(n^3))
    • 直观来说就是基本运算次数包含n且最高次为三次的算法,形式变化多样具体问题具体分析,但基础模型是三层循环嵌套。
  • 研究算法的目的主要是为了提高算法的效率,讨论算法的时间复杂度也是为了达到这一点,那么以上那么多类算法,运算速度如何呢?
    按从上往下的顺序,随着问题规模的增大,运算的速度从上往下,由快到慢。
  • 分支环境下关于算法时间复杂度的讨论问题。
  • 对于循环内部再嵌套了分支语句的程序来说,对算法的分析还会出现变数,因此我们还要引入,最好、最坏、和平均时间复杂度三个概念。
    • 最好情况下的平均时间复杂度意义不大。(可以理解为所需执行次数最少的那种情况)
    • 最坏情况的时间复杂度规定了算法时间复杂度的上界。
    • 平均时间复杂度,是假设出现所有情况的概率都相等的情况下,对所有可能出现的情况求加权平均数。
    • 常见的例子:排序算法,查找算法,在(有序)表的插入和删除算法。
      • 比如有序表的插入算法:在一个有n个元素的有序表中插入一个元素,这个插入的元素可能在表头,可能在表中,可能在表末,由于有序表的插入靠的是数组的移动,所以表头插入需要移动n个数据,表尾插入需要移动0个元素,而表中各个位置则需要移动的元素个数依次为从n-1到1,最后插入算法的平均时间复杂度就是各种可能情况的时间复杂度等权重数学期望。(删除算法同理)。
  • 用数组逆置的例子来辨析空间复杂度的计算
    • 方法一是,另外建一个新的数组来存放数组中的元素,这种情况下,原数组有多大,新建的临时数组空间也就要有多大,如果原数组长度为n,则临时数组的长度也要是n,故该算法的空间复杂度就是
      O(n)。
    • 方法二是重构当前的数组,利用一个与数组同类型的临时变量来做交换使用,(并且这样也只需要遍历数组长度的一半,在时间复杂度上也有优化,),用这个临时变量让头尾对称的元素进行两两交换,这样就能实现数组逆置,而且由于使用的临时空间只有一个变量,所以空间复杂度就为O(1)。
    • 此时不妨再深思考一步,如果规定该数组中的每一个数据都不同的话,我们要进行两个元素的交换,我们甚至不需要临时变量,因为有位运算的一个特殊例子就是:** a ^= b ^= a ^= b **这行代码可以实现不相同的两个数据a,b的交换,并且没用用到临时变量。

1.2 线性表(1分)

1.2.1 顺序表

1.2.1.1 顺序表结构体定义、顺序表插入、删除的代码操作

  • 顺序表结构体定义
typedef int ElemType; 
typedef struct
{
	ElemType data[MAXSIZE];   //MAXSIZE代表顺序表的最大容量
	int length;   //表示顺序表的长度,相当于数组长度。
}List;
typedef List *SqList;
  • 顺序表的初始化
void IniList(SqList & L)
{
	L = new SqList;
	L->length = 0;
}
  • 顺序表的销毁
void DelList(SqList & L)
{
	delete L;
}
  • 顺序表的插入
    • 首先要注意的是插入只前要先判断插入的位置是否合理,另要注意顺序表中指定的插入位置是逻辑位置 ,允许插入的位置为逻辑位置 (1 到 length+1)。
    • 判断完插入位置是否合理后,应该将逻辑位置变换成物理位置也就是下标。
    • 顺序表的插入靠的是移动数据的方法,length充当下标可以直接表示将要移动到的第一个位置。
    • 最后一个移动到的位置是物理位置i+1。
    • 移动完后记得把元素插入到腾出来的位置。
    • 记得改变顺序表的长度!!!!!
bool ListInsert(SqList & L, int i, int e)
{
	if (i<1 || i>L->length + 1)
	{
		return false;
	}
	i--;//将逻辑位置转换为物理位置
	for (int j = L->length; j > i; j--)
	{
		L->data[j] = L->data[j - 1];
	}
	L->data[i] = e;
	L->length++;
	return true;
}
  • 顺序表的删除操作
    • 空表无法进行删除。
    • 删除前要查找是否存在需要删除的元素,如果是指定位置删除要看删除的位置是否合理。
    • 顺序表的数据删除靠的是元素的移动。
    • 删完记得改顺序表的长度!!!!
    • 查找对象和指定位置删除也可以用重构法删除。
bool ListDelete(SqList*& L, int e)
{
	int fact = 0;//此时表示表中没有需要删除的元素。
	int loc;
	if (L->length == 0)
	{
		cout << "空表无法删除数据!";
		return false;
	}
	for (loc = 0; loc < L->length; loc++)
	{
		if (L->data[loc] == e)
		{
			fact = 1;//找到可以删除的元素
			break;
		}
	}
	if (fact)
	{
		for (int x = loc; x < L->length - 1; x++)
		{
			L->data[x] = L->data[x + 1];
		}
		L->length--;
	}
	else
	{
		cout << "找不到需要删除的元素,删除失败。";
		return false;
	}
}
  • 顺序表的区间删除

    • 空表莫得删除
    • 删除可以考虑重构顺序表(本质是重构数组,需要重构的下标)。
    • 不在要删除的区间范围内的元素存放入重构链表,在的就不放。
    • 最后要记得修改重构后的顺序表的长度。
    • 元素在实际意义上并没有被真正删除。
  • 顺序表重复元素的删除

    • 空表莫得删除
    • 本质上也是重构顺序表(依旧需要重构的下标)。
    • 首先要假定第一个元素已经存放到重构的顺序表当中,然后从第二个元素开始,一个一个将元素存放到重构的顺序表当中。
    • 重构的过程当中,要对即将存入的数据对已经存入的数据进行查重,如果能查到,就不插入,反之,就允许插入。
    • 最后要记得修改重构后的顺序表的长度。
  • 无论是重复数据删除还是区间删除,本质上都是条件删除,即对删除的对象有条件限制,凡是条件删除,都可以使用重构法,对符合条件的元素进行约束,并对不符合条件的元素开放,以及修改相关数据量比如数据容量即可。

1.2.1.2 顺序表插入、删除操作时间复杂度

  • 指定位置插入
    • 只有一个循环,基本语句只有一条,循环条件为L->length,也就是说随着顺序表容量越来越大,基本语句的执行次数也就越多,呈线性关系。此处的时间复杂度为O(n),线性阶。
  • 指定对象删除
    • 有两个不嵌套的循环,首先是查找到对象元素是否在顺序表内,如果在的话,在什么位置,存在好坏情况,不过平均时间复杂度仍为O(n),线性阶。
    • 其次是将删除位置后一位开始的元素逐个前移一位,基本语句一句,执行次数为删除位置到末尾的前一位的距离差,会随着序表容量的增大而增大,呈线性关系,此处的时间复杂度为O(n),线性阶。
    • 最终整合两处循环的时间复杂度得出最后的整个算法的时间复杂度也仍为O(n),线性阶。

1.2.2 链表(2分)

1.2.2.1 链表产生的背景及其模型的大概阐述:

  • 程序要编译时确定所需内存量,无论再怎么精算,都会出现空余或者不足的情况,这的确不如在运行的时候去确定所需的内存量,而这便需要动态申请内存。
  • 从内存角度出发去解决问题,能用指针解决问题的话,就少用数组,尤其是在结构体的使用上,使用结构体指针数组会比直接使用结构体数组来说剩下的空间会省很多!
  • 虽然结构不能含有与本身类型相同的结构,但是可以含有指向同类型结构的指针。这种定义是定义链表的基础,链表中的每一项都包含着在何处能够找到下一项的信息。
  • 我所认为的结构单链表是这样的:单链表是由一个头指针和多个包含指针成员的结构体,通过指针连接形成的,由头指针指向下一个结构体,接下来由这个结构体中的指针
    指向下一个结构体,如此连接下去直到最后一个结构体的指针指向NULL,
    这样形成的一个数据结构。
  • 链表的示意图如下:
    在这里插入图片描述
  • 链表结构体的阐述:
    • 很明显链表首先分为数据域和指针域,数据域用来存放数据,它可以是任意可以用来储存数据的各种数据结构,小到int类型的变量,大到一个结构体都可以。而指针域明显是用来存放指针的区域,如果是单链表就只有一个指向下一个结点的next指针,如果是双向链表就还有一个多出来的指向前一个结点的prior指针。
    • 所以我们可以如此定义:(这是带有表头的链表)
    typedef int ElemType;
    typedef struct LNode
    {
       ElemType data;
       struct LNode* next;
       //struct LNode* prior;   //双向链表特有的前驱指针。
    }List;
    typedef List* LinkList;
    
  • 链表的头插法
    • 如果不是从别的链上直接获取数据的话,那就需要新建一个结点。
    • 头插法首先要求插入的结点应该要先继承原来表头后续的结点。
    • 头插法然后要求载入数据后,让表头的next指向新建的这个结点。
    • 头插法插入的数据的逻辑顺序和实际的插入顺序相反。
    • 如果是从别的链上来获取数据的话,需要一个临时指针来指向即将要做头插操作的结点,并让保存链数据的指针往下移动。
    • 即始终会被划分成三个区域:表头,新结点,原始链。
      在这里插入图片描述
void CreateListF(LinkList& L, int n)//头插法建链表,L表示带头结点链表,n表示数据元素个数
{
    L = new LNode;//为链表头初始化
    L->next = NULL;

    for (int i = 1; i <= n; i++)
    {
        LinkList newL = new LNode;
        cin >> newL->data;//载入数据
        /*头插法核心*/
        newL->next = L->next;//first step
        L->next = newL;//second step
    }
}
  • 尾插法
    • 尾插法需要一个特殊的指针称为尾部指针(tail 或者 rear),尾部指针是保存链表最后一个结点的地址信息的指针。
    • 尾插法也需要新建一个新结点来保存具体数据。
    • 尾插法的过程首先是先让尾指针的next指向新结点。
    • 再让尾指针指向新结点,因为尾插完成后新的结点就是链表最尾部的结点。
    • 最后的最后要封尾,让载入完成数据后的tail->next指向nullptr。
    • 尾插法插入的数据的逻辑顺序和数据的插入顺序相同。
      在这里插入图片描述
void CreateListR(LinkList& L, int n)//尾插法建链表
{
    L = new LNode;//先替链表头初始化;
    LinkList rear = L;
    for (int i = 1; i <= n; i++)
    {
        LinkList newL = new LNode;
        cin >> newL->data;//载入数据
        /*尾插法核心*/
        rear->next = newL;
        rear = rear->next;
    }
    rear->next = NULL;//尾插法封尾
}
  • 链表的指定位置插入
    • 首先要找到插入位置的前驱。
    • 要考虑插入位置不合理的情况。
    • 插入需要新建一个新结点来载入新数据。
    • 新结点要先继承插入位置后续原有的结点。
    • 再让插入位置的前驱的next指向新结点。
bool ListInsert(LinkList& L, int i, ElemType e)//指定位置插入结点
{
	LinkList newL = new List;
	int j = 0;
	LinkList pKeep = L;

	while (j < i-1 && pKeep != NULL)
	{
		j++;
		pKeep = pKeep->next;
	}
	if (pKeep == NULL)//针对不合法插入进行处理
	{
		return false;
	}
	else
	{
		newL->next = pKeep->next;
		pKeep->next = newL;
		newL->data = e;
		return true;
	}
}
  • 链表的删除操作
    • 链表的删除需要考虑链表是否为空,空表不可删除。
    • 如果是针对对象的删除,要考虑链表中找不到数据对象的情况。
    • 和插入一样,删除也需要找到需要删除的位置的前驱。
    • 然后是要用一个新的指针来报存即将删除的结点的地址。
    • 让前驱指针的next指向即将删除的结点的next,从逻辑上将结点择去。
    • delete保存待删除结点地址的指针,从实际意义上删除结点。
  • 以下是以针对对象删除的链表删除为例来实现链表的删除的代码
void ListDelete(LinkList& L, ElemType e)//链表删除元素e
{
    /*如果链表已空就不需要再执行删除*/
    if (L->next == NULL)
    {
        return;
    }
    LinkList pre = L;//前驱指针
    LinkList delLNode;//待删除结点指针。
    int fact = 0;//状态变量,此时表示找不到
    while (pre->next)
    {
        if (pre->next->data == e)//找到了
        {
            fact = 1;//改变状态变量表示找到了数据
            break;
        }
        pre = pre->next;
    }
    if (fact)
    {
        delLNode = pre->next;
        pre->next = pre->next->next;
        delete delLNode;
    }
    else
    {
        cout << e << "找不到!" << endl;
    }
}
  • 链表重构的注意事项
    • 在重构链表之前,要记得先把链表中原有的数据链用一个指针保存下来。
    • 保存完数据链的地址后,可以用L->next = NULL来重构链表。
  • 链表和顺序表的优劣对比。
    • 链表一个一个结点在地址上的存储主要占用的是一个地址的空间大小,而顺序表需要占用数据域中数组的空间大小。
    • 顺序表的插入和删除需要移动多个数据元素,效率比较低,而链表的插入和删除只需要找到具体位置的直接前驱即可,插入删除的操作只需要改变结点的next关系即可。
    • 不过顺序表的数据可以通过下标进行随取随用,访问数据的效率比链表快。
    • 还有就是,顺序表需要预设数据容量,但是链表不需要预设结点容量,想要有几个结点就可以有,结点的容量无需在存储空间分析的时候就计算好,可以在程序运行的过程中具体变化。

1.2.3 有序表(1分)

  • 有序顺序表插入操作。

    • 有序顺序表的插入需要预先数组是否已满,已满的数组无法再插入数据。
    • 有序顺序表的插入需要先找到插入位置。
    • 知道插入位置后通过让最后一个数据到插入位置的数据每个数据逐一后移一位,可以腾出待插入的空间。
    • 最后就是将要插入的数据载入具体位置。
    • 仍然不要忘了,顺序表的插入需要修正插入后的长度大小!!
  • 有序单链表数据插入、删除。

    • 需要建立一个新结点来存储即将要插入的数据。
    • 插入操作重要的事情依旧是要找到插入位置的前驱指针。
    • 有序表寻找的条件是:如果是递增序向的话就要找到第一个比插入数据大的数据的结点的前面那个结点,反之如果是递减序向的话就要找到第一个比插入数据小的结点的前面的那个结点。
    • 找到了前驱结点的位置后就可以进行和链表插入相关的操作将新结点插入。
    • 表头插入和表尾插入的情况不需要另外考虑,因为如果是空表或者刚好就在第一个位置那就会自动跳出,而如果是在表尾当前驱指针的next指向nullptr时,循环也会跳出。而无论是哪种跳出的情况插入都只需要找到前驱指针即可。
void ListInsert(LinkList& L, ElemType e)//有序链表插入元素e
{
    /*初始化*/
    LinkList newLNode = new LNode;//插入的结点
    newLNode->data = e;//载入新数据
    LinkList pMove = L;
    while (pMove->next)
    {
        if (pMove->next->data > e)
        {
            break;
        }
        pMove = pMove->next;
    }
    /*无论那种情况pMove一直表示插入位置的前驱*/
    newLNode->next = pMove->next;
    pMove->next = newLNode;
}
  • 而有序单链表的指定对象数据删除和单链表的指定对象数据删除操作一致。(可以直接参考前面单链表的指定对象删除操作)
  • 有序单链表的区间删除
    • 关键是要找出删除区间的左区间的前驱指针和删除区间的右区间的结点。
    • 同时要保存好删除区间的左界的地址。
    • 然后是改变next的关系,让区间左界结点的前驱指针的next指向右区间结点的next。
    • 最后是删除符合删除区间的那条链。
  • 有序链表合并。
    • 首先是要分别把两条链的数据链择取出来分别用两个指针保存他们的地址。
    • 然后是对要充当新链的那条链重构。
    • 接着是在同时遍历两个指针的情况下对数据比较的不同情况进行处理:假设链为递增链的情况下,两个指针中较小的那个数据先尾插入重构的链(尾插需要尾指针)然后让有插入的那条链往下移动,并更新尾指针。
    • 如果两条链的数据相同的话,也即遇到重复数据的情况,那就任取一链的结点尾插,但两条链同时向下移动。
    • 此时往往还会遇到两条链并不同时遍历完的情况,这时就需要我们在循环外对当下两个指针分别判断,不为空的指针就让它剩下的数据接到重构链表的尾指针的next上就行了。
void MergeList(LinkList& L1, LinkList L2)//合并链表
{
    LinkList tail = L1;//先让尾指针指向表头。
    //链表重构
    LinkList pMoveL1 = L1->next;
    L1->next = NULL;
    LinkList pMoveL2 = L2->next;

    while (pMoveL1 && pMoveL2)
    {
        if (pMoveL1->data < pMoveL2->data)
        {
            tail->next = pMoveL1;
            tail = pMoveL1;
            pMoveL1 = pMoveL1->next;
        }
        else if (pMoveL1->data > pMoveL2->data)
        {
            tail->next = pMoveL2;
            tail = pMoveL2;
            pMoveL2 = pMoveL2->next;
        }
        else if (pMoveL1->data == pMoveL2->data)//处理重复数据
        {
            tail->next = pMoveL1;
            tail = pMoveL1;
            pMoveL1 = pMoveL1->next;//让两个链表都往下移动
            pMoveL2 = pMoveL2->next;
        }
    }

    /*处理剩下的数据*/
    if (pMoveL1)
    {
        tail->next = pMoveL1;
    }
    if (pMoveL2)
    {
        tail->next = pMoveL2;
    }
}
  • 有序链表合并的另外一种实现形式
    • 把两个链表的合并看成是将一个表中的所有元素,逐个插入到一个有序表中。
    • 那就只需要一个指针先保存其中一个表的数据链。
    • 然后遍历这条数据链,一个一个结点去找插入到另一条有序链中的指定位置。
    • 特别说明,链表的插入关键还是找到插入位置的前驱指针pre。
    • 当这条数据链为空时,就代表两条有序链表合并完毕了。
  • ** 有序单链表合并与有序顺序表合并的区别**
    • 有序顺序表的合并需要能够完全容纳两个顺序表的数据的大小的另一个顺序表,或者其中有一个顺序表的容量大到足够容纳即将合并的两个顺序表的总大小那就从尾部按顺序重构这个顺序表,但是单链表可以直接选取原始链表的其中一个进行重构不需要额外的变量。
    • 另外一点就是相对于有序顺序表,对于其中一条链或表已经遍历完而另外一条链或表仍有数据的情况时,有序链表的善后处理只需要分支语句(即让尾指针的next指向剩下的那条链就行),而有序顺序表则比较麻烦,需要通过循环将剩下的顺序表中的元素一一放入结果表。

2.PTA实验作业(4分)

2.1 两个有序序列的中位数

2.1.1 解题思路及伪代码

  • 首先是对数据结构的选择,由于题目有直接给出一些特殊的条件,比如给出了两组数据等长的特点,又给出了数据的具体范围,所以对于已知数据范围的情况我优先想到的是使用顺序表这种数据结构。因为本题需要得到中位数,而当数据量一庞大的时候,如果用链表取中位数,就要跑一段很长的循环,而循序表只要用下标就可以直接访问,并且由于等长,所以中位数的下标是个特殊值。

  • 而首先要做的事情就是把两个有序序列合并,此处我另外动态申请了一个数组来存放两个有序序列合并后的序列而实际操作的过程就是两个有序顺序表的合并详细的细节不赘述,直接看前文。

  • 合并完两个序列后最后直接cout << SeqArr[(2 * N + 1) / 2 - 1];根据题干信息直接用下标输出中位数。

  • 另外还需处理的两个函数是,需要有一个建立顺序表载入数据的函数。

  • 需要有一个释放顺序表空间的destroy函数。

  • 伪代码

* 数据结构定义如下:
typedef struct
{
	int* arr = new int[1000000];
	int length;
}List,*SqList;
* 建表函数逻辑如下:
void CreatList(SqList &L, int n)
{
	SqList L = new List;
	for form i=0 to n-1;
	cin << L->data[i];
	L->length = n;
}
* 合并函数逻辑如下:
void MergeL(int* &L,SqList L1,SqList L2)
{
     while(同时遍历两个表)
     {
     if(L1->data[i] <= L2->data[j])  L[k++] = L1->data[i++];
     else if(L1->data[i] >= L2->data[j])  L[k++] = L2->data[j++]; 
     }
     while (L1没遍历完) 遍历完L1将元素全部导入L
     while (L2没遍历完) 遍历完L2将元素全部导入L
}
* 主函数逻辑:
int main()
首先是输入公共长度N
然后是依次建立顺序表L1、L2
在外部动态申请一个空间为2N的结果数组
利用合并函数将两个有序表的数据合并到结果数组中
cout << SeqArr[(2 * N + 1) / 2 - 1];`
销毁L1,L2, L,释放内存。

2.1.2 碰到问题及解决方法

没有遇到明显的问题。
在这里插入图片描述

2.2 一元多项式的乘法与加法运算

2.2.1 解题思路及伪代码

  • 首先是要进行数据结构的选择,由于这题对于一个数据元素,存在两个数据项,我们不方便用顺序表表的来操作,所以这里用带有两个数据变量的单链表来操作效果会更好。此处这两个数据项分别是一元多项式的系数和指数。

  • 紧接着是一元多项式的数据的读入,这个需要一个单链表的数据载入的函数。

  • 一元多项式的加法的实现可以借助有序链表的合并的算法来实现,当指数项的数据相等的时候,就把两个项的系数项相加如果找不到相等的就先略过,合并时的排序按指数的降幂排序。即指数大的数先放入到结果链中。

  • 另外此题由于两条多项式链不能被改变,所以不能用重构链的方法来合并两个有序链,得另外建一个结果链来保存数据。而且数据的传递需要新建结点。

  • 一元多项式乘法的实现,可以先取第一个多项式的第一个项先与第二个多项式相乘,先得到原始的一条有序的结果链,之后再让第一个多项式剩余的每一个项都分别与第二个多项式的每一个项相乘,相乘后得到的数据再一一插入到原始结果链的对应顺序,该合并的合并,该插入的插入就行。

  • 最后分别用链表打印函数按格式输出结果链。

  • 实现加法的伪代码

while(同时遍历两个多项式)
如果两个多项式的指数相等,那就把他们的系数数据相加存入新结点的系数,把他们的指数存入新结点的指数,
然后在结果链中尾插新结点;
如果其中有一个多项式的项的指数更大,那就把它的指数和系数分别存入新结点的对应数据项中,然后在结果链中尾插新结点;
while(没遍历完的多项式),那就把它的结点的数据一个一个存入结果链。
  • 实现乘法的伪代码
while(遍历第二个多项式)
拿第一个多项式的第一个结点分别和第二个多项式的每个结点的数据相乘,
即系数相乘,指数相加,得到新的数据项尾插入结果链中;

while(从第一个多项式的第二个结点开始遍历)
	while(遍历第二个多项式)
	拿第一个多项式的每个结点与第二个多项式的每个结点相乘得到新数据
	    遍历前更新尾指针
		while(遍历初始结果链)
			如果新数据的指数更大,尾插入结果链
			如果相等则项相加,新数据直接修改在结果链的项中

2.2.2 碰到问题及解决方法

遇到的问题是在解决乘法的sample样例中,多次出现丢失部分数据项的问题。
问题的解决是通过单步调试对数据进行逐一排除得到的。
问题出现的主要原因是,里面使用的插入算法是通过前驱指针的next来遍历的寻找插入位置的,而这里进行的操作是在循环内部的分支中直接进行找到了的插入操作,没有注意到部分数据是尾部插入的情况的存在,所以才导致乘法算法的sample样例会出现部分丢失的情况,在循环外针对尾部插入新增分支后问题解决,不过也仅仅只是过了sample样例而已。

目前仍有三个测试点没有通过,解决办法在寻找中·····
在这里插入图片描述

3.阅读代码(1分)

3.1 题目及解题代码

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • 解题代码:
class Solution {
public:
    ListNode* mergeTwoLists(ListNode *a, ListNode *b) {
        if ((!a) || (!b)) return a ? a : b;
        ListNode head, *tail = &head, *aPtr = a, *bPtr = b;
        while (aPtr && bPtr) {
            if (aPtr->val < bPtr->val) {
                tail->next = aPtr; aPtr = aPtr->next;
            } else {
                tail->next = bPtr; bPtr = bPtr->next;
            }
            tail = tail->next;
        }
        tail->next = (aPtr ? aPtr : bPtr);
        return head.next;
    }

    ListNode* mergeKLists(vector<ListNode*>& lists) {
        ListNode *ans = nullptr;
        for (size_t i = 0; i < lists.size(); ++i) {
            ans = mergeTwoLists(ans, lists[i]);
        }
        return ans;
    }
};
作者:LeetCode-Solution
链接:https://leetcode-cn.com/problems/merge-k-sorted-lists/solution/he-bing-kge-pai-xu-lian-biao-by-leetcode-solutio-2/
来源:力扣(LeetCode)
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

3.2 该题的设计思路及伪代码

  • 本道题的设计思路需要有合并两个有序链表的知识为基础。

  • 本题的主要思想是旁生一个结果链表Ans,然后用结果链表为首链表,不断与新输入的新链表进行合并,合并后的数据依旧存放在Ans中,这样不断地用结果链与新输入的链进行两两合并最终达到合并k个链表的目的。

  • 特殊点:

    • 它这里两个待合并的链是没有表头的单链表。
    • 在合并过程中才引入一个头结点用来存放结果链。
  • 伪代码

* 合并两个有序链表
首先同时判断两个链表是否为空,一条为空的话返回不为空的那条链,都为空返回任意一条链
while(同时遍历两条链(不带头结点))
{//要求按升序排列
	如果a链的数据比b链小,则将a链的数据插入结果链
	否则(也就是b链数据更小或者两者相等的情况),那就尾插b链。
	//这样逻辑上更简洁,else中已经把数据重复的情况考虑在内了。
}
一条链遍历结束后,将另一条未空链的剩余数据尾插入结果链中。
返回 结果链的数据部分,即不返回表头。

* 合并k个有序链表
先创建一个空链,也就是结果链。
for from i=0 to k-1
每输入一个新的链表就让他与结果链合并

3.3 分析该题目解题优势及难点。

  • 该题目的优势有以下这些:
    • 首先要深刻认识到单链表 表头存在的目的是为了在插入和删除操作中更方便,所以并不是所有用到的单链表都一定按有表头的单链表来构建,应该具体情况具体分析,不要固化这两者的关系。
    • 其次是要学会使用三元运算符exp1 ? exp2 : exp3再两两抉择的关系中三元运算符的使用能很大程度上简化代码。比如此处用到的检验链表是否为空时的使用和最后处理剩余元素时的使用就都很简洁而锐利。
    • 再次是合并的内部判断中,其实对于数据重复的情况没必要另外判断,因为此处的重复数据并不是采取删除的处理方法,而是保留,那其实处理的情况就可以直接归类到else中就行。也就是说在我们以后写代码的分支判断过程中,要想好不同逻辑分支下的操作是否一致,如果一致的话,就不必赘述。
    • 最后的最后是难点也是优势的地方那就是这道题目提供了一种我们处理从二到n的问题的一种通解,那就就是不管n到底有多少个,对于n个我们只需要实现了2个的操作之后,就只需要把n个化成多个的结果和待处理两者之间的两两和并就行
    • 实现了两个的处理之后推广到n个的处理就仅仅只需要一个循环就可以解决了。 这样一句简单的话,就是这个算法最精髓也是最难的点。好的代码从来都是言简意赅,极度锐利。

4. 线性表上机考试的部分题目回炉Debug后得到体会

4.1 6-14 jmu-ds-链表区间删除 (20 分)

  • 题目思路:
    • 链表的插入排序和题目-> 6-9 jmu-ds-有序链表的插入删除 中的插入函数其实本质上是一样的这里不赘述,
      关键点是找到合适的插入位置的前驱指针,然后再插入。
    • 进行区间删除的大体思路如下:
      • 情况一:空表莫得删除;
      • 情况二:所有的数据全比min小;
      • 情况三:所有的数据全比max大;
      • 情况四:删除区间确实在链表数据的范围中,但是链表中没有这个区间的数据;
      • 情况五:链表内存在可以删除的区间。
    • 显然这里这一切的关键是要找出这个要被删除的区间的左右两界,准确的说是:
      删除区间的第一个元素的前一个元素的地址,以及删除区间内的最后一个元素。
    • 所以我们先找左界的直接前驱,找到后进行分支判断,如果左界为空,表示满足情况一,
      没得删,如果左界比max大,表示满足情况二和四(情况2是情况4的特殊情况),没得删。
    • 最后剩下情况五,那就是要找到删除区间内的最后一个元素,也即链表中第一个比max大的元素的直接前驱。
    • 找到这两个范围后就直接改变next关系并最后真正删除删除区间即可。
      代码如下:
void InsertList(LinkList& L, int n)//链表中插入n个节点,并保持链表递增有序
{
    L = new LNode;
    L->next = NULL;
    LinkList pre;
    if (n == 0)
    {
        return;
    }
    while (n--)
    {
        pre = L;//pre是前驱指针,每次查找前都要重新定位到表头。
        LinkList newLNode = new LNode;
        cin >> newLNode->data;
        while (pre->next)
        {
            if (pre->next->data > newLNode->data)
            {
                break;
            }
            pre = pre->next;
        }
        newLNode->next = pre->next;
        pre->next = newLNode;
    }
}
void DelList(LinkList& L, int min, int max)//删除[min,max]内元素
{
    if (L->next == NULL)//空表不删
    {
        return;
    }
    LinkList leftpre = L;
    LinkList rightpre = L;
    LinkList delnode;
    while (leftpre->next)
    {
        if (leftpre->next->data >= min)
        {
            break;
        }
        leftpre = leftpre->next;
    }

    if (leftpre->next == NULL  //代表所有的数都比min小那就没得删了。
        || leftpre->next->data > max  //代表链表中不存在区间内的数
        )
    {
        return;
    }

    rightpre = leftpre;
    while (rightpre->next)
    {
        if (rightpre->next->data > max)
        {
            break;
        }
        rightpre = rightpre->next;
    }

    /*改变next关系前要先保留住要删除的区间的首地址*/
    LinkList delZoneHead = leftpre->next;

    /*改变next关系来删除目标区间链*/
    leftpre->next = rightpre->next;
    /*另外建一个指针来保存尾界的地址防止地址丢失*/
    LinkList delZoneRear = rightpre->next;

    /*要删除的区间不是全部删完而是只到删除区间的区间末*/
    while (delZoneHead != delZoneRear)
    {
        delnode = delZoneHead;
        delZoneHead = delZoneHead->next;
        delete delnode;
    }
}

以下才是要说的这道题的烦人的点。

  • 改变next关系从逻辑上删除删除区间之前,一定要先保存删除区间的左界,先保存,再改构,否者会出现地址丢失!

  • 此处对删除区间的真实删除,并不是要一直删到结尾,只需要把删除区间内的最后一个结点删除就算完成了,所以在
    循环条件上,我们就需要有存储删除区间的最后一个结点的地址的指针。

  • 以及一个非常重要的习惯是:在链表中用指针保存地址的时候,最好不要用某个指针的next之类的来表示,
    老老实实新建一个指针来保存相应的地址,既可读性更好,也防止其他指针被不小心改动了位置而导致所需保存
    的位置丢失而引发的段错误问题。简单来说就是用来保存地址的指针值得且最好另外新建变量。

  • 另外一个小问题是,没有看清这里的区间删除是闭区间,第一次解读的时候,少了点东西:

原文地址:https://www.cnblogs.com/luoqianshi/p/14503265.html