数据结构

133 阅读21分钟

数据结构是相互之间存在一种或多种特定关系的数据元素的集合。

  • 数据:所有可以输入到计算机并被其处理的字符
  • 数据元素:数据的基本单位,作为一个整体处理
  • 数据对象:性质相同的数据元素的集合
  • 数据结构:相互之间存在一种或多种特定关系的数据元素的集合

数据结构三要素

  • 逻辑结构
    • 集合结构
    • 线性结构
    • 树形结构
    • 图状结构
  • 数据的运算
  • 物理结构(存储结构)

线性数据结构

时间复杂度对比: image.png

数组Array

数组是一种聚合数据类型,它是将具有相同类型的若干变量有序地组织在一起的集合。数组可以说是最基本的数据结构,在各种编程语言中都有对应。
一个数组可以分解为多个数组元素,按照数据元素的类型,数组可以分为整型数组、字符型数组、浮点型数组、指针数组和结构数组等。
根据数组中存储数据之间逻辑结构的不同,数组可细分为一维数组、二维数组、...、N维数组。需要注意的是,无论数组的维数是多少,数组中的数据类型都必须一致。

其中因为数组支持随机访问,所以查找操作是高效的,时间复杂度为O(1),而插入删除操作会涉及到数据搬移,执行效率较低,时间复杂度为O(n)

链表LinkedList

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。

链表的插入删除操作的复杂度为O(1),只需要知道目标位置元素的上一个元素即可。但是,在查找一个节点或者访问特定位置的节点的时候复杂度为O(n)

使用链表结构可以克服数组需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但链表不会节省空间,相比于数组会占用更多的空间,因为链表中每个节点存放的还有指向其他节点的指针。除此之外,链表不具有数组随机读取的优点

链表的分类

  1. 单链表
  2. 双向链表
  3. 循环链表
  4. 双向循环链表

image.png

单链表

单链表只有一个方向,结点只有一个后继指针next指向后面的节点。因此,链表这种数据结构通常在物理内存上是不连续的。我们习惯性地把第一个结点叫作头结点,链表通常有一个不保存任何值的head节点(头结点),通过头结点我们可以遍历整个链表。尾结点通常指向null

// Definition for singly-linked list.
struct SinglyListNode {
    int val;
    SinglyListNode *next;
    SinglyListNode(int x) : val(x), next(NULL) {}
};

双向链表

双向链表包含两个指针,一个prev指向前一个节点,一个next指向后一个节点。

// Definition for doubly-linked list.
struct DoublyListNode {
    int val;
    DoublyListNode *next, *prev;
    DoublyListNode(int x) : val(x), next(NULL), prev(NULL) {}
};

循环链表

循环链表其实是一种特殊的单链表,和单链表不同的是循环链表的尾结点不是指向null,而是指向链表的头结点。

双向循环链表

双向循环链表最后一个节点的next指向head,而headprev指向最后一个节点,构成一个环。

应用场景

  • 如果需要支持随机访问的话,链表没办法做到。
  • 如果需要存储的数据元素的个数不确定,并且需要经常添加和删除数据的话,使用链表比较合适。
  • 如果需要存储的数据元素的个数确定,并且不需要经常添加和删除数据的话,使用数组比较合适。

数组or链表

  • 数组支持随机访问,而链表不支持。
  • 数组使用的是连续内存空间对 CPU 的缓存机制友好,链表则相反。
  • 数组的大小固定,而链表则天然支持动态扩容。如果声明的数组过小,需要另外申请一个更大的内存空间存放数组元素,然后将原数组拷贝进去,这个操作是比较耗时的!

栈Stack

栈是一种运算受限的线性表。它只允许在有序的线性数据集合的一端(称为栈顶top)进行加入数据push和移除数据pop。因而按照后进先出LIFO: Last In First Out 的原理运作。在栈中,pushpop的操作都发生在栈顶。
栈常用一维数组或链表来实现,用数组实现的栈叫作顺序栈,用链表实现的栈叫作链式栈image.png

常用操作

  • push():添加一个元素到栈顶
  • pop():弹出栈顶元素
  • top():返回栈顶元素
  • isEmpty():判断栈是否为空
  • size():返回栈里元素个数
  • clear():清空栈

栈的应用

队列Queue

