第0讲 时间复杂度

一般ACM或者笔试题的时间限制是1秒或2秒。
在这种情况下,C++代码中的操作次数控制在 10^7∼10^8 为最佳。

下面给出在不同数据范围下,代码的时间复杂度和算法该如何选择:

数据范围 复杂度 常见算法
n≤30 指数级别 dfs+剪枝,状态压缩dp
n≤100 O(n3) floyd,dp,高斯消元
n≤1000 O(n∗算法基础acwing - 图2) 块状链表、分块、莫队
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≤10^9 O(算法基础acwing - 图3) 判断质数
n≤10^18 O(logn) 最大公约数,快速幂,数位DP
n≤算法基础acwing - 图4 O(logk*loglogk) k表示位数O(logk*loglogk),k表示位数 ,高精度加减、FFT/NTT

常见运算技巧

位操作技巧
看不懂系列

位操作交换符号

交换符号将正数变成负数,负数变成正数

  1. int reversal(int a) { return ~a + 1; }

整数取反加1,正好变成其对应的负数(补码表示);负数取反加一,则变为其原码,即正数

整数的绝对值是其本身,负数的绝对值正好可以对其进行取反加一求得,即我们首先判断其符号位(整数右移 31 位得到 0,负数右移 31 位得到 -1,即 0xffffffff),然后根据符号进行相应的操作

  1. int abs(int a) {
  2. int i = a >> 31;
  3. return i == 0 ? a : (~a + 1);
  4. }

上面的操作可以进行优化,可以将 i == 0 的条件判断语句去掉。我们都知道符号位 i 只有两种情况,即 i = 0 为正,i = -1 为负。对于任何数与 0 异或都会保持不变,与 -1 即 0xffffffff 进行异或就相当于对此数进行取反,因此可以将上面三目元算符转换为((a^i)-i),即整数时 a 与 0 异或得到本身,再减去 0,负数时与 0xffffffff 异或将 a 进行取反,然后在加上 1,即减去 i(i =-1)

  1. int abs(int a) {
  2. int i = a >> 31;
  3. return ((a^i) - i);
  4. }

位操作进行高低位交换

  1. a = (a >> 8) | (a << 8);


消去x最后一位的1

  1. x & (x - 1)

检测整数 n 是否是 2 的幂次

异或

异或参考

X^1 ~X
X^0 ~X
X^X 0

不使用四则运算求加法

  1. int add(int a, int b)
  2. {
  3. if (b == 0) return a;
  4. return add(a^b, (unsigned int)(a & b) << 1);
  5. }

向上和向下取整

a/b

向上

(a+b-1)/ b

向下

a/ b

四舍五入

(a+0.5) / 10

第1讲 数据结构

1.0 链表与邻接表

image.png

826. 单链表

image.png

插入操作
image.png

  1. //将x插入到头节点
  2. void add_to_head(int x)
  3. {
  4. e[idx] = x; // 存放新的节点的值
  5. ne[idx] = head; // 将新的节点指向head指向的节点
  6. head = idx; // 头节点指向新插入的idx节点
  7. idx++; // 下一个节点
  8. }
  1. #include <iostream>
  2. #include <stdio.h>
  3. using namespace std;
  4. const int N = 100010;
  5. //head 头节点下标
  6. //e[i]表示节点i的值
  7. //ne[i]当前节点i的next指针是多少
  8. //idx 存储当前已经用到了哪个点
  9. int head, e[N], ne[N], idx;
  10. void init()
  11. {
  12. head = -1;
  13. idx = 0;
  14. }
  15. //将x插入到头节点
  16. void add_to_head(int x)
  17. {
  18. e[idx] = x; // 存放新的节点的值
  19. ne[idx] = head; // 将新的节点指向head指向的节点
  20. head = idx; // 头节点指向新插入的idx节点
  21. idx++; // 下一个节点
  22. }
  23. //将x插入到下标是k的节点的后面
  24. void add(int k, int x)
  25. {
  26. e[idx] = x;
  27. ne[idx] = ne[k];
  28. ne[k] = idx++;
  29. }
  30. //将下标是k的后的一个点删除掉
  31. void remove(int k)
  32. {
  33. ne[k] = ne[ne[k]];
  34. }
  35. int main()
  36. {
  37. int m;
  38. cin >> m;
  39. init();
  40. while (m--)
  41. {
  42. int k, x;
  43. char OP;
  44. cin >> OP;
  45. if (OP == 'H')
  46. {
  47. cin >> x;
  48. add_to_head(x);
  49. }
  50. else if (OP == 'D')
  51. {
  52. cin >> k;
  53. if(!k) head = ne[head];
  54. else remove(k-1);
  55. }
  56. else if (OP == 'I')
  57. {
  58. cin >> k >> x;
  59. add(k - 1, x);
  60. }
  61. }
  62. for (int i = head;i != -1;i = ne[i])
  63. {
  64. cout << e[i] << " ";
  65. }
  66. puts("");
  67. return 0;
  68. }

1.1 Tire

用来高效的存储和查找字符串

之前对这里的理解有点问题,以为是第二层的字母一定是每个字符串的第二个,其实不是
通过下面的标号可以看到第二个字符b:2, c:10, c:10 , d:21
其实是每一个字符都位于N(N是所有字符串的总长度)层中的一层。每层26个元素,是因为字母可能是a-z中的任意一个。
所以,找的时候递归的往下寻找即可。
image.png

835. Trie字符串统计

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

  1. I x 向集合中插入一个字符串 xx;
  2. Q x 询问一个字符串在集合中出现了多少次。

共有 NN 个操作,输入的字符串总长度不超过 10^5,字符串仅包含小写英文字母。

通过输入字符串总长度为10^5可知道,N取100010,表明每一个字符都位于其中的一层。之所以每一层有26个,是因为字母可能是a-z中的任意一个

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010; // 所有字符串的总长度
  4. int son[N][26];
  5. int idx;
  6. int cnt[N];
  7. void insert(const string& w)
  8. {
  9. int p = 0; // p = 0 既是父节点,也是根节点
  10. for (auto &c : w) {
  11. int u = c - 'a';
  12. if (!son[p][u]) son[p][u] = ++idx;
  13. p = son[p][u];
  14. }
  15. cnt[p]++;
  16. }
  17. int query(const string& w)
  18. {
  19. int p = 0;
  20. for (auto &c : w) {
  21. int u = c - 'a';
  22. if (!son[p][u]) return 0;
  23. p = son[p][u];
  24. }
  25. return cnt[p];
  26. }
  27. int main()
  28. {
  29. int n;
  30. cin >> n;
  31. while (n--)
  32. {
  33. string op, w;
  34. cin >> op >> w;
  35. if (op[0] == 'I')
  36. insert(w);
  37. else
  38. cout << query(w) << endl;
  39. }
  40. return 0;
  41. }
  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010;
  4. int son[N][26], cnt[N], idx; // 下标是0的点,即是根节点,又是空节点
  5. char str[N];
  6. void insert(char str[]) // str是要插入到字符串
  7. {
  8. int p = 0;
  9. for(int i=0;str[i];i++) //从根节点开始,从前往后遍历
  10. {
  11. int u = str[i] - 'a';
  12. if(!son[p][u]) son[p][u] = ++idx; // 若果不存在,直接创建出来
  13. p = son[p][u];
  14. }
  15. cnt[p]++; //str已经出现的次数
  16. }
  17. int query(char str[])
  18. {
  19. int p = 0;
  20. for(int i=0;str[i];i++)
  21. {
  22. int u = str[i] - 'a';
  23. if(!son[p][u]) return 0;
  24. p = son[p][u];
  25. }
  26. return cnt[p];
  27. }
  28. int main()
  29. {
  30. int n;
  31. cin >> n;
  32. while(n--)
  33. {
  34. char op[2];
  35. cin >> op >> str;
  36. if(op[0] == 'I') insert(str);
  37. else cout << query(str) << "\n";
  38. }
  39. return 0;
  40. }

1.2 堆

image.png
根节点是从下标1开始的;
image.png

堆的构建

如果是一个个的插入点的话,就是n*log(n)的复杂度

因此选择从2/n的地方开始建堆

错误代码片段1

  1. for (int i=1; i<n/2; i++)
  2. down(i);

上面构建堆的放方式是有问题的,无法保证第一个元素是最小元素
需要从n/2到1,这样建堆能保证,h[1]是最小的元素

为何这样最小的元素一定在堆顶呢? 在down过程中会将小的元素上移,由于最后最后一层有n/2个元素。所以,第n/2个元素是从倒数第二层中的最后一个有子树的节点。 从n/2到 n/4的过程会将较小的元素换到倒数第二层中,依次交换到第一层,最小的元素也交换到了堆顶

如下图:

image.png

建堆方法是O(n)的证明

image.png
image.png

堆的操作

上移

这里使用的是小顶堆

