算法基础课笔记-第二章 数据结构

感想是,大学里好好听课还是很重要的

目录

一、链表与邻接表

单链表

双链表

二、栈与队列

队列

三、kmp

四、Trie树

五、并查集 ☆

六、堆

七、哈希表

存储结构

字符串哈希

八、C++ STL

参考


一、链表与邻接表

分类

  1. 单链表:邻接表:存储图、树
  2. 双链表:优化

单链表

单链表分为静态单链表和动态单链表,但是动态单链表实现方式在每次创建一个新结点时都需要使用 new() 函数,非常耗时,因此需要使用数组模拟的静态单链表。 静态单链表在算法题实际应用中非常广泛,最常见的就是由多个单链表构成的邻接表。邻接表用来存储树和图。 静态双链表的用途主要是可以对某些题目进行优化。

基本结构:

算法基础课笔记-第二章 数据结构_第1张图片

每一个结点 i ,都包含数据域 e 和指针域 ne。

定义一个单链表,需要定义头结点 head,数据域e[N],指针域ne[N],为下一次插入操作准备的空白结点的下标 idx

算法基础课笔记-第二章 数据结构_第2张图片

操作模板:

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

头插法:

算法基础课笔记-第二章 数据结构_第3张图片

算法基础课笔记-第二章 数据结构_第4张图片

// 头插法, x是数据域
void add_to_head(int x)
{
	e[idx] = x, ne[idx] = head, head = idx, idx++;
}

单链表结构由两个数组组成:ene。其中,e 数组用于存储链表节点的值,ne 数组用于存储节点之间的关联关系。

给定一个要插入的新元素 x,需要执行以下步骤:

  1. 创建一个新的节点,记为 idx,并将新元素 x 存储在 e[idx] 中。
  2. 将当前头节点的索引(即原链表的第一个节点的索引)存储在 ne[idx] 中,以保持链表的连续性。
  3. 更新头节点的索引为 idx,即 head = idx
  4. 最后,更新全局的索引 idx,使其指向下一个可用的位置。

通过这种方式,我们实现了在链表头部插入新元素的操作。由于新的元素成为了链表的第一个节点,因此称为头插法。

这种插入方法的时间复杂度为 O(1),因为只需要进行常数次操作即可完成插入。它适用于需要频繁在链表头部插入元素的场景,比如实现栈或者 LRU 缓存等数据结构。

插入到下标为k的结点的后面:

算法基础课笔记-第二章 数据结构_第5张图片

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

遍历:

// 注意:这里的~i等价于i != -1
for (int i = head; ~i; i = ne[i]) printf("%d ", e[i]);

删除:

算法基础课笔记-第二章 数据结构_第6张图片

    点1跳过点2,直接指向点3

算法基础课笔记-第二章 数据结构_第7张图片

例题01 

acwing——826. 单链表

实现一个单链表,链表初始为空,支持三种操作:

(1) 向链表头插入一个数;

(2) 删除第k个插入的数后面的数;

(3) 在第k个插入的数后插入一个数

现在要对该链表进行M次操作,进行完所有操作后,从头到尾输出整个链表。

注意:题目中第k个插入的数并不是指当前链表的第k个数。例如操作过程中一共插入了n个数,则按照插入的时间顺序,这n个数依次为:第1个插入的数,第2个插入的数,…第n个插入的数。

输入格式
第一行包含整数M,表示操作次数。

接下来M行,每行包含一个操作命令,操作命令可能为以下几种:

(1) “H x”,表示向链表头插入一个数x。

(2) “D k”,表示删除第k个输入的数后面的数(当k为0时,表示删除头结点)。

(3) “I k x”,表示在第k个输入的数后面插入一个数x(此操作中k均大于0)。

输出格式
共一行,将整个链表从头到尾输出。

输入样例:
10
H 9
I 1 1
D 1
D 0
H 6
I 3 6
I 4 5
I 4 5
I 3 4
D 6
输出样例:
6 4 6 5

#include 
using namespace std;

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

int main(){
	
	int m;
	cin >> m;
	
	head = -1; //初始化链表头 
	
	while(m--){
		int k, x;
		char op[2];
		cin >> op; 
		
		if(*op == 'H'){
			cin >> x;
			e[idx] = x; // 向链表头插入一个数 
			ne[idx] = head;
			head = idx ++ ;
			
		}else if(*op == 'D'){ // 删除第k个插入的数后面的数
			cin >> k;
			if(!k) head = ne[head];
			else ne[k - 1] = ne[ ne[k - 1] ];
			
		}else{  // 在第k个插入的数后插入一个数
			cin >> k >> x;
			k--;
			e[idx] = x;
			ne[idx] = ne[k];
			ne[k] = idx++;
		}
	}
	
	for(int i = head; ~i; i = ne[i]) cout << e[i] <<" ";
	cout<

双链表

初始状态如下图所示,注意 0 号和 1号结点实际上是不存储数据的,它们只是起到一个方便处理的作用。链表中第一个数据项实际上是 r [0], 最后一个数据项实际上是l[1]。
算法基础课笔记-第二章 数据结构_第8张图片

模板

   初始化、插入、删除操作

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

    遍历:

for (int i = r[0]; i != 1; i = r[i]) printf("%d ", e[i]);

例题 AcWing 827. 双链表 

一、题目描述
实现一个双链表,双链表初始为空,支持 5 种操作:

在最左侧插入一个数;
在最右侧插入一个数;
将第 k 个插入的数删除;
在第 k 个插入的数左侧插入一个数;
在第 k 个插入的数右侧插入一个数
现在要对该链表进行 M 次操作,进行完所有操作后,从左到右输出整个链表。

输入
第一行包含整数 M,表示操作次数。

接下来 M 行,每行包含一个操作命令,操作命令可能为以下几种:

L x,表示在链表的最左端插入数 x。
R x,表示在链表的最右端插入数 x。
D k,表示将第 k 个插入的数删除。
IL k x,表示在第 k 个插入的数左侧插入一个数。
IR k x,表示在第 k 个插入的数右侧插入一个数。

输出共一行,将整个链表从左到右输出。

输入样例:

10
R 7
D 1
L 3
IL 2 10
D 3
IL 2 7
L 8
R 9
IL 4 7
IR 2 2
输出样例:

8 7 7 3 2 9

#include 
#include 
using namespace std;

const int N = 1e5 + 10;
int e[N], l[N], r[N], idx;
int n;

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

// 在下标为k的结点的右侧插入结点
void add(int k, int x)
{
    e[idx] = x, r[idx] = r[k], l[idx] = k, l[r[k]] = idx, r[k] = idx, idx++;
}

// 删除下标为k的结点
void remove(int k)
{
    r[l[k]] = r[k], l[r[k]] = l[k];
}

int main()
{
    init();
    scanf("%d", &n);
    
    char op[5];
    while (n--)
    {
        scanf("%s", op);
        if (*op == 'L') 
        {
            int x;
            scanf("%d", &x);
            add(0, x);
        }
        else if (*op == 'R')
        {
            int x;
            scanf("%d", &x);
            add(l[1], x);
        }
        else if (*op == 'D')
        {
            int k;
            scanf("%d", &k);
            remove(k + 1);  // 因为idx初始为2
        }
        else if (!strcmp(op, "IL"))
        {
            int k, x;
            scanf("%d%d", &k, &x);
            add(l[k + 1], x);
        }
        else
        {
            int k, x;
            scanf("%d%d", &k, &x);
            add(k + 1, x);
        }
    }
    
    for (int i = r[0]; i != 1; i = r[i]) printf("%d ", e[i]);
    puts("");
    
    return 0;
}

二、栈与队列

模板:

   操作:push、pop、取栈顶、判断空栈

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

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

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

// 栈顶的值
stk[tt];

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

}

