AcWing.算法基础课-第二章 基础数据结构

链表

链表是一种物理存储结构上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。

链表分为两种单链表,双链表,如上图所示。

单链表:单方向,双链表:双向;

单链表

// head存储链表头,e[]存储节点的值,ne[]存储节点的next指针,idx表示当前用到了哪个节点
int head, e[N], ne[N], idx;

// 初始化
void init()
{
    head = -1;
    idx = 0;
}

// 在链表头插入一个数a
void insert(int a)
{
    e[idx] = a, ne[idx] = head, head = idx ++ ;
}

// 将头结点删除,需要保证头结点存在
void remove()
{
    head = ne[head];
}

双链表

// e[]表示节点的值,l[]表示节点的左指针,r[]表示节点的右指针,idx表示当前用到了哪个节点
int e[N], l[N], r[N], idx;

// 初始化
void init()
{
    //0是左端点,1是右端点
    r[0] = 1, l[1] = 0;
    idx = 2;
}

// 在节点a的右边插入一个数x
void insert(int a, int x)
{
    e[idx] = x;
    l[idx] = a, r[idx] = r[a];
    l[r[a]] = idx, r[a] = idx ++ ;
}

// 删除节点a
void remove(int a)
{
    l[r[a]] = l[a];
    r[l[a]] = r[a];
}

        堆栈又名栈(stack),他是计算机科学中最基础的数据结构之一。可以算是一种受限制的线性结构,,具有后进先出(LIFO, Last In First Out)的特性。由于此特性,堆栈常用一维数组和链表等线性结构来实现。

          栈的特点特别简单,先入后出,后入先出。

栈的重要术语:

  •     栈顶:栈中允许进行插入和删除操作的端部。
  •     栈底:与栈顶相对,是不能进行插入和删除操作的端部。
  •     空栈:不含任何元素的栈。

栈的基本操作:

  •     入栈:也称为压栈,是指在栈顶添加新的元素。
  •     出栈:是指移除栈顶的元素,使其相邻的元素成为新的栈顶元素。
  •     查看栈顶元素:此操作允许用户查看栈顶元素而不移除它。

代码实现

数组模拟

// tt表示栈顶
int stk[N], tt = 0;

// 向栈顶插入一个数
stk[ ++ tt] = x;

// 从栈顶弹出一个数
tt -- ;

// 栈顶的值
stk[tt];

// 判断栈是否为空,如果 tt > 0,则表示不为空
if (tt > 0)
{

}

STL应用

stack, 栈
    size() 栈的大小
    empty() 判断栈是否为空
    push()  向栈顶插入一个元素
    top()  返回栈顶元素
    pop()  弹出栈顶元素

例题讲解

思路分析

        这道题就是用栈去维护他的计算顺序,两个栈一个维护他的符号运算,另一个维护需要进行运算的数值。

代码

#include<bits/stdc++.h>
using namespace std;

const int N = 1e6+10;
stack<int> num;
stack<char> op;

void eval(){
    auto b = num.top(); num.pop();
    auto a = num.top(); num.pop();
    auto c = op.top(); op.pop();
    int x; 
    if(c == '+') x = a+b;
    else if(c == '-') x = a-b;
    else if(c == '*') x= a*b;
    else x = a/b;
    num.push(x);
}

int main()
{
    unordered_map<char,int> pr{{'+',1},{'-',1},{'*',2},{'/',2}};
    string str;
    cin >> str;
    for(int i = 0; i < str.size(); i++){
        auto c = str[i];
        if(isdigit(c)){
            int x = 0,j = i;
            while(j < str.size() && isdigit(str[j]))
                x = x*10+(str[j++]-'0');
            i = j-1;
            num.push(x);
        }
        else if(c == '(') op.push(c);
        else if(c == ')'){
            while(op.top() != '(') eval();
            op.pop();
        }
        else {
            while(op.size() && pr[op.top()] >= pr[c]) eval();
            op.push(c);
        }
    }
    while(op.size()) eval();
    cout << num.top() << endl;
    return 0;
}

队列

