「Algorithm」02 数据结构

  • 链表与邻接表:树与图的存储
  • 栈与队列:单调队列、单调栈
  • kmp
  • Trie
  • 并查集
  • Hash表

链表与邻接表:树与图的存储

用数组来模拟这些结构,不适用struct和STL。目的是为了提高效率

struct Node
{
		int val;
		Node * next;
};

Node * p = new Node();

面试题较多,但是new一个节点是比较慢的,做题不需要。

用数组模拟单链表

最常用的邻接表,存储图和树。它们都是用邻接表存储的。

Untitled

以上是用数组e[N]和数组ne[N]来模拟单链表,如果是不存在,那么取-1。

#include<iostream>

using namespace std; 

const int N = 1e5 + 10;

// head 表示头结点的下标
// e[i]是节点i的值
// ne[i] 是节点i的next指针
// idx是一个指针,指向当前用到的点。
int head, e[N], ne[N], idx;

void init()
{
    head = -1;
    idx = 0;    
}

//将x插入到头结点
void add_to_head(int x)
{
    e[idx] = x, ne[idx] = head, head = idx, idx ++;
}

//将x插入到下标是k的点的后面
void add(int k, int x)
{
    e[idx] = x, ne[idx] = ne[k], ne[k] = idx, idx ++;   
}

//删除k后面的节点
void remove(int k)
{
    ne[k] = ne[ne[k]];
}

int main()
{
    int m;
    cin >> m;
    
    init();
    while ( m --)
    {
        int k, x;
        char op;
        
        cin >> op;
        if (op == 'H')
        {
            cin >> x;
            add_to_head(x);
        }
        else if( op ==  'D')
        {
            cin >> k;
            if (k == 0) head = ne[head];
            else remove(k - 1);
        }else {
            cin >> k >> x;
            add(k - 1, x);
        }
    }
    
    for (int i = head; i != -1; i = ne[i])
        cout << e[i] << " ";
    
 
    return 0;   
}

用数组模拟双链表

用来优化某些问题。

int l[N], r[N];,让0是head,1是tail

Untitled

#include<iostream>

using namespace std;

const int N = 100010;

int m;
int e[N], l[N], r[N], idx;

void init()
{
    //0表示左端点,1表示右端点
    r[0] = 1;
    l[1] = 0;
    idx = 2;
}

void add(int k, int x)
{
    //在k的右边插入x
    e[idx] = x;
    r[idx] = r[k];
    l[idx] = k;
    
    l[r[k]] = idx;
    r[k] = idx;

    idx ++;
    //如果想插入左边,只要add(l[k], x)
}

void remove(int k)
{
    r[l[k]] = r[k];
    l[r[k]] = l[k];
}

int main() 
{
    cin >> m;
    
    
    
}

邻接表,就是n个单链表.

栈与队列

栈就是先进后出。

#include<iostream>

using namespace std;

const int N = 100010;

int stk[N], tt;

int main()
{
    初始化
    //tt = -1; 
    
    入栈
    //stk[ ++ tt] = x;
    
    出栈
    // return stk[tt --];
    
    空
    //if (tt >= 0) return true;
    //else return false;
    
    栈顶
    //stk[tt];
}

实际上,令tt = 0初始化,更简洁。

判空时,只要 if (tt > 0) return true; // if(tt) return true;
					else return false;

队列:


int q[N], hh = 0, tt = -1; //hh是队头, tt是队尾。队尾插入,队头弹出

//插入
q[ ++ tt] = x;

//弹出:
return q[hh ++];

if( hh <= tt) not empty;
else empty;

取出队头队尾元素:
return q[hh];
return q[tt];

考验记忆力和毅力(自制力)。

单调栈:

给定一个序列,求序列中的每一个数,离他最近的左边比他小的数。否则为-1。

我们设置一个栈,对于每个x,之前的元素,应该让stk[tt] ≥ x的都出栈。

这样x再入栈,就是一个单调递增的栈序列。而找到最小的,只要找到x入栈前的栈顶即可。

#include<iostream>

using namespace std;

const int N = 1e5 + 10;

int n;
int stk[N], tt;

int main()
{
    cin >> n;
    
    for(int i = 0; i < n; ++ i)
    {
        int x;
        cin >> x;
        while(tt && stk[tt] >= x) tt --;
        if (tt) cout << stk[tt] << " ";
        else cout << -1 << " ";
        
        stk[++tt] = x;
    }
    
    
    return 0;
}
//这个算法,每个元素都最多入栈和出栈一次,所以复杂度是O(n)

单调队列

https://www.acwing.com/problem/content/156/滑动窗口

