数据结构:顺序表结构详解

线性表

什么是线性表?

说起这个问题,我们一定不陌生。打开QQ或微信,我们可以看到好友列表,打开PTA,我们能看到题目列表,打开音乐软件,我们可以看见歌曲列表,线性表在我们的生活中无处不在。线性表是怎么呈现的呢?线性表把我们在生活中需要的信息,按照顺序进行排列,使得这些信息直观、有条理,如果是按照某种顺序排列的列表,我们可以做到信息的快速检索。

在数据结构中,线性表是最基本、最简单、也是最常用的一种数据结构。所谓线性表,是零个或多个数据元素的有限序列,线性表的元素具有相同的特征,数据元素之间的关系是一对一的关系。用数学语言来描述线性表,用n表示链表的长度,设序列中第i个元素(a1,…,ai-1,ai,ai+1,…,an),当表的长度为0时,就表示这是一个空表。那么线性表的一个数据元素包含多少数据项呢?就好比学生学籍信息表,除了学生的姓名,还会有诸如学号、出生日期、户籍所在地等信息,因此线性表的一个数据元素可以由多个数据项组成。如图所示:

线性表抽象数据结构

ADT List
{
    Data:
        D = {ai | 1 ≤ i ≤ n, n ≥ 0, ai 为 ElemType 类型}
    Relation:
        R = { <ai,ai+1> | ai,ai+1 ∈ D, i = 1, i ∈ (0,n)}
    Operation:
        InitList(&L);    //初始化,建立一个空的线性表L
        MakeList(&L);    //建立线性表,向表中存入数据
        ListEmpty(*L);    //空表判断,是则返回true,否则返回false
        DestroyList(&L);    //清除操作,清空线性表的元素
        GetElem(L,i,&e);    //获取线性表的元素,将线性表L的第i个元素的值返回给e
        LocateElem(L,e);    //按值查找元素,在线性表L中查找与e元素相等的元素,查找成功返回对应的序号,查找失败则返回0
        ListInsert(&L,i,e);    //插入操作,在线性表L的第i个位置插入元素e
        ListDelete(&L,i,&e);    //删除操作,删除线性表L中的第i个位置的元素,并将其用e返回
        ListLength(L);    //计算表长,返回线性表L的元素个数
        DispList(L);    //输出线性表,当线性表不为空表时,按顺序输出表中的每一个元素
}

复杂操作的实现

例如你有两个线性表分别是L1、L2,现在你要做的事情是合并两个表,即实现L1∪L2操作。思路很自然,我们直接去遍历L2,然后判断一下L2的元素是否是L1未包含的,如果是就将该元素插入L1即可。
代码实现:

void unionList(List &L1,List L2)
{
    int len_a,len_b;
    int i;
    ElemType e;
    
    len_a = ListLength(L1);    //获取表长
    len_b = ListLength(L2);
    for (i = 1; i <= len_b; i++)
    {
        GetElem(L2,i,e);    //获取线性表L2中的第i个元素
        if(!LocatElem(L1,e))    //判断元素e是否已经包含于线性表L1
            {
                ListInsert(La, ++len_a, e);    //若还未包含,执行插入插入
            }
    }
}

由此我们就能够明白了,一个复杂的操作,离不开对基本操作的组合应用。

顺序表

线性表的顺序存储结构是,把线性表中的所有元素按照其逻辑顺序依次存储到从计算机存储器中指定存储位置开始的一块连续的存储空间中,线性表中逻辑上相邻的两个元素在对应的顺序表中的存储位置也相邻。在C/C++中,我们使用一维数组实现线性表的顺序存储结构,当我们定义了一个数组,就分配了一块连续的存储空间,该存储空间的起始位置就是由数组名表示的地址常量,数组的基本类型就是线性表中元素的类型,需要注意的是数组的大小要大于等于线性表的长度

代码描述

在用代码描述线性表的顺序存储结构时,我们先要定义一个整型常量 MAXSIZE,用来表示线性表最大数据元素容纳量。由于我们创建的线性表是动态的,即我们需要经常向表中修改、插入和删除元素,因此需要有一个变量 Last 来存储线性表最后一个元素的位置,即表的实际长度,为了继承这些要素,我们用结构体类型来表示:

#define MAXSIZE 50
typedef int Position;
typedef int ElementType;    //int可改为其他数据类型
typedef struct SqList *List;
typedef struct
{
    ElementType Data[MAXSIZE];    //存放线性表中的元素
    Position Last;    //保存线性表中最后一个元素的位置,表示表长
}SqList;    //线性表类型定义

元素地址的确定

线性表的第一个元素,即下标为0的元素,是存储在这个数组的起始位置,假设存储地址是 LOC(A)。则第二个元素存储于下标为1的位置上,那么存储位置是“LOC(A)+1”吗?并不是,线性表中的每个元素都需要提供一定的空间来存储,单个元素所占的空间大小因类型的不同而不同,因此第二个元素的地址是 LOC(A) + sizeof(ElemType)。
以此类推,第i个元素的地址为:

LOC(Ai) = LOC(A) + sizeof(ElemType) * (i - 1)

顺序表基本操作

初始化顺序表

初始化顺序表的目的是构造一个空的顺序表L,我们需要分配足够的存储空间,并将表长设置为0。

List InitList()
{
    List L;

    L = new SqList;    //分配存放顺序表的空间
    L->Last = 0;
    return L;
}

建立顺序表

将给定含有n个元素,将n个元素依次输入,并放入到顺序表中,并将n赋值给顺序表的变量 Position。

void MakeList(List &L)
{
    int i;

    cin >> n;
    for(i = 0; i < n; i++)
    {
        cin >> L->Data[i];    //输入数据
    }
    L->Last = n;    //设置表长
}

销毁顺序表

直接将顺序表L的空间释放掉。

void DestroyList(List &L)
{
    delete L;
}

按照元素查找

利用顺序查找查找第一个与e相等的元素返回线性表中e的位置,若找不到则返回值为-1。

Position LocateElem(List L, ElementType e)
{
    int i = 0;
    for (Position i = 0; i <= L->Last; i++)   
    {
        if (L->Data[i] == e)
        {
             return i;
        }
    }
    return 0;
}

插入数据

将e插入在位置P并返回true,若空间已满或参数P指向非法位置并返回false,插入时我们需要从最后一个元素开始,遍历到第i个元素,并将这些元素都往后移动一个位置,以便于给需要插入的元素提供足够的空间。

bool Insert(List L, ElementType e, Position P)
{
    int i;

    if (L->Last + 1 >= MAXSIZE)    //判断空间是否已满
    {
        return false;
    }
    if (P > (L->Last + 1) || P < 0)    //参数错误时返回false
    {
        return false;
    }
    for (i = L->Last; i >= P; i--)    //将data[P]及后面元素后移一个位置
    {
        L->Data[i + 1] = L->Data[i];
    }
    L->Data[P] = X;    //插入数据
    L->Last += 1;    //顺序表长度加1
    return true;
}

删除数据

将位置 P 的元素删除并返回 true,若参数 P 指向非法位置,则返回 false,删除元素后,我们需要从删除的位置开始遍历到最后一个位置,并将它们往前移动一个位置。

bool Delete(List L, Position P)
{
    int i;

    if (P > L->Last || P < 0) 
    {
        return false;    //参数错误返回 false
    }
    for (i = P; i < L->Last; i++)
    {
        L->Data[i] = L->Data[i + 1];    //将data[P]之后的元素向前移动一个位置
    }
    L->Last -= 1;    //顺序表长度减1
    return true;
}

顺序表逆序

将顺序表中的 left 和 right 下标之间的元素逆序,可以设置两个整型变量 i 和 j,i 指向第一个元素,j 指向最后一个元素。接着遍历时交换 i 和 j 指向的元素,然后让 i 和 j 相向而行直到相遇即可。代码如下:

bool Reverse(List L, int left, int right)
{
    ElementType temp;
    for(int i = left, j = right; i < j; i++, j--)
    {
        temp = L->Data[i];
        L->Data[i] = L->Data[j];
        L->Data[j] = temp;
    }
    return true;
}