单调栈

  • 基本思想:假设序列为 a1 , a2 , a3 , . . . , an。求每一个数左侧第一个比它小的数,那么如果存在 i < j 且 ai > aj ,那么从 aj开始,ai就已经失去作用了。
  • 单调栈:每个 ai 必须入栈一次。在计算 ai 左侧第一个比它小的数时候,把栈顶元素 > = a i  的全部退栈,直到栈顶 < a i 时,栈顶元素就是答案,这时才将 ai 入栈,时刻保持从栈底到栈顶的严格单调递增性,这个性质与求中缀表达式的值中的符号栈性质非常相似。
  • 例:序列 3 4 2 7 9 ,过程如下:

算法基础课笔记-第二章 数据结构_第9张图片

例题  AcWing 830. 单调栈
一、题目描述
给定一个长度为 N的整数数列,输出每个数左边第一个比它小的数,如果不存在则输出 -1。

输入格式
第一行包含整数 N ,表示数列长度。第二行包含 N 个整数,表示整数数列。

输出格式
共一行,包含 N 个整数,其中第 i 个数表示第 i 个数的左边第一个比它小的数,如果不存在则输出 − 1 。

输入样例:

5
3 4 2 7 5
1
2
输出样例:

-1 3 -1 2 2

#include 
using namespace std;

const int N = 1e5 + 10;

int n;
int stk[N], tt;

int main(){
    scanf("%d", &n);
    
    for (int i = 0; i < n; i++){
        int x;
        scanf("%d", &x);
        
        while (tt && stk[tt] >= x) tt--;  // 栈不为空 && 栈顶元素>=x 栈顶元素不断pop 直到栈空/栈顶元素
  •  cin.tie(0)是用来解除cincout之间的绑定关系。默认情况下,cincout是绑定在一起的,意味着当从标准输入流(键盘)读取输入时,输出也会自动刷新到标准输出流(屏幕)。通过使用cin.tie(0),我们可以取消这种绑定关系,使得输入和输出可以独立进行。也就是说,取消绑定后,读取输入不会触发输出的刷新,可以先输入完所有内容再进行输出操作。
  • ios::sync_with_stdio(false)是一个用于优化输入输出性能的操作。在C++中,默认情况下,C++的输入输出流(cin, cout, cerr, clog)与C语言的标准输入输出流(stdin, stdout, stderr)是同步的。这意味着它们共享相同的缓冲区,并且对其中一个进行操作可能会导致另一个被刷新。通过调用ios::sync_with_stdio(false),可以取消C++流和C流之间的同步操作,从而提高程序的性能。当取消同步后,C++的输入输出流将使用自己的独立缓冲区,不再受到C流的影响。但是需要注意的是,在取消同步后,我们不能再混合使用C++流和C流,否则可能会导致未定义的行为。此外,取消同步也可能导致一些特定的行为变化,比如getline()函数和scanf()函数的交替使用可能会出现问题。

队列

1.普通队列

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

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

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

// 队头的值
q[hh];

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

}

2.循环队列

// 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)
{

}

例题01.

题目:AcWing 154. 滑动窗口
给定一个数组,有一个大小为 k 的滑动窗口,它从数组的最左边移动到最右边。你只能在窗口中看到 k 个数字。每次滑动窗口向右移动一个位置。

例子:该数组为 [1 3 -1 -3 5 3 6 7],k为 3。

窗口位置 最小值 最大值
[1 3 -1] -3 5 3 6 7 -1 3
1 [3 -1 -3] 5 3 6 7 -3 3
1 3 [-1 -3 5] 3 6 7 -3 5
1 3 -1 [-3 5 3] 6 7 -3 5
1 3 -1 -3 [5 3 6] 7 3 6
1 3 -1 -3 5 [3 6 7] 3 7

确定滑动窗口位于每个位置时,窗口中的最大值和最小值。
输入:第一行包含两个整数 n 和 k,分别代表数组长度和滑动窗口的长度。第二行有 n 个整数,代表数组的具体数值。同行数据之间用空格隔开。
输出包含两个:第一行输出,从左至右,每个位置滑动窗口中的最小值。第二行输出,从左至右,每个位置滑动窗口中的最大值。

输入样例:

8 3
1 3 -1 -3 5 3 6 7
1
2
输出样例:

-1 -3 -3 -3 3 3
3 3 5 5 6 7

思路:

  • 先思考用普通队列该怎么做(窗口本质就是一个队列)
  • 将队列中没有用的元素删除。何谓没有用的元素呢?比如窗口中存在 i < j , a i > a j,那么在窗口向右移动过程中,只要 a j 存在,那么 a i 都永远不会成为窗口最小值,应该被移出。因此,当滑动窗口刚刚移动到 a j 那一刹那(在 a j 真正进入队列之前),a i、以及窗口中一切 ≥ a j 的元素必须被移出队列。整个队列在滑动的过程中,在任何时刻都要保证严格的单调递增,因此窗口中的最小值永远是队首。
  • 可以使用 O(1) 的时间,从队头/队尾取出最值
  • 这里的单调队列 q 维护的是在单调队列中的 a 序列元素的下标。
     
#include 
using namespace std;

const int N = 1e6 + 10; 

int q[N], a[N], hh, tt = -1; // hh和tt(队列的头尾指针)
int n, k;

int main(){
	
   scanf("%d%d", &n, &k); 
   for (int i = 0; i < n; i++) scanf("%d", &a[i]); 
   
   for (int i = 0; i < n; i++){  // i本质是窗口的依次经历的右端点. 当前窗口区间是[i - k + 1, i]
       if (hh <= tt && q[hh] < i - k + 1) hh++;   // 如果队头元素已经不在当前窗口内,则将其出队
       while (hh <= tt && a[q[tt]] >= a[i]) tt--; // 删除队列中比当前元素更小的元素,保持队列单调递增
       q[++tt] = i; // 将当前元素的下标加入队列
       
       if (i + 1 >= k) printf("%d ", a[q[hh]]); // 如果窗口长度达到k,则输出当前窗口中的最小元素
   }
   
   puts("");
   
   hh = 0, tt = -1; // 重置队列的头尾指针
   for (int i = 0; i < n; i++){
   	
       if (hh <= tt && q[hh] < i - k + 1) hh++; // 如果队头元素已经不在当前窗口内,则将其出队
       while (hh <= tt && a[q[tt]] <= a[i]) tt--; // 删除队列中比当前元素更大的元素,保持队列单调递减
       q[++tt] = i; // 将当前元素的下标加入队列
       
       if (i + 1 >= k) printf("%d ", a[q[hh]]); // 如果窗口长度达到k,则输出当前窗口中的最大元素
   }  
    return 0;
}

    第一个循环用于求滑动窗口中的最小值。具体操作如下:

  • 首先,判断队列是否为空且队头元素是否已经不在当前窗口内(即下标小于i - k + 1),如果是则将队头元素出队。
  • 接着,不断循环判断队列中的元素是否比当前元素a[i]大,如果是,则将队尾元素出队。这样,队列中保留的元素将按照从小到大的顺序排列。
  • 将当前元素的下标i加入队列的队尾。
  • 最后,如果窗口长度达到k,则输出当前窗口中的最小值。

    第二个循环用于求滑动窗口中的最大值,与第一个循环类似,只是判断条件和操作有所不同。在该循环中,我们需要将队列中比当前元素更小的元素删除,以保持队列中元素的单调递减性质。

三、kmp

作用:字符串匹配。给两个字符串,寻找其中一个字符串是否包含另一个字符串,如果包含,返回包含的起始位置。
 

大二学数据结构时这一块就一知半解,能理解主要看这几篇,算是基本弄明白了原理:

KMP算法最浅显理解——一看就明白_路漫远吾求索的博客-CSDN博客

全网最通俗的KMP算法图解 - 知乎 (zhihu.com)

(算法)通俗易懂的字符串匹配KMP算法及求next值算法_Sirm23333的博客-CSDN博客

虽然但是看了很多篇博客都有矛盾的地方...比如下标,定义,计算方法,比较下来还是以和课本上一致的方法为准吧

kmp算法的关键在于next[]数组

举例:

目标字符串ptr: ababaca  计算长度为m的转移数组next。

next数组的含义:一个固定字符串的最长前缀和最长后缀相同的长度。

比如:abcjkdabc,那么这个数组的最长前缀和最长后缀相同必然是abc。
cbcbc,最长前缀和最长后缀相同是cbc。
abcbc,最长前缀和最长后缀相同是不存在的。

注意:最长前缀:是说以第一个字符开始,但是不包含最后一个字符。比如aaaa相同的最长前缀和最长后缀是aaa。

对于目标字符串ptr,ababaca,长度是7,所以next[0],next[1],next[2],next[3],next[4],next[5],next[6]分别计算的是:a,ab,aba,abab,ababa,ababac,ababaca的相同的最长前缀和最长后缀的长度

a,ab,aba,abab,ababa,ababac,ababaca的相同的最长前缀和最长后缀是“”,“”,“a”,“ab”,“aba”,“”,“a”,所以next数组的值是[].

定义:
 

  •  为了表示下一轮比较j定位的地方,我们将其定义为next[j],next[j]就是第j个元素前j-1个元素首尾重合部分个数加一。为了能遍历完整,首尾重合部分的元素个数应取到最多,即next[j]应取尽量大的值。
  • 最后,如果我们知道了一个字符串的next值,那么KMP算法也就很好懂了。相比朴素算法,当发生失配时,i不变,j=next[j]就好啦!接下来就是怎么确定next值了。

next[j+1]的值为pj+1的前j个元素的收尾重合的最大个数加一

  • 求next数组:
    int GetNext(char ch[], int cLen, int next[]) {
        next[1] = 0;  
        int i = 1, j = 0;  // i和j:当前比较字符的位置;模式串中已匹配字符的最后一个位置
        while (i <= cLen) {  // 从第二个字符开始,一直遍历到最后一个字符
            if (j == 0 || ch[i] == ch[j])  // j=0/当前字符=模式串中已匹配字符
                next[++i] = ++j;  // 更新next数组的下一个值并将i和j都加1
            else
                j = next[j];  // 失配,将j回溯到上一次匹配的位置
        }
    }
    
  • next[1] = 0; 初始化next数组的第一个元素为0。
  • int i = 1, j = 0; 定义两个变量i和j,分别表示当前比较字符的位置和模式串中已匹配字符的最后一个位置。
  • while (i <= cLen) 循环遍历模式串的每个字符,从第二个字符开始,一直遍历到模式串的最后一个字符。
  • if (j == 0 || ch[i] == ch[j]) 判断条件,如果j等于0或者当前字符与模式串中已匹配字符相等。
  • next[++i] = ++j; 更新next数组的下一个值,并将i和j都加1。即在模式串中,以当前字符为结尾的子串的最长公共前后缀长度。
  • else j = next[j]; 当前字符与模式串中已匹配字符不相等时,将j回溯到上一次匹配的位置,继续进行比较。

举例:

1、要求next[k+1] 其中k+1=17

2、已知next[16]=8,则元素有以下关系:

3、如果P8=P16,则明显next[17]=8+1=9
4、如果不相等,又若next[8]=4,则有以下关系

又加上2的条件知

在这里插入图片描述

主要是为了证明:

在这里插入图片描述

5、现在在判断,如果P16=P4则next[17]=4+1=5,否则,在继续递推
6、若next[4]=2,则有以下关系

在这里插入图片描述

7、若P16=P2,则next[17]=2+1=3;否则继续取next[2]=1、next[1]=0;遇到0时还没出结果,则递推结束,此时next[17]=1。最后,再返回看那5行算法,应该很容易明白了!

匹配过程:

算法基础课笔记-第二章 数据结构_第10张图片

​ s 串和 p 串都是从下标1开始的。i 从1 开始,j 从0开始,每次由 s[ i ] 和 p[ j + 1 ]进行比较:

当匹配过程到上图所示时,s[a, b] = p[1, j] && s[i] != p[j + 1],此时要整体移动 p 字符串(不是移动1 格,而是直接移动到下次能够匹配的位置)
其中①串为[ 1, next[j] ],③串为[ j - next[j] + 1, j ]。由匹配可知①串等于③串,③串等于②串。所以直接整体移动 p 串使①串到③串的位置即可。这个操作可由 j = next[j] 直接完成。 如此往复下去,当 j == m 时匹配成功。

匹配模板:

for(int i = 1, j = 0; i <= n; i++){
    while(j && s[i] != p[j + 1]) j = ne[j];
    //如果j有对应p串的元素, 且s[i] != p[j+1], 则失配, 移动p串
    //用while是由于移动后可能仍然失配,所以要继续移动直到匹配或整个p串移到后面(j = 0)

    if(s[i] == p[j + 1]) j++;
    //当前元素匹配,j移向p串下一位
    if(j == m)
    {
        //匹配成功,进行相关操作
        j = next[j];  //继续匹配下一个子串
    }
}

 next数组的求法是通过模板串自己与自己进行匹配操作得出来的(代码和匹配操作几乎一样)。

算法基础课笔记-第二章 数据结构_第11张图片

求next模板: 

for(int i = 2, j = 0; i <= m; i++){
    while(j && p[i] != p[j + 1]) j = next[j];

    if(p[i] == p[j + 1]) j++;

    next[i] = j;
}

 代码和匹配操作的代码几乎一样,关键在于每次移动 i 前,将 i 前面已经匹配的长度记录到 next 数组中。

例题:模板题 AcWing 831. KMP字符串

题目
给定一个模式串 S,以及一个模板串 P,所有字符串中只包含大小写英文字母以及阿拉伯数字。模板串 P 在模式串 S 中多次作为子串出现。

求出模板串 P 在模式串 S 中所有出现的位置的起始下标。

输入格式
第一行输入整数 N,表示字符串 P 的长度。

第二行输入字符串 P。

第三行输入整数 M,表示字符串 S 的长度。

第四行输入字符串 S。

输出共一行,输出所有出现位置的起始下标(下标从 0 开始计数),整数之间用空格隔开。

输入样例:

3
aba
5
ababa
输出样例:

0 2

#include 

using namespace std;

const int N = 100010, M = 10010; //N为模式串长度,M匹配串长度

int n, m;
int ne[M]; //next[]数组,避免和头文件next冲突
char s[N], p[M];  //s为模式串, p为匹配串

int main(){
    cin >> n >> s + 1 >> m >> p + 1;  //下标从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)  //满足匹配条件,打印开头下标, 从0开始
        {
            //匹配完成后的具体操作
            //如:输出以0开始的匹配子串的首字母下标
            //printf("%d ", i - m); (若从1开始,加1)
            j = ne[j];            //再次继续匹配
        }
    }

    return 0;
}