一个序列,在一个窗口内,算出其最大值和最小值,窗口逐渐向右移动。

在一个窗口内,如果右边的元素比左边的元素小,

  1. 首先考虑暴力怎么做。
  2. 然后考虑在窗口移动的时候,怎样删除一些无用的元素。

如果不开O2优化,那么数组比STL快一些。

O3优化

#pragma GCC optimize(2)

Untitled

#include<iostream>

using namespace std;

const int N = 1e6 + 10;

int a[N], q[N];
//q是下标,也就是队列,a只是记录的值a[q[i]]是取下标的值。
int n, k;

int main()
{
    cin >> n >> k;
    for(int i = 0; i < n; ++ i)
    {
        scanf("%d", &a[i]);
    }
    
    
    int hh = 0, tt = -1;
    for(int i = 0; i < n; i ++)
    {
        //判断队头是否已经出窗口
        if (hh <= tt && i - k + 1 > q[hh])
        {
            ++ hh;
        }
        
        //形成一个单调递增的序列。
        while(hh <= tt && a[q[tt]] >= a[i])
            tt --;
        q[++tt] = i;
        if (i >= k - 1) printf("%d ", a[q[hh]]);
    }
    
    puts("");
    hh = 0, tt = -1;
    for(int i = 0; i < n; ++ i)
    {
        if( hh <= tt && i - q[hh] + 1 > k)
            ++ hh;
        while( hh <= tt && a[q[tt]] <= a[i])
            tt --;
         q[++tt] = i;
        if (i >= k - 1) printf("%d ", a[q[hh]]);
       
    }   
}

KMP

  1. 暴力算法怎么做?
  2. 如何去优化。

朴素算法:

S[N]→ p[M] for(int i = 1; i ≤ n; ++i)

bool flag = true;

int t = i;

for(int j = 1; j ≤ m; j++, t++)

if (s[t] ≠ p[j])

{

flag = false;

break;

}

KMP解释(以1开始)

next数组表示,next[i] = j,表示p[1, j] = p[ i - j + 1, i]

Untitled

Untitled

Untitled

#include<iostream>
#include<cstdio>

using namespace std;

const int N = 100010, M = 1000010;

int n, m;
char p[N], s[M];
int ne[N];

int main() {
    cin >> n >> p + 1 >> m >> s + 1; 
    //index from 1    
    
    // 求Next
    for(int i = 2, j = 0; i <= n; ++ i)
    {
//每次都比较 j+1与i,到最后之前,可以保证1-j已经是匹配的。除非j = 0
        while(j && p[i] != p[ j + 1]) j = ne[j];
        if(p[i] == p[j+1]) j++;
        ne[i] = j;
        //前j个(1-j)的元素与[i-j +1, i]的元素匹配,所以ne[i] = j
    }
    
    
    for(int i = 1, j = 0; i <=m ; ++ i)
    {
        while(j && s[i] != p[ j + 1]) j = ne[j];
        
        if( s[i] == p[j+1]) j++;
        if(j == n)//前n个元素已经匹配
        {
            //匹配成功
            printf("%d ", i - n );
            j = ne[j];
        }
    }
    
    
    return 0;
}

Trie树

高效地存储和查找字符串,集合的数据结构

比如:

abcdef abdef aced bcdf bcff cdaa dcdc

当数量过大,可以使用二进制来表示。比如考虑汉字,直接找其二进制100000101010,每一步仅考虑1和0.

Untitled

#include<iostream>
#include<cstdio>

using namespace std;

const int N = 100010;

int son[N][26], cnt[N], idx;
//下标是0的点,既是根节点,又是空节点
char str[N];

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];
}

int main()
{
    int n;
    cin >> n;
    while(n--)
    {
        char op[2];
        cin >> op >> str;
        if (op[0] == 'I') insert(str);
        else cout << query(str) << endl;
    }
    return 0;
}

并查集

面试和比赛,非常容易出的数据结构。

用来快速的处理:

  1. 将两个集合合并
  2. 询问两个元素是否在一个集合当中

belong[x] = a 存储x属于集合a。

近乎O(1)的支持上面的两个操作。

Untitled

基本原理:每个集合用一棵树来表示,树根的编号就是整个集合的编号,每个节点存储它的父节点,p[x] 表示父节点。

  • 问题1: 如何判断树根 p[x] == x, x就是集合的编号
  • 问题2:如何求x的集合编号,while(p[x] ! = x) x = p[x]
  • 问题3:如何合并两个集合:假设px是x的集合的编号,py是y的集合的编号,那么p[x] = y

Untitled