C++    数据结构队列(Queue)是一种线性表,其特殊之处在于它只允许在表的后端进行插入操作,在表的前端进行删除操作。这种先进先出(FIFO,First In First Out)的结构类似于现实生活中的排队,最早等待的人将最先得到服务。

              队列的特点特别简单,先进先出

  • 单向队列(Queue):只能在一端插入数据,在另一端删除数据。(优化有循环队列)
  • 双向队列(Deque):每一端都可以进行插入数据和删除数据操作。
  • 优先级队列(PriorityQueue):数据项按照关键字排序,关键字最小(或最大)的数据项通常位于队列最前面。(也被称作堆:大根堆和小根堆)

代码实现

单向队列&&循环队列

//普通队列
// hh 表示队头,tt表示队尾
int q[N], hh = 0, tt = -1;

// 向队尾插入一个数
q[ ++ tt] = x;

// 从队头弹出一个数
hh ++ ;

// 队头的值
q[hh];

// 判断队列是否为空,如果 hh <= tt,则表示不为空
if (hh <= tt)
{

}
//循环队列
// hh 表示队头,tt表示队尾的后一个位置
int q[N], hh = 0, tt = 0;

// 向队尾插入一个数
q[tt ++ ] = x;
if (tt == N) tt = 0;

// 从队头弹出一个数
hh ++ ;
if (hh == N) hh = 0;

// 队头的值
q[hh];

// 判断队列是否为空,如果hh != tt,则表示不为空
if (hh != tt)
{

}

STL实现

queue, 队列
    size() //队列大小
    empty() //是否为空
    push()  向队尾插入一个元素
    front()  返回队头元素
    back()  返回队尾元素
    pop()  弹出队头元素

双端队列(STL)

deque, 双端队列
    size() //队列大小
    empty() //判断是否为空
    clear() //清空
    front()/back() //返回队头/队尾元素
    push_back()/pop_back() //添加/删除元素到队头
    push_front()/pop_front() 添加/删除元素到队尾

优先队列(堆)

priority_queue, 优先队列,默认是大根堆
    size() //堆的大小
    empty() //是否为空
    push()  //插入一个元素
    top()  //返回堆顶元素(最大或最小那个)
    pop()  //弹出堆顶元素
    定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;

单调栈&&单调队列

        使用单调栈或单调队列我们可以优化一些算法:如dp中的多重背包问题.....

单调栈

常见模型:找出每个数左边离它最近的比它大/小的数
int tt = 0;
for (int i = 1; i <= n; i ++ )
{
    int x; cin >> x;
    //发现栈顶大于当前这个数,但是我们要找的是小于的所以直接删除
    while (tt && check(stk[tt], x)) tt -- ;//check表示大于或小于当前元素
    stk[ ++ tt] = x;
}
​

单调队列

常见模型:找出滑动窗口中的最大值/最小值
#include<bits/stdc++.h>
using namespace std;

const int N = 1e6+10;
int head = 0,tail = -1,n,m;
int a[N],q[N]; //a存值,q存位置

int main()
{
    scanf("%d%d", &n, &m);
    for(int i = 0; i < n; i++) scanf("%d",&a[i]);
    for(int i = 0; i < n; i++){
        if(head <= tail && i-m+1 > q[head]) head++;
        while(head <= tail && a[q[tail]] >= a[i]) tail--;
        q[++tail] = i;
        if(i >= m-1) cout << a[q[head]] << " ";
    }
    head = 0,tail = -1;
    memset(q,0,sizeof q);
    cout << endl;
    for(int i = 0; i < n; i++){
        if(head <= tail && i-m+1 > q[head]) head++;
        while(head <= tail && a[q[tail]] <= a[i]) tail--;
        q[++tail] = i;
        if(i >= m-1) cout << a[q[head]] << " ";
    }
    return 0;
}

KMP

        KMP算法是一种高效的字符串匹配算法,算法名称取自于三位共同发明人名字的首字母组合。该算法的主要使用场景就是在字符串(也叫主串)中的模式串(也叫字串)定位问题,常见的有“求子串出现的起始位置”、“求子串的出现次数”等。

            KMP算法对朴素匹配算法进行了改进,利用匹配失败时失败之前的已知部分时匹配的这个有效信息,保持主串的 i 指针不回溯,通过修改模式串(子串)的 j 指针,使模式串尽量地移动到有效的匹配位置该算法的时间复杂度为 O(n+m),算法过程示例如下:

        

        简单来说,就是之前已经匹配好的一段,在看后面符不符合,比如ABCD,我当前匹配的ABD不符合我就往前跳指针到有AB的地方,在比较下一个字母C刚好合适,匹配成功。

        比较严谨的解释是,找到最大的前后缀相等的地方。

        所以,我们跳指针的地方用next数组存下来。