四、Trie树

  字典树是用来高效地存储和查找字符串集合的数据结构。使用要求:所存储的字符串要么都是小写、要么都是大写、要么都是数字,反正字符的种类不会很多。

  例如:一棵字典树,存储abcdef、abdef 、abc 、acef 、bcdf、bcdc、bcff、cdaa等字符如图所示,字符串存储的时候,要将每一个字符串结尾打上一个标记,如下图中的三角形。
算法基础课笔记-第二章 数据结构_第12张图片

用数组来模拟Trie树的具体分析: 

算法基础课笔记-第二章 数据结构_第13张图片

例题

模板题 AcWing 835. Trie字符串统计

题目
维护一个字符串集合,支持两种操作:

I x 向集合中插入一个字符串 x;
Q x 询问一个字符串在集合中出现了多少次。
共有 N 个操作,字符串仅包含小写英文字母。

输入
第一行包含整数 N,表示操作数。

接下来 N 行,每行包含一个操作指令,指令为 I x 或 Q x 中的一种。

输出
对于每个询问指令 Q x,都要输出一个整数作为结果,表示 x 在集合中出现的次数。

每个结果占一行。

输入样例:

5
I abc
Q abc
Q ab
I ab
Q ab
输出样例:

1
0
1

#include 
using namespace std;

// 因为输入字符串的总长度是1e5,所以总结点数就是1e5
const int N = 1e5 + 10;