up操作

  1. void up(int root) {
  2. int son = root / 2;
  3. while (son && h[son] > h[root]) {
  4. swap(h[son], h[root]);
  5. root = son;
  6. son = root / 2;
  7. }

下移

这里使用的是小顶堆

  1. /*
  2. 判断跟节点是否是 根/左/右中的最小值,
  3. 如果不是,交换跟节点和最小的节点,递归处理
  4. */
  5. void down(int rootIdx, int size) {
  6. int minIdx = rootIdx;
  7. int ls = rootIdx * 2, rs = rootIdx * 2 + 1;
  8. if (ls <= size && h[ls] < h[minIdx]) minIdx = ls;
  9. if (rs <= size && h[rs] < h[minIdx] minIdx = rs;
  10. if (minIdx != rootIdx) {
  11. swap(h[rootIdx], h[minIdx]);
  12. down(minIdx);
  13. }
  14. }

838. 堆排序

错误代码片段2

  1. for (int i=n; i; i--) {
  2. swap(h[1], h[i]); // 将最小的元素放到后面
  3. down(1, i); //重新构建堆,将最小的元素放大堆顶
  4. }

这里的排序也是有问题的。
由于已经将对小的元素放到最后一个位置了,因此重新down的时候,边界应该是n-1,对应这里的i-1。

最后贴一个完整的正确代码 😄

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010;
  4. int h[N];
  5. void down(int u, int mysize)
  6. {
  7. int ls = 2 * u, rs = 2 * u + 1;
  8. int m = u;
  9. if (ls <= mysize && h[ls] < h[m]) m = ls;
  10. if (rs <= mysize && h[rs] < h[m]) m = rs;
  11. if (m != u) {
  12. swap(h[m], h[u]);
  13. down(m, mysize);
  14. }
  15. }
  16. int main()
  17. {
  18. int n, m;
  19. cin >> n >> m;
  20. for (int i=1; i<=n; i++) cin >> h[i];
  21. for (int i=n/2; i; i--)
  22. down(i, n);
  23. for (int i=n; i>n-m; i--)
  24. {
  25. cout << h[1] << " ";
  26. swap(h[1], h[i]);
  27. down(1, i-1);
  28. }
  29. return 0;
  30. }

1.3 并查集

  1. 将两个集合合并
  2. 询问两个元素是否在一个集合当中

image.png

image.png

  1. 根节点的编号就是当前集合的编号
  2. 每一个节点存储一下父节点是谁,递归向上查找到每一个节点属于哪一个集合
  3. 路径压缩就是在寻找到根节点后,直接将节点挂在更节点下,实现0(1) 的查找


最重要的函数find,这里每次都会进行路径压缩

  1. int find(int x) { // 返回x 的祖宗节点 + 路径压缩
  2. if (p[x] != x) p[x] = find(p[x]); // 如果x不是根节点,
  3. // 就将当前的节点x的父节点p[x]制定为根节点(find最后递归得到的就是跟几点)
  4. return p[x];
  5. }

p[x] 中存放的是x 的父节点是谁

并查集的初始化

开始时有多少个区间,就初始化多少个并查集。

并查集的合并

将其中一个结合根节点的父节点指定为另外一个集合的根节点

836. 合并集合

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010;
  4. int p[N];
  5. int n, m;
  6. int find(int x) { // 返回x 的祖宗节点 + 路径压缩
  7. if (p[x] != x) p[x] = find(p[x]);
  8. return p[x];
  9. }
  10. int main() {
  11. cin >> n >> m;
  12. for (int i=1; i<=n; i++) p[i] = i; //初始化时,有n个并查集
  13. while (m--) {
  14. char op[2];
  15. int a, b ;
  16. cin >> op >> a >> b;
  17. if (op[0] == 'M') // 合并
  18. p[find(a)] = find(b); // 将a的根节点直接挂到b的根节点下
  19. else { // 判断是否在一个集合中
  20. if (find(a) == find(b)) puts("Yes");
  21. else puts("No");
  22. }
  23. }
  24. }

837. 连通块中点的数量

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010;
  4. int p[N];
  5. int cnt[N]; // 每个集合中点的个数
  6. int n, m;
  7. int find(int x) { // 返回x 的祖宗节点 + 路径压缩
  8. if (p[x] != x) p[x] = find(p[x]);
  9. return p[x];
  10. }
  11. int main() {
  12. cin >> n >> m;
  13. for (int i=1; i<=n; i++) {
  14. p[i] = i;
  15. cnt[i] = 1;
  16. }
  17. while (m--) {
  18. char op[5];
  19. int a, b ;
  20. cin >> op;
  21. // cout << op << endl;
  22. if (op[0] == 'C') { // 合并
  23. cin >> a >> b;
  24. if (find(a) == find(b)) continue;
  25. cnt[find(b)] += cnt[find(a)]; // a连通块的数量 增加了 b连通块的数量
  26. p[find(a)] = find(b); // 将a的根节点直接挂到b的根节点下
  27. }
  28. else if (op[1] == '1') { // 判断是否在一个集合中
  29. cin >> a >> b;
  30. if (find(a) == find(b)) puts("Yes");
  31. else puts("No");
  32. } else { //
  33. cin >> a;
  34. cout << cnt[find(a)] << endl;
  35. }
  36. }
  37. }

连通块的数量

  1. int cnt = 0;
  2. for (int i=0; i<n; i++)
  3. if (p[i] == i)
  4. cnt++;

因为初始化的时候,p[i] = i; 如果p[i] != i, 表明在路径压缩的时候,将i的父节点变成了别的节点

1.4 哈希表

image.png

使用场景: 将庞大的数据结构映射到小的数据范围

离散化是特殊的哈希方式(原集合需要是有序的)

image.png

一般会取一个质数作为模数,这样可以减少哈希冲突的概率(具体的原因自行搜索)

算法题中,一般是没有删除操作的, 就算是删除,也是开一个数组进行标记,不是真正的删除

插入操作
image.png
 

拉链法

  1. #include <iostream>
  2. #include <string.h>
  3. using namespace std;
  4. const int N = 100003;
  5. int h[N]; // 存储每一个槽
  6. int e[N]; //e[i]表示节点i的值
  7. int ne[N]; //ne[i]当前节点i的next指针是多少
  8. int idx; //idx 存储当前已经用到了哪个点
  9. void insert(int x) {
  10. int k = (x % N + N) % N; // +N是为了保证不为负数
  11. e[idx] = x; // 新生产一个节点idx,其值为x
  12. ne[idx] = h[k]; // 当前节点的下一节点为h[k]
  13. h[k] = idx++; // 新的头节点
  14. }
  15. bool find(int x) {
  16. int k = (x % N + N) % N;
  17. for (int i=h[k]; i!=-1; i=ne[i]) // 链表的最后一个节点是-1
  18. if (e[i] == x)
  19. return true;
  20. return false;
  21. }
  22. int main () {
  23. int n;
  24. cin >> n;
  25. memset(h, -1, sizeof h);
  26. while (n--) {
  27. char op[2];
  28. int x;
  29. cin >> op >> x;
  30. if (*op == 'I') insert(x);
  31. else {
  32. if (find(x)) puts("Yes");
  33. else puts("No");
  34. }
  35. }
  36. return 0;
  37. }

开放寻址法

  1. 哈希得到对应的idx
  2. 判断当前的坑位是否有人
    1. 有人,找到下一个空的坑位
    2. 没人,直接占用

image.png

删除,找到之后,打一个标记

数字的大小通常需要开到数据范围的2~3倍
image.png

  1. int find(int x) {
  2. int k = (x % N + N) % N;
  3. while (h[k] != null && h[k] != x) // 坑有人,且不是自己
  4. {
  5. k++;
  6. if (k == N) k = 0; // 到末尾了,就从头开始
  7. }
  8. return k;
  9. }
  1. #include <iostream>
  2. #include <cstring>
  3. using namespace std;
  4. const int N = 200003, null = 0x3f3f3f3f;
  5. int h[N]; // 槽
  6. int find(int x) {
  7. int k = (x % N + N) % N;
  8. while (h[k] != null && h[k] != x) {
  9. k++;
  10. if (k == N) k = 0;
  11. }
  12. return k;
  13. }
  14. int main() {
  15. int n;
  16. cin >> n;
  17. memset(h, 0x3f, sizeof h); // 清空所有的槽
  18. while (n--) {
  19. char op[2];
  20. int x;
  21. cin >> op >> x;
  22. int t = find (x);
  23. if (*op == 'I') h[t] = x;
  24. else {
  25. if (h[t] == null) puts("No");
  26. else puts("Yes");
  27. }
  28. }
  29. return 0;
  30. }

1.5 KMP

image.png

写代码前,写画出这张图, 记住这里有一个错位,i和j+1

模板串 next数组的计算

next[i]: p[1,j]中最长的与前缀相等的后缀,记录的是前缀中最后的一个字符的下标

image.png

计算的过程中,一直存在两个字符串错位的情况,其实是为了方便计算:
通常是拿i和j+1比较,个人觉得是为了方便写next[j]

计算next数组的过程,就是寻找p串的规律
image.png

第一个成立的原因:反证法 如果next[i-1]有更大的前后缀,由于p[i] == p[j+1], 那么next[i]也有更大的值,出现矛盾

image.png

一次简单的模拟的过程

image.png

当p[i] 和 p[j+1]一直不相等时,需要一直递归的向前移动,直到起点为止。

image.png

  1. for (int i=2; j=0; i<=n; i++) // next[1] = 0,不需要计算
  2. {
  3. while (j && p[i] != p[j+1]) // 匹配一直不成功,一直向前退
  4. j = ne[j];
  5. if (p[i] == p[j+1] //匹配成功,匹配下一个字符
  6. j++;
  7. ne[i] = j; //记录
  8. }

kmp匹配过程

image.png

  1. for (int i=1, j=0; i<=m; i++)
  2. {
  3. while (j && s[i] != p[j+1]) j = ne[j];
  4. if (s[i] == p[j+1]) j++;
  5. if (j == n)
  6. {
  7. printf("%d", i-n);
  8. j = ne[j];
  9. }
  10. }

匹配成功后,j = ne[j]回退的原因和计算next数组是一样的。
回退进行下一轮的匹配
image.png

KMP应用

  1. 循环节 n - next[n]

image.png

  1. 所有和前缀相等的后缀

image.png

总结

需要理解其原理(这里说的原理不是简单的模拟样例)

831. KMP字符串

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010, M = 1000010;
  4. char p[N], s[M];
  5. int ne[N]; // next[i]: p[1,j]中最长的与前缀相等的后缀,记录的是前缀中最后的一个字符的下标
  6. int main()
  7. {
  8. int n, m;
  9. cin >> n >> p+1 >> m >> s+1;
  10. // cal next
  11. for (int i=2, j=0; i<=n; i++)
  12. {
  13. while (j && p[j+1] != p[i]) j = ne[j];
  14. if (p[j+1] == p[i]) j++;
  15. ne[i] = j;
  16. }
  17. // kmp
  18. for (int i=1, j=0; i<=m; i++)
  19. {
  20. while (j && p[j+1] != s[i]) j = ne[j];
  21. if (p[j+1] == s[i]) j++;
  22. if (j == n)
  23. {
  24. cout << i - j << ' ';
  25. j = ne[j];
  26. }
  27. }
  28. return 0;
  29. }

第2讲 基础算法

模板

2.1 快速排序

练习题

快速排序

使用哨兵,将元素放到哨兵的两边。

分治 ==》 边分边治

  1. void quick_sort(int arr[], int l, int r)
  2. {
  3. // 递归终止
  4. if(l >= r) return;
  5. int i = l-1, j = r+1;
  6. int pivot = arr[l + r >> 1]; // 哨兵
  7. // 以哨兵为大小,数字交换到两侧
  8. while(i<j)
  9. {
  10. while(arr[++i] < pivot);
  11. while(arr[--j] > pivot);
  12. if(i < j) swap(arr[i], arr[j]);
  13. }
  14. // 分治
  15. quick_sort(arr, l, j);
  16. quick_sort(arr, j+1, r);
  17. }

第k个数

  1. int quick_sort(int l, int r, int k)
  2. {
  3. // k一定位于[l,r]中
  4. if(l == r) return arr[l];
  5. // 初始化 左端点为哨兵
  6. int x = arr[l], i = l-1, j = r + 1;
  7. // int x = arr[i + j >> 1]; // it's ok
  8. while(i < j)
  9. {
  10. while(arr[++i] < x);
  11. while(arr[--j] > x);
  12. if(i<j) swap(arr[i], arr[j]);
  13. }
  14. // 哨兵位置到左区间的长度
  15. int SLen = j - l + 1;
  16. if(k <= SLen) return quick_sort(l, j, k);
  17. else return quick_sort(j+1, r, k-SLen); // 迭代区间长度
  18. }

思考: i == j ?

215. 数组中的第K个最大元素

  1. class Solution {
  2. public:
  3. int findKthLargest(vector<int>& nums, int k) {
  4. return quickSelect(nums, 0, nums.size()-1, k);
  5. }
  6. int quickSelect(vector<int>&q, int l, int r, int k)
  7. {
  8. if (l==r) return q[l];
  9. int i = l-1, j = r+1;
  10. int pivot = q[l+r>>1];
  11. // 从大到小排列
  12. while (i < j)
  13. {
  14. while (q[++i] > pivot) ;
  15. while (q[--j] < pivot) ;
  16. if (i < j) swap(q[i], q[j]);
  17. }
  18. int len = j - l + 1;
  19. if (k <= len) return quickSelect(q, l, j, k);
  20. else return quickSelect(q, j+1, r, k-len);
  21. }
  22. };

2.2 归并排序

练习题

归并排序

分治 ==》 先分,后治

注意临时变量的赋值

  1. void merge_sort(int q[], int l, int r)
  2. {
  3. // 终止条件
  4. if(l >= r) return;
  5. // divide
  6. int mid = l + r >> 1;
  7. merge_sort(q, l, mid);
  8. merge_sort(q, mid+1, r);
  9. // 归并
  10. int idx = 0, i = l, j = mid+1, temp[r-l+1];
  11. while(i <= mid && j <= r)
  12. temp[idx++] = q[i] > q[j] ? q[j++] : q[i++];
  13. // 处理剩余元素
  14. while(i <= mid) temp[idx++] = q[i++];
  15. while(j <= r) temp[idx++] = q[j++];
  16. // 赋值原数组
  17. for(int i=0; i<r-l+1; i++)
  18. q[l+i] = temp[i];
  19. }
  1. template<class Iter>
  2. void merge_sort(Iter first, Iter last)
  3. {
  4. if (last - first > 1) {
  5. Iter middle = first + (last - first) / 2;
  6. merge_sort(first, middle);
  7. merge_sort(middle, last);
  8. std::inplace_merge(first, middle, last);
  9. }
  10. }

148. 排序链表

  1. /**
  2. * Definition for singly-linked list.
  3. * struct ListNode {
  4. * int val;
  5. * ListNode *next;
  6. * ListNode() : val(0), next(nullptr) {}
  7. * ListNode(int x) : val(x), next(nullptr) {}
  8. * ListNode(int x, ListNode *next) : val(x), next(next) {}
  9. * };
  10. */
  11. class Solution {
  12. public:
  13. ListNode* sortList(ListNode* head) {
  14. if (head ==nullptr || head->next==nullptr) return head;
  15. ListNode* mid = getMid(head);
  16. return mergeTwo(sortList(head), sortList(mid));
  17. }
  18. ListNode* getMid(ListNode* head)
  19. {
  20. ListNode* fast = head, *slow = head;
  21. while (fast->next != nullptr && fast->next->next!=nullptr)
  22. {
  23. fast = fast->next->next;
  24. slow = slow->next;
  25. }
  26. // 从中点断开链表
  27. ListNode* mid = slow->next;
  28. slow->next = nullptr;
  29. return mid;
  30. }
  31. ListNode* mergeTwo(ListNode* head1, ListNode* head2)
  32. {
  33. // 头节点一定初始化,否则时裸指针
  34. ListNode* head, *root = new ListNode(0);
  35. head = root;
  36. while (head1!=nullptr && head2!=nullptr)
  37. {
  38. if (head1->val < head2->val)
  39. {
  40. head->next = head1;
  41. head1 = head1->next;
  42. }
  43. else
  44. {
  45. head->next = head2;
  46. head2 = head2->next;
  47. }
  48. head = head->next;
  49. }
  50. head->next = head1==nullptr ? head2 : head1;
  51. return root->next;
  52. }
  53. };

【注】

l+r>>1 向下取整, l+r+1>>1 向上取整

逆序对的数量

注意:

  • 逆序对数量的计算
  • 最后剩余元素是否逆序的思考
  1. void merge_sort(int q[], int l, int r)
  2. {
  3. // 终止递归
  4. if(l >= r) return;
  5. // divide
  6. int mid = l + r >> 1;
  7. merge_sort(q, l, mid);
  8. merge_sort(q, mid+1, r);
  9. // merge
  10. int i=l, j=mid+1, k=0, temp[r-l+1];
  11. while(i<=mid && j<=r)
  12. {
  13. if(q[i] <= q[j])
  14. {
  15. temp[k++] = q[i++];
  16. }
  17. else
  18. {
  19. temp[k++] = q[j++];
  20. num += mid-i+1;
  21. // 向上归并的过程中,各子区间是有序的。
  22. // 当出现逆序时,左边区间的未遍历元素都构成逆序
  23. //num++; // 所以不对
  24. }
  25. }
  26. // 处理剩余元素
  27. while(i<=mid) temp[k++] = q[i++]; // num++; //前面已经计算
  28. while(j<=r) temp[k++] = q[j++];
  29. // 替换原数组元素
  30. for(int i=0; i<r-l+1; i++)
  31. q[l+i] = temp[i];
  32. }

2.3 二分

二分的理解

算法基础acwing - 图31
binarySearch.jpg
算法基础acwing - 图33)%3D%0A%5Cleft%5C%7B%20%20%0A%20%20%20%20%20%20%20%20%20%20%5Cbegin%7Barray%7D%7Blr%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20true%2C%20l%20%3D%20mid%2C%20%5Bmid%2C%20r%5D%26%5C%5C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20false%2C%20r%20%3D%20mid%2B1%2C%20%5Bl%2C%20mid%2B1%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%5Cend%7Barray%7D%20%20%0A%5Cright.%0A#card=math&code=if%28check%28mid%29%29%3D%0A%5Cleft%5C%7B%20%20%0A%20%20%20%20%20%20%20%20%20%20%5Cbegin%7Barray%7D%7B%2A%2Alr%2A%2A%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20true%2C%20l%20%3D%20mid%2C%20%5Bmid%2C%20r%5D%26%5C%5C%0A%20%20%20%20%20%20%20%20%20%20%20%20%20false%2C%20r%20%3D%20mid%2B1%2C%20%5Bl%2C%20mid%2B1%5D%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%5Cend%7Barray%7D%20%20%0A%5Cright.%0A&id=GFwjF)

二分的本质:

找到区间的一个分界点,使得包含分界点的区间满足某个性质,不包含分界点的区间不满足某个性质。

  • 对于第一中情况+1的说明

    (l+r) / 2是向下取整的,

    当 r = l + 1时:

    更新 l : mid = (l + (l + 1)) / 2 = l

    当true时, l被更新为l,进入无限循环

  • check

    表示是否满足某个性质

常见模板

  1. bool check(int x) {/* ... */} // 检查x是否满足某种性质
  2. // 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
  3. int bsearch_1(int l, int r)
  4. {
  5. while (l < r)
  6. {
  7. int mid = l + r >> 1;
  8. if (check(mid)) r = mid; // check()判断mid是否满足性质
  9. else l = mid + 1;
  10. }
  11. return l;
  12. }
  13. // 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
  14. int bsearch_2(int l, int r)
  15. {
  16. while (l < r)
  17. {
  18. int mid = l + r + 1 >> 1;
  19. if (check(mid)) l = mid;
  20. else r = mid - 1;
  21. }
  22. return l;
  23. }

练习题

数的范围

这里正好使用上了两种二分的模板

  1. pair<int, int> search(int q[], int num, int arrLen)
  2. {
  3. int first, second;
  4. int l = 0, r = arrLen - 1;
  5. while(l < r)
  6. {
  7. int mid = l + r >> 1;
  8. // 二分:区间是否满足某种性质
  9. // 左端点: 那某那么区间的值都满足>=左端点(check)
  10. if(q[mid] >= num) r = mid;
  11. else l = mid + 1;
  12. }
  13. if(arr[l] != num) // 此时l=r
  14. {
  15. return make_pair(-1, -1);
  16. }
  17. else
  18. {
  19. first = l;
  20. l = 0, r = arrLen - 1;
  21. while(l < r)
  22. {
  23. int mid = l + r + 1 >> 1; // 循环中的变量会被释放,需要重新定义
  24. // 右端点: 区间满足 所有的值<=右端点
  25. if(q[mid] <= num) l = mid;
  26. else r = mid - 1;
  27. }
  28. second = l; // 此时l=r
  29. }
  30. return make_pair(first, second);
  31. }

这里有一个需要理解的地方,为啥两个模板能分别取到左右的最后一个相同的数?

34. 在排序数组中查找元素的第一个和最后一个位置

这里就需要深刻理解二分的本质:请背诵 /doge

数的三次方根

  1. double my_sqrt3(double num)
  2. {
  3. double l = -10000, r = 10000;
  4. while(r - l > 1e-8)
  5. {
  6. double mid = (l + r) / 2;
  7. if(mid * mid * mid >= num) r = mid;
  8. else l = mid;
  9. }
  10. return l;
  11. }

2.4 前缀和与差分

快速的求出数组中某一段的和

image.png

795. 前缀和

796. 子矩阵的和

初始化的时候参考下面的图
image.png

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 1010;
  4. int m, n;
  5. int a[N][N], s[N][N];
  6. int main()
  7. {
  8. int q;
  9. scanf("%d%d%d", &n, &m, &q);
  10. for(int i=1;i<=n;i++)
  11. for(int j=1;j<=m;j++)
  12. scanf("%d", &a[i][j]);
  13. //初始化前缀和数组
  14. for(int i=1;i<=n;i++)
  15. for(int j=1;j<=m;j++)
  16. s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j];
  17. while(q--)
  18. {
  19. int x1, y1, x2, y2;
  20. scanf("%d%d%d%d", &x1, &y1, &x2, &y2);
  21. printf("%d\n", s[x2][y2] - s[x2][y1-1] - s[x1-1][y2] + s[x1-1][y1-1]);
  22. }
  23. return 0;
  24. }

查分
image.png
image.png
a是原数组
b称为a 的查分
a称为b 的前缀和
image.png

  1. b[l] += c;
  2. b[r+1] -= c;

image.png
假设最开始时,原数组为0,那么查分数组也为0
由于原数组每一个都有值,因此可以看成是进行了n次插入操作
(这里是一个关键的需要理解的地方)

因此不需要去想查分是如何构造的的

797. 差分

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 100010;
  4. int a[N], b[N];
  5. void insert(int l, int r, int c)
  6. {
  7. b[l] += c;
  8. b[r+1] -= c;
  9. }
  10. int main()
  11. {
  12. int n, m;
  13. cin >> n >> m;
  14. for (int i=1; i<=n; i++)
  15. cin >> a[i];
  16. // 插入每个元素
  17. for (int i=1; i<=n; i++)
  18. insert(i, i, a[i]);
  19. while (m--)
  20. {
  21. int l, r, c;
  22. cin >> l >> r >> c;
  23. insert(l, r, c);
  24. }
  25. // 累加,得到前缀和 查分数组的前缀和数组也就是原数组
  26. for (int i=1; i<=n; i++)
  27. b[i] += b[i-1];
  28. for (int i=1; i<=n; i++)
  29. cout << b[i] << " ";
  30. cout << endl;
  31. return 0;
  32. }

798. 差分矩阵

image.png

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 1010;
  4. int n, m, q;
  5. int a[N][N], b[N][N];
  6. void insert(int x1, int y1, int x2, int y2, int c)
  7. {
  8. b[x1][y1] += c;
  9. b[x1][y2+1] -= c;
  10. b[x2+1][y1] -= c;
  11. b[x2+1][y2+1] += c;
  12. }
  13. int main()
  14. {
  15. cin >> n >> m >> q;
  16. for(int i=1;i<=n;i++)
  17. for(int j=1;j<=m;j++)
  18. cin >> a[i][j];
  19. for(int i=1;i<=n;i++)
  20. for(int j=1;j<=m;j++)
  21. insert(i,j,i,j,a[i][j]);
  22. while(q--)
  23. {
  24. int x1, y1, x2, y2, c;
  25. cin >> x1 >> y1 >> x2 >>y2 >> c;
  26. insert(x1, y1, x2 ,y2, c);
  27. }
  28. for(int i=1;i<=n;i++)
  29. for(int j=1;j<=m;j++)
  30. b[i][j] += b[i-1][j] + b[i][j-1] - b[i-1][j-1];
  31. for(int i=1;i<=n;i++)
  32. {
  33. for(int j=1;j<=m;j++)
  34. cout << b[i][j] << " ";
  35. cout << endl;
  36. }
  37. return 0;
  38. }

2.5 位运算

lowbit

计算机里一般用补码表示负整数,-x = ~x + 1。

image.png
image.png
image.png

  1. // 得到x二进制数中的最后一个1
  2. int lowbit(int x)
  3. {
  4. return x & -x;
  5. }

原码 补码 反码
image.png
image.png

801. 二进制中1的个数

  1. #include <iostream>
  2. using namespace std;
  3. int main()
  4. {
  5. int n;
  6. scanf("%d", &n);
  7. while (n -- )
  8. {
  9. int x, s = 0;
  10. scanf("%d", &x);
  11. for (int i = x; i; i -= i & -i) s ++ ;
  12. printf("%d ", s);
  13. }
  14. return 0;
  15. }

2.6 离散化(整数)

离散化的本质,是映射,将间隔很大的点,映射到相邻的数组元素中。减少对空间的需求,也减少计算量

image.png

2.8 区间合并

803. 区间合并

  1. #include <iostream>
  2. #include <vector>
  3. #include <algorithm>
  4. #include <climits>
  5. using namespace std;
  6. typedef pair<int, int> PII;
  7. vector<PII> segs;
  8. int n;
  9. void merge(vector<PII> &segs)
  10. {
  11. vector<PII> res;
  12. //区间按左端点排序
  13. sort(segs.begin(), segs.end());
  14. int st = INT_MIN, ed = INT_MIN;
  15. for(auto seg : segs)
  16. if(ed < seg.first)//区间不重叠
  17. {
  18. if(st != INT_MIN) res.push_back({st, ed});
  19. st = seg.first, ed = seg.second;
  20. }
  21. else//区间重叠
  22. ed = max(ed, seg.second);
  23. //防止输入为空, 最后一个区间出现重叠时的情况
  24. if(st != INT_MIN) res.push_back({st, ed});
  25. segs = res;
  26. }
  27. int main()
  28. {
  29. cin >> n;
  30. int l, r;
  31. for(int i=0;i<n;i++)
  32. {
  33. cin >> l >> r;
  34. segs.push_back({l, r});
  35. }
  36. merge(segs);
  37. cout << segs.size();
  38. return 0;
  39. }

第3讲 搜索与图论

3.1 DFS

  • backtrace 回溯
  • 剪枝

搜索顺序很重要

练习题

N Queue

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 10;
  4. char path[N][N]; // choosen path
  5. bool rows[N], cols[N], dg[N*2], udg[N*2]; // state of the col
  6. int n;
  7. // num 已使用皇后的个数
  8. void dfs(int row, int col, int num)
  9. {
  10. if(num > n) return ;
  11. // 从左向右搜索,到达最右边,则搜索下一行第一个
  12. if(col == n) col = 0, row++;
  13. // 若搜索到最后一行,且刚好放了第n个皇后,则输出
  14. if(row == n)
  15. {
  16. if(num == n)
  17. {
  18. for(int i=0; i<n; i++) puts(path[i]);
  19. puts("");
  20. }
  21. return;
  22. }
  23. // 枚举当前位置的两种选择: 放 or 不放
  24. // 不放
  25. path[row][col] = '.';
  26. dfs(row, col + 1, num);
  27. //放皇后 =》 需满足条件:行、列、对角线没有皇后
  28. if(!rows[row] && !cols[col] && !dg[row + col] && !udg[n - col + row])
  29. {
  30. path[row][col] = 'Q';
  31. rows[row] = cols[col] = dg[row + col] = udg[n - col + row] = true;
  32. dfs(row, col+1, num+1);
  33. //回溯
  34. rows[row] = cols[col] = dg[row + col] = udg[n - col + row] = false;
  35. path[row][col] = '.';
  36. }
  37. }
  38. int main()
  39. {
  40. cin >> n;
  41. dfs(0, 0, 0);
  42. return 0;
  43. }

在遍历的过程中,需要控制左右边界是否越界,同时也需要记录已经使用皇后的数量

优化做法:

在递归的过程中,仅记录行数或列数;并且每行都使用一个皇后,不需要记录行数

  1. // it's important to calc the diagonal and under diagonal
  2. // just like line equation y = x + i (diagonal) y = -x + i (under diagonal)
  3. // y = -x + i + n to guarante y is great than 0
  4. // 在变化的过程中,每一行(列)遍历时,都是将i作为常量看待
  5. void dfs(int row)
  6. {
  7. if(row == n)
  8. {
  9. for(int i=0; i<n; i++)
  10. puts(g[i]);
  11. puts("");
  12. return ;
  13. }
  14. for(int i=0; i<n; i++)
  15. {
  16. if(!col[i] && !dg[i+row] && !udg[i-row+n])
  17. {
  18. g[row][i] = 'Q';
  19. col[i] = dg[i+row] = udg[i-row+n] = true;
  20. dfs(row+1);
  21. g[row][i] = '.';
  22. col[i] = dg[i+row] = udg[i-row+n] = false;
  23. }
  24. }
  25. }

3.2 BFS

BFS可以保证找到最短路

规律

当所有边的权重都是一样的时候,才能用BFS求最短路

模板

  1. queue <-- init;
  2. while(queue is not empty)
  3. {
  4. t <-- queue head;
  5. expand t's next;
  6. }

练习题

走迷宫

DFS不保证是最短路

bfs_1.jpg

这里的记录方式很优秀,通过(n,m)的值,就能得到最短路径

这里有个很优秀的做法,通过一个数组,记录是否被访问的同时,也记录了到起点的最短路径值

  1. #include <iostream>
  2. #include <queue>
  3. #include <cstring> // memset
  4. using namespace std;
  5. const int N = 110;
  6. int g[N][N], vis[N][N];
  7. int m, n;
  8. int bfs()
  9. {
  10. queue<pair<int, int>> q;
  11. // vis记录是否访问,同时记录当前点到(0,0)的最短路径值
  12. memset(vis, -1, sizeof vis);
  13. vis[0][0] = 0; //设置(0,0)的最短路径值
  14. q.push({0, 0});
  15. int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, -1, 1};
  16. while(!q.empty())
  17. {
  18. auto t = q.front(); q.pop();
  19. //向下层(四周)扩散
  20. for(int i=0; i<4; i++)
  21. {
  22. int x = t.first + dx[i], y = t.second + dy[i];
  23. // 在范围内,且能访问,并未访问
  24. if(x>=0 && x<n && y>=0 && y<m && g[x][y]==0 && vis[x][y]==-1)
  25. {
  26. vis[x][y] = vis[t.first][t.second] + 1;
  27. q.push({x, y});
  28. }
  29. }
  30. }
  31. return vis[n-1][m-1];
  32. }
  33. int main()
  34. {
  35. cin >> n >> m;
  36. for(int i=0; i<n; i++)
  37. for(int j=0; j<m; j++)
  38. cin >> g[i][j];
  39. cout << bfs();
  40. return 0;
  41. }