队列也是一种运算受限的线性表。它只允许在后端rear进行插入操作也就是入队enqueue,在前端front进行删除操作也就是出队dequeue。队列是先进先出FIFO: First In First Out的线性表。
在具体应用中通常用链表或者数组来实现,用数组实现的队列叫作顺序队列,用链表实现的队列叫作链式队列。 

image.png

单队列

单队列即常规队列,每次添加元素时,都是添加到队尾。单队列又分为顺序队列链式队列
顺序队列存在“假溢出”的问题也就是明明有位置却不能添加的情况。假设我们只能分配一个最大长度为 5 的数组。当我们只添加少于 5 个元素时,我们的解决方案很有效。 例如,如果我们只调用入队函数四次后还想要将元素 10 入队,那么我们可以成功。 但是我们不能接受更多的入队请求,这是合理的,因为现在队列已经满了。但是如果我们将一个元素出队呢? image.png 实际上,在这种情况下,我们应该能够再接受一个元素。

循环队列

循环队列可以解决顺序队列的假溢出和越界问题。解决办法就是:从头开始,这样也就会形成头尾相接的循环,这也就是循环队列名字的由来。
在顺序队列中,可以将rear指针指向数组下标为 0 的位置就不会有越界问题了。当我们再向队列中添加元素的时候,rear向后移动。 image.png 在顺序队列中,当rear==front时,我们判断队列为空,但在循环队列中则不适用。
解决办法有两种:

  1. 可以设置一个标志变量flag,当front==rear并且flag=0的时候队列为空,当front==rear并且flag=1的时候队列为满。
  2. 队列为空的时候就是front==rear,队列满的时候,我们保证数组还有一个空闲的位置,rear就指向这个空闲位置。那么现在判断队列是否为满的条件就是:(rear+1) % QueueSize= front

队列的应用

  • 广度优先搜索BFS
  • 请求队列/任务队列/消息队列/...

树Tree

树是由n(n≥0)个有限节点组成一个具有层次关系的集合。把它叫做“树”是因为它看起来像一棵倒挂的树,也就是说它是根朝上,而叶朝下的。它具有以下的特点:

  • 一棵树中的任意两个结点有且仅有唯一的一条路径连通。
  • 一棵树如果有 n 个结点,那么它一定恰好有 n-1 条边。
  • 一棵树不包含回路。
  • 每个节点有零个或多个子节点。
  • 每一个非根节点有且只有一个父节点
  • 除了根节点外,每个子节点可以分为多个不相交的子树

基本概念

  • 节点 :树中的每个元素都可以统称为节点。

  • 根节点 :顶层节点或者说没有父节点的节点。

  • 父节点 :若一个节点含有子节点,则这个节点称为其子节点的父节点。

  • 子节点 :一个节点含有的子树的根节点称为该节点的子节点。

  • 兄弟节点 :具有相同父节点的节点互称为兄弟节点。

  • 叶子节点 :没有子节点的节点。

  • 节点的高度 :该节点到叶子节点的最长路径所包含的边数。

  • 节点的深度 :根节点到该节点的路径所包含的边数

  • 节点的层数 :节点的深度+1。

  • 树的高度 :根节点的高度。

二叉树

二叉树Binary tree是每个节点最多只有两个分支的树结构。二叉树的分支通常被称作“左子树”或“右子树”。并且,二叉树 的分支具有左右次序,不能随意颠倒。
二叉树 的第 i 层至多拥有 2^(i-1) 个节点,深度为 k 的二叉树至多总共有 2^(k+1)-1 个节点(满二叉树的情况),至少有 2^(k) 个节点。

满二叉树

一个二叉树,如果每一个层的结点数都达到最大值,则这个二叉树就是 满二叉树。也就是说,如果一个二叉树的层数为 K,且结点总数是(2^k) -1,则它就是 满二叉树image.png

完全二叉树

除最后一层外,若其余层都是满的,并且最后一层或者是满的,或者是在右边缺少连续若干节点,则这个二叉树就是 完全二叉树 。 image.png

平衡二叉树

平衡二叉树 是一棵二叉排序树,且具有以下性质:

  1. 可以是一棵空树
  2. 如果不是空树,它的左右两个子树的高度差的绝对值不超过 1,并且左右两个子树都是一棵平衡二叉树。 image.png

二叉树存储结构

链式存储

和链表类似,二叉树的链式存储依靠指针将各个节点串联起来,不需要连续的存储空间。 每个节点至少包括三个属性:

  • 数据data
  • 左子树指针left
  • 右子树指针right image.png