// 因为这里存储的是小写英文字母,故每个结点最多往外连26条边
// cnt是以当前结点结尾的单词有多少个
// idx表示下一个可用的空白结点
int son[N][26], cnt[N], idx; // 下标为 0的结点既是根节点又是空节点
char str[N];
int n;

void insert(char str[]){
    
    int p = 0;   // p 指的是当前结点,初始为根结点
    for (int i = 0; str[i]; i++){  // 找到当前字符对应的子结点编号
        
        int u = str[i] - 'a';     //str[i]的asc码 
        
        if (!son[p][u]) son[p][u] = ++idx;  // 因为idx = 0是根节点,不存储数据,故++idx:下一个节点的大致位置 
        p = son[p][u];          //更新当前节点 
    }
    cnt[p]++;                  // 计数:以当前节点为结尾的单词个数 +1 
}

int query(char str[]){
    int p = 0;                // 当前节点,初始指向根节点 
    for (int i = 0; str[i]; i++){  // 遍历str[i]的所有子节点 
    	
        int u = str[i] - 'a';
        
        if (!son[p][u]) return 0;  // 说明不存在当前的查询路径,直接返回
        p = son[p][u];             // 不断遍历,直到到达叶节点(存储了这个单词的个数) 
    }
    return cnt[p];
}

int main(){
	
    scanf("%d", &n);
    
    while (n--){
        char op[2];
        scanf("%s%s", op, str);
        
        if (*op == 'I') insert(str);
        else printf("%d\n", query(str));
    }
    
    
    return 0;
}

AcWing 143. 最大异或对 

题目:给定N个整数 A1,A2 … A N中选出两个进行异或运算,得到的结果最大是多少?

输入:
第一行输入一个整数 N。

第二行输入N个整数 A 1 ~ A N。

输出:一个整数表示答案。

输入样例:

3
1 2 3
输出样例:3

暴力做法:固定一个 ai ,从 a1~an中选出一个 aj,使异或结果最大。二重循环。

#include 
using namespace std;

const int N = 1e5 + 10;
int a[N], n;

int main(){
	cin >> n;
	for (int i = 0; i < n; i++) cin >> a[i];
	
	int res = 0; // 存放当前的最大异或结果 
	for (int i = 0; i < n; i++) 
		for (int j = 0; j < n; j++)
			res = max(res, a[i] ^ a[j]);
			
	cout << res << endl;
	return 0;
}

优化:当 ai 确定以后,如何从所有数中最快的选出 aj,使得异或值最大呢?

思路:从最高位开始(从左往右),运算得到的1尽可能多。而异或运算:两个数相同得0,不同得1。那么就是说,从最高位开始,尽量找不同的数。

举例:设ai = 11001100101011...11001,一共31位。想要让异或值最大,要从最高位开始,先看看与当前位相反的数存不存在。比如 ai 的第31位是1,接下来,看是否存在一个数的第31位是0?如果存在,那就走0这个分支,继续看第30位是否存在0的分支…;如果不存在,那么第31位就走1的分支,再看第30位是否存在0的分支…以此类推,类似于贪心,从31位一直到第1位,都采取这种策略,最后找到的数 aj 与 ai 异或以后一定是最大值。类似于二分的试探路径,如果旋转90度,那么就可以联想到使用Trie字典树来实现存储和查询,如下图,绿色路径是最优的搜索路径(如果存在前4位为0011这样的 aj 的话)。

算法基础课笔记-第二章 数据结构_第14张图片

#include 
using namespace std;

const int N = 1e5 + 10, M = 31 * N;
// 注意son和cnt的结点个数=所有输入的符号总数=数的数量*31
int son[M][2], idx;
int a[N];
int n;

// 将每一个整数看成一个31位的二进制数
void insert(int x)
{
    int p = 0;
    // i >= 0 等价于 i != -1 等价于 ~i
    for (int i = 30; ~i; i--)
    {
        int &u = son[p][x >> i & 1];
        if (!u) u = ++idx;
        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];  // res += 0 << i; // 直接省略
    }
    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. 将两个集合合并
  2. 询问两个元素是否在一个集合当中

   基本原理:每一个集合用一个树来维护,每一个集合的编号就是当前集合树根结点的编号
对于每一个结点 x,都要记录其父结点 p[x]。

  • 问题1:如何判断树根? if ( p[x] == x ) 
  • 问题2:如何求 x 的集合编号? while( p[x] != x )  x = p[x]  找到x的根节点编号=集合编号
  • 问题3:如何合并两个集合?p[x]是x的集合编号,p[y]是y的集合编号,p[x] = y 

  路径压缩优化:在查找一个元素 x 所在集合的根结点时,一边往上溯根,一边将这些结点的父结点都改为最终的集合根结点。一言以蔽之:儿子和父亲变成兄弟。
算法基础课笔记-第二章 数据结构_第15张图片

*如何具体的知道每个集合里面包含哪些元素?

vector group;
for (int i = 0; i < n; i ++ )
	group[find(i)].push_back(i);

find(x):找到x的祖宗节点

查询

int find(int x)
{
    if(fa[x] == x)
        return x;
    else
        return find(fa[x]);
}

用递归的写法实现对代表元素的查询:一层一层访问父节点,直至根节点(根节点的标志就是父节点是本身)。要判断两个元素是否属于同一个集合,只需要看它们的根节点是否相同即可

合并

inline void merge(int i, int j)
{
    fa[find(i)] = find(j);
}

合并操作也是很简单的,先找到两个集合的代表元素,然后将前者的父节点设为后者即可。

路径压缩:

    随着链越来越长,我们想要从底部找到根节点会变得越来越难。既然我们只关心一个元素对应的根节点,那我们希望每个元素到根节点的路径尽可能短,最好只需要一步,像这样:

算法基础课笔记-第二章 数据结构_第16张图片

    在查询的过程中,把沿途的每个节点的父节点都设为根节点即可。下一次再查询时,我们就可以省很多事。

int find(int x){
    if(fa[x] == x) return x;
    else{
        fa[x] = find(fa[x]);
        return fa[x];
    }
}

合并(路径压缩):

  • find(fa[x]):找到x的根节点
  • fa[x] = find(fa[x]):把x的父节点设为根节点
  • 多了一步:先fa[x] = find(fa[x]) 找到根节点,往下回溯时fa[x]就始终是根节点了

以上代码常常简写为一行:

int find(int x)
{
    return x == fa[x] ? x : (fa[x] = find(fa[x]));
}

模板

(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)的偏移量

例题

AcWing 836. 合并集合

一、题目描述
一共有 n 个数,编号是 1 ∼ n ,最开始每个数各自在一个集合中。

现在要进行 m 个操作,操作共有两种:

   M a b,将编号为 a 和 b 的两个数所在的集合合并,如果两个数已经在同一个集合中,则忽略这个操作;
   Q a b,询问编号为 a  和 b 的两个数是否在同一个集合中;
输入
第一行输入整数 n 和 m。

接下来 m 行,每行包含一个操作指令,指令为 M a b 或 Q a b 中的一种。

输出
对于每个询问指令 Q a b,都要输出一个结果,如果 a 和 b 在同一集合内,则输出 Yes,否则输出 No。每个结果占一行。

输入样例:

4 5
M 1 2
M 3 4
Q 1 2
Q 1 3
Q 3 4
输出样例:

Yes
No
Yes

#include 
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]);   // 如果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 == 'M') p[find(a)] = find(b);  // 合并操作 
		else{
			if(find(a) == find(b)) puts("YES");
			else puts("NO");
		}
		
	}
	
	return 0 ;
}

AcWing 837. 连通块中点的数量

题目描述
给定一个包含 n 个点(编号为 1∼n)的无向图,初始时图中没有边。

现在要进行 m 个操作,操作共有三种:

C a b,在点 a 和点 b之间连一条边,a 和 b 可能相等;
Q1 a b,询问点 a 和点 b 是否在同一个连通块中,a 和 b 可能相等;
Q2 a,询问点 a 所在连通块中点的数量;

输入格式
第一行输入整数 n 和 m。

接下来 m 行,每行包含一个操作指令,指令为 C a b,Q1 a b 或 Q2 a 中的一种。