八数码

bsm1.jpg

代码待写

3.3 树和图的存储


image.png

稠密图和稀疏图

一个图中,顶点数 n 边数 m
算法基础acwing - 图50>>m 时,我们称之为稀疏。
当m相对较大时,我们称之为稠密。

邻接矩阵

通常用来存储稠密图

邻接表

  1. int h[N]; // 存放每个链表的头节点 通常是初始化为-1, 用-1来判断是否到达最后一个节点
  2. int e[N]; // 存放每个节点的值
  3. int ne[N]; // 存放下一个节点对应的idx (N)
  4. int idx; // 给每个节点编号 (就是上面所有的N对应的值)
  5. // 插入一条邻接表
  6. // 在a所在的邻接表里插入一个节点b
  7. void add(int a, int b)
  8. {
  9. e[idx] = b; // 先存储
  10. ne[idx] = h[a]; // 插入到第一个,参考h[2] 插入节点3
  11. h[a] = idx++;
  12. }
  13. memset(h, -1, sizeof h);

3.4 拓扑排序

宽搜的一个应用
图谱序一定是有向图

拓扑序

若一个由图中所有点构成的序列 A满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列。

image.png
对于每一条边,起点都在终点的前面。都是从前指向后

有向无环图一定存在拓扑序,因此也被称为拓扑图

入度和出度

image.png
所有入度为0度点都可以排在最前面的位置

image.png

d[j]表示第j个点的入度

848. 有向图的拓扑序列

image.png
ref

  1. #include<iostream>
  2. #include<cstring>
  3. #include<algorithm>
  4. using namespace std;
  5. const int N=100010;
  6. int h[N],e[N],ne[N],idx;
  7. int n,m;
  8. int q[N],d[N];//q表示队列,d表示点的入度
  9. void add(int a,int b)
  10. {
  11. e[idx]=b;
  12. ne[idx]=h[a];
  13. h[a]=idx++;
  14. }
  15. bool topsort()
  16. {
  17. // 使用数组模拟队列
  18. int hh=0,tt=-1; // 队头 队尾
  19. for(int i=1;i<=n;i++)
  20. if(!d[i])
  21. q[++tt]=i;//将入度为零的点入队
  22. while(hh<=tt)
  23. {
  24. int t=q[hh++];
  25. for(int i=h[t];i!=-1;i=ne[i])
  26. {
  27. int j=e[i];
  28. d[j]--;//删除点t指向点j的边
  29. if(d[j]==0)//如果点j的入度为零了,就将点j入队
  30. q[++tt]=j;
  31. }
  32. }
  33. return tt==n-1;
  34. //表示如果n个点都入队了话,那么该图为拓扑图,返回true,否则返回false
  35. }
  36. int main()
  37. {
  38. cin>>n>>m;
  39. memset(h,-1,sizeof(h));//如果程序时间溢出,就是没有加上这一句
  40. for(int i=0;i<m;i++)
  41. {
  42. int a,b;
  43. scanf("%d%d",&a,&b);
  44. add(a,b);//因为是a指向b,所以b点的入度要加1
  45. d[b]++;
  46. }
  47. if(topsort())
  48. {
  49. for(int i=0;i<n;i++)
  50. printf("%d ",q[i]);
  51. //经上方循环可以发现队列中的点的次序就是拓扑序列
  52. //注:拓扑序列的答案并不唯一,可以从解析中找到解释
  53. puts("");
  54. }
  55. else
  56. puts("-1");
  57. return 0;
  58. }

拓扑序并不是唯一的
image.png

3.5 最短路算法

难点: 建图

稠密图 :邻接矩阵
稀疏图:邻接表

稠密图和稀疏图
一个图中,顶点数 n 边数 m
算法基础acwing - 图56>>m 时,我们称之为稀疏
当m相对较大时,我们称之为稠密

有向图和无向图

无向图是一种特殊的有向图,可表示为两个有向图

image.png

Dijkstra

image.png
每次循环能确定一个点到所有点的最短距离
image.png

  1. 初始化

image.png

  1. 迭代 n次