​

// s[]是文本串,p[]是模式串,n是s的长度,m是p的长度
//也就是p在s上进行匹配,都是从1开始读入的如cin >> s+1 >> p+1;
//求模式串的Next数组:
for (int i = 2, j = 0; i <= m; i ++ )
{
    while (j && p[i] != p[j + 1]) j = ne[j];
    if (p[i] == p[j + 1]) j ++ ;
    ne[i] = j;
}

// 匹配
for (int i = 1, j = 0; i <= n; i ++ )
{
    while (j && s[i] != p[j + 1]) j = ne[j];
    if (s[i] == p[j + 1]) j ++ ;
    if (j == m)
    {
        j = ne[j];
        // 匹配成功后的逻辑代码
        .....
    }
}
​

Trie树(字典树)

        Trie树,又称字典树、前缀树、单词查找树、键树,是一种多叉树形结构,是一种哈希树的变种。Trie这个术语来自于retrieval,发音为/tri:/ “tree”,也有人读为/traɪ/ “try”。Trie树典型应用是用于快速检索(最长前缀匹配),统计,排序和保存大量的字符串,所以经常被搜索引擎系统用于文本词频统计,搜索提示等场景。它的优点是最大限度地减少无谓的字符串比较,查询效率比较高。

        他在存储时极大节省了重复前缀的情况所以查询操作很快,原理就是把一个字符串里的每个字符用树的形式存储,之后再结束点打上记号,表示单词结束了,由此下一个单词来的时候只需要像存储时遍历,看看最后到的节点是否被打上标记,即这个单词有没有出现过。

int son[N][26], cnt[N], idx;
// 0号点既是根节点,又是空节点
// son[][]存储树中每个节点的子节点
// cnt[]存储以每个节点结尾的单词数量

// 插入一个字符串
void insert(char str[])
{
    int p = 0;
    for (int i = 0; str[i]; i ++ )
    {
        int u = str[i] - 'a';
        if (!son[p][u]) son[p][u] = ++ idx; //如果没有下一个节点创建再赋值
        p = son[p][u]; //到下一个节点
    }
    cnt[p] ++ ; //单词数量++
}

// 查询字符串出现的次数
int query(char str[])
{
    int p = 0;
    for (int i = 0; str[i]; i ++ )
    {
        int u = str[i] - 'a';
        if (!son[p][u]) return 0; //如果没有下一个节点说明单词不存在
        p = son[p][u]; //走到下一个节点
    }
    return cnt[p]; //返回单词数
}

例题讲解

思路分析

        其实就是把每个元素的二进制表示存进去,在对每个数求最大异或值就优化了O(N^2)的做法

大致就是这个图:

                                        root
                          最高位1                      最高位0
                    2高位1      2高位0          2高位1         2高位0
                3高位1  3高位0             3高位1 3高位0   3高位1 3高位0
                                    .......

所以我们只需要每一位走与我当前这个数的这一二进制位相反的路即可求出最大异或对

代码

#include<bits/stdc++.h>
using namespace std;

const int N = 1e7+10;
int n; 
int son[N][2],idx;
int a[N];

void insert(int x){
    int p = 0;
    for(int i = 30; ~i; i--){ //~i即i >= 0
        //其实这两句y总写的比较简略,也比较复杂
        int &s = son[p][x >> i & 1];
        if(!s) s = ++ idx; //创建新节点
        p = s;
        //下面的代码可以完全替换:
        // int u=x>>i&1;   /取X的第i位的二进制数
        // if(!son[p][u]) son[p][u]=++idx; ///如果插入中发现没有该子节点,开出这条路
        // p=son[p][u]; //指针指向下一层
    }
}

int query(int x){
    int res = 0,p = 0;
    for(int i = 30; ~i; i--){
        int s = x >> i & 1;
        if(son[p][!s]){
            res += 1 << i;
            p = son[p][!s]; //累加答案的贡献值,其实就是把异或操作化入这一步了
        }else{
            p = son[p][s];
        }
    }
    return res;
}