输出格式
对于每个询问指令 Q1 a b,如果 a 和 b 在同一个连通块中,则输出 Yes,否则输出 No。

对于每个询问指令 Q2 a,输出一个整数表示点 a 所在连通块中点的数量

输入样例:

5 5
C 1 2
Q1 1 2
Q2 1
C 2 5
Q2 5
输出样例:

Yes
2
3

#include 
using namespace std;

const int N = 1e5 + 10;
int p[N], sz[N];
int n, m;

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

void merge(int a, int b)
{
    int pa = find(a), pb = find(b);
    // 特别是带有size这种附加属性的问题,一定要注意防止重复合并
    if (pa != pb)
    {
        sz[pb] += sz[pa];
        p[pa] = pb;
    }
}

int main()
{
    cin >> n >> m;
    
    for (int i = 1; i <= n; i++) p[i] = i, sz[i] = 1;
    
    while (m--)
    {
        string op;
        cin >> op;
        
        if (op == "C") 
        {
            int a, b;
            cin >> a >> b;
            
            merge(a, b);
        }
        else if (op == "Q1")
        {
            int a, b;
            cin >> a >> b;
            
            if (find(a) == find(b)) puts("Yes");
            else puts("No");
        }
        else 
        {
            int a;
            cin >> a;
            cout << sz[find(a)] << endl;
        }
    }
    
    return 0;
}

AcWing 240. 食物链 

题目描述
动物王国中有三类动物 A,B,C,这三类动物的食物链构成了有趣的环形。

A 吃 B,B吃C,C 吃 A。

现有 N 个动物,以 1 ∼ N 编号。每个动物都是 A ,B ,C 中的一种.

有人用两种说法对这 N 个动物所构成的食物链关系进行描述:

第一种说法是 1 X Y,表示 X 和 Y 是同类。

第二种说法是 2 X Y,表示 X 吃 Y。

此人对 N 个动物,用上述两种说法,一句接一句地说出 K 句话,这 K 句话有的是真的,有的是假的。

当一句话满足下列三条之一时,这句话就是假话,否则就是真话。

  1. 当前的话与前面的某些真的话冲突,就是假话;
  2. 当前的话中 X 或 Y 比 N 大,就是假话;
  3. 当前的话表示 X 吃 X,就是假话。

你的任务是根据给定的 N 和 K 句话,输出假话的总数。

输入
第一行是两个整数 N 和 K ,以一个空格分隔。

以下 K 行每行是三个正整数 D,X,Y,两数之间用一个空格隔开,其中 D表示说法的种类。

  1. 若 D = 1 ,   则表示 X 和 Y 是同类。
  2. 若 D = 2 ,则表示 X 吃 Y。

输出一个整数,表示假话的数目。

输入:

100 7
1 101 1 
2 1 2
2 2 3 
2 3 3 
1 1 3 
2 3 1 
1 5 5
输出:

3

算法基础课笔记-第二章 数据结构_第17张图片

 由于 A、B、C之间的关系是相互循环被吃,那么就用每个结点到根结点之间的距离来表示它和根结点之间的关系。

定义如下距离关系:下一层的吃上一层
(1)如果某个结点到根结点之间的距离是1,表示该结点可以吃掉根结点。即 该结点 吃 根结点

算法基础课笔记-第二章 数据结构_第18张图片

(2)如果某个结点到根结点之间的距离是2,表示该结点可以吃掉其父结点,因为其父结点又可以吃掉根结点,因此由循环关系可得根结点一定可以吃掉距离为2的顶点。即根结点 吃 该结点

算法基础课笔记-第二章 数据结构_第19张图片

(3)如果某个结点距离根结点的距离为3,那么说明该结点与根结点是同类。即 该结点 同类于 根结点。 

因此,所有结点与根结点之间的关系都可以通过将该结点距离根结点之间的距离 mod 3所得的结果(0,1, 2)判断出当前结点与根结点之间的关系。

  1. dis(该结点) % 3 == 0 :该结点与根结点同类
  2. dis(该结点) % 3 == 1 :该结点吃根结点
  3. dis(该结点) % 3 == 2 :该结点被根结点吃

通过每个节点到根节点的距离,可判断每两个点之间的关系。

那么,在代码实现的时候,如何记录距离这个概念呢?
    就像记录父节点一样,dis[i] 可以用来只记录 i 结点到其父结点的距离。在做路径压缩的时候,将 dist[i] 不断加上路径上的距离,最终路径压缩完成时,dis[i] 记录的就是 i 结点到根结点的距离。
例如:在对粉色结点进行find操作时,如下图。
算法基础课笔记-第二章 数据结构_第20张图片

真话中涉及的两个动物x和y还没有建立关系,该如何将他们所在的关系集合合并呢?

算法基础课笔记-第二章 数据结构_第21张图片

因为关系的集合非常简单,只需要执行 p[px]=py 即可,但是如何设定 d[px] 的值,成为了维护 x 和 y 关系的关键。

  • 如果需要保证 x 和 y 同类:说明 ( d[x] + d[px] ) % 3 = d[y] % 3 ,即 ( d[x] + d[px] − d[y]) % 3=0,推导出 d[py] = d[y] − d[x]
  • 如果需要保证 x 吃 y 的关系:说明 ( d[x] + d[px] ) % 3 − d[y] % 3 = 1,即 ( d[x] + d[px] − d[y] − 1) % 3 = 0,推导出 d[px] = d[y] + 1 − d[x]
#include 
using namespace std;

const int N = 5e4 + 10;
// d[x]记录的是x到p[x]之间的距离
int p[N], d[N];
int n, m;

int find(int x)
{
    if (x != p[x]) 
    {
        // 总思路:先更新距离,再更新父结点为根结点
        // 经过find(p[x])之后d[p[x]]存储的是x的父结点到根结点的距离
        // 同时t指的是根结点
        // 这里没有让p[x]=find(p[x])的原因是还要加上x到原始p[x]的距离
        int t = find(p[x]);
        d[x] += d[p[x]];
        p[x] = t;
    }
    return p[x];
}

int main()
{
    cin >> n >> m;
    // 初始化,d[i]默认为0
    for (int i = 1; i <= n; i++) p[i] = i;
    
    int res = 0;    // 假话的个数
    while (m--)
    {
        int t, x, y;
        cin >> t >> x >> y;
        
        if (x > n || y > n) res++;
        else
        {
            int px = find(x), py = find(y);
            // 假如x和y同类
            if (t == 1)
            {
                // 假如当前已经建立了关系(已经在同一个集合中)
                if (px == py && (d[x] - d[y]) % 3) res++;
                // 说明还没有建立关系
                else if (px != py)
                {
                    // 如果说要将两个集合合并:p[px] = py,并且满足x与y同类,那么要人为定义d[px]的距离
                    // (d[x] + d[px] - d[y]) % 3 = 0 即 d[px] = d[y] - d[x]
                    p[px] = py;
                    d[px] = d[y] - d[x];
                }
            }
            else
            {
                // 如果说要将两个集合合并:p[px] = py,并且满足x吃y,那么要人为定义d[px]的距离
                // (d[x] + d[px] - d[y] - 1) % 3 = 0 即 d[px] = d[y] + 1 - d[x]
                if (px == py && (d[x] - d[y] - 1) % 3) res++;
                else if (px != py)
                {
                    p[px] = py;
                    d[px] = d[y] + 1 - d[x];
                }
            }
        }
    }
    cout << res << endl;
    
    return 0;
}

并查集最直接的一个应用场景:亲戚问题

(洛谷P1551)亲戚