顺序表的优缺点

插入、删除数据的时间复杂度

首先是插入操作,插入操作时间复杂度最小的情况是,当元素要插入到最后一个位置时,你就不需要移动任何元素即可实现,只需要将需要插入的元素插在表的末端即可,时间复杂度O(1),最费时的操作就是插入的元素要放在表头,那我们就需要把表中的所有元素都移动了,时间复杂度为O(n)。删除操作也如此,当我们要删除最后一个元素,也不需要移动顺序表,而删除第一个元素时需要移动整个表。我们知道,在实际的操作中,删除表中的任何一个位置需要被插入删除的可能性是相同的,因此从平均角度来分析,移动表的平均次数为 (n - 1) / 2,时间复杂度为O(n)。
因此我们可以看出,顺序表在插入、删除操作时是比较费时间的,然而其他的基本操作例如初始化、建表或者销毁,时间复杂度都是O(1),因此我们在使用顺序表的时候,要尽量让表保持不变,而是多多使用顺序表的存储和随机提取等优点。

优缺点分析

顺序表主要有如下一些优点:

  1. 顺序表进行随机提取元素的效率较高,能够快速存储、提取元素;
  2. 建表时无需对表中元素的逻辑关系进行描述,各元素在存储地址上是连续的;
  3. 对于CPU,顺序表的高速缓存效率更高,且CPU流水线也不会总是被打断。

顺序表主要有如下一些缺点:

  1. 申请顺序表时,顺序表存储元素的上限是固定的,这就导致了存在溢出的可能性;
  2. 插入、删除元素时,时间复杂度较大,需要大范围移动表中的元素;
  3. 由于我们在很多情况下无法预知需要存储多少元素,因此容易导致内存碎片的现象,即申请了空间却没有充分利用。

思考

我们在实际应用中,也往往会很喜欢去使用顺序表,因为顺序表的操作是很便捷的,但是我们一直对顺序表,或者说数组在定义的时候需要给的具体长度这个问题很头疼。我们想到的问题,开发者也想到了,那么现在我们有什么方式可以消除这个问题的限制?C语言是怎么解决的?C++又是怎么解决的?

有序表

顾名思义,就是有序的线性表,表中的所有元素都以递增或递减的形式有序排列。它本是上还是线性表,因此对于线性表的所有操作都可以应用于有序表,我们需要关注的是有序表的插入操作以及归并操作。有序表是线性表的一个基础的应用,同时我们也可以通过这种应用去体会顺序表和链表的特点与不同之处。下列代码是在元素顺序为升序的有序表中的操作。

插入操作

执行插入操作时,我们并不关注元素 e 插入的位置,需要关注的是我要怎么操作才能保证操作结束后,L 仍然是个有序表。

void LinkInsert(SqList &L, ElemType e)
{
    int i = 0,j;
    
    while(i < L->Length && L->data[i] < e)
    {
        i++;    //定位元素 e 插入的位置。
    }
    for(j = ListLength(L); j > i; j--)
    {
        L->data[j] = L->data[j - 1];    //将插入位置后面的元素后移1位
    }
    L->data[i] = e;
    L->Length++;    //表长加1
}

有序表归并


要归并两个有序表,相比归并两个无顺序要求的线性表要复杂一些,因为有序表需要时刻保证表中的数据是有序的。执行归并操作的时候,我们要采用动态操作的思想,同时遍历两张表,同时移动下标或指针,遇到较小的元素就归并到新表上。

操作的目的是把有序顺序表 LA, LB 的元素归并到新表 LC 上,我们需要同时遍历 LA 和 LB,将元素依次拷贝到 LC 上。

