第3章〓数据结构基础 本章介绍基础的数据结构和一个比较简单的高级数据结构——并查集。它们是蓝桥杯软件赛的必考知识点。 很多计算机教材提到: 程序=数据结构+算法本书作者曾写过一句赠言: “以数据结构为弓,以算法为箭”。。数据结构是计算机存储、组织数据的方法。在常见的数据结构教材中一般包含数组(Array)、栈(Stack)、队列(Queue)、链表(Linked List)、树(Tree)、图(Graph)、堆(Heap)、散列表(Hash Table)等内容。数据结构分为线性表和非线性表两大类。数组、栈、队列、链表是线性表,其他是非线性表。 1. 线性数据结构概述 线性表有数组、链表、队列、栈,它们有一个共同的特征: 把同类型的数据按顺序一个接一个地串在一起。与非线性数据结构相比,线性表的存储和使用显得很简单。由于简单,很多高级操作线性表无法完成。 下面对线性表做一个概述,并比较它们的优缺点。 (1) 数组。数组是最简单的数据结构,它的逻辑结构形式和数据在物理内存上的存储形式完全一样。例如在C语言中定义一个整型数组int a[5],系统会分配一个20字节的存储空间,而且这20字节的存储地址是连续的。 1#include 2using namespace std; 3int main(){ 4 int a[5]; 5 for (int i=0;i<5;i++) 6cout<<&a[i]<<" ";//打印5个整数的存储地址 7 return 0; 8} 在作者的计算机上运行,输出5个整数的存储地址: 0x6dfed8 0x6dfedc 0x6dfee0 0x6dfee4 0x6dfee8 数组的优点如下: ① 简单,容易理解,容易编程。 ② 访问快捷,如果要定位到某个数据,只需要使用下标即可。例如a[0]是第1个数据,a[i]是第i-1个数据。虽然a[0]在物理上是第1个数据,但是在编程时有时从a[1]开始更符合逻辑。 ③ 与某些应用场景直接对应。例如数列是一维数组,可以在一维数组上进行排序操作; 矩阵是二维数组,表示平面的坐标; 二维数组还可以用来存储图。 数组的缺点: 由于数组是连续存储的,中间不能中断,这导致删除和增加数据非常麻烦和耗时。例如要删除数组int a[1000]的第5个数据,只能使用覆盖的方法,从第6个数据开始,每个往前挪一个位置,需要挪动近1000次。增加数据也麻烦,例如要在第5个位置插入一个数据,只能把原来从第5个开始的数据逐个往后挪一位,空出第5个位置给新数据,也需要挪动近1000次。 (2) 链表。链表能克服数组的缺点,链表的插入和删除操作不需要挪动数据。简单地说,链表是“用指针串起来的数组”,链表的数据不是连续存放的,而是用指针串起来的。例如,删除链表的第3个数据,只要把原来连接第3个数据的指针断开,然后连接它前后的数据即可,不用挪动任何数据的存储位置,如图3.1所示。 图3.1删除第3个数据 链表的优点: 增加和删除数据很便捷。这个优点弥补了数组的缺点。 链表的缺点: 定位某个数据比较麻烦。例如要输出第500个数据,需要从链表头开始,沿着指针一步一步走,直到第500个。 链表和数组的优缺点正好相反,它们的应用场合不同,数组适合静态数据,链表适合动态数据。 链表如何编程实现?在常见的数据结构教材中,链表的数据节点是动态分配的,各节点之间用指针来连接。但是在算法竞赛中,如果手写链表,一般不用动态分配,而是用静态数组来模拟各种场景的手写链表参考: 《算法竞赛》,清华大学出版社,罗勇军、郭卫斌著,第3页的“1.1.2 静态链表”。。当然,除非有必要,一般不手写链表,而是用系统提供的链表,例如C++STL的list。 (3) 队列。队列是线性数据的一种使用方式,模拟现实世界的排队操作。例如排队购物,只能从队头离开队伍,新来的人只能排到队尾,不能插队。队列有一个出口和一个入口,出口是队头,入口是队尾。队列的编程实现可以用数组,也可以用链表。 队列这种数据结构无所谓优缺点,只有适合不适合。例如宽度优先搜索(BFS)就是基于队列的,用其他数据结构都不合适。 (4) 栈。栈也是线性数据的一种使用方式,模拟现实世界的单出入口。例如一管泡腾片,先放进去的泡腾片位于最底层,最后才能拿出来。栈的编程比队列更简单,同样可以用数组或链表实现。 栈有它的使用场合,例如递归使用栈来处理函数的自我调用过程。 2. 非线性数据结构概述 (1) 二叉树。二叉树是一种高度组织性、高效率的数据结构。例如在一棵有n个节点的满二叉树上定位某个数据,只需要走O(log2n)步就能找到这个数据; 插入和删除某个数据也只需要O(log2n)次操作。不过,如果二叉树的形态没有组织好,可能退化为链表,所以维持二叉树的平衡是一个重要的问题。在二叉树的基础上发展出了很多高级数据结构和算法。大多数高级数据结构,例如树状数组、线段树、树链剖分、平衡树、动态树等,都是基于二叉树的本作者曾拟过一句赠言: “二叉树累并快乐着,她有一大堆孩子,都是高级数据结构”。,可以说“高级数据结构≈基于二叉树的数据结构”。 (2) 哈希表(Hash Table,又称为散列表)。哈希表是一种“以空间换时间”的数据结构,是一种重要的数据组织方法,用起来简单、方便,在算法竞赛中很常见。 在使用哈希表时,用一个哈希函数计算出它的哈希值,这个哈希值直接对应到空间的某个位置(在大多数情况下,这个哈希值就是存储地址),当后面需要访问这个数据时,只需要再次使用哈希函数计算出哈希值就能直接定位到它的存储位置,所以访问速度取决于哈希函数的计算量,差不多就是O(1)。 哈希表的主要缺点: 不同的数据,计算出的哈希值可能相同,从而导致冲突。所以在使用哈希表时一般需要使用一个修正方法,判断是否产生了冲突。当然,更关键的是设计一个好的哈希函数,从根源上减少冲突的产生。设计哈希函数,一个重要的思想是“雪崩效应”,如果两个数据有一点不同,它们的哈希值就会差别很大,从而不容易冲突。 哈希表的空间效率和时间效率是矛盾的,使用的空间越大,越容易设计哈希函数。如果空间很小,再好的哈希函数也会产生冲突。在使用哈希表时,需要在空间和时间效率上取得平衡。 3.1数组与高精度 数组是最简单的数据结构。数组的一个应用是高精度,高精度算法就是大数的计算方法。例如两个整数的计算,Java和Python能直接计算任意大的数,而C++的最大数据类型是64位的long long,更大的数不能直接计算,需要用数组来模拟。例如两个200位的十进制数相加,定义int a[205]和int b[205],a[i]和b[i]代表整数的第i位,a[0]是个位,a[1]是十位,a[2]是百位,等等。 在竞赛中经常用到很大的数组。建议不要用malloc()动态分配,因为动态分配需要多写代码而且容易出错。大数组应该定义为全局静态数组,而且不需要初始化为0,因为全局变量在编译时会自动初始化为全0。 1#include 2using namespace std; 3int a[10000000];//定义一个很大的全局数组,一千万个。自动初始化为0 4int main(){ 5 cout << a[0]; //输出0 6 return 0; 7} 大数组不能定义在函数内部,可能会导致栈溢出错误。因为大多数编译器的局部变量是在用到时才分配,大小不能超过栈,而栈一般不会很大。下面这样做很可能会报错: 1#include 2using namespace std; 3int main(){ 4 int a[10000000]={0};//这样写大多数编译器会报错 5 cout << a[0];//出错 6 return 0; 7} 另外,注意全局变量和局部变量的初值。全局变量如果没有赋值,在编译时会被自动初始化为0。在函数内部定义的局部变量,若需要初值为0,一定要初始化为0,否则初值不可预测。 1#include 2using namespace std; 3int a;//全局变量,如果不赋值,自动初始化为0 4int c = 999;//赋值为999 5int main(){ 6 int b; 7 cout << a < 2using namespace std; 3char a[1005],b[1005];//加数和被加数,或者定义为char 4string add(string sa,string sb){ 5 int lena=sa.size(),lenb=sb.size(); 6 for(int i=0;ilenb ? lena : lenb; 11 for(int i=0;i=0;i--)//把数字转换成字符,然后翻转 19ans += a[i]+'0'; 20 return ans; 21} 22int main(){ 23 string sa,sb; 24 cin >> sa >> sb; 25 cout << add(sa,sb); 26 return 0; 27} 2. 高精度减法 例3.2高精度减法https://www.luogu.com.cn/problem/P2142 问题描述: 给定两个整数a、b(第二个数可能比第一个数大),求它们的差。 输入: 分两行输入,0 2using namespace std; 3char a[11000],b[11000];//被减数和减数 4string sub(string sa,string sb){ 5 if(sa == sb) return "0";//特判: 两数字相等 6 bool neg = 0;//标记是否为负数 7 if(sa.size() < sb.size() || sa.size() == sb.size() && sa < sb) 8swap(sa, sb), neg = 1;//让a大于b 9 int lena=sa.size(),lenb=sb.size(); 10 for(int i=0;i0) ; //找到首位为0的位置,什么都不做 23 lmax++; 24 string ans; 25 for(int i=lmax-1;i>=0;i--)//把数字转换成字符,然后翻转 26ans += a[i]+'0'; 27 if(neg) ans = "-" + ans;//检查是否为负数 28 return ans; 29} 30int main(){ 31 string sa,sb; cin>>sa>>sb; 32 cout< 2using namespace std; 3int a[2005], b[2005], c[4005];//c = a*b 4string mul(string sa,string sb){ 5 if(sa=="0"||sb=="0")return "0"; 6 int lena=sa.size(),lenb=sb.size(); 7 for(int i=0;i=1;i--) 18 ans += c[i]+'0';//倒过来,把c的高位放在ans的前面 19 return ans; 20} 21int main(){ 22 string sa,sb; 23 cin >> sa >> sb; 24 cout << mul(sa,sb); 25 return 0; 26} 4. 高精度除法 例3.4A/B Problemhttps://www.luogu.com.cn/problem/P1480 问题描述: 给出两个整数a和b,求它们的商。 输入: 输入共两行,第一行是被除数,第二行是除数。0 2using namespace std; 3long long a[10001],b,c[10001];//被除数、除数、商 4int main(){ 5 string sa; 6 cin >> sa >> b;//读被除数、除数 7 int len=sa.size();//被除数的位数 8 for (int i=1;i<=len;i++) 9 a[i]=sa[i-1]-'0';//将被除数一位一位放入a数组 10 long long d = 0;//余数 11 for (int i=1;i<=len;i++) {//被除数一位一位除以除数,若不够除,借位 12c[i] = (d*10+a[i])/b; 13d = (d*10+a[i])%b; 14 } 15 int lenc = 1;//商的位数 16 while (c[lenc]==0 && lenc//万能头文件 这个万能头文件包含了每个标准库,几乎所有编译器都支持。蓝桥杯使用的编译器也支持它,所以大家不用担心。 3.2.1String库 在算法竞赛中,字符串处理是极为常见的考点。虽然C语言也有字符串操作,但是只能通过定义字符串数组,自己编码实现字符串运算,相当麻烦。用STL的Strings libraryhttps://en.cppreference.com/w/cpp/string/basic_string提供的字符串处理函数,可以轻松、简便地处理字符串的计算。可以说,如果在竞赛时不用string,成绩会大受影响。 表3.1列出了常用的String函数。 表3.1常用的String函数 函数 功能 length() 返回字符串的长度 size() 返回字符串的长度 push_back() 在字符串尾部添加一个字符 append() 在字符串尾部添加一个字符串 find(str,pos) 查找str在pos(含)之后第一次出现的位置,若不写pos,默认为pos=0。如果找不到,返回 -1,注意需要强制转换为int型才能输出-1 substr(pos,len) 返回从pos位置开始截取最多len个字符组成的字符串,如果从pos开始的子串的长度不足len,则截取这个子串 insert(index,count,str) 在index处连续插入count次字符串str insert(index,str) 在index处插入字符串str erase(index,count) 将字符串从index位置开始(含)的count个字符删除,若不传参给count,则表示删去index位置及以后的所有字符 replace(pos,count,str) 把从pos位置开始count个字符的子串替换为str replace(first,last,str) 把以first开始(含)、last结束(不含)的子串替换为str,其中first和last均为迭代器 empty() 判断字符串是否为空,如果为空返回1,不空返回0 下面给出例子。注意第18行和第52~56行,string重载了符号+、==、<、>,可以直接用于计算。 特别是字符串的比较,请特别注意。两个string变量,按它们的字典序进行比较。例如"bcd">"abc"。容易让人误解的是两个字符串长度不一样时的情况,例如“123”和“99”,按字符串比较,"123"<"99",但按数字比较是123>99,两种比较的结果不同。 1#include 2using namespace std; 3int main(){ 4//定义、初始化、赋值 5 string s1;//定义 6 string s2="bcd";//定义并赋值 7 s2="efg";//重新赋值 8 cout< s7)cout << ">" < 2using namespace std; 3int main(){ 4 vector a;//初始化一个size为0的vector 5 a.insert(a.begin(), 4, 2);//在a开始位置处插入4个2 6 //下面的4个for是4种遍历方法 7 for (int i=0;i::iterator it = a.begin(); it != a.end(); ++it) 10cout << *it << " "; cout <<"\n";//通过迭代器访问 11 for (auto it = a.begin(); it != a.end(); ++it) //用auto获得迭代器 12cout << *it << " "; cout <<"\n"; 13 for (auto i : a)//直接访问 14cout << i << " "; cout <<"\n"; 15 a.insert(a.begin()+1,9);//在a[1]处插入9, a = {2 9 2 2 2} 16 a.insert(a.begin()+2,2,5);//在a[2]处插入两个5,a = {2 9 5 5 2 2 2} 17 a.resize(3);//改变a的大小。a = {2 9 5} 18 vector c(a);//复制a到c 19 vector b; 20 b.insert(b.begin(), a.begin(), a.begin()+3); //将a的前3个数复制到b 21 //下面演示二维vector 22 vector> ch(2,vector(3,'#'));//2行3列 23 for (int i=0;i<2;i++)//2行 24for (int j=0;j<3;j++)//3列 25cout << ch[i][j]; 26 //下面演示用vector存结构体 27 struct point{ int x,y;}; 28 vector PP; 29 for (int i=0;i<3;i++){//赋值 30point t; t.x=i; t.y=i; 31PP.push_back(t); 32 } 33 for (int i=0;i::iterator it=PP.begin(); 36 for (;it!=PP.end();++it) cout << it->x <<" " << it->y<<"\n"; 37 return 0; 38} 3.2.5算法函数概述 STL算法库https://zh.cppreference.com/w/cpp/algorithm提供了大量的实用函数(例如查找、排序、计数等)。STL把算法函数分为以下几类: (1) 不修改序列的操作,有批量操作、搜索操作、折叠操作。例如搜索函数find(first,last,value),用于搜索[first,last)区间内是否有value。 (2) 修改序列的操作,有复制操作、交换操作、变换操作、生成操作、移除操作、顺序变更操作、采样操作。 (3) 排序和相关操作,有划分操作、排序操作、二分搜索操作(在已划分范围上)、集合操作(在已排序范围上)、归并操作(在已排序范围上)、堆操作、最大/最小操作、字典序比较操作、排列操作。 (4) 数值运算。 (5) 在未初始化内存上的操作。 下面简要介绍一些函数,它们在算法竞赛中很常用,如表3.3所示。 表3.3一些常用函数 函数 功能 find(a.begin(),a.end(),value) 顺序查找,value 为需要查找的值 reverse(a.begin(),a.end()) 翻转数组、字符串 sort(a.begin(),a.end(),cmp) 排序,cmp 为自定义的比较函数 unique(first,last) 去除容器中相邻的重复元素,所以一般需要先排序,然后用unique去重。返回值为指向去重后最后一个不同元素的迭代器。注意原容器大小不变,末尾的重复元素可以用erase()删除 nth_element(a.begin(), a.begin()+mid,a.end(),cmp) 按指定范围进行分类,找出序列中第 n 大的元素,使其左边均为小于它的数,右边均为大于它的数。该函数一般用于求第k小的数 binary_search(a.begin(), a.end(),value) 在已经排好序的序列上做二分查找,需要先用sort()排序。value为需要查找的值,如果找到,返回true,否则返回false lower_bound(a.begin(), a.end(),x) 在一个有序序列中进行二分查找,返回指向第一个大于或等于x的元素的位置。如果不存在这样的元素,则返回尾迭代器 upper_bound(a.begin(), a.end(),x) 在一个有序序列中进行二分查找,返回指向第一个大于x的元素的位置的迭代器。如果不存在这样的元素,则返回尾迭代器 next_permutation(a.begin(),a.end()) 将当前排列更改为全排列中的下一个排列。如果当前排列已经是全排列中的最后一个排列,该函数返回false,并将排列更改为全排列中的第一个排列 prev_permutation() 将当前排列更改为全排列中的上一个排列,用法与 next_permutation()相同 max()、min() 查找最大、最小值 swap() 交换两个元素的值 下面给出例子。为了方便读者理解,数列a[]分别用vector和数组定义,然后用各种函数操作它。 (1) 用vector定义数列a,函数用迭代器访问。 1#include 2using namespace std; 3vector a {23,5,36,4,8,7,5,23}; 4void out(){ 5 for(int i=0;i b {2,5,7,4,5}; 33 do { 34for(int i = 1; i < 4; i++) cout << b[i] << " "; 35cout << endl;//输出:5 7 4 ;7 4 5 ;7 5 4 ; 36 } while (next_permutation(b.begin()+1, b.begin()+4));//从{5,7,4}开始的全排列 37 return 0; 38} (2) 用数组定义数列a。 1#include 2using namespace std; 3int a[] = {23,5,36,4,8,7,5,23}; 4int n = 8; 5void out(){ 6 for(int i=0;i>s1;”。 (3) set中的元素不像map那样可以同时拥有实值(value)和键值(key),只能存储键,是单纯的键的集合。 (4) set 中元素的值不能直接被改变。 (5) set支持大部分的map操作,但是set不支持下标操作。 sethttps://en.cppreference.com/w/cpp/container/set的常见操作如表3.4所示。 表3.4set的常见操作 操作说明 begin() 返回指向第一个元素的迭代器 end() 返回指向最后一个元素的后一个位置的迭代器 clear() 清除所有元素 count() 返回某个元素的个数,如果有这个元素,它的个数为1,否则为0 size() 返回集合中元素的数量 empty() 如果集合为空,返回true,否则返回false insert() 在集合中插入元素 erase() 删除集合中的元素 find() 返回一个指向被查找到元素的迭代器,如果没有找到,返回end() upper_bound() 返回大于某个值元素的迭代器 lower_bound() 返回指向大于(或等于)某值的第一个元素的迭代器 max_size() 返回集合能容纳的元素的最大限值 set在竞赛中常用于去重,把所有元素放进set,重复的会被去掉,保留在set中的都是唯一的,且按从小到大排序。 下面是set操作的例子,用count()判断set中是否有某个元素,见第20行和第21行。 1#include 2using namespace std; 3setst{5,9,2,3};//定义set,赋初值 4void out(){//输出set的所有元素 5 //set::iterator it = st.begin(); 6 auto it = st.begin();//与上一句的功能相同 7 for( ; it != st.end(); ++it) cout<<*it<<" "; 8 cout << endl; 9} 10int main() { 11 out();//set的元素从小到大排序。输出:2 3 5 9 12 st.insert(9); 13 out();//重复元素被去重,输出:2 3 5 9 14 auto it = st.lower_bound(6);//返回大于或等于6的第一个元素的迭代器 15 cout<<*it< 2using namespace std; 3mapmp{{7,"tom"},{2,"Joy"},{3,"Rose"}};//定义和赋初值 4void out(){//输出map的所有元素 5 //set::iterator it = mp.begin(); 6 auto it = mp.begin();//与上一句的功能相同 7 for( ; it != mp.end(); ++it) 8cout<first<<" "<second<<"; "; 9 cout << endl; 10} 11int main() { 12 out();//输出:2 Joy; 3 Rose; 7 tom; 13 cout<<"size = "<(9, "Hu"));//用pair定义键值对,然后插入 17 out();//输出:2 Joy; 3 Luo; 5 Wang; 7 tom; 9 Hu; 18 mp.erase(5); 19 out();//输出:2 Joy; 3 Luo; 7 tom; 9 Hu; 20 auto it = mp.find(3); //查询 21 if(it != mp.end()) 22cout<first<<" "<second< 2using namespace std; 3map mp; 4int a[101000],b[101000]; 5int main(){ 6 int n,m; 7 scanf("%d%d",&n,&m); 8for(int i=1;i<=n;i++) scanf("%d",&a[i]); 9for(int i=1;i<=m;i++) { 10scanf("%d",&b[i]); 11mp[b[i]]=true; 12 } 13for(int i=1;i<=n;i++) 14if(mp[a[i]]) 15printf("%d ",a[i]);//如果出现过,直接输出 16 return 0; 17} (2) 用set实现。 1#include 2using namespace std; 3set st; 4int a[101000], b[101000]; 5int main() { 6 int n, m; 7scanf("%d%d", &n, &m); 8 for(int i = 1; i <= n; i++) scanf("%d", &a[i]); 9 for(int i = 1; i <= m; i++) { 10scanf("%d", &b[i]); 11st.insert(b[i]); 12 } 13 for(int i = 1; i <= n; i++) 14if(st.count(a[i])) 15printf("%d ",a[i]); 16 return 0; 17} 3.3链表 数组的特点是使用连续的存储空间,访问每个数组元素非常快捷、简单。但是在某些情况下,数组的这些特点变成了缺点: (1) 需要占用连续的空间。若某个数组很大,可能没有这么大的连续空间给它用。不过这一般发生在较大的工程软件中,在竞赛中不用考虑占用的空间是否连续。例如一道题给定的存储空间是256MB,那么定义char a[100000000],占用了连续的100MB空间,也是合法的。 (2) 删除和插入的效率很低。例如删除数组中间的一个数据,需要把后面所有的数据往前挪动,以填补这个空位,产生大量的复制开销,计算量为O(n)。中间插入数据,也同样需要挪动大量的数据。在算法竞赛中这是经常出现的考点,此时不能简单地使用数组。 数据结构“链表”能解决上述问题,它不需要把数据存储在连续的空间上,而且删除和增加数据都很方便。链表把数据元素用指针串起来,这些数据元素的存储位置可以是连续的,也可以不连续。 链表有单向链表和双向链表两种。单向链表如图3.2所示,指针是单向的,只能从左向右单向遍历数据。链表的头和尾比较特殊,为了方便从任何一个位置出发能遍历到整个链表,让首尾相接,尾巴tail的next指针指向头部head的data。由于链表是循环的,所以任意位置都可以成为头或尾。有时应用场景比较简单,不需要循环,可以让第一个节点始终是头。 图3.2单向链表 双向链表是对单向链表的优化。每个数据节点有两个指针,pre指针指向前一个节点,next指针指向后一个节点。双向链表也可以是循环的,最后节点的next指针指向第一个节点,第一个节点的pre指针指向最后的节点,如图3.3所示。 图3.3双向链表 在需要频繁访问前后几个节点的场合可以使用双向链表。例如删除一个节点now的操作,前一个节点是pre,后一个节点是next,那么让pre指向next,now被跳过,相当于被删除。此时需要找到pre和next节点,如果是双向链表,很容易得到pre和next; 如果是单向链表,不方便找到pre。 链表的操作有初始化、遍历、插入、删除、查找、释放等。 和数组相比,链表的优点是删除和插入很快,例如删除功能,找到节点后,直接断开指向它的指针,再指向它后面的节点即可,不需要移动其他所有节点。 链表仍是一种简单的数据结构,和数组一样,它的缺点是查找慢。例如查找data等于某个值的节点时,需要遍历整个链表才能找到它,计算量是O(n)。 3.3.1手写链表 链表的编程实现有动态链表、静态链表、STL链表等多种方法。动态链表是用malloc()函数动态分配空间生成节点,静态链表是定义全局静态数组来模拟链表。在算法竞赛中为了加快编码速度,一般用静态链表或STL list。 1. 静态链表 静态链表用数组来模拟链表,即“逻辑上是链表,物理上是数组”。 下面先给出单向链表的实现。 (1) 链表节点的定义。用struct node定义链表节点: id,表示这个节点的编号,一般用不着,因为可以用nodes[i]的下标i来表示节点的编号; data,节点存储的数据; nextid,链表的指针,指向下一个节点的编号。 1const int N = 10000;//按需要定义静态链表的空间大小 2struct node{//单向链表 3 int id;//这个节点的id 4 int data;//数据 5 int nextid;//指向下一个节点的id 6}nodes[N];//静态分配需要定义在全局 7//为链表的next指针赋初值,例如: 8 nodes[0].nextid = 1; 9 for(int i = 1; i <= n; i++){ 10nodes[i].id = i;//把第i个节点的id赋值为i 11nodes[i].nextid = i + 1; //next指针指向下一个节点 12 } 13//定义为循环链表:尾指向头 14 nodes[n].nextid = 1; 15//遍历链表,沿着nextid访问节点即可 16//删除节点。设当前位于位置now,删除这个节点。需要找到前一个节点,即preid指针 17 nodes[preid].nextid = nodes[now].nextid; //跳过节点now,即删除now 18 now = nodes[preid].nextid;//更新now 19//插入节点,略 (2) 链表的初始化。因为直接用i来赋值nodes[i],第10行的id是不必要的,可以省去。重点是第11行的nextid和第14行的头尾相接,这样才能完成一个循环的链表结构。 (3) 遍历链表。沿着nextid指针访问所有节点。 (4) 删除节点。若当前节点是now,删除它,需要先找到前一个节点pre,然后nodes[pre].nextid=nodes[now].nextid。如何找到pre,是单向链表的麻烦。 (5) 插入节点。由于是用数组来模拟链表的,当需要插入一个节点时,只能把数组末尾还没用到的新nodes[]赋值给新节点。例如定义一个nodes[10000]的静态链表,已经用nodes[1]~nodes[100]构成了一个循环链表,现在需要在nodes[1]和nodes[2]之间插入一个新节点,那么新节点就使用nodes[101],然后赋值nodes[1].nextid=101,nodes[101].nextid=2,这样就完成了插入。 用静态数组模拟链表的缺点是,若有频繁的删除和插入,会逐渐消耗数组空间。因为删除的节点不方便回收再使用。在插入节点时,不能使用已经被删除的节点,而是一直使用数组末尾的新nodes[]。所以需要定义一个足够大的nodes[N],避免用完。 双向静态链表的实现和单向链表差不多,只是多了指向前一个节点的指针preid。下面是部分代码。 1const int N = 10000; 2struct node{//双向链表 3 int id;//节点编号 4 int data;//数据 5 int preid;//前一个节点 6 int nextid;//后一个节点 7}nodes[N]; 8//为节点的指针赋初值 9 nodes[0].nextid = 1; 10 nodes[1].preid = 0; 11 for(int i = 1; i <= n; i++){//建立链表 12nodes[i].id = i; 13nodes[i].preid = i-1;//前节点 14nodes[i].nextid = i+1;//后节点 15 } 16//定义为循环链表 17 nodes[n].nextid = 1;//循环链表:尾指向头 18 nodes[1].preid = n;//循环链表:头指向尾 用下面的简单题说明链表的应用。 例3.7小王子单链表lanqiaoOJ 1110 问题描述: 小王子有一天迷上了排队的游戏,桌子上有标号为1~10的10个玩具,现在小王子将它们排成一列,可小王子还是太小了,他不确定到底想把哪个玩具摆在哪里,直到最后才能排成一条直线,求玩具的编号。已知他排了M次,每次都是选取标号为X的放到最前面,求每次排完后玩具的编号序列。 输入: 第一行是一个整数M,表示小王子排玩具的次数。随后M行,每行包含一个整数X,表示小王子要把编号为X的玩具放在最前面。 输出: 共M行,第i行输出小王子第i次排完序后玩具的编号序列。 输入样例: 5 3 2 3 4 2 输出样例: 3 1 2 4 5 6 7 8 9 10 2 3 1 4 5 6 7 8 9 10 3 2 1 4 5 6 7 8 9 10 4 3 2 1 5 6 7 8 9 10 2 4 3 1 5 6 7 8 9 10 本题是单链表的直接应用。 把1~10这10个数据存到10个节点上,即toy[1]~toy[10]这10个节点。代码有一个小技巧: toy[0]始终是链表的头,但是不用它存数据,真正的数据在它的后面。当需要遍历链表的时候,直接从toy[0]开始即可。 1#include 2using namespace std; 3struct node{ 4//int id;//没用到 5 int data; 6 int nextid; 7}toy[20]; 8void init(){//初始化链表 9 toy[0].nextid = 1;//节点0是链表头,它指向下一个节点1 10 toy[10].nextid = -1;//最后的节点10指向-1,表示没有后续节点 11 for (int i = 1; i <=10; i++) { 12toy[i].data = i;//节点的值 13toy[i].nextid = i + 1;//指针,指向下一个节点 14 } 15} 16void tohead(int x){//把x放到最前面 17 int p = 0;//p是x的前一个节点 18 while(toy[p].nextid !=-1){ //遍历链表,查找x的前一个节点p 19if (toy[toy[p].nextid].data == x) break; 20p = toy[p].nextid; 21 } 22 int now = toy[p].nextid;//now是x所在的节点 23 toy[p].nextid = toy[now].nextid;//删除x节点 24 toy[now].nextid = toy[0].nextid; 25 toy[0].nextid = now;//把x放到最前面 26} 27void out(){//输出链表,就是题目的编号序列 28 int head = toy[0].nextid;//toy[0]始终是链表头,并且不用来存编号 29 for (int i = 1; i <=10; i++){ 30cout << toy[head].data << ' '; 31head = toy[head].nextid; 32 } 33 cout << endl; 34} 35int main(){ 36 init(); 37 int m; 38 cin >> m; 39 while (m--) { 40int x; 41cin >> x; 42tohead(x); 43out(); 44 } 45 return 0; 46} 本节后面用STL list重新写这一题的代码,非常简短。 2. 极简链表 这里介绍一种极为简单的链表,虽然应用场景很少,不过也有它的优势。 定义一维数组R[],R[i]的i是链表上节点i的值,而R[i]的值是下一个节点,也就是说R[i]是指向下一个节点的指针。 图3.4构造了一个单向链表,只要定义R[1]=2,R[2]=3,R[3]=4,…,就完成了单向链表的构造。 图3.4构造一个单向链表 添加或删除节点很简单。例如删除节点2,只需要修改R[1],从原来的R[1]=2改为R[1]=3,这样就跳过了节点2,如图3.5所示。 图3.5删除节点2 极简链表的使用场景有限,因为一个节点i只能存一个数据,这个数据就等于i。在特定场景下这种方法很有用,因为它有一个优点: 可以通过R[i]的下标i直接找到数据i,而不需要像普通链表那样需要遍历整个链表才能找到i。 下面的蓝桥杯真题巧妙地应用了极简链表的这个特点。 例3.82023年第十四届C/C++大学B组试题H: 整数删除lanqiaoOJ 3515 时间限制: 1s内存限制: 256MB本题总分: 20分 问题描述: 给定一个长度为N的整数数列A1,A2,…,AN,请重复以下操作K次。 每次选择数列中最小的整数(如果最小值不止一个,选择最靠前的),将其删除,并把与它相邻的整数加上被删除的数值。 输出K次操作后的序列。 输入: 第一行包含两个整数N和K。第二行包含N个整数A1,A2,A3,…,AN。 输出: 输出N-K个整数,中间用一个空格隔开,代表K次操作后的序列。 输入样例: 5 3 1 4 2 8 7 输出样例: 17 7 评测用例规模与约定: 对于20%的评测用例,1≤K 2using namespace std; 3const int N = 5e5 + 10; 4long long v[N];//数列的值相加后可能超过int,需要用long long 5int L[N], R[N];//双向链表 6void del(int x) {//双向链表:删除x节点 7 R[L[x]] = R[x], L[R[x]] = L[x];//删除第x个节点 8 v[L[x]] += v[x], v[R[x]] += v[x];//更新左、右邻居 9} 10int main() { 11 int n, k; cin >> n >> k; 12 //优先队列, 优先队列的元素是{权值,节点下标} 13 priority_queue, vector>, 14greater>> Q; 15 //输入并构造双向链表 16 R[0] = 1;//队头0,右指针R[0]指向节点1 17 L[n + 1] = n;//队尾n+1,左指针L[N+1]指向节点n 18 for (int i = 1; i <=n; i ++) { 19cin >> v[i];//读数列 20L[i] = i - 1, R[i] = i + 1;//构造双向链表,第i个节点表示V[i] 21Q.push({v[i], i});//把数列放进优先队列,求最小值 22 } 23 while (k--) {//k次操作 24auto p = Q.top(); 25//读优先队列的队头,队头是最小值。p.first是值,p.second是它的位置 26Q.pop();//弹走队头,优先队列会重新排序,新的队头仍是最小值 27if (p.first != v[p.second]){//这个队头被del()改过了,不一定最小 28Q.push({v[p.second], p.second}); //重新放进队列,重新排序 29k++;//撤销这次操作 30} 31else del(p.second);//删除节点并更新邻居 32 } 33 int t = R[0];//队头0,R[0]指向第一个数 34 while (t != n + 1) {//遍历链表 35cout << v[t] << " "; //输出链表元素 36t = R[t]; 37 } 38 return 0; 39} 3.3.2STL list 在大多数情况下不需要手写链表,而是直接使用C++的STL listhttps://zh.cppreference.com/w/cpp/container/list,代码更简单。 STL list的常用操作如表3.6所示。 表3.6STL list的常用操作 操作 说明 size() 链表的元素个数 empty() 链表是否为空 push_front() 在链表头添加元素 push_back() 在链表尾添加元素 unique() 将链表中相邻的重复元素去除,如果重复元素不相邻,则无效 sort() 升序排序 pop_back() 删除链表尾 pop_front() 删除链表头 front() 读队头 back() 读队尾 reverse() 反转链表 insert(it) 在it位置插入元素 erase(it) 在it位置删除元素 下面的代码演示了这些操作。 1#include 2using namespace std; 3list lis = {1,2,3};//定义链表,初值为{1,2,3} 4void out(){//输出链表 5 for (auto it = lis.begin(); it != lis.end(); ++it) //遍历链表 6cout << *it << " "; 7 cout << "\n"; 8} 9int main(){ 10 list list2(lis);//复制链表 11 cout << lis.size()<<"\n"; //链表的元素个数,输出:3 12 if(!lis.empty()) cout<<"no empty"<<"\n"; //输出:no empty 13 lis.push_front(9); out();//在链表头添加元素,输出:9 1 2 3 14 lis.push_back(9); out();//末尾添加元素,输出:9 1 2 3 9 15 lis.unique(); out();//相邻重复元素去重,输出:9 1 2 3 9 16 lis.sort(); out();//升序排序,输出:1 2 3 9 9 17 lis.unique(); out();//去重,此时有效,输出:1 2 3 9 18 lis.pop_back(); out();//删除队尾,输出:1 2 3 19 lis.pop_front(); out();//删除队头,输出:2 3 20 cout< 2using namespace std; 3int main(){ 4 list toy{1,2,3,4,5,6,7,8,9,10};//定义链表 5 int m; cin >> m; 6 while (m--) { 7int x; cin >> x; 8toy.remove(x);//删除链表中的x 9toy.push_front(x);//把x插到链表头 10for(auto i:toy) cout< 2using namespace std; 3int main() { 4 int n, m; 5 cin >> n >> m; 6 list lis; 7 for (int i = 1; i <=n; i++) 8lis.push_back(i); //链表赋值 9 while(m--){ 10int x, y, z; 11cin >> x >> y >> z; 12lis.remove(x);//删除x 13list::iterator it = find(lis.begin(), lis.end(), y);//找到y 14//上一句可以把 list::iterator it 改为 auto it 15if (z == 0) lis.insert(++it, x);//x放在y后面 16if (z == 1) lis.insert(it, x);//x放在y前面 17 } 18 for (int x : lis) 19cout << x << " "; 20 cout << endl; 21 return 0; 22} 【练习题】 lanqiaoOJ: 约瑟夫环1111、小王子双链表1112,以及种瓜得瓜,种豆得豆3150。 洛谷: 单向链表 B3631、队列安排P1160。 3.4队列 队列(queue)也是一种简单的数据结构。普通队列的数据存取方式是“先进先出”,只能往队尾插入数据、从队头移出数据。队列在生活中的原型就是排队,例如在网红店排队买奶茶,排在队头的人先买到奶茶然后离开,后来的人排到队尾。 图3.6队列 图3.6是队列的原理,队头head指向队列中的第一个元素a1,队尾tail指向队列中的最后一个元素an。元素只能从队头方向出去,并且只能从队尾进入队列。 3.4.1手写队列 队列的代码很容易实现。如果使用环境简单,最简单的手写队列代码用数组实现。 1const int N = 10005;//定义队列容量,确保够用 2int que[N];//队列,用数组模拟 3int head = 0;//head始终指向队头。que[head]是队头 4//开始时队列为空,head = 0 5int tail = -1;//tail始终指向队尾。que[tail]是队尾 6//开始时队列为空,tail = -1 7//队列长度等于tail-head+1 8head++;//弹出队头元素,让head指向新队头 9//注意保持head <=tail 10que[head];//读队头 11que[++tail] = data;//入队:先tail加1,然后数据data入队 12//注意tail必须小于N 把它封装到结构体里更整洁一些: 1const int N = 10005; //定义队列容量,确保够用 2struct myqueue{ 3 int a[N];//队列,用数组模拟 4 int head = 0; 5//head始终指向队头。que[head]是队头。开始时队列为空,head = 0 6 int tail = -1; 7//tail始终指向队尾。que[tail]是队尾。开始时队列为空,tail = -1 8 int size(){return tail-head+1;}//队列长度等于tail-head+1 9 void push(int data){a[++tail]=data;} 10//入队:先tail加1,然后数据data入队 11 int front(){return a[head];}//读队头 12 void pop(){head++;} 13//弹出队头元素,让head指向新队头。注意保持head <=tail 14}; 这个手写代码有一个缺陷: 如果进入队列的数据太多,使得tail超过了N,数组a[N]就会溢出,导致出错。 用下面的例子给出上述手写代码的应用。 例3.10约瑟夫问题https://www.luogu.com.cn/problem/P1996 问题描述: n个人,编号为1~n,按顺序围成一圈,从第一个人开始报数,数到m的人出列,再由下一个人重新从1开始报数,数到m的人再出圈,以此类推,直到所有的人都出圈,请依次输出出圈人的编号。 输入: 两个整数n和m,1≤n,m≤100。 输出: n个整数,按顺序输出每个出圈人的编号。 输入样例: 10 3 输出样例: 3 6 9 2 7 1 8 5 10 4 约瑟夫问题是一个经典问题,可以用队列、链表等数据结构实现。下面的代码用队列来模拟报数。方法是反复排队,从队头出去,然后重新排到队尾,每一轮数到m的人离开队伍。第22行把队头移到队尾,第23行让数到m的人离开。 1#include 2using namespace std; 3const int N = 10005; 4struct myqueue{ 5 int a[N]; 6 int head; 7 int tail; 8 void init(){head=0, tail=-1;} 9 int size(){return tail-head+1;} 10 void push(int data){a[++tail]=data;} 11 int front(){return a[head];} 12 void pop(){head++;} 13}; 14myqueue que; 15int main(){ 16 int n,m; 17 cin>>n>>m; 18 que.init(); 19 for(int i=1;i<=n;i++) que.push(i); 20 while(que.size()!=0){ 21for(int i=1;i q; 定义队列,Type为数据类型,如int、float、char等 push() 在队列尾部插入一个元素 front() 返回队首元素,但不会删除 pop() 删除队首元素 back() 返回队尾元素 size() 返回元素的个数 empty() 检查队列是否为空 下面是“洛谷P1996”的STL queue实现,代码很简短。 1#include 2using namespace std; 3int main(){ 4 int n,m; 5 cin>>n>>m; 6 queueq; 7 for(int i=1;i<=n;i++) q.push(i); 8 while(!q.empty()){ 9for(int i=1;i 2using namespace std; 3bool hashtable[1003];//全局数组,自动初始化为0 4int main(){ 5 int m,n; 6 cin >> m >> n; 7 int ans=0;//函数内部变量,一定要初始化为0 8 queue q; 9 for(int i=0;i> x; 11if(hashtable[x] == false) { 12hashtable[x] = true; 13if(q.size() < m) q.push(x); 14else { 15hashtable[q.front()] = false; 16q.pop(); 17q.push(x); 18} 19ans++;//内存中没有这个单词,到外存中找,答案加1 20} 21 } 22 cout << ans << '\n'; 23 return 0; 24} 【练习题】 lanqiaoOJ: 餐厅排队 3745、小桥的神秘礼物盒3746、CLZ银行问题1113、繁忙的精神疗养院3747。 3.5优 先 队 列 前一节的普通队列,特征是只能从队头、队尾进出,不能在中间插队或出队。 本节的优先队列不是一种“正常”的队列。在优先队列中,所有元素有一个“优先级”,一般用元素的数值作为它的优先级,或者越小越优先,或者越大越优先。让队头始终是队列内所有元素的最值(最大值或最小值)。队头弹走之后,新的队头仍保持为队列中的最值。举个例子: 一个房间,有很多人进来; 规定每次出来一个人,要求这个人是房间中最高的那个; 如果某人刚进去,发现自己是房间里面最高的,就不用等待,能立刻出去。 优先队列的一个简单应用是排序: 以最大优先队列为例,先让所有元素进入队列,然后再一个一个弹出,弹出的顺序就是从大到小排序。优先队列更常见的应用是动态的,进出同时发生: 一边进队列,一边出队列。 如何实现优先队列?先试一下最简单的方法。以最大优先队列为例,如果简单地用数组存放这些元素,设数组中有n个元素,那么其中的最大值是队头,要找到它,需要逐一在数组中找,计算量是n次比较。这样是很慢的,例如有n=100万个元素,就得比较100万次。把这里的n次比较的计算量记为O(n)。 优先队列有没有更好的实现方法?常见的高效方法是使用二叉堆这种数据结构《算法竞赛》,清华大学出版社,罗勇军、郭卫斌著,第27页的“1.5 堆”。。它非常快,每次弹出最大值队头,只需要计算O(log2n)次。例如n=100万的优先队列,取出最大值只需要计算log2(100万)=20次。 在竞赛中,一般不用自己写二叉堆来实现优先队列,而是直接使用STL priority_queue,初学者只需要学会如何使用它即可。 STL优先队列priority_queuehttps://en.cppreference.com/w/cpp/container/priority_queue用堆来实现,堆是基于二叉树的一种数据结构。 定义: priority_queue Type是数据类型,Container是容器类型(默认是vector),Functional是比较的方式,当需要用自定义的数据类型时才需要传入这3个参数,在使用基本数据类型时,只需要传入数据类型,默认是大顶堆。 priority_queue的常见操作如表3.8所示。 表3.8priority_queue的常见操作 操作 说明 priority_queue 定义队列 pq.push() 入队 pop() 出队 size() 返回当前队列中元素的个数 top() 返回队首元素 empty() 判断是否为空(空返回 1,非空返回 0) 下面是例子。第5行定义了一个队首是最大值的优先队列,第10行的输出如下: 27-wuhan21-shanghai11-beijing 第13行定义了一个队首是最小值的优先队列,第19行的输出如下: 11-beijing21-shanghai27-wuhan 1#include 2using namespace std; 3int main(){ 4 priority_queue> pq;//队首是最大值 5 pair a(11,"beijing"), b(21,"shanghai"), c(27,"wuhan"); 6 pq.push(a); 7 pq.push(b); 8 pq.push(c); 9while (!pq.empty()){ 10cout<,vector>, \ 14greater>> pq2;//队首是最小值 15 pq2.push(a); 16 pq2.push(b); 17 pq2.push(c); 18 while (!pq2.empty()) { 19cout< 2using namespace std; 3typedef long long ll; 4int prime[] = {3,7,17,29,53}; 5int main(){ 6 sets;//判重 7 s.insert(1);//第一个丑数是1 8 priority_queue, greater>q; //队列中是新生成的丑数 9 q.push(1);//第一个丑数是1,进入队列 10 int n = 20220; 11 ll ans; 12 for(int i = 1; i <=n; i++){ //从队列中由小到大取出20220个丑数 13ll now = q.top(); 14q.pop();//把队列中最小的取出来,它也是已经取出的最大的 15ans = now; 16for(int i = 0; i < 5; i++){//5个素数 17ll tmp = now * prime[i]; //从已取出的最大值开始乘以5个素数 18if(!s.count(tmp)) {//tmp这个数没有出现过 19s.insert(tmp);//放到set里面 20q.push(tmp);//把tmp放进队列 21} 22} 23 } 24 cout<表示每个数的数量和数字。优先队列priority_queue会把每个数按数量从多到少拿出来,相当于按数量多少排序。 代码的执行步骤: 把所有数放进队列; 每次拿出k个不同的数并输出,直到结束。 代码的计算复杂度: 进出一次队列是O(log2n),共n个数,总复杂度为O(nlog2n)。 1#include 2using namespace std; 3const int N = 1e6 + 10; 4int num[N]; 5int main(){ 6 int n, k; cin >> n >> k; 7 priority_queue> q; 8 for (int i = 0; i < n; i++) { 9int x; cin >> x; 10num[x]++;//x这个数有num[x]个 11 } 12 for (int i = 0; i < N; ++) 13if(num[i] > 0) 14q.push({num[i], i});//数i的个数,数i 15 while(q.size() >= k) {//队列中数量大于k,说明还够用 16vector> tmp(k); 17for (int i = 0; i < k; i++){ //拿k个数出来,且这k个数不同,这是一份 18tmp[i] = q.top();//先出来的是num[]最大的 19q.pop(); 20} 21for (int i = 0; i < k; i++) { //打印一份,共k个数 22cout << tmp[i].second << " \n"[i == k - 1]; 23tmp[i].first -= 1;//这个数用了一次,减1 24if(tmp[i].first > 0) q.push(tmp[i]);//没用完,再次进队列 25} 26 } 27 return 0; 28} 【练习题】 lanqiaoOJ: 小蓝的神奇复印机3749、Windows的消息队列3886、小蓝的智慧拼图购物3744、餐厅就餐4348。 3.6栈 栈(stack)是比队列更简单的数据结构,它的特点是“先进后出”。 队列有两个口,一个入口和一个出口。栈只有唯一的一个口,既从这个口进入,又从这个口出来。栈像一个只有一个门的房子,而队列这个房子既有前门又有后门。所以如果自己写栈的代码,比写队列的代码更简单。 栈在编程中有基础的应用,例如常用的递归,在系统中是用栈来保存现场的。栈需要用空间存储,如果栈的深度太大,或者存进栈的数组太大,那么总数会超过系统为栈分配的空间,就会爆栈导致栈溢出。不过,算法竞赛一般不会出现这么大的栈。 栈的常见操作如下。 empty(): 返回栈是否为空。 size(): 返回栈的长度。 top(): 查看栈顶元素。 push(): 进栈,向栈顶添加元素。 pop(): 出栈,删除栈顶元素。 栈的这些操作的计算量都是O(1),效率很高。 3.6.1手写栈 如果使用环境简单,最简单的手写栈代码用数组实现。 1const int N = 300008;//定义栈的大小 2struct mystack{ 3int a[N];//存放栈元素,从a[0]开始 4 int t = -1;//栈顶位置,初始栈为空,置初值为-1 5 void push(int data){a[++t] = data;}//把元素data送入栈 6 int top() {return a[t];}//读栈顶元素,不弹出 7 void pop() {if(t>-1) t--;}//弹出栈顶 8 int size() {return t+1;}//栈内元素的数量 9 int empty() {return t==-1 ? 1:0;}//若栈为空,返回1 10}; 在使用栈时要注意不能超过栈的空间。上面第1行定义了栈的大小N,进栈的元素数量不要超过它。 用下面的例子给出上述手写代码的应用。 例3.14表达式括号匹配https://www.luogu.com.cn/problem/P1739 问题描述: 假设一个表达式由英文字母(小写)、运算符(+、-、*、/)和左右小(圆)括号构成,以@作为表达式的结束符。请编写一个程序检查表达式中的左右圆括号是否匹配,若匹配,输出YES,否则输出NO。表达式的长度小于255,左圆括号少于20个。 输入: 一行,表达式。 输出: 一行,YES或NO。 输入样例: 2*(x+y)/(1-x)@ 输出样例: YES 合法的括号串例如“(())”和“()()()”,像“)(()”这样是非法的。合法括号组合的特点是左括号先出现,右括号后出现; 左括号和右括号一样多。 括号组合的合法检查是栈的经典应用。用一个栈存储所有的左括号。遍历字符串的每一个字符,处理流程如下。 (1) 若字符是'(',进栈。 (2) 若字符是')',有两种情况: 如果栈不空,说明有一个匹配的左括号,弹出这个左括号,然后继续读下一个字符; 如果栈空了,说明没有与右括号匹配的左括号,字符串非法,输出NO,程序退出。 (3) 在读完所有字符后,如果栈为空,说明每个左括号有匹配的右括号,输出YES,否则输出NO。 1#include 2using namespace std; 3const int N = 300008;//定义栈的大小 4struct mystack{ 5 int a[N];//存放栈元素,从a[0]开始 6 int t = -1;//栈顶位置,初始栈为空,置初值为-1 7 void push(int data){a[++t] = data;} //把元素data送入栈 8 int top() {return a[t];}//读栈顶元素,不弹出 9 void pop() {if(t>-1) t--;}//弹出栈顶 10 int size() {return t+1;}//栈内元素的数量 11 int empty() {return t==-1 ? 1:0;}//若栈为空,返回1 12}st; 13int main(){ 14 char x; 15 while(cin>>x){//循环输入 16if(x=='@') break;//输入为@停止 17if(x=='(') st.push(x);//左括号入栈 18if(x==')'){//遇到一个右括号 19if(st.empty()) {//栈空,没有左括号与右括号匹配 20cout<<"NO"; 21return 0; 22} 23else st.pop();//匹配到一个左括号,出栈 24} 25 } 26 if(st.empty())//栈为空,所有左括号已经匹配到右括号,输出YES 27cout<<"YES"; 28 else cout<<"NO"; 29 return 0; 30} 3.6.2STL stack 在竞赛时一般不自己手写栈,而是用STL stackhttps://www.cplusplus.com/reference/stack/stack/。 STL stack的主要操作如表3.9所示。 表3.9STL stack的主要操作 操作 说明 stacks; 定义栈,Type为数据类型,如int、float、char等 push(item) 把item放到栈顶 top() 返回栈顶的元素,但不会删除 pop() 删除栈顶的元素,但不会返回 size() 返回栈中元素的个数 empty() 检查栈是否为空,如果为空返回true,否则返回false 用下面的例题说明STL stack的应用。 例3.15排列http://oj.ecustacm.cn/problem.php?id=1734 问题描述: 给定一个1~n的排列,每个对的价值是j-i+1,计算所有满足以下条件的对的总价值。(1)1≤i称为一个“凹”。首先模拟检查“凹”,了解执行的过程。以“3 1 2 5”为例,其中的“凹”有“312”和“3125”,以及相邻的“31”“12”“25”。一共有5个“凹”,总价值为13。 像“312”和“3125”这样的“凹”,需要检查连续3个以上的数字。 例如“312”,从“3”开始,下一个应该比“3”小,如“1”,再后面的数字比“1”大,才能形成“凹”。 再例如“3125”,前面的“312”已经是“凹”了,最后的“5”会形成新的“凹”,条件是这个“5”必须比中间的“12”大才行。 总结上述过程是先检查“3”; 再检查“1”符合“凹”; 再检查“2”,比前面的“1”大,符合“凹”; 再检查“5”,比前面的“2”大,符合“凹”。 以上是检查一个“凹”的两头,还有一种是“嵌套”。一旦遇到比前面小的数字,那么以这个数字为头,可能形成新的“凹”。例如“6 4 2 8”,其中的“6428”是“凹”,内部的“428”也是“凹”。如果大家学过递归、栈,就会发现这是嵌套,所以本题用栈来做很合适。 以“6 4 2 8”为例,用栈模拟找“凹”。当新的数比栈顶的数小,就进栈; 如果比栈顶的数大,就出栈,此时找到了一个“凹”并计算价值。图3.7中的圆圈数字是数在数组中的下标位置,用于计算题目要求的价值。 图3.7用栈统计凹 图(1): 6进栈。 图(2): 4准备进栈,发现比栈顶的6小,说明可以形成凹,4进栈。 图(3): 2准备进栈,发现比栈顶的4小,说明可以形成凹,2进栈。 图(4): 8准备进栈,发现比栈顶的2大,这是一个凹“428”,对应下标“②④”,弹出2,然后计算价值,j-i+1=④-②+1=3。 图(5): 8准备进栈,发现比栈顶的4大,这是一个凹“648”,对应下标“①④”,也就是原数列的“6428”。弹出4,然后计算价值,j-i+1=④-①+1=4。 图(6): 8终于进栈了,数字也处理完了,结束。 在上述过程中,只计算了长度大于或等于3的凹,没有计算题目中“(3)a[i]~a[j]不存在其他数字”的长度为2的凹,所以最后统一加上这种情况的价值(n-1)×2=6。 最后统计得“6 4 2 8”的总价值是3+4+6=13。 下面是代码。 1#include 2using namespace std; 3const int N = 300008; 4int a[N];//这里a[]是题目的数字排列 5int main(){ 6 int n;cin>>n; 7 for(int i = 1;i <=n;i++)cin>>a[i]; //输入数列 8 stack st;//定义栈 9 long long ans = 0; 10 for(int i = 1;i <=n;i++){ 11while(!st.empty() && a[st.top()] < a[i]){ 12st.pop(); 13if(!st.empty()){ 14int last = st.top(); 15ans += (i - last + 1); 16} 17} 18st.push(i); 19 } 20 ans += (n - 1) * 2;//(3)a[i]~a[j]不存在其他数字的情况 21 cout< 2using namespace std; 3const int N=100;//注意const不能少 4struct Node{//定义静态二叉树结构体 5 char v;//把value简写为v 6 int ls, rs;//左右孩子,把lson、rson简写为ls、rs 7}t[N];//把tree简写为t 8void print_tree(int u){//打印二叉树 9 if(u){ 10cout<1的节点,其父节点是p/2。例如p=4,父亲是4/2=2; p=5,父亲是5/2=2。 (2) 如果2×p>k,那么p没有孩子; 如果2×p+1>k,那么p没有右孩子。例如k=11,p=6的节点没有孩子; k=12,p=6的节点没有右孩子。 (3) 如果节点p有孩子,那么它的左孩子是2×p、右孩子是2×p+1。 在该图中圆圈内是节点的值,圆圈外的数字是节点的存储位置。 下面是代码。用ls(p)找p的左孩子,用rs(p)找p的右孩子。在ls(p)中把p*2写成p<<1,用了位运算。 1#include 2using namespace std; 3const int N=100;//注意const不能少 4char t[N];//简单地用一个数组定义二叉树 5int ls(int p){return p<<1;}//定位左孩子,也可以写成p*2 6int rs(int p){return p<<1 | 1;}//定位右孩子,也可以写成p*2+1 7int main(){ 8 t[1]='A';t[2]='B';t[3]='C'; 9 t[4]='D';t[5]='E';t[6]='F';t[7]='G'; 10 t[8]='H';t[9]='I';t[10]='J'; t[11]='K'; 11 cout<> n; 30 for(int i = 1; i <= n; i++) { 31int a, b; cin >> a >> b; 32t[i].v = i; 33t[i].ls = a; 34t[i].rs = b; 35 } 36 preorder(1); cout << endl; 37 midorder(1); cout << endl; 38 postorder(1); cout << endl; 39} 再看一道例题。 例3.172023年第十四届蓝桥杯省赛C/C++大学C组试题J: 子树的 大小lanqiaoOJ 3526 时间限制: 2s内存限制: 256MB本题总分: 25分 问题描述: 给定一棵包含n个节点的完全m叉树,节点按从根到叶、从左到右的顺序依次编号。例如图3.13是一棵拥有11个节点的完全3叉树。 图3.13一棵包含11个节点的完全3叉树 请求出第k个节点对应的子树拥有的节点数量。 输入: 输入包含多组询问。输入的第一行包含一个整数t,表示询问次数。接下来t行,每行包含3个整数n、m、k,表示一组询问。 输出: 一个正整数,表示答案。 输入样例: 3 1 2 1 11 3 4 7 4 5 3 输出样例: 1 2 24 评测用例规模与约定: 对于40%的评测用例,t≤50,n≤106,m≤16; 对于100%的评测用例,1≤t≤105,1≤k≤n≤109,2≤m≤109。 这一题可以帮助读者理解树的结构。 第u个节点的最左孩子的编号是多少?第u号节点前面有u-1个点,每个节点各有m个孩子,再加上1号节点,可得第u个节点的左孩子下标为(u-1)×m+2。例如该图中的3号节点,求它的最左孩子的编号。3号节点前面有两个点,即1号节点和2号节点,每个节点都有3个孩子,1号节点的孩子是2、3、4,2号节点的孩子是5、6、7,共6个孩子。那么3号节点的最左孩子的编号是1+2×3+1=8。 同理,第u个节点的孩子如果是满的,它的最右孩子的编号为u×m+1。 分析第u个节点的情况: ① 节点u在最后一层。此时节点u的最左孩子的编号大于n,即(u-1)×m+2>n,说明这个孩子不存在,也就是说节点u在最后一层,那么以节点u为根的子树的节点数量是1,就是u自己。 ② 节点u不是最后一层,且u的孩子是满的,即最右孩子的编号u×m+1≤n。此时可以继续分析u的孩子的情况。 ③ 节点u不是最后一层,u有左孩子,但是孩子不满,此时u在倒数第2层,它的最右孩子的编号就是n。以u为根的子树的数量=右孩子编号-(左孩子编号-1)+u自己,即n-((u-1)×m+1)+1=n-u×m+m。 下面用两种方法求解。 (1) DFS,通过40%的测试。DFS在第6章讲解,请读者在学过第6章后回头看这个方法。 在情况2),用DFS继续搜u的所有孩子,下面的代码实现了上述思路。 代码的计算量是多少?每个点都要计算一次,共t组询问,所以总复杂度是O(nt),只能通过40%的测试。 1#include 2using namespace std; 3typedef long long ll;//注意要用long long,因为下面m*u可能超过int 4ll dfs(ll n, ll m, ll u) { 5 ll ans = 1;//u点自己算一个 6 if(m*u - (m-2) > n) return 1;//情况1),u点在最后一层, ans=1 7 else if(m*u + 1 <=n) {//情况2),u在倒数第2层且孩子满了 8for(ll c = m*u - (m-2); c <=m*u + 1; c++)//深搜u的每个孩子 9ans += dfs(n, m, c);//累加每个孩子的数量 10return ans; 11 } 12 else return n + m - m*u;//情况3),u在倒数第2层且孩子不满 13} 14int main() { 15 int t; cin >> t; 16 while(t--) { 17ll n, m, k;cin >> n >> m >> k; 18cout << dfs(n,m,k) << endl; 19 } 20 return 0; 21} (2) 模拟。 上面的DFS方法,对于情况2),把每个点的每个孩子都做了一次DFS,计算量很大。 其实每一层算一次就行了,在情况2)时每一层也只需要算一次。以题目的图为例,计算点1为根的节点数量。1号节点这一层有1个; 它的下一层是满的,有3个,左孩子是2,右孩子是4; 再下一层,2号节点的左孩子是5,4号节点的孩子是11,那么这一层有11-5+1=7个。累加得1+3+7=11。 计算量是多少?每一层只需要计算一次,共O(log2n)层,t组询问,总计算复杂度是O(tlog2n),能通过100%的测试。 1#include 2using namespace std; 3typedef long long ll;//注意要用long long,因为下面m*u可能超过int 4int main(){ 5 int t; cin >> t; 6 while(t--){ 7ll n,m,k; 8cin >> n >> m >> k; 9ll ans = 1;//初值为1,即k点自己 10ll ls = k, rs = k;//从k点开始,分析它的最左和最右孩子 11while(1) {//从第k点开始一层一层往下计算直到最后一层 12ls =(ls-1)*m + 2; //这一层的最左孩子 13rs = rs*m + 1;//这一层的最右孩子 14if(ls > n) break; //情况1),已经到最后一层,结束 15if(rs >=n){//情况3),孩子不满 16ans += n-ls+1;//加上孩子数量 17break;//结束 18} 19ans += rs-ls+1;//情况2),这一层是满的,累加这一层的所有孩子 20} 21cout << ans < 2using namespace std; 3char s[1100],tree[4400];//tree[]存满二叉树 4int ls(int p){return p<<1;}//定位左儿子:p*2 5int rs(int p){return p<<1|1;}//定位右儿子:p*2+1 6void build_FBI(int p,int left,int right){ 7if(left==right){//到达叶子节点 8if(s[right]=='1') tree[p]='I'; 9else tree[p]='B'; 10return; 11 } 12 int mid=(left+right)/2;//分成两半 13build_FBI(ls(p),left,mid);//递归左半 14build_FBI(rs(p),mid+1,right);//递归右半 15 if(tree[ls(p)]=='B' && tree[rs(p)]=='B')//左右儿子是B,自己也是B 16tree[p]='B'; 17 else if(tree[ls(p)]=='I'&&tree[rs(p)]=='I')//左右儿子是I,自己也是I 18tree[p]='I'; 19else tree[p]='F'; 20} 21void postorder(int p){//后序遍历 22 if(tree[ls(p)]) postorder(ls(p)); 23 if(tree[rs(p)]) postorder(rs(p)); 24printf("%c",tree[p]); 25} 26int main(){ 27 int n;cin >>n; 28 cin >>s+1; 29build_FBI(1,1,strlen(s+1)); 30 postorder(1); 31} 【练习题】 lanqiaoOJ: 完全二叉树的权值183。 洛谷: American Heritage P1827,求先序排列P1030。 3.8并查集 并查集通常被认为是一种“高级数据结构”,可能是因为用到了集合这种“高级”方法。不过,并查集的编码很简单,数据存储方式也仅用到了最简单的一维数组,可以说并查集是“并不高级的高级数据结构”。 并查集,英文为Disjoint Set,直译是“不相交集合”。意译为“并查集”非常好,因为它概括了并查集的3个要点: 并、查、集。并查集是“不相交集合上的合并、查询”。 并查集精巧、实用,在算法竞赛中很常见,原因有三点: 简单且高效、应用很直观、容易和其他数据结构与算法结合。并查集的经典应用有判断连通性、最小生成树Kruskal算法并查集是Kruskal算法的绝配,如果不用并查集,Kruskal算法很难实现。本作者曾拟过一句赠言: “Kruskal对并查集说,咱们一辈子是兄弟!”、最近公共祖先(Least Common Ancestors,LCA)等。 通常用“帮派”的例子来说明并查集的应用背景。在一个城市中有n个人,他们分成不同的帮派; 给出一些人的关系,例如1号、2号是朋友,1号、3号也是朋友,那么他们都属于一个帮派; 在分析完所有的朋友关系之后,问有多少个帮派,每个人属于哪个帮派。给出的n可能大于106。如果用并查集实现,不仅代码很简单,而且计算复杂度几乎是O(1),效率极高。 并查集效率高,是因为用到了“路径压缩本作者曾拟过一句赠言: “路径压缩担任总经理之后,并查集公司的管理效能实现了跨越式发展。””这一技术。 3.8.1并查集的基本操作 用“帮派”的例子说明并查集的3个基本操作: 初始化、合并、查找。 (1) 初始化。在开始的时候,帮派的每个人是独立的,相互之间没有关系。把每个人抽象成一个点,每个点有独立的集,n个点就有n个集。也就是说,每个人的帮主就是自己,共有n个帮派。 如何表示集?非常简单,用一维数组int s[]来表示,s[i]的值就是点i所属的并查集。初始化s[i]=i,也就是说,点i的集就是s[i]=i,例如点1的集s[1]=1,点2的集s[2]=2,等等。 用图3.14说明并查集的初始化。左边的图给出了点i与集s[i]的值,下画线数字表示集。右边的图表示点和集的逻辑关系,用圆圈表示集,方块表示点。初始时,每个点属于独立的集,5个点有5个集。 图3.14并查集的初始化 (2) 合并。把两个点合并到一个集,就是把两个人所属的帮派合并成一个帮派。 如何合并?如果s[i]=s[j],就说明i和j属于同一个集。操作很简单,把它们的集改成一样即可。下面举例说明。 例如点1和点2是朋友,把它们合并到一个集。具体操作是把点1的集1改成点2的集2,s[1]=s[2]=2。当然,把点2的集改成点1的集也行。经过这个合并,1和2合并成一个帮,帮主是2。 图3.15演示了合并的结果,此时有5个点,有4个集,其中s[2]包括两个点。 图3.15合并(1,2) 下面继续合并,合并点1和点3。合并的结果是让s[1]=s[3]。 首先查找点1的集,发现s[1]=2。再继续查找点2的集,s[2]=2,点2的集是自己,无法继续,查找结束。这个操作是查找1的帮主。 再查找点3的集,s[3]=3。由于s[2]不等于s[3],说明2和3属于不同的帮派。下面把点2的集2合并到点3的集3。具体操作是修改s[2]=3,也就是让2的帮主成为3。此时,点1、2、3都属于一个集: s[1]=2、s[2]=3、s[3]=3。1的上级是2,2的上级是3,这3个人的帮主是3,形成了一个多级关系。 图3.16演示了合并的结果。为了简化图示,把点2和集2画在了一起。 图3.16合并(1,3) 继续合并,合并点2和点4。结果如图3.17所示,合并过程请读者自己分析。合并的结果是s[1]=2、s[2]=3、s[3]=4、s[4]=4。4是1、2、3、4的帮主。另外还有一个独立的集s[5]=5。 图3.17合并(2,4) (3) 查找某个点属于哪个集。从上面的图示可知,这是一个递归的过程,例如找点1的集,递归步骤是s[1]=2、s[2]=3、s[3]=4、s[4]=4,最后点的值和它的集相等,递归停止,就找到了集。 (4) 统计有几个集。只要检查有多少个点的集等于自己(自己是自己的帮主),就有几个集。如果s[i]=i,这是这个集的根节点,是它所在的集的代表(帮主); 统计根节点的数量,就是集的数量。在上面的图示中,只有s[4]=4、s[5]=5,有两个集。 从上面的图中可以看到,并查集是“树的森林”,一个集是一棵树,有多少棵树就有多少个集。有些树的高度可能很大(帮会中每个人都只有一个下属),递归步骤是O(n)的。此时这个集变成了一个链表,出现了并查集的“退化”现象,使得递归查询十分耗时。这个问题可以用“路径压缩”来彻底解决。经过路径压缩后的并查集,查询效率极高,是O(1)的。 用下面的例题给出并查集的基本操作的编码。 例3.19亲戚https://www.luogu.com.cn/problem/P1551 问题描述: 若某个家族人员过多,要判断两个人是否为亲戚,确实很不容易,现在给出某个亲戚关系图,求任意给出的两个人是否具有亲戚关系。规定: x和y是亲戚,y和z是亲戚,那么x和z也是亲戚。如果x和y是亲戚,那么x的亲戚都是y的亲戚,y的亲戚也都是x的亲戚。 输入: 第一行有3个整数n、m、p,n、m、p≤5000,分别表示有n个人,m个亲戚关系,询问p对亲戚关系。以下m行,每行两个数Mi、Mj、1≤Mi,Mj≤n,表示Mi和Mj有亲戚关系。接下来p行,每行两个数Pi、Pj,询问Pi,Pj是否为一个亲戚关系。 输出: 输出p行,每行一个Yes或No,表示第i个询问的答案为“具有”或“不具有”亲戚关系。 输入样例: 6 5 3 1 2 1 5 3 4 5 2 1 3 1 4 2 3 5 6 输出样例: Yes Yes No 下面是并查集的代码。 (1) 初始化init_set()。 (2) 查找find_set()。这是一个递归函数,若x==s[x],则这是一个集的根节点,结束。若x!=s[x],继续递归查找根节点。 (3) 合并merge_set(x,y)。合并x和y的集,先递归找到x的集,再递归找到y的集,然后把x合并到y的集上。如图3.18所示,x递归到根b,y递归到根d,最后合并为set[b]=d。合并后,这棵树变长了,查询效率变低。 图3.18合并 1#include 2using namespace std; 3const int N = 5010; 4int s[N]; 5void init_set(){//初始化 6 for(int i=1; i<=N; i++) s[i] = i; 7} 8int find_set(int x){//查找 9 //if(x==s[x]) return x; 10 //else return find_set(s[x]);//这两行合并为下面的一行 11 return x==s[x]? x:find_set(s[x]); 12} 13void merge_set(int x, int y){//合并 14 x = find_set(x); 15 y = find_set(y); 16 if(x != y) s[x] = s[y];//y成为x的上级,x的集改成是y的集 17} 18int main(){ 19 init_set(); 20 int n,m,p; cin>>n>>m>>p; 21 while(m--){//合并 22int x,y; cin>>x>>y; 23merge_set(x, y); 24 } 25 while(p--){//查询 26int x,y; cin>>x>>y; 27if(find_set(x)==find_set(y)) cout << "Yes"< 2using namespace std; 3struct node{int x,y,t;} e[100010]; 4bool cmp(node a, node b){return a.t 2using namespace std; 3const int N=1000002;//A的hash,1≤Ai≤1000000 4int vis[N];//hash: vis[i]=1表示数字i已经存在 5int main(){ 6 int n; 7 scanf("%d",&n); 8 for(int i=0;i 2using namespace std; 3const int N = 1000002; 4int A[N]; 5int s[N];//并查集 6int find_set(int x){//用“路径压缩”优化的查询 7 if(x != s[x]) s[x] = find_set(s[x]);//路径压缩 8 return s[x]; 9} 10int main(){ 11 for(int i=1;i