题目
若某个家族人员过于庞大,要判断两个是否是亲戚,确实还很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。
规定:x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x,y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。
输入
第一行:三个整数n,m,p,(n<=5000,m<=5000,p<=5000),分别表示有n个人,m个亲戚关系,询问p对亲戚关系。
以下m行:每行两个数Mi,Mj,1<=Mi,Mj<=N,表示Mi和Mj具有亲戚关系。
接下来p行:每行两个数Pi,Pj,询问Pi和Pj是否具有亲戚关系。
输出
P行,每行一个’Yes’或’No’。表示第i个询问的答案为“具有”或“不具有”亲戚关系。
#include 
#define MAXN 5005
int fa[MAXN], rank[MAXN];
inline void init(int n)
{
    for (int i = 1; i <= n; ++i)
    {
        fa[i] = i;
        rank[i] = 1;
    }
}
int find(int x)
{
    return x == fa[x] ? x : (fa[x] = find(fa[x]));
}
inline void merge(int i, int j)
{
    int x = find(i), y = find(j);
    if (rank[x] <= rank[y])
        fa[x] = y;
    else
        fa[y] = x;
    if (rank[x] == rank[y] && x != y)
        rank[y]++;
}
int main()
{
    int n, m, p, x, y;
    scanf("%d%d%d", &n, &m, &p);
    init(n);
    for (int i = 0; i < m; ++i)
    {
        scanf("%d%d", &x, &y);
        merge(x, y);
    }
    for (int i = 0; i < p; ++i)
    {
        scanf("%d%d", &x, &y);
        printf("%s\n", find(x) == find(y) ? "Yes" : "No");
    }
    return 0;
}

六、堆

定义:堆是一棵完全二叉树,其有如下操作:

  1. 插入一个数
  2. 求堆中最值
  3. 删除堆顶最值
  4. 删除任意一个元素
  5. 修改任意一个元素

  堆的递归定义:以小根堆为例,每一个结点都 ≤ 左右子树的根结点。

  堆的存储:因为堆是建立在完全二叉树上的,凡是涉及完全二叉树皆可以使用顺序存储。
假设堆存储在数组 h 中,那么根结点始终为h[1],若某个结点的下标为 i ,则其左儿子的下标为2i,右儿子的下标为 2i+1。
算法基础课笔记-第二章 数据结构_第22张图片

堆的向下调整:down()操作
  如果对某个结点 u 执行down(u)操作,则需要将该结点的值和其左右儿子的值对比,与其中的最小值的结点进行交换,然后递归执行上述操作,该操作时间复杂度和树的高度成正比,因此为O(logn)。每次down操作只调整节点u、左儿子、右儿子三个节点,保证这三个点满足堆。

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

堆的向上调整:up(u)操作
  如果对某个结点 u 执行 up(u) 操作,则需要将该结点的值和其父结点的值对比,如果比其父结点的值小,则与父结点交换,然后递归执行上述操作,该操作时间复杂度和树的高度成正比,因此为 O(logn)。

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


堆的插入操作
  在整个堆的最后一个位置插入 x,即 h[++n] = x,然后将这个数向上调整 up(n),时间复杂度 O(logn)。

void push(int x){
    h[++n] = x;
    up(x);
}


求堆的最值
堆顶元素一定是堆集合的最值:h[1],时间复杂度O(1)。

int top(){
    return h[1];
}


删除堆顶最值
  将整个堆中的最后一个元素覆盖堆顶元素即h[1] = h[n],然后令n--,表示删除最后一个元素,然后让堆顶元素向下调整down(1),该操作时间复杂度 O(logn)。为什么要这样做呢?因为在堆中,直接删除堆顶元素比较困难,然而删除最后一个元素却很简单。

void pop(){
    h[1] = h[n--];
    down(1);
}


删除任意位置上的一个元素
  将整个堆中的最后一个元素覆盖 h 中下标为 k 位置的元素,即h[k] = h[n],然后令n--,表示删除最后一个元素。此时需要分类讨论,如果 h[k] 相对变大了,就需要down(k);如果 h[k] 相对变小了,就需要up(k)。其实,我们可以偷懒,不分类讨论,直接down(k), up(k),这两个中最多只会执行一个,该操作时间复杂度 O(logn)。

void remove(int k){
    h[k] = h[n--];
    down(k), up(k);
}


修改任意位置上的一个元素
  修改操作与删除某个位置上的元素类似,直接在h[k] 上修改,然后down(k), up(k)即可。

void update(int k, int x){
    h[k] = x;
    down(k), up(k);
}

如何快速的将一个数组建成堆
一般方法:一个一个往堆中插入的方式插入数组元素,但是这样做的时间复杂度是O(nlogn),有一个快速的数组建堆方案是,从 h 序列的 n/2 处向 1  逐个执行down() 操作即可,该操作时间复杂度可以优化到O(n).
算法基础课笔记-第二章 数据结构_第23张图片

  n/2是最后一个叶节点的父节点,也就是最后一个非叶子节点

例题

AcWing 838. 堆排序

题目描述
输入一个长度为 n 的整数数列,从小到大输出前 m 小的数。

输入
第一行包含整数 n 和 m。

第二行包含 n 个整数,表示整数数列。

输出格式
共一行,包含 m 个整数,表示整数数列中前 m 小的数。

输入样例:

5 3
4 5 1 3 2
1
2
输出样例:

1 2 3

#include 
using namespace std;

const int N = 1e5 + 10; 
int h[N], size;   
int n, m;

void down(int u){  // 将元素u向下调整使得满足堆的性质
	int t = u;
	if(u * 2 <= size && h[u * 2] <= h[t]) t = u * 2;  //如果u的左子节点存在且<=节点t的值,则将t更新为左子节点的索引
	if(u * 2 + 1 <= size && h[u * 2 + 1] <= h[t]) t = u * 2 + 1; //如果节点u的右子节点存在且小于等于节点t的值,则将t更新为右子节点的索引
	if(u != t){    // 如果u不等于t,即节点u存在比自己更小的子节点
		swap(h[u], h[t]);
		down(t);  // 继续向下调整以确保子树也满足堆的性质
	}
}

int main(){
	cin >> n >> m;
	for(int i = 1; i <= n; i++) cin >> h[i];
	size = n;  // 将size设置为n,表示堆的大小
	
	for(int i = n/2; i; i--) down(i);  //从最后一个非叶子节点开始向上调整,直到根节点,使得整个数组h满足堆的性质
	
	while(m--){
		cout << h[1];
		h[1] = h[size]; // 将最后一个元素移到堆顶
		size--;
		down(1);       // 向下调整堆顶元素,以恢复堆的性质
	}

	return 0 ;
}

AcWing 839. 模拟堆

题目
维护一个集合,初始时集合为空,支持如下几种操作:

I x,插入一个数 x;
PM,输出当前集合中的最小值;
DM,删除当前集合中的最小值(数据保证此时的最小值唯一);
D k,删除第 k 个插入的数;
C k x,修改第 k 个插入的数,将其变为 x ;
现在要进行 N 次操作,对于所有第 2 个操作,输出当前集合的最小值。

输入格式
第一行包含整数 N。

接下来 N 行,每行包含一个操作指令,操作指令为 I x,PM,DM,D k 或 C k x 中的一种。

输出格式
对于每个输出指令 PM,输出一个结果,表示当前集合中的最小值。

输入样例:

8
I -10
PM
I -10
D 1
C 2 8
I 6
PM
DM
输出样例:

-10
6

算法思路
   本题的基本操作依然还是来自基础堆,现在需要做的额外的工作是如何维护第 k 次插入的数与堆中结点下标 i 的映射关系。

  • 首先定义两个数组:hp[] 和 ph[],我们需要明白这两个数组的关系和作用:
  •  hp 是 heap to pointer 的缩写,hp[i] = k表示堆数组中的下标 i 所指的结点是第 k 次插入的。
  •  ph 是 pointer to heap 的缩写,ph[k] = i表示第 k 次插入的元素在堆数组中对应的结点下标 i。因此,hp 和 ph 是反函数。

为什么要使用 ph 和 hp 数组呢?
   原因在于删除第 k 次插入的元素时,我们必须知道第 k 次插入的元素在堆数组中的下标 i,因此使用 ph 数组可以方便查找。

// 删除第k次插入的元素


if (op == "D"){
	int k;
	cin >> k;
	k = ph[k]; // 找到第k次插入的元素在堆中的下标
	heap_swap(k, n); // 第k次插入的元素和堆尾元素交换
	n--; // 删除堆尾元素
	up(k), down(k);
}