int main()
{
    cin >> n;
    for(int i = 0; i < n; i++){
        cin >> a[i];
        insert(a[i]);
    }
    int res = 0;
    for(int i = 0; i < n; i++) res = max(res,query(a[i]));
    cout << res << endl;
    return 0;
}

并查集

        顾名思义,就是对类似集合进行操作,其底层原理是属树,可以很快完成查询和合并操作,对于查询进行优化(路径压缩)那么每个操作的时间复杂度接近O(1).

(1)朴素并查集:

    int p[N]; //存储每个点的祖宗节点

    // 返回x的祖宗节点
    int find(int x)
    {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ ) p[i] = i;

    // 合并a和b所在的两个集合:
    p[find(a)] = find(b);


(2)维护size的并查集:

    int p[N], size[N];
    //p[]存储每个点的祖宗节点, size[]只有祖宗节点的有意义,表示祖宗节点所在集合中的点的数量

    // 返回x的祖宗节点
    int find(int x)
    {
        if (p[x] != x) p[x] = find(p[x]);
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ )
    {
        p[i] = i;
        size[i] = 1;
    }

    // 合并a和b所在的两个集合:
    size[find(b)] += size[find(a)];
    p[find(a)] = find(b);


(3)维护到祖宗节点距离的并查集:

    int p[N], d[N];
    //p[]存储每个点的祖宗节点, d[x]存储x到p[x]的距离

    // 返回x的祖宗节点
    int find(int x)
    {
        if (p[x] != x)
        {
            int u = find(p[x]);
            d[x] += d[p[x]];
            p[x] = u;
        }
        return p[x];
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ )
    {
        p[i] = i;
        d[i] = 0;
    }

    // 合并a和b所在的两个集合:
    p[find(a)] = find(b);
    d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量

例题讲解



思路分析

        这道题是对并查集一个变形题,还有个专门的名词叫做扩展域并查集,这道题我们只需要维护其中吃和被吃的关系就可以查明是否为真话,比如这句话为a和b是同类,但是我们一查发现b和吃a的动物一类,那么a和b就不可以为同类,或者说是a和吃b的动物为同类那么ab也不可能是一类。

        由此我们就明白了我们要维护的东西了,吃和被吃,那么我们可以引进两个虚拟物种n+x(吃x的动物),和2*n+x(被x吃的动物),维护即可。

代码

#include <bits/stdc++.h>
using namespace std;

const int N = 1e6 + 10;

int f[N], ans;

int find(int x) {
    if (f[x] != x)
        return f[x] = find(f[x]);
    return f[x];
}

void merge(int x, int y) {
    x = find(x), y = find(y);
    if (x != y)
        f[x] = y;
}

bool check(int x, int y) { return find(x) == find(y); }

//这里是扩展域并查集,我们引入一下虚拟动物,x/y + n表示吃x和y的动物,x/y+2n表示被x和y吃的动物

int main() {
    int n, k;
    cin >> n >> k;
    for (int i = 1, im = n * 3; i <= im; i++) f[i] = i;
    while (k--) {
        int t, x, y;
        cin >> t >> x >> y;
        if (x > n || y > n)
            ans++;
        else if (t == 1) {
            // x和吃y的动物是同类,那么x,y不可能是同类
            //同理,如果x和被y吃的动物是同类,那么x,y也不可能是同类
            if (check(x, y + n) || check(x, y + 2 * n))
                ans++;
            else {
                merge(x, y);                  // x,y是同类
                merge(x + n, y + n);          //吃x,吃y的动物是同类
                merge(x + 2 * n, y + 2 * n);  //被x,被y吃的动物是同类
            }
        } else if (t == 2) {
            //如果x和y是同类,那么x可能吃y
            //如果x是被y吃的动物,那么x也不可能吃y
            if (check(x, y) || check(x, y + 2 * n))
                ans++;
            else {
                merge(x, y + n);          // x和吃y的动物是同类
                merge(x + n, y + 2 * n);  //吃x的动物和被y吃的动物是同类
                merge(x + 2 * n, y);      //被x吃的动物和y是同类
            }
        }
    }
    cout << ans << endl;
    return 0;
}

        这个用STL的优先队列即可手写太复杂了。