并查集的优化:

  • (路径压缩), 每次查询其父节点,只要直接指向其祖宗节点。

合并集合

https://www.acwing.com/problem/content/838/

#include<iostream>
#include<cstdio>

using namespace std;

const int N = 1e5 + 10;

int p[N];
int n, m;

int find(int x) //返回x的祖宗节点,加上路径压缩
{
    if(p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    cin >> n >> m;
    
    for(int i = 1; i <=n ; ++ i) p[i] = i;
    
    while(m --)
    {
        char op[2];
//建议读入一个字符,也要使用字符串,自动过滤空格和回车
        int a, b;
        cin >> op >> a >> b;
        if(op[0] == 'M') p[find(a)] = find(b);
        else {
            if(find(a) == find(b)) puts("Yes");
            else puts("No");
        }
    }
    
}

连通块中点的数量https://www.acwing.com/problem/content/839/

#include<iostream>
#include<cstdio>

using namespace std;

const int N = 1e5 + 10;

//如何查询每个联通块的数量?维护根节点的size即可。
int p[N], siz[N];
int n, m;

int find(int x) //返回x的祖宗节点,加上路径压缩
{
    if(p[x] != x) p[x] = find(p[x]);
    return p[x];
}

int main()
{
    cin >> n >> m;
    
    for(int i = 1; i <=n ; ++ i) {
        p[i] = i;
        siz[i] = 1;
    }
    
    while(m --)
    {
        char op[5];
//建议读入一个字符,也要使用字符串,自动过滤空格和回车
        int a, b;
        cin >> op;
        if(op[0] == 'C')
        {
            cin >> a >> b;
            if(find(a) != find(b))
                siz[find(b)] += siz[find(a)];
            p[find(a)] = find(b);
            
        } else if (op[1] == '1')
        {
            cin >> a >> b;
            if(find(a) == find(b)) puts("Yes");
            else puts("No");
        } else
        {
            cin >> a;
            cout << siz[find(a)] << endl;
        }
        
    }
    
}

如何手写一个堆?

  1. 插入一个数
  2. 求集合当中的最小值
  3. 删除最小值
  4. 删除任意一个元素
  5. 修改任意一个元素

堆的基本结构:

是一棵二叉树。是一棵完全二叉树。

以小顶堆为例,每个节点的左右子节点都大于该节点的值。

使用一个一维数组存放堆。

1号点是根节点,x的左儿子是2x,右子节点是2x+1。

操作:

  • down(x),往下调整x。每次找这个节点和两个子节点的最小值,然后交换即可。
  • up(x),往上调整x。每次往上和父节点比较,需要交换时交换即可。

怎么插入x?

  • heap[ ++ idx] = x; up(x);

最小值:

  • heap[1]

删除最小值:

  • heap[1] = heap[idx]; idx—; down(1);

删除任意元素

  • heap[k] = heap[size], size—; down(k); up(k);

修改元素:

  • heap[k] = x; down(k); up(k);
#include<iostream>
#include<algorithm>

using namespace std;

const int N = 100010;

int n, m;
int h[N], idx;

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

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

int main()
{
    cin >> n >> m;
    
    for(int i = 1; i <= n; ++ i) scanf("%d", &h[i]);
    idx = n;
    
    for(int i = n/2; i ; -- i) down(i);
    /*这是建立堆的过程
    为什么从n/2开始down?
    n/2的子孩子是最后一个元素,是完全二叉树的倒数第二层,所以这样一直递归
    的做就好,而且时间复杂度是O(n);
    */
    
    while(m--)
    {
        printf("%d ", h[1]);
        h[1] = h[idx];
        idx --;
        down(1);
    }

    return 0;
}

带映射版的堆操作:

https://www.acwing.com/problem/content/841/

#include<iostream>
#include<algorithm>
#include<cstring>

using namespace std;

const int N = 100010;

int n;
int h[N], ph[N], hp[N], idx;
//ph存放插入的第k个元素在堆的下标位置
//hp存放的是堆中第j个元素在ph数组的位置索引。
//ph和hp是互为反函数。

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 <= idx && h[u * 2] < h[t]) t = u * 2;
    if (u * 2 + 1 <= idx && 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/2 ] > h[u])
    {
        heap_swap(u/2, u);
        u/=2;
    }   
}