插入时需要完成的操作:

if (op == "I")
{
	int x;
	cin >> x;
	n++, m++; // n代表堆中元素总数, m代表插入的次数
	ph[m] = n, hp[n] = m;
	h[n] = x; // 在结尾处插入新元素
	up(n);
}

  这样看起来,似乎有了 ph 数组就可以完成所有操作了,但是为什么还要有一个 hp 数组呢?
原因在于简单的堆的交换操作中,我们使用的是swap(h[a],h[b]),传入的参数a,b都是堆中的下标,但是无法知道每个堆数组的下标所对应的是第几次插入,因此需要引入hp,便于查找.
本质上来说,hp 的存在是为了实现交换堆中a, b下标元素时,交换ph[a] 和ph[b]。

// 所有堆内下标的交换都换成这个
void heap_swap(int a, int b)
{
	swap(ph[hp[a]], ph[hp[b]]);
	swap(hp[a], hp[b]);
	swap(h[a], h[b]);
}
#include 
using namespace std;

const int N = 1e5 + 10;
int h[N], hp[N], ph[N];
int n, m;

// 堆内交换操作传入的是堆中的下标
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 <= n && h[t] > h[u * 2]) t = u * 2;
    if (u * 2 + 1 <= n && h[t] > h[u * 2 + 1]) t = u * 2 + 1;
    if (t != u)
    {
        heap_swap(t, u);
        down(t);
    }
}

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

int main()
{
    int s;
    cin >> s;
    
    string op;
    while (s--)
    {
        cin >> op;
        if (op == "I")
        {
            int x;
            cin >> x;
            
            n++, m++;
            hp[n] = m, ph[m] = n;
            h[n] = x;
            up(n);
        }
        else if (op == "PM") cout << h[1] << endl;
        else if (op == "DM")
        {
            heap_swap(1, n);
            n--;
            down(1);
        }
        else if (op == "D")
        {
            int k;
            cin >> k;
            
            k = ph[k];
            heap_swap(k, n);
            n--;
            down(k), up(k);
        }
        else
        {
            int k, x;
            cin >> k >> x;
            
            k = ph[k];
            h[k] = x;
            down(k), up(k);
        }
    }
    
    return 0;
}

七、哈希表

存储结构

分为:链地址法、开放寻址法(常用)

下面的实现,本质上就是用数组模拟STL库中的 std::unordered_set

1. 拉链法

  本质就是邻接表,将重复的元素挂在一条链上。
  槽的大小:这里的槽就是指哈希表的单元格,指的就是 N,一般取 10^5 或 10^6 后面的质数
  哈希函数:hash(x) = ( x % N + N ) % N
 

插入操作:利用哈希函数得到哈希值 k,然后确定的这个槽就是 h[k]

void insert(int x){
    int k = (x % N + N) % N; // 哈希函数
    e[idx] = x, ne[idx] = h[k], h[k] = idx++; // 头插法
}


查询操作:与插入操作类似,如果一个 hash 值对应的槽有多个元素挂在上面,则顺序遍历即可。

bool query(int x){
    int k = (x % N + N) % N;
    for (int i = h[k]; ~i; i = ne[i])
        if (e[i] == x) return true;
    return false;
}

~i 等价于 i != 0
运行效果对比:使用该方法耗时56ms,使用STL库自带的unordered_set容器耗时186ms。

2. 开放寻址法(常用)

    开放寻址法只开了一个数组,但是大小要开到题目的数据范围的 2~3 倍的质数。这样取是一个经验方法,冲突的概率比较低。该方法常用的原因是,可以使用更少的数组个数。

   槽的大小:题目的数据范围的2~3倍,同样也是一个质数。
   哈希函数:hash(x) = (x % N + N) % N
   冲突处理:如果当前 h[k] 已经被占用,则像上厕所一样一个一个坑位顺序往后找,直到找到第一个空的坑位为止。

   find函数:开放寻址法的核心操作是 find 函数,也称为蹲坑法,find(x) 如果在哈希表中能找到 x,则返回 x 所在的位置;如果 x 在哈希表中不存在的话,则返回它应该存储的位置。

// null 这里要设置一个用不到的标志数,当做标志位
const int N = 200003, null = 0x3f3f3f3f3;
int h[N]; // 要先memset(h, 0x3f, sizeof h);

int find(int x)
{
    int k = (x % N + N) % N;
    while (h[k] != null && h[k] != x) // 要么找到x,要么找到空的坑位
    {
        k++;
        if (k === N) k = 0; // 类似于循环队列
    }
    return k;
}


插入操作:

int k = find(x);  // 找到一个可以给x的位置,其哈希值记录为k
h[k] = x;         //把x放到那个位置上


查找操作:

int k = find(x);   // find函数返回的k:x不存在,则找一个空的坑位;x存在,返回x的位置
if (h[k] != null) puts("Yes");
else puts("No");


删除操作:类似于冲突处理,找到以后打一个标记。

memset()函数

函数原型 void *memset(void *str, int c, size_t n)

解释:将s中当前位置后面的 n 个字节用 ch 替换并返回 s 。

作用:是在一段内存块中填充某个给定的值,是对较大的结构体或数组进行清零的一种最快方法

头文件:C中#include,C++中#include

作用就是用于初始化,但是需要注意的是memset赋值的时候是按字节赋值,是将参数化成二进制之后填入一个字节。

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

例题

AcWing 840. 模拟散列表

题目描述
维护一个集合,支持如下几种操作:

  • I x,插入一个数 x xx;
  • Q x,询问数 x xx 是否在集合中出现过;

现在要进行 N  次操作,对于每个询问操作输出对应的结果。
输入
第一行包含整数 N,表示操作数量。

接下来 N 行,每行包含一个操作指令,操作指令为 I x,Q x 中的一种。

输出
对于每个询问指令 Q x,输出一个询问结果,如果 x 在集合中出现过,则输出 Yes,否则输出 No。

输入样例:

5
I 1
I 2
I 3
Q 2
Q 5
输出样例:

Yes
No

代码:

1.链地址法

#include 
#include 
using namespace std;

// mod的这个数,一般要开质数
const int N = 100003;

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

// 头插法
void insert(int x){
    int k = (x % N + N) % N; // k是hash值
    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; i = ne[i])
        if (e[i] == x) return true;
    return false;
}

int main(){
    int n;
    scanf("%d", &n);
    
    // 清空哈希表(邻接表)
    memset(h, -1, sizeof h);
    
    while (n--){
        char op[2];
        int x;
        
        scanf("%s%d", op, &x);
        
        if (*op == 'I') insert(x);
        else{
            if (find(x)) puts("Yes");
            else puts("No");
        }
    }
    
    return 0;
}

2.开放寻址法

#include 
#include 
using namespace std;

// 注意:N要开2~3倍数据范围的奇数, null是表示哈希槽尚未占用的标志
const int N = 2e5 + 3, null = 0x3f3f3f3f;
int h[N];
int n;

// 开放寻址法的核心函数
int find(int x)
{
    int k = (x % N + N) % N;
    while (h[k] != null && h[k] != x) 
    {
        k++;
        if (k == N) k = 0;
    }
    return k;
}

int main()
{
    scanf("%d", &n);
    // 要像使用邻接表那样初始化都为null
    memset(h, 0x3f, sizeof h);
    char op[2];
    int x;
    while (n--)
    {
        scanf("%s%d", op, &x);
        
        int k = find(x);
        if (*op == 'I') h[k] = x;
        else
        {
            if (h[k] != null) puts("Yes");
            else puts("No");
        }
    }
    
    return 0;
}

对比:

  1. 插入:链地址法:用单链表中的头插法。开放寻址法:往后找一个空的坑位
  2. 查询:都只能线性遍历。链地址法根据ne[ ]往后找,开放寻址法根据h[k]往后找
  3. 链地址法的插入和查询分别写成两个函数。而开放寻址的插入和查询都只需要一个find

字符串哈希

