基础数据结构

前言

基础数据结构是非常基础的数据结构(?),它们非常基础、功能简单、效率(相比高级数据结构)较为低下,甚至也没有高级数据结构那么唬人(被机房的神犇吊打惯了)。但它们仍然是程序开始追求效率的基石,是许多算法、高级数据结构的必需品,所以学习并完全掌握它们是非常必要的。
本文主要介绍七种基础数据结构:数组、链表、栈、队列、哈希表、树、堆。
需要图解帮助理解的请移步 visualgo.net

数组

数组是最简单的数据结构(当然也是 C++ 基本语法之一)。它可以在一段连续的空间中连续存储多个元素,并方便地访问它们。
数组的定义和使用非常简单:

#define N 1000000
int a[N]; //定义数组
a[3]; //访问数组中的元素

一些注意事项:
一、数组的下标是从 0 开始的,也就是说,对于上面定义的数组,其下标范围是 0999999 的;
二、……(好像没了);
数组还是有很多优点的:
一、按照索引查找、修改元素快且方便,时间复杂度 Θ(1)
二、多个元素之间的排列直观;
三、好写甚至不用特地写;

不过缺点也有很多:
一、在数组中间添加新的元素慢,比如将数组 {1,2,3} 变为 {1,4,2,3},需要将 23 向后移动一位(从后往前一个一个移动),再将原来 2 的位置改为 4
写成代码是这样的:

int a[100004] = {1, 2, 3};
// 在 1 和 2 之间插入 4
for (int i = 2; i >= 1; --i) a[i + 1] = a[i];
a[1] = 4;

不难发现这玩意是 Θ(n) 的,跑得巨慢,与下面要讲的链表完全没法比。

二、无法扩容(这很明显,同时和 Python 的 list 形成明显对比)
针对无法扩容这个问题,C++ 封装了 vector(你可以理解为可变数组),它充分解决了空间问题,与此同时它也带来了时间问题。
我认为我们有必要好好理解一下 vector 的扩容机制:vector 除了存储当前容器里的元素数量(就是 vector<typename>::size() 返回那玩意)之外,还会存储一个“临界值”(可以用 vector<typename>::capacity() 获取);当当前容器里的元素数量来到临界值的一半的时候,vector 会生成一个临界值为原来容器 2 倍的新容器,然后把原容器中的所有的元素拷贝到新容器来,最后销毁掉原来的容器(数量减少到容量的 12 同理)。而拷贝的复杂度最坏是可以到 Θ(n) 的,所以你明白为什么 Python 的 list 同样很慢的原因了吗?
当然 vector 的扩容也没有那么可怕。准确来说,vector 的扩容是最坏 O(n),均摊 O(1) 的,所以把不准数据量的时候还是可以放心大胆地用 vector 的!(如果你有闲心的话也可以尝试通过指针自己手写一个 vector,反正本蒟蒻写过)

说回正题。数组作为最简单、最底层的数据结构(甚至本蒟蒻之前都不认为数组可以称为数据结构),其用处之广不必多说(你几乎在 OI 的任何级别的赛事中见不到不用数组或依赖数组的其他数据结构的题)。数组是其他几乎所有数据结构的底层,非常重要但不难,想切洛谷红题的同志要好好掌握。

链表

链表几乎可以说是数组的反面。链表通过额外存储每个元素的相邻(视需求)元素的指针(或位置),从而达到快速插入和删除的目的(时间复杂度 Θ(1) )。但作为代价,它访问特定元素的复杂度是 O(n) 的;同时,与数组对应的,链表中元素的排列方式并不直观。
链表最标准的实现是用指针,使用指针就不用初始化容量,即支持动态扩容:

struct Node {
    int val; //存放你想放的东西
    Node *pre, *nxt; //pre 表示当前元素的前一个元素的指针,nxt 表示当前元素的后一个元素的指针
};
Node *head = nullptr;

不过追求编码速度的 OIer 也可以使用大静态数组 + 下标的形式代替,这样仍然是限制容量的,不支持动态扩容(你要把 lis 写成 vector<int>lis 我没意见,看内存有没有意见就对了):

struct Node {
    int val;
    int pre, nxt;
} lis[N];
int cnt; //内存池,想添加新节点的时候 ++cnt 即可

以下有关链表的示例代码采用第二种实现。
链表的插入:

// 在 cur 的后面插入 val
void ins(int cur, int val) {
    ++cnt;
    lis[cnt].val = val;
    lis[cnt].nxt = lis[cur].nxt;
    lis[cnt].pre = cur;
    lis[lis[cur].nxt].pre = cnt;
    lis[cur].nxt = cnt;
}

链表的删除:

// 将 lis[cur] 删除
void del(int cur) {
    if (cur != 0) {
        s[s[cur].pre].nxt = s[cur].nxt;
        s[s[cur].nxt].pre = s[cur].pre;
    }
}

在规模小,需要频繁插入、删除元素时很好用。链式前向星存图必备前置知识点。
C++ 封装了 listforward_list( C++ 11 ),但不好用就是了。

栈,又名先进后出表,顾名思义,先进表的元素后出表。常用于拆递归(部分 Windows 电脑评测不开栈时要用,也可以用来卡常)。
实现(理论是要用指针的,作者是 OIer 作者觉得那样太麻烦,所以作者一半用大数组):

class Stack {
    private:
    int a[N], cnt;
    public:
    bool empty() { return cnt == 0; }
    int size() { return cnt; }
    void push(int x) { a[++cnt] = x; }
    void pop() { if (!empty()) --cnt; }
    int top() { return a[cnt]; }
};

当然可以把 int 换成你想维护的类型。
C++ 封装了 stack 作为栈,一半足够使用了。不过考虑到其基于 deque,被卡的时候还是自己手写比较好(又不难写对吧)。

