胡凡 曾磊 主编
机械工业出版社
数组较大时应该定义在主函数外面
memset(数组名,值,sizeof(数组名))
使用该函数对数组中的每一个元素赋以相同的值,需要记住在程序开头添加string.h头文件
memset按字节赋值,即对每个字节赋予相同的值
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)
冒泡排序的本质在于交换,即每次通过交换的方式把当前剩余元素的最大值移动到另一端
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
这里主要介绍直接插入排序。
for(int i=1;i0 && temp
#include
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
有时题目中不需要记录排名,输出即可,那么可以用下面的代码
int r = 1;
for(int i=0; i0 && 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的功能。
假设字符串均由大写字母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
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列皇后所在的行号依次写出,那么就会是一个1~n的一个排列,总共有n!个排列,比直接枚举优秀
于是可以在全排列的代码基础上求解,此时到达边界还需要判断是否合法
#include
#include
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
#include
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;
}
贪心算法总是考虑在当前状态下局部最优,来使全局的结果达到最优。贪心的证明往往比贪心本身更难。
给定数字 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。
时间复杂度为O(logn)
mid = left+(right-left)/2
寻找有序序列中第一个满足某条件元素的位置
木棒切割问题:根据对于当前长度L来说能得到的木棒段数k与K的关系来进行二分。由于这个问题可以写成求解最后一个满足条件的k>=K的长度L,因此不妨转换为求解第一个满足k 它基于二分的思想,因此也常称为二分幂。快速幂基于以下事实 显然,在log(b)级别次数的转换后,就可以把b变为0,而任何整数的0次方都是1。 两个细节需要注意: 分别掌握递归写法和迭代写法 这是算法编程中一种非常重要的思想。two pointers的思想十分简洁,但却提供了非常高的算法效率 给定一个递增的正整数序列和一个正整数M,求序列中两个不同位置的数a和b,使得他们的和恰好为M 令i的初值为0,j的初值为n-1 反复执行上面三个判断,直到i>=j成立 序列合并问题:假设有两个递增序列A与B,要求将它们合并为一个递增序列C 同样的,可以设置两个下标,初值均为0,根据a[i]与b[j]的大小决定哪一个放入序列C 归并排序是一种基于“归并”思想的排序方法。 2-路归并排序的原理是:将序列两两分组,将序列归并为[n/2]个组,组内单独排序,然后再将这些组两两归并,生成[n/4]个组,组内再次单独排序,依次类推,直到只剩下一个组为止。 归并排序的时间复杂度为O(nlogn) 是排序算法中平均时间复杂度为O(nlogn)的一种算法 先解决一个问题:对一个序列中的A[1],调整序列中元素的位置,使得它左侧所有元素都不大于它,右侧所有元素都大于它 速度最快的方法是双指针 其中用以划分区间的元素A[left]被称为主元 快速排序的思路是 元素越有序,时间复杂度越高,因为主元没有把当前区间分成两个长度接近的区间 我们可以用随机数来选取主元 用空间换时间 有些题目找到递推关系可以让时间复杂度下降不少 如何从一个无序的数组中求出第K大的数? 最小公倍数a/gcd(a,b)*b PAT 甲级 1008 Elevator (20 分) 定义vector数组 通过下标访问 访问vi[index]即可 通过迭代器访问 iterator可以理解为一种类似指针的东西,其定义是 这样it就是一个 例如有这样定义的一个vector容器 从这里可以看出vi[i]和*(vi.begin()+i)是等价的 既然说到begin()函数的作用为取vi的首元素地址,那么这里还要提到end()函数。end()函数取尾元素地址的下一个地址 另外,迭代器还实现了两种自加操作:++it和it++ 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)内的所有元素 set翻译为集合,是一个内部自动有序且不含重复元素的容器 set只能通过迭代器(iterator)访问 这样就得到了迭代器it,并且可以通过*it来访问set里的元素 由于除开vector和string类以外的STL容器都不支持(it+i)的访问方式*,因此只能按如下方式枚举 insert(x) 将x插入set容器中,并自动递增排序和去重 fine(value) 返回set中对应值为value的迭代器 erase() 删除单个元素 st.erase(it),it为所需要删除元素的迭代器,可以结合find函数来使用 st.erase(value),value为所需要删除元素的值 删除一个区间内的所有元素 st.erase(first, last)可以删除一个区间内的所有元素,first为所需要删除区间的起始迭代器,last则为所需要删除区间的末尾迭代器的下一个地址 size() 用来获得set内元素的个数 clear() 用来清空set中的所有元素 通过下标访问 一般来说,可以直接像字符数组那样去访问string 如果要读入和输出整个字符串,只能用cin和cout 通过迭代器访问 支持直接对迭代器进行加减某个数字 operator += 将两个string拼接起来 compare operator 两个string类型可以直接使用==,!=,<,>等比较大小,比较规则是字典序 length()/size() length()返回string的长度,size()和length()基本相同 insert() insert(pos, string) 在pos号位置插入字符串string insert(it, it2, it3) it为原字符串欲插入的位置,it2和it3为待插字符串的首位迭代器 erase() 删除单个元素 str.erase(it) 删除一个区间内的所有元素 clear() 用以清空string中的数据 substr() substr(pos, len)返回从pos号位开始,长度为len的子串 string::npos 是一个常数,本身的值为-1,用以作为find函数失配时的返回值 find() replace() map翻译为映射,也是常用的STL容器 在定义数组时,其实是定义了一个从int型到int型的映射。map可以将任意基本类型(包括STL容器)映射到任意基本类型,也就可以建立string到int的映射 map和其他容器在定义上有点不一样,因为map要确定键和值 如果是字符串到整型的映射,必须用string而不能用char[]数组 map一般有两种访问方式:通过下标访问或通过迭代器访问 通过下标访问 例如对一个定义为map 通过迭代器访问 map可以使用it->first来访问键,使用it->second来访问值 map会以键从小到大的顺序自动排序 find() find(‘key’)返回为key的映射的迭代器 erase() erase()有两种用法:删除单个元素、删除一个区间内的所有元素 删除单个元素 删除一个区间内的所有元素 mp.erase(first, last),first为需要删除的区间的起始迭代器,last为末尾迭代器的下一个地址,左闭右开 size() 用来获得map中映射的对数 clear() 用来清空map中的所有元素 另:一个键对应多个值 multimap 散列实现 unordered_map queue翻译为队列,在STL中是一个先进先出的容器 由于队列本身就是一种限制性数据结构,因此在STL中只能通过front()来访问队首元素,或是通过back()来访问队尾元素 push() push(x)将x进行入队 front(), back() 分别获得队首元素和队尾元素 pop() 令队首元素出队 empty() 检测queue是否为空,返回true为空,返回false为非空 size() 返回queue内元素的个数 另:双端队列deque 优先队列priority_queue 又称为优先队列,其底层是用堆来进行实现的 在优先队列中,队首元素一定是当前队列中优先级最高的哪一个 只能通过top()函数来访问队首元素(也可以称为堆顶元素),也就是优先级最高的元素 push() push(x)将令x入队 top() 获得队首元素 pop() 令队首元素出队 empty() 检测优先队列是否为空 size() 返回优先队列内元素的个数 基本数据类型的优先级设置 优先队列默认设置是数字大的优先级越高 因此,如果想让优先队列总是把最小的元素放在队首,只需进行如下定义 结构体的优先级设置 现在希望按水果的价格高的为优先级高,就需要重载小于号 此时就可以直接定义fruit类型的优先队列,其内部就是以价格高的水果为优先级高 如果需要价格低的水果优先级高,如下重载 有些类似于cmp函数 还有一种写法 在这种情况下,需要用之前讲的第二种定义方式定义优先队列 即便是基本数据类型或者其他STL容器,也可以通过同样的方式定义优先级 数据较为庞大时,建议使用引用提高效率 可以解决一些贪心问题,也可以优化Dijkstra算法 使用top()函数前必须用empty()判断队列是否为空 stack翻译为栈,是STL中实现的一个后进先出的容器 只能通过top()来访问栈顶元素 push() push(x)将x入栈 top() 获得栈顶元素 pop() 弹出栈顶元素 empty() 检测stack是否为空,返回true为空,返回false为非空 size() 返回stack内元素的个数 用来模拟实现一些递归 pair实际上可以看作一个内部有两个元素的结构体,且可以指定这两个元素的类型 注意,由于map的内部实现中涉及pair,所以添加map头文件时会自动添加utility头文件 比较操作数 两个pair类型可以直接使用==,!=,<等,比较规则是先以first的大小作为标准,只有当first相等时才去判别second的大小 代替二元结构体 作为map的键值对进行插入 max(x,y)返回x和y中的最大值 min(x,y)返回x和y中的最小值 abs(x)返回x的绝对值 swap(x,y)用来交换x和y的值 reverse(it1,it2)可以将数组指针在[it, it2)之间的元素或容器的迭代器在[it, it2)范围内的元素进行反转 next_permutation()给出一个排序在全排列中的下一个序列 在上述代码中,使用循环是因为next_permutation()在到达全排列的最后一个时会返回false,方便退出循环 fill()可以把数组或容器中的某一段区间赋为某个相同的值。和memset()不同,这里的赋值可以是数组类型对应范围中的任意值 容器的排序 在STL标准容器中,只有vector、string、deque是可以使用sort的。这是因为像set、map这种容器是用红黑树实现的,元素本身有序,故不允许使用sort排序 string默认是字典序排序 lower_bound()和upper_bound()需要用在一个有序数组或容器中 lower_bound(first, last, val)用来寻找在数组或容器的[first, last)范围内第一个值大于等于val的元素的位置,如果是数组,则返回该位置的指针;如果是容器,则返回该位置的迭代器。 upper_bound(first, last, val)用来寻找在数组或容器的[first, last)范围内第一个值大于val的元素的位置,如果是数组,则返回该位置的指针;如果是容器,则返回该位置的迭代器。 显然,如果数组或容器中没有需要寻找的元素,则它们均返回可以插入该元素的位置的指针或迭代器 想获得欲查元素的下标,可以直接令返回值减去数组首地址 栈是一种后进先出的数据结构 栈顶指针是始终指向栈的最上方的一个标记,栈空时令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常用函数实例解析*** 按正常方式定义一个数组时,计算机会从内存中取出一块连续的地址来存放给定长度的数组,而链表则是由若干个结点组成,且结点在内存中的存储位置通常是不连续的 定义静态链表 在程序的开始,对静态链表进行初始化。一般来说,需要对定义中的XXX进行初始化,将其定义为正常情况下达不到的数字。例如对结点是否在链表上这个性质来说,我们可以初始化为0,表示节点不在链表上 题目一般都会给出一条链表的首结点地址,那么我们就可以根据这个地址来遍历得到整条链表。需要注意的是,这一步同时也是我们对结点的性质XXX进行标记、并且对有效结点的个数进行计数的时候。例如对结点是否在链表上这个性质来说,当我们遍历链表时,就可以把XXX置为1 由于使用静态链表时,是直接采用地址映射的方式,这就会使得数组下标不连续,而很多时候题目给出的结点并不都是有效结点,为了可控地访问有效结点,一般都需要对数组进行排序以把有效结点移到数组左端 一般来说题目一定会有额外的要求,因此cmp函数中一般都需要有第二级排序 当碰到岔道口时,总是以“深度”作为前进的关键词,不碰到死胡同就不回头,因此把这种搜索方式称为 深度优先搜索 深度优先搜索是一种枚举所有完整路径以遍历所有情况的搜索方法 例题:给定N个整数(可能有负数),从中选择K个数,使得这K个数之和恰好等于一个给定的整数X;如果有多种方案,选择它们中元素平方和最大的一个。数据保证这样的方案唯一 当碰到岔道口时,总是先依次访问从该岔道口能直接到达的所有结点,然后再按这些结点被访问的顺序去依次访问它们能直接到达的所有结点,以此类推,直到所有结点都被访问为止。 BFS的一般由队列实现,且总是按层次的顺序进行遍历,其基本写法如下 给定一个m*n的矩阵,矩阵中的元素为0或1。如果矩阵中有若干个1是相邻的(不必两两相邻),那么称这些1构成了一个块,求给定的矩阵中块的个数 需要注意的一件事是,queue的push操作只是制造了该元素的一个副本入队,在入队后对原元素的修改不会影响队列中的副本。 数据结构中把树枝分叉处、树叶、树根抽象为 结点(node),其中树根抽象为 根节点(root),且对一棵树来说最多存在一个根节点;把树叶概括为 叶节点(leaf),把茎干和树枝抽象为 边(edge)。 在数据结构中,一般把根结点置于最上方,然后向下延伸出若干条边到 子结点(child),从而向下形成 子树(subtree)。 注意区分二叉树与度为2的树的区别:二叉树的左右子树是严格区分的,不能随意交换左子树和右子树的位置 用链表来定义,又把这种链表称为二叉链表 对于完全二叉树,可以给它的所有结点按从上到下,从左到右的顺序进行编号,然后建立数组存放 先序遍历:根结点,左子树,右子树 中序遍历:左子树,根结点,右子树 后序遍历:左子树,右子树,根结点 层序遍历:逐层往下进行 性质:对一颗二叉树的先序遍历序列,序列的第一个一定是根节点 性质:只要知道根结点,就可以通过根结点在中序遍历序列中的位置区分出左子树和右子树 性质:对一颗二叉树的后序遍历序列,序列的最后一个一定是根节点 总的来说,无论是先序遍历序列还是后序遍历序列,都必须知道中序遍历序列才能唯一地确定一棵树 最后解决一个重要的问题:给定一颗二叉树的先序遍历序列和中序遍历序列,重建该二叉树 结论:中序序列可以与先序序列、后序序列、层序序列中的任意一个来构建唯一的二叉树,后面那三个怎么搭配都不行 例题:1102 Invert a Binary Tree (25 分) 在考试中涉及树的考查时,一般会给出结点编号,在这种情况下,就不需要newNode函数了,因为题目中给定的编号可以直接作为Node数组的下标 需要特别指出的是,如果题目中不涉及结点的数据域,即只需要树的结构,那么上面的结构体可以简化地写成vector数组,这种写法其实就是图的邻接表表示法在树中的应用 对一棵树,总是先访问根结点,再访问所有子树 树的层序遍历与二叉树的层序遍历的思路是一致的,即总是从树根开始,一层一层地向下遍历 深度优先搜索DFS与先根遍历 对所有合法的DFS求解过程,都可以把它化作树的形式,并且对这颗树的DFS遍历就是树的先根遍历过程 另外,在进行DFS的过程中对某条可以确定不存在解的子树采取 直接剪断的策略称为 剪枝。但使用的前提是必须保证剪枝的正确性。 广度优先搜索BFS与层序遍历 对所有合法的BFS求解过程,都可以把它化作树的形式,并且对这颗树的BFS遍历就是树的层序遍历过程 二叉查找树的递归定义如下: 由二叉查找树的定义可知,二叉查找树实际上是一颗数据域有序的二叉树 二叉查找树的基本操作有查找、加入、建树、删除 查找操作 插入操作 二叉查找树的建立 二叉查找树的删除 对二叉查找树进行中序遍历,遍历的结果是有序的 二叉查找树的缺陷:在某些情况下退化为一条链 对数的结构进行调整,使树的高度在每次插入元素后仍然能保持O(logn)的级别 平衡二叉树(AVL)仍然是一颗二叉查找树,只是在其基础上增加了“平衡”的要求 所谓平衡是指,对AVL树的任意结点来说,其左子树与右子树的高度之差的绝对值不超过1,其中左子树与右子树的高度之差称为该结点的平衡因子 只要能随时保证每个结点平衡因子的绝对值不超过1,AVL的高度就始终能保持O(logn)级别 查找操作 由于AVL树是一颗二叉查找树,因此其查找操作的做法与二叉查找树相同 插入操作 在往其中插入一个结点时,一定会有结点的平衡因子发生变化。 需要用到左旋和右旋操作来调整 可以证明,只要把最靠近插入结点的失衡结点调整到正常,路径上的所有结点就都会平衡 并查集是一种维护集合的数据结构,它的名字中"并"“查”"集"分别取自Union、Find、Set这三个单词。也就是说,并查集支持下面两个操作 并查集的实现:数组 其中father[i]表示元素i的父亲结点,而父亲结点本身也是这个集合内的元素;另外如果father[i] == i,则说明元素i是该集合的根结点,但对同一个集合来说只存在一个根结点,且将其作为所属集合的标识。 1.初始化 一开始每个元素都是独立的集合,因此需要令所有的father[i] = i 2.查找 由于规定同一个集合中只存在一个根结点,因此查找操作就是对给定的结点寻找其根结点的过程 3.合并 合并是指把两个集合合并成一个集合,题目中一般给出两个元素,要求把这两个元素所在的集合合并 并查集的一个性质:在合并的过程中,只对两个不同的集合进行合并,如果两个元素在相同的集合中,那么就不会对它们进行操作,这就保证了在同一个集合中一定不会产生环,即 并查集产生的每一个集合都是一棵树。 上面的并查集查找函数是没有经过优化的,在极端情况下效率较低 把当前查询结点的路径上的所有结点的父亲都指向根结点,查找的时候就不用一直回溯去寻找父亲了 堆是一棵完全二叉树,树中每个结点的值都不小于(或不大于)其左右孩子结点的值 其中,如果父亲结点的值大于或等于孩子结点的值,那么称这样的堆为 大顶堆,这时每个结点的值都是以它为根结点的子树的最大值;如果父亲结点的值小于或等于孩子结点的值,那么称这样的堆为 小顶堆。 堆一般用于优先队列的实现,而优先队列默认情况下使用的是大顶堆。 对完全二叉树来说,比较简洁的实现方法是按照9.1.3节中介绍的那样,使用数组来存储完全二叉树,这样结点就按层序存储于数组中,其中第一个结点将存储于数组中的1号位,并且数组i号位表示的结点的左孩子就是2i号位,而右孩子则是2i+1号位。于是可以定义数组表示堆: 每次调整都是把结点从上往下的调整,针对这种向下调整,总是将当前结点V与它的左右孩子比较,假如孩子中存在比结点V的权值大的,则将最大的与V交换,交换完后继续让V和孩子比较,直到结点V的孩子的权值都比它小或者没有孩子 倒着枚举建堆,可以保证每个结点都是以其为根结点的子树中的权值最大的结点 如果想要添加一个元素,可以把想要添加的元素放在数组最后,然后进行向上调整操作 堆排序是指使用堆结构对一个序列进行排序 合并果子问题 事实上可以发现,消耗体力之和也可以通过把叶子结点的权值乘以它们各自的路径长度再求和来获得,其中叶子结点的路径长度是指从根结点出发到达该结点所经过的边数 把叶子结点的权值乘以其路径长度的结果称为这个叶子结点的带权路径长度 树的带权路径长度等于它所有叶子结点的带权路径长度之和 于是合并果子问题就转换成:已知n个数,寻找一颗树,使得树的所有叶子结点的权值恰好为这n个数,并且使得这棵树的带权路径长度最小。带权路径长度最小的树被称为 哈夫曼树(又称为最优二叉树)。显然对于同一组叶子结点来说,哈夫曼树可以不是唯一的,但是最小带权路径长度一定是唯一的。 构造一颗哈夫曼树: 对哈夫曼树来说不存在度为1的结点,并且权值越高的结点相对来说越接近根结点 哈夫曼树的构造思想:反复选择两个最小的元素,合并,直到只剩下一个元素 以合并果子问题为例,可以直接使用优先队列来实现 对任意一棵二叉树来讲,如果把二叉树上的所有分支都进行编号,将所有的左分支都标记为0,所有右分支都标记为1,那么对树上的任意一个结点,都可以根据从根结点出发到达它的分支顺序得到一个编号,且这个编号是唯一的。 对任何一个非叶子结点,其编号一定是某个叶子结点编号的前缀,例如结点T的编号就是结点C和结点D的编号的前缀。并且,对于任意一个叶子结点,其编号一定不会成为其他任何一个结点编号的前缀。 假设现在有一个字符串,要把它编码成一个01串,需要寻找一种编码方式,使得其中任何一个字符的编码都不是另一个字符的编码的前缀,同时把满足这种编码方式的编码称为 前缀编码 前缀编码的存在意义在于不产生混淆,让解码能够正常进行 对一个给定的字符串来说,肯定有很多种前缀编码的方式,但是为了信息传递的效率,需要尽量选择长度最短的编码方式。我们很快就能发现,如果把ABCD的出现次数作为各自叶子结点的权值,那么 字符串编码成01串后的长度实际上就是这棵树的带权路径长度 只需要针对叶子结点权值为1、2、3、4建立哈夫曼树,其叶子结点对应的编码方式就是所需要的 这种由哈夫曼树产生的编码方式被称为 哈夫曼编码,显然哈夫曼编码是能使给定字符串编码成01串后长度最短的前缀编码。 哈夫曼编码是针对确定的字符串来讲的 抽象来看,图由顶点和边组成,每条边的两端都必须是图的两个顶点。 一般来说,图可分为 有向图 和 无向图 。有向图的所有边都有方向,而无向图的所有边都是双向的。在一些问题中,可以把无向图当作所有边都是正向和负向的两条有向边组成,这对解决一些问题很有帮助。 顶点的度是指和该顶点相连的边的条数。对于有向图,出边条数为出度,入边条数为入度 顶点和边的权值分别称为点权和边权 一般来说有两种:邻接矩阵和邻接表 设图(G, E)的顶点标号为0,1,…,N-1,那么可以令二维数组 如果 虽然邻接矩阵比较好写,但只适用于顶点数目不太大(不超过1000)的题目 每个顶点都可能有若干条出边。如果把同一个顶点的所有出边放在一个列表里,那么N个顶点就会有N个列表,这N个列表被称为图G的邻接表,记为Adj[N]。其中Adj[i]存放顶点i的所有出边组成的列表 如果邻接表只存放每条边的终点编号而不存放边权,则vector中的元素类型可以直接定义为int型 如果需要同时存放边的终点编号和边权,那么可以建立结构体 当然,更快的做法是定义结构体中的构造函数 图的遍历是指对图的所有顶点按一定的顺序访问,遍历方法一般有两种:DFS和BFS 深度优先搜索以“深度”为第一关键词,每次都是沿着路径到不能再前进时才退回到最近的岔道口。 DFS的具体实现 下面把连通分量和强连通分量均称为连通块 DFS遍历图的基本思路就是将已经过的顶点设置为已访问,在下次递归时碰到这个顶点时就不再去处理,直到整个图的顶点都被标记为已访问 【PAT A1034】Head of a Gang 以“广度”作为关键词,每次以扩散的方式向外访问顶点。和树的遍历一样,使用BFS遍历图需要使用一个队列,通过反复取出队首顶点,将该顶点可到达的未曾加入过队列的顶点全部入队,直到队列为空时遍历结束 BFS的具体实现 使用BFS遍历图的基本思想是建立一个队列,并把初始顶点加入队列,以后每次都取出队首顶点进行访问,并把从该顶点出发可以到达的未曾加入过队列的顶点全部加入队列,直到队列为空 邻接表版 【PAT A1076】Forwards on Weibo 给定图G(V,E),求一条从起点到终点的路径,使得这条路径上经过的所有边的边权之和最小 解决最短路径的常用算法有 Dijkstra算法 、Bellman-Ford算法、SPFA算法、Floyd算法 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为顶点个数) Dijkstra算法的具体实现: 由于Dijkstra算法的策略偏重于理论化,因此为了方便编写代码,需要想办法来实现策略中两个较为重要的东西,即集合S的实现、起点s到达顶点Vi的最短距离的实现 邻接表版 寻找最小d[u]的过程却可以不必达到O(V)的复杂度,而使用堆优化降低复杂度 Dijkstra算法只能应对所有边权都是非负数的情况,如果边权出现负数,最好使用SPFA算法 最短路径本身怎么求解呢? 可以设置数组pre[],令pre[v]表示从起点s到顶点v的最短路径上v的前一个结点(即前驱结点)的编号。这样,当伪代码中的条件成立时,就可以将u赋给pre[v],最终就能把最短路径上每一个顶点的前驱结点记录下来 以邻接矩阵作为举例: 那么当想要知道从起点到达终点的最短路径,就可以用一个递归输出 【PAT A1003】Emergency Dijkstra+DFS 回顾上面只使用Dijkstra算法的思路,会发现,算法中数组pre总是保持着最优路径,而这显然需要在执行Dijkstra算法的过程中使用严谨的思路来确定何时更新每个结点v的前驱结点pre[v] 事实上更简单的方法是:先在Dijkstra算法中记录下所有最短路径(只考虑距离),然后从这些最短路径中选出一条第二标尺最优的路径(因为在给定一条路径的情况下,针对这条路径的信息都可以通过边权和点权很容易计算出来) 使用Dijkstra算法记录所有最短路径 完整代码如下所示 遍历所有最短路径,找出一条使第二标尺最优的路径 由于每个结点的前驱结点可能有多个,遍历的过程就会形成一棵递归树,例如1中pre数组产生的递归树。显然,对这棵树进行遍历时,每次到达叶子结点,就会产生一条完整的最短路径。因此,每得到一条完整路径,就可以对这条路径计算其第二标尺的值,令其与当前第二标尺的最优值进行比较,如果比当前值更优,那么更新它,并且用这条路径覆盖当前的最优路径。这样,当所有的最短路径都遍历完毕后,就可以得到最优第二标尺与最优路径 DFS代码如下所示 需要注意的是, 由于递归的原因,存放在tempPath中的路径结点是逆序的,因此访问结点需要倒着进行。当然,如果仅是对边权或点权进行求和,那么正序访问也是可以的 最后指出,如果需要同时计算最短路径的条数,既可以添加num数组来求解,也可以开一个全局变量来记录最短路径的条数,当DFS到达叶子结点时令该全局变量+1即可 【PAT A1030】Travel Plan Dijkstra算法可以很好地解决无负权图的最短路径问题,但如果出现了负权边,该算法就会失效 为了更好求解有负权边的最短路径问题,需要使用Bellman-Ford算法(贝尔曼-福特算法),和Dijkstra算法一样,贝尔曼-福特算法可解决最短路径问题,但也能处理有负权边的情况 贝尔曼-福特算法的主要思路:对图中的边进行V-1轮操作,每轮都遍历图中的所有边:对每条边u->v,如果以u为中介点可以使d[u]更小,即d[u]+length[u->v] 若图中存在未确认的顶点,则对边集合的一次迭代松弛后,会增加至少一个已确认顶点 时间复杂度为O(VE) 当一次循环中没有松弛操作成功时停止。 每次循环是 O(m) 的,那么最多会循环多少次呢? 答案是 ∞!(如果有一个 S 能走到的负环就会这样) 但是此时某些结点的最短路不存在。 我们考虑最短路存在的时候。 由于一次松弛操作会使最短路的边数至少 +1,而最短路的边数最多为 n−1。 所以最多执行 n−1 次松弛操作,即最多循环 n−1 次。 【PAT A1003】Emergency 这种优化后的算法被称为SPFA,它的期望时间复杂度是O(kE)。这个算法在处理大部分数据时异常高效,并且经常性的优于堆优化的Dijkstra算法 虽然在大多数情况下 SPFA 跑得很快,但其最坏情况下的时间复杂度为 O(VE),将其卡到这个复杂度也是不难的,所以考试时要谨慎使用(在没有负权边时最好使用 Dijkstra 算法,在有负权边且题目中的图没有特殊性质时,若 SPFA 是标算的一部分,题目不应当给出 Bellman-Ford 算法无法通过的数据范围)。 SPFA十分灵活,其内部的写法可以根据具体场景进行不同调整,例如上面代码中的队列可以替换成优先队列以加快速度,或者替换成双端队列,使用SLF优化和LLL优化,以使效率提高至少50% 除此之外,上面的代码给出的是BFS的SPFA,还可以替换为DFS的SPFA 然而现在卡SPFA成为了一种普遍现象,所以有说法称SPFA已死 弗洛伊德算法用来解决全源最短路问题 即对给定的图G(V,E),求任意两点u,v之间的最短路径长度,时间复杂度为O(n3)。由于n3的复杂度决定了顶点数n的限制约在200以内,因此使用邻接矩阵来实现Floyd算法是非常合适且方便的 对Floyd算法来说,不能将最外层的k循环放到内层:因为如果较后访问的 最小生成树是在一个给定的无向图G(V,E)中求一棵树T,使得这颗树拥有图G中的所有顶点,且所有边都是来自图G中的边,并且满足整棵树的边权最小 最小生成树有三个性质要掌握 求解最小生成树一般有两种算法,即prim算法和kruskal算法。这两个算法都是采用了贪心的思想,只是贪心的策略不太一样 prim算法用来解决最小生成树问题,其基本思想是对图G(V,E)设置集合S,存放已被访问的顶点,然后每次从集合V-S中选择与集合S的最短距离最小的一个顶点(记为u),访问并加入集合S,之后令顶点u为中介点,优化所有从u能到达的顶点v与集合S之间的最短距离。这样的操作执行n次(n为顶点个数),直到集合S已包含所有顶点 Dijkstra算法和prim算法实际上是相同的思路,只不过是数组d[]的含义不同罢了 邻接矩阵版 邻接表版 和Dijkstra算法一样,复杂度为O(V2),其中邻接表实现的prim算法可以通过堆优化使时间复杂度降为O(VlogV+E) 尽量在图的顶点数目较少而边数较多的情况下(稠密图)使用prim算法 以亚历山大攻打恶魔大陆的例子写出代码 kruskal算法同样是解决最小生成树问题的一个算法,和prim算法不同,kruskal算法采取了 边贪心的策略,其思想极其简洁:在初始状态是隐去图中的所有边,每个顶点自成一个连通块。之后执行下面的步骤: kruskal算法的时间复杂度主要来源于对边进行排序,因此其时间复杂度为O(ElogE),其中E为图的边数。显然kruskal适合顶点数较多、边数较少的情况,这和prim算法恰好相反,于是可以根据题目所给的数据范围来选择何时的算法,即如果是稠密图(边多),则用prim算法;如果是稀疏图(边少),则用kruskal算法 如果一个有向图的任意顶点都无法通过一些有向边回到自身,那么称这个有向图为有向无环图(DAG) 拓扑排序是将有向无环图的所有顶点排成一个线性序列,使得对图G中的任意两个顶点u、v,如果存在边u->v,那么在序列中u一定在v前面。这个序列又被称为拓扑序列 抽象为以下步骤 可使用邻接表实现拓扑排序,显然,由于需要记录结点的入度,因此需要额外建立一个数组inDegree[MAXV],并在程序一开始读入图时就记录好每个结点的入度,接下来就只需要按上面所说的步骤进行实现即可,拓扑排序的代码如下: 拓扑排序一个很重要的应用就是判断一个给定的图是否是有向无环图 如果要求有多个入度为0的顶点,选择编号最小的顶点,那么把queue改成priority_queue,并保持队首元素是优先队列中最小的元素即可(当然用set亦可) 有多个源点(入度为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的最早发生时间和最迟发生时间 如果想要求ve[j]的正确值,必须先得到它的所有前驱结点,我们可以使用拓扑排序 同理,求vl[i]必须先得到它的后继结点,这里可以通过逆拓扑序列来实现 幸运的是, 可以通过颠倒拓扑序列来获得一组合法的逆拓扑序列,上面使用了栈来存储拓扑序列,只要按顺序出栈就是逆拓扑序列 主体部分代码如下(适用于 汇点确定且唯一的情况,以n-1号顶点为汇点为例) 需要存储e和l的话在结构体中对应添加域即可 如果事先不知道汇点编号,可以取ve数组的最大值。原因在于,ve数组的含义是事件的最早开始时间,因此所有事件中ve最大的一个一定是最后一个(或多个)事件,也就是汇点,只需要稍微修改代码即可 即使图中有多条关键路径,但如果只要求输出关键活动,按上面的写法即可 如果要完整输出所有关键路径,就需要把关键活动存储下来,方法是新建一个邻接表,当确定边是关键活动时,将边加入邻接表,这样最后生成的邻接表就是所有关键路径合成的图 使用动态规划的做法可以更简洁地求解关键路径 动态规划是一种非常精妙的算法思想,它没有固定的写法、极其灵活,常常需要具体问题具体分析 以下是斐波拉契数列的动态规划递归求解 通过记忆化搜索,把复杂度从O(2n)降到了O(n) 通过上面的例子可以引申出一个概念:如果一个问题可以被分解为若干个子问题,且这些子问题会重复出现,那么就称这个问题拥有重叠子问题 一个问题必须拥有重叠子问题,才能使用动态规划去解决 以经典的数塔问题为例, 把 下面根据这种思想写出动态规划的代码 显然使用递归也可以实现上面的例子,两者的区别在于:使用 递推写法 的计算方式是 自底向上,即从边界开始不断向上解决问题,直到解决目标问题;而使用 递归写法 的计算方式是 自顶向下,即从目标问题开始,将它分解成子问题的组合,直到分解至边界为止 如果一个问题的最优解可以由其子问题的最优解有效地构造出来,那么就称这个问题拥有 最优子结构,最优子结构保证了动态规划中原问题地最优解可以由子问题的最优解推导而来。因此,一个问题必须拥有最优子结构才能使用动态规划去解决 总结:一个问题必须同时拥有重叠子问题和最优子结构,才能使用动态规划去解决 代码如下所示 此处介绍后无效性的概念。 状态的后无效性是指:当前状态记录了历史信息,一旦当前状态确定,就不会再改变,且未来的决策只能在已有的一个或者若干个状态的基础上进行,历史信息只能通过已有的状态去影响未来的决策。 对动态规划可解的问题来说,总会有很多设计状态的方式,但并不是所有状态都具有后无效性,因此必须设计一个拥有后无效性的状态以及相应的状态转移方程,否则动态规划就没有办法得到正确结果 事实上,如何设计状态和状态转移方程,才是动态规划的核心,而它们也是动态规划最难的地方 最长不下降子序列是这样一个问题 状态转移方程 求LIS长度代码如下 最长公共子序列是这样一个问题 状态转移方程 求LCS长度代码如下 最长回文子串是这样一个问题 状态转移方程 边界 如果按照i和j从小到大的顺序来枚举子串的两个端点,然后更新 求最长回文子串代码如下 DAG就是有向无环图,之前已讨论过“关键路径”的求解。但这里还有更简便的方法 两个问题: 先讨论第一个问题:给定一个有向无环图,怎样求解整个图的所有路径中权值之和最大的那条 dp[i]表示从i号顶点出发能获得的最长路径长度 在上面的基础上,讨论 固定终点,求DAG的最长路径长度 与前一个的问题区别在于边界。在第一个问题中没有固定终点,因此所有出度为0的顶点的dp值为0是边界;但是在这个问题中固定了终点,因此边界应为dp[T]=0。而且不可以对整个dp数组都赋值为0,合适的做法是初始化dp数组为一个负的大数(即-INF),来保证“无法到达终点”的含义得以表达,然后设置一个vis数组表示顶点是否已经被计算 至于如何记录方案以及如何选择字典序最小的方案,均与第一个问题相同 矩形嵌套问题 给出n个矩阵的长和宽,定义矩形的嵌套关系为:如果有两个矩形A和B,其中矩形A的长和宽分别为a、b,矩形B的长和宽分别为c、d,且满足a 将每个矩形都看成一个顶点,并将嵌套关系视为顶点之间的有向边,边权均为1,于是就可以转换为DAG最长路问题 背包问题是一类经典的动态规划问题,它非常灵活、变体多样,这里只介绍两种最简单的背包问题 01背包问题和完全背包问题 有一类动态规划可解的问题,它可以描述成若干个有序的阶段,且每个阶段的状态只和上一个阶段的状态有关,一般把这类问题称为多阶段动态规划问题,对这种问题,只需要从第一个问题开始,按照阶段的顺序解决每个阶段中状态的计算,就可以得到最后一个阶段中状态的解 通过边界把整个dp数组递推出来,边界 发现时间复杂度和空间复杂度都是O(nV),且空间复杂度可以进一步优化 v的枚举顺序变为从右往左, 我们把这种技巧称为滚动数组 逆序枚举是由于需要用到滚动前上一个状态的dp[v-w[i]] 完整代码如下所示 01背包中的每个物品都可以看作一个阶段,这个阶段中的状态有 从中也可得到一个技巧:如果当前设计的状态不满足后无效性,那么不妨把状态进行升维,即增加一维或若干维来表示相应的信息,这样可能就满足后无效性了 完全背包问题和01背包问题唯一区别就在于 无穷件 状态转移方程 同样可以改写为一维 区别在于这里v是 正向枚举 求解 最大连续子序列和 令 最长不下降子序列(LIS) 令 最长公共子序列(LCS) 令 最长回文子串 令 数塔DP 令 DAG最长路 令 01背包 令 完全背包 令 ①到④,这四个都是关于序列或者字符串的问题(特别说明:一般地,子序列可以不连续,子串必须连续) 当题目与序列或字符串有关时,可以考虑把状态设计为下面两种形式,然后根据端点特点去考虑状态转移方程 其中XXX为原问题描述 ⑤到⑧,可以发现它们的状态设计都包含了某种方向的意思,这又说明了这一类动态规划问题的状态设计办法 分析题目中的状态需要几维来表示,然后对其中的每一维采取下面的某一个表述: 在每一维的含义设置完毕之后,dp数组的含义就可以设置成”令dp数组表示恰好为i(或前i)、恰好为j(或前j)的XXX,其中XXX为原问题描述,然后根据端点特点去考虑状态转移方程 在大多数情况下,都可以把动态规划可解的问题看作一个有向无环图,图中的结点就是状态,边就是状态转移的方向,求解问题的顺序就是按照DAG的拓扑序进行求解 字符串hash是指将一个字符串S映射为一个整数,使得该整数可以尽可能唯一地代表字符串S 这样转换虽然字符串和整数一一对应,但由于没有进行处理,因此字符串长度较长时,产生的整数会非常大 通过这种方式把字符串转换成范围上能接受的整数,但也导致可能有多个字符串的hash值相同 在实践中发现,在int数据范围内,如果 把进制数设置为一个10的7次方级别的素数p(如10000019),同时把mod设置为一个10的9次方级别的素数(如1000000007),那么冲突的概率将会变得非常小 问题:给出N个只有小写字母的字符串,求其中不同的字符串的个数 考虑求解字符串的子串的hash值 *H[i…j] = ((H[j] - H[i-1]pj-i+1) % mod + mod) % mod 问题:输入两个长度均不超过1000的字符串,求它们的最长公共子串的长度 这里将用hash+二分的思路去解决它,时间复杂度为O(nlogn),其中n为字符串的长度 如果确实碰到了极其针对进制数p=10000019,模数mod=1000000007的数据,只需要调整p和mod 或者使用效果更强的双hash法,用两个hash函数生成的整数组合表示一个字符串 需要注意的是,这里介绍的字符串hash函数只是众多字符串hash方法中的一个,即从进制转换的角度来进行字符串hash,除此之外,还有BKDRHash,ELFHash等 总结next数组的求解过程 判断pattern是否是text的子串: 求解next数组的过程其实就是模式串pattern进行自我匹配的过程 统计模式串pattern出现次数: nextval[i]的含义应该理解为当模式串pattern的i+1位发生失配时,i应当回退到的最佳位置 可以把有限状态自动机看作一个有向图,其中顶点表示不同的状态(类似动态规划中的状态),边表示状态的转移。另外,有限状态自动机中会有一个起始状态和终止状态,如果从起始状态出发,最终转移到了终止状态,那么自动机就正常停止。 对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大的元素 【PAT A1057】Stack 整数在计算机中一般采用的是补码存储,并且把一个补码表示的整数x变成其相反数-x的过程相当于把x的二进制的每一位都取反,然后末位加1。而这等价于直接把x的二进制最右边的1左边的每一位都取反。因此很容易推得lowbit运算就是取x的二进制最右边的1和它右边所有0,因此它一定是2的幂次 对这个问题,一般的做法是开一个sum数组,其中sum[i]表示前i个整数之和,(数组下标从1开始),这样sum数组就可以在输入N个整数时就预处理出来。接着每次查询前x个整数之和时,输出sum[x]即可 树状数组(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开始,这是需要注意的 写出getSum函数 显然,由于lowbit(i)的作用是定位i的二进制中最右边的1,因此 如果要求数组下标在区间[x,y]内的数之和,可以转换成getSum(y)-getSum(x-1) update函数的做法很明确:只要让x不断加上lowbit(x),并让每步的C[x]都加上v,直到x超过给定的数据范围为止 显然,这个过程是从右往左不断定位x的二进制最右边的1左边的0的过程,因此update函数的时间复杂度为O(logN) 由于树高是O(logN),因此可以同样得到update函数的时间复杂度就是O(logN) 问题:给定一个有N个正整数的序列A,对序列中的每个数,求出序列中它左边比它小的数的个数 这就是树状数组最经典的应用:统计序列中在元素左边比该元素小的元素个数 统计序列中在元素左边比该元素大的元素个数等价于计算 至于元素统计右边的只需要把原始数组从右往左遍历就好了 如果A[i]<=N不成立,可以设置一个临时的结构体数组,用以存放输入的序列元素的值以及原始序号,而在输入完毕后将数组按val从小到大排序,排序完再按照“计算排名”的方法将“排名”根据原始序号pos存入一个新的数组即可 由于这种做法可以把任何不在合适区间的整数或者非整数都转换为不超过元素个数大小的整数,因此一般把这种技巧称为 离散化 一般来说离散化只适用于离线查询,但对在线查询也可以先把所有操作都记录下来,然后对其中出现的数据进行离散化,之后再按照记录下来的操作顺序正常进行操作即可。 求二维整数矩阵相关问题只需要把树状数组推广为二维即可,具体做法是,直接把update函数和getSum函数中的for循环改成两重,更高维的情况将for循环改为对应的重数即可 如果要求子矩阵,只需计算 代码如下: 之前一直在进行 单点更新、区间查询。如果想要进行 区间更新,单点查询,该怎么做? 数组累加得到前i-1块中存在的元素总个数,然后判断加入i号块的元素个数后元素总个数能否达到K,如果能,则说明第K大的数就在当前枚举的这个块中,此时只需从小到大遍历该块中的每个元素,利用table数组继续累加元素的存在个数,直到总累计数达到K,则说明找到了序列第K大的数 lowbit(x) = x & (-x) 问题:给出一个整数序列A,元素个数为N,接下来查询K次,每次查询将给出一个正整数x,求前x个整数之和 升级后的问题:假设在查询的过程中可能随时给第x个整数加上一个整数v,要求在查询中能实时输出前x整数之和(更新操作和查询操作的次数总和为K次) 由 //getSum函数返回前x个整数之和 //update函数将第x个整数加上v #include const int maxn = 100010; //getSum函数返回前x个整数之和 int main() { } #include const int maxn = 100010; //update函数将第x个整数加上v //getSum函数返回前x个整数之和 //按val从小到大排序 int main() { } //求序列元素第K大 getSum(x,y) - getSum(x-1,y) - getSum(x,y-1) + getSum(x-1,y-1) int c[maxn][maxn]; //二维树状数组 //update函数将位置为(x,y)的整数加上v //getSum函数返回前x个整数之和 //getSum函数返回第x个整数的值 //update函数将前x个整数都加上v快速幂
two pointers
什么是two pointers
归并排序
//对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)//左右合并
}
}
快速排序
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
其他高效技巧和算法
打表
活用递推
随机选择算法
入门篇(3)——数学问题
简单数学
最大公约数和最小公倍数
int gcd(int a, int b){
return !b ? a : gcd(b, a % b);
}
分数的四则运算
素数
埃氏筛法
#include
质因子分解
#include
大整数运算
扩展欧几里得算法
组合数
C++ 标准模板库STL介绍
vector 的常见用法详解
#include
vector的定义
vector
vector
vector容器内元素的访问
vector
vector
型的变量vector
vector常用函数实例解析
vector常见用途
set的常见用法详解
#include
set的定义
set
set容器内元素的访问
set
for(set
set常用函数实例解析
st.erase(st.find(100));
st.erase(100);
string的常见用法详解
#include
string的定义
string str;
string中内容的访问
printf("%s\n", str.c_str());
string::iterator it;
string常用函数实例解析
str3 = str1 + str2;
str1 += str2;
str.insert(str.begin()+3, str2.begin(), str2.end());
str.erase(first, last);
str.erase(pos, length);//pos为需要开始删除的起始位置,length为删除的字符个数
str.find(str2),当str2是str的子串时,返回其在str中第一次出现的位置,否则返回string::npos
str.fine(str2, pos),从pos号位开始匹配str2,返回与上相同
str.replace(pos, len, str2);//把str从pos号位开始,长度为len的字串替换为str2
str.replace(it1, it2, str2);//把str的迭代器[it1,it2)范围的子串替换为str2
map的常见用法详解
#include
map的定义
map
map容器内元素的访问
map
map常用函数实例解析
mp.erase(it)//it为需要删除的元素的迭代器
mp.erase(key)//key为欲删除的映射的键
map的常见用途
queue的常见用法详解
queue的定义
#include
queue容器内元素的访问
queue常用函数实例解析
queue常见用途
priority_queue的常见用法详解
priority_queue的定义
#include
priority_queue容器内元素的访问
priority_queue常用函数实例解析
priority_queue内元素优先级的设置
priority_queue
priority_queue
struct fruit{
string name;
int price;
};
struct fruit{
string name;
int price;
friend bool operator < (fruit f1, fruit f2){
return f1.price < f2.price;
}
}
priority_queue
friend bool operator < (fruit f1, fruit f2){
return f1.price > f2.price;
}
struct cmp{
bool operator() (fruit f1, fruit f2){
return f1.price > f2.price;
}
}
priority_queue
(const fruit &f1, const fruit &f2)
priority_queue的常见用途
stack的常见用法详解
stack的定义
#include
stack容器内元素的访问
stack常用函数实例解析
stack的常见用途
pair的常见用法详解
#include
pair的定义
pair
pair中元素的访问
p = make_pair("haha",5);
cout << p.first << " " << p.second << endl;
pair常用函数实例解析
pair的常见用途
mp.insert(make_pair("heihei",5));
algorithm头文件下的常用函数
#include
max(),min(),abs()
swap()
reverse()
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));
fill()
fill(a, a+5, 233);//将a[0]到a[4]都赋为233
sort()
sort(vi.begin(), vi.end(), cmp);//对整个vector排序
lower_bound()和upper_bound()
提高篇(1)——数据结构专题(1)
栈的应用
队列的应用
链表处理
静态链表通用解题步骤
struct Node{
int address;//结点地址
typename data;//数据域
int next;//指针域
XXX;//结点的某个性质
}node[100010]
for(int i=0;i
int p = begin, count = 0;
while(p != -1){
XXX = 1;
count++;
p = node[p].next;
}
bool cmp(Node a,Node b){
if(a.XXX == -1 || b.XXX == -1){
return a.XXX>b.XXX;
}else{
//第二级排序
}
}
提高篇(2)——搜索专题
深度优先搜索(DFS)
//序列A中n个数选k个数使得和为x,最大平方和为maxSumSqu
int n, k, x, maxSumSqu = -1, A[maxn];
//temp存放临时方案,ans存放平方和最大的方案
vector
广度优先搜索(BFS)
void BFS(int s){
queue
#include
提高篇(3)——数据结构专题(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
二叉树的遍历
先序遍历
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
//当前先序序列区间为[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;//返回根结点地址
}
二叉树的静态实现
/*
* 根据题意可使用静态二叉树
*/
#include
树的遍历
树的静态写法
struct node {
int data;
//int layer; //记录层号
vector
树的先根遍历
void PreOrder(int root){
cout << Node[root].data << " "; //访问当前结点
for(int i=0; i
树的层序遍历
void LayerOrder(int root){
queue
从树的遍历看DFS和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
//寻找以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)
平衡二叉树的定义
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;
}
平衡二叉树的基本操作
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);
}
}
}
}
并查集
并查集的定义
int father[N]; //存放父亲结点
int isRoot[N] = { 0 }; //记录每个结点是否作为某个集合的根结点
并查集的基本操作
void init(int n) {
for (int i = 1; i <= n; i++) {
father[i] = i;
}
}
//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]);
}
void Union(int a,int b){
int faA = findFather(a);
int faB = findFather(b);
if(faA != faB){
father[faA] = faB;
}
}
路径压缩
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
}
}
堆
堆的定义与基本操作
const int maxn = 100;
int heap[maxn], n = 10;
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);
}
}
哈夫曼树
哈夫曼树
#include
哈夫曼编码
提高篇(4)——图算法专题
图的定义和相关术语
图的存储
邻接矩阵
G[N][N]
的两维分别表示图的顶点标号。G[i][j]
为1,则说明顶点i和顶点j之间有边;如果为0则说明它们之间不存在边。这个二维数组被称为邻接矩阵。另外如果存在边权,可以令邻接矩阵存放边权,对不存在的边可以设边权为0,-1,或很大的一个数邻接表
vector
struct Node{
int v; //终点编号
int w; //边权
};
vector
struct Node{
int v; //终点编号
int w; //边权
Node(int _v, int _w): v(_v), w(_w) {};
}
Adj[1].push_back(Node(3,4));
图的遍历
采用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
#include
采用BFS遍历图
struct Node{
int v; //顶点编号
int layer; //顶点层号
}
vector
#include
最短路径
Dijkstra算法(迪杰斯特拉算法)
struct Node{
int v,dis; //v为边的目标顶点,dis为边权
};
vector
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
void DFS(int s, int v){ //s为起点编号,v为当前访问的顶点编号(从终点开始递归)
if(v == s){ //如果当前已经到达起点s,则输出起点并返回
cout<
#include
vector
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();
}
//边权之和
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]; //增加点权
}
//单使用Dijkstra算法
#include
//使用Dijkstra+DFS
#include
Bellman-Ford算法(贝尔曼-福特算法)
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的所有值都已经达到最优
}
#include
SPFA算法
vector
Floyd算法
#include
dis[u][v]
有了优化之后,前面访问的dis[i][j]
会因为已经被访问而无法获得进一步优化最小生成树
最小生成树及其性质
prim算法(普里姆算法)
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
#include
kruskal算法(克鲁斯卡尔算法)
#include
拓扑排序
有向无环图
拓扑排序
vector
关键路径
AOV网和AOE网
最长路径
关键路径
//拓扑序列
stack
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;
}
}
}
//关键路径,不是有向无环图返回-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]; //返回关键路径长度
}
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)的结果
}
}
动态规划的递推写法
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
最大连续子序列和
dp[i]=max(a[i],dp[i-1]+a[i])
,边界显然为dp[0] = a[0]#include
最长不下降子序列(LIS)
在一个数字序列中,找到一个最长的子序列(可以不连续),使得这个子序列是非递减的
dp[i] = max(1,dp[j] + 1);
#include
最长公共子序列(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;
#include
最长回文子串
给出一个字符串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]);
dp[i][j]
,会无法保证dp[i+1][j-1]
已经被计算过,从而无法得到正确的dp[i][j]
,根据递推写法从边界出发的原理,注意到边界表示的是长度为1和2的子串,且每次转移时都对子串的长度减了1,因此不妨考虑按子串的长度和子串的初始位置进行枚举,即第一遍将长度为3的子串的dp值全部求出,第二遍通过第一遍结果计算出长度为4的子串的dp值#include
DAG最长路
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);
}
}
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]
}
背包问题
多阶段动态规划问题
01背包问题
//令dp[i][v]表示前i个物品恰好装入容量为v的背包所能获得的最大价值
//状态转移方程
dp[i][v] = max(dp[i-1][v], dp[i-1][v-w[i]] + c[i]);
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]);
}
}
注意到状态转移方程中计算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]);
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]);
}
}
#include
dp[i][0]~dp[i][V]
,它们均由上一个阶段的状态得到。事实上,对能够划分阶段的问题来说,都可以尝试把阶段作为状态的一维,这可以使我们更方便地得到满足后无效性的状态完全背包问题
有n件物品,每种物品的单件重量为w[i],价值为c[i],现有一个容量为V的背包,问如何选取物品进入背包,使得背包内的物品总价值最大,其中每种物品都有无穷件
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]);
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]
作为末尾的连续序列的最大和dp[i]
表示以A[i]
结尾的最长不下降子序列长度dp[i][j]
表示字符串A的i号位和字符串B的j号位之前的LCS长度dp[i][j]
表示S[i]至S[j]所表示的子串是否是回文子串dp[i][j]
表示从第i行第j个数字出发的到达最底层的所有路径上所能获得的最大和dp[i]
表示从i号顶点出发能获得的最长路径长度dp[i][v]
表示前i个物品恰好装入容量为v的背包所能获得的最大价值dp[i][v]
表示前i个物品恰好装入容量为v的背包所能获得的最大价值
dp[i]
表示以A[i]
结尾(或开头)的XXXdp[i][j]
表示A[i]至A[j]区间的XXX
提高篇(6)——字符串专题
字符串hash进阶
H[i] = H[i-1]*26 + index(str[i]);
H[i] = (H[i-1]*26 + index(str[i])) % mod;
H[i] = (H[i-1]*p + index(str[i])) % mod;
#include
#include
最长回文子串
#include
KMP算法
next数组
//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算法
//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还没完全匹配成功
}
//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; //返回成功匹配次数
}
//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算法
专题扩展
分块思想
从小到大枚举块号,利用block数组累加得到前i-1块中存在的元素总个数,然后判断加入i号块的元素个数后元素总个数能否达到K,如果能,则说明第K大的数就在当前枚举的这个块中,此时只需从小到大遍历该块中的每个元素,利用table数组继续累加元素的存在个数,直到总累计数达到K,则说明找到了序列第K大的数
树状数组(BIT)
lowbit运算
lowbit(x) = x & (-x)
树状数组及其应用
问题:给出一个整数序列A,元素个数为N,接下来查询K次,每次查询将给出一个正整数x,求前x个整数之和
升级后的问题:假设在查询的过程中可能随时给第x个整数加上一个整数v,要求在查询中能实时输出前x整数之和(更新操作和查询操作的次数总和为K次)
由
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函数返回前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;
}
i = i-lowbit(i)
事实上是不断把i的二进制中最右边的1置为0的过程。所以getSum函数的时间复杂度为O(logN)
//update函数将第x个整数加上v
void update(int x, int v){
for(int i = x; i <= N; i += lowbit(i)){ //注意i必须能取到N
c[i] += v;
}
}
#include
hash[A[i]-1] + ... + hash[N]
即getSum(N) - getSum(A[i])
#include
//求序列元素第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; //返回二分夹出 的元素
}
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个整数的值
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
}
}
> 【PAT A1057】Stack
### 树状数组(BIT)
#### lowbit运算
整数在计算机中一般采用的是补码存储,并且把一个补码表示的整数x变成其相反数-x的过程相当于把x的二进制的每一位都取反,然后末位加1。**而这等价于直接把x的二进制最右边的1左边的每一位都取反**。因此很容易推得**lowbit运算就是取x的二进制最右边的1和它右边所有0**,因此它一定是2的幂次
#### 树状数组及其应用
对这个问题,一般的做法是开一个sum数组,其中sum[i]表示前i个整数之和,(**数组下标从1开始**),这样sum数组就可以在输入N个整数时就预处理出来。接着每次查询前x个整数之和时,输出sum[x]即可
树状数组(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函数
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超过给定的数据范围为止
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
using namespace std;
#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;
}
}
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;
}
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
using namespace std;
#define lowbit(i) ((i) & (-i))
struct Node {
int val; //序列元素的值
int pos; //原始序号
}temp[maxn]; //temp数组临时存放输入数据
int c[maxn]; //树状数组
int A[maxn]; //离散化后的原始数组
void update(int x, int v) {
for (int i = x; i <= N; i += lowbit(i)) { //注意i必须能取到N
c[i] += v;
}
}
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;
}
bool cmp(Node a, Node b) {
return a.val < b.val;
}
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;
一般来说离散化只适用于离线查询,但对在线查询也可以先把所有操作都记录下来,然后对其中出现的数据进行离散化,之后再按照记录下来的操作顺序正常进行操作即可。
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循环改为对应的重数即可
如果要求子矩阵,只需计算
代码如下:
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;
}
}
}
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]表示这段区间中每个数当前被加了多少
int getSum(int x){
int sum = 0; //记录和
for(int i = x; i <= N; i += lowbit(i)){ //沿着i增大的路径
sum += c[i];//累计c[i]
}
return sum; //返回和
}
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)