顺序存储

使用一组地址连续的存储单元依次自上而下,自左至右存储完全二叉树上的结点元素,即将完全二叉树上编号为i的结点元素存储在一维数组中下标为i的分量中。 image.png

这种存储方式对于满二叉树和完全二叉树是非常合适也是很高效方便的,因为满二叉树和完全二叉树采用顺序存储结构既不浪费空间,也可以根据公式很快地确定结点之间的关系。
但是对于一般的二叉树而言,必须用“虚结点”将一颗二叉树补成一棵完全二叉树来存储,否则无法确定结点之间的关系,这样的话就会造成存储空间的浪费。

二叉树的遍历

  • 先序遍历
  • 中序遍历
  • 后序遍历 image.png
//先序遍历:
void PreOrder(BiTree T) {
    if(T==NULL) exit(0);
    print(T);
    PreOrder(T->left);
    PreOrder(T->right);
}

//中序遍历
void MidOrder(BiTree T) {
    if(T==NULL) exit(0);
    MidOrder(T->left);
    print(T);
    MidOrder(T->right);
}

//后续遍历
void PostOrder(BiTree T) {
    if(T==NULL) exit(0);
    PostOrder(T->left);
    PostOrder(T->right);
    print(T);
}
  • 层次遍历 image.png
void LevelOrder(BiTree T) {
    LinkQueue Q;
    InitQueue(&Q);
    BiTree p;
    EnQueue(&Q, T);     //根节点入队
    while (!IsEmpty(Q)) {
        DeQueue(&Q, &p)     //队头结点出队
        print(p);   //访问出队结点
        if(p->left!=NULL) 
            EnQueue(&Q, p->left);     //左孩子入队
        if(p->right!=NULL) 
            EnQueue(&Q, p->right);       //右孩子入队
    }
}

//数据结构:
typedef struct BiTNode{    //二叉树结点
    ElemType data;
    struct BiTnode *left, *right;
}BiTNode, *BiTree;

typedef struct LinkNode{    //链式队列结点
    BiTNode *data; 
    struct LinkNode *next;
}LinkNode;

typedef struct{
    LinkNode *front, *rear; //队头队尾
}LinkQueue;

线索二叉树

在二叉树的结点上加上线索的二叉树称为线索二叉树,对二叉树以某种遍历方式(如先序、中序、后序或层次等)进行遍历,使其变为线索二叉树的过程称为对二叉树进行线索化。

一个二叉树通过如下的方法“穿起来”:所有原本为空的右(孩子)指针改为指向该节点在中序序列中的后继,所有原本为空的左(孩子)指针改为指向该节点的中序序列的前驱。

// 数据结构
typedef struct BiTNode{     //二叉树结点
    ElemType data;
    struct BiTNode *left, *right;
}BiTNode, *BiTree;

typedef struct ThreadNode{     //线索二叉树结点
    ElemType data;
    struct ThreadNode *left, *right;
    int ltag, rtag; //线索标志,0表示指向孩子,1表示指向线索
}

image.png

image.png

image.png

霍夫曼树

给定N个权值作为N个叶子结点,构造一棵二叉树,若该树的带权路径长度达到最小,称这样的二叉树为哈夫曼树(Huffman Tree),也称为最优二叉树。哈夫曼树是带权路径长度最短的树,权值较大的结点离根较近。
带权路径长度:书中所有叶节点的带权路径长度之和WPL: Weighted Path Length,即:WPL=sumi=1nwiliWPL=sum_{i=1}^{n}w_il_i

image.png

霍夫曼编码

哈夫曼编码是可变字长编码(VLC)的一种,该方法完全依据字符出现概率来构造异字头的平均长度最短的码字。

......

树的存储结构

  • 双亲表示法->顺序存储
  • 孩子表示法->顺序存储+链式存储
  • 孩子兄弟表示法->链式存储

......

树和森林的遍历

    • 先根遍历
    • 后根遍历
    • 层次遍历 image.png image.png
  • 森林
    • 先序遍历:效果等同于依次对各个树进行先根遍历若森林非空 遍历规则:
      1. 访问森林中第一棵树的根节点
      2. 先序遍历第一棵树的根节点的子树森林
      3. 先序遍历除去第一棵树之后的树构成的森林 image.png
    • 中序遍历:效果等同于依次对各个树进行后根遍历若森林非空 遍历规则:
      1. 中序遍历第一棵树的根节点的子树森林
      2. 访问森林中第一棵树的根节点
      3. 中序遍历除去第一棵树之后的树构成的森林 image.png