每次迭代,找到当前没有确定的点的最小值, 然后更新其他所有点到这个点的距离
image.png
再从没有确定的点中迭代(红色表示没有确定的点的权值)

image.png
将2加入s,然后用2更新其他的点
image.png
下一轮迭代
image.png
至此,每一个点到起点的最短距离,都已经确定了

image.png

849. Dijkstra求最短路 I
  1. int g[N][N]; // 邻接矩阵
  2. int dist[N]; // 从1到每个点的当前最短距离
  3. bool st[N]; // 每个点的最短路是否已经确定了
  4. int n, m;
  5. int dijkstra()
  6. {
  7. // 1. 初始化距离
  8. memset(dist, 0x3f, sizeof dist);
  9. dist[1] = 0;
  10. // 2. 迭代n次,找最小值
  11. for (int i=0; i<n; i++)
  12. {
  13. // a. 找当前没有确定最短路长度的点当中距离最小的那个点
  14. int t = -1;
  15. for (int j=1; j<=n; j++)
  16. if (!st[j] && (t == -1 || dist[t] > dist[j])) // 当前点没有确定最小值 && ()
  17. t = j;
  18. st[t] = true; // 将t加到集合st中去
  19. // 3. 用dist[t] 更新从1到i的距离
  20. for (int i=1; i<=n; i++)
  21. dist[i] = min(dist[i], dist[t] + g[t][i]);
  22. }
  23. if (dist[n] == INF) return -1; // 1和n之间不连通
  24. return dist[n];
  25. }

使用最小堆对算法进行优化,这样就能在O(1)的时间内取出最小值了

850. Dijkstra求最短路 II
  1. #include <iostream>
  2. #include <queue> // priority_queue
  3. #include <vector>
  4. #include <cstring> // memset
  5. #define INF 0x3f3f3f3f
  6. using namespace std;
  7. using PII = pair<int, int>;
  8. int n, m;
  9. const int N = 1e6 + 10;
  10. int h[N], e[N], ne[N], w[N], idx;
  11. int dist[N];
  12. bool st[N];
  13. void add(int a, int b, int c )
  14. {
  15. e[idx] = b;
  16. ne[idx] = h[a];
  17. w[idx] = c;
  18. h[a] = idx++;
  19. }
  20. int dijkstra()
  21. {
  22. memset(dist, 0x3f, sizeof dist);
  23. dist[1] = 0;
  24. priority_queue<PII, vector<PII>, greater<PII>> heap; // 距离,编号
  25. heap.push({0, 1});
  26. while (!heap.empty())
  27. {
  28. auto t = heap.top();
  29. heap.pop();
  30. int distance = t.first, ver = t.second; // ver 编号
  31. if (st[ver]) continue;
  32. st[ver] = true;
  33. for (int i = h[ver]; i != -1; i = ne[i])
  34. {
  35. int j = e[i];
  36. if (dist[j] > dist[ver] + w[i])
  37. dist[j] = dist[ver] + w[i];
  38. heap.push({dist[j], j});
  39. }
  40. }
  41. if (dist[n] == INF) return -1;
  42. return dist[n];
  43. }
  44. int main()
  45. {
  46. memset(h, -1, sizeof h);
  47. cin >> n >> m;
  48. while (m--)
  49. {
  50. int a, b, c;
  51. cin >> a >> b >> c;
  52. add(a, b, c);
  53. }
  54. int t = dijkstra();
  55. cout << t << endl;
  56. return 0;
  57. }

这里邻接表插入的代码参考3.3 邻接表

Bellman-Ford

image.png

有最短路,通常不存在负权值

3 3 1 1 2 1 2 3 1 1 3 3

对于样例, k=1,一次只能使用一条边,那么1到3的最短距离是3
image.png
如果更新的时候,先更新2为1, 然后更新3为1
由于这里更新使用到了上一次的结果,为了更新的时候只使用上一轮的结果。因此需要备份。

image.png
为啥不能存在环的原因(上图)

853. 有边数限制的最短路
  1. #include <iostream>
  2. #include <cstring>
  3. #define INF 0x3f3f3f3f
  4. using namespace std;
  5. const int N = 510, M = 10010;
  6. struct Edge
  7. {
  8. int a, b, w;
  9. }edges[M];
  10. int dist[N];
  11. int last[N]; // 备份
  12. int n, m, k;
  13. void bellman_ford()
  14. {
  15. memset(dist, 0x3f, sizeof dist);
  16. dist[1] = 0;
  17. for (int i=0; i<k; i++)
  18. {
  19. memcpy(last, dist, sizeof last);
  20. for (int j=0; j<m; j++)
  21. {
  22. auto e = edges[j];
  23. dist[e.b] = min(dist[e.b], last[e.a] + e.w);
  24. }
  25. }
  26. }
  27. int main()
  28. {
  29. cin >> n >> m >> k;
  30. for (int i=0; i<m; i++)
  31. {
  32. int a, b, w;
  33. cin >> a >> b >> w;
  34. edges[i] = {a, b, w};
  35. }
  36. bellman_ford();
  37. if (dist[n] > INF / 2) cout << "impossible" << endl;
  38. else cout << dist[n] << endl;
  39. return 0;
  40. }

SPFA

是对上面Bellman_Ford算法的改进

由下面的公式可以知道,只有dist[a] 更新了,dist[b]才会更新
image.png
更新过谁,它后面的点才回被更新
image.png

851. spfa求最短路(important)
  1. #include <iostream>
  2. #include <queue>
  3. #include <cstring>
  4. #define INF 0x3f3f3f3f
  5. using namespace std;
  6. const int N = 100010;
  7. int dist[N];
  8. bool st[N];
  9. int h[N], e[N], ne[N], w[N], idx;
  10. int n, m;
  11. void add(int a, int b, int c)
  12. {
  13. e[idx] = b;
  14. w[idx] = c;
  15. ne[idx] = h[a];
  16. h[a] = idx++;
  17. }
  18. void spfa()
  19. {
  20. memset(dist, 0x3f, sizeof dist);
  21. dist[1] = 0;
  22. queue<int> q;
  23. q.push(1);
  24. st[1] = true;
  25. while (!q.empty())
  26. {
  27. auto t = q.front();
  28. q.pop();
  29. st[t] = false;
  30. for (int i=h[t]; i != -1; i = ne[i])
  31. {
  32. int j = e[i];
  33. if (dist[j] > dist[t] + w[i])
  34. {
  35. dist[j] = dist[t] + w[i];
  36. if (!st[j])
  37. {
  38. q.push(j);
  39. st[j] = true;
  40. }
  41. }
  42. }
  43. }
  44. }
  45. int main()
  46. {
  47. cin >> n >> m;
  48. memset(h, -1, sizeof h);
  49. int a, b, w;
  50. while (m--)
  51. {
  52. cin >> a >> b >> w;
  53. add(a, b, w);
  54. }
  55. spfa();
  56. if (dist[n] > INF/2) puts("impossibe");
  57. else cout << dist[n] << endl;
  58. return 0;
  59. }

852. spfa判断负环

image.png

  1. #include <iostream>
  2. #include <queue>
  3. #include <cstring>
  4. #define INF 0x3f3f3f3f
  5. using namespace std;
  6. const int N = 100010;
  7. int dist[N];
  8. bool st[N];
  9. int cnt[N];
  10. int h[N], e[N], ne[N], w[N], idx;
  11. int n, m;
  12. void add(int a, int b, int c)
  13. {
  14. e[idx] = b;
  15. w[idx] = c;
  16. ne[idx] = h[a];
  17. h[a] = idx++;
  18. }
  19. int spfa()
  20. {
  21. queue<int> q;
  22. for (int i=1; i<=n; i++)
  23. q.push(i), st[i] = true;
  24. while (!q.empty())
  25. {
  26. auto t = q.front();
  27. q.pop();
  28. st[t] = false;
  29. for (int i=h[t]; i != -1; i = ne[i])
  30. {
  31. int j = e[i];
  32. if (dist[j] > dist[t] + w[i])
  33. {
  34. dist[j] = dist[t] + w[i];
  35. cnt[j] = cnt[t] + 1;
  36. if (cnt[j] >= n) return true;
  37. if (!st[j])
  38. {
  39. q.push(j);
  40. st[j] = true;
  41. }
  42. }
  43. }
  44. }
  45. return false;
  46. }
  47. int main()
  48. {
  49. cin >> n >> m;
  50. memset(h, -1, sizeof h);
  51. int a, b, w;
  52. while (m--)
  53. {
  54. cin >> a >> b >> w;
  55. add(a, b, w);
  56. }
  57. if (spfa()) puts("Yes");
  58. else puts("No");
  59. return 0;
  60. }

Floyd

image.png
这里是一个动态规划的思想
image.png
d[k,i,j]表示从i到j,只经过1-k中的某一点的最小路径

854. Floyd求最短路
  1. #include <iostream>
  2. #include <cstring>
  3. using namespace std;
  4. const int N = 210, INF = 1e9;
  5. int n, m, Q;
  6. int d[N][N];
  7. void floyd()
  8. {
  9. for (int k=1; k<=n; k++)
  10. for (int i=1; i<=n; i++)
  11. for (int j=1; j<=n; j++)
  12. d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
  13. }
  14. int main()
  15. {
  16. cin >> n >> m >> Q;
  17. for (int i=1; i<=n; i++)
  18. for (int j=1; j<=n; j++)
  19. if (i == j) d[i][j] = 0;
  20. else d[i][j] = INF;
  21. int a, b, w;
  22. while (m--)
  23. {
  24. cin >> a >> b >> w;
  25. d[a][b] = min(d[a][b], w); //重边取最小的
  26. }
  27. floyd();
  28. while (Q--)
  29. {
  30. cin >> a >> b;
  31. int t = d[a][b];
  32. if (t > INF / 2) puts("impossible");
  33. else cout << t << endl;
  34. }
  35. return 0;
  36. }

3.6 最小生成树

image.png

生存树的最小代价

Prim算法

image.png

858. Prim算法求最小生成树
  1. #include <iostream>
  2. #include <cstring>
  3. using namespace std;
  4. const int N = 510, INF = 0x3f3f3f3f;
  5. int g[N][N];
  6. int dist[N]; // 到集合(已构成到最小生成树)的距离
  7. bool st[N];
  8. int n, m;
  9. int prim()
  10. {
  11. memset(dist, 0x3f, sizeof dist);
  12. int res = 0; // 最小生成树里的所有边点长度之和
  13. for (int i=0; i<n; i++)
  14. {
  15. int t = -1; // 当前最近的点
  16. for (int j=1; j<=n; j++)
  17. {
  18. if (!st[j] && (t == -1 || dist[t] > dist[j])) // j不在集合内||t=-1 表示还没有找到一个点
  19. t = j;
  20. }
  21. // 不是第一个点 || 当前点不连通
  22. if (i && dist[t] == INF) return INF; // 不存在最小生产树
  23. // 先累计,再更新
  24. // 最小生成树不能有自环, 若是有自环,且权值很小时,若先更新,会将权值更新的更小
  25. if (i) res += dist[t];
  26. st[t] = true;
  27. // 更新距离
  28. for (int j=1; j<=n; j++)
  29. dist[j] = min(dist[j], g[t][j]);
  30. }
  31. return res;
  32. }
  33. int main()
  34. {
  35. memset(g, 0x3f, sizeof g);
  36. cin >> n >> m;
  37. while (m--)
  38. {
  39. int a, b, w;
  40. cin >> a >> b >> w;
  41. g[b][a] = g[a][b] = min(g[b][a], w);
  42. }
  43. int t = prim();
  44. if (t == INF) puts("impossible");
  45. else cout << t <<endl;
  46. return 0;
  47. }

Kruskal

image.png

859. Kruskal算法求最小生成树
  1. #include <iostream>
  2. #include <cstring>
  3. #include <algorithm>
  4. using namespace std;
  5. const int N =100010, M = 200010, INF = 0x3f3f3f3f;
  6. int p[N];
  7. int n, m;
  8. struct Edge
  9. {
  10. int a, b, w;
  11. bool operator< (const Edge &rhs)
  12. {
  13. return rhs.w > w;
  14. }
  15. } edges[M];
  16. int find(int x)
  17. {
  18. if (x != p[x])
  19. p[x] = find(p[x]);
  20. return p[x];
  21. }
  22. int kruskal()
  23. {
  24. sort(edges, edges+m);
  25. for (int i=1; i<=n; i++) p[i] = i; // 初始化并查集
  26. int res = 0, usedEdges = 0;
  27. for (int i=0; i<m; i++)
  28. {
  29. int a = edges[i].a, b = edges[i].b, w = edges[i].w;
  30. a = find(a), b = find(b);
  31. if (a != b)
  32. {
  33. p[a] = b;
  34. res += w;
  35. usedEdges++;
  36. }
  37. }
  38. if (usedEdges < n-1) return INF;
  39. return res;
  40. }
  41. int main()
  42. {
  43. cin >> n >> m;
  44. for (int i=0; i<m; i++)
  45. {
  46. int a, b, w;
  47. cin >> a >> b >> w;
  48. edges[i] = {a, b, w};
  49. }
  50. int t = kruskal();
  51. if (t == INF) puts("impossible");
  52. else cout << t << endl;
  53. return 0;
  54. }

3.7 二分图

image.png

可以将所有的点划分到两个集合中,使得所有的边都在两个集合之间,集合内没有边

二分图 《==》 图中不包含奇数环

必要性:
image.png
如果环中点的个数是奇数,那么回到起点的时候,起点会被标记为2,
那么环就不能被分为两个集合了

image.png
由于图中不含有奇数环,所以染色过程一定没有矛盾

染色法

image.png

860. 染色法判定二分图
  1. #include <iostream>
  2. #include <cstring>
  3. using namespace std;
  4. const int N = 100010, M = 200010;
  5. // N条边,则可能有2N个点
  6. int h[N], e[M], ne[M], idx;
  7. int color[N];
  8. int n, m;
  9. void add(int a, int b)
  10. {
  11. e[idx] = b;
  12. ne[idx] = h[a];
  13. h[a] = idx++;
  14. }
  15. bool dfs(int u, int c) // c表示染色的颜色
  16. {
  17. color[u] = c;
  18. for (int i = h[u]; i != -1; i = ne[i])
  19. {
  20. int j = e[i];
  21. if (!color[j])
  22. {
  23. // 3-c 1和2互换,表示染不同的颜色
  24. if (!dfs(j, 3-c)) // 染色失败
  25. return false;
  26. }
  27. else if (color[j] == c) // 染色相同,矛盾
  28. return false;
  29. }
  30. return true;
  31. }
  32. int main()
  33. {
  34. memset(h, -1, sizeof h);
  35. cin >> n >> m;
  36. while (m--)
  37. {
  38. int a, b;
  39. cin >> a >> b;
  40. add(a, b), add(b, a);
  41. }
  42. bool flag = true; // 是否染色失败,有矛盾发生
  43. for (int i=1; i<=n; i++)
  44. {
  45. if (!color[i])
  46. {
  47. if (!dfs(i, 1))
  48. {
  49. flag = false;
  50. break;
  51. }
  52. }
  53. }
  54. if (flag) puts("Yes");
  55. else puts("No");
  56. return 0;
  57. }