int main()
{
    cin >> n;
    int m = 0;
    //m是第m个插入的元素。
    while(n --)
    {
        char op[10];
        int k, x;
        cin >> op;
        if (!strcmp(op, "I"))
        {
            cin >> x;
            idx ++;
            m ++;
            ph[m] = idx;
            hp[idx] = m;
            h[idx] = x;
            up(idx);
        }
        else if (!strcmp(op, "PM")) cout << h[1] << endl;
        else if (!strcmp(op, "DM"))
        {
            heap_swap(1, idx);
            idx --;
            down(1);
        }
        else if(!strcmp(op, "D"))
        {
            cin >> k;
            k = ph[k];
            heap_swap(k, idx);
            idx --;
            down(k), up(k);
        } else 
        {
            cin >> k >> x;
            k = ph[k];
            h[k] = x;
            down(k), up(k);
        }
    }

    return 0;
}

find()和cnt()可以改成,get()和cnt()保证不编译错误,compile ERROR。

Hash表

  • 哈希表的存储结构,认为是O(1). 如果要删除,只要开个数组标记一下。

    • 开放寻址法

    • 拉链法

      Untitled

    常见情景: 把0-10^9的数映射到0-10^5的数组。

    x mod 10 ^ 5 附近的质数。然后处理冲突。100003.

    和离散化比较,离散是需要保序的。

  • 常用的字符串的哈希方式。

//拉链法
#include<iostream>
#include<cstring>//有memset

using namespace std;

const int N = 100003;
//取大于10万的最小的质数。

int h[N], e[N], ne[N], idx;

void insert(int x)
{
    int k = (x % N + N) % N; //因为有的数是负数。
    
    e[idx] = x;
    //e存值
    ne[idx] = h[k];
    //ne存指针
    h[k] = idx;
    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;
}

int main(){
    int n;
    
    memset(h, -1, sizeof h);
    
    cin >> n;
    while(n --)
    {
        char op[2];
        int x;
        cin >> op >> x;
        
        if(op[0] == 'I') insert(x);
        else {
            if (find(x)) puts("Yes");
            else puts("No");
        }
    }
        
    
    return 0;
}

Untitled

//开放寻址法
#include<iostream>
#include<cstring>

using namespace std;

//0xf3f3f3f3是10^9级别,很大,用来初始化数组
const int N = 2e5 + 3, null = 0x3f3f3f3f;
//一般取三倍。
//取质数。
int h[N];

int find(int x)
{
    int k = (x % N + N) % N;
 
    while(h[k] != null && h[k] != x)
    {
        k ++;
        if(k == N) k = 0;
    }
    //这个过程一定会停止的,因为总共只有1e5个数字,而N是二倍,所以不担心满;
    //结束条件时,h[k] == null 或 h[k] == x
    return k;
}

int main(){
    int n;
    cin >> n;
    
    memset(h, 0x3f, sizeof h);
    
    while(n --)
    {
        char op[2];
        int x;
        cin >> op >> x;
        
        int k = find(x);
        if (op[0] == 'I')
        {
            h[k] = x;
        } 
        else
        {
            if (h[k] != null) puts("Yes");
            else puts("No");
        }
        
    }
    
    
    return 0;
}

字符串前缀哈希法。

str = “ABCDEF” h[1] = “A” // 前1个 h[2] = “AB”

即处理前缀的hash,h[0] = 0

如果对字符串进行哈希?当成p进制数,a * p ^ 3 + b * p * 2 ……;再取模Q。

不能把字母映射成0.

Untitled

那么前缀哈希值有什么用呢?因为我们已经知道 h[L]到h[R]的哈希值

所以求L - R的哈希值,只要 h[R] - h[L - 1] * p ^ {R-L+1};

#include<iostream>
#include<cstring>

using namespace std;

typedef unsigned long long ULL;

const int N = 100010, P = 131; 

int n, m;
char str[N];
ULL h[N], p[N];
//p是存放P的n次方

ULL get(int l, int r)
{
    return h[r] - h[l - 1] * p[r - l + 1];
}

int main(){
    
    cin >> n >> m >> str + 1;
    
    p[0] = 1;
    for(int i = 1; i <= n; ++ i)
    {
        p[i] = p[i - 1] * P;
        h[i] = h[i - 1] * P + str[i];
    }
    
    while( m --)
    {
        int l1, r1, l2, r2;   
        cin >> l1 >> r1 >> l2 >> r2;
        if(get(l1, r1) == get(l2, r2))
        {
            puts("Yes");   
        } else {
            puts("No");   
        }
        
    }
    
    return 0;
}

hash算法求两个字符串相同比KMP更好

KMP更适合循环节。

C++STL使用

vector 可变长数组,倍增的思想
string 处理字符串的利器,substr(), c_str()
queue, 队列,进行push(),front(), pop()
priority_queue,优先队列,堆,push(), top(), pop()
stack 栈,Push(), top(), pop()

deque 双端队列