图Graph

图是一种较为复杂的非线性结构。图的数据结构包含一个有限(可能是可变的)的集合作为节点集合,以及一个无序对(对应无向图)或有序对(对应有向图)的集合作为(有向图中也称作)的集合。节点可以是图结构的一部分,也可以是用整数下标或引用表示的外部实体。图的数据结构还可能包含和每条边相关联的数值(edge value)。

基本概念

  • 顶点:图中的数据元素,我们称之为顶点,图至少有一个顶点(非空有穷集合)

  • :图中的数据元素即顶点之间的关系用边表示。

  • :度表示一个顶点包含多少条边。在有向图中,还分为出度和入度,出度表示从该顶点出去的边的条数,入度表示进入该顶点的边的条数。

  • 无向图:对于一个图,若每条边都是没有方向的,则称该图为无向图。

  • 有向图:对于一个图,若每条边都是有方向的,则称该图为有向图。

  • 无权图:对于一个图,若每条边是没有权重的,或者是每条边权重为同一个正数。

  • 有权图:对于一个图,若每条边都有有权重且不全相同。

  • 简单图:不存在重复边且不存在顶点到自身的边的图。

  • 多重图:两个顶点之间的的边数多于一条,又允许顶点通过一条边与自己关联的图。

  • 连通图:若图中任意两个顶点都是连通的,则称图为连通图。

  • 强连通图:有向图中任意两点v1、v2之间都存在着v1到v2的路径及v2到v1的路径。

  • 完全图:无向图中任意两个顶点 之间都存在边(无向完全图);无向图中任意两个顶点 之间都存在边(有向完全图)。

  • 子图/生成子图/生成树/生成森林/网

图的存储

邻接矩阵

邻接矩阵将图用二维矩阵存储,是一种较为直观的表示方式。
image.png

优点

  1. 邻接矩阵的存储方式简单、直接,可以高效的获取两个顶点的关系
  2. 方便计算任意一顶点的度和求解最短路径(Floyd-Warshall 算法)

缺点

  1. 对于无向图,a[i][j] == a[j][i],我们只需要存储一个就好,在二维数组中,通过对角线可以划分为两部分,我们只要利用其中一部分的空间就可以了,另外一部分则是多余的。
  2. 对于稀疏图,在存储时定点很多和边很少,这样存储会浪费大量存储空间。

邻接表

邻接表存储图的实现方式是,给图中的各个顶点独自建立一个链表,用节点存储该顶点,用链表中其他节点存储各自的临界点。
与此同时,为了便于管理这些链表,通常会将所有链表的头节点存储到数组中(也可以用链表存储)。也正因为各个链表的头节点存储的是各个顶点,因此各链表在存储临界点数据时,仅需存储该邻接顶点位于数组中的位置下标即可。 image.png

优点

使用邻接表存储节省空间,只存储实际存在的边。

缺点

  1. 当计算顶点的度时,就可能需要遍历一个链表。
  2. 对于无向图,如果需要删除一条边,就需要在两个链表上查找并删除。

十字链表

十字链表只用于存储有向图。 image.png

邻接链表

邻接链表只用于存储无向图。 image.png

对比

image.png

BFS && DFS

广度优先遍历

广度优先搜索的具体实现方式用到了线性数据结构——队列 。

image.png

image.png

深度优先遍历

深度优先搜索的具体实现方式用到了线性数据结构——栈

image.png

image.png

图的应用

  • 最小生成树
    • Prime普利姆算法:适用于稀疏树 image.png
    • Kruskal克鲁斯卡尔算法:适用于稠密树 image.png
  • 最短路径
    • Dijkstra迪杰斯特拉算法
    • Floyd弗洛伊德算法
  • 拓扑排序

堆Heap

是一种特别的二叉树,满足以下条件的二叉树,可以称之为

  1. 完全二叉树;
  2. 每一个节点的值都必须 大于等于或者小于等于 其孩子节点的值。