匈牙利算法

image.png
算法步骤:

  1. 遍历所有男生
    1. 重置所有的妹子的访问状态为0
    2. 当前男生和妹子进行匹配
      1. 枚举该男生看上的所有妹子(该点所有连接的点)
        1. 如果该妹纸之前没有被考虑过
        2. 如果该妹纸没有匹配任何男生 || 已经匹配了男生 但是能为妹纸匹配的男生找到下家
          1. 匹配成功

861. 二分图的最大匹配
  1. #include <iostream>
  2. #include <cstring>
  3. using namespace std;
  4. const int N = 510, M = 100010;
  5. int h[N], e[M], ne[M], idx;
  6. bool st[N];
  7. int match[N];
  8. int n1, n2, m;
  9. void add(int a, int b)
  10. {
  11. e[idx] = b;
  12. ne[idx] = h[a];
  13. h[a] = idx++;
  14. }
  15. bool find(int x)
  16. {
  17. for (int i = h[x]; i != -1; i = ne[i]) // 枚举该男生看上的所有妹子
  18. {
  19. int j = e[i];
  20. if (!st[j]) // 之前没有考虑过
  21. {
  22. st[j] = true;
  23. if (match[j] == 0 || find(match[j])) // 妹子没有匹配任何男生 || 妹子匹配了男生但是能为男生找到下家(别的妹子)
  24. {
  25. match[j] = x; // 匹配成功
  26. return true;
  27. }
  28. }
  29. }
  30. return false;
  31. }
  32. int main()
  33. {
  34. cin >> n1 >> n2 >> m;
  35. memset(h, -1, sizeof h);
  36. while (m--)
  37. {
  38. int a, b;
  39. cin >> a >> b;
  40. add(a, b);
  41. }
  42. int res = 0;
  43. for (int i=1; i<=n1; i++) // 依次查看每个男生的情况
  44. {
  45. memset(st, false, sizeof st); // 将所有的妹子清空,保证每个人妹子只被考虑一次
  46. if (find(i)) res++; // 成功找到
  47. }
  48. cout << res << endl;
  49. return 0;
  50. }

第4讲 数学知识

4.1质数

definition:

  1. 大于1的数,且只能被1和本身整除

试除法求质数

  • 约数是成对出现的,因此可以缩小范围
  • 注意溢出问题
  1. bool is_prime(int n)
  2. {
  3. if(n < 2) return false; // 特殊情况
  4. for(int i=2; i <= n/i; i++)
  5. if(n % i == 0)
  6. return false;
  7. return true;
  8. }

复杂度

  • 时间
    sqrt(n)

分解质因数

  • 大于sqrt(n)的质因数只有一个
  • 理解透一点,如何保证每次遍历到的i一定是质数
    • 由于当前i,一定是不可能被前面的数整除的,所以一定是质因数。
  • 缩小范围 i<=n/i
  1. void divide(int n)
  2. {
  3. for(int i=2; i<=n/i; i++)
  4. {
  5. int k = 0;
  6. while(n%i == 0)
  7. {
  8. k++;
  9. n /= i;
  10. }
  11. if(k)
  12. cout << i << " " << k << endl;
  13. }
  14. if(n>1) cout << n << " 1" << endl;
  15. cout << endl;
  16. }

复杂度

  • 时间
    log(n) - sqrt(n)

筛素数

埃氏筛法
  • 做法:
    删除当前质数的所有合数(该质数的倍数)
  1. // cnt : 记录个数
  2. // primes : 保存质数
  3. void get_primes(int n)
  4. {
  5. for(int i=2; i<=n; i++)
  6. {
  7. if(!vis[i])
  8. {
  9. primes[cnt++] = i;
  10. for(int j=i+i; j<=n; j+=i)
  11. vis[j] = true;
  12. }
  13. }
  14. }

线性筛法
  • 做法
    用已有的所有质数,去删掉对应的合数

切记

  1. 不要将筛选的过程放到判断中去
  1. // 线性筛选法
  2. void get_primes_(int n)
  3. {
  4. for(int i=2; i<=n; i++)
  5. {
  6. if(!st[i]) // primer
  7. prime[cnt++] = i;
  8. for(int j=0; prime[j]<=n/i; j++)
  9. {
  10. // 循环的条件可以保证prime[j]*i <= n
  11. st[prime[j] * i] = true;
  12. // prime[j]是i的最小质因子,为啥退出循环
  13. // 保证每个合数都是被最小质数筛去,保证每个数只被标记一次,减少运算
  14. if(i % prime[j] == 0) break;
  15. }
  16. }
  17. }

image.png
因为每次枚举是从小的质数开始枚举的,对于某个合数,如果我用了大的质数进行标记,后面肯定又会用小的质数重复标记。

4.2 约数

试除法求约数

  • 理解约数是成对出现的
  • 注意:
    特殊情况的处理
  1. vector<int> get_dividors(int n)
  2. {
  3. vector<int> res;
  4. for(int i=1; i<=n/i; i++)
  5. {
  6. if(n%i == 0)
  7. {
  8. res.emplace_back(i);
  9. // 成对出现
  10. if(i != n/i) res.emplace_back(n/i);
  11. }
  12. }
  13. sort(res.begin(), res.end());
  14. return res;
  15. }

注意:这里思路和素数那里是一样的,都有利用约数是成对出现

复杂度

  • 时间
    sqrt(n)

约数的个数

求输入的一些列的数的乘积的约数的个数

a1 a2 a3 … an的乘积可以表示为:

算法基础acwing - 图83

其中p为质数,由于对任意的p_i可以选择0~x_i次,因此其约数的个数可以表示为:

算法基础acwing - 图84(x_2%2B1)…*(x_n%2B1)%0A#card=math&code=%28x_1%2B1%29%2A%28x_2%2B1%29%2A…%2A%28x_n%2B1%29%0A&id=TY4Ft)

  1. const int MOD = 1e9 + 7;
  2. unordered_map<int, int> primes;
  3. int main()
  4. {
  5. int n;
  6. cin >> n;
  7. while(n--)
  8. {
  9. int a; cin >> a;
  10. for(int i=2; i<=a/i; i++)
  11. {
  12. while(a % i == 0)
  13. {
  14. primes[i]++;
  15. a /= i;
  16. }
  17. }
  18. if(a > 1) primes[a]++;
  19. }
  20. long long res = 1;
  21. for(auto item : primes)
  22. res = res*(item.second+1) % MOD;
  23. cout << res;
  24. return 0;
  25. }

约数之和

求输入的一些列的数的乘积的所有约数的和

a1 a2 a3 … an的乘积可以表示为:

算法基础acwing - 图85

其中p为质数,由于对任意的p_i可以选择0~x_i次,因此其约数的和可以表示为:

算法基础acwing - 图86(p_2%5E%7B0%7D%2Bp_2%5E%7B1%7D%2B…%2Bp_2%5E%7Bx_2%7D)(p_n%5E%7B0%7D%2Bp_n%5E%7B1%7D%2B…%2Bp_n%5E%7Bx_n%7D)%0A#card=math&code=%28p_1%5E%7B0%7D%2Bp_1%5E%7B1%7D%2B…%2Bp_1%5E%7Bx_1%7D%29%28p_2%5E%7B0%7D%2Bp_2%5E%7B1%7D%2B…%2Bp_2%5E%7Bx_2%7D%29%2A…%2A%28p_n%5E%7B0%7D%2Bp_n%5E%7B1%7D%2B…%2Bp_n%5E%7Bx_n%7D%29%0A&id=ffYUw)

如何计算()中的每一项呢?函数式编程思想

算法基础acwing - 图87%3D(((1%2Bx)%2B1)…)%2B1%0A#card=math&code=%281%2Bx%5E1%2Bx%5E2%2B…%2Bx%5En%29%3D%28%28%281%2Bx%29%2B1%29…%29%2B1%0A&id=WVbgP)

其中有n-1个()需要展开

  1. #include <iostream>
  2. #include <unordered_map>
  3. using namespace std;
  4. using LL = long long;
  5. const int MOD = 1e9 + 7;
  6. unordered_map<int, int> primes;
  7. int main()
  8. {
  9. int n;
  10. cin >> n;
  11. while(n--)
  12. {
  13. int a;
  14. cin >> a;
  15. for(int i=2; i<=a/i; i++)
  16. {
  17. while(a%i == 0)
  18. {
  19. primes[i]++;
  20. a /= i;
  21. }
  22. }
  23. if(a>1) primes[a]++;
  24. }
  25. LL res = 1;
  26. for(auto p : primes)
  27. {
  28. int prime = p.first, frequency = p.second;
  29. LL sum_pi = 1;
  30. while(frequency--)
  31. sum_pi = (sum_pi*prime + 1) % MOD;
  32. res = res * sum_pi % MOD;
  33. }
  34. cout << res;
  35. return 0;
  36. }

872. 最大公约数(GCD)

Greatest Common Diviso

image.png

  1. int gcd(int a, int b){
  2. return b ? gcd(b,a%b):a;
  3. }

可以看到知道b为0时,才结束递归,返回a
无论输入的时候是a大还是b大,都行

若b >a, —> gcd(b, a%b) = gcd(b, a)

LCM

Least Common Multiple

  1. int lcm(int a, int b)
  2. {
  3. return a * b / gcd(a, b);
  4. }

4.3 快速幂

快速幂

思路:

其中算法基础acwing - 图89可以用如下算法基础acwing - 图90的式子表示出来

算法基础acwing - 图91

相乘可以看出

算法基础acwing - 图92

其中求出k是关键

如何用算法基础acwing - 图93表示出算法基础acwing - 图94呢?

那么这里就可以联想k的二进制形式求出,如 算法基础acwing - 图95时,有:

算法基础acwing - 图96_2%3D2%5E0%20%2B%202%5E2#card=math&code=5%3D%28101%29_2%3D2%5E0%20%2B%202%5E2&id=fCtES)

求幂的常规解法O(n)

  1. using LL = long long;
  2. LL FasterPower(int base, int exponent, int mod)
  3. {
  4. LL res = 1;
  5. while(exponent--)
  6. res = res * base % mod;
  7. return res;
  8. }

快速幂解法O(logn)

  1. using LL = long long;
  2. LL FasterPower(int base, int exponent, int mod)
  3. {
  4. LL res = 1;
  5. while (exponent)
  6. {
  7. if (exponent & 0x01)
  8. res = res * base % mod;
  9. exponent >>= 1;
  10. base = (LL)base * base % mod;
  11. }
  12. return res;
  13. }

只有当指数表示的二进制位为1时,才计算

底数在循环的时候迭代,表示出当前的2^i

第5讲 动态规划

5.1 背包问题

DP常见步骤

  • 状态表示
    • f[i][j]表示的含义(通常每一个维度表示一种条件)
    • 求最大还是最小
  • 状态计算
    • 如何实现状态的转移

参考网址

01背包

有 NN 件物品和一个容量是 VV 的背包。每件物品只能使用一次。 第 ii 件物品的体积是 vivi,价值是 wiwi。 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。 所用的物品只能用一次

dp1.png

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 1010;
  4. int f[N][N], w[N], v[N];
  5. int main()
  6. {
  7. int n, m;
  8. cin >> n >> m;
  9. for(int i=1; i<=n; i++)
  10. cin >> v[i] >> w[i];
  11. for(int i=1; i<=n; i++)
  12. {
  13. for(int j=1; j<=m; j++)
  14. {
  15. if(j >= v[i])
  16. f[i][j] = max(f[i-1][j], f[i-1][j-v[i]] + w[i]); // 这里是f[i-1][j]
  17. else
  18. f[i][j] = f[i-1][j];
  19. }
  20. }
  21. cout << f[n][m];
  22. return 0;
  23. }

下面的写法可能更好理解,无聊是否大于当前物品的体积,都要考虑不取当前物品的场景

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 1100;
  4. int v[N], w[N];
  5. int f[N][N];
  6. int main() {
  7. int n, V;
  8. cin >> n >> V;
  9. for (int i=1; i<=n; i++) cin >> v[i] >> w[i];
  10. for (int i=1; i<=n; i++) {
  11. for (int j=1; j<=V; j++) {
  12. if (j >= v[i])
  13. f[i][j] = max(f[i][j], f[i-1][j-v[i]] + w[i]);
  14. f[i][j] = max(f[i][j], f[i-1][j]);
  15. }
  16. }
  17. cout << f[n][V];
  18. return 0;
  19. }

实践

按我的理解,这里也可以写成if else 模式 === > 已改

dp问题的优化,基本上都是代码层面的优化。

将二维数组变成了一维数组,时间复杂度没有变

为什么能进行空间上的优化?

f[i][j] 都是由f[i-1][]转化而来,所以没有必要保存之前的状态

如何实现优化?

内层的循环采用逆序

为啥内层循环要逆序??

blog

下图解释的非常好,(这里描述的i和j和我代码中有些相反,主要是借用了别人的图)

主要是为了保持本次更新用到的值【f[i-v[j]]】不会受到本次更新的影响,

保持f[i-v[j]]都是上一轮更新的值,从而能实现空间上 的优化

01bag.png

  1. int main()
  2. {
  3. int n, m;
  4. cin >> n >> m;
  5. for(int i=1; i<=n; i++)
  6. cin >> v[i] >> w[i];
  7. for(int i=1; i<=n; i++)
  8. for(int j=m; j>=v[i]; j--) // 逆序
  9. f[j] = max(f[j], f[j-v[i]] + w[i]);
  10. cout << f[m];
  11. return 0;
  12. }

完全背包

有 NN 种物品和一个容量是 VV 的背包,每种物品都有无限件可用。 第 ii 种物品的体积是 vivi,价值是 wiwi。 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。 所有的物品都能用无限次

  • 状态表示:
    同01背包
  • 状态转移

朴素解法

  1. int f[N][N], v[N], w[N];
  2. int main()
  3. {
  4. int n, m;
  5. cin >> n >> m;
  6. for(int i=1; i<=n; i++)
  7. cin >> v[i] >> w[i];
  8. for (int i=1; i<=n; i++)
  9. for(int j=1; j<=m; j++)
  10. for (int k=0; k<=j/v[i]; k ++)
  11. f[i][j] = max(f[i][j], f[i-1][j-k*v[i]] + k*w[i]);
  12. cout << f[n][m];
  13. return 0;
  14. }

疑问:

为啥状态转移max中要用f[i][j]不用f[i-1][j]

这里是用f[i, j]来存储最大值,所以每次都要用f[i, j]来更新。

三层循环,肯定是会超过时间的限制的,那么如何优化呢?

从状态转移的过程中推导