队列

队列,又名先进先出表,栈的难兄难弟。顾名思义,先进表的元素先出来。常用于 BFS,多线程等。
实现:

class Queue {
    private:
    int a[N], head, tail;
    Queue() { head = 0, tail = 1; } //构造函数
    public:
    bool empty() { return tail - head == 1; }
    int size() { return tail - head; }
    void push(int x) { a[tail++] = x; }
    void pop() { if (!empty()) ++head; }
    int front() { return a[head]; }
}

C++ 封装了 queue 作为队列,一般足够使用了。尽管其同样基于 deque,但由于手写队列确实比手写栈难多了(上面的写法有大量空间被浪费),所以一般没有出题人会特地卡 queue,放心大胆地用。

哈希表

哈希表,又名散列表,是根据键 (key) 和值 (value) 直接访问的数据结构,通过建立键和值的映射来找到集合中的对应元素。
注意:使用哈希表时要谨慎构造哈希函数防止哈希冲突!
哈希表是基于链表的。实现:

const int SIZE = 1000000;
const int M = 999997;

struct HashTable {
    struct Node {
        int next, value, key;
    } data[SIZE];
    int head[M], size;
    int f(int key) { return (key % M + M) % M; }
    int get(int key) {
        for (int p = head[f(key)]; p; p = data[p].next)
            if (data[p].key == key) return data[p].value;
        return -1;
    }
    int modify(int key, int value) {
        for (int p = head[f(key)]; p; p = data[p].next)
            if (data[p].key == key) return data[p].value = value;
    }
    int add(int key, int value) {
        if (get(key) != -1) return -1;
        data[++size] = (Node){head[f(key)], value, key};
        head[f(key)] = size;
        return value;
    }
};

C++ 封装了 unordered_map 作为哈希表,这玩意效率还不错,就是构造起来有点麻烦(要自己构造哈希函数防 hack )。哈希表的另一种替代品是 map,不过 map 是基于红黑树(一种平衡树)的,每步操作复杂度是 Θ(logn) 的,优点是不用考虑哈希冲突。

几乎一切高级数据结构的基础,它的拓展包括但不限于各种线段树,trie 树,平衡树,并查集等。
树的存储与它的超集图相同,采用邻接表、链式前向星等方式存储。其中邻接表最好写,用 vector e[i] 记录以 i 为起点的所有边的终点即可。带权图给边定义一个类即可:

struct Edge {
    int to, val;
};
vector <Edge> tree[N];

对于树的一个特殊形式,也是线段树、平衡树等高级数据结构的本质——二叉树,可以采用一种更直接的方式——存储每个节点的左儿子和右儿子即可:

struct Node {
    int ls, rs;
    int val; // 你想维护的值
};

树有很多优美的性质,二叉树更是堪称数据结构里「纯美」的化身:譬如形态平衡的二叉树的层数一定是 log2n 层的(其中 n 表示节点数量);因此维护二叉树的平衡性就成了各种平衡树的主要任务,这里按下不表。

(不加限定的堆往往都指二叉堆,以下的“堆”也指二叉堆)
堆是一种特殊的二叉树,它通过维护节点与其左右儿子的权值的大小关系做到有序,特殊在它通过数组实现了树(尽管这种树只支持维护节点权值的大小)。
堆的标准定义是:堆是一棵树,其每个节点都有一个键值,且每个节点的键值都大于等于/小于等于其父亲的键值。
堆的手写实现(以小根堆,即父亲的权值小于儿子的权值的堆为例):

int a[N], tot;
void ins(int val) {
    a[++tot] = val;
    for (int t = tot; t != 1; t >>= 1)
        if (a[t >> 1] > a[t])
            swap(a[t >> 1], a[t]);
        else
            break;
}
void del() {
    a[1] = a[tot--];
    for (int i = 1, son;; i = son) {
        son = i << 1;
        if (son < tot && a[son] > a[son + 1])
            son++;
        if (son <= tot && a[i] > a[son])
            swap(a[i], a[son]);
        else
            break;
    }
}

堆的优点几乎都来自于二叉树「纯美」的性质:它一定只有 log2n 层;它可以在 Θ(logn) 的复杂度下维护节点间的大小关系,即:每次插入是 Θ(logn) 的,查询最值是 Θ(1) 的,删除是 Θ(logn) 的。
堆的使用小技巧:懒删除。传统的堆只能删除堆顶,而通过给堆打标记来记录某个节点是否被删除,在查询最值时遇到了被标记的点再删除,可以大大减小删除的时间开销(毕竟删除操作好像是堆的所有操作里时间开销最大的)。懒标记的思想在线段树里会有更充分的运用,甚至堪称线段树的生命(为什么我后半段老是跑题);
C++ 封装了 priority_queue 作为堆,默认是大根堆,可以通过给类定义比较运算符或者在模板参数列表中定义更多参数来实现小根堆,例如:

struct Pair {
    int a, b;
    bool operator < (const Pair& _) const { return a == _.a ? b < _.b : a < _.a; }
};
priority_queue <Pair, vector<Pair>, greater<Pair> > heap;
// 可以实现对 Pair 类按照以 a 为第一关键字,b 为第二关键字建立小根堆

还可以用 make_heap, push_heap, pop_heap 来直接将数组按照堆的形式维护,不过容易写错且相比 priority_queue 优势不大,所以 priority_queue 完全够用。

结语

这是蒟蒻的第一篇博客,很多地方都是不知所言地就写过去了,也可能有错误,欢迎神犇指正!
弱弱地说一句:今天是蒟蒻的阴历生日,看到这篇博客的神犇可以祝蒟蒻生日快乐吗?

posted @   qazsedcrfvgyhnujijn  阅读(21)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
点击右上角即可分享
微信分享提示