代码实现

数组实现

        

// h[N]存储堆中的值, h[1]是堆顶,x的左儿子是2x, 右儿子是2x + 1
// ph[k]存储第k个插入的点在堆中的位置
// hp[k]存储堆中下标是k的点是第几个插入的
int h[N], ph[N], hp[N], size;

// 交换两个点,及其映射关系
void heap_swap(int a, int b)
{
    swap(ph[hp[a]],ph[hp[b]]);
    swap(hp[a], hp[b]);
    swap(h[a], h[b]);
}

void down(int u)
{
    int t = u;
    if (u * 2 <= size && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1;
    if (u != t)
    {
        heap_swap(u, t);
        down(t);
    }
}

void up(int u)
{
    while (u / 2 && h[u] < h[u / 2])
    {
        heap_swap(u, u / 2);
        u >>= 1;
    }
}

// O(n)建堆
for (int i = n / 2; i; i -- ) down(i);

STL实现

priority_queue, 优先队列,默认是大根堆
    size() //堆的大小
    empty() //是否为空
    push()  //插入一个元素
    top()  //返回堆顶元素(最大或最小那个)
    pop()  //弹出堆顶元素
    定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;


​

Hash表

基本概念

为什么需要哈希表?

静态查找表与动态查找表中,为了查找某关键字值等于某个值的记录,都要经过一系列的关键字进行比较,以确定待查记录的储存位置或查找失败,查找的时间总是与比较次数有关


什么是哈希表?

哈希表,也叫散列表,英文Hash table,是根据关键码值而直接进行访问的数据结构。
哈希表的基本思想

    将记录的存储位置与它的关键字之间建立一个确定的关系H,使每个关键字和唯一的存储位置对应。而这种关系H就是该哈希表的一个哈希函数。
    在查找时,只需要根据对应关系计算出给定的关键字值H(k),就可以得到记录的存储位置。这样,不经过比较,一次存取就能得到所查元素的查找方法。

哈希表相关术语

    哈希函数:在记录的关键字与记录的存储地址之间建立的一种对应关系。
    冲突: 若关键字不同而函数值相同,则称这两个关键字为“同义词”,并称这种现象为冲突。
    哈希查找:利用哈希函数进行查找的过程。

哈希表性质

    哈希表实际上是以空间换取时间,它的查找的时间效率一般比其它方法高,但消耗空间资源
    冲突一般不可避免,发生冲突的次数与表的装填程度呈正相关
    哈希函数相同的情况下,处理冲突的方法不同,所得哈希表的平均查找长度也不同
    线性探测再散列处理冲突容易造成记录的“二次聚集”,即使得本不是同义词的关键字又产生新的冲突
    对开放定址处理冲突的哈希表而言,表长必须≥记录数
    链地址处理冲突的哈希表不要求表长必须≥记录数,它的平均查找长度主要取决于哈希函数本身

构造Hash函数

我们构造hash函数判断好不好就看他hash冲突的概率高不高,尽量减少hash冲突,这需要很复杂的数学知识。

代码实现

一般的数字hash

(1) 拉链法
    int h[N], e[N], ne[N], idx;

    // 向哈希表中插入一个数
    void insert(int x)
    {
        int k = (x % N + N) % N;
        e[idx] = x;
        ne[idx] = h[k];
        h[k] = idx ++ ;
    }

    // 在哈希表中查询某个数是否存在
    bool find(int x)
    {
        int k = (x % N + N) % N;
        for (int i = h[k]; i != -1; i = ne[i])
            if (e[i] == x)
                return true;

        return false;
    }

(2) 开放寻址法
    int h[N];

    // 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
    int find(int x)
    {
        int t = (x % N + N) % N;
        while (h[t] != null && h[t] != x)
        {
            t ++ ;
            if (t == N) t = 0;
        }
        return t;
    }

字符串hash

核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果

typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64

// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ )
{
    h[i] = h[i - 1] * P + str[i];
    p[i] = p[i - 1] * P;
}

// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
    return h[r] - h[l - 1] * p[r - l + 1];
}

  • 20
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值