算法基础acwing - 图99#card=math&code=f%5Bi%5D%5Bj%5D%3Dmax%5C%7Bf%5Bi-1%5D%5Bj%5D%2C%5C%3A%20f%5Bi-1%5D%5Bj-v%5Bi%5D%5D%2Bw%5Bi%5D%2C%5C%3Af%5Bi-1%5D%5Bj-2%2Av%5Bi%5D%5D%2B2%2Aw%5Bi%5D%2C%5C%3A…%2C%5C%3Af%5Bi-1%5D%5Bj-k%2Av%5Bi%5D%5D%2Bk%2Aw%5Bi%5D%5C%7D%5C%3A%5C%3A%EF%BC%88k%2Av%5Bi%5D%3C%3Dj%EF%BC%89%5C%3A%5C%3A%281%29&id=q3iie)

如何通过上面的表达式构造出优化表达式呢?

通过观察可以发现,f[i][j]中j每次的变化量都是v[i]。那么,我们可以构造出f[i][j-v[i]],依次消元

算法基础acwing - 图100v%5Bi%5D%5D%2B(k-1)w%5Bi%5D%5C%7D%5C%3A%5C%3A%EF%BC%88(k-1)*v%5Bi%5D%3C%3Dj%EF%BC%89%5C%3A%5C%3A(2)#card=math&code=f%5Bi%5D%5Bj-v%5Bi%5D%5D%3Dmax%5C%7Bf%5Bi-1%5D%5Bj-v%5Bi%5D%5D%2Bw%5Bi%5D%2C%5C%3Af%5Bi-1%5D%5Bj-2%2Av%5Bi%5D%5D%2B2%2Aw%5Bi%5D%2C%5C%3A…%2C%5C%3Af%5Bi-1%5D%5Bj-%28k-1%29%2Av%5Bi%5D%5D%2B%28k-1%29%2Aw%5Bi%5D%5C%7D%5C%3A%5C%3A%EF%BC%88%28k-1%29%2Av%5Bi%5D%3C%3Dj%EF%BC%89%5C%3A%5C%3A%282%29&id=G1RQM)

通过(1)和(2)的对比可以发现,(1)第一项后面的表达式都和(2)中的表达式相差一个w[i],那么有:

算法基础acwing - 图101

  1. int f[N][N], v[N], w[N];
  2. int main()
  3. {
  4. int n, m;
  5. cin >> n >> m;
  6. for(int i=1; i<=n; i++)
  7. cin >> v[i] >> w[i];
  8. for (int i=1; i<=n; i++)
  9. for (int j=1; j<=m; j++)
  10. {
  11. f[i][j] = f[i-1][j];
  12. if(j>=v[i])
  13. f[i][j] = max(f[i][j], f[i][j-v[i]] + w[i]);
  14. }
  15. cout << f[n][m];
  16. return 0;
  17. }

疑问:

如何又是f[i-1][j]变成了f[i][j]

下面的写法更好理解

  1. int main()
  2. {
  3. int n, m;
  4. cin >> n >> m;
  5. for(int i=1; i<=n; i++)
  6. cin >> v[i] >> w[i];
  7. for (int i=1; i<=n; i++){
  8. for (int j=1; j<=m; j++){
  9. if(j>=v[i])
  10. f[i][j] = max(f[i-1][j], f[i][j-v[i]] + w[i]);
  11. else
  12. f[i][j] = f[i-1][j];
  13. }
  14. }
  15. cout << f[n][m];
  16. return 0;
  17. }

通过01背包可以发现,这里一样可以做空间上的优化

  1. int f[N], v[N], w[N];
  2. int main()
  3. {
  4. int n, m;
  5. cin >> n >> m;
  6. for(int i=1; i<=n; i++)
  7. cin >> v[i] >> w[i];
  8. for (int i=1; i<=n; i++)
  9. for (int j=v[i]; j<=m; j++)
  10. f[j] = max(f[j], f[j-v[i]] + w[i]);
  11. cout << f[m];
  12. return 0;
  13. }

疑问,为什么这里没有倒序遍历?

f[i][j]是由f[i][j-v[i]]变换而来,是同一层,01倒序是因为用到了上一层的数据,避免将需要使用的数据改变

完全背包中,从前往后更新,不会影响到需要使用的数据

多重背包

有 NN 种物品和一个容量是 VV 的背包。 第 ii 种物品最多有 sisi 件,每件体积是 vivi,价值是 wiwi。 求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。

朴素解法

  1. int f[N][N], v[N], w[N], s[N];
  2. int main()
  3. {
  4. int n, m;
  5. cin >> n >> m;
  6. for (int i=1; i<=n; i++)
  7. cin >> v[i] >> w[i] >> s[i];
  8. for (int i=1; i<=n; i++)
  9. for (int j=1; j<=m; j++)
  10. for (int k=0; k<=s[i] && k*v[i]<=j; k++)
  11. f[i][j] = max(f[i][j], f[i-1][j-k*v[i]] + k*w[i]);
  12. cout << f[n][m];
  13. return 0;
  14. }

根据状态转移方程,可以看出和01背包基本一致

那么可以进行相同的优化,具体细节请参考01背包小节

  1. int f[N], v[N], w[N], s[N];
  2. int main()
  3. {
  4. int n, m;
  5. cin >> n >> m;
  6. for (int i=1; i<=n; i++)
  7. cin >> v[i] >> w[i] >> s[i];
  8. for(int i=1; i<=n; i++)
  9. for (int j=m; j>=v[i]; j--)
  10. for (int k=0; k<=s[i]&&k*v[i]<=j; k++)
  11. f[j] = max(f[j], f[j-k*v[i]] + k*w[i]);
  12. cout << f[m];
  13. return 0;
  14. }

疑问:

为啥第二层循环倒叙?

为啥不能和完全背包一样去掉一层循环?

参考01背包中的描述

每件东西的数量不同,无法得到一个统一的表达式

多重背包问题2

相对于1,这里主要是对时间复杂度进行了优化

那么如何优化呢?

采用二进制优化

具体做法

对于任意物品i, 其体积、价值和数量分别为算法基础acwing - 图102, 算法基础acwing - 图103, 算法基础acwing - 图104,

可以将其分为算法基础acwing - 图105

为什么可以这么分呢?

因为对于任意的体积V,都可以表示为:

算法基础acwing - 图106

如 200 = 1 + 2 + 4 + 8 + 16 + 32 + 64 + 73(C)

那么对于任意一种物品的数量,由原来的算法基础acwing - 图107份变为为算法基础acwing - 图108

有了上述背景,如何来优化呢

首先对物品进行处理,得到新的价值和体积、数量

然后变成了01背包问题

代码如下

  1. // N*2^floor(logS) 是多余量
  2. const int N = 12010, M = 2010;
  3. int v[N], w[N];
  4. int f[M];
  5. int main()
  6. {
  7. int n, m; cin >> n >> m;
  8. int cnt = 0; // 所有新的物品的编号
  9. for(int i=1; i<=n; i++)
  10. {
  11. int a, b, s;
  12. cin >> a >> b >>s;
  13. int k=1;
  14. while(k<=s)
  15. {
  16. cnt++;
  17. v[cnt] = a * k;
  18. w[cnt] = b * k;
  19. s -= k;
  20. k <<= 1;
  21. }
  22. if(s>0) // 加上剩余的 s - 2^k
  23. {
  24. cnt++;
  25. v[cnt] = a * s;
  26. w[cnt] = b * s;
  27. }
  28. }
  29. // 转换成了01背包
  30. n = cnt;
  31. for(int i=1; i<=n; i++)
  32. for(int j=m;j>=v[i];j--)
  33. f[j] = max(f[j], f[j-v[i]] + w[i]);
  34. cout << f[m];
  35. return 0;
  36. }

分组背包问题

有 N 组物品和一个容量是 V 的背包。

每组物品有若干个,同一组内的物品最多只能选一个。
每件物品的体积是 vij,价值是 wij,其中 i 是组号,j 是组内编号。

求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。

输出最大价值。

直接参考01背包,使用了优化

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 110;
  4. int v[N][N], w[N][N], f[N], s[N];
  5. int main(){
  6. int n, m;
  7. cin >> n >> m;
  8. for (int i=1; i<=n; i++)
  9. {
  10. cin >> s[i];
  11. for (int j=1; j<=s[i]; j++)
  12. cin >> v[i][j] >> w[i][j];
  13. }
  14. for (int i=1; i<=n; i++)
  15. for (int j=m; j>=0; j--)
  16. for (int k=0; k<=s[i]; k++)
  17. if (v[i][k] <= j)
  18. f[j] = max(f[j], f[j-v[i][k]] + w[i][k]);
  19. cout << f[m];
  20. return 0;
  21. }

5.2 线性DP

数字三角形

  1. 7
  2. 3 8
  3. 8 1 0
  4. 2 7 4 4
  5. 4 5 2 6 5

输入方式(存储方式)

  1. 7
  2. 3 8
  3. 8 1 0
  4. 2 7 4 4
  5. 4 5 2 6 5

本题采用的从下往上进行搜索,有如下好处:

可以不用另开数组

不用考虑边界特判

  1. const int N = 510;
  2. int f[N][N];
  3. int main()
  4. {
  5. int n; cin >> n;
  6. for (int i=1; i<=n; i++)
  7. for (int j=1; j<=i; j++)
  8. cin >> f[i][j];
  9. for (int i=n; i>=1; i--)
  10. for (int j=i; j>=1; j--)
  11. f[i][j] += max(f[i+1][j], f[i+1][j+1]);
  12. cout << f[1][1];
  13. return 0;
  14. }

最长上升子序列

给定一个长度为 N 的数列,求数值严格单调递增的子序列的长度最长是多少。

子序列可以是不连续的

哈哈,我一来就以为是连续的, 然后就写出了下面的答案了

  1. const int N = 1010;
  2. int a[N], f[N];
  3. int main()
  4. {
  5. int n;
  6. cin >> n;
  7. for (int i=1; i<=n; i++)
  8. cin >> a[i];
  9. for (int i=1; i<=n; i++)
  10. if (a[i]<=a[i-1])
  11. f[i] = 1;
  12. else
  13. f[i] = f[i-1] + 1;
  14. cout << *max_element(f+1, f+n);
  15. return 0;
  16. }

状态表示的集合和属性我能想到,但是状态计算不知道

这里的状态有两种表示方式:

  1. 如果大于所比较的值,那么就+1
  2. 小于,=1
  1. const int N = 1010;
  2. int a[N], f[N];
  3. int main()
  4. {
  5. int n;
  6. cin >> n;
  7. for(int i=1; i<=n; i++)
  8. cin >> a[i];
  9. for (int i=1; i<=n; i++)
  10. {
  11. f[i] = 1;
  12. for (int j=1; j<i; j++)
  13. if (a[j] < a[i])
  14. f[i] = max(f[i], f[j]+1);
  15. }
  16. cout << *max_element(f+1, f+n+1);
  17. return 0;
  18. }

最长上升子序列2

本题是对上面的优化

那么可以从哪个角度找到优化的方式呢?

i have no idea at all..

直接说,二分

采用数组q[], 其中q[i] 表示长度为i+1的最长子串的结尾的最小值,那么,q一定是一个递增的序列
image.png
对于a[i],在q中找到小于a[i]的最大的一个数,这里假设是q[4],那么长度是5的最长子序列的最小值为a[i],则更新q[5] 为a[i]

  1. const int N = 100010;
  2. // tails保存长度为i的所有子序列中,结尾元素的最小值
  3. // tails[i] 表示长度为i的最长子串的尾部元素值,计算的过程中,保持每个尾部元素值最小
  4. // tails一定是一个递增序列:当每个尾部元素的值最小的前提下,子序列越长,其尾部的元素值也一定越大
  5. int tails[N], a[N];
  6. int main()
  7. {
  8. int n; cin >> n;
  9. for(int i=0; i<n; i++) cin >> a[i];
  10. int len = 0; // 当前长度
  11. tails[0] = -2e8;
  12. for(int i=0; i<n; i++)
  13. {
  14. int l = 0, r = len; // r为当前的最大长度
  15. // 二分在q[0, len]中找出小于a[i]的最大的一个数
  16. while(l < r)
  17. {
  18. int mid = l + r + 1>> 1;
  19. if(tails[mid] < a[i]) l = mid; // tails[0] must be smallest
  20. else r = mid - 1;
  21. }
  22. // 更新当前长度
  23. len = max(len, r+1);
  24. // 更新q[i]值
  25. tails[r+1] = a[i];
  26. }
  27. cout << len;
  28. return 0;
  29. }

300. 最长递增子序列

最长公共子序列

此题相处状态表示和转移及很容易计算

  1. const int N = 1010;
  2. char a[N], b[N];
  3. // f[i][j] a从1开始长度为i,b从1开始长度为j的最长公共子序列
  4. int f[N][N];
  5. int main(){
  6. int n, m;
  7. cin >> n >> m >> a+1 >> b+1;
  8. for (int i=1; i<=n; i++)
  9. for (int j=1; j<=m; j++)
  10. if (a[i] == b[j])
  11. f[i][j] = f[i-1][j-1] + 1;
  12. else
  13. f[i][j] = max(f[i][j-1], f[i-1][j]);
  14. cout << f[n][m];
  15. return 0;
  16. }

最短编辑距离

给定两个字符串 A 和 B,现在要将 A 经过若干操作变为 B,可进行的操作有:

  1. 删除–将字符串 A 中的某个字符删除。
  2. 插入–在字符串 A 的某个位置插入某个字符。
  3. 替换–将字符串 A 中的某个字符替换为另一个字符。

首先,构造出状态表达

f[i][j] 表示从a[1, i]到b[1,j]的最短编辑距离

那么状态转移呢?

由于能通过三种方式实现两者之间的转换,因此,采用三种方式之间的最小值

每一种方式的状态又是如何转移的呢?

1)删除操作:把a[i]删掉之后a[1,i]和b[1,j]匹配所以之前要先做到a[1, (i-1)]和b[1, j]匹配
f[i-1][j] + 1
2)插入操作:插入之后a[i]与b[j]完全匹配,所以插入的就是b[j] 那填之前a[1, i]和b[1, (j-1)]匹配
f[i][j-1] + 1
3)替换操作:把a[i]改成b[j]之后想要a[1, i]与b[1, j]匹配 那么修改这一位之前,a[1, (i-1)]应该与b[1, (j-1)]匹配
f[i-1][j-1] + 1
但是如果本来a[i]与b[j]这一位上就相等,那么不用改,即
f[i-1][j-1] + 0

好的那么f[i][j]就由以上三个可能状态转移过来,取个min

  1. int N = 1010;
  2. char a[N], b[N];
  3. int f[N][N];
  4. int main()
  5. {
  6. int n, m;
  7. cin >> n >> a+1 >> m >> b+1;
  8. // 写完后面的发现特殊情况没有处理,hha
  9. for(int i=1; i<=n; i++) f[i][0] = i;
  10. for(int i=1; i<=m; i++) f[0][i] = i;
  11. for (int i=1; i<=n; i++)
  12. {
  13. for (int j=1; j<=m; j++)
  14. {
  15. f[i][j] = min(f[i][j-1]+1, f[i-1][j]+1); // inset, delete
  16. if(a[i] == b[j])
  17. f[i][j] = min(f[i][j], f[i-1][j-1]); //
  18. else
  19. f[i][j] = min(f[i][j], f[i-1][j-1]+1); // change
  20. }
  21. }
  22. cout << f[n][m];
  23. return 0;
  24. }

