时空复杂度分析
一般ACM或者笔试题的时间限制是1秒或2秒。在这种情况下,C++代码中的操作次数控制在107~108为最佳。下面给出在不同数据范围下,代码的时间复杂度和算法该如何选择:
- n ≤ 30,指数级别,dfs+剪枝,状态压缩dp
- n ≤ 100 => O(n3),floyd,dp,高斯消元
- n ≤ 1000 =>O(n2),O(n2logn),dp,二分,朴素版Dijkstra、朴素版Prim、Bellman-Ford
- n ≤ 10000 => o(n * \(\sqrt{n}\)),块状链表、分块、莫队
- n ≤ 100000 => O(nlogn) => 各种sort,线段树、树状数组、set/map、heap、拓扑排序、dijkstra+heap、prim+heap、Kruskal、spfa、求凸包、求半平面交、二分、CDQ分治、整体二分、后缀数组、树链剖分、动态树
- n ≤ 1000000 =>O(n),以及常数较小的O(nlogn)算法 => 单调队列、hash、双指针扫描、并查集,kmp、AC自动机,常数比较小的O(nlogn)的做法: sort、树状数组、heap、dijkstra、spfa
- n ≤ 10000000 => o(n),双指针扫描、kmp、AC自动机、线性筛素数
- n ≤ 109=> o(\(\sqrt{n}\)),判断质数
- n ≤ 1018 => o(logn),最大公约数,快速幂,数位DP
- n ≤ 101000 => o((logn)2),高精度加减乘除
- n ≤ 10100000 => o(logk x loglogk),k表示位数,高精度加减、FFT/NTT
基础算法-模板
排序
//快排
void quick_sort(int q[], int l, int r){
if (l >= r) return;
int i = l - 1, j = r + 1, x = q[l + r >> 1];
while (i < j){
do i ++; while (q[i] < x);
do j --; while (q[j] > x);
if (i < j) swap(q[i], q[j]);
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
//归排
void merge_sort(int q[], int l, int r){
if (l >= r) return;
int mid = l + r >> 1;
merge_sort(q, l, mid);
merge_sort(q, mid + 1, r);
int k = 0, i = l, j = mid + 1;
while (i <= mid && j <= r)
if (q[i] <= q[j]) tmp[k ++] = q[i ++];
else tmp[k ++] = q[j ++];
while(i <= mid) tmp[k ++] = q[i ++];
while(j <= r) tmp[k ++] = q[j ++];
for (i = l, j = 0; i <= r; i ++, j ++) q[i] = tmp[j];
}
二分
// 整数二分
bool check(int x){} // 查找x是否满足某种性质
// 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:符合条件的第一个位置
int bsearch_1(int l, int r){
while (l < r){
int mid = l + r >> 1;
if (check(mid)) r = mid; // check(mid) 判断 [l,mid] 这个区间是否满足条件
else l = mid + 1;
}
return l;
}
// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:符合条件的最后一个位置
int bsearch_2(int l, int r){
while (l < r){
int mid = l + r + 1 >> 1; // + 1 的原因是 l + r >> 1 有可能 == l , l = mid 这条就会导致死循环
if (check(mid)) l = mid; // check(mid) 判断 [mid,r] 这个区间是否满足条件
else r = mid - 1;
}
return l;
}
// 浮点数二分
double bsearch_3(double l, double r){
const double eps = 1e-6; // 查找的精度
while (r - l > eps){
double mid = (l + r) / 2;
if (check(mid)) r = mid;
else l = mid;
}
return l;
}
高精度
// 加法
vector<int> add_plus(vector<int> &a, vector<int> &b){ //数组元素都是倒置
if (a.size() < b.size()) return add_plus(b,a);
vector<int> c;
int t = 0;
for (int i = 0; i < a.size(); i ++){
t += a[i];
if (i < b.size()) t += b[i];
c.push_back(t % 10);
t /= 10;
}
if (t) c.push_back(t);
return c;
}
//减法 C = A - B, 满足A >= B, A >= 0, B >= 0
vector<int> sub(vector<int> &a, vector<int> &b){
vector<int> c;
for (int i = 0, t = 0; i < a.size(); i ++ ){
t = a[i] - t;
if (i < b.size()) t -= b[i];
c.push_back((t + 10) % 10);
if (t < 0) t = 1;
else t = 0;
}
while (c.size() > 1 && c.back() == 0) c.pop_back();
return c;
}
//高精×低精 C = A * b, A >= 0, b >= 0
vector<int> mul(vector<int> &a, int b){
vector<int> c;
int t = 0;
for (int i = 0; i < a.size() || t; i ++ ){
if (i < a.size()) t += A[i] * b;
c.push_back(t % 10);
t /= 10;
}
while (c.size() > 1 && c.back() == 0) c.pop_back();
return c;
}
//高精÷低精 A / b = C ... r, A >= 0, b > 0
vector<int> div(vector<int> &a, int b, int &r){
vector<int> c;
r = 0;
for (int i = a.size() - 1; i >= 0; i -- ){
r = r * 10 + A[i];
c.push_back(r / b);
r %= b;
}
reverse(c.begin(), c.end());
while (c.size() > 1 && c.back() == 0) c.pop_back();
return c;
}
前缀和
//一维
s[i] = s[i - 1] + a[i];
[x1,x2]的和 sum = s[x2] - s[x1 - 1];
//二维
s[i][j] += s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1];
[(x1,y1),(x2,y2)]的和 sum = s[x2][y2] - s[x2][y1 - 1] - s[x1 - 1][y2] + s[x1 - 1][y1 - 1];
差分
//一维差分 思路: 存数按询问方式操作
给区间[l, r]中的每个数加上c: B[l] += c, B[r + 1] -= c
//二维差分
给以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵中的所有元素加上c:
S[x1, y1] += c, S[x2 + 1, y1] -= c, S[x1, y2 + 1] -= c, S[x2 + 1, y2 + 1] += c
位运算
① &: 与 ② |: 或 ③ ^: 异或同0异1 ④ ~: 取反 ⑤ <<: 左移 ⑥ >>: 右移
//求n的二进制的第k位数字:n >> k & 1; 19 10011
cout << (19 >> 4 & 1) << endl; //1
cout << (19 >> 3 & 1) << endl; //0
cout << (19 >> 2 & 1) << endl; //0
cout << (19 >> 1 & 1) << endl; //1
cout << (19 >> 0 & 1) << endl; //1
//求n的二进制的最后一位1的位置lowbit(n) = n&-n; 20 10100
cout << (20&-20) << endl;//4
双指针
for (int i = 0, j = 0; i < n; i ++ ){
while (j < i && check(i, j)) j ++ ;
// 具体问题的逻辑
}
常见问题分类:
(1) 对于一个序列,用两个指针维护一段区间
(2) 对于两个序列,维护某种次序,比如归并排序中合并两个有序序列的操作
离散化
vector<int> alls; // 存储所有待离散化的值
sort(alls.begin(), alls.end()); // 将所有值排序
alls.erase(unique(alls.begin(), alls.end()), alls.end()); // 去掉重复元素
// 二分求出x对应的离散化的值
int find(int x) // 找到第一个大于等于x的位置{
int l = 0, r = alls.size() - 1;
while (l < r){
int mid = l + r >> 1;
if (alls[mid] >= x) r = mid;
else l = mid + 1;
}
return r + 1; // 映射到1, 2, ...n
}
区间合并
void merge(vector<PII> &segs){
vector<PII> res;
sort(segs.begin(), segs.end());
int st = -2e9, ed = -2e9;
for (auto seg : segs){
if (ed < seg.first){
if (st != -2e9) res.push_back({st, ed});
st = seg.first, ed = seg.second;
}
else ed = max(ed, seg.second);
}
if (st != -2e9) res.push_back({st, ed});
segs = res;
}
数据结构-模板
单链表
// head存储链表头,e[]存储节点的值,ne[]存储节点的next指针,idx表示当前用到了哪个节点
int head, e[N], ne[N], idx;
// 初始化
void init(){
head = -1;
idx = 0;
}
// 在链表头插入一个数a
void insert(int a){
e[idx] = a, ne[idx] = head, head = idx ++ ;
}
// 在结点k后插入一个数x
void add(int k, int x){
e[idx] = x, ne[idx] = ne[k], ne[k] = idx ++ ;
}
// 将头结点删除,需要保证头结点存在
void remove(){
head = ne[head];
}
双链表
// e[]表示节点的值,l[]表示节点的左指针,r[]表示节点的右指针,idx表示当前用到了哪个节点
int e[N], l[N], r[N], idx;
// 初始化
void init(){
//0是左端点,1是右端点
r[0] = 1, l[1] = 0;
idx = 2;
}
// 在节点a的右边插入一个数x
void insert(int a, int x){
e[idx] = x;
l[idx] = a, r[idx] = r[a]; // 新节点连旧
l[r[a]] = idx, r[a] = idx ++; // 旧节点连新
}
// 删除节点a
void remove(int a){
l[r[a]] = l[a];
r[l[a]] = r[a];
}
// 遍历单链表
for (int i = h[k]; i != -1; i = ne[i])
x = e[i];
单调栈
//常见模型:找出每个数左边离它最近的比它大/小的数
int stk[N],tt = 0; // 栈中存数据或下标
for (int i = 1; i <= n; i ++){
int x; cin >> x;
while (tt && stk[tt] >= x) tt -- ; // 左边比它小的数
stk[ ++ tt] = i; // 把当前值放在合适地方
}
单调队列
//常见模型:找出滑动窗口中的最大值/最小值
int a[N],q[N]; // q[N] 存的是a数组的下标
int hh = 0, tt = -1; // hh 队头(左) tt 队尾(右)
for (int i = 0; i < n; i ++){
while (hh <= tt && check_out(q[hh])) hh ++ ; // 判断队头是否滑出窗口
while (hh <= tt && check(q[tt], i)) tt -- ; // 舍去不合理数据
q[ ++ tt] = i; // 把当前数据的坐标插入适合的地方
}
KMP
// s[1-m]是长文本,p[1-n]是模式串,m是s的长度,n是p的长度
// 求next
for (int i = 2, j = 0; i <= n; i ++){
while (j && p[i] != p[j + 1]) j = ne[j];
if (p[i] == p[j+1]) j ++;
ne[i] = j;
}
// 匹配
for (int i = 1, j = 0; i <= m; i ++){
while (j && s[i] != p[j + 1]) j = ne[j];
if (s[i] == p[j + 1]) j ++;
if (j == n) {
printf("%d ",i - n);
j = ne[j];
}
}
Tree树
int son[N][26], cnt[N], idx;
// 0号点既是根节点,又是空节点
// son[][]存储树中每个节点的子节点 【实质是多开*26空间记录每个节点的信息】【这个26是根据提目要求具体有所变化】
// cnt[]存储以每个节点结尾的单词数量
// idx 节点编号
// 插入一个字符串
void insert(char *str){
int p = 0;
for (int i = 0; str[i]; i ++ ){
int u = str[i] - 'a';
if (!son[p][u]) son[p][u] = ++ idx;// 该节点是否存过
p = son[p][u];
}
cnt[p] ++;
}
// 查询字符串出现的次数
int query(char *str){
int p = 0;
for (int i = 0; str[i]; i ++ ){
int u = str[i] - 'a';
if (!son[p][u]) return 0;
p = son[p][u];
}
return cnt[p];
}
并查集
(1)朴素并查集:
int p[N]; //存储每个点的祖宗节点
// 返回x的祖宗节点
int find(int x){
if (p[x] != x) p[x] = find(p[x]); // 路径压缩
return p[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ ) p[i] = i;
// 合并a和b所在的两个集合:
p[find(a)] = find(b);
(2)维护size的并查集:
int p[N], size[N];
//p[]存储每个点的祖宗节点, size[]只有祖宗节点的有意义,表示祖宗节点所在集合中的点的数量
// 返回x的祖宗节点
int find(int x){
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ ){
p[i] = i;
size[i] = 1;
}
// 合并a和b所在的两个集合:
size[find(b)] += size[find(a)];
p[find(a)] = find(b);
(3)维护到祖宗节点距离的并查集:
int p[N], d[N];
//p[]存储每个点的祖宗节点, d[x]存储x到p[x]的距离
// 返回x的祖宗节点
int find(int x){
if (p[x] != x){
int u = p[x]; // u记录旧的父节点
p[x] = find(p[x]); // 路径压缩,新父节点变成根节点了
d[x] += d[u]; // x到新父节点的距离等于x到旧父节点的距离加上旧父节点到根节点的距离
}
return p[x];
}
// 初始化,假定节点编号是1~n
for (int i = 1; i <= n; i ++ ){
p[i] = i;
d[i] = 0;
}
// 合并a和b所在的两个集合:
p[find(a)] = find(b);
d[find(a)] = distance; // 根据具体问题,初始化find(a)的偏移量
// 240. 食物链 ------ (3)维护到祖宗节点距离的并查集
#include <bits/stdc++.h>
using namespace std;
const int N = 50010;
int n, m;
int p[N], d[N];
int find(int x) {
if (p[x] != x) {
int u = p[x]; // u记录旧的父节点
p[x] = find(p[x]); // 路径压缩,新父节点变成根节点了
d[x] += d[u]; // x到新父节点的距离等于x到旧父节点的距离加上旧父节点到根节点的距离
}
return p[x];
}
int main(){
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; i ++ ) p[i] = i;
int res = 0;
while (m -- ){
int t, x, y;
scanf("%d%d%d", &t, &x, &y);
if (x > n || y > n) res ++ ;
else{
int px = find(x), py = find(y);
if (t == 1) { //x和y是同类
if (px == py && (d[x] - d[y]) % 3) res ++ ; //如果d[x]=d[y]说明距离相等
else if (px != py) { //更新
p[px] = py;
d[px] = d[y] - d[x]; //(d[x]+?-d[y])%3==0
}
}else { //x和y不是同类
if (px == py && (d[x] - d[y] - 1) % 3) res ++ ;
else if (px != py) {
p[px] = py;
d[px] = d[y] + 1 - d[x]; //(d[x]+?-d[y]-1)%3==0
}
}
}
}
printf("%d\n", res);
return 0;
}
堆
// h[N]存储堆中的值, h[1]是堆顶,x的左儿子是2x, 右儿子是2x + 1
// ph[k]存储第 k 个插入的点在堆中的位置
// hp[J]存储堆中下标为 J 的点是第几个插入的
int h[N], ph[N], hp[N], size;
// 交换两个点,及其映射关系
void heap_swap(int i, int j){ // 交换i节点和j节点(附带更新是第几个插入的节点)
swap(ph[hp[i]],ph[hp[j]]); //更新 i 和 j ph 信息
swap(hp[i], hp[j]); //更新 i 和 j hp 信息
swap(h[i], h[j]); //交换 i 和 j 数值
}
void down(int u){ // 向下更新
int t = u;
if (u * 2 <= size && h[u * 2] < h[t]) t = u * 2; // 左孩子
if (u * 2 + 1 <= size && h[u * 2 + 1] < h[t]) t = u * 2 + 1; // 右孩子
if (u != t){
heap_swap(u, t);
down(t); // 向下递归继续更新
}
}
void up(int u){ // 向上更新
while (u / 2 && h[u] < h[u / 2]){
heap_swap(u, u / 2);
u >>= 1;
}
}
// O(n)建堆
for (int i = n / 2; i; i -- ) down(i);
哈希表
(1) 拉链法
int h[N], e[N], ne[N], idx;
// 向哈希表中插入一个数
void insert(int x){
int k = (x % N + N) % N;
e[idx] = x;
ne[idx] = h[k];
h[k] = idx ++;
}
// 在哈希表中查询某个数是否存在
bool find(int x){
int k = (x % N + N) % N;
for (int i = h[k]; i != -1; i = ne[i])
if (e[i] == x)
return true;
return false;
}
(2) 开放寻址法
int h[N];
// 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
int find(int x){
int t = (x % N + N) % N; // N 一般取 大于数据范围的素数
while (h[t] != null && h[t] != x){
t ++ ;
if (t == N) t = 0;
}
return t;
}
字符串哈希
核心思想:将字符串看成P进制数,P的经验值是131或13331,取这两个值的冲突概率低
小技巧:取模的数用2^64,这样直接用unsigned long long存储,溢出的结果就是取模的结果
typedef unsigned long long ULL;
ULL h[N], p[N]; // h[k]存储字符串前k个字母的哈希值, p[k]存储 P^k mod 2^64
// 初始化
p[0] = 1;
for (int i = 1; i <= n; i ++ ){
h[i] = h[i - 1] * P + str[i];// 这个str[i]只要不是0就行任意值都行,因此不需要转成1-26
p[i] = p[i - 1] * P;
}
// 计算子串 str[l ~ r] 的哈希值
// 由于h数组的特殊定义,h数组前面都是哈希值的高位,所以l-r的哈希值可以通过
// 类似, l=123,r=123456,r-l哈希值等于123456-123000
ULL get(int l, int r){
return h[r] - h[l - 1] * p[r - l + 1];
}
STL
vector:变长数组,倍增的思想
vector<int> a(10),a(10,1); // 长度10,且初始化为1
vector<int> a[10]; // 10个vector
【size()返回元素个数】 【empty()返回是否为空】 【clear()清空】 【front()/back()】
【push_back()/pop_back()】 【begin()/end()】 【[数组]】 【支持比较运算,按字典序】
pair<int, int>
【first, 第一个元素】 【second, 第二个元素】
【支持比较运算,以first为第一关键字,以second为第二关键字(字典序)】
【p = make_pair(10,20); p = {10,20};】
string,字符串
【size()/length()返回字符串长度】 【empty()】 【clear()】
【substr(起始下标,(子串长度))返回子串】 【c_str()返回字符串所在字符数组的起始地址】
queue, 队列
【size()】 【empty()】 【push()向队尾插入一个元素】 【front()返回队头元素】
【back() 返回队尾元素】 【pop() 弹出队头元素】
priority_queue, 优先队列,默认是大根堆 【黑科技:插入负数就是小根堆】
【size()】 【empty()】 【push()插入一个元素】 【top()返回堆顶元素】
【pop()弹出堆顶元素】 【定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;】
stack, 栈
【size()】 【empty()】 【push()向栈顶插入一个元素】 【top()返回栈顶元素】 【pop()弹出栈顶元素】
deque, 双端队列
【size()】 【empty()】 【clear()】 【front()/back()】 【push_back()/pop_back()】
【push_front()/pop_front()】 【begin()/end()】 【[数组/随机访问]】
set, map, multiset, multimap, 基于平衡二叉树(红黑树),动态维护有序序列
【size()】 【empty()】 【clear()】 【begin()/end()】 【++,-- 返回前驱和后继,时间复杂度 O(logn)】
set(无重复)/multiset(可重复)
【insert() 插入一个数】 【find() 查找一个数】 【count() 返回某一个数的个数】
【erase()】
(1) 输入是一个数x,删除所有x O(k + logn)
(2) 输入一个迭代器,删除这个迭代器
lower_bound()/upper_bound()
【lower_bound(x) 返回大于等于x的最小的数的迭代器】
【upper_bound(x) 返回大于x的最小的数的迭代器】
map/multimap
【insert() 插入的数是一个pair】 【erase() 输入的参数是pair或者迭代器】 【find()】
【[下标索引] 注意multimap不支持此操作。 时间复杂度是 O(logn)】 【lower_bound()/upper_bound()】
unordered_set, unordered_map, unordered_multiset, unordered_multimap, 哈希表
和上面类似,增删改查的时间复杂度是 O(1)
不支持 lower_bound()/upper_bound(), 迭代器的++,--
bitset, 圧位
bitset<10000> s;
~, &, |, ^
>>, <<
==, !=
[]
count() 返回有多少个1
any() 判断是否至少有一个1
none() 判断是否全为0
set() 把所有位置成1
set(k, v) 将第k位变成v
reset() 把所有位变成0
flip() 等价于~
flip(k) 把第k位取反
搜索与图论-模板
树与图的存储
树是一种特殊的图,与图的存储方式相同。
对于无向图中的边ab,存储两条有向边a->b, b->a。
因此我们可以只考虑有向图的存储。
-
邻接矩阵: g[a][b]存储边a->b
-
邻接表:
// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点 int h[N], e[N], ne[N], idx; // 添加一条边a->b void add(int a, int b){ e[idx] = b, ne[idx] = h[a], h[a] = idx ++ ; } // 初始化 idx = 0; memset(h, -1, sizeof h);
树与图的遍历
时间复杂度O(n + m), n表示点数,m表示边数
-
深度优先搜索
int dfs(int u){ st[u] = true; // st[u] 表示点u已经被遍历过 for (int i = h[u]; i != -1; i = ne[i]){ int j = e[i]; if (!st[j]) dfs(j); } }
-
宽度优先搜索
queue<int> q; st[1] = true; // 表示1号点已经被遍历过 q.push(1); while (q.size()){ int t = q.front(); q.pop(); for (int i = h[t]; i != -1; i = ne[i]){ int j = e[i]; if (!st[j]){ st[j] = true; // 表示点j已经被遍历过 q.push(j); } } }
拓扑排序
时间复杂度 O(n+m),n表示点数,m表示边数
int q[N],d[N]; // q模拟队列,d记录入度
bool topsort(){
int hh = 0, tt = -1;
for (int i = 1; i <= n; i ++ )
if (!d[i])
q[ ++ tt] = i; // 度为0的点队尾入队
while (hh <= tt){
int t = q[hh ++ ]; // 队头出队
for (int i = h[t]; i != -1; i = ne[i]){
int j = e[i];
if (-- d[j] == 0) // 度为0的点入队
q[ ++ tt] = j;
}
}
// 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
return tt == n - 1; // 1 说明有n个节点入过队列
}
朴素dijkstra算法
时间复杂是O(n2+m), n表示点数, m表示边数
int g[N][N]; // 存储每条边
int dist[N]; // 存储1号点到每个点的最短距离
bool st[N]; // 存储每个点的最短路是否已经确定
// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
for (int i = 0; i < n - 1; i ++ ){
int t = -1; // 在还未确定最短路的点中,寻找距离最小的点
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
// 用t更新其他点的距离
for (int j = 1; j <= n; j ++ )
dist[j] = min(dist[j], dist[t] + g[t][j]);
st[t] = true;
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
堆优化版dijkstra
时间复杂度O(mlogn), n表示点数, m表示边数
typedef pair<int, int> PII;
int n; // 点的数量
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储所有点到1号点的距离
bool st[N]; // 存储每个点的最短距离是否已确定
// 求1号点到n号点的最短距离,如果不存在,则返回-1
int dijkstra(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0, 1}); // first存储距离,second存储节点编号
while (heap.size()){
auto t = heap.top();
heap.pop();
int ver = t.second, distance = t.first;
if (st[ver]) continue;
st[ver] = true;
for (int i = h[ver]; i != -1; i = ne[i]){
int j = e[i];
if (dist[j] > distance + w[i]){
dist[j] = distance + w[i];
heap.push({dist[j], j});
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
Bellman-ford算法
时间复杂度O(nm), n表示点数, m表示边数
int n, m; // n表示点数,m表示边数
int dist[N]; // dist[x]存储1到x的最短路距离
struct Edge{ // 边,a表示出点,b表示入点,w表示边的权重
int a, b, w;
}edges[M];
// 求1到n的最短路距离,如果无法从1走到n,则返回-1。
int bellman_ford(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
// 如果第n次迭代仍然会松弛三角不等式,就说明存在一条长度是n+1的最短路径,由抽屉原理,路径中至少存在两个相同的点,说明图中存在负权回路。
for (int i = 0; i < n; i ++ ){
for (int j = 0; j < m; j ++ ){
int a = edges[j].a, b = edges[j].b, w = edges[j].w;
if (dist[b] > dist[a] + w)
dist[b] = dist[a] + w;
}
}
if (dist[n] > 0x3f3f3f3f / 2) return -1;
return dist[n];
}
求边数限制的最短路算法 通过k次松弛,所求得的最短路,就是边数限制的最短路
const int N = 510, M = 10010;
struct Edge{
int a, b, c;
}edges[M];
int n, m, k;
int dist[N];
int last[N];
void bellman_ford(){
memset(dist, 0x3f, sizeof dist); // 初始化
dist[1] = 0;
for (int i = 0; i < k; i ++ ){
// 为了防止发生串联 如: 1→2→3,在一次循环里1更新2,2有就可能更新3,这是不允许的,所以保存初始dist数组
memcpy(last, dist, sizeof dist);
for (int j = 0; j < m; j ++ ){
auto e = edges[j];
dist[e.b] = min(dist[e.b], last[e.a] + e.c); // 松弛
}
}
}
int main(){
scanf("%d%d%d", &n, &m, &k);
for (int i = 0; i < m; i ++ ){
int a, b, c;
scanf("%d%d%d", &a, &b, &c);
edges[i] = {a, b, c};
}
bellman_ford();
//不可dist[n]==0x3f3f3f3f 因为有可能出现1到不了2,2到3为负数,所以大于无穷的一半就可以判定无法到达
if (dist[n] > 0x3f3f3f3f / 2) puts("impossible");
else printf("%d\n", dist[n]);
return 0;
}
spfa 算法
队列优化的Bellman-Ford算法: 时间复杂度平均情况下O(m),最坏情况下O(nm), n表示点数, m表示边数
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N]; // 存储每个点到1号点的最短距离
bool st[N]; // 存储每个点是否在队列中
// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
queue<int> q;
q.push(1);
st[1] = true; // st 数组记录哪些点在队列里
while (q.size()){
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i]){
int j = e[i];
if (dist[j] > dist[t] + w[i]){ // 松弛:对于队列中所有符合条件的边进行松弛
dist[j] = dist[t] + w[i];
if (!st[j]){ // 如果队列中已存在j,则不需要将j重复插入
q.push(j); // 只要是符合条件就进队列
st[j] = true;
}
}
}
}
if (dist[n] == 0x3f3f3f3f) return -1;
return dist[n];
}
spfa 求负环
int n; // 总点数
int h[N], w[N], e[N], ne[N], idx; // 邻接表存储所有边
int dist[N], cnt[N]; // dist[x]存储1号点到x的最短距离,cnt[x]存储1到x的最短路中经过的点数
bool st[N]; // 存储每个点是否在队列中
// 如果存在负环,则返回true,否则返回false。
bool spfa(){
// 不需要初始化dist数组,因为不用求具体数值,只需要矢量的比较就行
// 原理:如果某条最短路径上有n个点(除了自己),那么加上自己之后一共有n+1个点,由抽屉原理一定有两个点相同,所以存在环。
queue<int> q;
for (int i = 1; i <= n; i ++ ){ // 求整个图中的负环
q.push(i);
st[i] = true;
}
while (q.size()){
auto t = q.front();
q.pop();
st[t] = false;
for (int i = h[t]; i != -1; i = ne[i]){
int j = e[i];
if (dist[j] > dist[t] + w[i]){ // 松弛
dist[j] = dist[t] + w[i];
cnt[j] = cnt[t] + 1;
if (cnt[j] >= n) return true; // 如果从1号点到x的最短路中包含至少n个点(不包括自己),则说明存在环
if (!st[j]){
q.push(j);
st[j] = true;
}
}
}
}
return false;
}
Floyd算法
// 初始化:
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
if (i == j) d[i][j] = 0;
else d[i][j] = INF;
// 算法结束后,d[a][b]表示a到b的最短距离
void floyd(){
for (int k = 1; k <= n; k ++ )
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= n; j ++ )
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
// 输出结果
if(g[a][b] > INF/2)puts("impossible");
else printf("%d\n",g[a][b]);
朴素版Prim算法
时间复杂度是O(n2+m), n表示点数,m表示边数
用堆优化版prim,和用堆优化版Dijkstra差不多
int n; // n表示点数
int g[N][N]; // 邻接矩阵,存储所有边
int dist[N]; // 存储其他点到当前最小生成树的距离
bool st[N]; // 存储每个点是否已经在生成树中
// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim(){
memset(dist, 0x3f, sizeof dist);
dist[1] = 0;
int res = 0;
for (int i = 0; i < n; i ++ ){
int t = -1;
for (int j = 1; j <= n; j ++ )
if (!st[j] && (t == -1 || dist[t] > dist[j]))
t = j;
if (dist[t] == INF) return INF;
res += dist[t];
st[t] = true;
// 放在下面,是因为数据中有自环,容易造成误算
for (int j = 1; j <= n; j ++ ) dist[j] = min(dist[j], g[t][j]); // 把t所连且距离更短的放入集合
}
return res;
}
堆优化版Prim
不用刻意优化
const int N = 510, INF = 0x3f3f3f3f;
int n, m;
int g[N][N];
bool st[N];
int prim(){
int res = 0, cnt = 0;
priority_queue<PII, vector<PII>, greater<PII>> heap;
heap.push({0,1});
while (heap.size()){
auto t = heap.top();
heap.pop();
if(st[t.second]) continue;
st[t.second] = true;
res += t.first;
cnt ++;
for (int i = 1; i <= n; i ++){
if (!st[i] && g[t.second][i] != INF){
heap.push({g[t.second][i], i});
}
}
}
if(cnt != n)return INF;
return res;
}
Kruskal算法
int n, m; // n是点数,m是边数
int p[N]; // 并查集的父节点数组
struct Edge{ // 存储边
int a, b, w;
bool operator< (const Edge &W)const{
return w < W.w;
}
}edges[M];
int find(int x){ // 并查集核心操作
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int kruskal(){
sort(edges, edges + m);
for (int i = 1; i <= n; i ++ ) p[i] = i; // 初始化并查集
int res = 0, cnt = 0;
for (int i = 0; i < m; i ++ ){
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a), b = find(b);
if (a != b){ // 如果两个连通块不连通,则将这两个连通块合并
p[a] = b;
res += w;
cnt ++ ;
}
}
if (cnt < n - 1) return INF;
return res;
}
二分图-染色判定法
- 二分图定义: 图中不存在奇数环;或图可被分为两部分,两部分内部不存在边,只在中间存在边。
- 时间复杂度是O(n + m), n表示点数,m表示边数
int n; // n表示点数
int h[N], e[M], ne[M], idx; // 邻接表存储图
int color[N]; // 表示每个点的颜色,0表示未染色,1表示白色,2表示黑色
// 参数:u表示当前节点,c表示当前点的颜色
bool dfs(int u, int c){
color[u] = c;
for (int i = h[u]; i != -1; i = ne[i]){
int j = e[i];
if (!color[j] && !dfs(j, 3 - c)) return false;
else if (color[u] == color[j]) return false;
}
return true;
}
bool check(){
bool flag = true;
for (int i = 1; i <= n; i ++ )
if (!color[i] && !dfs(i, 0)){
flag = false;
break;
}
return flag;
}
二分图-匈牙利算法
- 匈牙利算法为了解决二分图两部分的节点的最大匹配数。
- 匈牙利算法: 二分图的两部分,一方男同志,一方女同志,两方匹配,一方按顺序匹配,有心仪的女生(即有边),即匹配成功,到某个男生匹配时,发现心仪的女生已经匹配了,那么这个男生就要女生问问她的配偶是否有备胎,递归去问备胎是否单身....。若备胎也没匹配,那么她男朋友和他备胎在一起,直到所有有联系的人都问完。---给人找到下家,才去挖墙脚。(做错不重要,重要的是错过)
- 时间复杂度是O(nm), n表示点数,m表示边数
int n1, n2; // n1表示第一个集合中的点数,n2表示第二个集合中的点数
int h[N], e[M], ne[M], idx; // 邻接表存储所有边,匈牙利算法中只会用到从第一个集合指向第二个集合的边,所以这里只用存一个方向的边
bool st[N]; // 男生匹配每个女生只尝试一次
int match[N]; // 该女生匹配了哪个男生
bool find(int x){
for (int i = h[x]; i != -1; i = ne[i]){
int j = e[i];
if (!st[j]){
st[j] = true; // 只尝试一次
if (match[j] == 0 || find(match[j])){ // 没匹配或者对象有备胎
match[j] = x; // 匹配成功
return true;
}
}
}
return false;
}
// 求最大匹配数,依次枚举第一个集合中的每个点能否匹配第二个集合中的点
int res = 0;
for (int i = 1; i <= n1; i ++ ){
memset(st, false, sizeof st); // 每个都尝试
if (find(i)) res ++ ;
}
数学知识-模板
试除法判定质数
时间复杂度大O(sqrt(n))
bool is_prime(int x){
if (x < 2) return false;
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0)
return false;
return true;}
试除法分解质因数
时间复杂度O(log(n) - sqrt(n))
void divide(int x){
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0){ // i 一定是质数,因为合数在前面已经除完了
int s = 0;
while (x % i == 0) x /= i, s ++ ;
cout << i << ' ' << s << endl;
}
if (x > 1) cout << x << ' ' << 1 << endl;// 一个数最多有一个大于sqrt(n)的质因子,因为若是有两个那么乘积就大于n了
cout << endl;
}
朴素筛法求素数
时间复杂度O(nlog(log(n)))近似O(n)
int primes[N], cnt; // primes[]存储所有素数
bool st[N]; // st[x]存储x是否被筛掉
void get_primes(int n){
for (int i = 2; i <= n; i ++ ){
if (st[i]) continue;
primes[cnt ++ ] = i;
for (int j = i + i; j <= n; j += i) // 只需要以素数筛就可以,因为前面的素数会将后面的合数筛掉
st[j] = true;
}
}
线性筛法求素数
原理: n只会被它的最小质因子筛掉 时间复杂度 O(n)
int primes[N], cnt; // primes[]存储所有素数
bool st[N]; // st[x]存储x是否被筛掉
void get_primes(int n){
for (int i = 2; i <= n; i ++ ){
if (!st[i]) primes[cnt ++ ] = i;
// 用已经筛出的素数去筛,保证了每次都是最小质因子筛掉合数
/*
primes[j]<=n/i是合理的, 因为j永远小于i, 即primes已存的素数都是小于i的
1、当 i 是合数, 那么一定在下面的break出去, 因为一定有最小质因子。
2、当 i 是质数, 如果不从primes[j] <= n / i退出,那一定在下面break退出(因为总会j++到primes[j] == i时)
*/
for (int j = 0; primes[j] <= n / i; j ++ ){
st[primes[j] * i] = true; //在下面注释
if (i % primes[j] == 0) break; // 只能被自己最小质因子筛掉
}
}
}
注释:
- 若i % primes[j] == 0 那么primes[j] 一定是 i 的最小质因子,此时i可以直接被筛掉,且primes[j] * i 的最小质因子也是primes[j]。
- 若i % primes[j] != 0 说明前面筛出的素数都不是i最小质因子,但primes[j] * i 的最小质因子也是 primes[j]。
- 总之,primes[j] * i 的最小质因子始终是 primes[j] 对应代码 st[primes[j] * i] = true;
试除法求所有约数
时间复杂度为O(logn)
vector<int> get_divisors(int x){
vector<int> res;
for (int i = 1; i <= x / i; i ++ )
if (x % i == 0){
res.push_back(i);
if (i != x / i) res.push_back(x / i);
}
sort(res.begin(), res.end());
return res;
}
约数个数和约数之和
如果 N = p1^c1 * p2^c2 * ... *pk^ck // p为质因子
约数个数:(c1 + 1) * (c2 + 1) * ... * (ck + 1) // 组合数
// 按照组合数选数, 展开的每一项就是约数, 总和就是约数之和
约数之和:(p1^0 + p1^1 + ... + p1^c1) * ... * (pk^0 + pk^1 + ... + pk^ck)
const int N = 110;
const int mod = 1e9+7;
int main(){
int n; cin >> n;
unordered_map<int, int> primes;
while (n --){
int x; cin >> x;
for (int i = 2; i <= x / i; i ++){
while (x % i == 0){
primes[i] ++;
x /= i;
}
}
if (x > 1) primes[x] ++;
}
LL res = 1;
// 约数个数
for (auto prime:primes)res = res * (prime.second + 1) % mod;
cout << res << endl;
res = 1;
// 约数之和
for (auto prime:primes){
int p = prime.first,k = prime.second;
LL t = 1;
while (k --) t = (t * p + 1) % mod; // 这里要取模所以用等比数列前n项和不合适
res = res * t % mod;
}
cout << res << endl;
return 0;
}
欧几里得算法
假设d为任意两个数的最大公约数
-
定理: 若d|a 和 d|b, 即d|ax + by
|: 整除的意思 ↔ d|a a整除b
裴蜀定理: 对于任意正整数a, b, 一定存在非零整数x, y, 使得ax + by = (a, b) 即a, b组合的最小的正整数为a和b的最大公约数。 -
推理: a mod b = a - a / b * b = a - c * b
令c = a / b
-
论证: (b, a % b) == (b, a - c * b) 由(1)得 d|(a - c * b) 和 d|b 得d|(a- c * b + c * b),即d|a,
所以(b, a % b) == (a, b)(a,b)即a和b最大公约数
- 结论: (a, b) == (a, a % b)
int gcd(int a, int b){ // (a,b) == (a, a % b) 递归下去, 即求最大公约数 递归结束条件 b == 0
return b ? gcd(b, a % b) : a;// b不等于0, 返回gcd(b, a % b), 否者返回a, 因为a和0的最大公约数为a
}
欧拉函数
-
极性函数证明
-
容斥原理证明
代码如下
int phi(int x){
int res = x;
for (int i = 2; i <= x / i; i ++ )
if (x % i == 0){
res = res / i * (i - 1); // res / i * (i - 1) == res * (1 - 1 / i);
while (x % i == 0) x /= i;
}
if (x > 1) res = res / x * (x - 1);
return res;
}
筛法求欧拉筛
int primes[N], cnt; // primes[]存储所有素数
int euler[N]; // 存储每个数的欧拉函数
bool st[N]; // st[x]存储x是否被筛掉
void get_eulers(int n){
euler[1] = 1; // 定义的
for (int i = 2; i <= n; i ++ ){
if (!st[i]){
primes[cnt ++ ] = i;
euler[i] = i - 1; // 质数的欧拉值为 i - 1
}
for (int j = 0; primes[j] <= n / i; j ++ ){
int t = primes[j] * i;
st[t] = true;
if (i % primes[j] == 0){ // primes[j] 是 i的最小质因子
/*
phi[i] = i*(1-1/p1)*(1-1/p2)*...*(1-1/pk),且primes[j]是i的质因子,
所以phi[t] = primes[j]*i*(1-1/p1)*(1-1/p2)*...*(1-1/pk) = primes[j]*phi[i]
*/
euler[t] = euler[i] * primes[j];
break;
}
/*
解释一:
i 不能整除 primes[j], 那么 i 就和 primes[j] 互质, 根据积性函数得 φ(t) = φ(i) * φ(primes[j])
解释二:
i 不能整除 primes[j], 但是primes[j]仍是t的最小质因子, 因此不仅需要将基数N修正为primes[j]倍, 还需要 补上1 - 1 / primes[j]这一项, 因此最终结果phi[i] * (primes[j] - 1)
*/
euler[t] = euler[i] * (primes[j] - 1);
}
}
}
快速幂
求 m^k mod p,时间复杂度 O(log(k))
原理: 预处理m的1,2,4,8,16....次方,进行k的二进制规律进行组合相乘
int qmi(int m, int k, int p){
int res = 1 % p, t = m;
while (k){ // k次, k转成二进制
if (k&1) res = res * t % p; // 每次看末位是否为1,为1则进行累乘
t = t * t % p;
k >>= 1;
}
return res;
}
快速幂求逆元(p质数)
≡ : 同余
a / b ≡ a * x (mod p)
两边同乘b可得 a ≡ a * b * x (mod p)
即 1 ≡ b * x (mod p)
同 b * x ≡ 1 (mod p)
由费马小定理可知,当p为质数时
b(p-1) ≡ 1 (mod p)
拆一个b出来可得 b * b(p-2) ≡ 1 (mod p)
故当n为质数时,b的乘法逆元 x = b(p-2)
LL qmi(int m, int k, int p){
LL res = 1 % p, t = m;
while (k){
if (k&1) res = res * t % p;
t = t * t % p;
k >>= 1;
}
return res;
}
int main(){
int n;
scanf("%d", &n);
while (n -- ){
int a, p;
scanf("%d%d", &a, &p);
if (a % p == 0) puts("impossible"); // 质数只和自己的倍数不互质
else printf("%lld\n", qmi(a, p - 2, p));
}
return 0;
}
扩展欧几里得算法
证明1:
写法一
int exgcd(int a, int b, int &x, int &y){//返回gcd(a,b) 并求出解(引用带回)
if(b==0){
x = 1, y = 0;
return a;
}
int x1,y1,gcd;
gcd = exgcd(b, a%b, x1, y1);
x = y1, y = x1 - a/b*y1; // 递归回溯回时记录答案
return gcd;
}
写法二
// 求x, y,使得ax + by = gcd(a, b)
int exgcd(int a, int b, int &x, int &y){
if (!b){
x = 1; y = 0; // 当 b = 0时, a和b的最大公约数为 a, 系数为 x = 1, y = 0;
return a;
}
int d = exgcd(b, a % b, y, x);
y -= (a / b) * x; // y = y' - a/b * x' y'和x'都是回溯上层的结果
return d;
}
线性同余方程
求同余方程ax ≡ b(mod m)的系数 x
推理: ax ≡ b(mod m)↔(ax % m = b % m),知存在yk使得 ax = myk + b,得ax - myk = b,令 y = -yk,即 ax + my = b。ax + my = b有解的必要条件是gcd(a, m)|b。设求出ax0 + my0 = gcd(a,m) ,即得 x = b / gcd(a,m) * x0 = b * x0 / gcd(a, m)
while (n -- ){
int a, b, m;
scanf("%d%d%d", &a, &b, &m);
int x, y;
int d = exgcd(a, m, x, y);
if (b % d) puts("impossible"); // 说明b不能整除gcd(a, m)
else printf("%d\n", (LL)b * x / d % m); // 题目要求在int范围内,且(a*x)%m = (a*(x%m))%m, 所以最后需要%m
}
扩展欧几里得求逆元(p非质数)
求ax ≡ 1 (mod p)的x,根据线性同余方程等价求ax + py = 1的x
while (n--) {
cin >> a >> p;
if (exgcd(a, p, x, y) == 1) cout << (x + p) % p << endl;
else cout << "impossible" << endl;//如果 exgcd(a,p,x,y) != 1, 说明ax+py=1无解, 因为1只能整除1
}
高斯消元
// a[N][N]是增广矩阵
int gauss(){
int c, r;
for (c = 0, r = 0; c < n; c ++ ){
int t = r;
for (int i = r; i < n; i ++ )//找到绝对值最大的行,寻找最大的数值是因为可以避免系数变得太大,精度较高.
if (fabs(a[i][c]) > fabs(a[t][c]))
t = i;
if (fabs(a[t][c]) < eps) continue;
for (int i = c; i <= n; i ++ ) swap(a[t][i], a[r][i]); // 将绝对值最大的行换到最顶端 r
for (int i = n; i >= c; i -- ) a[r][i] /= a[r][c]; // 将当前行的首位变成 1
for (int i = r + 1; i < n; i ++ ) // 用当前行将下面所有的列消成 0
if (fabs(a[i][c]) > eps)
for (int j = n; j >= c; j -- )
a[i][j] -= a[r][j] * a[i][c];
r ++ ;
}
if (r < n){
for (int i = r; i < n; i ++ )
if (fabs(a[i][n]) > eps) // 最后一列有非零则无解
return 2; // 无解
return 1; // 有无穷多组解
}
for (int i = n - 1; i >= 0; i -- )
for (int j = i + 1; j < n; j ++ )
a[i][n] -= a[i][j] & a[j][n]; // 回解每个未知数
return 0; // 有唯一解
}
递推法求组合数
\(C_{m}^{n}\) = \(C_{m-1}^{n}\) + \(C_{m-1}^{n-1}\) : m个数选n个,可分为两种情况,某数x,① 确定选 x 再在m-1个中选n-1个,即\(C_{m-1}^{n-1}\)② 确定不选 x 再在m-1个中选n个\(C_{m-1}^{n}\)
数据范围: 10000次询问,1 <= b <= a <= 2000
// c[a][b] 表示从a个苹果中选b个的方案数
for (int i = 0; i < N; i ++ )
for (int j = 0; j <= i; j ++ )
if (!j) c[i][j] = 1; // c[i][0] = 1;
else c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % mod;
预处理逆元的方式求组合数
首先预处理出所有阶乘取模的余数fact[N],以及所有阶乘取模的逆元infact[N]
如果取模的数是质数,可以用费马小定理求逆元
数据范围: 10000次询问,1 <= b <= a <= 105
int qmi(int a, int k, int p){ // 快速幂模板
int res = 1;
while (k){
if (k & 1) res = (LL)res * a % p;
a = (LL)a * a % p;
k >>= 1;
}
return res;
}
// 预处理阶乘的余数和阶乘逆元的余数
fact[0] = infact[0] = 1;
for (int i = 1; i < N; i ++ ){
fact[i] = (LL)fact[i - 1] * i % mod;
infact[i] = (LL)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
Lucas定理求组合数
若p是质数,则对于任意整数 1 <= m <= n,有: \(C_{m}^{n}\) = \(C_{m\%p}^{n\%p}\) * \(C_{m/p}^{n/p}\) (mod p)
数据范围: 20次询问,1 <= b <= a <= 1018,1 <= p <= 105
int qmi(int a, int k, int p){ // 快速幂模板
int res = 1 % p;
while (k){
if (k & 1) res = (LL)res * a % p;
a = (LL)a * a % p;
k >>= 1;
}
return res;
}
int C(int a, int b, int p){ // 通过定理求组合数C(a, b)
if (a < b) return 0;
LL x = 1, y = 1; // x是分子,y是分母
for (int i = a, j = 1; j <= b; i --, j ++ ){
x = (LL)x * i % p;
y = (LL) y * j % p;
}
return x * (LL)qmi(y, p - 2, p) % p;
}
int lucas(LL a, LL b, int p){
if (a < p && b < p) return C(a, b, p);
return (LL)C(a % p, b % p, p) * lucas(a / p, b / p, p) % p;
}
分解质因数法求组合数
当我们需要求出组合数的真实值,而非对某个数的余数时,分解质因数的方式比较好用:
-
筛法求出范围内的所有质数
-
通过 C(a, b) = a! / b! / (a - b)! 这个公式求出每个质因子的次数。 \(\lfloor {n \over p} \rfloor\) + \(\lfloor {n \over p^{2}} \rfloor\) + \(\lfloor {n \over p^{3}} \rfloor\) + ... \(\lfloor {n \over p^{k}(p^{k}\leqslant n)} \rfloor\)
n*(n-1)*(n-2)*...2*1 中 p 的次数: p为质因子
- \({n \over p}\) 代表 1 - n 中 p倍数的数字个数 1p,2p,3p,...xp \({\leqslant}\) n 这个x=\({n \over p}\)
- \({n \over p^{2}}\) 代表1 - n/p 中 p倍数的数字个数1p,2p,...mp\({\leqslant}\) n/p 其中m=\({n \over p^{2}}\)
- .....
- \({n \over p^{k}}\) 代表1 - n/pk-1 中 p倍数的数字个数1p,2p,3p,....,kp\({\leqslant}\) n/pk-1 其中k=\({n \over p^{k}}\) (循环结束条件: pk+1 > n)
- 所以 n! 中p的次数是 \(\lfloor {n \over p} \rfloor\) + \(\lfloor {n \over p^{2}} \rfloor\) + \(\lfloor {n \over p^{3}} \rfloor\) + ... \(\lfloor {n \over p^{k}(p^{k}\leqslant n)} \rfloor\)
-
用高精度乘法将所有质因子相乘
// 线性筛求素数
int primes[N], cnt;
int sum[N];
bool st[N];
void get_primes(int n){
for (int i = 2; i <= n; i ++ ){
if (!st[i]) primes[cnt ++ ] = i;
for (int j = 0; primes[j] <= n / i; j ++ ){
st[primes[j] * i] = true;
if (i % primes[j] == 0) break;
}
}
}
// 求n!中的次数(核心代码)
int get(int n, int p){
int res = 0;
while (n){
res += n / p; // 累计一次p的数量
n /= p; // 增加一次方
}
return res;
}
// 高精度乘低精度模板
vector<int> mul(vector<int> a, int b){
vector<int> c;
int t = 0;
for (int i = 0; i < a.size(); i ++ ){
t += a[i] * b;
c.push_back(t % 10);
t /= 10;
}
while (t){
c.push_back(t % 10);
t /= 10;
}
return c;
}
/*************************************************************************/
get_primes(a); // 预处理范围内的所有质数
for (int i = 0; i < cnt; i ++ ){// 求每个质因数的次数
int p = primes[i];
sum[i] = get(a, p) - get(b, p) - get(a - b, p); // 分子的p次数 减去 分母p的次数
}
// 剩余的质因子相乘(高精度乘低精度)
vector<int> res;
res.push_back(1);
for (int i = 0; i < cnt; i ++ ) // 用高精度乘法将所有质因子相乘
for (int j = 0; j < sum[i]; j ++ )
res = mul(res, primes[i]);
卡特兰数(组合数)
给定n个0和n个1,它们按照某种顺序排成长度为2n的序列,满足任意前缀中0的个数都不少于1的个数的序列的数量为:Cat(n) = \(C_{2n}^{n} \over n + 1\)
将01序列置于坐标系中,起点定于原点。若0表示向右走,1表示向上走,那么任何前缀中0的个数不少于1的个数就转化为,路径上的任意一点,横坐标大于等于纵坐标。题目所求即为这样的合法路径数量。
下图中,表示从(0,0)走到(n, n)的路径,在绿线及以下表示合法,若触碰红线即不合法。
由图可知,任何一条不合法的路径(如黑色路径),都对应一条从(0,0)走到(n - 1,n + 1)的一条路径(如灰色路径)。而任何一条(0,0)走到(n - 1,n+1)的路径,也对应了一条从(0,0)走到(n,n)的不合法路径。
结论: 所有(0,0)到(n,n)且不经过红线的路线即为答案,所有经历红线并到达(n,n)的路线数 等价于 所有从(0,0)到(n-1,n+1)路线数,因为(0,0)到(n-1,n+1)一定经历红线。
证明: \(C_{2n}^{n}\) - \(C_{2n}^{n-1}\) = \(C_{2n}^{n} \over n + 1\)
\(C_{2n}^{n}\) - \(C_{2n}^{n-1}\) = \((2n)! \over n! n!\) - \((2n)! \over (n-1)!(n+1)!\) = \((2n)!(n+1) - (2n)!n\over (n+1)!n!\) = \((2n)! \over (n+1)!n!\) = \(1 \over n+1\)\((2n)!\over n!n!\) = \(C_{2n}^{n} \over n + 1\)
int a = n * 2, b = n;
int res = 1;
// 2n!/(n+1)!n! = 2n*(2n-1)*...*(2n-n+1)/(n+1)!
for (int i = a; i > a - b; i -- ) res = (LL)res * i % mod; // 2n*(2n-1)*...*(2n-n+1)
for (int i = 1; i <= b + 1; i ++ ) res = (LL)res * qmi(i, mod - 2, mod) % mod; // res*((n+1)!的逆元)
cout << res << endl;
容斥原理
应用: 能被整除的数
给定一个整数n和m个不同的质数p1,p2,... ,pm,请你求出1~n中能被p1,p2,...,pm中的至少一个数整除的整数有多少个。
解题思路:
实现思路:
// 二进制枚举
#include <iostream>
using namespace std;
typedef long long LL;
const int N = 20;
int p[N], n, m;
int main() {
cin >> n >> m;
for(int i = 0; i < m; i ++) cin >> p[i];
int res = 0;
//枚举从1 到 1111...(m个1)的每一个集合状态, (至少选中一个集合)
for(int i = 1; i < 1 << m; i ++) {
int t = 1; //选中集合对应质数的乘积
int s = 0; //选中的集合数量
//枚举当前状态的每一位
for(int j = 0; j < m; j ++){
//选中一个集合
if(i >> j & 1){
if((LL)t * p[j] > n){
t = -1;
break;//乘积大于n, 则n / t = 0, 跳出这轮循环
}
s++; //有一个1, 集合数量+1
t *= p[j];
}
}
if(t == -1) continue;
if(s & 1) res += n / t; //选中奇数个集合, 则系数应该是1, n/t为当前这种状态的集合数量
else res -= n / t; //反之则为 -1
}
cout << res << endl;
return 0;
}
博弈论-NIM游戏
经典NIM游戏
for(int i = 0; i < n; i++) {
int x;
scanf("%d", &x);
res ^= x;
}
if(res) puts("Yes");
else puts("No");
NIM游戏拓展
题目描述: 现在,有一个n级台阶的楼梯,每级台阶上都有若干个石子,其中第 i 级台阶上有 a 个石子(i ≥ 1)。两位玩家轮流操作,每次操作可以从任意一级台阶上拿若干个石子放到下一级台阶中(不能不拿)。已经拿到地面上的石子不能再拿,最后无法进行操作的人视为失败。问如果两人都采用最优策略,先手是否必胜。
最优策略:
- 把所有奇数台阶看做经典NIM游戏,若是所有奇数台阶异或和为0,则必败,否者先手将奇数台阶拿走若干石子到下一台阶(偶数台阶),把所有奇数台阶的异或和恢复为 0 。
- 将经典NIM游戏中的拿走某堆中若干,看做两种情况,① 拿奇数台阶到下一台阶(偶数台阶),就相当于NIM游戏中拿走某堆中若干 ② 拿偶数台阶到下一台阶(奇数台阶),那后手就将拿过去的都拿到下一台阶(偶数),那么奇数台阶又恢复异或为0的状态。
- 为什么不用偶数台阶计算?因为最后都落到0号台阶且不能再移动,0号台阶是偶数台阶。
int f = 0;
for (int i = 1,x; i <= n; i++){
cin >> x;
if(i%2)f^=x;
}
if (f)puts("Yes");
else puts("No");
博弈论-SG函数
例子: 若干堆石子,每一次只能拿2, 5个,其他规则和NIM游戏相同
SG函数过程:
结合代码重点理解Mex运算,以及SG函数如何利用Mex运算
应用一: 集合-Nim游戏
给定n堆石子以及一个由k个不同正整数构成的数字集合S。
现在有两位玩家轮流操作,每次操作可以从任意一堆石子中拿取石子,每次拿取的石子数量必须包含于集合S,最后无法进行操作的人视为失败。
问如果两人都采用最优策略,先手是否必胜。
#include <iostream>
#include <cstring>
#include <algorithm>
#include <unordered_set>
using namespace std;
const int N=110,M=10010;
int n,m;
int f[M],s[N];//s存储的是可供选择的集合,f存储的是所有可能出现过的情况的sg值
int sg(int x){
if(f[x] != -1) return f[x];// 如果此sg值出现过就不再重复计算
unordered_set<int> S; // set代表的是有序集合,记录所有子节点的sg值
for(int i = 0;i < m;i ++){
int sum = s[i];
if(x >= sum) S.insert(sg(x - sum));// 当x大于sum是才可以"拿"递归下去
}
/*************************************重点Mex运算***************************************
循环完之后可以进行选出最小的没有出现的自然数的操作,这里就保证了sg值可以像Nim游戏一样,
Nim游戏中可以拿任意数量,sg(x)节点可以走到小于它的任何节点,这是一个有向图
***************************************************************************************/
for(int i=0;;i++)
if(!S.count(i)) return f[x] = i;
}
int main(){
cin >> m;
for (int i = 0;i < m;i ++)
cin >> s[i];
cin >> n;
memset(f,-1,sizeof(f));//初始化f均为-1,方便在sg函数中查看x是否被记录过
int res = 0;
for (int i = 0;i < n; i++){
int x;
cin >> x;
res ^= sg(x);//观察异或值的变化,基本原理与Nim游戏相同
}
if(res) printf("Yes");
else printf("No");
return 0;
}
应用二: 拆分-Nim游戏
题目描述:
给定n堆石子,两位玩家轮流操作,每次操作可以拿走其中的一堆石子,然后重新放置两堆规模更小的石子(新堆规模可以为0,且两个新堆的石子总数可以大于取走的那堆石子数),最后无法进行操作的人视为失败。
问如果两人都采用最优策略,先手是否必胜。加黑解释: 新的两堆,不是以原来的石子分的,是重新放的两堆石子,只是要求这两堆每一堆都小于原来那堆的数量。
题目分析:
相比于集合-Nim,这里的每一堆可以变成小于原来那堆的任意大小的两堆,即ai可以拆分成(bi, bj),为了避免重复规定bi >= bj,
相当于一个局面拆分成了两个局面,由SG函数理论,多个独立局面的SG值,等于这些局面SG值的异或和。因此需要存储的状态就是sg(bi)^sg(bj) (与集合-Nim的唯一区别)
int f[N];
unordered_set<int> S;
/*****************************为什么可以把 S 定义成全局变量********************************
因为这个sg函数的特殊性, 求sg(100)时, 它会将1-100的所有sg(1)-sg(100)都计算出来,
当 x <= 100 的都会直接return f[x]; 当x > 100 的会因为sg是递归性质, 因此会按顺序求出sg(101),
sg(102),...,sg(x), 所以把S设置成全局变量更好.
**************************************************************************************/
int sg(int x){
if(f[x] != -1) return f[x];
for(int i = 0 ; i < x ; i++)
for(int j = 0 ; j <= i ; j++)//规定j不大于i,避免重复
//相当于一个局面拆分成了两个局面,由SG函数理论,多个独立局面的SG值,等于这些局面SG值的异或和
S.insert(sg(i) ^ sg(j));
for(int i = 0 ; ; i++)
if(!S.count(i))
return f[x] = i;
}
动态规划-模型
背包问题
01背包
每件物品只能选一次,在不超过体积 j 的前提下可以选择的最大价值
朴素版
int v[N],w[M];
int f[N][N]; // 在1-i中选出体积不超过j的最大价值
for (int i = 1; i <= n; i ++){
for (int j = 0; j <= m; j ++){
f[i][j] = f[i - 1][j]; // 不选第i个物品: 只从1-i-1中选, 且体积不超过j
if (j > v[i]) f[i][j] = max(f[i][j],f[i - 1][j - v[i]] + w[i]); // 选第i个物品: f[i - 1][j - v[i]] + w[i]
}
}
cout << f[n][m] << endl;
优化版
int v[N],w[M];
int f[N];
for (int i = 1; i <= n; i ++){
for (int j = m; j >= v[i]; j --) // 倒着循环保证f[j-v[i]]是上一轮的数据没有被覆盖
/*
1. 本轮没选第i个物品 f[i - 1][j] == f[j]
2. 本轮选第i个物品 f[i - 1][j - v[i]] + w[i] == f[j - v[i]] + w[i]
3. 两者取max
*/
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
cout << f[m] << endl;
完全背包
每件物品可以被选无数个,在不超过体积 j 的前提下可以选择的最大价值
朴素版
int v[N],w[M];
int f[N][N]; // 在1-i中选出体积不超过j的最大价值
for (int i = 1; i <= n; i ++)
for (int j = 0; j <= m; j ++)
for (int k = 0; k * v[i] <= j; k ++)
f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);
cout << f[n][m] << endl;
优化版1
int v[N],w[M];
int f[N][N]; // 在1-i中选出体积不超过j的最大价值
for (int i = 1; i <= n; i ++)
for (int j = 0; j <= m; j ++){
/*
f[i][j] = max{f[i-1][j],f[i-1][j-v]+w,f[i-1][j-2*v]+2*w,f[i-1][j-3*v]+3*w,...}
f[i][j-v] = max{ f[i-1][j-v] ,f[i-1][j-2*v]+ w,f[i-1][j-3*v]+2*w,...}
所以 f[i][j] = max{f[i-1][j],f[i][j-v]+w}
*/
f[i][j] = f[i - 1][j];
if (j >= v[i]) f[i][j] = max(f[i][j], f[i][j - v[i]] + w[i]);
}
cout << f[n][m] << endl;
优化版2
int v[N],w[M];
int [N]; // 在1-i中选出体积不超过j的最大价值
for (int i = 1; i <= n; i ++)
for (int j = v[i]; j <= m; j ++){ // 和01背包唯一的区别就是循环的顺序
/*
1. f[i][j] = f[i - 1][j]; == f[j] = f[j]
2. f[i][j] = max(f[j], f[i][j - v[i]] + w[i]); == f[j] = max(f[j], f[j - v[i]] + w[i])
3. 循环不用倒着是因为f[i][j - v[i]]就是需要本层已经更新过的, 因此不用担心覆盖问题
*/
f[j] = max(f[j], f[j - v[i]] + w[i]);
}
cout << f[m] << endl;
多重背包
每件物品可以被选xi个,在不超过体积 j 的前提下可以选择的最大价值
朴素版 时间复杂度O(n*m*s)
int v[N], w[N], s[N];
int f[N][N];
for (int i = 1; i <= n; i ++)
for (int j = 0; j <= m; j ++)
for (int k = 0; k <= s[i] && k * v[i] <= j; k ++)
f[i][j] = max(f[i][j], f[i - 1][j - k * v[i]] + k * w[i]);
cout << f[m] << endl;
为什么不用完全背包优化的方法优化多重背包?
优化版 时间复杂度O(n*m*log(s)) 利用二进制将多重背包优化成01背包
例子: x = 200 = 1 + 2 + 4 + 8 + 16 + 32 + 64 + 73,且200以内所有的数都可以用这些数组和表示
// N = 1000*log(2,2000)
int n,m,cnt;
int v[N],w[N];
int f[N];
for (int i = 0; i < n; i ++){
int a, b, s; cin >> a >> b >> s;
int k = 1;
while (k <= s){
cnt ++;
v[cnt] = a * k;
w[cnt] = b * k;
s -= k;
k *= 2;
}
if (s > 0){
v[++ cnt] = a * s;
v[cnt] = b * s;
}
}
for (int i = 1; i <= cnt; i ++)
for (int j = m; j >= v[i]; j --)
f[j] = max(f[j], f[j - v[i]] + w[i]);
cout << f[m] << endl;
分组背包
每组有多种物品,每种物品只有一个,每组只能选一个,,在不超过体积 j 的前提下可以选择的最大价值
多重背包是每组选几个,而分组背包是每组选哪个。
f[i][j] = max{f[i - 1][j], f[i - 1][j - ki] + w[i][ki]} 类似01背包
int n,m;
int v[N][N],w[N][N];
int f[N],s[N];
for (int i = 1; i <= n; i ++)
for (int j = m; j > 0; j --)
for (int k = 1; k <= s[i]; k ++)
if (v[i][k] <= j) f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);
cout << f[m] << endl;
PS: 第三重循环和第二重循环是不可以换位子的,因为第二重循环是从m开始的,为了避免覆盖上层,而不能使用上层。如果换位置,f[j] 就会循环s[i]次导致上层数据被覆盖。但是如果是没有进行一维优化的话,用二维i,j,k就可以交换位置了,那样就不会覆盖上层数据。
线性DP
数字三角形
for (int i = n - 1; i >= 1; i --)
for (int j = 1; j <= i; j ++)
a[i][j] += max(a[i + 1][j], a[i + 1][j + 1]);
cout << a[1][1] << endl;
最长上升子序列
数据范围 1 <= N <= 1000
int n,a[1010],f[1010],g[1010]; // g[i] 记录 f[i] 是从哪个状态转移过来的, 最后可以倒着推出序列是什么
for(int i = 1; i <= n; i ++){
f[i] = 1;
g[i] = 0; // 以i为起点的最长子序列
for(int j = 1; j < i; j ++)
if(a[j] < a[i] && f[i] < f[j] + 1){
f[i] = f[j] + 1; // 若是a[j] < a[i] 那么以i结尾的最长子序列长度 = f[j] + 1
g[i] = j;
}
}
int ans = 0;
for(int i = 1; i <= n; i ++) ans = max(ans, f[i]);
cout << ans << endl;
数据范围 1 <= N <= 100000
/************************** DP ----> 贪心 **************************
q数组下标: 代表最长子序列长度
q数组的值: 记录下标len的子序列最后一个数的最小值
因为q数组的定义可知,所以q[len]一定小于q[len+1],因此数组q具有单调递增性质,
可以利用二分找到第一个大于a[i]的值, q[i + 1] = a[i]
*******************************************************************/
int n,a[N],q[N];
int len = 0;
for (int i = 0; i < n; i ++ ){
int l = 0, r = len;
while (l < r){
int mid = l + r + 1 >> 1;
if (q[mid] < a[i]) l = mid;
else r = mid - 1;
}
len = max(len, r + 1);
q[r + 1] = a[i];
}
printf("%d\n", len);
最长公共子序列
闫式DP分析
-
状态表示f[i, j]
① 集合: 所有在第一个序列的前i个字母出现,且在第二个序列的前j个字母出现的子序列
② 属性: Max -
状态计算: f[i, j] 分为4种状态 00(i不选, j不选),01(i不选, j选),10(i选, j不选),11(i选, j选)
00: 这个状态好表示 f[i, j] = f[i - 1, j - 1]
01: 这个状态表示为 f[i, j] = f[i - 1, j]
10: 这个状态通过为 f[i, j] = f[i, j - 1]
11: 这个状态好表示 f[i, j] = f[i - 1, j - 1] + 1 - 通过对f[i, j]的定义,可以发现f[i - 1, j - 1] 这种状态属于 f[i - 1, j] 和 f[i, j - 1] 这两种状态中。
int n, m;
char a[N], b[N];
int f[N][N];
scanf("%s%s", a + 1, b + 1);
for (int i = 1; i <= n; i ++) // 双重循环--二维dp
for (int j = 1; j <= m; j ++){
f[i][j] = max(f[i - 1][j], f[i][j - 1]);
if (a[i] == b[j]) f[i][j] = max(f[i][j], f[i - 1][j - 1] + 1);
}
cout << f[n][m] << endl;
/*
acbd
abedc
*/
最短编辑距离
题意: 两个字符串a, b,有三种操作: 增,删,改。问最少的操作次数使得字符串a变成b
- 状态表示 f[i, j]
- 集合: 所有将a[1~i]变成b[1~j]的操作方式
- 属性: Min
- 状态计算
- 删除: f[i - 1, j] + 1 要保证a[0,i - 1] 和 b[0,j] 相等的条件下
- 增加: f[i, j - 1] + 1 要保证a[0,i] 和 b[0,j - 1] 相等的条件下
- 修改: f[i - 1, j - 1] + 1不同or 0相同 要保证 a[0,i - 1] 和 b[0,j - 1] 相等的条件下
int f[N][N];
char a[N],b[N];
scanf("%s%s", a + 1, b + 1);
// 初始化
for(int i = 0; i <= m; i ++)f[0][i] = i; // a[0,0] 到 b[0,i] 需要添加操作i次
for(int i = 0; i <= n; i ++)f[i][0] = i; // a[0,i] 到 b[0,0] 需要删除操作i次
for(int i = 1; i <= n; i ++){
for(int j = 1; j <= m; j ++){
if(a[i] != b[j]) f[i][j] = f[i - 1][j - 1] + 1; // a[i] == b[i] 修改 +1
else f[i][j] = f[i - 1][j - 1]; // 修改
f[i][j] = min(f[i][j], min(f[i - 1][j] + 1,f[i][j - 1] + 1)); // 比较三种情况选出最小值
}
}
cout << f[n][m] << endl;
区间DP-石子合并
-
题意: 合并 N 堆石子,每次只能合并相邻的两堆石子,求最小代价
-
解题思路:
关键点: 最后一次合并一定是左边连续区间和右边连续区间进行合并
-
状态表示: f[i][j] 表示将 i 到 j 这一区间的石子合并成一个区间的集合,属性时Min
-
状态计算:
f[i][j] = min{f[i][ki] + f[ki + 1][j] + s[j] - s[i - 1]} (i ≤ ki ≤ j - 1) 至少 ki 把[i, j]分成两个区间
-
int s[N];
int f[N][N];
for (int i = 1; i <= n; i ++ ) s[i] += s[i - 1]; // 前缀和
for (int len = 2; len <= n; len ++ ) // 枚举区间长度
for (int i = 1; i + len - 1 <= n; i ++ ){
int l = i, r = i + len - 1;
f[l][r] = 1e8;
for (int k = l; k < r; k ++ ) //k ∈ [l, r - 1]
f[l][r] = min(f[l][r], f[l][k] + f[k + 1][r] + s[r] - s[l - 1]);
}
printf("%d\n", f[1][n]);
记忆化搜索做法
int dp(int i, int j) {
if (i == j) return 0; // 判断边界
int &v = f[i][j];
if (v != -1) return v;// 减枝避免重复计算,因为下面循环会出现区间重叠
v = 1e8;
for (int k = i; k < j; k ++)
v = min(v, dp(i, k) + dp(k + 1, j) + s[j] - s[i - 1]);
return v;
}
memset(f, -1, sizeof f);
cout << dp(1, n) << endl;
区间DP常用模板
for (int len = 1; len <= n; len ++) { // 区间长度
for (int i = 1; i + len - 1 <= n; i ++) { // 枚举起点
int j = i + len - 1; // 区间终点
if (len == 1) {
dp[i][j] = 初始值
continue;
}
for (int k = i; k < j; k ++) { // 枚举分割点,构造状态转移方程
dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + w[i][j]);
}
}
}
计数类DP-整数划分
问题描述: 一个正整数n可以表示成若干个正整数之和,形如:n = n1 + n2 + … + nk,其中 n1 ≥ n2 ≥ … ≥ nk, k≥1,我们将这样的一种表示称为正整数 n 的一种划分,现在给定一个正整数n,请你求出n共有多少种不同的划分方法
方法一
/***************************利用完全背包的推理***********************
f[i][j]: 表示前i个整数(1,2…,i)恰好拼成j的方案数
f[i][j] = f[i-1][j]+f[i-1][j-i]+f[i-1][j-i*2]...f[i-1][j-i*s] i*s <= j < i*(s+1)
f[i][j-i] = f[i-1][j-i]+f[i-1][j-i*2]...f[i-1][j-i*s]
得出转移方程 f[i][j] = f[i-1][j]+f[i][j-i]
优化维度 f[j] = f[j]+f[j-i]
******************************************************************/
int f[N];
f[0] = 1; //总和为0的方案数,也就是f[i][0]前i个整数(1,2…,i)恰好拼成0的方案数,只有一种就是一个都不选
for (int i = 1; i <= n; i ++)
for (int j = i; j <= n; j ++)
f[j] = (f[j] + f[j - i]) % mod;
cout << f[n] << endl;
方法二
/*********************计数DP****************************
f[i][j]表示和为i,恰好选j个数的方案数
划分为两种情况
1.最小值为1 那把为1的情况去掉 就是f[i-1][j-1]这种情况的方案数
2.最小值大于1 那把i个数都减去1 就是f[i-j][j] 这个情况的方案数
转移方程: f[i][j] = f[i-1][j-1] + f[i-j][j]
ans = f[n][1] + f[n][2] + ... + f[n][n]
*******************************************************/
int f[N][N];
f[1][1] = 1; //初始化源头
for (int i = 2; i <= n; i ++)
for (int j = 1; j <= i; j ++)
f[i][j] = (f[i - 1][j - 1] + f[i - j][j]) % mod;
int res = 0;
for (int i = 1; i <= n; i++)res = (res + f[n][i]) % mod; //枚举每种情况相加
cout << res << endl;
数位统计DP-计数问题
题目描述:
给定两个整数 a 和 b,求 a 和 b 之间的所有数字中0~9的出现次数。
例如,a=1024,b = 1032,则a和b之间共有9个数如下:1024 1025 1026 1027 1028 1029 1030 1031 1032
其中0出现10次,1出现10次,2出现7次,3出现3次等等...
算法思想: 前缀和,数位dp
例如 n = abcdefg 求 0 ~ n 中x出现的次数,记作count(n, x),核心思想是计算x在abcdefg上每一位出现的次数之和
计算x在数字'd'这个位置出现的次数
-
① 'abc'位置是[000, abc - 1] 此时ans += abc*10^3
② 当x = 0时要特判,因为多算了000x这种情况,所以ans -= 10^3
-
'abc'位置是abc时
- ③ d < x 时,那么abcxefg就大于abcdefg,此时不符合条件不计入ans
- ④ d = x 时,那么efg就是所求x在数字d所在位置的次数 ans += efg+1 (000 ~ efg)
- ⑤ d > x 时,那么efg所在的位置可以填任何数字, ans += 1000 (000 ~ 999)
最后将x在n的每一位上计算的次数相加,就是0~n中x出现的次数
所以求 a~b之间的x出现的次数,利用前缀和原理,即等于求0~b出现x的次数减去0~a-1出现x的次数: ans = count(b, x) - count(a - 1, x)
int get(vector<int> num, int l, int r){ // 计算num[l],num[l+1],...,num[r]十进制数
int res = 0;
for (int i = l; i >= r; i --) res = res * 10 + num[i];
return res;
}
int power10(int x){ // 计算10^x
int res = 1;
while (x -- ) res *= 10;
return res;
}
int count(int n, int x){ // 计算0~n中x出现的次数
if (!n) return 0;
vector<int> num; // 把n的每一位拆分放进num数组中
while (n){
num.push_back(n % 10);
n /= 10;
}
n = num.size();
int res = 0;
for (int i = n - 1 - !x; i >= 0; i --){
if (i < n - 1){ // 计算i的前缀是0 ~ (abc-1)
res += get(num, n - 1, i + 1) * power10(i); //① 0~(abc-1)数量等于abc res+="前缀数量"*power10(i)
if (!x) res -= power10(i); //② 如果x是0, 那么就会多数一种情况000xefg, 即多加一个 power10(i)
}
if (num[i] == x) res += get(num, i - 1, 0) + 1; //④ 前缀是abc且d = x
else if (num[i] > x) res += power10(i); //⑤ 前缀是abc且d > x
}
return res;
}
int main(){
int a, b;
while (cin >> a >> b , a){
if (a > b) swap(a, b);
for (int i = 0; i <= 9; i ++)
cout << count(b, i) - count(a - 1, i) << ' ';
cout << endl;
}
return 0;
}
状态压缩DP
蒙德里安的猜想-DP
题意: n x m的棋盘可以摆放不同的1 × 2小方格的种类数。
题目分析:
- 摆放方块的时候,先放横着的,再放竖着的。总方案数等于只放横着的小方块的合法方案数。
- 如何判断,当前方案数是否合法? 所有剩余位置能否填充满竖着的小方块。可以按列来看,每一列内部所有连续的空着的小方块需要是偶数个。
- 这是一道动态规划的题目,并且是一道状态压缩的dp: 用一个N位的二进制数,每一位表示一个物品,0/1表示不同的状态。因此可以用0 →2N -1中的所有数来枚举列全部的状态。
状态表示: f[i][j]表示已经将前i-1列摆好,且从第i-1列,伸出到第i列的状态是j的所有方案。其中j是一个二进制数,用来表示第i-1列转化成第i列的状态(j对应二进制中的1表示从i-1列横着放一个方块,0表示从i-1类到i列没变化),其位数和棋盘的行数一致。
状态转移: f[i][j] += f[i - 1][ki] (0 ≤ ki ≤ 2n-1) 表示第i列的状态j的方案数等于所有符合条件的第i-1列的状态ki之和。其中状态ki 表示第i-2列转化到第i-1列的状态,状态j表示第i-1列转化到第i列的状态
typedef long long LL;
const int N = 12, M = 1 << N;
int n, m;
LL f[N][M];// 第一维表示列, 第二维表示所有可能的状态
vector<int> state[M];
bool st[M];//存储每种状态是否有奇数个连续的0, 如果奇数个0是无效状态, 如果是偶数个零置为true
int main(){
while (cin >> n >> m, n || m){
// 预处理(一): 预处理出[0,1<<n]中每个数的n位中连续0的数量是否为偶数 1 表示伸出 0 表示不伸出
for (int i = 0; i < 1 << n; i ++ ){
int cnt = 0;
bool is_valid = true;
for (int j = 0; j < n; j ++ )
if (i >> j & 1){
if (cnt & 1){
is_valid = false;
break;
}
cnt = 0; // 这一步可以不写, 因为上面if不满足的话, cnt一定是偶数
}
else cnt ++ ;
if (cnt & 1) is_valid = false;
st[i] = is_valid;
}
// 预处理(二): 预处理出f[k-1,i]到f[k,j]状态转移的所有合法方案, 此时属于减少不必要的枚举
for (int i = 0; i < 1 << n; i ++ ){
state[i].clear();
for (int j = 0; j < 1 << n; j ++ )
/*
i & j == 1 说明i和j的n位上有同时为1的情况, 这是不允许的, 若是i的某位为1,
说明在那个位置有从i的前一种状态伸出, 那么此时就不能在这个位置填一个块伸出到j对应位置
st[i|j]==true 标明在i转换成j状态后,i中剩余连续的0是否符合偶数,因为剩下的0要填竖着的方块
例如i='10101' j='01000' i|j=='11101' 这个就是不符合条件的, 即i不能转化为j, 排除
*/
if ((i & j) == 0 && st[i | j])
state[i].push_back(j);
}
memset(f, 0, sizeof f);
f[0][0] = 1;
/*******************************为什么f[0][0] = 1**********************************
1. 题目中输入参数的列数是从1开始到m,即范围为1~m,但我们写的时候是将其先映射到数组0~m-1里
2. 对于第一列,也就是数组中的第0列,是需要初始化的;也就是我们需要初始化f[0][x] = ?回到定义,
f[0][x] 表示从-1列伸到0列(此处说的都是数组下标)状态为x的方案。
3. 我们发现,合法的方案只能是不伸过来,因为根本没有-1列。即x只能取0的时候方案合法,f[0][0] = 1;
接着dp过程就从第1列(数组下标)开始。
4. 那么答案为什么是f[m][0] 呢,因为横放的时候方块最多够到第m-1列(数组下标),不能从m-1再往外伸,
所以是f[m][0];
**********************************************************************************/
for (int i = 1; i <= m; i ++ )
for (int j = 0; j < 1 << n; j ++ )
for (auto k : state[j])
f[i][j] += f[i - 1][k]; // 枚举所有符合从i-1的k状态且能成功转化i的j状态, 并累加
cout << f[m][0] << endl;
}
return 0;
}
蒙德里安的猜想-记忆化搜索
定义状态: dp[i][j]表示前i - 1列的方格都已完全覆盖,第i列方格被第i - 1列伸出的方块覆盖后状态为j的所有方案数。
例如,上图表示的就是dp[3][010010]的状态(红色为2 * 1方块,绿色为1 * 2方块) 0 表示没有覆盖,1 表示覆盖。
状态转移:
我们采用由底至上的递推方式,即由当前状态推出下一列状态的方案数。
以某一列的状态而言
- 【情况一】如果当前行的格子已被上一列伸出的方块覆盖,则跳过
- 【情况二】如果当前行的格子未被覆盖,说明可以放一个1 * 2的方块
- 【情况三】如果当前行的格子和下一行的格子都未被覆盖,说明可以放一个2 * 1的方块
- 【总结】此列所有行的格子都覆盖完后,我们便可以得出下一列的合法状态
如上图,我们对第3列的状态进行搜索后可到达的其中一种状态
为什么要搜索?
根据dp数组的定义可知,第一列不可能被上一列伸出的方块覆盖,所以初始化为dp[1][000] = 1,搜索下一列可得:
可知第二列可到达的状态只有3种,于是进行第三列的搜索时只需从这3种状态开始dfs,当前阶段总是影响下一阶段,我们只对可到达的进行讨论,并不需要枚举每一种情况。
时间复杂度:
- 外层循环时间: m * (1<<n) = 11*2^11
- 递归时间: 最坏情况是一个不满的二叉树 20+21+22+...+210 = (211 - 1)
- 总时间 = 外层循环时间*递归时间 ≈ 10 * 211 * 211 = 46137344 ≈ 4e7
int n, m;
long long dp[12][2500];
void dfs(int row, int col, int state, int next) {
//row为当前行, col为当前列, state为当前列的状态, next为可到达的下一列的状态
//当前列全覆盖后可到达的下一个状态加上当前状态的方案数
if (row == n) {
//当前列所有行都已覆盖完毕
dp[col + 1][next] += dp[col][state];
return;
}
//情况一: 如果当前行(state二进制中第row位等于1)的格子已被覆盖,跳过
if (state & (1 << row)) dfs(row + 1, col, state, next);
else {
//当前行未被覆盖,可放一个1*2的方块
dfs(row + 1, col, state, next | (1 << row));// 情况二
//当前行和下一行都未被覆盖,可放一个2*1的方块
if (row + 1 < n && (state & (1 << (row + 1))) == 0) dfs(row + 2, col, state, next);// 情况三
}
}
int main() {
while (scanf("%d%d", &n, &m) && n && m) {
if (n > m) swap(n, m);
//因为n行m列和n列m行的方案数等价, 所以我们不妨将min(n, m)作为二进制枚举的指数, 减少方案数
memset(dp, 0, sizeof(dp));
dp[0][0] = 1;
for (int i = 0; i < m; i++) {
for (int j = 0; j < (1 << n); j ++) {
if (dp[i][j] > 0) { //筛选出之前搜索过可到达的状态
dfs(0, i, j, 0);
}
}
}
//因为下标从0开始,所以dp[m][0]表示第m + 1列没有任何第m列的方块伸出的方案数
cout << dp[m][0] << endl;
}
return 0;
}
最短Hamilton路径
题目描述: 给定一张n个点的带权无向图,点从0 ~ n-1标号,求起点0到终点n-1的最短Hamilton路径。Hamilton路径的定义是从0到n-1不重不漏地经过每个点恰好一次。
状态表示: f[i][j]
- 集合: 所有从0走到j,走过的所有点是i的所有路径
- 属性: Min
状态计算: 0→...→k→j f[ i ][ j ] = min(f[ i ][ j ], f[ i - (1 << j) ][ ki ] + w[ ki ][ j ])
int f[M][N],w[N][N];
int main(){
cin >> n;
for (int i = 0; i < n; i ++)
for (int j = 0; j < n; j ++)
cin >> w[i][j];
memset(f, 0x3f, sizeof f); // 初始化费用最大值
f[1][0] = 0; // 0 到 0 路径只有 0 的费用是 0
//for (int i = 0; i < 1 << n; i ++)
for (int i = 1; i < 1 << n; i += 2) //优化: 0001 + 10 = 0011 因为第0位始终只有是1才是合法的, 所以+2是符合条件的
for (int j = 0; j < n; j ++)
if (i >> j & 1) // i的第j位二进制是否为1
for (int k = 0; k < n; k ++) // 节点j的前一个路径节点k
if(i - (1 << j) >> k & 1) // i - 1 << j 的第k位是否为1
f[i][j] = min(f[i][j], f[i - (1 << j)][k] + w[k][j]);
cout << f[(1 << n) - 1][n - 1] << endl; // f[11...11][n - 1]是答案
return 0;
}
树形DP-没有上司的舞会
题目描述: Ural大学有N名职员,编号为1~N。他们的关系就像─棵以校长为根的树,父节点就是子节点的直接上司。每个职员有一个快乐指数,用整数Hi给出,其中1≤i≤N。现在要召开一场周年庆宴会,不过,没有职员愿意和直接上司一起参会。在满足这个条件的前提下,主办方希望邀请一部分职员参会,使得所有参会职员的快乐指数总和最大,求这个最大值。
状态表示: f[u][0],f[u][1]
- 集合: f[u][0]表示以u为根节点且u不参加的快乐指数最大值,f[u][1]表示以u为根节点且u参加的快乐指数最大值
- 属性: Max
状态计算: f[u][0] += max(f[ui][0], f[ui][1]),f[u][1] += f[ui][0]
最后的结果ans = max(f[u, 0], f[u, 1]])
void dfs(int u){
f[u][0] = 0; // 不加当前结点
f[u][1] = a[u]; // 加上当前结点
for(int i = h[u]; i != -1; i = ne[i]){
int j = e[i];
dfs(j); // 一直递归到最深处
f[u][0] += max(f[j][0], f[j][1]); // 不加当前结点,那么他的子结点就可以选或者不选
f[u][1] += f[j][0]; // 加上当前结点,那么他的子结点只能不选
}
}
for(int i = 1; i <= n; i++) if(!ru[i]) root = i; // 找出根节点
dfs(root);
printf("%d\n", max(f[root][0], f[root][1]));
记忆化搜索
题目描述: 一张n*m的图,图上每一个点都有一个高度,a点走到b点的要求是a点高度要大于b点高度,求某个点可以走的最大步数。
5 5
1 2 3 4 5
16 17 18 19 6
15 24 25 20 7
14 23 22 21 8
13 12 11 10 9
如上图最大的步数是从25走,螺旋路线,最远走到1,一共25步
int n, m;
int g[N][N],f[N][N];
int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};
int dp(int x, int y){
int &v = f[x][y];
if (v != -1) return v;
v = 1; // 最次也可以走一步
for (int i = 0; i < 4; i ++ ){
int a = x + dx[i], b = y + dy[i];
if (a >= 1 && a <= n && b >= 1 && b <= m && g[x][y] > g[a][b])
v = max(v, dp(a, b) + 1);
}
return v;
}
memset(f, -1, sizeof f);
int res = 0;
for (int i = 1; i <= n; i ++ )
for (int j = 1; j <= m; j ++ )
res = max(res, dp(i, j));
printf("%d\n", res);
贪心-思想
区间问题
区间选点
题目描述: 给定N个闭区间[ai,bi],请你在数轴上选择尽量少的点,使得每个区间内至少包含一个选出的点。输出选择的点的最小数量。位于区间端点上的点也算作区间内。
struct Range{
int l, r;
bool operator< (const Range &W)const{
return r < W.r;
}
}range[N];
sort(range, range + n);
int res = 0, ed = -2e9;
for (int i = 0; i < n; i ++ )
if (range[i].l > ed){
res ++ ;
ed = range[i].r;
}
printf("%d\n", res);
最大不相交区间数量
题目描述: 给定N个闭区间[ai,bi],请你在数轴上选择若干区间,使得选中的区间之间互不相交(包括端点)。输出可选取区间的最大数量。
计算方法和区间选点一模一样。
struct Range{
int l, r;
bool operator< (const Range &W)const{
return r < W.r;
}
}range[N];
sort(range, range + n);
int res = 0, ed = -2e9;
for (int i = 0; i < n; i ++ )
if (range[i].l > ed){
res ++ ;
ed = range[i].r;
}
printf("%d\n", res);
区间分组
题目描述: 给定N个闭区间[ai,bi],请你将这些区间分成若干组,使得每组内部的区间两两之间(包括端点)没有交集,并使得组数尽可能小。输出最小组数。
思路:
- 将所有区间按左端点从小到大排序
- 从前往后处理每个区间,判断能否将其放到某个现有的组中L[i] > Max_r
- 如果不存在这样的组,则开新组,然后再将其放进去;
- 如果存在这样的组,将其放进去,并更新当前组的Max_r
sort(range, range + n);
priority_queue<int, vector<int>, greater<int>> heap;// 小根堆里存在每个组右端点
for (int i = 0; i < n; i ++ ){
auto r = range[i];
if (heap.empty() || heap.top() >= r.l) heap.push(r.r);// 最小的右端点都大于r.l那就需要新开一个组
else{ // 否者就把这个组加入右端点最小的那个组, 并且更新
heap.pop();
heap.push(r.r);
}
}
printf("%d\n", heap.size());
区间覆盖
题目描述: 给定N个闭区间[ai, bi]以及一个线段区间[s, t],请你选择尽量少的区间,将指定线段区间完全覆盖。输出最少区间数,如果无法完全覆盖则输出 -1。
核心思想: 在左端点l都小于等于st的情况下, 取右端点最大的小区间
- 将所有区间按照左端点从小到大进行排序
- 从前往后枚举每个区间,在所有能覆盖
start
的区间中,选择右端点的最大区间,然后将start
更新成右端点的最大值这—步用到了贪心决策
int n;
int st, ed;
struct Range{
int l, r;
bool operator< (const Range &W)const{
return l < W.l;
}
}range[N];
sort(range, range + n);
int res = 0;
bool success = false;
int i = 0;
while (i < n){
int r = -2e9;
/*********************************************************************************
int r = -2e9 不能放在外面
例如:
4 10
2
4 5
11 12 这个样例不会执行里面的while,i 不会 ++, 且if (r < st) 永远不会执行, 就会陷入死循环
**********************************************************************************/
while (i < n && range[i].l <= st){ //在左端点l都小于等于st的情况下, 取右端点最大的小区间
r = max(r, range[i].r);
i ++ ;
}
if (r < st){ // 若 r < st 即说明while循环结束条件是 i < n, 所以说明所有的区间都不在[st, ed]里面
res = -1;
break;
}
res ++ ; // 成功找到合适的一个区间预设res ++
if (r >= ed){ // 若 r >= ed 说明已经找到一个合适的区间, 此时退出, 贪心停止
success = true;
break;
}
st = r; // st值设定成当前寻找的符合条件的右端点
}
if (!success) res = -1;
printf("%d\n", res);
Huffman树-合并果子
priority_queue<int, vector<int>, greater<int>> heap;
while (n --){
int x;
scanf("%d", &x);
heap.push(x);
}
int res = 0;
while (heap.size() > 1){
int a = heap.top(); heap.pop();
int b = heap.top(); heap.pop();
res += a + b;
heap.push(a + b);
}
printf("%d\n", res);
排序不等式-排队打水
题目描述: 有n 个人排队到1个水龙头处打水,第i个人装满水桶所需的时间是t,请问如何安排他们的打水顺序才能使所有人的等待时间之和最小?
sort(t, t + n);
reverse(t, t + n);
LL res = 0;
for (int i = 0; i < n; i ++ ) res += t[i] * i;
printf("%lld\n", res);
绝对值不等式-货仓选址
题目描述: 在—条数轴上有N家商店,它们的坐标分别为A1~ AN。现在需要在数轴上建立一家货仓,每天清晨,从货仓到每家商店都要运送一车商品。为了提高效率,求把货仓建在何处,可以使得货仓到每家商店的距离之和最小。
sort(a,a + t);
int ans = 0;
for(int i = 0; i < t; i ++)
/*
1. 当n为奇数时, 站点放在中位数a[t/2]时ans最小
2. 当n为偶数时, 站点放在范围为a[(t-1)/2]~a[t/2]中任意位置都行,设[a,b]中有一个x,即|a - x| + |b - x| = b - a
3. 所以无论n为奇数还是偶数, ans都是最小
*/
ans += abs(a[i] - a[t/2]);
cout << ans << endl;
/*
1 2 3 4 5 6
4 - 1 + 4 - 2 + 4 - 3 = 6
*/
推公式-耍杂技的牛
题目描述: 农民约翰N头奶牛(编号为1..N)计划逃跑并加入马戏团,为此它们决定练习表演杂技。奶牛们不是非常有创意,只提出了一个杂技表演:
叠罗汉,表演时,奶牛们站在彼此的身上,形成一个高高的垂直堆叠。奶牛们正在试图找到自己在这个堆叠中应该所处的位置顺序。
这N头奶牛中的每一头都有着自己的重量Wi以及自己的强壮程度Si。一头牛支撑不住的可能性取决于它头上所有牛的总重量(不包括它自己)减去它的身体强壮程度的值,现在称该数值为风险值,风险值越大,这只牛撑不住的可能性越高。您的任务是确定奶牛的排序,使得所有奶牛的风险值中的最大值尽可能的小。
贪心思路: 按照wi+si从小到大的顺序排,最大的危险系数一定是最小的。
typedef pair<int, int> PII;
const int N = 50010;
int n;
PII cow[N];
int main(){
scanf("%d", &n);
for (int i = 0; i < n; i ++ ){
int s, w;
scanf("%d%d", &w, &s);
cow[i] = {w + s, w};
}
sort(cow, cow + n);
int res = -2e9, sum = 0;
for (int i = 0; i < n; i ++ ){
int s = cow[i].first - cow[i].second, w = cow[i].second;
res = max(res, sum - s);
sum += w;
}
printf("%d\n", res);
return 0;
}