set, map, multiset, multimap 基于平衡二叉树(红黑树)动态维护有序序列
unordered_set, unordered_map, unordered_multiset, unordered_multimap 哈希表
bitset;压位

list用的不多。

vector:

#include<iostream>
#include<cstdio>
#include<cstring>
#include<algorithm>
#include<vector>

using namespace std;

int main()
{
    vector<int> a(10, 3);
    vector<int> b[10]; //定义数组
    //定义一个长度为10的,初始值为3的vector
    //for(auto x : a) cout << x << endl;
    
    a.size(); //O(1)
    a.empty(); 
    
    a.clear();//清空
    /*系统分配空间时,所需时间与空间大小无关,与申请次数有关!!!
    所以尽量减少申请空间的次数。
    vector最初分配32的空间,然后如果不够,分配64,然后copy过去之前的元素。
    申请长度为2 ^ n的数组,那么需要copy(1 + 2 + 2^n-1) = 2^n可以认为是O(1)
    申请空间的次数是log(2^n) = n
    */
    
    a.front();
    a.back();
    a.push_back();
    a.pop_back();
    begin();
    end();
    [];
    
		a < b可以判断两个vector的大小,按字典序比。
    return 0;
}

下面是模板

C++ STL简介
vector, 变长数组,倍增的思想
    size()  返回元素个数
    empty()  返回是否为空
    clear()  清空
    front()/back()
    push_back()/pop_back()
    begin()/end()
    []
    支持比较运算,按字典序

pair<int, int>p;
    first, 第一个元素
    second, 第二个元素
    支持比较运算,以first为第一关键字,以second为第二关键字(字典序)
		p = make_pair(10, 2);
		p = {10, 2};
		pair<int, pair<int, int>>p;存三个

string,字符串
    size()/length()  返回字符串长度
    empty()
    clear()
    substr(起始下标,(子串长度))  返回子串
    c_str()  返回字符串所在字符数组的起始地址
				用于用printf输出
		支持加运算:
				string a = "1";
				a += "bcd";

queue, 队列
    size()
    empty()
    push()  向队尾插入一个元素
    front()  返回队头元素
    back()  返回队尾元素
    pop()  弹出队头元素

priority_queue, 优先队列,默认是大顶堆
    size()
    empty()
    push()  插入一个元素
    top()  返回堆顶元素
    pop()  弹出堆顶元素
    定义成小顶堆的方式:
				priority_queue<int, vector<int>, greater<int>> q;
		
		priority_queue<int> heap;
				可以通过插入-x实现小顶堆。

stack, 栈
    size()
    empty()
    push()  向栈顶插入一个元素
    top()  返回栈顶元素
    pop()  弹出栈顶元素

deque, 双端队列,加强版vector,效率低,尽量不用
    size()
    empty()
    clear()
    front()/back()
    push_back()/pop_back()
    push_front()/pop_front()
    begin()/end() 迭代器
    [] 支持直接索引

set, map, multiset, multimap, 基于平衡二叉树(红黑树),动态维护有序序列
    size()
    empty()
    clear()
    begin()/end()
    ++, -- 返回前驱和后继,时间复杂度 O(logn)

    set/multiset
				set不允许重复元素
        insert()  插入一个数
        find()  查找一个数,返回迭代器,找不到返回end迭代器
        count()  返回某一个数的个数
        erase()
            (1) 输入是一个数x,删除所有x   O(k + logn)
            (2) 输入一个迭代器,删除这个迭代器
        lower_bound()/upper_bound()
            lower_bound(x)  返回大于等于x的最小的数的迭代器
            upper_bound(x)  返回大于x的最小的数的迭代器
    map/multimap
        insert()  插入的数是一个pair
        erase()  输入的参数是pair或者迭代器
        find()
        []  注意multimap不支持此操作。 时间复杂度是 O(logn)
        lower_bound()/upper_bound()
				
				map<string ,int> a;
				a["yxc"] = 1;
				cout << a["yec"] << endl;
				
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
    和上面类似,增删改查的时间复杂度是 O(1)
    不支持 lower_bound()/upper_bound(), 迭代器的++,--.

bitset, 圧位
		是bool数组的1/8。🐂
    bitset<10000> s; 
		~s;

    ~, &, |, ^
    >>, <<
    ==, !=
    []

    count()  返回有多少个1

    any()  判断是否至少有一个1
    none()  判断是否全为0

    set()  把所有位置成1
    set(k, v)  将第k位变成v
    reset()  把所有位变成0
    flip()  等价于~
    flip(k) 把第k位取反

作者:yxc
链接:https://www.acwing.com/blog/content/404/
来源:AcWing