下面的写法更朴素

  1. #include <climits> // INT_MAX
  2. using namespace std;
  3. const int N = 1010;
  4. char a[N], b[N];
  5. int f[N][N];
  6. int main()
  7. {
  8. int n, m;
  9. cin >> n >> a+1 >> m >> b+1;
  10. for (int i=1; i<=n; i++)
  11. for (int j=1; j<=m; j++)
  12. f[i][j] = INT_MAX;
  13. // 写完后面的发现特殊情况没有处理,hha
  14. for(int i=1; i<=n; i++) f[i][0] = i;
  15. for(int i=1; i<=m; i++) f[0][i] = i;
  16. for (int i=1; i<=n; i++)
  17. {
  18. for (int j=1; j<=m; j++)
  19. {
  20. f[i][j] = min(f[i][j], f[i-1][j]+1); // delete
  21. f[i][j] = min(f[i][j], f[i][j-1]+1); // insert
  22. if(a[i] == b[j])
  23. f[i][j] = min(f[i][j], f[i-1][j-1]); //
  24. else
  25. f[i][j] = min(f[i][j], f[i-1][j-1]+1); // change
  26. }
  27. }
  28. cout << f[n][m];
  29. return 0;
  30. }

5.3 区间DP

石子合并

这题感觉好难啊,希望下次看到答案能立马看懂

首先,状态表示:

f[i][j]:所有将【i,j】合并成一堆的集合

其次,这个状态是如何转移的呢?

取k (i<k<j) f[i][j] = min(f[i][k]+f[k+1][j]+s[j] - s[i-1], …. )

具体描述为:取任意k对应的两个状态对应的代价(f[i][k] and f[k+1][j]),和本次的代价(i到j的重量,这里用s[j]-s[i-1]表示 )

区间DP常见套路

  1. for (int i = 1; i <= n; i++) {
  2. dp[i][i] = 初始值
  3. }
  4. for (int len = 2; len <= n; len++) //区间长度
  5. for (int i = 1; i + len - 1 <= n; i++) { //枚举起点
  6. int j = i + len - 1; //区间终点
  7. for (int k = i; k < j; k++) { //枚举分割点,构造状态转移方程
  8. dp[i][j] = min(dp[i][j], dp[i][k] + dp[k + 1][j] + w[i][j]);
  9. }
  10. }

我对这里的右端点的值i+len-1不是很理解

  1. #include <climits> // INT_MAX
  2. using namespace std;
  3. const int N = 310;
  4. int s[N];
  5. int f[N][N];
  6. int main()
  7. {
  8. int n;
  9. cin >> n;
  10. for (int i=1; i<=n; i++)
  11. cin >> s[i], s[i] += s[i-1];
  12. for (int len=2; len<=n; len++)
  13. {
  14. for (int i=1; i+len-1<=n; i++) // 这里不太理解为啥i的最大值是n-len+1
  15. {
  16. int j= i+len-1; // right
  17. f[i][j] = INT_MAX;
  18. for (int k=i; k<j; k++)
  19. f[i][j] = min(f[i][j], f[i][k] + f[k+1][j] + s[j] - s[i-1]);
  20. }
  21. }
  22. cout << f[1][n];
  23. return 0;
  24. }

疑问解答

  1. 这里不太理解为啥i的最大值是n-len+1?
  1. k 取值[i, j)?

由于j表示的是右端点,因此有i+len-1<=n, 否则就要判断大于n时,退出

比如,对于1,3,5, 2,当len = 2, i=1(i指起始下标),那么右端点为i+len-1=2

感觉还是有问题

若是取到j,则f[k+1][j]==>f[j+1][j],和f[i][j]表达的意义不同了

  1. #include <iostream>
  2. #include <climits> // INT_MAX
  3. using namespace std;
  4. const int N = 310;
  5. int s[N];
  6. int f[N][N];
  7. int main()
  8. {
  9. int n;
  10. cin >> n;
  11. for (int i=1; i<=n; i++)
  12. cin >> s[i], s[i] += s[i-1];
  13. for (int len=1; len<n; len++)
  14. {
  15. for (int i=1; i+len<=n; i++)
  16. {
  17. int j= i+len; // right
  18. f[i][j] = INT_MAX;
  19. for (int k=i; k<j; k++)
  20. f[i][j] = min(f[i][j], f[i][k] + f[k+1][j] + s[j] - s[i-1]);
  21. }
  22. }
  23. cout << f[1][n];
  24. return 0;
  25. }

reference

从两种写法可以看出,len的取值范围,会影响右边界的取值

5.4 计数类DP

整数划分

一个正整数 n 可以表示成若干个正整数之和,形如:n=n1+n2+…+nk,其中 n1≥n2≥…≥nk,k≥1。

我们将这样的一种表示称为正整数 n的一种划分。

可以类比完全背包,第i种物品的体积为i

那么f[i][j] 可以表示:前i种物品,体积为j的集合

第i种物品,选0到si(si<=j)个

状态转移方程为:

// f[i][j] = f[i-1][j] + f[i-1][j-i] + f[i-1][j-2i] +…+f[i-1][j-si]
// f[i][j-i] = f[i-1][j-i] + f[i-1][j-2i] +…+f[i-1][j-si]

==>

f[i][j] = f[i-1][j] + f[i][j-i]

那么可以当作完全背包来写了

  1. const int N = 1010;
  2. const int mod = 1e9 + 7;
  3. // 完全背包的解法
  4. int f[N][N];
  5. // f[i][j] 从1-i中选,结果为j的方案数
  6. // f[i][j] = f[i-1][j] + f[i-1][j-i] + f[i-1][j-2*i] +...+f[i-1][j-s*i]
  7. // f[i][j-i] = f[i-1][j-i] + f[i-1][j-2*i] +...+f[i-1][j-s*i]
  8. int main()
  9. {
  10. int n;
  11. cin >> n;
  12. // 特殊情况忘记处理了
  13. for (int i=1; i<=n; i++)
  14. f[i][0] = 1;
  15. for (int i=1; i<=n; i++)
  16. for (int j=1; j<=n; j++)
  17. if (i <= j) f[i][j] = (f[i-1][j] + f[i][j-i]) % mod;
  18. else f[i][j] = f[i-1][j] % mod;
  19. cout << f[n][n];
  20. return 0;
  21. }

这里要特别注意特殊情况的处理f[i][0] = 1

优化~

  1. #include <iostream>
  2. using namespace std;
  3. const int N = 1010;
  4. const int mod = 1e9 + 7;
  5. // 完全背包的解法
  6. int f[N];
  7. int main()
  8. {
  9. int n; cin >> n;
  10. f[0] = 1;
  11. for (int i=1; i<=n; i++)
  12. for (int j=i; j<=n; j++)
  13. f[j] = (f[j] + f[j-i]) % mod;
  14. cout << f[n];
  15. return 0;
  16. }

5.5 状态压缩DP


291. 蒙德里安的梦想

image.png
image.png
这里的k表示的i-1, j表示i

  1. 表示横着的方块不能存在重叠
  2. 方块之间的空隙必须是偶数,否则竖着的方块无法放

朴素写法

  1. #include <cstring>
  2. #include <iostream>
  3. #include <algorithm>
  4. using namespace std;
  5. const int N = 12, M = 1 << N;
  6. int n, m;
  7. long long f[N][M];
  8. bool st[M];
  9. int main()
  10. {
  11. while (cin >> n >> m, n || m)
  12. {
  13. for (int i = 0; i < 1 << n; i ++ )
  14. {
  15. int cnt = 0;
  16. st[i] = true;
  17. for (int j = 0; j < n; j ++ )
  18. if (i >> j & 1)
  19. {
  20. if (cnt & 1) st[i] = false;
  21. cnt = 0;
  22. }
  23. else cnt ++ ;
  24. if (cnt & 1) st[i] = false;
  25. }
  26. memset(f, 0, sizeof f);
  27. f[0][0] = 1;
  28. for (int i = 1; i <= m; i ++ )
  29. for (int j = 0; j < 1 << n; j ++ )
  30. for (int k = 0; k < 1 << n; k ++ )
  31. if ((j & k) == 0 && st[j | k])
  32. f[i][j] += f[i - 1][k];
  33. cout << f[m][0] << endl;
  34. }
  35. return 0;
  36. }

ref

  1. /*
  2. 下文对 if ((j & k ) == 0 && st[ j | k] ) 有清晰的解释!!!
  3. */
  4. #include <bits/stdc++.h>
  5. using namespace std;
  6. const int N = 12, M = 1<< N;
  7. long long f[N][M] ;// 第一维表示列, 第二维表示所有可能的状态
  8. bool st[M]; //存储每种状态是否有奇数个连续的0,如果奇数个0是无效状态,如果是偶数个零置为true。
  9. //vector<int > state[M]; //二维数组记录合法的状态
  10. vector<vector<int>> state(M); //两种写法等价:二维数组
  11. int m, n;
  12. int main() {
  13. while (cin >> n >> m, n || m) { //读入n和m,并且不是两个0即合法输入就继续读入
  14. //第一部分:预处理1
  15. //对于每种状态,先预处理每列不能有奇数个连续的0
  16. for(int i = 0; i < (1 << n); i ++) {
  17. int cnt = 0 ;//记录连续的0的个数
  18. bool isValid = true; // 某种状态没有奇数个连续的0则标记为true
  19. for(int j = 0; j < n; j ++) { //遍历这一列,从上到下
  20. if ( (i >> j) & 1) {
  21. //i >> j位运算,表示i(i在此处是一种状态)的二进制数的第j位;
  22. // &1为判断该位是否为1,如果为1进入if
  23. if (cnt & 1) {
  24. //这一位为1,看前面连续的0的个数,如果是奇数(cnt &1为真)则该状态不合法
  25. isValid =false; break;
  26. }
  27. cnt = 0; // 既然该位是1,并且前面不是奇数个0(经过上面的if判断),计数器清零。
  28. //其实清不清零没有影响
  29. }
  30. else cnt ++; //否则的话该位还是0,则统计连续0的计数器++。
  31. }
  32. if (cnt & 1) isValid = false; //最下面的那一段判断一下连续的0的个数
  33. st[i] = isValid; //状态i是否有奇数个连续的0的情况,输入到数组st中
  34. }
  35. //第二部分:预处理2
  36. // 经过上面每种状态 连续0的判断,已经筛掉一些状态。
  37. //下面来看进一步的判断:看第i-2列伸出来的和第i-1列伸出去的是否冲突
  38. for (int j = 0; j < (1 << n); j ++) { //对于第i列的所有状态
  39. state[j].clear(); //清空上次操作遗留的状态,防止影响本次状态。
  40. for (int k = 0; k < (1 << n); k ++) { //对于第i-1列所有状态
  41. if ((j & k ) == 0 && st[ j | k])
  42. // 第i-2列伸出来的 和第i-1列伸出来的不冲突(不在同一行)
  43. //解释一下st[j | k]
  44. //已经知道st[]数组表示的是这一列没有连续奇数个0的情况,
  45. //我们要考虑的是第i-1列(第i-1列是这里的主体)中从第i-2列横插过来的,
  46. //还要考虑自己这一列(i-1列)横插到第i列的
  47. //比如 第i-2列插过来的是k=10101,第i-1列插出去到第i列的是 j =01000,
  48. //那么合在第i-1列,到底有多少个1呢?
  49. //自然想到的就是这两个操作共同的结果:两个状态或。 j | k = 01000 | 10101 = 11101
  50. //这个 j|k 就是当前 第i-1列的到底有几个1,即哪几行是横着放格子的
  51. state[j].push_back(k);
  52. //二维数组state[j]表示第j行,
  53. //j表示 第i列“真正”可行的状态,
  54. //如果第i-1列的状态k和j不冲突则压入state数组中的第j行。
  55. //“真正”可行是指:既没有前后两列伸进伸出的冲突;又没有连续奇数个0。
  56. }
  57. }
  58. //第三部分:dp开始
  59. memset(f, 0, sizeof f);
  60. //全部初始化为0,因为是连续读入,这里是一个清空操作。
  61. //类似上面的state[j].clear()
  62. f[0][0] = 1 ;// 这里需要回忆状态表示的定义
  63. //按定义这里是:前第-1列都摆好,且从-1列到第0列伸出来的状态为0的方案数。
  64. //首先,这里没有-1列,最少也是0列。
  65. //其次,没有伸出来,即没有横着摆的。即这里第0列只有竖着摆这1种状态。
  66. for (int i = 1; i <= m; i ++) { //遍历每一列:第i列合法范围是(0~m-1列)
  67. for (int j = 0; j < (1<<n); j ++) { //遍历当前列(第i列)所有状态j
  68. for (auto k : state[j]) // 遍历第i-1列的状态k,如果“真正”可行,就转移
  69. f[i][j] += f[i-1][k]; // 当前列的方案数就等于之前的第i-1列所有状态k的累加。
  70. }
  71. }
  72. //最后答案是什么呢?
  73. //f[m][0]表示 前m-1列都处理完,并且第m-1列没有伸出来的所有方案数。
  74. //即整个棋盘处理完的方案数
  75. cout << f[m][0] << endl;
  76. }
  77. }

91. 最短Hamilton路径

image.png
ref

  1. #include<iostream>
  2. #include<cstring>
  3. #include<algorithm>
  4. using namespace std;
  5. const int N=20,M=1<<N;
  6. int f[M][N],w[N][N];//w表示的是无权图
  7. int main()
  8. {
  9. int n;
  10. cin>>n;
  11. for(int i=0;i<n;i++)
  12. for(int j=0;j<n;j++)
  13. cin>>w[i][j];
  14. memset(f,0x3f,sizeof(f));//因为要求最小值,所以初始化为无穷大
  15. f[1][0]=0;//因为零是起点,所以f[1][0]=0;
  16. for(int i=0;i<1<<n;i++)//i表示所有的情况
  17. for(int j=0;j<n;j++)//j表示走到哪一个点
  18. if(i>>j&1)
  19. for(int k=0;k<n;k++)//k表示走到j这个点之前,以k为终点的最短距离
  20. if(i>>k&1)
  21. f[i][j]=min(f[i][j],f[i-(1<<j)][k]+w[k][j]);//更新最短距离
  22. cout<<f[(1<<n)-1][n-1]<<endl;//表示所有点都走过了,且终点是n-1的最短距离
  23. //位运算的优先级低于'+'-'所以有必要的情况下要打括号
  24. return 0;
  25. }

5.6 树形DP

285. 没有上司的舞会

5.7 记忆化搜索

901. 滑雪

第6讲 贪心