(英语:Heap)是计算机科学中的一种特别的完全二叉树。若是满足以下特性,即可称为堆:“给定堆中任意节点P和C,若P是C的母节点,那么P的值会小于等于(或大于等于)C的值”。若母节点的值恒小于等于子节点的值,此堆称为最小堆(min heap);反之,若母节点的值恒大于等于子节点的值,此堆称为最大堆(max heap)。在堆中最顶端的那一个节点,称作根节点(root node),根节点本身没有母节点(parent node)。 对于堆是完全二叉树这一说法存疑...

堆具有以下的特点:

  • 可以在O(logN)的时间复杂度内向堆中插入元素;
  • 可以在O(logN)的时间复杂度内向堆中删除元素;
  • 可以在O(1)的时间复杂度内获取堆中的最大值或最小值。

堆的分类

堆有两种类型:最大堆和最小堆:

  • 最大堆:堆中每一个节点的值 都大于等于 其孩子节点的值。所以最大堆的特性是 堆顶元素(根节点)是堆中的最大值。
  • 最小堆:堆中每一个节点的值 都小于等于 其孩子节点的值。所以最小堆的特性是 堆顶元素(根节点)是堆中的最小值。

堆的作用

堆的存储

为了方便存储和索引,二叉堆可以用完全二叉树的形式进行存储。由于完全二叉树的优秀性质,利用数组存储二叉树即节省空间,又方便索引:若根结点的序号为1,那么对于树中任意节点i,其左子节点序号为 2*i,右子节点序号为 2*i+1image.png

堆的操作

插入元素

步骤如下:

  1. 将要插入的元素放到最后
  2. 从底向上,如果父结点比该元素大/小,则该节点和父结点交换,直到无法交换

删除堆顶元素

删除堆顶元素后,为了保持堆的性质,需要对堆的结构进行调整,这个过程称之为"堆化",堆化的方法分为两种:

  • 一种是自底向上堆化,元素从最底部向上移动。
  • 一种是自顶向下堆化,元素由最顶部向下移动。

自底向上堆化

  1. 首先删除堆顶元素,使得数组中下标为1的位置空出。
  2. 比较根结点的左子节点和右子节点,也就是下标为2,3的数组元素,将较大/小的元素填充到根结点(下标为1)的位置。
  3. 一直循环比较空出位置的左右子节点,并将较大/小者移至空位,直到堆的最底部。 image.png

采用这种方法,可以有图看出数组中出现了“气泡”,这会导致存储空间的浪费。

自顶向下堆化

  1. 首先删除堆顶元素,使得数组中下标为1的位置空出。
  2. 将最后一个元素移动到堆顶。
  3. 让该元素不停与左右子节点的值进行比较,和较大/小的子节点交换位置,直到无法交换位置。

image.png

堆的应用

  • 堆排序
  • 优先队列

散列表Hash

Hash是根据键而直接访问在内存储存位置的数据结构。也就是说,它通过计算出一个键值的函数,将所需查询的数据映射到表中一个位置来让人访问,这加快了查找速度。这个映射函数称做散列函数,存放记录的数组称做散列表

哈希表的关键思想是使用哈希函数将键映射到存储桶。更确切地说,

  1. 当我们插入一个新的键时,哈希函数将决定该键应该分配到哪个桶中,并将该键存储在相应的桶中;
  2. 当我们想要搜索一个键时,哈希表将使用相同的哈希函数来查找对应的桶,并只在特定的桶中进行搜索。

哈希函数

哈希函数指将哈希表中元素的关键键值映射为元素存储位置的函数哈希函数能够将任意长度的输入值转变成固定长度的值输出,该值称为散列值,输出值通常为字母与数字组合

散列函数(Hash function)又称散列算法、哈希函数,是一种从任何一种数据中创建小的数字“指纹”的方法。散列函数把消息或数据压缩成摘要,使得数据量变小,将数据的格式固定下来。该函数将数据打乱混合,重新创建一个叫做散列值(hash values,hash codes,hash sums,或hashes)的指纹。散列值通常用一个短的随机字母和数字组成的字符串来代表。

哈希函数的应用

  • 哈希表
  • 保护资料
  • 确保传递真实的信息
  • 错误校正
  • 语音识别

哈希冲突

Hash算法并不完美,有可能两个不同的原始值在经过哈希运算后得到同样的结果, 这样造成了哈希碰撞,也叫做哈希冲突。

解决方法

  • 开放寻址法
  • 链式地址法
  • 再哈希