void UnionList(SqList *LA, SqList *LB, SqList &LC)
{
    int i = 0, j = 0, k = 0;    //由于需要动态操作3个表,因此需要3个变量操作下标
    LC = new SqList;    //为新表LC申请空间
    
    while(i < LA->Length && j < LB-> Length)    //同时遍历两张表,将元素归并到 LC 上
    {
        if(LA->data[i] < LB->data[j])
        {
            LC->data[k++] = LA->data[i++];
        }
        else
        {
            LC->data[k++] = LB->data[j++];
        }
    }
    while (i < LA->Length)    //处理 LA 剩余的元素
    {
        LC->data[k++] = LA->data[i++];
    }
    while (i < LB->Length)    //处理 LB 剩余的元素
    {
        LC->data[k++] = LB->data[j++];
    }
    LC->Length = k;
}

例题解析

逆序问题

题干

给定一个长度为 n 的顺序表,逆序问题经常包括以下 3 个问题:

  1. 将顺序表前端的 k(k < n) 个元素逆序后移动到数组后端,原线性表数据不丢失,其余元素位置可变;
  2. 将顺序表前端的 k(k < n) 个元素保持原来顺序移动到数组后端,原线性表数据不丢失,其余元素位置可变;
  3. 将顺序表循环左移 p(0 < p < n)个位置。

代码实现

对于问题一只需要逆序整个顺序表,就可以将顺序表前端的 k(k < n) 个元素逆序后移动到数组后端。

void reverse(List L, int left, int right, int k)
{
    int temp;
    for(int i = left, j = right; i < left + k; i++, j--)
    {
        temp = L->data[i];
        L->data[i] = L->Data[j];
        L->data[j] = temp;
    }
}

问题二需要先将前 k 个元素逆序,然后再将整个顺序表逆序。

void moveToEnd(List L, int k)
{
	reverse(L, 0, k - 1; k);
	reverse(L, 0, L->length - 1; k);
}

问题三的左移 p 位的效果如图所示,可通过先将 0 ~ p-1 位置的元素逆序,然后将 p ~ n-1 位置的元素逆序,最后再将整个顺序表逆序得到。

void moveP(List L, int p)
{
	reverse(L, 0, p - 1; p);
	reverse(L, p, L->length - 1; L->length - p);
	reverse(L, 0, L->length - 1; L->length);
}

两个有序序列的中位数

题干

题目解析

看到题干,我们最直观的想法是,直接把两个线性表存起来,然后给这两个表排序,排序结束后我们就能直接获取有序表的中位数了。但是如果数据规模很大,排序算法的效率太低,就会有超时的风险,好在 STL 库给我们提供了泛型算法,让我们可以直接享受快速排序的便捷。代码如下:

不过,这种做法虽然可以实现目的,但是这是个可惜的做法,因为输入的数据是有序的,可是我们非要绕远路,把有序的逻辑打乱,再重新整合,其实只需要一个二路归并算法即可实现。那么我们来想一个问题,我们的目的是找到中位数,我们需要将两个有序表完整地归并吗?大可不必。

仔细观察,我们只归并两个有序表的前半部分,那么这两个表的中位数必定被包含在其中,而且是在表尾的位置,所以我们的工作量折半了,虽然时间复杂度不变,但是效率确实又提升了一步。

代码实现

#include <iostream>
#include <vector>
using namespace std;
int main()
{
    int length = 0;
    int num;
    int i, a_idx, b_idx;
    vector<int> a_vec, b_vec, vec;
    
    cin >> length;
    for (i = 0; i < length; i++)
    {
        cin >> num;
        a_vec.push_back(num);
    }
    for (i = 0; i < length; i++)
    {
        cin >> num;
        b_vec.push_back(num);
    }
    a_idx = b_idx = 0;
    for ( i = 0; i < length; i++)
    {
        if (a_vec[a_idx] > b_vec[b_idx])
        {
            vec.push_back(b_vec[b_idx++]);
        }
        else
        {
            vec.push_back(a_vec[a_idx++]);
        }
    }
    cout << vec[length - 1];
    return 0;
}

参考资料

《大话数据结构》—— 程杰 著,清华大学出版社
《数据结构教程》—— 李春葆 主编,清华大学出版社
《数据结构与算法》—— 王曙燕 主编,人民邮电出版社
线性表之顺序表与单链表的区别及优缺点
C语言中文网

posted @ 2022-07-20 16:40  乌漆WhiteMoon  阅读(570)  评论(0编辑  收藏  举报