字符串前缀哈希法
    我们之前学过KMP字符串匹配算法,但是实际上我们很多字符串的问题可以使用字符串哈希来做。

    如何求字符串的哈希值呢?将一个字符串看成一个 P 进制的数,字符串的每一个字符都可以看成 P 进制下的某一位数字。例如:对字符串"ABCD"进行哈希:
   首先,每一个字符可以设计为映射某一个数字,ABCD可以看成 (1234)p = 1 × p^3 + 2 × p^2 + 3 × p^1 + 4 × p^0,转化成一个数字。但是这样会出现一个问题,如果字符串特别长的话,比如说有10万个字符,那么会导致这个 hash 值非常大,无法存储。因此要对这个结果取模一个较小的数Q,即 (1234)p = (1 × p^3 + 2 × p^2 + 3 × p^1 + 4 × p^0 ) % Q,因此这样就可以将一个字符串映射到从0 ~ Q−1之间的一个数了。

注意:

  • 字符最好不要映射成为 0,不然会产生二义性,一般映射的数字先从 1 开始。
  • 传统的 hash 是要设计冲突解决策略的,但是字符串哈希我们假设人品足够好,不会出现冲突。换言之,该方法发生冲突的可能性非常非常非常低。
  • 经验值:P 取 131 或者 13331,Q 取 2^64
  • P 、Q 取成这对数值,则 99.99%的情况下不会发生冲突。这里有一个技巧,可以用 unsigned long long 类型来存储哈希值,这样在计算时就无需取模

什么是字符串前缀哈希呢?

  假设现在存在一个字符串 str="ABCABCDEYXCACWING” 
h [ 0 ] = 0 
h [ 1 ] = " A " 的哈希值
h [ 2 ] = " A B " 的哈希值
h [ 3 ] = " A B C " 的哈希值
h [ 4 ] = " A B C A " 的哈希值,以此类推。

用上述的哈希方式,配合上前缀哈希,有什么好处?

   好处在于我们可以利用前缀哈希,算出整个字符串任意一个子串的哈希值,类似于前缀和
因为我们是将字符串看成 P 进制的数,因此字符串左边是高位,右边是低位。

   ( 求前缀和:a[r] ~ a[l] 的和 = S[r] - S[l-1] )

算法基础课笔记-第二章 数据结构_第24张图片因此,在我们预处理完所有前缀的哈希值后,就可以用 O(1) 的时间算出任意一个子串的哈希值。同时,预处理前缀的哈希值也非常简单,利用如下方式处理即可:

// 字符串前缀哈希预处理
h[0] = 0;
for (int i = 1; i <= n; i++) h[i] = h[i - 1] * P + str[i];

在实际应用中,如果两个子串的哈希值相同,则我们认为这两个子串完全相同。很多特别困难的字符串题目,都可以用这个方法水掉。

区间和公式的理解: ABCDE 与 ABC 的前三个字符值是一样,只差两位,乘上P的二次方把 ABC 变为 ABC00,再用 ABCDE - ABC00 得到 DE 的哈希值。

例题

AcWing 841. 字符串哈希
题目
给定一个长度为 n 的字符串,再给定 m 个询问,每个询问包含四个整数 l1,r1,l2,r2,请你判断 [ l1, r1 ] 和 [ l2, r2 ] 这两个区间所包含的字符串子串是否完全相同。

输入
第一行包含整数 n 和 m,表示字符串长度和询问次数。

第二行包含一个长度为 n 的字符串,字符串中只包含大小写英文字母和数字。

接下来 m 行,每行包含四个整数 l1,r1,l2,r2,表示一次询问所涉及的两个区间。

注意,字符串的位置从 1 开始编号。

输出
对于每个询问输出一个结果,如果两个字符串子串完全相同则输出 Yes,否则输出 No。

每个结果占一行。

输入样例:

8 3
aabbaabb
1 3 5 7
1 3 6 8
1 2 1 2
输出样例:

Yes
No
Yes

#include 
using namespace std;

typedef unsigned long long ull;

const int N = 1e5 + 10, P = 131; // P取131或者13331,经验值
char str[N];
int n, m;

// 这里的p[i]的存在是因为在计算时,p的i次方可能经常用到
ull h[N], p[N];

// 计算字符串子串的哈希值
uul 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的幂次
		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;
}

核心就是1.计算前缀哈希值和p的幂次 

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

2.get函数计算得子串的哈希值

return h[r] - h[l - 1] * p[r - l + 1] 

八、C++ STL

vector, 变长数组,倍增的思想
    size()  返回数组里的元素个数
    empty()  返回是否为空。空:true。时复:O(1)。这两个函数基本所有容器都有。
    clear()  清空。不是所有容器都有,比如队列queue就没有。
    front()/back()
    push_back()/pop_back()
    begin()/end()
    支持比较运算,按字典序

    倍增:自动变长。

    c/c++的特点:系统为某进程分配空间时,所需时间与空间大小无关,只与申请次数有关。所以变长数组要尽量减少申请空间的次数,也是优化目标。可以浪费空间。

pair
    first, 第一个元素
    second, 第二个元素
    支持比较运算,以first为第一关键字,以second为第二关键字(字典序)

    适用于:有两种不同类型的数据

    初始化:1.  p = make_pair( 1, 2 )   2. p = { 1, 2 }

    可看做二元结构体,还自带比较函数

string,字符串
    size()/length()  返回字符串长度
    empty()
    clear()
    substr(起始下标,(子串长度))  返回子串 。可省略第二个参数,返回从起始下标开始的整个子串
    c_str()  返回字符串所在字符数组的起始地址

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

    队列没有clear()函数!

priority_queue, 优先队列,默认是大根堆。实现原理就是堆
    size()
    empty()
    push()  插入一个元素
    top()  返回堆顶元素
    pop()  弹出堆顶元素

    需要写头文件 #include
    定义成小根堆的方式:priority_queue, greater> q;

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

deque, 双端队列
    size()
    empty()
    clear()
    front()/back()
    push_back()/pop_back()
    push_front()/pop_front()
    begin()/end()
    []

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

  set/multiset

        multiset可以有重复元素
        insert()  插入一个数
        find()  查找一个数
        count()  返回某一个数的个数,计数君
        erase()
            (1) 输入一个数x,删除所有x   时间复杂度O(k + logn)
            (2) 输入一个迭代器,删除这个迭代器,只能删一个
        lower_bound()/upper_bound() ☆ set最核心的两个操作
            lower_bound(x)  返回 >=x 的最小的数 的迭代器
            upper_bound(x)  返回 >x 的最小的数 的迭代器

    map/multimap

        map内部实现:平衡树
        insert()  插入的数是一个pair
        erase()  输入的参数是pair或者迭代器
        find()
        []  注意multimap不支持此操作。 基本所有操作的时间复杂度都是 O(logn)
        lower_bound()/upper_bound()

        使用方式:类似数组。取元素时复O(logn)

#include
    map a;
    a["str"] = 1;
    cout << a["str"]; // 输出1

unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
    和上面类似,增删改查的时间复杂度是 O(1)
    不支持 lower_bound()/upper_bound(), 迭代器的++,--。因为内部无序,所有排序有关的操作都不支持

bitset, 圧位
    bitset<10000> s;
    ~, &, |, ^
    >>, <<
    ==, !=
    [ ]

    count()  返回有多少个1

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

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

参考

常用代码模板2——数据结构 - AcWing

AcWing 827. 双链表_铁头娃撞碎南墙的博客-CSDN博客

铁头娃撞碎南墙_简单算法,数据结构与算法,Java-CSDN博客

KMP 算法详解 - 知乎 (zhihu.com)

全网最通俗的KMP算法图解 - 知乎 (zhihu.com)

fucking-algorithm/数据结构系列 at master · labuladong/fucking-algorithm · GitHub

KMP算法最浅显理解——一看就明白_路漫远吾求索的博客-CSDN博客

(算法)通俗易懂的字符串匹配KMP算法及求next值算法_Sirm23333的博客-CSDN博客

算法学习笔记(1) : 并查集 - 知乎 (zhihu.com)

你可能感兴趣的:(笔记,数据结构)