贪心算法(英语:greedy algorithm),又称贪婪算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法

不一定能获得最优解

贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。

贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。

6.1 区间问题

区间选点

给定 N 个闭区间 [ai,bi],请你在数轴上选择尽量少的点,使得每个区间内至少包含一个选出的点。

输出选择的点的最小数量。

位于区间端点上的点也算作区间内。

解题想法

对区间(按右端点)进行排序,当前一个区间的左端点大于后一个区间的右端点时,点的数量+1

于是就有了下面的结果

  1. #include <iostream>
  2. #include <algorithm> // sort
  3. using namespace std;
  4. const int N= 100010;
  5. struct Range
  6. {
  7. int l, r;
  8. bool operator<(const Range& rhs){
  9. return r < rhs.r;
  10. }
  11. } range[N];
  12. int main()
  13. {
  14. int n; cin >> n;
  15. for (int i=0; i<n; i++)
  16. cin >> range[i].l >> range[i].r;
  17. sort(range, range+n);
  18. int res = 1;// 先覆盖第一个区间
  19. for (int i=1; i<=n; i++)
  20. if(range[i].l < range[i-1].r) // this is where problem occurs
  21. res++;
  22. cout << res;
  23. return 0;
  24. }

上面代码的问题肯定是边界的更新问题,但是还没有找出来

[1 - 5]

  1. [3 - 9]
  2. [7 - 12]
  3. [13 -15]

比如向上面那样 的区间,按照这种写法,就需要2个点 实际上是需要三个点的。

从上面可以看出,当右边界和左边界进行比较时,左边界不是每次都需要更新的。

只有当7>5 或者13>12这种情形发生时,才需要更新左边界。

左边界可以看成是一个完全被点覆盖的区间

比如当9>5时,如果将边界更新为9,那么区间【5,9】不是一个完备的被点覆盖的区间 当【7,12】的出现时,只能保证【7,9】被覆盖,【5,7】可能是被空缺出来的

因此必须保证左边界的完备性

使用右边界排序

  1. const int N= 100010;
  2. struct Range
  3. {
  4. int l, r;
  5. bool operator<(const Range& rhs)
  6. {
  7. return r < rhs.r;
  8. }
  9. } range[N];
  10. int main()
  11. {
  12. int n; cin >> n;
  13. for (int i=0; i<n; i++)
  14. cin >> range[i].l >> range[i].r;
  15. sort(range, range+n);
  16. int res = 0, ed = -2e9;
  17. for (int i=0; i<=n; i++)
  18. if(range[i].l > ed)
  19. {
  20. res++;
  21. ed = range[i].r;
  22. }
  23. cout << res;
  24. return 0;
  25. }

这里的ed就像一个已经覆盖的区间的边界

使用左边界排序

  1. const int N= 100010;
  2. struct Range
  3. {
  4. int l, r;
  5. bool operator<(const Range& rhs)
  6. {
  7. return l < rhs.l;
  8. }
  9. } range[N];
  10. int main()
  11. {
  12. int n; cin >> n;
  13. for (int i=0; i<n; i++)
  14. cin >> range[i].l >> range[i].r;
  15. sort(range, range+n);
  16. int res = 0, ed = 2e9;
  17. for (int i=n-1; i>=0; i--)
  18. if(range[i].r < ed)
  19. {
  20. res++;
  21. ed = range[i].l;
  22. }
  23. cout << res;
  24. return 0;
  25. }

最大不相交的区间的数量

给定 N个闭区间 [ai,bi],请你在数轴上选择若干区间,使得选中的区间之间互不相交(包括端点)。 输出可选取区间的最大数量。

思路和区间问题基本一致,直接贴代码

  1. #include <iostream>
  2. #include <algorithm>
  3. using namespace std;
  4. const int N = 100010;
  5. struct Range
  6. {
  7. int l, r;
  8. bool operator<(const Range& rhs)
  9. {
  10. return r < rhs.r;
  11. }
  12. } range[N];
  13. int main()
  14. {
  15. int n;
  16. cin >> n;
  17. for (int i=0; i<n; i++)
  18. cin >> range[i].l >> range[i].r;
  19. sort(range, range+n);
  20. int res=0, ed = -2e9;
  21. for (int i=0; i<n; i++)
  22. if(range[i].l > ed)
  23. res++, ed = range[i].r;
  24. cout << res;
  25. return 0;
  26. }

区间分组

给定 N 个闭区间 [ai,bi],请你将这些区间分成若干组,使得每组内部的区间两两之间(包括端点)没有交集,并使得组数尽可能小。

输出最小组数。

看到题目的思路,根据贪心的思路,

  • 首先将区间排序
  • 然后贪心的选取第一组区间
  • 选取第二组区间
  • 直到区间选完,输出组数
  1. #include <iostream>
  2. #include <algorithm>
  3. #include <queue>
  4. using namespace std;
  5. const int N = 100010;
  6. struct Range
  7. {
  8. int l, r;
  9. bool operator<(const Range& rhs)
  10. {
  11. return l < rhs.l;
  12. }
  13. };
  14. int main()
  15. {
  16. int n; cin >> n;
  17. vector<Range> range;
  18. int l,r;
  19. for (int i=0; i<n; i++)
  20. {
  21. cin >> l >> r;
  22. range.push_back({l, r});
  23. }
  24. sort(range, range+n);
  25. int res = 0;
  26. while (!range.empty())
  27. {
  28. int len = range.szie();
  29. int ed = -2e9;
  30. for (int i=0; i<len;i++)
  31. {
  32. if (range[i].l > ed)
  33. {
  34. ed = range[i].r;
  35. range[i].erase(); //length will change if erase
  36. }
  37. }
  38. res++;
  39. }
  40. cout << heap.size();
  41. return 0;
  42. }

这里的容器不支持,代码有问题

于是就有了如下的思路

  1. 将所有的区间按左端点,从小到大排序
  2. 从头到尾处理每个区间
    判断能否将当前区间放到现有的某个组中 L[i] > max_r
    1. 如果不存在这样的数组,则新开一个组,然后将其放进去
    2. 如果存在,将其放进去,更新max_r


yxc给的思路,但是和代码也不太一致啊

代码中的思路

使用小顶堆存放区间的右端点,当当前区间的与最小左端点的区间不相交时,删除区间

最后根据堆中数量的多少来判断需要多少个区间。

因为能分成一组的区间早已经移除,剩余的只是每一个区间的代表。

  1. #include <iostream>
  2. #include <algorithm>
  3. #include <queue>
  4. using namespace std;
  5. const int N = 100010;
  6. struct Range
  7. {
  8. int l, r;
  9. bool operator<(const Range& rhs)
  10. {
  11. return l < rhs.l;
  12. }
  13. } range[N];
  14. int main()
  15. {
  16. int n; cin >> n;
  17. for (int i=0; i<n; i++)
  18. cin >> range[i].l >> range[i].r;
  19. sort(range, range+n);
  20. priority_queue<int, vector<int>, greater<int>> heap; //小顶堆
  21. heap.push(range[0].r);
  22. for (int i=1; i<n; i++)
  23. {
  24. if (heap.top() >= range[i].l) //出现区间重叠
  25. {
  26. heap.push(range[i].r);
  27. }
  28. else
  29. {
  30. heap.pop(); // 移除右边界最小的区间
  31. heap.push(range[i].r);
  32. }
  33. }
  34. cout << heap.size();
  35. return 0;
  36. }

本题类似活动安排

有若干个活动,第i个活动开始时间和结束时间是[SiSi,fifi],同一个教室安排的活动之间不能交叠,求要安排所有活动,少需要几个教室?

思路

我们可以把所有开始时间和结束时间排序,遇到开始时间就把需要的教室加1,遇到结束时间就把需要的教室减1,在一系列需要的教室个数变化的过程中,峰值就是多同时进行的活动数,也是我们至少需要的教室数。

只需要对所有的时间进行 排序,然后出现开始+1,结束-1;取最大的数,即为所需要的值

见代码

  1. const int N = 100010;
  2. int times[2*N];
  3. int main()
  4. {
  5. int n; cin >> n;
  6. int l, r;
  7. for (int i=0; i<2*n; i+=2)
  8. {
  9. cin >> l >> r;
  10. times[i] = l * 2; // 左端点标记为偶数
  11. times[i+1] = r * 2 +1; // 右端点标记为奇数
  12. }
  13. sort(times, times+2*n);
  14. int count = 0, res = 0;
  15. for (int i=0; i<2*n; i++)
  16. {
  17. if (times[i]&1) count--;
  18. else count++;
  19. res = max(res, count);
  20. }
  21. cout << res;
  22. return 0;
  23. }

区间覆盖

注意处理边界的更新和区间部分存在未覆盖的情况即可

  1. #include <iostream>
  2. #include <algorithm>
  3. using namespace std;
  4. const int N = 100010;
  5. struct Range
  6. {
  7. int l, r;
  8. bool operator<(const Range& rhs)
  9. {
  10. return l < rhs.l;
  11. }
  12. } range[N];
  13. int main()
  14. {
  15. int s, t, n;
  16. cin >> s >> t >> n;
  17. for (int i=1;i<=n; i++)
  18. cin >> range[i].l >> range[i].r;
  19. sort(range+1, range+n+1);
  20. int res = 0, bSuccess=false;
  21. for (int i=1; i<=n; i++)
  22. {
  23. int j=i, r = -2e9;
  24. // 在左端点满足的条件下,寻找最大的右断点
  25. while (j<=n && range[j].l <= s)
  26. {
  27. r = max(r, range[j].r);
  28. j++;
  29. }
  30. // 区间中有一段不能覆盖(区间已排序)
  31. if (r < s) break;
  32. res++;
  33. if(r >= t)
  34. {
  35. bSuccess = true;
  36. break;
  37. }
  38. // 更新区间范围
  39. i = j - 1;
  40. s = r;
  41. }
  42. bSuccess ? cout << res : cout << -1;
  43. return 0;
  44. }

6.2 Huffman树

什么是哈夫曼树

当用 n 个结点(都做叶子结点且都有各自的权值)试图构建一棵树时,如果构建的这棵树的带权路径长度最小,称这棵树为“最优二叉树”,有时也叫“赫夫曼树”或者“哈夫曼树”。

Huffman树的构建原则

在构建哈弗曼树时,要使树的带权路径长度最小,只需要遵循一个原则,那就是:权重越大的结点离树根越近。在图 1 中,因为结点 a 的权值最大,所以理应直接作为根结点的孩子结点。

ref

合并果子

本题要注意一个细节,就是最后一堆是不用计算的

这里的大致思路,就是每次都是合合并两个重量最轻的堆,那么耗费的力气最小

有了上述的思路,那么就可以选择堆

  1. using namespace std;
  2. const int N = 10010;
  3. int main()
  4. {
  5. int n; cin >> n;
  6. priority_queue<int, vector<int>, greater<int>> heap; // 小根堆
  7. int v;
  8. for (int i=1; i<=n; i++)
  9. cin >> v, heap.push(v);
  10. long long res = 0;
  11. while (heap.size() >= 2)
  12. {
  13. auto f = heap.top(); heap.pop();
  14. auto s = heap.top(); heap.pop();
  15. heap.push(f + s);
  16. res += f + s;
  17. }
  18. cout << res;
  19. return 0;
  20. }

6.3 排序不等式

排队打水

本题整体思路简单,使用时间短的先打,即可获得最短总体的时间

  1. const int N = 100010;
  2. int t[N];
  3. int main()
  4. {
  5. int n; cin >> n;
  6. for (int i=1; i<=n; i++)
  7. cin >> t[i];
  8. sort(t+1, t+n+1);
  9. long long res = 0;
  10. for (int i=1; i<n; i++)
  11. res += t[i] * (n-i);
  12. cout << res;
  13. return 0;
  14. }
  1. #include <iostream>
  2. #include <algorithm> // sort
  3. using namespace std;
  4. const int N = 100010;
  5. int t[N];
  6. int main()
  7. {
  8. int n; cin >> n;
  9. for (int i=1; i<=n; i++)
  10. cin >> t[i];
  11. sort(t+1, t+n+1);
  12. // 前缀和
  13. for (int i=1; i<=n; i++)
  14. t[i] += t[i-1];
  15. long long res = 0;
  16. for (int i=1; i<n; i++)
  17. res += t[i];
  18. cout << res;
  19. return 0;
  20. }

6.4 绝对值不等式

仓库选址

本题的思路也相对简单,将位置排序,然后选择中间的点做仓库即可

这里选择了一个内置的函数,直接将中间点放在中间,避免排序

  1. int A[N];
  2. int main()
  3. {
  4. int n; cin >> n;
  5. for(int i=0; i<n; i++)
  6. cin >> A[i];
  7. // 将中位数放在中间的位置 n/2, O(n), 排序算法复杂度O(nlogn)
  8. nth_element(A, A + n/2, A+n);
  9. int res = 0;
  10. for(int i=0; i<n; i++)
  11. res += abs(A[n/2] - A[i]);
  12. cout << res;
  13. return 0;
  14. }

耍杂技的牛

农民约翰的 N头奶牛(编号为 1..N1)计划逃跑并加入马戏团,为此它们决定练习表演杂技。

奶牛们不是非常有创意,只提出了一个杂技表演:

叠罗汉,表演时,奶牛们站在彼此的身上,形成一个高高的垂直堆叠。

奶牛们正在试图找到自己在这个堆叠中应该所处的位置顺序。

这 N 头奶牛中的每一头都有着自己的重量 Wi以及自己的强壮程度 Si。

一头牛支撑不住的可能性取决于它头上所有牛的总重量(不包括它自己)减去它的身体强壮程度的值,现在称该数值为风险值,风险值越大,这只牛撑不住的可能性越高。

您的任务是确定奶牛的排序,使得所有奶牛的风险值中的最大值尽可能的小。

这里的关键是按什么对所有的牛进行排序?

y总给的思路是按 w+s进行排序,思路如下

对于第i和第i-1头牛

他们的危险度为

提前给出的推断是,当$ si + w_i > s{i+1}+w_{i+1}$ 时,危险值更大

i i+1
交换前 w0 + w_1 +…+ w(i-1) - s_i w0 + w_1 +…+ w_i - s(i+1)
交换后 w0 + w_1 +…+ w(i-1) - s_(i+1) w0 + w_1 +…+ w(i-1)+w_(i+1)-s_i
  1. typedef pair<int, int> PII;
  2. const int N = 500010;
  3. PII a[N];
  4. int main()
  5. {
  6. int n; cin >> n;
  7. int w, s;
  8. for(int i=0; i<n; i++)
  9. {
  10. cin >> w >> s;m
  11. a[i] = {w+s, w};
  12. }
  13. sort(a, a+n);
  14. int wt = 0, res = INT_MIN;
  15. for(int i=0; i<n; i++)
  16. {
  17. res = max(res, wt - a[i].first + a[i].second);
  18. wt += a[i].second;
  19. }
  20. cout << res;
  21. return 0;
  22. }