算法笔记
胡凡 曾磊 主编
机械工业出版社
文章目录
C/C++快速入门
提醒
数组较大时应该定义在主函数外面
memset
memset(数组名,值,sizeof(数组名))
使用该函数对数组中的每一个元素赋以相同的值,需要记住在程序开头添加string.h头文件
memset按字节赋值,即对每个字节赋予相同的值
sscanf与sprintf
sscanf(str,"%d",&n);
sprintf(str,"%d",n);
sscanf的作用是把str中的内容以%d形式写入n中
sprintf的作用是把n中的内容以%d的格式写入str中
可以进行复杂的格式输入和输出,支持正则表达式
引用
对原变量起一个别名
void change(int &x);
浮点数的比较
const double eps = 1e-8;
如果一个浮点数a要大于b,a-b>eps必须成立
圆周率
const double PI = acos(-1.0)
复杂度
- 时间复杂度中高等级的幂次会覆盖低等级的幂次
- 对于一般的OJ系统来说,一秒能承受的运算次数大概是1e7~1e8
- 如果消耗的最大空间是一个二维数组,那么这个算法的空间复杂度就是O(n2)
黑盒测试
- 对单点测试来说,系统会判断每组数据的输出结果是否正确,如果正确,那么对该组数据来说就通过了测试
- 多点测试要求程序能一次运行所有数据
- 多点测试每一次循环都要重置一下变量和数组
入门篇(1)——入门模拟
入门篇(2)——算法初步
排序
冒泡排序
冒泡排序的本质在于交换,即每次通过交换的方式把当前剩余元素的最大值移动到另一端
int a[5] = {3,4,1,5,2};
for(int i=0;i<4;i++){
for(int j=0;j<4-i){
if(a[j]>a[j+1]){
int temp = a[j];
a[j] = a[j+1];
a[j+1] = temp;
}
}
}
选择排序
这里主要介绍选择排序方法中的简单选择排序。
for(int i=0;i<n-1;i++){
int min = i;
for(int j=i+1;j<n;j++){
if(a[j]<a[min]) min = j;
}
int temp = a[i];
a[i] = a[min];
a[min] = temp;
}
插入排序
这里主要介绍直接插入排序。
for(int i=1;i<n;i++){
int temp = a[i], j=i;
while(j>0 && temp<a[j-1]){
a[j] = a[j-1];
j--;
}
a[j] = temp;
}
sort函数的应用
#include<algorithm>
using namespace std;
sort(首元素地址,尾元素地址的下一个地址,比较函数);
如何实现比较函数cmp
-
若比较函数不填,默认按照从小到大的顺序排序
-
如果要从大到小排序,则要告诉sort何时交换元素
bool cmp(int a,int b){ return a>b; }
-
详见STL介绍
bool cmp(Stu a,Stu b){
if(a.score != b.score) return a.score > b.score;
else return strcmp(a.name, b.name) < 0;
}
//如果分数不相同,分数高的排在前面
//否则,将姓名字典序小的排在前面
排名的实现
分数不同的排名不同,分数相同的排名相同但占用一个排位
stu[0].r = 1;
for(int i=1; i<n; i++){
if(stu[i].score == stu[i-1].score) stu[i].r = stu[i-1].r;
else stu[i].r = i+1;
}
有时题目中不需要记录排名,输出即可,那么可以用下面的代码
int r = 1;
for(int i=0; i<n; i++){
if(i>0 && stu[i].score != stu[i-1].score) r = i+1;
printf("%d",r);//这里可以根据需要修改
}
散列
散列hash是常用的算法思想之一。
给出N个正整数,再给出M个正整数,问这M个数中的每个数是否在N中出现过。
对每个欲查询的数遍历N次,时间复杂度为O(NM),NM很大时无法接受
用时间换空间,设定一个很大的bool型数组,读入的数为x时,就令
hashtable[x] = true;
查询时只需用该数组判断,这样时间复杂度减少到O(N+M)。
同理,如果查询在N中出现的次数,把bool型数组替换为int型即可。
但是这种策略有一个问题,输入的数字过大或者是字符串怎么办?
hash可以将元素通过一个函数转换为整数,使得该整数可以尽量唯一地代表这个元素。其中把这个转换函数称为散列函数H,如果元素在转换前是key,那么转换后就是一个整数H(key)。
对key是整数的情况来说,常用的有直接定址法、平方取中法,除留余数法。
除留余数法是指把key除以一个数mod得到的余数作为hash值的方法。
H(key)=key % mod;
通过这个散列函数可以把很大的数转换为不超过mod的整数。显然,当mod是一个素数时,H(key)能尽可能覆盖[0,mod)范围的每一个数。因此为了方便起见,我们将表长设为一个素数,而mod直接等于表长。
但是依然还有问题:这样做可能使两个不同的数hash值相同,我们把这种情况叫做冲突。
下面三种方法解决冲突,其中第一种和第二种计算了新的hash值,又称为开放定址法。
-
线性探查法
当H(key)的位置已经被其他某个元素使用了,那么检查下一个位置,如果没有就使用该位置,否则继续找下一个位置。如果检查过程中超出了表长,那么回到表的首位置继续循环。这个做法容易导致扎堆,即表中连续若干个位置都被使用。会在一定程度上降低效率。
-
平方探查法
为了避免扎堆,用如下顺序检查表中的位置:H(key)+12、H(key)-12、H(key)+22、H(key)-22、H(key)+32以此类推。如果检查过程中超出了表长tsize,那么就把H(key)+k2对表长取模。如果检查过程中出现H(key)-k2<0的情况,那么将H(key)-k2不断加上表长再对表长取模直到出现第一个非负数
-
链地址法
把所有H(key)相同的key连接成一条单链表。设定一个数组link,范围是link[0]~link[mod-1],其中link[h]存放H(key)=h的一条单链表
可以使用标准库模板库中的map来直接使用hash的功能。
字符串hash初步
假设字符串均由大写字母A~Z组成,那么可以把26个大写字母对应到26进制中。如果有小写字母,可以把52个字母对应转换为52进制,数字增至62
但是使用时字符串不能太长,否则数字会过大
递归
分治
分治将原问题划分为若干个规模较小而结构与原问题相同或相似的子问题,分别解决这些子问题,最后合并子问题的解,即可得到原问题的解。
- 分解
- 解决
- 合并
分治法的子问题应该是相互独立的。
分治法作为一种算法思想,不限于用递归的手段实现。
递归
“要理解递归,你要先理解递归,直到你能理解递归。”
递归的逻辑中有两个重要概念
- 递归边界
- 递归调用
递归调用是将原问题分解为若干个子问题的手段
//使用递归求解n的阶乘
//如果用F(n)表示n!,就可以写成F(n)=F(n-1) * n
int F(int n){
if(n == 0) return 1;
else return F(n-1) * n;
}
全排列
全排列指这n个整数能形成的所有排列,从递归的角度去思考,如果把问题描述成“输出1~n这n个整数的全排列”,那么可以被分解为n个子问题:“输出以1开头的全排列”“输出以2开头的全排列”…“输出以n开头的全排列”。不妨设定一个数组P,用来存放当前的排列;再谁当一个散列数组hashtable,其中hashtable[x]当整数x已经在P中时为true。
现在按顺序往P的第1位到第n位中填入数字,不妨假设已经填好了P[1]P[index-1],正准备填P[index]。显然需要枚举1n,如果当前枚举的数字x还没有在前面出现过,那么就把它填入P[index],同时将hashtable设为true,接着去处理P的第index+1位。当递归完成后,再将hashtable[x]还原为false,以便让P[index]填下一个数字。
当index达到n+1,说明P的所有位都已经填好,可以输出数组P,然后直接return。
#include<cstdio>
const int maxn = 11;
int n, P[maxn], hashtable[maxn] = { false };
void generateP(int index) {
if (index == n + 1) { //递归边界,输出当前排列
for (int i = 1; i <= n; i++) {
printf("%d", P[i]);
}
printf("\n");
return;
}
for (int x = 1; x <= n; x++) { //枚举1~n
if (hashtable[x] == false) {//如果x未被使用
P[index] = x;//将x加入当前排列
hashtable[x] = true;//x已被占用
generateP(index + 1);//处理该排列的下一位
hashtable[x] = false;//x恢复状态
}
}
}
int main() {
n = 3;
generateP(1);
return 0;
}
n皇后问题
在一个n*n的棋盘上放置n个皇后,使这n个皇后两两均不在同行同列同对角线,求合法的方案数
把n列皇后所在的行号依次写出,那么就会是一个1~n的一个排列,总共有n!个排列,比直接枚举优秀
于是可以在全排列的代码基础上求解,此时到达边界还需要判断是否合法
#include<cstdio>
#include<cmath>
const int maxn = 11;
int n, P[maxn], hashtable[maxn] = { false };
int count = 0;
void generateP(int index) {
if (index == n + 1) {
bool flag = true;
for (int i = 1; i <= n; i ++ ) {
for (int j = i + 1; j <= n; j++) {
if (abs(i - j) == abs(P[i] - P[j])) {
flag = false;
}
}
}
if (flag) count++;
return;
}
for (int x = 1; x <= n; x++) {
if (hashtable[x] == false) {
P[index] = x;
hashtable[x] = true;
generateP(index + 1);
hashtable[x] = false;
}
}
}
int main() {
n = 8;
generateP(1);
printf("%d", count);
return 0;
}
这种直接枚举的方法称为暴力法。
如果在到达递归边界前的某层,由于一些事实导致已经不需要往任何一个子问题递归,就可以直接返回上一层。一般把这种方法称为回溯法。
#include<cstdio>
#include<cmath>
const int maxn = 11;
int n, P[maxn], hashtable[maxn] = { false };
int count = 0;
void generateP(int index) {
if (index == n + 1) {
count++;
return;
}
for (int x = 1; x <= n; x++) {//第x行
if (hashtable[x] == false) {//如果第x行没有皇后
//以下检查是否和之前的皇后冲突
bool flag = true;
for (int pre = 1; pre < index; pre++) {
if (abs(index - pre) == abs(x - P[pre])) {
flag = false;
break;
}
}
if (flag) {//如果可以把皇后放在这行
P[index] = x;//令index列皇后的行号为x
hashtable[x] = true;//占用x行
generateP(index + 1);//处理第index+1列皇后
hashtable[x] = false;//释放x行
}
}
}
}
int main() {
n = 8;
generateP(1);
printf("%d", count);
return 0;
}
贪心
简单贪心
贪心算法总是考虑在当前状态下局部最优,来使全局的结果达到最优。贪心的证明往往比贪心本身更难。
B1023 组个最小数 (20 分)
给定数字 0-9 各若干个。你可以以任意顺序排列这些数字,但必须全部使用。目标是使得最后得到的数尽可能小(注意 0 不能做首位)。例如:给定两个 0,两个 1,三个 5,一个 8,我们得到的最小的数就是 10015558。
现给定数字,请编写程序输出能够组成的最小的数。
输入格式:
输入在一行中给出 10 个非负整数,顺序表示我们拥有数字 0、数字 1、……数字 9 的个数。整数间用一个空格分隔。10 个数字的总个数不超过 50,且至少拥有 1 个非 0 的数字。
输出格式:
在一行中输出能够组成的最小的数。
输入样例:
2 2 0 0 0 3 0 0 1 0
输出样例:
10015558
思路
首先由于所有数字都必须参与组合,因此位数确定;由于最高位不等于0,因此从1-9中选出最小的数输出。针对除最高位以外的所有位,也是从高位到低位优先选择0-9中还存在的最小的数输出
区间贪心
题目:给出N个开区间(x,y),从中选择尽可能多的开区间,使得这些开区间两两没有交集
如果开区间I1被I2包含,I1显然是更好的选择,因为这样有更大的空间去容纳其他开区间
把所有开区间按照左端点x从大到小排序,如果去除掉区间包含的情况,那么有y1>y2>…>yn成立,观察发现I1右边有一段一定不和其他区间重叠,去掉它后I1被I2包含,因此应该选择I1。
由上述可知,对这种情况,总是先选择左端点最大的区间(同理总是先选择右端点最小的区间也可行)
与这个问题类似的是区间选点问题:给出N个闭区间,求最少需要确定多少个点,才能使每个闭区间中都至少存在一个点
总的来说,贪心是用来解决一类最优化问题,并希望由局部最优策略来推得全局最优结果的算法思想。贪心算法适用的问题一定满足最优子结构性质,即一个问题的最优解可以由它的子问题的最优解有效地构造出来。
二分
二分查找
猜数字:每次选择从当前范围的中间数去猜,就能尽可能快地逼近正确的数字。
这个游戏的背后是一个经典的问题:如何在一个严格递增序列A中找出给定的数x。
- 如果A[mid]==x,查找成功,退出查询
- 如果A[mid]>x,往右区间查找
- 如果A[mid]<x,往左区间查找
时间复杂度为O(logn)
mid = left+(right-left)/2
寻找有序序列中第一个满足某条件元素的位置
二分法拓展
木棒切割问题:根据对于当前长度L来说能得到的木棒段数k与K的关系来进行二分。由于这个问题可以写成求解最后一个满足条件的k>=K的长度L,因此不妨转换为求解第一个满足k<K的长度L,然后减1即可
快速幂
它基于二分的思想,因此也常称为二分幂。快速幂基于以下事实
- 如果b是奇数,那么有ab=a*ab-1
- 如果b是偶数,那么有ab=ab/2*ab/2
显然,在log(b)级别次数的转换后,就可以把b变为0,而任何整数的0次方都是1。
两个细节需要注意:
- 如果初始时a有可能大于等于m,那么需要在进入函数前就让a对m取模
- 如果m为1,可以直接在函数外部特判为0,无需进入函数计算
分别掌握递归写法和迭代写法
two pointers
什么是two pointers
这是算法编程中一种非常重要的思想。two pointers的思想十分简洁,但却提供了非常高的算法效率
给定一个递增的正整数序列和一个正整数M,求序列中两个不同位置的数a和b,使得他们的和恰好为M
令i的初值为0,j的初值为n-1
- 如果满足a[i]+a[j]==M,说明找到了其中一组方案,由于序列递增,此时剩余的方案只可能在[i+1,j-1]区间中产生,令i=i+1、j=j-1
- 如果满足a[i]+a[j]>M,由于序列递增,此时剩余的方案只可能在[i,j-1]区间中产生,令j=j-1
- 如果满足a[i]+a[j]<M,由于序列递增,此时剩余的方案只可能在[i+1,j]区间中产生,令i=i+1
反复执行上面三个判断,直到i>=j成立
序列合并问题:假设有两个递增序列A与B,要求将它们合并为一个递增序列C
同样的,可以设置两个下标,初值均为0,根据a[i]与b[j]的大小决定哪一个放入序列C
归并排序
归并排序是一种基于“归并”思想的排序方法。
2-路归并排序的原理是:将序列两两分组,将序列归并为[n/2]个组,组内单独排序,然后再将这些组两两归并,生成[n/4]个组,组内再次单独排序,依次类推,直到只剩下一个组为止。
归并排序的时间复杂度为O(nlogn)
//对a数组当前区间[left,right]进行归并排序
void mergeSort(int a[], int left, int right){
if(left < right){//只要left小于right
int mid = (left + right) / 2;//取中点
mergeSort(a, left, mid);//左侧归并
mergeSort(a, mid+1, right);//右侧归并
merge(A, left, mid+1, right)//左右合并
}
}
快速排序
是排序算法中平均时间复杂度为O(nlogn)的一种算法
先解决一个问题:对一个序列中的A[1],调整序列中元素的位置,使得它左侧所有元素都不大于它,右侧所有元素都大于它
速度最快的方法是双指针
- 先将它存至某个临时变量temp,然后left、right分别指向首尾。
- 只要right指向的元素大于temp就将right不断左移,当某个时候A[right]小于等于temp时,将A[right]挪到A[left]处
- 只要left指向的元素小于等于temp就将right不断左移,当某个时候A[right]大于temp时,将A[left]挪到A[right]处
- 重复2、3,直到left与right相遇,把temp放到这里
其中用以划分区间的元素A[left]被称为主元
快速排序的思路是
- 用上述方法调整序列中的元素
- 对该元素的左侧和右侧分别递归进行1的调整,直到当前调整区间的长度不超过1
void quickSort(int a[], int left, int right){
if(left < right){//区间长度大于1
//按a[left]一分为二
int pos = partition(a, left, right);
quickSort(a, left, pos-1);//左侧快排
quickSort(a, pos+1, right);//右侧快排
}
}
元素越有序,时间复杂度越高,因为主元没有把当前区间分成两个长度接近的区间
我们可以用随机数来选取主元
int randPartition(int a[], int left, int right){
//生成[left, right]内的随机数p
int p = (int)(round(1.0*rand()/RAND_MAX*(right-left)+left));
swap(a[p], a[left]);
//以下为原先的划分过程
int temp = a[left];
while(left<right){
while(left<right && a[right]>temp) right--;
a[left] = a[right];
while(left<right && a[left]<=temp) left++;
a[right] = a[left];
}
a[left] = temp;
return left;
}
其他高效技巧和算法
打表
用空间换时间
- 一次性计算出所有需要的结果,后面直接取
- 手工把结果写在数组内
- 暴力计算找规律
活用递推
有些题目找到递推关系可以让时间复杂度下降不少
随机选择算法
如何从一个无序的数组中求出第K大的数?
入门篇(3)——数学问题
简单数学
最大公约数和最小公倍数
int gcd(int a, int b){
return !b ? a : gcd(b, a % b);
}
最小公倍数a/gcd(a,b)*b
分数的四则运算
素数
埃氏筛法
#include<iostream>
using namespace std;
const int maxn = 101;//100以内的素数
int prime[maxn], pNum = 0;
bool p[maxn] = { 0 };//false为素数
void Find_Prime() {
for (int i = 2; i <= sqrt(maxn); ++i) {
if (p[i] == false) {//如果当前数是素数
prime[pNum++] = i;//素数个数+1,将该数存到素数数组中
for (int j = i*i; j < maxn; j += i) {
p[j] = true;//能被该数整除的都不是素数
}
}
}
}
int main() {
Find_Prime();
for (int i = 0; i < pNum; ++i) {
printf("%d", prime[i]);
}
return 0;
}
质因子分解
PAT 甲级 1008 Elevator (20 分)
#include<iostream>
#include<cmath>
using namespace std;
const int maxn = 100010;
int prime[maxn], pNum = 0;
bool p[maxn] = { 0 };//将所有数设为素数
void Find_Prime() {
for (int i = 2; i <= sqrt(maxn); ++i) {
if (p[i] == false) {//如果当前数是素数
prime[pNum++] = i;//素数个数+1,将该数存到素数数组中
for (int j = i*i; j < maxn; j += i) {
p[j] = true;//能被该数整除的都不是素数
}
}
}
}
struct factor {
int x, cnt;
}fac[10];
int main() {
Find_Prime();
int n, num = 0;
cin >> n;
//特判1的情况
if (n == 1)printf("1=1");
else {
printf("%d=", n);
for (int i = 0; i < pNum && prime[i] <= sqrt(n); ++i) {
if (n % prime[i] == 0) {
fac[num].x = prime[i];
fac[num].cnt = 0;
while (n % prime[i] == 0) {
fac[num].cnt++;
n /= prime[i];
}
num++;
}
if (n == 1) break;
}
if (n != 1) {
fac[num].x = n;
fac[num].cnt = 1;
num++;
}
for (int i = 0; i < num; ++i) {
if (i > 0)printf("*");
printf("%d", fac[i].x);
if (fac[i].cnt > 1)printf("^%d", fac[i].cnt);
}
}
return 0;
}
大整数运算
扩展欧几里得算法
组合数
C++ 标准模板库STL介绍
vector 的常见用法详解
#include<vertor>
using namespace std;
vector的定义
vector<typename> name;
定义vector数组
vector<int> vi[100];
vector容器内元素的访问
-
通过下标访问
访问vi[index]即可
-
通过迭代器访问
iterator可以理解为一种类似指针的东西,其定义是
vector<typename>::iterator it;
这样it就是一个
vector<typename>::iterator
型的变量例如有这样定义的一个vector容器
vector<int> vi; for(int i = 1; i <= 5; ++i){ vi.push_back(i);//push_back(i)在vi的末尾添加元素i,即依次添加1 2 3 4 5 } vector<int>::iterator it = vi.begin(); //vi.begin()为取vi的首元素地址,而it指向这个地址 for(int i = 0; i < 5; ++i){ printf("%d ", *(it + i));//输出vi[i] }
从这里可以看出vi[i]和*(vi.begin()+i)是等价的
既然说到begin()函数的作用为取vi的首元素地址,那么这里还要提到end()函数。end()函数取尾元素地址的下一个地址
另外,迭代器还实现了两种自加操作:++it和it++
vector常用函数实例解析
-
push_back(x)
在vector末尾添加一个元素x
-
pop_back()
删除vector末尾的元素
-
size()
返回vector中元素的个数
-
clear()
用来清空vector中的所有元素
-
insert()
insert(it,x)用来向vector的任意迭代器it处插入一个元素x
-
erase
可以删除单个元素,也可删除一个区间内的所有元素
删除单个元素
erase(it)即删除迭代器为it处的元素
删除一个区间的所有元素
erase(first,last)即删除[first,last)内的所有元素
vector常见用途
- 存储数据
- 用邻接表存储图
set的常见用法详解
set翻译为集合,是一个内部自动有序且不含重复元素的容器
#include<set>
using namespace std;
set的定义
set<int> name;
set<set<int> > name;//注意分开两个>
set<int> a[100];
set容器内元素的访问
set只能通过迭代器(iterator)访问
set<int>::iterator it;
这样就得到了迭代器it,并且可以通过*it来访问set里的元素
由于除开vector和string类以外的STL容器都不支持(it+i)的访问方式*,因此只能按如下方式枚举
for(set<int>::iterator it = st.begin(); it != st.end(); it++){
printf("%d", *it);
}
set常用函数实例解析
-
insert(x)
将x插入set容器中,并自动递增排序和去重
-
fine(value)
返回set中对应值为value的迭代器
-
erase()
删除单个元素
st.erase(it),it为所需要删除元素的迭代器,可以结合find函数来使用
st.erase(st.find(100));
st.erase(value),value为所需要删除元素的值
st.erase(100);
删除一个区间内的所有元素
st.erase(first, last)可以删除一个区间内的所有元素,first为所需要删除区间的起始迭代器,last则为所需要删除区间的末尾迭代器的下一个地址
-
size()
用来获得set内元素的个数
-
clear()
用来清空set中的所有元素
string的常见用法详解
#include<string>
using namespace std;
string的定义
string str;
string中内容的访问
通过下标访问
-
一般来说,可以直接像字符数组那样去访问string
-
如果要读入和输出整个字符串,只能用cin和cout
printf("%s\n", str.c_str());
通过迭代器访问
string::iterator it;
支持直接对迭代器进行加减某个数字
string常用函数实例解析
-
operator +=
str3 = str1 + str2; str1 += str2;
将两个string拼接起来
-
compare operator
两个string类型可以直接使用==,!=,<,>等比较大小,比较规则是字典序
-
length()/size()
length()返回string的长度,size()和length()基本相同
-
insert()
insert(pos, string)
在pos号位置插入字符串string
insert(it, it2, it3)
it为原字符串欲插入的位置,it2和it3为待插字符串的首位迭代器
str.insert(str.begin()+3, str2.begin(), str2.end());
-
erase()
删除单个元素
str.erase(it)
删除一个区间内的所有元素
str.erase(first, last); str.erase(pos, length);//pos为需要开始删除的起始位置,length为删除的字符个数
-
clear()
用以清空string中的数据
-
substr()
substr(pos, len)返回从pos号位开始,长度为len的子串
-
string::npos
是一个常数,本身的值为-1,用以作为find函数失配时的返回值
-
find()
str.find(str2),当str2是str的子串时,返回其在str中第一次出现的位置,否则返回string::npos str.fine(str2, pos),从pos号位开始匹配str2,返回与上相同
-
replace()
str.replace(pos, len, str2);//把str从pos号位开始,长度为len的字串替换为str2 str.replace(it1, it2, str2);//把str的迭代器[it1,it2)范围的子串替换为str2
map的常见用法详解
map翻译为映射,也是常用的STL容器
在定义数组时,其实是定义了一个从int型到int型的映射。map可以将任意基本类型(包括STL容器)映射到任意基本类型,也就可以建立string到int的映射
#include<map>
using namespace std;
map的定义
map<typename1, typename2> mp;
map和其他容器在定义上有点不一样,因为map要确定键和值
如果是字符串到整型的映射,必须用string而不能用char[]数组
map容器内元素的访问
map一般有两种访问方式:通过下标访问或通过迭代器访问
-
通过下标访问
例如对一个定义为map<char, int> mp的map来说,就可以使用mp[‘c’]的方式来访问它对应的整数
-
通过迭代器访问
map<typename1, typename2>::iterator it;
map可以使用it->first来访问键,使用it->second来访问值
map会以键从小到大的顺序自动排序
map常用函数实例解析
-
find()
find(‘key’)返回为key的映射的迭代器
-
erase()
erase()有两种用法:删除单个元素、删除一个区间内的所有元素
删除单个元素
mp.erase(it)//it为需要删除的元素的迭代器 mp.erase(key)//key为欲删除的映射的键
删除一个区间内的所有元素
mp.erase(first, last),first为需要删除的区间的起始迭代器,last为末尾迭代器的下一个地址,左闭右开
-
size()
用来获得map中映射的对数
-
clear()
用来清空map中的所有元素
map的常见用途
- 需要建立映射
- 判断大整数或其他类型数据是否存在
- 字符串和字符串的映射
另:一个键对应多个值 multimap
散列实现 unordered_map
queue的常见用法详解
queue翻译为队列,在STL中是一个先进先出的容器
queue的定义
#include<queue>
using namespace std;
queue<typename> name;
queue容器内元素的访问
由于队列本身就是一种限制性数据结构,因此在STL中只能通过front()来访问队首元素,或是通过back()来访问队尾元素
queue常用函数实例解析
-
push()
push(x)将x进行入队
-
front(), back()
分别获得队首元素和队尾元素
-
pop()
令队首元素出队
-
empty()
检测queue是否为空,返回true为空,返回false为非空
-
size()
返回queue内元素的个数
queue常见用途
- 需要实现广度优先搜索时
- 使用pop和front前,必须用empty判断队列是否为空
另:双端队列deque
优先队列priority_queue
priority_queue的常见用法详解
又称为优先队列,其底层是用堆来进行实现的
在优先队列中,队首元素一定是当前队列中优先级最高的哪一个
priority_queue的定义
#include<queue>
using namespace std;
priority_queue<typename> name;
priority_queue容器内元素的访问
只能通过top()函数来访问队首元素(也可以称为堆顶元素),也就是优先级最高的元素
priority_queue常用函数实例解析
-
push()
push(x)将令x入队
-
top()
获得队首元素
-
pop()
令队首元素出队
-
empty()
检测优先队列是否为空
-
size()
返回优先队列内元素的个数
priority_queue内元素优先级的设置
-
基本数据类型的优先级设置
优先队列默认设置是数字大的优先级越高
priority_queue<int, vector<int>, less<int> > q;
因此,如果想让优先队列总是把最小的元素放在队首,只需进行如下定义
priority_queue<int, vector<int>, greater<int> > q;
-
结构体的优先级设置
struct fruit{ string name; int price; };
现在希望按水果的价格高的为优先级高,就需要重载小于号
struct fruit{ string name; int price; friend bool operator < (fruit f1, fruit f2){ return f1.price < f2.price; } }
此时就可以直接定义fruit类型的优先队列,其内部就是以价格高的水果为优先级高
priority_queue<fruit> q;
如果需要价格低的水果优先级高,如下重载
friend bool operator < (fruit f1, fruit f2){ return f1.price > f2.price; }
有些类似于cmp函数
还有一种写法
struct cmp{ bool operator() (fruit f1, fruit f2){ return f1.price > f2.price; } }
在这种情况下,需要用之前讲的第二种定义方式定义优先队列
priority_queue<fruit, vector<fruit>, cmp> q;
即便是基本数据类型或者其他STL容器,也可以通过同样的方式定义优先级
数据较为庞大时,建议使用引用提高效率
(const fruit &f1, const fruit &f2)
priority_queue的常见用途
可以解决一些贪心问题,也可以优化Dijkstra算法
使用top()函数前必须用empty()判断队列是否为空
stack的常见用法详解
stack翻译为栈,是STL中实现的一个后进先出的容器
stack的定义
#include<stack>
using namespace std;
stack<typename> name;
stack容器内元素的访问
只能通过top()来访问栈顶元素
stack常用函数实例解析
-
push()
push(x)将x入栈
-
top()
获得栈顶元素
-
pop()
弹出栈顶元素
-
empty()
检测stack是否为空,返回true为空,返回false为非空
-
size()
返回stack内元素的个数
stack的常见用途
用来模拟实现一些递归
pair的常见用法详解
pair实际上可以看作一个内部有两个元素的结构体,且可以指定这两个元素的类型
#include<utility>
using namespace std;
注意,由于map的内部实现中涉及pair,所以添加map头文件时会自动添加utility头文件
pair的定义
pair<typename1, typename2> name;
pair<string, int> p;
pair<string, int> p("haha",5);
pair<string, int>("haha",5);
make_pair("haha",5)
pair中元素的访问
p = make_pair("haha",5);
cout << p.first << " " << p.second << endl;
pair常用函数实例解析
比较操作数
两个pair类型可以直接使用==,!=,<等,比较规则是先以first的大小作为标准,只有当first相等时才去判别second的大小
pair的常见用途
-
代替二元结构体
-
作为map的键值对进行插入
mp.insert(make_pair("heihei",5));
algorithm头文件下的常用函数
#include<algorithm>
using namespace std;
max(),min(),abs()
max(x,y)返回x和y中的最大值
min(x,y)返回x和y中的最小值
abs(x)返回x的绝对值
swap()
swap(x,y)用来交换x和y的值
reverse()
reverse(it1,it2)可以将数组指针在[it, it2)之间的元素或容器的迭代器在[it, it2)范围内的元素进行反转
next_permutation()
next_permutation()给出一个排序在全排列中的下一个序列
int a[3] = {1,2,3};
do{
printf("%d%d%d\n", a[0], a[1], a[2]);
}while(next_permutation(a, a+3));
在上述代码中,使用循环是因为next_permutation()在到达全排列的最后一个时会返回false,方便退出循环
fill()
fill()可以把数组或容器中的某一段区间赋为某个相同的值。和memset()不同,这里的赋值可以是数组类型对应范围中的任意值
fill(a, a+5, 233);//将a[0]到a[4]都赋为233
sort()
容器的排序
在STL标准容器中,只有vector、string、deque是可以使用sort的。这是因为像set、map这种容器是用红黑树实现的,元素本身有序,故不允许使用sort排序
sort(vi.begin(), vi.end(), cmp);//对整个vector排序
string默认是字典序排序
lower_bound()和upper_bound()
lower_bound()和upper_bound()需要用在一个有序数组或容器中
lower_bound(first, last, val)用来寻找在数组或容器的[first, last)范围内第一个值大于等于val的元素的位置,如果是数组,则返回该位置的指针;如果是容器,则返回该位置的迭代器。
upper_bound(first, last, val)用来寻找在数组或容器的[first, last)范围内第一个值大于val的元素的位置,如果是数组,则返回该位置的指针;如果是容器,则返回该位置的迭代器。
显然,如果数组或容器中没有需要寻找的元素,则它们均返回可以插入该元素的位置的指针或迭代器
想获得欲查元素的下标,可以直接令返回值减去数组首地址
提高篇(1)——数据结构专题(1)
栈的应用
栈是一种后进先出的数据结构
栈顶指针是始终指向栈的最上方的一个标记,栈空时令TOP为-1
栈的一些常用操作:
-
清空
栈的清空操作将栈顶指针TOP置为-1,表示栈中没有元素
-
获取栈内元素个数
由于栈顶指针TOP始终指向栈顶元素,而数组下标从0开始,因此栈内元素的数为TOP+1
-
判空
仅当TOP==-1时为栈空
-
进栈
push(x)将x置于栈顶,由于TOP指向栈顶元素,因此需要先把TOP加1,然后把x存入TOP指向的位置
-
出栈
pop()将栈顶元素出栈,事实上可以直接将栈顶指针-1来实现这个效果
-
取栈顶元素
由于栈顶指针始终指向栈顶元素,因此可以st[TOP]即为栈顶元素
出栈操作和取栈顶元素操作前必须先判是否为空
队列的应用
队列是一种先进先出的数据结构
一般来说,需要一个队首指针front来指向队首元素的前一个位置,而使用一个队尾指针rear来指向队尾元素
详见***queue常用函数实例解析***
链表处理
按正常方式定义一个数组时,计算机会从内存中取出一块连续的地址来存放给定长度的数组,而链表则是由若干个结点组成,且结点在内存中的存储位置通常是不连续的
静态链表通用解题步骤
-
定义静态链表
struct Node{ int address;//结点地址 typename data;//数据域 int next;//指针域 XXX;//结点的某个性质 }node[100010]
-
在程序的开始,对静态链表进行初始化。一般来说,需要对定义中的XXX进行初始化,将其定义为正常情况下达不到的数字。例如对结点是否在链表上这个性质来说,我们可以初始化为0,表示节点不在链表上
for(int i=0;i<maxn;i++){ node[i].XXX = 0; }
-
题目一般都会给出一条链表的首结点地址,那么我们就可以根据这个地址来遍历得到整条链表。需要注意的是,这一步同时也是我们对结点的性质XXX进行标记、并且对有效结点的个数进行计数的时候。例如对结点是否在链表上这个性质来说,当我们遍历链表时,就可以把XXX置为1
int p = begin, count = 0; while(p != -1){ XXX = 1; count++; p = node[p].next; }
-
由于使用静态链表时,是直接采用地址映射的方式,这就会使得数组下标不连续,而很多时候题目给出的结点并不都是有效结点,为了可控地访问有效结点,一般都需要对数组进行排序以把有效结点移到数组左端
一般来说题目一定会有额外的要求,因此cmp函数中一般都需要有第二级排序
bool cmp(Node a,Node b){ if(a.XXX == -1 || b.XXX == -1){ return a.XXX>b.XXX; }else{ //第二级排序 } }
提高篇(2)——搜索专题
深度优先搜索(DFS)
当碰到岔道口时,总是以“深度”作为前进的关键词,不碰到死胡同就不回头,因此把这种搜索方式称为 深度优先搜索
深度优先搜索是一种枚举所有完整路径以遍历所有情况的搜索方法
例题:给定N个整数(可能有负数),从中选择K个数,使得这K个数之和恰好等于一个给定的整数X;如果有多种方案,选择它们中元素平方和最大的一个。数据保证这样的方案唯一
//序列A中n个数选k个数使得和为x,最大平方和为maxSumSqu
int n, k, x, maxSumSqu = -1, A[maxn];
//temp存放临时方案,ans存放平方和最大的方案
vector<int> temp, ans;
//当前处理index号整数,当前已选整数个数为nowK
//当前已选整数之和为sum,当前已选整数平方和为sumSqu
void DFS(int index, int nowK, int sum, int sumSqu){
if(nowK == k && sum == x){//找到k个数的和为x
if(sumSqu > maxSumSqu){//如果比当前找到的更优
maxSumSqu = sumSqu;//更新最大平方和
ans = temp;//更新最优方案
}
return;
}
//已经处理完n个数,或者超过k个数,或者和超过x,返回
if(index == n ||nowK > k || sum > x) return;
//选index号数
temp.push_back(A[index]);
DFS(index+1, nowK+1, sum+A[index], sumSqu+A[index]*A[index]);
temp.pop_back();
//不选index号数
DFS(index+1, nowK, sum, sumSqu);
}
广度优先搜索(BFS)
当碰到岔道口时,总是先依次访问从该岔道口能直接到达的所有结点,然后再按这些结点被访问的顺序去依次访问它们能直接到达的所有结点,以此类推,直到所有结点都被访问为止。
BFS的一般由队列实现,且总是按层次的顺序进行遍历,其基本写法如下
void BFS(int s){
queue<int> q;
q.push(s);
while(!q.empty()){
//取出队首元素
//访问队首元素
//将队首元素出队
//将top的下一层结点中未曾入队的结点全部入队,并设置为已入队
}
}
给定一个m*n的矩阵,矩阵中的元素为0或1。如果矩阵中有若干个1是相邻的(不必两两相邻),那么称这些1构成了一个块,求给定的矩阵中块的个数
#include<iostream>
#include<queue>
using namespace std;
const int maxn = 100;
struct node{
int x,y; //位置(x,y)
}Node;
int n,m; //矩阵大小n*m
int matrix[maxn][maxn]; //01矩阵
bool inq[maxn][maxn] = {false}; //记录位置是否已入过队
int X[4] = {0, 0, 1, -1};// 增量数组
int Y[4] = {1, -1, 0, 0};
bool judge(int x,int y){ //判断坐标是否需要访问
if(x >= n || x < 0 || y >= m || y < 0) return false;
if(matrix[x][y] == 0 || inq[x][y] == true) return false;
return true;
}
void BFS(int x,int y){
queue<node> Q;
Node.x = x, Node.y = y;
Q.push(Node);
inq[x][y] = true; //设置已入过队
while(!Q.empty()){
node top = Q.front();
Q.pop();
for(int i=0; i<4; i++){
int newX = top.x + X[i];
int newY = top.y + Y[i];
if(judge(newX, newY)){ //如果新位置需要访问
Node.x = newX, Node.y = newY;
Q.push(Node);
inq[newX][newY] = true;//设置位置入队
}
}
}
}
int main(){
cin >> n >> m;
for(int i=0; i<n; i++){
for(int j=0; j<m; j++){
cin >> matrix[i][j];
}
}
int ans = 0;
for(int i=0; i<n; i++){
for(int j=0; j<m; j++){
if(matrix[i][j] == 1 && inq[i][j] == false){
ans++; //块数加1
BFS(i, j); //BFS访问整个块
}
}
}
cout << ans << endl;
return 0;
}
需要注意的一件事是,queue的push操作只是制造了该元素的一个副本入队,在入队后对原元素的修改不会影响队列中的副本。
提高篇(3)——数据结构专题(2)
树和二叉树
树的定义和性质
数据结构中把树枝分叉处、树叶、树根抽象为 结点(node),其中树根抽象为 根节点(root),且对一棵树来说最多存在一个根节点;把树叶概括为 叶节点(leaf),把茎干和树枝抽象为 边(edge)。
在数据结构中,一般把根结点置于最上方,然后向下延伸出若干条边到 子结点(child),从而向下形成 子树(subtree)。
- 树可以没有结点,这种情况下称之为空树
- 树的层次从根节点算起,即根节点为第一层
- 把结点的子树棵数称为结点的 度(degree),而树中结点的最大的度称为树的度
- 由于树中不存在环,因此对有n个结点的树,边数一定是n-1。且满足连通,边数等于顶点数-1的结构一定是一棵树
- 叶子结点被定义为度为0的结点,树中只有根节点时,根节点也算作叶子结点
- 结点的 **深度(depth)**是指从根节点(深度为1)开始自顶向下逐层累加至该结点时的高度值,**高度(height)**是指从最底层叶子结点(高度为1)开始自底向上逐层累加至该结点时的高度值。树的深度是指树中结点的最大深度,树的高度是指树中结点的最大高度,对树而言这两者在数值上相等
- 多棵树组合在一起称为 森林(forest)
二叉树的递归定义
注意区分二叉树与度为2的树的区别:二叉树的左右子树是严格区分的,不能随意交换左子树和右子树的位置
- 满二叉树:每一层的结点个数都达到了当层能达到的最大结点数。
- 完全二叉树:除了最下面一层之外,其余层的结点个数都达到了当层能达到的最大结点数,且最下面一层只从左至右连续存在若干结点,而这些连续结点右边的结点全部不存在
二叉树的存储结构和基本操作
用链表来定义,又把这种链表称为二叉链表
struct node{
typename data; //数据域
int layer; //层次
node* lchild; //指向左子树根结点的指针
node* rchild; //指向右子树根结点的指针
};
//由于在二叉树建树前根结点不存在,因此其地址一般设为NULL
node* root = NULL;
//生成一个新结点,v为结点权值
node* newNode(int v){
node* Node = new node;
Node->data = v; //结点权值为v
Node->lchild = Node->rchild = NULL; //没有孩子
return Node;
}
//查找和修改
void search(node* root, int x, int newdata){
if(root == NULL){
return; //空树,死胡同,边界
}
if(root->data == x){ //找到后修改值
root->data = newdata;
}
search(root->lchild, x, newdata); //递归往左搜索
search(root->rchild, x, newdata); //递归往右搜索
}
//insert函数将在二叉树中插入一个数据域为x的新结点
//注意使用引用(需要新建结点即修改二叉树结构时)
void insert(node* &root, int x){
if(root == NULL){ //空树,插入位置
root = newNode(x);
return;
}
if(//由于二叉树的性质x插在左子树){
insert(root->lchild, x); //递归往左搜索
}else{
insert(root->rchild, x); //递归往右搜索
}
}
//二叉树的建立
node* Create(int data[], int n){
node* root = NULL; //新建空根节点root
for(int i=0; i<n; i++){
insert(root, data[i]); //将数组中的数据插入二叉树中
}
return root; //返回根节点
}
对于完全二叉树,可以给它的所有结点按从上到下,从左到右的顺序进行编号,然后建立数组存放
二叉树的遍历
先序遍历:根结点,左子树,右子树
中序遍历:左子树,根结点,右子树
后序遍历:左子树,右子树,根结点
层序遍历:逐层往下进行
先序遍历
void preorder(node* root){
if(root == NULL){
return; //到达空树,递归边界
}
cout << root->data << endl; //访问根结点并做一些事
preorder(root->lchild); //访问左子树
preorder(root->rchild); //访问右子树
}
性质:对一颗二叉树的先序遍历序列,序列的第一个一定是根节点
中序遍历
void inorder(node* root){
if(root == NULL){
return; //到达空树,递归边界
}
preorder(root->lchild); //访问左子树
cout << root->data << endl; //访问根结点并做一些事
preorder(root->rchild); //访问右子树
}
性质:只要知道根结点,就可以通过根结点在中序遍历序列中的位置区分出左子树和右子树
后序遍历
void postorder(node* root){
if(root == NULL){
return; //到达空树,递归边界
}
postorder(root->lchild); //访问左子树
postorder(root->rchild); //访问右子树
cout << root->data << endl; //访问根结点并做一些事
}
性质:对一颗二叉树的后序遍历序列,序列的最后一个一定是根节点
总的来说,无论是先序遍历序列还是后序遍历序列,都必须知道中序遍历序列才能唯一地确定一棵树
层序遍历
void LayerOrder(node* root){
queue<node*> q; //队列里存放的是地址
root->layer = 1; //根结点层次为1
q.push(root); //根结点入队
while(!q.empty()){
node* now = q.front(); //取出队首元素
q.pop();
cout << root->data << endl; //访问队首元素并做一些事
if(now->lchild != NULL){ //左子树非空
now->lchild->layer = now->layer + 1;
q.push(now->lchild);
}
if(now->rchild != NULL){ //右子树非空
now->rchild->layer = now->layer + 1;
q.push(now->rchild);
}
}
}
最后解决一个重要的问题:给定一颗二叉树的先序遍历序列和中序遍历序列,重建该二叉树
//当前先序序列区间为[int preL, int preR],中序序列区间为[int inL, int inR],返回根结点地址
node* create(int preL, int preR, int inL, int inR){
if(preL > preR){
return NULL; //先序序列长度小于等于0时直接返回
}
node* root = new node; //新建一个结点,用于存放当前二叉树的根结点
root->data = pre[preL]; //新节点的数据域等于根结点的值
int k;
for(k=inL; k<=inR; k++){
if(in[k] == pre[preL]){ //在中序序列中找到根结点
break;
}
}
int numLeft = k - inL; //左子树的结点个数
//左子树的先序区间,中序区间,返回左子树的根结点地址,赋给root的左指针
root->lchild = create(preL+1, preL+numLeft, inL, k-1);
//右子树的先序区间,中序区间,返回右子树的根结点地址,赋给root的右指针
root->rchild = create(preL+1+numLeft, preR, k+1, inR);
return root;//返回根结点地址
}
结论:中序序列可以与先序序列、后序序列、层序序列中的任意一个来构建唯一的二叉树,后面那三个怎么搭配都不行
二叉树的静态实现
例题:1102 Invert a Binary Tree (25 分)
/*
* 根据题意可使用静态二叉树
*/
#include<iostream>
#include<queue>
using namespace std;
const int maxn = 100;
struct node { //二叉树的静态写法
int lchild, rchild;
}Node[maxn];
bool notRoot[maxn] = { false };//初始化所有的结点为根结点
int flag_1 = 1;
void inorder(int root) {
if (root == -1) {
return; //到达空树,递归边界
}
inorder(Node[root].lchild); //访问左子树
if (flag_1) {
printf("%d", root);
flag_1 = 0;
}
else printf(" %d", root); //访问根结点并做一些事
inorder(Node[root].rchild); //访问右子树
}
int flag_2 = 1;
void LayerOrder(int root) {
queue<int> q; //队列里存放的是地址
q.push(root); //根结点入队
while (!q.empty()) {
int now = q.front(); //取出队首元素
q.pop();
if (flag_2) {
printf("%d", now);
flag_2 = 0;
}
else printf(" %d", now); //访问根结点并做一些事
if (Node[now].lchild != -1) { //左子树非空
q.push(Node[now].lchild);
}
if (Node[now].rchild != -1) { //右子树非空
q.push(Node[now].rchild);
}
}
}
int str2num(char c) {
if (c == '-')return -1; //-1代表该结点不存在
else {
notRoot[c - '0'] = true; //存在则不是根结点
return c - '0'; //返回该结点的地址
}
}
int main() {
char lchild, rchild;
int n;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> rchild >> lchild; //因为要反转,所以反着读取左子树和右子树
Node[i].lchild = str2num(lchild);
Node[i].rchild = str2num(rchild);
}
int root;
for (root = 0; root < n; root++) {
if (notRoot[root] == false)break; //找到根结点
}
LayerOrder(root);
cout << endl;
inorder(root);
return 0;
}
树的遍历
树的静态写法
struct node {
int data;
//int layer; //记录层号
vector<int> child; //指针域,存放所有子节点的下标
}Node[maxn];
//新建结点
int index = 0;
int newNode(int v){
Node[index].data = v;
Node[index].child.clear();
return index++; //返回结点下标,并令index自增
}
在考试中涉及树的考查时,一般会给出结点编号,在这种情况下,就不需要newNode函数了,因为题目中给定的编号可以直接作为Node数组的下标
需要特别指出的是,如果题目中不涉及结点的数据域,即只需要树的结构,那么上面的结构体可以简化地写成vector数组,这种写法其实就是图的邻接表表示法在树中的应用
树的先根遍历
对一棵树,总是先访问根结点,再访问所有子树
void PreOrder(int root){
cout << Node[root].data << " "; //访问当前结点
for(int i=0; i<Node[root].child.size(); i++){
PreOrder(Node[root].child[i]); //递归访问结点root的所有子结点
}
}
树的层序遍历
树的层序遍历与二叉树的层序遍历的思路是一致的,即总是从树根开始,一层一层地向下遍历
void LayerOrder(int root){
queue<int> Q;
Q.push(root); //将根结点入队
Node[root].layer = 0; //记根结点的层号为0
while(!Q.empty()){
int front = Q.front(); //取出队首元素
cout << Node[front].data << " "; //访问当前结点
Q.pop();
for(int i=0; i<Node[front].child.size(); i++){
int child = Node[front].child[i];
Node[child].layer = Node[front].layer + 1;
Q.push(child); //将当前结点的所有子结点入队
}
}
}
从树的遍历看DFS和BFS
深度优先搜索DFS与先根遍历
对所有合法的DFS求解过程,都可以把它化作树的形式,并且对这颗树的DFS遍历就是树的先根遍历过程
另外,在进行DFS的过程中对某条可以确定不存在解的子树采取 直接剪断的策略称为 剪枝。但使用的前提是必须保证剪枝的正确性。
广度优先搜索BFS与层序遍历
对所有合法的BFS求解过程,都可以把它化作树的形式,并且对这颗树的BFS遍历就是树的层序遍历过程
二叉查找树(BST)
二叉查找树的定义
二叉查找树的递归定义如下:
- 要么二叉查找树是一颗空树
- 要么二叉查找树由根结点,左子树,右子树组成,其中左子树和右子树都是二叉查找树,且左子树上所有结点的数据域均小于或等于根结点的数据域,右子树上所有结点的数据域均大于根结点的数据域
由二叉查找树的定义可知,二叉查找树实际上是一颗数据域有序的二叉树
二叉查找树的基本操作
二叉查找树的基本操作有查找、加入、建树、删除
查找操作
void search(node* root, int x){
if(root == NULL){
printf("search failed\n");
return;
}
if(x == root->data){
printf("%d\n", root->data);
}else if(x < root->data){
search(root->lchild, x); //往左子树搜索
}else if(x > root->data){
search(root->rchild, x); //往右子树搜索
}
}
插入操作
void insert(node* root, int x){
if(root == NULL){
root = newNode(x); //新建结点
return;
}
if(x == root->data){
return;
}else if(x < root->data){
insert(root->lchild, x); //往左子树搜索
}else if(x > root->data){
insert(root->rchild, x); //往右子树搜索
}
}
二叉查找树的建立
node* Create(int data[],int n){
node* root = NULL; //新建结点
for(int i=0; i<n; i++){
insert(root, data[i]);
}
return root;
}
二叉查找树的删除
//寻找以root为根结点的树中的最大权值结点
node* fideMax(node* root){
while(root->rchild != NULL){
root = root->rchild;
}
return root;
}
//寻找以root为根结点的树中的最小权值结点
node* findMin(node* root){
while(root->lchild != NULL){
root = root->lchild;
}
return root;
}
void deleteNode(node* &root, int x){
if(root == NULL) return;
if(root->data == x){
if(root->lchild == NULL && root->rchild == NULL){
root = NULL;
}else if(root->lchild != NULL){
node* pre = findMax(root->lchild); //找root前驱
root->data = pre->data; //用前驱覆盖root
deleteNode(root->lchild, pre->data); //删除结点pre
}else{
node* next = findMin(root->rchild); //找root后继
root->data = next->data;
deleteNode(root->rchild, next->data); //删除结点next
}
}else if(root->data > x){
deleteNode(root->lchild, x); //在左子树中删除x
}else{
deleteNode(root->rchild, x); //在右子树中删除x
}
}
二叉查找树的性质
对二叉查找树进行中序遍历,遍历的结果是有序的
平衡二叉树(AVL)
平衡二叉树的定义
二叉查找树的缺陷:在某些情况下退化为一条链
对数的结构进行调整,使树的高度在每次插入元素后仍然能保持O(logn)的级别
平衡二叉树(AVL)仍然是一颗二叉查找树,只是在其基础上增加了“平衡”的要求
所谓平衡是指,对AVL树的任意结点来说,其左子树与右子树的高度之差的绝对值不超过1,其中左子树与右子树的高度之差称为该结点的平衡因子
只要能随时保证每个结点平衡因子的绝对值不超过1,AVL的高度就始终能保持O(logn)级别
struct node{
int v,height; //v为结点权值,height为当前子树高度
node *lchild, *rchild; //左右孩子结点地址
}
//生成一个新结点,v为结点权值
node *newNode(int v){
node* Node = new node; //申请一个node型变量的空间
Node->v = v; //结点权值为v
Node->height = 1; //结点高度初始为1
Node->lchild = Node->rchild = NULL; //初始状态没有左右孩子
return Node;
}
//获取以root为根结点的子树的当前height
int getHeight(node* root){
if(root == NULL) return 0; //空结点高度为0
return root->height;
}
//计算结点root的平衡因子
int getBalanceFactor(node* root){
//左子树高度减右子树高度
return getHeight(root->lchild) - getHeight(root->rchild);
}
//更新结点root的height
//显然结点root所在子树的height等于其左子树的height与右子树的height的较大值+1
void updateHeight(node* root){
//左右子树中高度较大者+1
root->height = max(getHeight(root->lchild),getHeight(root->rchild)) + 1;
}
平衡二叉树的基本操作
查找操作
由于AVL树是一颗二叉查找树,因此其查找操作的做法与二叉查找树相同
void search(node* root,int x){
if(root == NULL){
//空树,查找失败
return;
}
if(x == root->data){
//访问之
}else if(x < root->data){
search(root->lchild, x);
}else{
search(root->rchild, x);
}
}
插入操作
在往其中插入一个结点时,一定会有结点的平衡因子发生变化。
需要用到左旋和右旋操作来调整
//左旋
void L(node* &root){
node* temp = root->rchild; //root指向结点a,temp指向结点b
root->rchild = temp->lchild; //步骤1
temp->lchild = root; //步骤2
updateHeight(root); //更新a结点的高度
updateHeight(temp); //更新b结点的高度
root = temp; //步骤3,temp为新的根结点
}
//右旋
void R(node* &root){
node* temp = root->lchild; //root指向结点b,temp指向结点a
root->lchild = temp->rchild; //步骤1
temp->rchild = root; //步骤2
updateHeight(root); //更新b结点的高度
updateHeight(temp); //更新a结点的高度
root = temp; //步骤3,temp为新的根结点
}
可以证明,只要把最靠近插入结点的失衡结点调整到正常,路径上的所有结点就都会平衡
树型 | 判定条件 | 调整方法 |
---|---|---|
LL | BF(root)=2, BF(root->lchild)=1 | 对root进行右旋 |
LR | BF(root)=2, BF(root->lchild)=-1 | 先对root->lchild左旋,再对root右旋 |
RR | BF(root)=-2, BF(root->lchild)=-1 | 对root进行左旋 |
RL | BF(root)=-2, BF(root->lchild)=1 | 先对root->lchild右旋,再对root左旋 |
void insert(node* &root, int v){
if(root == NULL){ //到达空结点
root = newNode(v);
return;
}
if(v < root->v){ //v比根结点的权值小
insert(root->lchild, v); //往左子树插入
updateHeight(root); //更新树高
if(getBalanceFactor(root) == 2){
if(getBalanceFactor(root->lchild) == 1){ //LL
R(root);
}else if(getBalanceFactor(root->lchild) == -1){ //LR
L(root->lchild);
R(root);
}
}
}else{
insert(root->rchild, v); //往右子树插入
updateHeight(root); //更新树高
if(getBalanceFactor(root) == -2){
if(getBalanceFactor(root->rchild) == -1){ //RR
L(root);
}else if(getBalanceFactor(root->rchild) == 1){ //RL
R(root->rchild);
L(root);
}
}
}
}
并查集
并查集的定义
并查集是一种维护集合的数据结构,它的名字中"并"“查”"集"分别取自Union、Find、Set这三个单词。也就是说,并查集支持下面两个操作
- 合并:合并两个集合
- 查找:判断两个元素是否在一个集合
并查集的实现:数组
int father[N]; //存放父亲结点
int isRoot[N] = { 0 }; //记录每个结点是否作为某个集合的根结点
其中father[i]表示元素i的父亲结点,而父亲结点本身也是这个集合内的元素;另外如果father[i] == i,则说明元素i是该集合的根结点,但对同一个集合来说只存在一个根结点,且将其作为所属集合的标识。
并查集的基本操作
1.初始化
一开始每个元素都是独立的集合,因此需要令所有的father[i] = i
void init(int n) {
for (int i = 1; i <= n; i++) {
father[i] = i;
}
}
2.查找
由于规定同一个集合中只存在一个根结点,因此查找操作就是对给定的结点寻找其根结点的过程
//findFather函数返回元素x所在集合的根结点
int findFather(int x){
while(x != father[x]){ //如果不是根结点,继续循环
x = father[x]; //获得自己的父亲结点
}
return x;
}
//也可以用递归实现
int findFather(int x){
if(x == father[x]) return x;
else return findFather(father[x]);
}
3.合并
合并是指把两个集合合并成一个集合,题目中一般给出两个元素,要求把这两个元素所在的集合合并
- 对于给定的两个元素a、b,判断它们是否属于同一集合(找出两个根结点)
- 合并两个集合:只需要把其中一个根结点的父亲结点设为另一个根结点
void Union(int a,int b){
int faA = findFather(a);
int faB = findFather(b);
if(faA != faB){
father[faA] = faB;
}
}
并查集的一个性质:在合并的过程中,只对两个不同的集合进行合并,如果两个元素在相同的集合中,那么就不会对它们进行操作,这就保证了在同一个集合中一定不会产生环,即 并查集产生的每一个集合都是一棵树。
路径压缩
上面的并查集查找函数是没有经过优化的,在极端情况下效率较低
把当前查询结点的路径上的所有结点的父亲都指向根结点,查找的时候就不用一直回溯去寻找父亲了
- 按原先的写法获得x的根结点r
- 重新从x开始走一遍寻找根结点的过程,把路径上经过的所有结点的父亲全部改为根结点r
int findFather(int x){
//由于x在下面的while中会变成根结点,因此先把原来的x存一下
int a = x;
while(x != father[x]){
x = father[x];
}
//到这里,x存放的是根结点,下面把路径上的所有结点的father全部改成根结点
while(a != father[a]){
int t = a;
a = father[a];
father[t] = x;
}
return x;
}
//以下是递归写法
int findFather(int v){
if(v == father[v]) return v;
else{
int F = findFather(father[v]); //递归寻找根结点
father[v] = F; //将根结点F赋给father[v]
return F; //返回根结点F
}
}
堆
堆的定义与基本操作
堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子结点的值
其中,如果父亲结点的值大于或等于孩子结点的值,那么称这样的堆为 大顶堆,这时每个结点的值都是以它为根结点的子树的最大值;如果父亲结点的值小于或等于孩子结点的值,那么称这样的堆为 小顶堆。
堆一般用于优先队列的实现,而优先队列默认情况下使用的是大顶堆。
对完全二叉树来说,比较简洁的实现方法是按照9.1.3节中介绍的那样,使用数组来存储完全二叉树,这样结点就按层序存储于数组中,其中第一个结点将存储于数组中的1号位,并且数组i号位表示的结点的左孩子就是2i号位,而右孩子则是2i+1号位。于是可以定义数组表示堆:
const int maxn = 100;
int heap[maxn], n = 10;
每次调整都是把结点从上往下的调整,针对这种向下调整,总是将当前结点V与它的左右孩子比较,假如孩子中存在比结点V的权值大的,则将最大的与V交换,交换完后继续让V和孩子比较,直到结点V的孩子的权值都比它小或者没有孩子
void downAdjust(int low, int high){
int i = low, j = i*2;
while(j<=high){
if(j + 1 <= high && heap[j+1] > heap[j]){//右孩子存在,且右孩子大于左孩子
j = j+1;
}
if(heap[j] > heap[i]){ //如果孩子中最大的权值比欲调整结点i大
swap(heap[j], heap[i]); //交换最大权值的孩子与欲调整结点i
i = j; //保持i为欲调整结点,j为i的左孩子
j = i*2;
}
else{
break;//孩子的权值均比欲调整结点i小
}
}
}
倒着枚举建堆,可以保证每个结点都是以其为根结点的子树中的权值最大的结点
//建堆
void createHeap(){
for(int i=n/2; i>=1; i--){
downAdjust(i, n);
}
}
//删除堆顶元素
void deleteTop(){
heap[1] = heap[n--];
downAdjust(1, n);
}
如果想要添加一个元素,可以把想要添加的元素放在数组最后,然后进行向上调整操作
void upAdjust(int low, int high){
int i = high, j = i / 2;
while(j >= low){
if(heap[j] < heap[i]){
swap(heap[j],heap[i]);
i = j;
j = i/2;
}else{
break;
}
}
}
//添加元素x
void insert(int x){
heap[++n] = x; //元素个数+1,然后将数组末位赋值为x
upAdjust(1, n); //向上调整
}
堆排序
堆排序是指使用堆结构对一个序列进行排序
void heapSort(){
createHeap(); //建堆
for(int i=n; i>1; i--){
swap(heap[i], heap[1]);
downAdjust(1, i - 1);
}
}
哈夫曼树
哈夫曼树
合并果子问题
事实上可以发现,消耗体力之和也可以通过把叶子结点的权值乘以它们各自的路径长度再求和来获得,其中叶子结点的路径长度是指从根结点出发到达该结点所经过的边数
把叶子结点的权值乘以其路径长度的结果称为这个叶子结点的带权路径长度
树的带权路径长度等于它所有叶子结点的带权路径长度之和
于是合并果子问题就转换成:已知n个数,寻找一颗树,使得树的所有叶子结点的权值恰好为这n个数,并且使得这棵树的带权路径长度最小。带权路径长度最小的树被称为 哈夫曼树(又称为最优二叉树)。显然对于同一组叶子结点来说,哈夫曼树可以不是唯一的,但是最小带权路径长度一定是唯一的。
构造一颗哈夫曼树:
- 初始状态下共有n个结点,将它们视作n棵只有一个结点的树
- 合并其中根结点权值最小的两棵树,生成两棵树根结点的父结点,权值为这两个根结点的权值之和,这样树的数量就减少了一棵
- 重复2,直到只剩下一棵树为止,该树就是哈夫曼树
对哈夫曼树来说不存在度为1的结点,并且权值越高的结点相对来说越接近根结点
哈夫曼树的构造思想:反复选择两个最小的元素,合并,直到只剩下一个元素
以合并果子问题为例,可以直接使用优先队列来实现
#include<iostream>
#include<queue>
using namespace std;
//代表小顶堆的优先队列
priority_queue<long long, vector<long long>, greater<long long> > q;
int main() {
int n;
long long temp, x, y, ans = 0;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> temp;
q.push(temp); //将初始重量压入优先队列
}
while (q.size() > 1) { //只要优先队列里有两个及以上的元素
x = q.top();
q.pop();
y = q.top();
q.pop();
q.push(x + y); //取出堆顶的两个元素,求和后压入优先队列
ans += x + y; //累计求和的结果
}
printf("%lld\n", ans);
return 0;
}
哈夫曼编码
对任意一棵二叉树来讲,如果把二叉树上的所有分支都进行编号,将所有的左分支都标记为0,所有右分支都标记为1,那么对树上的任意一个结点,都可以根据从根结点出发到达它的分支顺序得到一个编号,且这个编号是唯一的。
对任何一个非叶子结点,其编号一定是某个叶子结点编号的前缀,例如结点T的编号就是结点C和结点D的编号的前缀。并且,对于任意一个叶子结点,其编号一定不会成为其他任何一个结点编号的前缀。
假设现在有一个字符串,要把它编码成一个01串,需要寻找一种编码方式,使得其中任何一个字符的编码都不是另一个字符的编码的前缀,同时把满足这种编码方式的编码称为 前缀编码
前缀编码的存在意义在于不产生混淆,让解码能够正常进行
对一个给定的字符串来说,肯定有很多种前缀编码的方式,但是为了信息传递的效率,需要尽量选择长度最短的编码方式。我们很快就能发现,如果把ABCD的出现次数作为各自叶子结点的权值,那么 字符串编码成01串后的长度实际上就是这棵树的带权路径长度
只需要针对叶子结点权值为1、2、3、4建立哈夫曼树,其叶子结点对应的编码方式就是所需要的
这种由哈夫曼树产生的编码方式被称为 哈夫曼编码,显然哈夫曼编码是能使给定字符串编码成01串后长度最短的前缀编码。
哈夫曼编码是针对确定的字符串来讲的
提高篇(4)——图算法专题
图的定义和相关术语
抽象来看,图由顶点和边组成,每条边的两端都必须是图的两个顶点。
一般来说,图可分为 有向图 和 无向图 。有向图的所有边都有方向,而无向图的所有边都是双向的。在一些问题中,可以把无向图当作所有边都是正向和负向的两条有向边组成,这对解决一些问题很有帮助。
顶点的度是指和该顶点相连的边的条数。对于有向图,出边条数为出度,入边条数为入度
顶点和边的权值分别称为点权和边权
图的存储
一般来说有两种:邻接矩阵和邻接表
邻接矩阵
设图(G, E)的顶点标号为0,1,…,N-1,那么可以令二维数组G[N][N]
的两维分别表示图的顶点标号。
如果G[i][j]
为1,则说明顶点i和顶点j之间有边;如果为0则说明它们之间不存在边。这个二维数组被称为邻接矩阵。另外如果存在边权,可以令邻接矩阵存放边权,对不存在的边可以设边权为0,-1,或很大的一个数
虽然邻接矩阵比较好写,但只适用于顶点数目不太大(不超过1000)的题目
邻接表
每个顶点都可能有若干条出边。如果把同一个顶点的所有出边放在一个列表里,那么N个顶点就会有N个列表,这N个列表被称为图G的邻接表,记为Adj[N]。其中Adj[i]存放顶点i的所有出边组成的列表
如果邻接表只存放每条边的终点编号而不存放边权,则vector中的元素类型可以直接定义为int型
vector<int> Adj[N];
如果需要同时存放边的终点编号和边权,那么可以建立结构体
struct Node{
int v; //终点编号
int w; //边权
};
vector<Node> Adj[N];
当然,更快的做法是定义结构体中的构造函数
struct Node{
int v; //终点编号
int w; //边权
Node(int _v, int _w): v(_v), w(_w) {};
}
Adj[1].push_back(Node(3,4));
图的遍历
图的遍历是指对图的所有顶点按一定的顺序访问,遍历方法一般有两种:DFS和BFS
采用DFS遍历图
深度优先搜索以“深度”为第一关键词,每次都是沿着路径到不能再前进时才退回到最近的岔道口。
DFS的具体实现
- 连通分量:在无向图中,如果两个顶点之间可以相互到达(可以是通过一定路径间接到达),我们就称这两个顶点连通。如果图G(V,E)的任意两个顶点都连通,则称图G为连通图,否则称它为非连通图,且称其中的极大连通子图为连通分量
- 强连通分量:在有向图中,如果两个顶点可以各自通过一条有向路径到达另一个顶点,就称这两个顶点强连通。如果图G(V,E)的任意两个顶点都强连通,则称图G为强连通图,否则称它为非强连通图,且称其中的极大强连通子图为强连通分量
下面把连通分量和强连通分量均称为连通块
DFS遍历图的基本思路就是将已经过的顶点设置为已访问,在下次递归时碰到这个顶点时就不再去处理,直到整个图的顶点都被标记为已访问
const int MAXV = 1000; //最大顶点数
const int INF = 1000000000; //设INF为一个很大的数
//邻接矩阵版
int n, G[MAXV][MAXV]; //顶点数为n
bool vis[MAXV] = {false}; //初始所有顶点都没有被访问
void DFS(int u, int depth){ //u为当前访问的顶点标号,depth为深度
vis[u] = true; //设置u已被访问
//进行某些操作
for(int v = 0; v<n; v++){
if(vis[u] == false && G[u][v] != INF){
DFS(v, depth+1);
}
}
}
void DFSTrave(){
for(int u=0; u<n; u++){
if(vis[u] == false){
DFS(u,1);
}
}
}
//邻接表版
vector<int> Adj[MAXV];
int n;
bool vis[MAXV] = {false}; //初始所有顶点都没有被访问
void DFS(int u, int depth){ //u为当前访问的顶点标号,depth为深度
vis[u] = true; //设置u已被访问
//进行某些操作
for(int i = 0; i<Adj[u].size(); i++){
int v = Adj[u][i];
if(vis[u] == false){
DFS(v, depth+1);
}
}
}
void DFSTrave(){
for(int u=0; u<n; u++){
if(vis[u] == false){
DFS(u,1);
}
}
}
【PAT A1034】Head of a Gang
#include<iostream> #include<map> #include<string> using namespace std; const int maxn = 2010; //人数上限 const int INF = 1000000000; map<int, string> intToString; //编号to姓名 map<string, int> stringToInt; //姓名to编号 map<string, int> Gang; //头领to人数 int G[maxn][maxn] = { 0 }, weight[maxn] = { 0 }; //邻接矩阵G,点权weight int n, k, numPerson = 0; //边权n,下限k及总人数numPerson bool vis[maxn] = { false }; //标记是否被访问 //DFS函数访问单个连通块,nowVisir为当前访问的编号 //head为头目,numMember为成员编号,totalValue为连通块的总边权 void DFS(int nowVisit, int& head, int& numMember, int& totalValue) { numMember++; //成员人数+1 vis[nowVisit] = true; //标记已访问 if (weight[nowVisit] > weight[head]) {//如果当前成员点权大于头目 head = nowVisit; //更换头目 } for (int i = 0; i < numPerson; i++) { if (G[nowVisit][i] > 0) { //边权大于0 totalValue += G[nowVisit][i]; G[nowVisit][i] = G[i][nowVisit] = 0; //删除这条边,防止回头 if (vis[i] == false) { //若该点尚未被访问 DFS(i, head, numMember, totalValue); } } } } //DFSTrave函数遍历整个图,获取每个连通块的信息 void DFSTrave() { for (int i = 0; i < numPerson; i++) { if (vis[i] == false) { int head = i, numMember = 0, totalValue = 0; DFS(i, head, numMember, totalValue); //DFS函数访问单个连通块 if (numMember > 2 && totalValue > k) { //满足成为团伙的条件 Gang[intToString[head]] = numMember; //记录之 } } } } int change(string str) { if (stringToInt.find(str) != stringToInt.end()) { return stringToInt[str]; //查找到该成员则返回编号 } else { //未查找到 stringToInt[str] = numPerson; //成员编号为当前成员总数 intToString[numPerson] = str; //设置编号to姓名 return numPerson++; //成员总数+1 } } int main() { int w; string str1, str2; cin >> n >> k; for (int i = 0; i < n; i++) { cin >> str1 >> str2 >> w; int id1 = change(str1); int id2 = change(str2); weight[id1] += w; //两个点权都要加 weight[id2] += w; G[id1][id2] += w; //两个方向的边权都要加 G[id2][id1] += w; } DFSTrave(); cout << Gang.size() << endl; map<string, int>::iterator it; //声明一个迭代器 for (it = Gang.begin(); it != Gang.end(); it++) { cout << it->first << " " << it->second << endl; } return 0; }
采用BFS遍历图
以“广度”作为关键词,每次以扩散的方式向外访问顶点。和树的遍历一样,使用BFS遍历图需要使用一个队列,通过反复取出队首顶点,将该顶点可到达的未曾加入过队列的顶点全部入队,直到队列为空时遍历结束
BFS的具体实现
使用BFS遍历图的基本思想是建立一个队列,并把初始顶点加入队列,以后每次都取出队首顶点进行访问,并把从该顶点出发可以到达的未曾加入过队列的顶点全部加入队列,直到队列为空
邻接表版
struct Node{
int v; //顶点编号
int layer; //顶点层号
}
vector<Node> Adj[N];
void BFS(int s){
queue<Node> q; //BFS队列
Node start; //起始顶点
start.v = s; //起始顶点编号
start.layer = 0; //起始顶点层号为0
q.push(start); //将起始顶点压入队列
inq[start.v] = true; //起始顶点的编号设为已被加入过队列
while(!q.empty()){
Node topNode = q.front(); //取出队首顶点
q.pop(); //队首顶点出队
int u = topNode.v; //队首顶点的编号
for(int i = 0; i < Adj[u].size(); i++){
Node next = Adj[u][i]; //从u出发能到达的顶点next
next.layer = topNode.layer + 1; //next层号等于当前顶点层号+1
//如果next的编号未被加入过队列
if(inq[next.v] == false){
q.push(next); //将next入队
inq[next.v] = true; //设置为已被加入过队列
}
}
}
}
【PAT A1076】Forwards on Weibo
#include<iostream> #include<cstring> #include<vector> #include<queue> using namespace std; const int MAXV = 1010; struct Node { int id; //结点编号 int layer; //结点层号 }; vector<Node> Adj[MAXV]; //邻接表 bool inq[MAXV] = { false }; //顶点是否已被加入过队列 int BFS(int s, int L) { //start为起始结点,L为层数上限 int numForward = 0; //转发数 queue<Node> q; //BFS队列 Node start; //起始顶点 start.id = s; //起始顶点编号 start.layer = 0; //起始顶点层号为0 q.push(start); //将起始顶点压入队列 inq[start.id] = true; //起始顶点的编号设为已被加入过队列 while (!q.empty()) { Node topNode = q.front(); //取出队首顶点 q.pop(); //队首顶点出队 int u = topNode.id; //队首顶点的编号 for (int i = 0; i < Adj[u].size(); i++) { Node next = Adj[u][i]; //从u出发能到达的顶点next next.layer = topNode.layer + 1; //next层号等于当前顶点层号+1 //如果next的编号未被加入过队列,并且next的层次不超过上限L if (inq[next.id] == false && next.layer <= L) { q.push(next); //将next入队 inq[next.id] = true; //设置为已被加入过队列 numForward++; } } } return numForward; } int main() { Node user; int n, L, numFollow, idFollow; cin >> n >> L; for (int i = 1; i <= n; i++) { user.id = i; cin >> numFollow; for (int j = 0; j < numFollow; j++) { cin >> idFollow; //i号用户关注的用户编号 Adj[idFollow].push_back(user); //边idFollow->i } } int numQuery, s; cin >> numQuery; //查询个数 for (int i = 0; i < numQuery; i++) { memset(inq, false, sizeof(inq)); //inq数组复原 cin >> s; //起始结点编号 int numForward = BFS(s, L); cout << numForward << endl; } return 0; }
最短路径
给定图G(V,E),求一条从起点到终点的路径,使得这条路径上经过的所有边的边权之和最小
解决最短路径的常用算法有 Dijkstra算法 、Bellman-Ford算法、SPFA算法、Floyd算法
Dijkstra算法(迪杰斯特拉算法)
Dijkstra算法用来解决 单源最短路问题,即给定图G和起点s,通过算法得到S到达其他每个顶点的最短距离。
Dijkstra的基本思想是对图G(V,E)设置集合S,存放已被访问的顶点,然后每次从集合V-S中选择与起点s的最短距离最小的一个顶点(记为u),访问并加入集合S。之后,令顶点u为中介点,优化起点s与所有从u能到达的顶点v之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S已包含所有顶点
Dijkstra算法的策略是:
设置集合S存放已被访问的顶点,然后执行n次下面的两个步骤(n为顶点个数)
- 每次从集合V-S中选择与起点s的最短距离最小的一个顶点(记为u),访问并加入集合S
- 之后,令顶点u为中介点,优化起点s与所有从u能到达的顶点v之间的最短距离
Dijkstra算法的具体实现:
由于Dijkstra算法的策略偏重于理论化,因此为了方便编写代码,需要想办法来实现策略中两个较为重要的东西,即集合S的实现、起点s到达顶点Vi的最短距离的实现
- 集合S可以用一个bool型数组vis[]来实现,即当vis[i] == true时表示顶点Vi已被访问,当vis[i] == false时表示顶点Vi未被访问
- 令int型数组d[]表示起点s到达顶点Vi的最短距离,初始时除了起点s的d[s]赋为0,其余顶点都赋给一个很大的数来表示INF,即不可达
邻接表版
struct Node{
int v,dis; //v为边的目标顶点,dis为边权
};
vector<Node> Adj[MAXV]; //图G,Adj[u]存放从顶点u出发可以到达的所有顶点
int n; //n为顶点数,图G使用邻接表实现,MAXV为最大顶点数
int d[MAXV]; //起点到达各点的最短路径长度
bool vis[MAXV] = {false}; //标记数组,vis[i] == true表示已访问,初值均为false
void Dijkstra(int s){ //s为起点
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF(慎用INF)
d[s] = 0; //起点s到自身的距离为0
for(int i = 0; i < n; i++){ //循环n次
int u = -1, MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for(int j = 0; j < n; j++){ //找到未访问的顶点中d[]最小的
if(vis[j] == false && d[j] < MIN){
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
}
//找不到小于INF的d[u],说明剩下的顶点和起点不连通
if(u == -1) return;
vis[u] = true; //标记为已访问
for(int j = 0; j < Adj[u].size(); j++){
int v = Adj[u][j].v; //通过邻接表直接获得u能到达的顶点v
if(vis[v] == false && d[u] + Adj[u][j].dis < d[v]){
d[v] = d[u] + Adj[u][j].dis; //优化d[v]
}
}
}
}
寻找最小d[u]的过程却可以不必达到O(V)的复杂度,而使用堆优化降低复杂度
Dijkstra算法只能应对所有边权都是非负数的情况,如果边权出现负数,最好使用SPFA算法
最短路径本身怎么求解呢?
可以设置数组pre[],令pre[v]表示从起点s到顶点v的最短路径上v的前一个结点(即前驱结点)的编号。这样,当伪代码中的条件成立时,就可以将u赋给pre[v],最终就能把最短路径上每一个顶点的前驱结点记录下来
以邻接矩阵作为举例:
int n, G[MAXV][MAXV]; //n为顶点数,MAXV为最大顶点数
int d[MAXV]; //起点到达各点的最短路径长度
int pre[MAXV]; //pre[v]表示从起点到顶点v的最短路径上v的前一个顶点
bool vis[MAXV] = {false}; //标记数组,vis[i] == true表示已访问,初值均为false
void Dijkstra(int s){ //s为起点
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF(慎用INF)
for(int i=0; i<n; i++) pre[i] = i; //初始状态设每个顶点的前驱为自身
d[s] = 0; //起点s到自身的距离为0
for(int i = 0; i < n; i++){ //循环n次
int u = -1, MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for(int j = 0; j < n; j++){ //找到未访问的顶点中d[]最小的
if(vis[j] == false && d[j] < MIN){
if(vis[j] == false && d[j] < MIN){
u = j;
MIN = d[j];
}
}
}
//找不到小于INF的d[u],说明剩下的顶点和起点不连通
if(u == -1) return;
vis[u] = true; //标记为已访问
for(int v = 0; v < n; v++){
//如果v未访问,u能到达v,以u为中介点可以使d[v]更优
if(vis[v] == false && G[u][v] != INF && d[u] + G[u][v] < d[v]){
d[v] = d[u] + G[u][v]; //优化d[v]
pre[v] = u; //记录v的前驱顶点是u
}
}
}
}
那么当想要知道从起点到达终点的最短路径,就可以用一个递归输出
void DFS(int s, int v){ //s为起点编号,v为当前访问的顶点编号(从终点开始递归)
if(v == s){ //如果当前已经到达起点s,则输出起点并返回
cout<<s<<endl;
return;
}
DFS(s, pre[v]); //递归访问v的前驱顶点pre[v]
cout<<v<<endl; //从最深处return回来之后,输出每一层的顶点号
}
【PAT A1003】Emergency
#include<iostream> #include<cstring> #include<algorithm> using namespace std; const int MAXV = 510; const int INF = 1000000000; int n, m, C1, C2; int G[MAXV][MAXV]; //邻接矩阵 int d[MAXV]; //起点到各点的距离 int weight[MAXV]; //各点的点权 int w[MAXV] = { 0 }; //最短路径上收集的救援小队 int num[MAXV] = { 0 }; //最短的路径条数 bool vis[MAXV] = { false }; //标记数组 void Dijkstra(int s) { fill(d, d + MAXV, INF); //将所有距离设置为INF d[s] = 0; //起点到自身的距离为0 num[s] = 1; //到自身有一条 w[s] = weight[s]; //起点的点权加上 for (int i = 0; i < n; i++) { int u = -1, MIN = INF; for (int j = 0; j < n; j++) { if (vis[j] == false && d[j] < MIN) { u = j; MIN = d[j]; } } if (u == -1) return; vis[u] = true; //标记为已访问 for (int v = 0; v < n; v++) { if (vis[v] == false && G[u][v] != INF) { if (d[u] + G[u][v] < d[v]) { d[v] = d[u] + G[u][v]; num[v] = num[u]; w[v] = w[u] + weight[v]; } else if (d[u] + G[u][v] == d[v]) { num[v] += num[u]; if (w[u] + weight[v] > w[v]) { w[v] = w[u] + weight[v]; } } } } } } int main() { cin >> n >> m >> C1 >> C2; for (int i = 0; i < n; i++) { cin >> weight[i]; } fill(G[0], G[0] + MAXV * MAXV, INF); for (int i = 0; i < m; i++) { int c1, c2, L; cin >> c1 >> c2 >> L; G[c1][c2] = L; G[c2][c1] = L; } Dijkstra(C1); cout << num[C2] << " " << w[C2] << endl; return 0; }
Dijkstra+DFS
回顾上面只使用Dijkstra算法的思路,会发现,算法中数组pre总是保持着最优路径,而这显然需要在执行Dijkstra算法的过程中使用严谨的思路来确定何时更新每个结点v的前驱结点pre[v]
事实上更简单的方法是:先在Dijkstra算法中记录下所有最短路径(只考虑距离),然后从这些最短路径中选出一条第二标尺最优的路径(因为在给定一条路径的情况下,针对这条路径的信息都可以通过边权和点权很容易计算出来)
-
使用Dijkstra算法记录所有最短路径
完整代码如下所示
vector<int> pre[MAXV]; void Dijkstra(int s) { //s为起点 fill(d, d + MAXV, INF); d[s] = 0; for (int i = 0; i < n; i++) { int u = -1, MIN = INF; for (int j = 0; j < n; j++) { if (vis[j] == false && d[j] < MIN) { u = j; MIN = d[j]; } } if (u == -1)return; vis[u] = true; for (int v = 0; v < n; v++) { if (vis[v] == false && G[u][v] != INF) { if (d[u] + G[u][v] < d[v]) { d[v] = d[u] + G[u][v]; pre[v].clear(); pre[v].push_back(u); } else if (d[u] + G[u][v] == d[v]) { pre[v].push_back(u); } } } } }
-
遍历所有最短路径,找出一条使第二标尺最优的路径
由于每个结点的前驱结点可能有多个,遍历的过程就会形成一棵递归树,例如1中pre数组产生的递归树。显然,对这棵树进行遍历时,每次到达叶子结点,就会产生一条完整的最短路径。因此,每得到一条完整路径,就可以对这条路径计算其第二标尺的值,令其与当前第二标尺的最优值进行比较,如果比当前值更优,那么更新它,并且用这条路径覆盖当前的最优路径。这样,当所有的最短路径都遍历完毕后,就可以得到最优第二标尺与最优路径
-
DFS代码如下所示
void DFS(int v) { //v为当前访问结点 //递归边界 if (v == st) { //如果到达了叶子结点st(即路径的起点) tempPath.push_back(v); //将起点st加入临时路径tempPath的最后面 int value; //存放临时路径tempPath的第二标尺的值 //计算路径tempPath上的value值 if (value > optvalue) { //根据实际情况进行填充大写或者小写 optvalue = value; //更新第二标尺最优值与最优路径 path = tempPath; } tempPath.pop_back(); //将刚加入的结点删除 return; } //递归式 tempPath.push_back(v); //将当前访问结点加入临时路径tempPath的最后面 for (int i = 0; i < pre[v].size(); i++) { DFS(pre[v][i]); //结点v的前驱结点pre[v][i],递归 } tempPath.pop_back(); }
需要注意的是, 由于递归的原因,存放在tempPath中的路径结点是逆序的,因此访问结点需要倒着进行。当然,如果仅是对边权或点权进行求和,那么正序访问也是可以的
//边权之和
int value = 0;
for (int i = tempPath.size() - 1; i > 0; i--) { //倒着访问结点,循环条件为i>0
//当前结点id,下一个结点idNext
int id = tempPath[i], idNext = tempPath[i - 1];
value += V[id][idNext]; //增加边权
}
//点权之和
int value = 0;
for (int i = tempPath.size() - 1; i >= 0; i--) { //倒着访问结点,循环条件为i>0
int id = tempPath[i]; //当前结点id
value += W[id]; //增加点权
}
最后指出,如果需要同时计算最短路径的条数,既可以添加num数组来求解,也可以开一个全局变量来记录最短路径的条数,当DFS到达叶子结点时令该全局变量+1即可
【PAT A1030】Travel Plan
//单使用Dijkstra算法 #include<iostream> #include<cstring> #include<algorithm> using namespace std; const int MAXV = 510; //最大顶点数 const int INF = 1000000000; //无穷大 //n为顶点数,m为边数,st和ed分别为起点和终点 //G为距离矩阵,cost为花费矩阵 //d[]记录最小距离,c[]记录最小花费 int n, m, st, ed, G[MAXV][MAXV], cost[MAXV][MAXV]; int d[MAXV], c[MAXV], pre[MAXV]; bool vis[MAXV] = { false }; void Dijkstra(int s) { fill(d, d + MAXV, INF); fill(c, c + MAXV, INF); for (int i = 0; i < n; i++)pre[i] = i; d[s] = 0; c[s] = 0; for (int i = 0; i < n; i++) { int u = -1, MIN = INF; for (int j = 0; j < n; j++) { if (vis[j] == false && d[j] < MIN) { u = j; MIN = d[j]; } } //找不到小于INF的d[u],说明剩下的顶点和起点不连通 if (u == -1) return; vis[u] = true; //标记为已访问 for (int v = 0; v < n; v++) { //如果v未访问,u能到达v,以u为中介点可以使d[v]更优 if (vis[v] == false && G[u][v] != INF) { if (d[u] + G[u][v] < d[v]) { d[v] = d[u] + G[u][v]; //优化d[v] c[v] = c[u] + cost[u][v]; pre[v] = u; } else if (d[u] + G[u][v] == d[v]) { if (c[u] + cost[u][v] < c[v]) { c[v] = c[u] + cost[u][v]; pre[v] = u; } } } } } } void DFS(int v) { if (v == st) { cout << v << " "; return; } DFS(pre[v]); cout << v << " "; } int main() { cin >> n >> m >> st >> ed; int u, v; fill(G[0], G[0] + MAXV * MAXV, INF); for (int i = 0; i < m; i++) { cin >> u >> v; cin >> G[u][v] >> cost[u][v]; G[v][u] = G[u][v]; cost[v][u] = cost[u][v]; } Dijkstra(st); DFS(ed); cout << d[ed] << " " << c[ed] << endl; return 0; }
//使用Dijkstra+DFS #include<iostream> #include<cstring> #include<vector> #include<algorithm> using namespace std; const int MAXV = 510; //最大顶点数 const int INF = 1000000000; //无穷大 //n为顶点数,m为边数,st和ed分别为起点和终点 //G为距离矩阵,cost为花费矩阵 //d[]记录最小距离,mincost记录最短路径上的最小花费 int n, m, st, ed, G[MAXV][MAXV], cost[MAXV][MAXV]; int d[MAXV], mincost = INF; bool vis[MAXV] = { false }; vector<int> pre[MAXV]; //前驱 vector<int> tempPath, path; //临时路径,最优路径 void Dijkstra(int s) { fill(d, d + MAXV, INF); d[s] = 0; for (int i = 0; i < n; i++) { int u = -1, MIN = INF; for (int j = 0; j < n; j++) { if (vis[j] == false && d[j] < MIN) { u = j; MIN = d[j]; } } //找不到小于INF的d[u],说明剩下的顶点和起点不连通 if (u == -1) return; vis[u] = true; //标记为已访问 for (int v = 0; v < n; v++) { //如果v未访问,u能到达v,以u为中介点可以使d[v]更优 if (vis[v] == false && G[u][v] != INF) { if (d[u] + G[u][v] < d[v]) { d[v] = d[u] + G[u][v]; //优化d[v] pre[v].clear(); pre[v].push_back(u); } else if (d[u] + G[u][v] == d[v]) { pre[v].push_back(u); } } } } } void DFS(int v) { if (v == st) { //递归边界,到达叶子结点 tempPath.push_back(v); int tempCost = 0; //记录当前路径的花费之和 for (int i = tempPath.size() - 1; i > 0; i--) { int id = tempPath[i], idNext = tempPath[i - 1]; tempCost += cost[id][idNext]; } if (tempCost < mincost) { mincost = tempCost; path = tempPath; } tempPath.pop_back(); return; } tempPath.push_back(v); for (int i = 0; i < pre[v].size(); i++) { DFS(pre[v][i]); } tempPath.pop_back(); } int main() { cin >> n >> m >> st >> ed; int u, v; fill(G[0], G[0] + MAXV * MAXV, INF); fill(cost[0], cost[0] + MAXV * MAXV, INF); for (int i = 0; i < m; i++) { cin >> u >> v; cin >> G[u][v] >> cost[u][v]; G[v][u] = G[u][v]; cost[v][u] = cost[u][v]; } Dijkstra(st); DFS(ed); for (int i = path.size() - 1; i >= 0; i--) { cout << path[i] << " "; } cout << d[ed] << " " << mincost << endl; return 0; }
Bellman-Ford算法(贝尔曼-福特算法)
Dijkstra算法可以很好地解决无负权图的最短路径问题,但如果出现了负权边,该算法就会失效
为了更好求解有负权边的最短路径问题,需要使用Bellman-Ford算法(贝尔曼-福特算法),和Dijkstra算法一样,贝尔曼-福特算法可解决最短路径问题,但也能处理有负权边的情况
贝尔曼-福特算法的主要思路:对图中的边进行V-1轮操作,每轮都遍历图中的所有边:对每条边u->v,如果以u为中介点可以使d[u]更小,即d[u]+length[u->v]<d[v]成立时,就用d[u]+length[u->v]更新d[v]。同时也可以看出,贝尔曼-福特算法的时间复杂度是O(VE),其中n是顶点个数,E是边数
若图中存在未确认的顶点,则对边集合的一次迭代松弛后,会增加至少一个已确认顶点
时间复杂度为O(VE)
当一次循环中没有松弛操作成功时停止。
每次循环是 O(m) 的,那么最多会循环多少次呢?
答案是 ∞!(如果有一个 S 能走到的负环就会这样)
但是此时某些结点的最短路不存在。
我们考虑最短路存在的时候。
由于一次松弛操作会使最短路的边数至少 +1,而最短路的边数最多为 n−1。
所以最多执行 n−1 次松弛操作,即最多循环 n−1 次。
bool Bellman(int s) {
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF
d[s] = 0; //起点s到达自身的距离为0
//以下为求解数组d的部分
for (int i = 0; i < n - 1; i++) { //执行n-1轮操作,n为顶点数
for (int u = 0; u < n; u++) { //每轮操作都遍历所有边
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; //邻接边的顶点
int dis = Adj[u][j].dis; //邻接边的边权
if (d[u] + dis < d[v]) {
d[v] = d[u] + dis; //松弛操作
}
}
}
}
//以下为判断负环的代码
for (int u = 0; u < n; u++) {
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v;
int dis = Adj[u][j].dis;
if (d[u] + dis < d[v]) { //如果仍然可以被松弛
return false;
}
}
}
return true; //d的所有值都已经达到最优
}
【PAT A1003】Emergency
#include<iostream> #include<cstring> #include<vector> #include<set> #include<algorithm> using namespace std; const int MAXV = 510; const int INF = 0x3fffffff; struct Node { int v, dis; //v为邻接边的目标顶点,dis为邻接边的边权 Node(int _v, int _dis) : v(_v), dis(_dis){} }; vector<Node>Adj[MAXV]; //n为顶点数,m为边数,st和ed分别为起点和终点,weight[]记录点权 int n, m, st, ed, weight[MAXV]; //d[]记录最短距离,w[]记录最大点权之和,num[]记录最短路径条数 int d[MAXV], w[MAXV], num[MAXV]; set<int> pre[MAXV]; //前驱 void Bellman(int s) { fill(d, d + MAXV, INF); memset(num, 0, sizeof(num)); memset(w, 0, sizeof(w)); d[s] = 0; w[s] = weight[s]; num[s] = 1; //以下为求解数组d的部分 for (int i = 0; i < n - 1; i++) { //执行n-1轮操作,n为顶点数 for (int u = 0; u < n; u++) { //每轮操作都遍历所有边 for (int j = 0; j < Adj[u].size(); j++) { int v = Adj[u][j].v; //邻接边的顶点 int dis = Adj[u][j].dis; //邻接边的边权 if (d[u] + dis < d[v]) { d[v] = d[u] + dis; //松弛操作 w[v] = w[u] + weight[v]; num[v] = num[u]; pre[v].clear(); pre[v].insert(u); } else if (d[u] + dis == d[v]) { if (w[u] + weight[v] > w[v]) { w[v] = w[u] + weight[v]; } pre[v].insert(u); num[v] = 0; set<int>::iterator it; for (it = pre[v].begin(); it != pre[v].end(); it++) { num[v] += num[*it]; } } } } } } int main() { cin >> n >> m >> st >> ed; for (int i = 0; i < n; i++) { cin >> weight[i]; } int u, v, wt; for (int i = 0; i < m; i++) { cin >> u >> v >> wt; Adj[u].push_back(Node(v, wt)); Adj[v].push_back(Node(u, wt)); } Bellman(st); printf("%d %d\n",num[ed],w[ed]); return 0; }
SPFA算法
- Bellman算法的时间复杂度很高,仔细思考后发现,Bellman算法的每轮操作都需要操作所有边
- 注意到,只有当某个顶点u的d[u]值改变时,从它出发的边的邻接点v的d[v]值才有可能被改变
- 由此可以进行一个优化:建立一个队列,每次将队首顶点u取出,然后对从u出发的所有边进行松弛操作,也就是判断d[u]+length[u->v]<d[v]是否成立,若成立,则用后者覆盖前者,于是d[v]获得更优的值,此时如果v不在队列中,就把v加入队列,这样操作直到队列为空或是某个顶点的入队次数超过V-1
这种优化后的算法被称为SPFA,它的期望时间复杂度是O(kE)。这个算法在处理大部分数据时异常高效,并且经常性的优于堆优化的Dijkstra算法
虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度为 O(VE),将其卡到这个复杂度也是不难的,所以考试时要谨慎使用(在没有负权边时最好使用 Dijkstra 算法,在有负权边且题目中的图没有特殊性质时,若 SPFA 是标算的一部分,题目不应当给出 Bellman-Ford 算法无法通过的数据范围)。
vector<Node> Adj[MAXV]; //图G的邻接表
int n, d[MAXV], num[MAXV]; //num数组记录顶点的入队次数
bool inq[MAXV]; //顶点是否在队列中
bool SPFA(int s) {
//初始化部分
memset(inq, false, sizeof(inq));
memset(num, 0, sizeof(num));
fill(d, d + MAXV, INF);
//源点入队部分
queue<int> Q;
Q.push(s); //源点入队
inq[s] = true; //源点已入队
num[s]++; //源点入队次数+1
d[s] = 0; //源点的d值为0
//主体部分
while (!Q.empty()) {
int u = Q.front(); //队首顶点编号为u
Q.pop(); //出队
inq[u] = false; //设置u为不在队列中
//遍历u的所有邻接边v
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; //邻接边的顶点
int dis = Adj[u][j].dis; //邻接边的边权
//松弛操作
if (d[u] + dis < d[v]) {
d[v] = d[u] + dis;
if (!inq[v]) { //如果v不在队列中
Q.push(v); //v入队
inq[v] = true; //设置v在队列中
num[v]++; //v的入队次数+!
if (num[v] >= n)return false; //有可到达的负环
}
}
}
}
return true; //无可到达的负环
}
SPFA十分灵活,其内部的写法可以根据具体场景进行不同调整,例如上面代码中的队列可以替换成优先队列以加快速度,或者替换成双端队列,使用SLF优化和LLL优化,以使效率提高至少50%
除此之外,上面的代码给出的是BFS的SPFA,还可以替换为DFS的SPFA
然而现在卡SPFA成为了一种普遍现象,所以有说法称SPFA已死
Floyd算法
弗洛伊德算法用来解决全源最短路问题
即对给定的图G(V,E),求任意两点u,v之间的最短路径长度,时间复杂度为O(n3)。由于n3的复杂度决定了顶点数n的限制约在200以内,因此使用邻接矩阵来实现Floyd算法是非常合适且方便的
#include<iostream>
#include<algorithm>
using namespace std;
const int INF = 0x3fffffff;
const int MAXV = 200;
int n, m; //n为顶点数,m为边数
int dis[MAXV][MAXV]; //dis[i][j]表示顶点i和顶点j的最短距离
void Floyd() {
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dis[i][k] != INF && dis[k][j] != INF && dis[i][k] + dis[k][j] < dis[i][j]) {
dis[i][j] = dis[i][k] + dis[k][j];
}
}
}
}
}
int main() {
int u, v, w;
fill(dis[0], dis[0] + MAXV * MAXV, INF); //dis数组赋初值
cin >> n >> m;
for (int i = 0; i < n; i++) {
dis[i][i] = 0; //顶点i到顶点i的距离初始化为0
}
for (int i = 0; i < m; i++) {
cin >> u >> v >> w;
dis[u][v] = w;
}
Floyd(); //算法入口
for (int i = 0; i < n; i++) { //输出dis数组
for (int j = 0; j < n; j++) {
cout << dis[i][j] << " ";
}
cout << endl;
}
return 0;
}
对Floyd算法来说,不能将最外层的k循环放到内层:因为如果较后访问的dis[u][v]
有了优化之后,前面访问的dis[i][j]
会因为已经被访问而无法获得进一步优化
最小生成树
最小生成树及其性质
最小生成树是在一个给定的无向图G(V,E)中求一棵树T,使得这颗树拥有图G中的所有顶点,且所有边都是来自图G中的边,并且满足整棵树的边权最小
最小生成树有三个性质要掌握
- 最小生成树是树,因此其边数等于顶点数-1,且树内一定不会有环
- 对给定的图G(V,E),其最小生成树可以不唯一,但其边权之和一定是唯一的
- 由于最小生成树是在无向图上生成的,因此其根结点可以是这棵树上的任意一个结点。于是,如果题目中涉及最小生成树本身的输出,为了让最小生成树唯一,一般都会直接给出根结点,只需要以给出的结点作为根节点来求解最小生成树即可
求解最小生成树一般有两种算法,即prim算法和kruskal算法。这两个算法都是采用了贪心的思想,只是贪心的策略不太一样
prim算法(普里姆算法)
prim算法用来解决最小生成树问题,其基本思想是对图G(V,E)设置集合S,存放已被访问的顶点,然后每次从集合V-S中选择与集合S的最短距离最小的一个顶点(记为u),访问并加入集合S,之后令顶点u为中介点,优化所有从u能到达的顶点v与集合S之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S已包含所有顶点
Dijkstra算法和prim算法实际上是相同的思路,只不过是数组d[]的含义不同罢了
邻接矩阵版
int prim() { //默认0号为初始点,函数返回最小生成树的边权之和
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF
d[0] = 0; //只有0号顶点到集合s的距离为0,其余全为INF
int ans = 0; //存放最小生成树的边权之和
for (int i = 0; i < n; i++) { //循环n次
int u = -1, MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for (int j = 0; j < n; j++) { //找到未访问的顶点中d[]最小的
if (vis[j] == false && d[j] < MIN) {
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
}
//找不到小于INF的d[u],说明剩下的顶点和起点不连通
if (u == -1) return -1;
vis[u] = true; //标记为已访问
ans += d[u]; //将与集合s距离最小的边加入最小生成树
for (int v = 0; v < n; v++) {
//如果v未访问,u能到达v,以u为中介点可以使d[v]更优
if (vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
d[v] = G[u][v]; //优化d[v]
}
}
}
return ans; //返回最小生成树的边权之和
}
邻接表版
struct Node {
int v, dis; //v为边的目标顶点,dis为边权
};
vector<Node> Adj[MAXV]; //图G,Adj[u]存放从顶点u出发可以到达的所有顶点
int n; //n为顶点数,图G使用邻接表实现,MAXV为最大顶点数
int d[MAXV]; //顶点到集合s的最短距离
bool vis[MAXV] = { false }; //标记数组,vis[i] == true表示已访问,初值均为false
int prim() { //默认0号为初始点,函数返回最小生成树的边权之和
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF(慎用INF)
d[0] = 0; //起点s到自身的距离为0
int ans = 0; //存放最小生成树的边权之和
for (int i = 0; i < n; i++) { //循环n次
int u = -1, MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for (int j = 0; j < n; j++) { //找到未访问的顶点中d[]最小的
if (vis[j] == false && d[j] < MIN) {
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
}
//找不到小于INF的d[u],说明剩下的顶点和起点不连通
if (u == -1) return -1;
vis[u] = true; //标记为已访问
ans += d[u]; //将与集合s距离最小的边加入最小生成树
for (int j = 0; j < Adj[u].size(); j++) {
int v = Adj[u][j].v; //通过邻接表直接获得u能到达的顶点v
if (vis[v] == false && Adj[u][j].dis < d[v]) {
d[v] = Adj[u][j].dis; //优化d[v]
}
}
}
}
和Dijkstra算法一样,复杂度为O(V2),其中邻接表实现的prim算法可以通过堆优化使时间复杂度降为O(VlogV+E)
尽量在图的顶点数目较少而边数较多的情况下(稠密图)使用prim算法
以亚历山大攻打恶魔大陆的例子写出代码
#include<iostream>
#include<algorithm>
using namespace std;
const int INF = 0x3fffffff;
const int MAXV = 1000;
int n, m, G[MAXV][MAXV]; //n为顶点数,MAXV为最大顶点数
int d[MAXV]; //顶点与集合s的最短距离
bool vis[MAXV] = { false }; //标记数组
int prim() { //默认0号为初始点,函数返回最小生成树的边权之和
fill(d, d + MAXV, INF); //fill函数将整个d数组赋为INF
d[0] = 0; //只有0号顶点到集合s的距离为0,其余全为INF
int ans = 0; //存放最小生成树的边权之和
for (int i = 0; i < n; i++) { //循环n次
int u = -1, MIN = INF; //u使d[u]最小,MIN存放该最小的d[u]
for (int j = 0; j < n; j++) { //找到未访问的顶点中d[]最小的
if (vis[j] == false && d[j] < MIN) {
if (vis[j] == false && d[j] < MIN) {
u = j;
MIN = d[j];
}
}
}
//找不到小于INF的d[u],说明剩下的顶点和起点不连通
if (u == -1) return -1;
vis[u] = true; //标记为已访问
ans += d[u]; //将与集合s距离最小的边加入最小生成树
for (int v = 0; v < n; v++) {
//如果v未访问,u能到达v,以u为中介点可以使d[v]更优
if (vis[v] == false && G[u][v] != INF && G[u][v] < d[v]) {
d[v] = G[u][v]; //优化d[v]
}
}
}
return ans; //返回最小生成树的边权之和
}
int main() {
int u, v, w;
cin >> n >> m; //顶点个数、边数
fill(G[0], G[0] + MAXV * MAXV, INF); //初始化图G
for (int i = 0; i < m; i++) {
cin >> u >> v >> w;
G[u][v] = G[v][u] = w; //无向图
}
int ans = prim(); //prim算法入口
cout << ans << endl;
return 0;
}
kruskal算法(克鲁斯卡尔算法)
kruskal算法同样是解决最小生成树问题的一个算法,和prim算法不同,kruskal算法采取了 边贪心的策略,其思想极其简洁:在初始状态是隐去图中的所有边,每个顶点自成一个连通块。之后执行下面的步骤:
- 对所有边按边权从小到大进行排序
- 按边权从小到大测试所有边,如果当前测试边所连接的两个顶点不在同一个连通块中,则把这条测试边加入当前最小生成树中;否则将边舍弃
- 执行步骤②,直到最小生成树中的边数等于总顶点数减1或是测试完所有边时结束,而当结束时如果最小生成树的边数小于总顶点数减1,说明该图不连通
kruskal算法的时间复杂度主要来源于对边进行排序,因此其时间复杂度为O(ElogE),其中E为图的边数。显然kruskal适合顶点数较多、边数较少的情况,这和prim算法恰好相反,于是可以根据题目所给的数据范围来选择何时的算法,即如果是稠密图(边多),则用prim算法;如果是稀疏图(边少),则用kruskal算法
#include<iostream>
#include<algorithm>
using namespace std;
const int MAXE = 10010;
const int MAXV = 110;
//边集定义部分
struct edge {
int u, v; //边的两个端点编号
int cost; //边权
}E[MAXE]; //最多有MAXE条边
bool cmp(edge a, edge b) {
return a.cost < b.cost;
}
//并查集部分
int father[MAXV]; //并查集数组
int findFather(int x) { //并查集查询函数
int a = x;
while (x != father[x]) {
x = father[x];
}
//路径压缩
while (a != father[a]) {
int z = a;
a = father[a];
father[z] = x;
}
return x;
}
//kruskal部分,返回最小生成树的边权之和,参数n为顶点个数,m为图的边数
int kruskal(int n, int m) {
//ans为所求边权之和,Num_Edge为当前生成树的边数
int ans = 0, Num_Edge = 0;
for (int i = 0; i < n; i++) {
father[i] = i; //并查集初始化
}
sort(E, E + m, cmp); //所有边按边权从小到大排序
for (int i = 0; i < m; i++) { //枚举所有边
int faU = findFather(E[i].u); //查询测试边两个端点所在集合的根结点
int faV = findFather(E[i].v);
if (faU != faV) { //如果不在一个集合中
father[faU] = faV; //合并集合
ans += E[i].cost; //增加测试边的边权
Num_Edge++; //当前生成树的边数加1
if (Num_Edge == n - 1) break; //边数等于顶点数-1时结束算法
}
}
if (Num_Edge != n - 1) return -1; //无法连通时返回-1
else return ans; //返回最小生成树的边权之和
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 0; i < m; i++) {
cin >> E[i].u >> E[i].v >> E[i].cost;
}
int ans = kruskal(n, m); //kruskal算法入口
cout << ans << endl;
return 0;
}
拓扑排序
有向无环图
如果一个有向图的任意顶点都无法通过一些有向边回到自身,那么称这个有向图为有向无环图(DAG)
拓扑排序
拓扑排序是将有向无环图的所有顶点排成一个线性序列,使得对图G中的任意两个顶点u、v,如果存在边u->v,那么在序列中u一定在v前面。这个序列又被称为拓扑序列
抽象为以下步骤
- 定义一个队列Q,并把所有入度为0的结点加入队列
- 取队首结点,输出,然后删除所有从它出发的边,并令这些边到达的顶底入度减1。如果某个顶点的入度减为0,则将其加入队列,
- 反复进行②操作,直到队列为空,如果队列为空时入过队的结点数目恰好为N,说明拓扑排序成功,图G为有向无环图;否则拓扑排序失败,图G中有环
可使用邻接表实现拓扑排序,显然,由于需要记录结点的入度,因此需要额外建立一个数组inDegree[MAXV],并在程序一开始读入图时就记录好每个结点的入度,接下来就只需要按上面所说的步骤进行实现即可,拓扑排序的代码如下:
vector<int> G[MAXV]; //邻接表
int n, m, inDegree[MAXV]; //顶点数,入度
//拓扑排序
bool topologicalSort() {
int num = 0; //记录加入拓扑序列的顶点数
queue<int> q;
for (int i = 0; i < n; i++) {
if (inDegree[i] = 0) {
q.push(i); //将所有入度为0的顶点入队
}
}
while (!q.empty()) {
int u = q.front(); //取队首顶点u
//此处可以输出顶点u
q.pop();
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][v]; //u的后继结点v
inDegree[v]--; //顶点v的入度减1
if (inDegree[v] == 0) { //顶点v的入度减为0则入队
q.push(v);
}
}
G[u].clear(); //清空u的所有出边(如无必要可以不写)
num++; //加入拓扑序列的顶点数加1
}
if (num == n) return true; //加入拓扑序列的顶点为u说明排序成功
else return false;
}
拓扑排序一个很重要的应用就是判断一个给定的图是否是有向无环图
如果要求有多个入度为0的顶点,选择编号最小的顶点,那么把queue改成priority_queue,并保持队首元素是优先队列中最小的元素即可(当然用set亦可)
关键路径
AOV网和AOE网
- 顶点活动(AOV)网是指用顶点表示活动,而用边集表示活动间优先关系的有向图
- 边活动(AOE)网是指用带权的边集表示活动,而用顶点表示事件的有向图,其中边权表示完成活动需要的时间
- 图中不应当存在有向环,否则会让优先关系出现逻辑错误
有多个源点(入度为0)和多个汇点(出度为0),可以通过添加超级源点和超级汇点的方法转换成一个源点和一个汇点
需要指出的是,如果给定AOV网中各顶点活动所需要的时间,那么就可以将AOV网转换成AOE网,比较简单的方法是:将AOV网中的每个顶点都拆成两个顶点,分别表示起点和终点,两个顶点之间用有向边连接,该有向边表示原顶点的活动,边权给定;原AOV网中的边全部视为空活动,边权为0。
既然AOE网是基于工程提出的概念,那么一定有其需要解决的问题,AOE网需要着重解决两个问题:
- 工程起始到终止至少需要多少时间
- 哪条路径上的活动是影响整个工程进度的关键
AOE网中的最长路径被称为 关键路径(强调:关键路径就是AOE网的最长路径),而把关键路径上的活动称为 关键活动。显然关键活动将会影响整个工程的进度
最长路径
对一个没有正环的图(指从源点可达的正环),如果需要求最长路径的长度,则可以令所有边的边权乘-1,然后使用Bellman算法或者SPFA算法求最短路径长度,将所得结果取反即可
注意:此处不能使用Dijkstra算法,原因是Dijkstra算法不能处理有负权边的情况
显然,如果图中有正环,那么最长路径是不存在的,但是,如果需要求最长简单路径(也就是每个顶点最多经过一次的路径),那么虽然最长简单路径本身存在,但却并没有办法通过Bellman等算法求解,原因是最长路径问题是
NP-Hard问题(也就是没有多项式时间复杂度算法的问题)
最长路径问题,即LPP,寻求的是图中的最长简单路径
如果求有向无环图的最长路径长度,则下面要讨论的求法可以比上面的更块
关键路径
由于关键活动是那些不允许拖延的活动,因此这些活动的最早开始时间必须等于最迟开始时间,因此可以设置数组e和l,其中e[r]和l[r]分别表示活动ar的最早开始时间和最迟开始时间,当求出这两个数组之后,就可以通过判断e[r]==l[r]是否成立来确定活动r是否是关键活动
用ve[i]和vl[i]分别表示事件i的最早发生时间和最迟发生时间
- e[r] == ve[i]
- l[r] == vl[j]-length[r]
如果想要求ve[j]的正确值,必须先得到它的所有前驱结点,我们可以使用拓扑排序
//拓扑序列
stack<int> topOrder;
//拓扑排序,顺便求ve数组
bool topologicalSort() {
queue<int> q;
for (int i = 0; i < n; i++) {
if (inDegree[i] == 0) {
q.push(i);
}
}
while (!q.empty()) {
int u = q.front(); //取队首顶点u
//此处可以输出顶点u
topOrder.push(u); //将u加入拓扑序列
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][v]; //u的后继结点v
inDegree[v]--; //顶点v的入度减1
if (inDegree[v] == 0) { //顶点v的入度减为0则入队
q.push(v);
}
//用ve[u]来更新u的所有后继结点v
if (ve[u] + G[u][i].w > ve[v]) {
ve[v] = ve[u] + G[u][i].w;
}
}
}
if (topOrder.size() == n) return true; //加入拓扑序列的顶点为u说明排序成功
else return false;
}
同理,求vl[i]必须先得到它的后继结点,这里可以通过逆拓扑序列来实现
幸运的是, 可以通过颠倒拓扑序列来获得一组合法的逆拓扑序列,上面使用了栈来存储拓扑序列,只要按顺序出栈就是逆拓扑序列
fill(vl, vl + n, ve[n - 1]); //vl数组初始化,初始值为终点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while (!topOrder.empty()) {
int u = topOrder.top(); //栈顶元素为u
topOrder.pop();
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][v].v; //u的后继结点v
//用u的所有后继节点v的vl值来更新vl[u]
if (vl[v] - G[u][i].w < vl[u]) {
vl[u] = vl[v] - G[u][i].w;
}
}
}
主体部分代码如下(适用于 汇点确定且唯一的情况,以n-1号顶点为汇点为例)
//关键路径,不是有向无环图返回-1,否则返回关键路径长度
int CriticalPath() {
memset(ve, 0, sizeof(ve)); //ve数组初始化
if (topologicalSort == false) {
return -1;
}
fill(vl, vl + n, ve[n - 1]); //vl数组初始化,初始值为终点的ve值
//直接使用topOrder出栈即为逆拓扑序列,求解vl数组
while (!topOrder.empty()) {
int u = topOrder.top(); //栈顶元素为u
topOrder.pop();
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][v].v; //u的后继结点v
//用u的所有后继节点v的vl值来更新vl[u]
if (vl[v] - G[u][i].w < vl[u]) {
vl[u] = vl[v] - G[u][i].w;
}
}
}
//遍历邻接表的所有边,计算活动的最早开始时间e和最迟开始时间l
for (int u = 0; u < n; u++) {
for (int i = 0; i < G[u].size(); i++) {
int v = G[u][i].v, w = G[u][i].w;
//活动的最早开始时间e和最迟开始时间l
int e = ve[u], l = vl[v] - w;
//如果e==l,说明该活动为关键活动
if (e == l) {
printf("%d->%d", u, v); //输出关键活动
}
}
}
return ve[n - 1]; //返回关键路径长度
}
需要存储e和l的话在结构体中对应添加域即可
如果事先不知道汇点编号,可以取ve数组的最大值。原因在于,ve数组的含义是事件的最早开始时间,因此所有事件中ve最大的一个一定是最后一个(或多个)事件,也就是汇点,只需要稍微修改代码即可
int maxLength = 0;
for(int i = 0; i < n; i++){
if(ve[i] > maxLength){
maxLength = ve[i];
}
}
fill(vl, vl+n, maxLength);
即使图中有多条关键路径,但如果只要求输出关键活动,按上面的写法即可
如果要完整输出所有关键路径,就需要把关键活动存储下来,方法是新建一个邻接表,当确定边是关键活动时,将边加入邻接表,这样最后生成的邻接表就是所有关键路径合成的图
使用动态规划的做法可以更简洁地求解关键路径
提高篇(5)——动态规划专题
动态规划的递归写法和递推写法
动态规划是一种非常精妙的算法思想,它没有固定的写法、极其灵活,常常需要具体问题具体分析
什么是动态规划
- 动态规划是一种用来解决一类 最优化问题 的算法思想,简单来说,动态规划将一个复杂的问题分解成若干个子问题,通过综合子问题的最优解来得到原问题的最优解
- 需要注意的是,动态规划会将每个求解过的子问题的解记录下来,这样当下一次碰到同样的子问题时,就可以直接使用之前记录的结果,而不是重复计算
- 一般可以使用递归或者递推的写法来实现动态规划,其中递归写法在此处又称作记忆化搜索
动态规划的递归写法
以下是斐波拉契数列的动态规划递归求解
int dp[MAXN];
//在这里将dp数组初始化全部赋值-1
int F(int n){
if(n == 0 || n == 1) return 1; //递归边界
if(dp[n] != -1) return dp[n]; //已经计算过,直接返回结果
else{
dp[n] = F(n-1) + F(n-2); //计算并保存
return dp[n]; //返回之前F(n)的结果
}
}
通过记忆化搜索,把复杂度从O(2n)降到了O(n)
通过上面的例子可以引申出一个概念:如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题
一个问题必须拥有重叠子问题,才能使用动态规划去解决
动态规划的递推写法
以经典的数塔问题为例,dp[i][j]=max(dp[i+1][j],dp[i+1][j+1])+f[i][j]
把dp[i][j]
称为问题的 状态,而把上面的式子称作 状态转移方程,它把状态dp[i][j]
转移为dp[i+1][j]和dp[i+1][j+1]
。最后一层的dp值总是等于元素本身,把这种可以直接确定其结果的部分称为 边界,而动态规划的递推写法总是从这些边界出发,通过状态转移方程扩散到整个dp数组
下面根据这种思想写出动态规划的代码
//数塔问题
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 1010;
int f[maxn][maxn], dp[maxn][maxn];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= i; j++) {
cin >> f[i][j];
}
}
//边界
for (int j = 1; j <= n; j++) {
dp[n][j] = f[n][j];
}
//从第n-1层不断往上计算出dp[i][j]
for (int i = n-1; i >= 1; i--) {
for (int j = 1; j <= i; j++) {
dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + f[i][j];
}
}
cout << dp[1][1] << endl;
return 0;
}
显然使用递归也可以实现上面的例子,两者的区别在于:使用 递推写法 的计算方式是 自底向上,即从边界开始不断向上解决问题,直到解决目标问题;而使用 递归写法 的计算方式是 自顶向下,即从目标问题开始,将它分解成子问题的组合,直到分解至边界为止
如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么就称这个问题拥有 最优子结构,最优子结构保证了动态规划中原问题地最优解可以由子问题的最优解推导而来。因此,一个问题必须拥有最优子结构才能使用动态规划去解决
总结:一个问题必须同时拥有重叠子问题和最优子结构,才能使用动态规划去解决
- 分治与动态规划:分治法分解出的子问题是不重叠的;动态规划解决的问题拥有重叠子问题
- 贪心与动态规划:贪心中没被选择的子问题就不去求解,直接抛弃;动态规划则有可能再次考虑
最大连续子序列和
- 设置dp数组,状态dp[i]表示以a[i]作为末尾的连续序列的最大和
- 那么要求得最大连续子序列和就是dp数组中的最大值(因为哪个元素结尾已知了)
- 状态转移方程:
dp[i]=max(a[i],dp[i-1]+a[i])
,边界显然为dp[0] = a[0]
代码如下所示
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 10010;
int a[maxn], dp[maxn]; //a[i]存放序列,dp[i]存放以a[i]结尾的连续序列的最大和
int main() {
int n;
cin >> n;
for (int i = 0; i < n; i++) {
cin >> a[i];
}
//边界
dp[0] = a[0];
for (int i = 1; i < n; i++) {
//状态转移方程
dp[i] = max(a[i], dp[i - 1] + a[i]);
}
//dp[i]存放以a[i]结尾的连续序列的最大和,需要遍历i得到最大的才是结果
int k = 0;
for (int i = 1; i < n; i++) {
if (dp[i] > dp[k]) {
k = i;
}
}
cout << dp[k] << endl;
return 0;
}
此处介绍后无效性的概念。 状态的后无效性是指:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或者若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。
对动态规划可解的问题来说,总会有很多设计状态的方式,但并不是所有状态都具有后无效性,因此必须设计一个拥有后无效性的状态以及相应的状态转移方程,否则动态规划就没有办法得到正确结果
事实上,如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划最难的地方
最长不下降子序列(LIS)
最长不下降子序列是这样一个问题
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是非递减的
状态转移方程
dp[i] = max(1,dp[j] + 1);
求LIS长度代码如下
#include<iostream>
#include<algorithm>
using namespace std;
const int maxn = 100;
int a[maxn], dp[maxn];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
int ans = -1; //记录最大的dp[i]
for (int i = 1; i <= n; i++) { //按顺序计算出dp[i]的值
dp[i] = 1; //边界初始条件(即先假设每个元素自成一个子序列)
for (int j = 1; j < i; j++) {
if (a[i] >= a[j] && dp[j] + 1 > dp[i]) {
dp[i] = dp[j] + 1; //状态转移方程
}
}
ans = max(ans, dp[i]);
}
cout << ans << endl;
return 0;
}
最长公共子序列(LCS)
最长公共子序列是这样一个问题
给定两个字符串(或数字序列)A和B,求一个字符串,使得这个字符串是A和B的最长公共部分(子序列可以不连续)
状态转移方程
A[i]!=B[j] dp[i][j] = max(dp[i-1][j],dp[i][j-1]);
A[i]==B[j] dp[i][j] = dp[i-1][j-1] + 1;
求LCS长度代码如下
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int maxn = 100;
char a[maxn], b[maxn];
int dp[maxn][maxn];
int main() {
gets_s(a + 1, maxn); //从下标为1开始读入
gets_s(b + 1, maxn);
int len_a = strlen(a + 1); //由于从下标1处开始读入,因此计算长度也从1开始计算
int len_b = strlen(b + 1);
//初始化边界
for (int i = 0; i <= len_a; i++) {
dp[i][0] = 0;
}
for (int i = 0; i <= len_b; i++) {
dp[0][i] = 0;
}
//计算LCS
for (int i = 1; i <= len_a; i++) {
for (int j = 1; j <= len_b; j++) {
if (a[i] == b[j]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
}
else {
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
cout << dp[len_a][len_b] << endl;
return 0;
}
最长回文子串
最长回文子串是这样一个问题
给出一个字符串S,求S的最长回文字串长度
状态转移方程
S[i]==S[j] dp[i][j] = dp[i+1][j-1];
S[i]!=S[j] 0
边界
dp[i][i] = 1;
dp[i][i+1] = (S[i]==S[i+1]);
如果按照i和j从小到大的顺序来枚举子串的两个端点,然后更新dp[i][j]
,会无法保证dp[i+1][j-1]
已经被计算过,从而无法得到正确的dp[i][j]
,根据递推写法从边界出发的原理,注意到边界表示的是长度为1和2的子串,且每次转移时都对子串的长度减了1,因此不妨考虑按子串的长度和子串的初始位置进行枚举,即第一遍将长度为3的子串的dp值全部求出,第二遍通过第一遍结果计算出长度为4的子串的dp值
求最长回文子串代码如下
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int maxn = 1010;
char S[maxn];
int dp[maxn][maxn];
int main() {
gets_s(S, maxn);
int len = strlen(S), ans = 1;
memset(dp, 0, sizeof(dp)); //dp数组初始化为0
//初始化边界
for (int i = 0; i < len; i++) {
dp[i][i] = 1;
if (i < len - 1 && S[i] == S[i + 1]) {
dp[i][i + 1] = 1;
ans = 2; //初始化时注意更改最长回文子串长度
}
}
//状态转移方程
for (int L = 3; L <= len; L++) {
for (int i = 0; i + L - 1 < len; i++) {
int j = i + L - 1;
if (S[i] == S[j] && dp[i + 1][j - 1] == 1) {
dp[i][j] = 1;
ans = L; //更新最长回文子串长度
}
}
}
cout << ans << endl;
return 0;
}
DAG最长路
DAG就是有向无环图,之前已讨论过“关键路径”的求解。但这里还有更简便的方法
两个问题:
- 求整个DAG中的最长路径(即不固定起点和终点)
- 固定终点,求DAG的最长路径
先讨论第一个问题:给定一个有向无环图,怎样求解整个图的所有路径中权值之和最大的那条
dp[i]表示从i号顶点出发能获得的最长路径长度
int DP(int i){
if(dp[i]>0) return dp[i]; //dp[i]已经得到
for(int j = 0; j < n; j++){ //遍历i的所有出边
if(G[i][j] != INF){
int temp = DP(j) + G[i][j]; //单独计算防止if中调用DP函数两次
if(temp > dp[i]){ //有更长的路径
dp[i] = temp; //覆盖dp[i]
choice[i] = j; //i号顶点的后继顶点是j
}
}
}
return dp[i]; //返回计算完毕的dp[i]
}
//调用printPath前需要先得到最大的dp[i],然后将i作为路径起点传入
void printPath(int i){
printf("%d",i);
while(choice[i] != -1){ //choice数组初始化为-1
i = choice[i];
printf("->%d",i);
}
}
在上面的基础上,讨论 固定终点,求DAG的最长路径长度
与前一个的问题区别在于边界。在第一个问题中没有固定终点,因此所有出度为0的顶点的dp值为0是边界;但是在这个问题中固定了终点,因此边界应为dp[T]=0。而且不可以对整个dp数组都赋值为0,合适的做法是初始化dp数组为一个负的大数(即-INF),来保证“无法到达终点”的含义得以表达,然后设置一个vis数组表示顶点是否已经被计算
int DP(int i){
if(vis[i]) return dp[i]; //dp[i]已经得到
vis[i] = true;
for(int j = 0; j < n; j++){ //遍历i的所有出边
if(G[i][j] != INF){
dp[i] = max(dp[i], DP(j) + G[i][j]);
}
}
return dp[i]; //返回计算完毕的dp[i]
}
至于如何记录方案以及如何选择字典序最小的方案,均与第一个问题相同
矩形嵌套问题
给出n个矩阵的长和宽,定义矩形的嵌套关系为:如果有两个矩形A和B,其中矩形A的长和宽分别为a、b,矩形B的长和宽分别为c、d,且满足a<c,b<d,或a<d,b<c,则称矩形A可以嵌套于矩形B中,现在要求一个矩形序列,使得这个序列中任意两个相邻矩形都满足前面矩形可以嵌套于后一个矩形内,且序列的长度最长,如果有多个这样的最长序列,选择矩形编号序列的字典序最小的那个
将每个矩形都看成一个顶点,并将嵌套关系视为顶点之间的有向边,边权均为1,于是就可以转换为DAG最长路问题
背包问题
背包问题是一类经典的动态规划问题,它非常灵活、变体多样,这里只介绍两种最简单的背包问题
01背包问题和完全背包问题
多阶段动态规划问题
有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,一般把这类问题称为多阶段动态规划问题,对这种问题,只需要从第一个问题开始,按照阶段的顺序解决每个阶段中状态的计算,就可以得到最后一个阶段中状态的解
01背包问题
//令dp[i][v]表示前i个物品恰好装入容量为v的背包所能获得的最大价值
//状态转移方程
dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]] + c[i]);
通过边界把整个dp数组递推出来,边界dp[0][v] = 0;
for (int i = 1; i <= n; i++) {
for (int v = w[i]; v <= V; v++) {
dp[i][v] = max(dp[i - 1][v], dp[i - 1][v - w[i]] + c[i]);
}
}
发现时间复杂度和空间复杂度都是O(nV),且空间复杂度可以进一步优化
注意到状态转移方程中计算dp[i][v]时总是只需要dp[i-1][v]左侧部分的数据,且当计算dp[i+1][]的部分时,dp[i-1]的数据又完全用不到了,因此不妨可以直接开一个一维数组dp[v],枚举方向改变为i从1到n,v从V到0
这样状态转移方程改变为:
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
v的枚举顺序变为从右往左,dp[i][v]
右边的部分为刚计算过需要保存给下一行使用的数据,dp[i][v]
左上角的部分为当前需要使用的部分。每计算出一个dp[i][v]
,就相当于把dp[i-1][v]
抹消
我们把这种技巧称为滚动数组
for(int i=1; i<=n; i++){
for(int v=V; v>=w[i]; v--){ //逆序枚举v
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
}
}
逆序枚举是由于需要用到滚动前上一个状态的dp[v-w[i]]
完整代码如下所示
#include<iostream>
#include<algorithm>
#include<cstring>
using namespace std;
const int maxn = 100; //物品最大件数
const int maxv = 1000; //V的上限
int w[maxn], c[maxn], dp[maxv];
int main() {
int n, V;
cin >> n >> V;
for (int i = 1; i <= n; i++) {
cin >> w[i];
}
for (int i = 1; i <= n; i++) {
cin >> c[i];
}
//初始化边界
for (int v = 0; v <= V; v++) {
dp[v] = 0;
}
for (int i = 1; i <= n; i++) {
for (int v = V; v >= w[i]; v--) { //逆序枚举v
dp[v] = max(dp[v], dp[v - w[i]] + c[i]);//状态转移方程
}
}
//寻找dp数组中最大的值即为答案
int max = 0;
for (int v = 0; v <= V; v++) {
if (dp[v] > max) {
max = dp[v];
}
}
cout << max << endl;
return 0;
}
01背包中的每个物品都可以看作一个阶段,这个阶段中的状态有dp[i][0]~dp[i][V]
,它们均由上一个阶段的状态得到。事实上,对能够划分阶段的问题来说,都可以尝试把阶段作为状态的一维,这可以使我们更方便地得到满足后无效性的状态
从中也可得到一个技巧:如果当前设计的状态不满足后无效性,那么不妨把状态进行升维,即增加一维或若干维来表示相应的信息,这样可能就满足后无效性了
完全背包问题
有n件物品,每种物品的单件重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品进入背包,使得背包内的物品总价值最大,其中每种物品都有无穷件
完全背包问题和01背包问题唯一区别就在于 无穷件
状态转移方程
dp[i][v] = max(dp[i-1][v],dp[i][v-w[i]]+c[i]);
//边界dp[0][v] = 0
同样可以改写为一维
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
区别在于这里v是 正向枚举
for(int i=1; i<=n; i++){
for(int v=w[i]; v<=V; v++){ //正向枚举v
dp[v] = max(dp[v], dp[v-w[i]] + c[i]);
}
}
求解dp[i][v]
需要它左边的dp[i][v-w[i]]
和它上方的dp[i-1][v]
,显然如果让v从小到大枚举,dp[i][v-w[i]]
就总是已经计算出的结果,而计算出dp[i][v]
后dp[i-1][v]
就用不到了,可以直接覆盖
总结
-
最大连续子序列和
令
dp[i]
表示以A[i]
作为末尾的连续序列的最大和 -
最长不下降子序列(LIS)
令
dp[i]
表示以A[i]
结尾的最长不下降子序列长度 -
最长公共子序列(LCS)
令
dp[i][j]
表示字符串A的i号位和字符串B的j号位之前的LCS长度 -
最长回文子串
令
dp[i][j]
表示S[i]至S[j]所表示的子串是否是回文子串 -
数塔DP
令
dp[i][j]
表示从第i行第j个数字出发的到达最底层的所有路径上所能获得的最大和 -
DAG最长路
令
dp[i]
表示从i号顶点出发能获得的最长路径长度 -
01背包
令
dp[i][v]
表示前i个物品恰好装入容量为v的背包所能获得的最大价值 -
完全背包
令
dp[i][v]
表示前i个物品恰好装入容量为v的背包所能获得的最大价值
①到④,这四个都是关于序列或者字符串的问题(特别说明:一般地,子序列可以不连续,子串必须连续)
当题目与序列或字符串有关时,可以考虑把状态设计为下面两种形式,然后根据端点特点去考虑状态转移方程
- 令
dp[i]
表示以A[i]
结尾(或开头)的XXX - 令
dp[i][j]
表示A[i]至A[j]区间的XXX
其中XXX为原问题描述
⑤到⑧,可以发现它们的状态设计都包含了某种方向的意思,这又说明了这一类动态规划问题的状态设计办法
分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述:
- 恰好为i
- 前i
在每一维的含义设置完毕之后,dp数组的含义就可以设置成”令dp数组表示恰好为i(或前i)、恰好为j(或前j)的XXX,其中XXX为原问题描述,然后根据端点特点去考虑状态转移方程
在大多数情况下,都可以把动态规划可解的问题看作一个有向无环图,图中的结点就是状态,边就是状态转移的方向,求解问题的顺序就是按照DAG的拓扑序进行求解
提高篇(6)——字符串专题
字符串hash进阶
字符串hash是指将一个字符串S映射为一个整数,使得该整数可以尽可能唯一地代表字符串S
H[i] = H[i-1]*26 + index(str[i]);
这样转换虽然字符串和整数一一对应,但由于没有进行处理,因此字符串长度较长时,产生的整数会非常大
H[i] = (H[i-1]*26 + index(str[i])) % mod;
通过这种方式把字符串转换成范围上能接受的整数,但也导致可能有多个字符串的hash值相同
在实践中发现,在int数据范围内,如果 把进制数设置为一个10的7次方级别的素数p(如10000019),同时把mod设置为一个10的9次方级别的素数(如1000000007),那么冲突的概率将会变得非常小
H[i] = (H[i-1]*p + index(str[i])) % mod;
问题:给出N个只有小写字母的字符串,求其中不同的字符串的个数
#include<iostream>
#include<string>
#include<vector>
#include<algorithm>
using namespace std;
const int MOD = 1e9 + 7;
const int P = 1e7 + 19;
vector<int> ans;
//字符串hash
long long hashFunc(string str) {
long long H = 0; //使用longlong避免溢出
for (int i = 0; i < str.length(); i++) {
H = (H * P + str[i] - 'a') % MOD;
}
return H;
}
int main() {
string str;
while (getline(cin, str), str != "#") {
long long id = hashFunc(str); //将字符串str转换为整数
ans.push_back(id);
}
sort(ans.begin(), ans.end());
int count = 0;
for (int i = 0; i < ans.size(); i++) {
if (i == 0 || ans[i] != ans[i - 1]) {
count++; //统计不同数的个数
}
}
cout << count << endl;
return 0;
}
考虑求解字符串的子串的hash值
*H[i…j] = ((H[j] - H[i-1]pj-i+1) % mod + mod) % mod
问题:输入两个长度均不超过1000的字符串,求它们的最长公共子串的长度
#include<iostream>
#include<map>
#include<string>
#include<vector>
#include<algorithm>
typedef long long LL;
using namespace std;
const LL MOD = 1e9 + 7; //MOD为计算hash值时的模数
const LL P = 1e7 + 19; //P为计算hash值时的进制数
const LL MAXN = 1010; //字符串最长长度
vector<int> ans;
//powP[i]存放P^i%MOD, H1和H2分别存放str1和str2的hash值
LL powP[MAXN], H1[MAXN] = { 0 }, H2[MAXN] = { 0 };
//pr1存放str1的所有<子串hash,子串长度>, pr2同理
vector<pair<int, int> > pr1, pr2;
//init函数初始化powP函数
void init(int len) {
powP[0] = 1;
for (int i = 1; i <= len; i++) {
powP[i] = (powP[i - 1] * P) % MOD;
}
}
//calH函数计算字符串str的hash值
void calH(LL H[], string& str) {
H[0] = str[0];
for (int i = 1; i < str.length(); i++) {
H[i] = (H[i - 1] * P + str[i]) % MOD;
}
}
//calSingleSubH计算H[i...j]
int calSingleSubH(LL H[], int i, int j) {
if (i == 0) return H[j]; //H[0...j]单独处理
return ((H[j] - H[i - 1] * powP[j - i + 1]) % MOD + MOD) % MOD;
}
//calSubH计算所有子串的hash值,并将<子串hash值,子串长度>存入pr
void calSubH(LL H[], int len, vector<pair<int, int> >& pr) {
for (int i = 0; i < len; i++) {
for (int j = i; j < len; j++) {
int hashValue = calSingleSubH(H, i, j);
pr.push_back(make_pair(hashValue, j - i + 1));
}
}
}
//计算pr1和pr2中相同的hash值,维护最大长度
int getMax() {
int ans = 0;
for (int i = 0; i < pr1.size(); i++) {
for (int j = 0; j < pr2.size(); j++) {
if (pr1[i].first == pr2[j].first) {
ans = max(ans, pr1[i].second);
}
}
}
return ans;
}
int main() {
string str1, str2;
getline(cin, str1);
getline(cin, str2);
init(max(str1.length(), str2.length())); //初始化powP数组
calH(H1, str1); //分别计算str1和str2的hash值
calH(H2, str2);
calSubH(H1, str1.length(), pr1); //分别计算所有H1[i...j]和H2[i...j]
calSubH(H2, str2.length(), pr2);
printf("ans = %d\n", getMax()); //输出最大公共子串长度
return 0;
}
最长回文子串
这里将用hash+二分的思路去解决它,时间复杂度为O(nlogn),其中n为字符串的长度
#include<iostream>
#include<string>
#include<vector>
#include<algorithm>
typedef long long LL;
using namespace std;
const LL MOD = 1e9 + 7; //MOD为计算hash值时的模数。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。。lllllllllllllllllll'
const LL P = 1e7 + 19; //P为计算hash值时的进制数
const LL MAXN = 200010; //字符串最长长度
//powP[i]存放P^i%MOD, H1和H2分别存放str和rstr的hash值
LL powP[MAXN], H1[MAXN] = { 0 }, H2[MAXN] = { 0 };
//init函数初始化powP函数
void init(int len) {
powP[0] = 1;
for (int i = 1; i <= len; i++) {
powP[i] = (powP[i - 1] * P) % MOD;
}
}
//calH函数计算字符串str的hash值
void calH(LL H[], string& str) {
H[0] = str[0];
for (int i = 1; i < str.length(); i++) {
H[i] = (H[i - 1] * P + str[i]) % MOD;
}
}
//calSingleSubH计算H[i...j]
int calSingleSubH(LL H[], int i, int j) {
if (i == 0) return H[j]; //H[0...j]单独处理
return ((H[j] - H[i - 1] * powP[j - i + 1]) % MOD + MOD) % MOD;
}
//对称点为i,字符串长len,在[l,r]里二分回文半径
//寻找最后一个满足条件“hashL == hashR”的回文半径
//等价于寻找第一个满足条件“hashL != hashR”的回文半径,然后-1即可
//isEven当求奇回文时为0,当求偶回文时为1
int binarySearch(int l, int r, int len, int i, int isEven) {
while (l < r) { //当出现l == r时结束,因为范围是[l,r]
int mid = (l + r) / 2;
//左半子串hash值H1[H1L...H1R],右半子串hash值H2[H2L...H2R]
int H1L = i - mid + isEven, H1R = i;
int H2L = len - 1 - (i + mid), H2R = len - 1 - (i + isEven);
int hashL = calSingleSubH(H1, H1L, H1R);
int hashR = calSingleSubH(H2, H2L, H2R);
if (hashL != hashR) r = mid; //hash不等说明回文半径小于等于mid
else l = mid + 1; //hash相等说明回文半径大于mid
}
return l - 1; //返回最大回文半径
}
int main() {
string str;
getline(cin, str);
init(str.length()); //初始化powP
calH(H1, str); //计算str的hash数组
reverse(str.begin(), str.end()); //将字符串反转
calH(H2, str); //计算rstr的hash数组
int ans = 0;
//奇回文
for (int i = 0; i < str.length(); i++) {
//二分上界为分界点i的左右长度较小值+1
int maxLen = min(i, (int)str.length() - 1 - i) + 1;
int k = binarySearch(0, maxLen, str.length(), i, 0);
ans = max(ans, k * 2 + 1);
}
//偶回文
for (int i = 0; i < str.length(); i++) {
//二分上界为分界点i的左右长度较小值+1
int maxLen = min(i+1, (int)str.length() - 1 - i) + 1;
int k = binarySearch(0, maxLen, str.length(), i, 1);
ans = max(ans, k * 2);
}
cout << ans << endl;
return 0;
}
如果确实碰到了极其针对进制数p=10000019,模数mod=1000000007的数据,只需要调整p和mod
或者使用效果更强的双hash法,用两个hash函数生成的整数组合表示一个字符串
需要注意的是,这里介绍的字符串hash函数只是众多字符串hash方法中的一个,即从进制转换的角度来进行字符串hash,除此之外,还有BKDRHash,ELFHash等
KMP算法
- 如果给出两个字符串text和pattern,需要判断字符串pattern是否是字符串text的子串,一般把text称为文本串,而pattern称为模式串
- 暴力解法:枚举文本串的起始位置,从该位开始逐位与模式串进行匹配,如果每一位都相同,则匹配成功;否则就让文本串的起始位置变成i+1,从头开始匹配。时间复杂度为O(nm)
- KMP算法:Knuth,Morris,Pratt共同发现,时间复杂度为O(n+m)
next数组
- next[i]就是子串s[0…i]的最长相等前后缀的前缀最后一位的下标
- 每次求出next[i]时,总是让j指向next[i],以方便继续求解next[i+1]求解
总结next数组的求解过程
- 初始化next数组,令j = next[0] = -1
- 让i在1~len-1范围遍历,对每个i,执行③④,以求解next[i]
- 不断令j = next[j],直到 j 回退为 -1,或是s[i] == s[j+1]成立
- 如果s[i] == s[j+1],则next[i] = j+1;否则next[i] = j
//getNext求解长度为len的字符串s的next数组
void getNext(char s[], int len){
int j = -1;
next[0] = -1; //初始化j = next[0] = -1
for(int i = 1; i < len; i++){ //求解next数组
while(j != -1 && s[i] != s[j+1]){
j = next[j];
}//直到j回退到-1,或是s[i] == s[j+1]
if(s[i] == s[j+1]){ //如果s[i] == s[j+1]
j++; //则next[i] = j+1,先令j指向这个位置
}
next[i] = j; //令next[i] = j
}
}
KMP算法
- 初始化j = -1,表示pattern当前已被匹配的最后位
- 让i遍历文本串text,对每个i,执行③④来试图匹配text[i]和pattern[j+1]
- 不断令j = next[j],直到j回退为-1,或是text[i] == pattern[j+1]成立
- 如果text[i] == pattern[j+1],则令j++,如果j达到m-1 ,说明pattern是text的子串,返回true
判断pattern是否是text的子串:
//KMP算法,判断pattern是否是text的子串
bool KMP(char text[], char pattern[]){
int n = strlen(text), m = strlen(pattern); //字符串长度
getNext(pattern, m); //计算pattern的next数组
int j = -1; //初始化j为-1,表示当前还没有任意一位被匹配
for(int i = 0; i < n; i++){ //试图匹配text[i]
while(j != -1 && text[i] != pattern[j+1]){
j = next[j];
}//直到j回退到-1,或是text[i] == pattern[j+1]
if(text[i] == pattern[j+1]){
j++; //匹配成功,j++
}
if(j == m-1){
return true; //完全匹配成功
}
}
return false; //执行完text还没完全匹配成功
}
求解next数组的过程其实就是模式串pattern进行自我匹配的过程
统计模式串pattern出现次数:
//KMP算法,统计模式串pattern出现次数
int KMP(char text[], char pattern[]){
int n = strlen(text), m = strlen(pattern); //字符串长度
getNext(pattern, m); //计算pattern的next数组
int j = -1, ans = 0; //初始化j为-1,表示当前还没有任意一位被匹配,ans表示成功匹配次数
for(int i = 0; i < n; i++){ //试图匹配text[i]
while(j != -1 && text[i] != pattern[j+1]){
j = next[j];
}//直到j回退到-1,或是text[i] == pattern[j+1]
if(text[i] == pattern[j+1]){
j++; //匹配成功,j++
}
if(j == m-1){
ans++; //成功匹配次数+1
j = next[j]; //让j回退到next[j]继续匹配
}
}
return ans; //返回成功匹配次数
}
nextval[i]的含义应该理解为当模式串pattern的i+1位发生失配时,i应当回退到的最佳位置
//getNextval求解长度为len的字符串s的nextval数组
void getNextval(char s[], int len){
int j = -1;
next[0] = -1; //初始化j = next[0] = -1
for(int i = 1; i < len; i++){ //求解next数组
while(j != -1 && s[i] != s[j+1]){
j = next[j];
}//直到j回退到-1,或是s[i] == s[j+1]
if(s[i] == s[j+1]){ //如果s[i] == s[j+1]
j++; //则next[i] = j+1,先令j指向这个位置
}
//与getNext函数相比只有下面不同
if(j == -1 || s[i+1] != s[j+1]){ //j == -1 不需要回退
nextval[i] = j;
}else{
nextval[i] = nextval[j];
}
}
}
从有限状态自动机的角度看待KMP算法
可以把有限状态自动机看作一个有向图,其中顶点表示不同的状态(类似动态规划中的状态),边表示状态的转移。另外,有限状态自动机中会有一个起始状态和终止状态,如果从起始状态出发,最终转移到了终止状态,那么自动机就正常停止。
对KMP算法而言,实际上相当于对模式串pattern构造一个有限状态自动机,然后把文本串text的字符从头到尾一个个送入这个自动机,如果自动机可以从初始状态开始达到终止状态,那么说明pattern是text的子串
把KMP算法产生的自动机推广为树形,就会产生字典树(也叫前缀树),此时就可以解决 多维字符串匹配问题,即一个文本串匹配多个模式串的匹配问题。通常把 解决多维字符串匹配问题的算法称为AC自动机,事实上KMP算法只是AC自动机的特殊情形
专题扩展
分块思想
给定一个非负整数序列A,元素个数为N,在有可能随时添加或删除元素的情况下,实时查询元素第K大,即把元素从小到大排序后从左到右的第K个元素
一般来说,如果在查询过程中,元素可能发生改变,就称这种查询为 在线查询,查询中元素不发生改变叫 离线查询
从字面意思理解 分块,就是 把有序元素划分为若干块,一般地,对一个有N个元素的有序序列来说,除最后一块外,其余每块中的元素个数都应当为根号N,于是块数也为根号N(此处向上取整)
例如11个元素,分为3,3,3,2四块
暴力的做法由于添加和删除元素时需要O(n)的复杂度来移动元素,考虑到序列中的元素都是不超过100000的非负整数,因此不妨设置一个hash数组table[100001],其中table[x]表示整数x的当前存在个数,然后借助分块思想,将100001分为317块,其中每块的元素个数为316
这样分块有什么用呢?可以定义一个 统计数组block[317],其中block[i]表示第i块中存在的元素个数,于是加入要新增一个元素x,就可以先计算出x所在的块号为x/316,然后让block[x/316]+1,表示该块中元素多了1,然后让table[x]+1,表示整数x的当前存在个数多了1
显然新增和删除元素的时间复杂度都是O(1)
查询序列中第K大的元素
从小到大枚举块号,利用block数组累加得到前i-1块中存在的元素总个数,然后判断加入i号块的元素个数后元素总个数能否达到K,如果能,则说明第K大的数就在当前枚举的这个块中,此时只需从小到大遍历该块中的每个元素,利用table数组继续累加元素的存在个数,直到总累计数达到K,则说明找到了序列第K大的数
【PAT A1057】Stack
树状数组(BIT)
lowbit运算
lowbit(x) = x & (-x)
整数在计算机中一般采用的是补码存储,并且把一个补码表示的整数x变成其相反数-x的过程相当于把x的二进制的每一位都取反,然后末位加1。而这等价于直接把x的二进制最右边的1左边的每一位都取反。因此很容易推得lowbit运算就是取x的二进制最右边的1和它右边所有0,因此它一定是2的幂次
树状数组及其应用
问题:给出一个整数序列A,元素个数为N,接下来查询K次,每次查询将给出一个正整数x,求前x个整数之和
对这个问题,一般的做法是开一个sum数组,其中sum[i]表示前i个整数之和,(数组下标从1开始),这样sum数组就可以在输入N个整数时就预处理出来。接着每次查询前x个整数之和时,输出sum[x]即可
升级后的问题:假设在查询的过程中可能随时给第x个整数加上一个整数v,要求在查询中能实时输出前x整数之和(更新操作和查询操作的次数总和为K次)
树状数组(Binary Indexed Tree, BIT)。它其实仍然是一个数组,并且与sum数组类似,是一个用来记录和的数组,只不过它存放的不是前i个整数之和,而是在i号位之前(含i号位,下同) lowbit(i)个整数之和
数组C是树状数组,其中C[i]存放数组A中i号位之前lowbit(i)个元素之和,显然,C[i]的覆盖长度是lowbit(i)
此处强调,树状数组的定义非常重要,特别是“ C[i]的覆盖长度是lowbit(i) ”这点;另外,树状数组的下标必须从1开始,这是需要注意的
由
SUM(1,x) = A[1] + ... + A[x];
C[x] = A[x-lowbit(x)+1] + ... + A[x]
推得
SUM(1,x) = SUM(1,x-lowbit(x)) + C[x]
写出getSum函数
//getSum函数返回前x个整数之和
int getSum(int x){
int sum = 0; //记录和
for(int i = x; i>0; i-=lowbit(i)){
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
显然,由于lowbit(i)的作用是定位i的二进制中最右边的1,因此i = i-lowbit(i)
事实上是不断把i的二进制中最右边的1置为0的过程。所以getSum函数的时间复杂度为O(logN)
如果要求数组下标在区间[x,y]内的数之和,可以转换成getSum(y)-getSum(x-1)
update函数的做法很明确:只要让x不断加上lowbit(x),并让每步的C[x]都加上v,直到x超过给定的数据范围为止
//update函数将第x个整数加上v
void update(int x, int v){
for(int i = x; i <= N; i += lowbit(i)){ //注意i必须能取到N
c[i] += v;
}
}
显然,这个过程是从右往左不断定位x的二进制最右边的1左边的0的过程,因此update函数的时间复杂度为O(logN)
由于树高是O(logN),因此可以同样得到update函数的时间复杂度就是O(logN)
问题:给定一个有N个正整数的序列A,对序列中的每个数,求出序列中它左边比它小的数的个数
#include<iostream>
#include<cstring>
using namespace std;
const int maxn = 100010;
#define lowbit(i) ((i) & (-i))
int c[maxn]; //树状数组
int N, x;
//update函数将第x个整数加上v
void update(int x, int v) {
for (int i = x; i <= N; i += lowbit(i)) { //注意i必须能取到N
c[i] += v;
}
}
//getSum函数返回前x个整数之和
int getSum(int x) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
int main() {
cin >> N;
memset(c, 0, sizeof(c));
for (int i = 0; i < N; i++) {
cin >> x;
update(x, 1);
printf("%d\n", getSum(x - 1));
}
return 0;
}
这就是树状数组最经典的应用:统计序列中在元素左边比该元素小的元素个数
统计序列中在元素左边比该元素大的元素个数等价于计算
hash[A[i]-1] + ... + hash[N] 即getSum(N) - getSum(A[i])
至于元素统计右边的只需要把原始数组从右往左遍历就好了
如果A[i]<=N不成立,可以设置一个临时的结构体数组,用以存放输入的序列元素的值以及原始序号,而在输入完毕后将数组按val从小到大排序,排序完再按照“计算排名”的方法将“排名”根据原始序号pos存入一个新的数组即可
由于这种做法可以把任何不在合适区间的整数或者非整数都转换为不超过元素个数大小的整数,因此一般把这种技巧称为 离散化
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int maxn = 100010;
#define lowbit(i) ((i) & (-i))
struct Node {
int val; //序列元素的值
int pos; //原始序号
}temp[maxn]; //temp数组临时存放输入数据
int c[maxn]; //树状数组
int A[maxn]; //离散化后的原始数组
//update函数将第x个整数加上v
void update(int x, int v) {
for (int i = x; i <= N; i += lowbit(i)) { //注意i必须能取到N
c[i] += v;
}
}
//getSum函数返回前x个整数之和
int getSum(int x) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
//按val从小到大排序
bool cmp(Node a, Node b) {
return a.val < b.val;
}
int main() {
int N;
cin >> N;
memset(c, 0, sizeof(c));
for (int i = 0; i < N; i++) {
cin >> temp[i].val;
temp[i].pos = i;
}
//离散化
sort(temp, temp + N, cmp); //按val从小到大排序
for (int i = 0; i < N; i++) {
//与上一个元素值不同时,赋值为元素个数
if (i == 0 || temp[i].val != temp[i - 1].val) {
A[temp[i].pos] = i + 1; //注意是从1开始的
}
else { //与上一个元素值相同时直接继承
A[temp[i].pos] = A[temp[i - 1].pos];
}
}
for (int i = 0; i < N; i++) {
update(A[i], 1);
printf("%d\n", getSum(A[i] - 1));
}
return 0;
}
一般来说离散化只适用于离线查询,但对在线查询也可以先把所有操作都记录下来,然后对其中出现的数据进行离散化,之后再按照记录下来的操作顺序正常进行操作即可。
//求序列元素第K大
int findKthElement(int K){
int l = 1, r = MAXN, mid; //初始区间为[1, MAXN]
while(l < r){ //循环直到能锁定单一元素
mid = (l + r) / 2;
if(getSum(mid) >= K) r = mid; //所求位置不超过mid
else l = mid + 1; //所求位置大于mid
}
return l; //返回二分夹出 的元素
}
求二维整数矩阵相关问题只需要把树状数组推广为二维即可,具体做法是,直接把update函数和getSum函数中的for循环改成两重,更高维的情况将for循环改为对应的重数即可
如果要求子矩阵,只需计算
getSum(x,y) - getSum(x-1,y) - getSum(x,y-1) + getSum(x-1,y-1)
代码如下:
int c[maxn][maxn]; //二维树状数组
//update函数将位置为(x,y)的整数加上v
void update(int x, int y, int v){
for(int i = x; i <= N; i += lowbit(i)){ //注意i必须能取到N
for(int j = y; j <= N; j += lowbit(i)){
c[i][j] += v;
}
}
}
//getSum函数返回前x个整数之和
int getSum(int x, int y) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
for(int j = y; j > 0; j -= lowbit(i)){
sum += c[i][j]; //累计c[i][j],然后把问题缩小为SUM(1,x-lowbit(x))
}
}
return sum;
}
之前一直在进行 单点更新、区间查询。如果想要进行 区间更新,单点查询,该怎么做?
- 设计函数getSum(x),返回A[x]
- 设计函数update(x,v),将A[1]~A[x]的每个数都加上一个数v
- 更改树状数组c[x]的定义为:c[i]表示这段区间中每个数当前被加了多少
//getSum函数返回第x个整数的值
int getSum(int x){
int sum = 0; //记录和
for(int i = x; i <= N; i += lowbit(i)){ //沿着i增大的路径
sum += c[i];//累计c[i]
}
return sum; //返回和
}
//update函数将前x个整数都加上v
void update(int x, int v){
for(int i = x; i > 0; i -= lowbit(i)){
c[i] += v; //让c[i]加上v
}
}
数组累加得到前i-1块中存在的元素总个数,然后判断加入i号块的元素个数后元素总个数能否达到K,如果能,则说明第K大的数就在当前枚举的这个块中,此时只需从小到大遍历该块中的每个元素,利用table数组继续累加元素的存在个数,直到总累计数达到K,则说明找到了序列第K大的数
> 【PAT A1057】Stack
### 树状数组(BIT)
#### lowbit运算
lowbit(x) = x & (-x)
整数在计算机中一般采用的是补码存储,并且把一个补码表示的整数x变成其相反数-x的过程相当于把x的二进制的每一位都取反,然后末位加1。**而这等价于直接把x的二进制最右边的1左边的每一位都取反**。因此很容易推得**lowbit运算就是取x的二进制最右边的1和它右边所有0**,因此它一定是2的幂次
#### 树状数组及其应用
问题:给出一个整数序列A,元素个数为N,接下来查询K次,每次查询将给出一个正整数x,求前x个整数之和
对这个问题,一般的做法是开一个sum数组,其中sum[i]表示前i个整数之和,(**数组下标从1开始**),这样sum数组就可以在输入N个整数时就预处理出来。接着每次查询前x个整数之和时,输出sum[x]即可
升级后的问题:假设在查询的过程中可能随时给第x个整数加上一个整数v,要求在查询中能实时输出前x整数之和(更新操作和查询操作的次数总和为K次)
树状数组(Binary Indexed Tree, BIT)。它其实仍然是一个数组,并且与sum数组类似,是一个用来记录和的数组,只不过它存放的不是前i个整数之和,而是**在i号位之前(含i号位,下同) lowbit(i)个整数之和**
数组C是树状数组,其中C[i]存放数组A中i号位之前lowbit(i)个元素之和,显然,**C[i]的覆盖长度是lowbit(i)**
**此处强调,树状数组的定义非常重要,特别是“ C[i]的覆盖长度是lowbit(i) ”这点;另外,树状数组的下标必须从1开始,这是需要注意的**
由
SUM(1,x) = A[1] + … + A[x];
C[x] = A[x-lowbit(x)+1] + … + A[x]
推得
SUM(1,x) = SUM(1,x-lowbit(x)) + C[x]
写出getSum函数
//getSum函数返回前x个整数之和
int getSum(int x){
int sum = 0; //记录和
for(int i = x; i>0; i-=lowbit(i)){
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
显然,由于lowbit(i)的作用是定位i的二进制中最右边的1,因此`i = i-lowbit(i)`事实上是不断把i的二进制中最右边的1置为0的过程。所以**getSum函数的时间复杂度为O(logN)**
> **如果要求数组下标在区间[x,y]内的数之和,可以转换成getSum(y)-getSum(x-1)**
update函数的做法很明确:只要让x不断加上lowbit(x),并让每步的C[x]都加上v,直到x超过给定的数据范围为止
//update函数将第x个整数加上v
void update(int x, int v){
for(int i = x; i <= N; i += lowbit(i)){ //注意i必须能取到N
c[i] += v;
}
}
显然,这个过程是从右往左不断定位x的二进制最右边的1左边的0的过程,因此**update函数的时间复杂度为O(logN)**
> 由于树高是O(logN),因此可以同样得到update函数的时间复杂度就是O(logN)
问题:**给定一个有N个正整数的序列A,对序列中的每个数,求出序列中它左边比它小的数的个数**
#include
#include
using namespace std;
const int maxn = 100010;
#define lowbit(i) ((i) & (-i))
int c[maxn]; //树状数组
int N, x;
//update函数将第x个整数加上v
void update(int x, int v) {
for (int i = x; i <= N; i += lowbit(i)) { //注意i必须能取到N
c[i] += v;
}
}
//getSum函数返回前x个整数之和
int getSum(int x) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
int main() {
cin >> N;
memset(c, 0, sizeof©);
for (int i = 0; i < N; i++) {
cin >> x;
update(x, 1);
printf("%d\n", getSum(x - 1));
}
return 0;
}
这就是树状数组最经典的应用:**统计序列中在元素左边比该元素小的元素个数**
> 统计序列中在元素左边比该元素大的元素个数等价于计算
>
> ```
> hash[A[i]-1] + ... + hash[N]
> 即getSum(N) - getSum(A[i])
> ```
>
> 至于元素统计右边的只需要把原始数组从右往左遍历就好了
如果A[i]<=N不成立,可以设置一个临时的结构体数组,用以存放输入的序列元素的值以及原始序号,而在输入完毕后将数组按val从小到大排序,排序完再按照“计算排名”的方法将“排名”根据原始序号pos存入一个新的数组即可
由于这种做法可以把任何不在合适区间的整数或者非整数都转换为不超过元素个数大小的整数,因此一般把这种技巧称为 **离散化**
#include
#include
#include
using namespace std;
const int maxn = 100010;
#define lowbit(i) ((i) & (-i))
struct Node {
int val; //序列元素的值
int pos; //原始序号
}temp[maxn]; //temp数组临时存放输入数据
int c[maxn]; //树状数组
int A[maxn]; //离散化后的原始数组
//update函数将第x个整数加上v
void update(int x, int v) {
for (int i = x; i <= N; i += lowbit(i)) { //注意i必须能取到N
c[i] += v;
}
}
//getSum函数返回前x个整数之和
int getSum(int x) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
sum += c[i]; //累计c[i],然后把问题缩小为SUM(1,x-lowbit(x))
}
return sum;
}
//按val从小到大排序
bool cmp(Node a, Node b) {
return a.val < b.val;
}
int main() {
int N;
cin >> N;
memset(c, 0, sizeof©);
for (int i = 0; i < N; i++) {
cin >> temp[i].val;
temp[i].pos = i;
}
//离散化
sort(temp, temp + N, cmp); //按val从小到大排序
for (int i = 0; i < N; i++) {
//与上一个元素值不同时,赋值为元素个数
if (i == 0 || temp[i].val != temp[i - 1].val) {
A[temp[i].pos] = i + 1; //注意是从1开始的
}
else { //与上一个元素值相同时直接继承
A[temp[i].pos] = A[temp[i - 1].pos];
}
}
for (int i = 0; i < N; i++) {
update(A[i], 1);
printf("%d\n", getSum(A[i] - 1));
}
return 0;
}
一般来说离散化只适用于离线查询,但对在线查询也可以先把所有操作都记录下来,然后对其中出现的数据进行离散化,之后再按照记录下来的操作顺序正常进行操作即可。
//求序列元素第K大
int findKthElement(int K){
int l = 1, r = MAXN, mid; //初始区间为[1, MAXN]
while(l < r){ //循环直到能锁定单一元素
mid = (l + r) / 2;
if(getSum(mid) >= K) r = mid; //所求位置不超过mid
else l = mid + 1; //所求位置大于mid
}
return l; //返回二分夹出 的元素
}
求二维整数矩阵相关问题只需要把树状数组推广为二维即可,具体做法是,直接把update函数和getSum函数中的for循环改成两重,更高维的情况将for循环改为对应的重数即可
如果要求子矩阵,只需计算
getSum(x,y) - getSum(x-1,y) - getSum(x,y-1) + getSum(x-1,y-1)
代码如下:
int c[maxn][maxn]; //二维树状数组
//update函数将位置为(x,y)的整数加上v
void update(int x, int y, int v){
for(int i = x; i <= N; i += lowbit(i)){ //注意i必须能取到N
for(int j = y; j <= N; j += lowbit(i)){
c[i][j] += v;
}
}
}
//getSum函数返回前x个整数之和
int getSum(int x, int y) {
int sum = 0; //记录和
for (int i = x; i > 0; i -= lowbit(i)) {
for(int j = y; j > 0; j -= lowbit(i)){
sum += c[i][j]; //累计c[i][j],然后把问题缩小为SUM(1,x-lowbit(x))
}
}
return sum;
}
之前一直在进行 **单点更新、区间查询**。如果想要进行 **区间更新,单点查询**,该怎么做?
- 设计函数getSum(x),返回A[x]
- 设计函数update(x,v),将A[1]~A[x]的每个数都加上一个数v
- 更改树状数组c[x]的定义为:c[i]表示这段区间中每个数当前被加了多少
//getSum函数返回第x个整数的值
int getSum(int x){
int sum = 0; //记录和
for(int i = x; i <= N; i += lowbit(i)){ //沿着i增大的路径
sum += c[i];//累计c[i]
}
return sum; //返回和
}
//update函数将前x个整数都加上v
void update(int x, int v){
for(int i = x; i > 0; i -= lowbit(i)){
c[i] += v; //让c[i]加上v
}
}
显然,如果需要让A[x]~A[y]的每个数加上v,只要先让A[1]~A[y]的每个数加上v,然后再让A[1]~A[x-1]的每个数加上-v即可,即先后执行update(y, v)与update(x-1, -v)