蒟蒻自救指南

蒟蒻の自救指南

每天一组题,记录代码的优雅写法和算法思路。

算法模板

模板的作用 👉 节省思考 🤔 的时间

快排:分治思想

img
调整范围用的方法:

  1. 暴力做法
    img
  2. 优美方法
    两个指针 i, j,初始分别位于 L, R,移动 i, j 直至 i 所指位置大于等于 j,移动过程中保证 i 左边的数小于 x,j 右边的数大于 x,否则交换(都等于 x 也交换)。
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);   //循环结束时q[j] 一定小于等于x,i一定大于等于j,故小于等于x的部分是l~j,剩余部分是j + 1~r 👉不要取i~r,可能出现i~r等于l~r的情况
}

归并排序:分治思想

img
分界点是下标值

合并的方法:

双指针遍历递归排序好的两个序列
img
复杂度\(O(nlog n)\)

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];
}

整数二分

本质:区间能分成两个子区间,一个满足某性质, 一个不满足某性质,二分可以找到分界点.(例如单调区间可以二分)
img

例如找红色分界点:
img

bool check(int x) {/* ... */} // 检查x是否满足某种性质

// 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用:
int bsearch_2(int l, int r)
{
    while (l < r)  //区间内只有一个点是为答案
    {
        int mid = l + r + 1 >> 1;
        if (check(mid)) l = mid;
        else r = mid - 1;
    }
    return l;
}

注意这里 mid = l + r + 1 >> 1,如果不加 1 的话,若 r = l + 1, 则更新后 l = mid = l + r >> 1,下取整仍是原本的 l, 相当于循环之后 l 没更新,进入死循环.

找绿色分界点:

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是否满足性质
        else l = mid + 1;
    }
    return l;
}

判断满足 check 条件的区间是向右还是向左的,向右的则 true 时 r = mid,false 时 l = mid + 1;记住这时候 mid = l + r >> 1,向左的是相对的即可。
找哪个分界点是无所谓的, 因为整数二分中 红色分界点 = 绿色分界点 - 1

浮点数二分

参照整数二分的本质,这里分界点只有一个,且区间长度可以严格地缩小一半.
当区间长度很小(例如 1e-6)时,可以认为是答案.

bool check(double x) {/* ... */} // 检查x是否满足某种性质

double bsearch_3(double l, double r)
{
    const double eps = 1e-6;   // eps 表示精度,取决于题目对精度的要求   至少比要求的有效位数小2个数量级
    while (r - l > eps)
    {
        double mid = (l + r) / 2;
        if (check(mid)) r = mid;
        else l = mid;
    }
    return l;
}

高精度计算

位数在\(10^6\)以内
img

  • 大整数存储:数组下标小的存低位数字
  • 大整数运算:模拟竖式计算

使用

#include<vector>

因为 vector 和 string 一样自带一个 size 函数

// C = A + B, A >= 0, B >= 0
vector<int> add(vector<int> &A, vector<int> &B)
{
    if (A.size() < B.size()) return add(B, A);

    vector<int> C;
    int t = 0;
    for (int i = 0; i < A.size(); i ++ )
    {
        t += A[i];   //t 保存每位的计算结果
        if (i < B.size()) t += B[i];
        C.push_back(t % 10);    //先将当前位的结果写入答案,再处理进位
        t /= 10;
    }

    if (t) C.push_back(t);
    return C;
}

int main()
{
    string a, b;
    vector<int> A, B;

    cin >> a >> b;
    for(int i = a.size() - 1; i >= 0; i --) A.push_back(a[i] - '0');   //注意逆序存入
    for(int i = b.size() - 1; i >= 0; i --) B.push_back(b[i] - '0');

    vector<int> C = add(A, B);

    for(int i = C.size() - 1; i >= 0; i --) printf("%d", c[i]);  //注意从高位开始输出
    return 0;
}

这里函数声明时用到 A、B 的引用,避免了不必要的复制,同时使得函数能够直接修改 A、B。

//判断是否有 A >= B
bool cmp(vector<int> &A, vector<int> &B)
{
    if(A.size() != B.size()) return A.size() > B.size();
    for(int i = A.size() - 1; i >= 0; i --)
        if(A[i] != B[i]) return A[i] > B[i];
    return true;  //如果完全相等
}
// 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();  //去掉前导0 除非最终结果为0
    return C;
}

int main()
{
    string a, b;
    vector<int> A, B;

    cin >> a >> b;
    for(int i = a.size() - 1; i >= 0; i --) A.push_back(a[i] - '0');   //注意逆序存入
    for(int i = b.size() - 1; i >= 0; i --) B.push_back(b[i] - '0');

    if(cmp(A, B))
    {
        vector<int> C = sub(A, B);
        for(int i = C.size() - 1; i <= 0; i --) printf("%d", c[i]);  //注意从高位开始输出
    }
    else
    {
        vector<int> C = sub(B, A);
        printf("-");
        for(int i = C.size() - 1; i <= 0; i --) printf("%d", c[i]); //注意从高位开始输出
    }
    return 0;
}
// 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不只为0或1,甚至可以超过10
        t /= 10;
    }

    while (C.size() > 1 && C.back() == 0) C.pop_back();

    return C;
}

int main()
{
    string a;
    int b;

    cin >> a >> b;

    vector<int> A;
    for(int i = a.size() - 1; i >= 0; i --) A.push_back(a[i] - '0');

    auto C = mul(A, b);

    for(int i = C.size() - 1; i >= 0 ; i --) printf("%d", C[i]);
}

img
除法是求每一位的商,余数加到下一位做除法
注意除法与其他三种运算不同,是从最高位开始算的,但为了统一低位仍保存在数组下标小的元素中

// A / b = C ... r, A >= 0, b > 0  不要求 A 大于 b
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());  //现在C[0]存的是所得商的最高位   reverse需要头文件algorithm
    while (C.size() > 1 && C.back() == 0) C.pop_back();
    return C;
}

int main()
{
    string a;
    int b;

    cin >> a >> b;

    vector<int> A;
    for(int i = a.size() - 1; i >= 0; i --) A.push_back(a[i] - '0');

    int r;
    auto C = div(A, b, r);

    for(int i = C.size() - 1; i >= 0 ; i --) printf("%d", C[i]);
    cout << endl << r << endl;

    return 0;
}

前缀和

需要计算前缀和的数组下标一般从 1 开始,方便计算
img
代码很简单,思想很重要
区间和查询的利器

S[i] = a[1] + a[2] + ... a[i]
a[l] + ... + a[r] = S[r] - S[l - 1]

类似思想求矩阵中某区域的和
img
img

S[i, j] = 第i行j列格子左上部分所有元素的和
以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵的和为:
S[x2, y2] - S[x1 - 1, y2] - S[x2, y1 - 1] + S[x1 - 1, y1 - 1]

差分

前缀和的逆运算

同样的,数组下标通常从 1 开始
img
有 a 构造 b,使 a 是 b 的前缀和,则 b 是 a 的差分.

作用:
有了 B 数组,就一定能够用 O(n)的代价得到 A 👉 求一遍前缀和即可
且能够以 O(1)的代价,实现 A 的下标在[l,r]之间的元素全部加 c 👉 b[l] += c; b[r + 1] -= c;
如果上述操作进行了很多次,那么可以预处理差分数组 b,然后用 O(1)的代价替代原本的 O(n)的代价,最后求得 A
img

构造 b 数组的过程可以假定为在一个全 0 的 A 数组上,进行 n 次操作 👉 给 A 的[i, i]区间加上 a[i] i = 1, 2, 3, ..... n

const int N = 100010;

int n, m;
int a[N], b[N];
//给区间[l, r]中的每个数加上c:B[l] += c, B[r + 1] -= c
void insert(int l, int r, int c)
{
    b[l] += c;
    b[r + 1] -= c;
}

int main()
{
    scanf("%d%d", &n, &m);
    for(int i = 1; i <= n; i ++) scanf("%d", &a[i]);

    for(int i = 1; i <= n; i ++) insert(i, i, a[i]);  //初始化差分数组

    while(m --)
    {
        int l, r, c;
        scanf("%d%d%d", &l, &r, &c);
        insert(l, r, c);
    }
    for(int i = 1; i <= n; i ++) b[i] += b[i - 1];

    for(int i = 1; i <= n; i ++) printf("%d ", b[i]);

    return 0;
}

类似地,二维差分
img

b[x1, y1] += c;的效果就是,所有的S[i, j] (i >= x1, j >= x2)都加上了c 👉因为是前缀和 👉这里的S数组就是A数组

给以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵中的所有元素加上c:
b[x1, y1] += c, b[x2 + 1, y1] -= c, b[x1, y2 + 1] -= c, b[x2 + 1, y2 + 1] += c

双指针算法

两种类型:

  • 归并排序中合并的过程就用到了双指针,用来按顺序遍历
    img
  • 快排划分的过程也用到双指针,用来维护一个区间
    img
for (int i = 0, j = 0; i < n; i ++ )
{
    while (j < n && check(i, j)) j ++ ;
    // 首先判断j的合法范围,之后检查性质


    // 具体问题的逻辑
}
常见问题分类:
    (1) 对于一个序列,用两个指针维护一段区间
    (2) 对于两个序列,维护某种次序,比如归并排序中合并两个有序序列的操作

核心思想:应用某种单调性质,将暴力搜索的\(O(n^2)\)优化到用两个指针扫描的\(O(n)\)

考虑双指针算法求解,可以先想暴力解法
例如 求最长不重复子序列的长度 1 2 2 3 5 👉 3

//朴素做法 O(n ^ 2)
for(int i = 0; i < n; i ++)
{
    for(int j = i; j < n; j ++)
    {
        if(check(i, j)) continue;
        res = max(res, j - i + 1);
    }
}

//双指针算法 : O(n)
for(int i = 0, j = 0; i < n; i ++)
{
    while(j < n && check(i, j)) j ++;
    res = max(res, j - i + 1);
}
//如果[i, j]之间没有重复,那么[i + 1, j]之间一定没有重复,两个指针都移动n,算法从n^2 优化为了2n

位运算

//注意是二进制表示
求n的第k位数字: n >> k & 1

返回n的最后一位1:lowbit(n) = n & -n
// -n = ~n + 1
//10010 👉 10
//101000 👉 1000

离散化

整数保序离散化

取值范围大,但数据规模小(例如值域为 0 ~ 10^9, 规模为 10^5)
那么可以将值映射到连续的自然数
img

问题:

  • 最初的数据中可能有重复元素(去重)
  • 如何正确映射 (保序离散化的话,用二分即可找到离散化之后数据的下标)
vector<int> alls; // 存储所有待离散化的值
sort(alls.begin(), alls.end()); // 将所有值排序
alls.erase(unique(alls.begin(), alls.end()), alls.end());   // 其中unique进行数组去重,返回去重之后的数组末尾的下标,erase进行删除
//⚠️unique函数如果接受无序的vector似乎并不能去掉重复元素

// 二分求出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
}

vector alls 用来记录值域范围内真正用到的下标
如果原始数据每个下标还存在一个与之对应的有意义的值,我们还需要定义一个数组来保存对应位置的值(该数组的下标应为离散化之后的,故该数组被压缩了),find 函数用来根据原始数据的值找到压缩数组对应数据的下标,进而操作压缩数组中的数据

区间合并

将若干个可能有交集的区间进行合并
img

做法:类似贪心,维护一个当前区间(区间相关问题,大部分都采用类似做法)
img
碰到第一个没有交集的区间就可以选择该区间进行维护了,因为当前区间和之后的所有区间都不会再有交集。
可能需要合并的区间的左端点一定在当前维护区间的右边(因为先进行了排序)

// 将所有存在交集的区间合并
void merge(vector<PII> &segs)
{
    vector<PII> res;

    sort(segs.begin(), segs.end());  //首先按区间左端点进行排序
    // C++中sort对pair先比较第一个元素,再比较后一个元素

    int st = -2e9, ed = -2e9;  //st , ed 保存当前要维护区间的左右端点
    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;
}

链表与邻接表、栈与队列

很多种实现方式,这里给出如何用静态的数组来模拟

当然也可以用结构体+指针实现或者 stl 容器来实现

但后者为动态的 👉 new 一个 Node 很慢

  • 单链表
    最常用的单链表 👉 邻接表 👉 用来存储图和树
    img
    如何实现:一个数组 e[N]存储每个节点的值,另一个数组 ne[N]存储下一个节点
    img

    O(1)的代价找到某节点后面的点,但找到某节点前面的点只能从头开始遍历

    插入新的节点
    img
    img
    删除节点
    img
    img
    执行删除操作之后,删掉的点的数据虽然还在两个数组中,但从 head 开始的链表里将无法找到这个节点(相应 idx 的那块内存泄露了,但是算法题中不需要考虑)

    // head存储链表头,e[]存储节点的值,ne[]存储节点的next指针,idx表示当前用到了哪个节点
      int head, e[N], ne[N], idx;
    
      // 初始化  ne数组要初始化为-1
      void init()
      {
          head = -1;
          idx = 0;
      }
    
      // 在链表头插入一个数a, 采用头插法
      void insert(int a)
      {
          e[idx] = a, ne[idx] = head, head = idx ++ ;
      }
    
      // 将头结点删除,需要保证头结点存在
      void remove()
      {
          head = ne[head];
      }
    
      // 更一般地将x插到下标是k的点后面
      void add(int k, int x)
      {
          e[idx] = x;
          ne[idx] = ne[k];
          ne[k] = idx ++;
      }
    
      // 更一般地将下标是k的点后面的点删掉, 但这时将不能删除头结点,因为它不是任何点后面的点
      void remove(int k)
      {
          ne[k] = ne[ne[k]];
      }
    
  • 双链表
    用来优化某些问题

    双链表与单链表的不同是,每个节点有两个指针,一个指向前,另外一个指向后
    不妨让下标为 0 的点为头结点,下标为 1 的点为尾节点

    在某点的右边插入一个新的节点
    img
    与单链表类似,先增加指针,然后修改原本的指针
    img
    img
    注意修改时的顺序,要保证修改右侧节点的左指针(因为左侧结点下标已知,一般先修改右侧节点的左指针,再修改左侧节点的右指针,否则将不能通过左侧节点的右指针找到正确的右侧节点)

    删除节点
    img
    img

    // e[]表示节点的值,l[]表示节点的左指针,r[]表示节点的右指针,idx表示当前用到了哪个节点
      int e[N], l[N], r[N], idx;
    
      // 初始化
      void init()
      {
          //0是左端点,1是右端点   0的右边为1,1的左边为0,这两个点为边界,不存数据
          r[0] = 1, l[1] = 0;
          idx = 2;
      }
    
      // 在节点a的右边插入一个数x, 想在左边插入可以调用insert(l[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];  //右边节点的左指针,直接指向节点a的左边
          r[l[a]] = r[a];
      }
    
  • 邻接表
    用单链表来存储某个点的所有邻边
    有 n 个节点的邻接表,就相当于 n 个单链表(head[i]指向第 i 个点)
    详见图论部分
    img


  • 先进后出 FILO,先插入的元素会最后被弹出

      // tt表示栈顶元素的下标, tt为0标示栈空   这样的话0号元素将不再使用
      int stk[N], tt = 0;
    
      // 向栈顶插入一个数
      stk[ ++ tt] = x;
    
      // 从栈顶弹出一个数,tt原本的位置可用了
      tt -- ;
    
      // 栈顶的值
      stk[tt];
    
      // 判断栈是否为空,如果 tt > 0,则表示不为空
      if (tt > 0)
      {
    
      }
    
  • 队列
    先进先出 FIFO,先放进去的元素会最早被拿出来

      // hh 表示队头元素的下标,tt表示队尾元素的下标,队尾小于队头标示队列为空
      int q[N], hh = 0, tt = -1;
    
      // 向队尾插入一个数
      q[ ++ tt] = x;
    
      // 从队头弹出一个数
      hh ++ ;
    
      // 队头的值
      q[hh];
    
      //队尾的值
      q[tt];
    
      // 判断队列是否为空,如果 hh <= tt,则表示不为空
      if (hh <= tt)
      {
    
      }
    

    循环队列

    // hh 表示队头,tt表示队尾的后一个位置, hh = tt 标示队空
      int q[N], hh = 0, tt = 0;
    
      // 向队尾插入一个数
      q[tt ++ ] = x;
      if (tt == N) tt = 0;
    
      // 从队头弹出一个数
      hh ++ ;
      if (hh == N) hh = 0;
    
      // 队头的值
      q[hh];
    
      // 判断队列是否为空,如果hh != tt,则表示不为空
      if (hh != tt)
      {
          //如果hh > tt,则元素个数为n + tt - hh
          //如果tt > hh, 则元素个数为 tt - hh
      }
    
  • 单调栈
    用到的场景很少(当答案的可能取值可以用栈来存,且栈中有的值没用的时候才可能用到)

      //常见模型:给定一个序列找出每个数左/右边离它最近的比它大/小的数
    
      //以找到左边最近的最小的数为例
      //用栈来保存从左到右遍历过的点,这样找答案时可以从栈顶开始找 FILO
      int tt = 0;
      for (int i = 1; i <= n; i ++ )
      {
          while (tt && check(stk[tt], i)) tt -- ;  //这里check函数应为skt[tt] >= a[i]
          //如果a[i] < skt[tt], 因为a[i]比skt[tt]小,那么对于后面的点来说skt[tt]将不可能是答案,可以从栈里面删去
          //while循环跳出且tt != 0,则找到了答案,这一行可以选择输出
          stk[ ++ tt] = i; //把i加入栈
      }
    
  • 单调队列
    用到的场景很少(当答案的可能取值可以用队列来存,且队列中有的值没用的时候才可能用到)

      //常见模型:找出滑动窗口中的最大值/最小值
    
      //以找滑动窗口中的最小值为例
      //用队列来保存可能答案的下标(这样才能判断该答案是否要出队),FIFO,因为先入队的答案会先滑出窗口
      int hh = 0, tt = -1;
      for (int i = 0; i < n; i ++ )
      {
          while (hh <= tt && check_out(q[hh])) hh ++ ;  // check_out函数用来判断队头是否滑出窗口,
          while (hh <= tt && check(q[tt], i)) tt -- ;   //这里的check函数应该为a[q[tt]] >= a[i]
          //如果a[q[tt]]大于a[i],因为q[tt]一定比i更早出队,那么它就不可能作为答案,可以删掉
          q[ ++ tt] = i;  //i入队
          //这一行可以选择输出,注意i要先入队,因为a[i]可能是答案
      }
    

kmp 算法(模式匹配)

先考虑暴力算法:用模式串 P 来匹配原串 S
img
第一重循环标示从原串的哪个位置开始匹配
img
img


优化:暴力做法在匹配失败时,相当于将模式串向 ➡️ 移动一位重新开始匹配,kmp 算法考虑最少向 ➡️ 移动若干位使得我们可以开始重新匹配(移动后绿颜色的部分和原串可以匹配)
img
因为是平移,则图中可以找出四部分是相同的
img
意味着模式串中的以某一位为终点的一后缀和该模式串的一前缀相等,且该前缀(后缀)应尽量长
img
定义一个数组 next,其中 next[j] = i 表示模式串中 p[1~i] = p[j-i+1 ~ j],也就是模式串中以 j 为结束点的后缀和模式串中最长为 i 的前缀可以匹配,但 i 不能等于 j,否则模式串将没法向 ➡️ 挪,即一定有 next[j] < j.
那么可以做到,当出现模式串第 j + 1 位出现不匹配时
img
将模式串中下标为 next[j]的点挪到原本 j 的位置可以开始重新匹配
img

求 ne 数组的方法是用模式串 p 去匹配模式串 p

// s[]是长文本,p[]是模式串,n是s的长度,m是p的长度
//ne就是next数组,C++中某个头文件用到了next,所以命名为ne更好

cin >> s + 1 >> p + 1; //串从下标为1开始

求模式串的Next数组:
for (int i = 2, j = 0; i <= m; i ++ )  // 注意这里m可以用strlen(p + 1)来计算 , n可以用strlen(s + 1)来计算 (如果是string,就是s.size())   注意用int来记录一下,否则string较长时计算strlen比较耗时
// 注意 ne[0]、ne[1]必须为0 否则会陷入死循环
{
    while (j && p[i] != p[j + 1]) j = ne[j];
    if (p[i] == p[j + 1]) j ++ ; //用p去模式匹配p
    ne[i] = j;  //获得同一个字符串中前缀和以某元素为结尾的后缀的最长公共部分
}

// 匹配
for (int i = 1, j = 0; i <= n; i ++ ) //注意每次是s[i]与p[j + 1]匹配
{
    while (j && s[i] != p[j + 1]) j = ne[j]; //j没有退回起点 且 不匹配 就挪动模式串
    if (s[i] == p[j + 1]) j ++ ;
    //如果j退回起点了,那么不用挪动j了,调整i直至匹配
    if (j == m)
    {
        // 匹配成功后的逻辑 (如果用到j,那么注意先计算结果再更新j)

        j = ne[j];  // 为下一次匹配做准备,将模式串向➡️挪

    }
}

求 ne 数组时有两种情况
第一种跳出循环时 p[i] = p[j + 1],之后 j ++,则根据定义 ne[i] = j
第二种跳出循环时,j = 0,根据定义 ne[i] = j
且 i ++ 之后在 while 循环过程中也一定只会用到 ne[1 ~ j]
img

Trie

字典树 🌳,用来高效地存储和查找字符串集合(字符元素的种类一般不会太多)

可以利用字符串的公共前缀来节省空间,查询和插入代价都为 O(len(字符串))

如何存储:img
如何查找:从 root 节点沿着树找,注意需要有 ⭐ 标记来标示字符串的结束


//以字符串只包含小写字母为例
int son[N][26], cnt[N], idx;
// 0号点既是根节点,又是空节点
// son[][]存储树中每个节点的子节点 (第一维为父节点,第二维为子节点的种类)
// son数组可以理解为指针,指向子节点的位置
// cnt[]存储以每个节点结尾的单词数量
// idx为当前用到的节点的编号
// N 是树中节点数 应取 输入的字符串的总长度(最坏情况下所有字符串没有公共部分

// 插入一个字符串
void insert(char *str)
{
    int p = 0;  //p指示当前节点,用于沿着树创建
    for (int i = 0; str[i]; i ++ )
    {
        int u = str[i] - 'a';  // 确定子节点的(种类)编号
        if (!son[p][u]) son[p][u] = ++ idx; // 如果p节点没有u这种儿子,创建子节点,注意是 ++ idx,不是 idx ++, 否则son[p][u]可能会指向根节点
        p = son[p][u];   //p移动
    }
    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];  //如果确实是字符串的结尾返回true,否则为false
}

// 删除操作较为复杂

扩展:01Trie 用于解决异或问题(O(len(二进制数))代价求某一个数和一组数据中任意一个数作异或的最大值)

例如求 3 与 5 和 6 异或的最大值
img

并查集

支持的操作

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

并查集做这两个操作的代价都近乎\(O(1)\)

做法:用树来维护集合,用根节点作为集合的代表元素(存储集合的编号),且每个非根节点 x 用 p[x]存储它的父节点的编号
img

如何判断树根: p[x] = x;
如何求 x 的集合编号: while(p[x] != x) x = p[x]; 最后 x 就是集合编号
如何合并两个集合: 将一颗树的根节点,作为另一颗树中任意节点的儿子. 即给一根节点 x,指定 p[x] = y(y 一般是另一棵树的根节点)
路径压缩优化: 将查找 x 祖宗节点的路径上的点都指向祖宗节点
img
按秩合并: 将矮树接到高的树上,一般用不到
用并查集维护额外的信息: 如下代码模板

朴素并查集

(1)朴素并查集:

    int p[N]; //存储每个点的祖宗节点

    // 返回x的祖宗节点
    int find(int x)
    {
        if (p[x] != x) p[x] = find(p[x]);   //路径压缩
        return p[x];     //压缩后p[x]指向该集合的祖宗节点
    }

    // 初始化,假定节点编号是1~n
    for (int i = 1; i <= n; i ++ ) p[i] = i;   //初始时每个节点自己是一个集合

    // 合并a和b所在的两个集合:
    p[find(a)] = find(b);  //这样写当a,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所在的两个集合:
    if(find(b) == find(a))  //当a、b在同一个集合时size不能累加
    {
        size[find(b)] += size[find(a)];    //注意先累加size 再合并 否则size会出错
        p[find(a)] = find(b);
    }
(3)维护到祖宗节点距离的并查集:

    int p[N], d[N];
    //p[]存储每个点的祖宗节点, d[x]存储x到祖宗节点的距离

    // 返回x的祖宗节点
    int find(int x)
    {
        if (p[x] != x)
        {
            int u = find(p[x]);
            d[x] += d[p[x]];  //先计算距离  这里写的只是一个示例,具体问题具体分析
            p[x] = u;  //再路径压缩
        }
        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)的偏移量

手写堆(一个数据集合)

支持的操作:

  1. 插入元素
  2. 求集合当中的最小值
  3. 删除最小值(小根堆)
  4. 删除任意元素(stl 里的优先队列不能直接实现)
  5. 修改任意元素(stl 里的优先队列不能直接实现)

如何定义:完全二叉树,且满足每个节点都 ≤ 它的子节点(小根堆)
如何存储:一维数组,1 为根节点,节点 x 的左儿子为 2x,右儿子为 2x + 1 (注意如果从 0 开始,根节点的左儿子将还是根节点)
如何实现支持的操作:用两个基本操作 down 和 up 来实现(down 和 up 代价都是\(O(log n)\))

// h[N]存储堆中的值, h[1]是堆顶,x的左儿子是2x, 右儿子是2x + 1
// ph[k]存储第k个插入的点在堆中的位置(p为下标,ph数组是从下标到堆的映射,不必需,看题意是否需要)
// hp[k]存储堆中下标是k的点是第几个插入的(从堆到下标的映射,不必需,看题意是否需要)
int h[N], ph[N], hp[N], size;

// 交换两个点,及其映射关系
void heap_swap(int a, int b)
{
    swap(ph[hp[a]],ph[hp[b]]);  //a b两点交换,ph也应交换
    swap(hp[a], hp[b]);  //交换hp
    swap(h[a], h[b]); //交换a b两点
}

void down(int u)
{
    int t = u;  //t 存储父节点 和左右节点 中最小的那个点的下标
    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)  //如果父节点已经最小就不需要再down了
    {
        heap_swap(u, t);
        down(t); // 递归down
    }
}

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);  //注意这里的n应该是最小的大于等于堆中元素数的2的幂
//若要求堆通过若干离散的插入操作建立 则参照下面插入的做法 这种做法的建堆代价为O(nlog n)

//插入 👉 在堆底加入一个元素,然后向上调整  h[++ size] = x; up(size);
//求最小值 👉 堆顶元素  h[1];
//删除最小值 👉 将堆顶元素变为堆的最后一个元素,然后向下调整  h[1] = h[size --]; down(1);
//删除任意元素 👉  将堆中对应元素变为堆的最后一个元素,然后向下或向上调整  h[k] = h[size --]; down(k); up(k);   因为k位置的值可能变大了也可能变小了,变大就要down,变小就要up
//修改任意元素 👉 就地修改 然后调整  h[k] = x; down(k); up(k);  同样可能变大,也可能变小

哈希表

img

用于将大值域映射到小空间(一般\(0 \sim n\)\(n\)一般\(10^5 \sim 10^6\))

支持的操作:

  1. 添加某元素(算法题中一般不要求从哈希表中删除元素)
  2. 查找某元素是否在集合中

前面学的离散化需要保序,是一种特殊的哈希方式,这里则不做要求

哈希函数:将大值域的 x 映射为小值域,例如 \(h(x) \in [0, 10^5] \qquad x \in (10^{-9},10^9)\)

映射过程中一般会出现冲突,即不同的 x 映射结果相同
解决冲突的方式

  • 拉链法(冲突就拉链)img
  • 开放地址法(冲突就顺延)img
(1) 拉链法
    int h[N], e[N], ne[N], idx;  //N一般取质数,这样冲突的概率会更小
    //h[N]是映射后的小空间,每个h[N]也相当于一个链表头,e,ne是链表数组

    // 向哈希表中插入一个数
    void insert(int x)
    {
        int k = (x % N + N) % N;  //x 可以是负数 ,数学中余数只能为正数,但C++中余数可以为正也可以为负
        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]; //N经验来说是题目数据规模的2~3倍

    // 如果x在哈希表中,返回x的下标;如果x不在哈希表中,返回x应该插入的位置
    int find(int x)
    {
        int t = (x % N + N) % N;
        while (h[t] != null && h[t] != x)  //定义一个null来标示哈希表没被占用,null应该不在数据范围内
        {
            t ++ ;
            if (t == N) t = 0;
        }
        return t;
    }
    //插入 h[find(x)] = x;
    //查找 h[find(x)] == null

字符串前缀哈希法

用于判断两段字符串(子串)是否完全一样(用 hash 值反映字符串的特征,如果 hash 值相同就认为字符串相同 👉 故应尽可能避免冲突)

做法:求每个前缀子串的哈希值,哈希值通过将字符串看成 p 进制数模一个 Q 来计算
img
类似于前缀和,计算完所有前缀子串的哈希值就能很快计算任意区间的哈希值,注意与前缀和思路类似,从下标 1 开始计算。

图中 1 ~ L-1 中第 L-1 位为最低位,权重为 1,1 ~ R 中第 R 位为最低位,权重为 1。故将 h[L-1]*p[r-l+1],使其与 1 ~ R 的字符串中对应子串的权重一致。

//核心思想:将字符串看成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];   //这里的高位相当于是h[1],低位是h[n]
    p[i] = p[i - 1] * P;
}

// 计算子串 str[l ~ r] 的哈希值
ULL get(int l, int r)
{
    return h[r] - h[l - 1] * p[r - l + 1];
}

STL 容器使用

vector

vector, 变长数组  //#include<vector>

//倍增的思想,系统分配空间的代价和空间大小无关仅与申请分配的次数有关
//一开始分配长度为32的空间,超过则倍增,如果空间大小为n,则申请代价为O(log n),copy的代价接近O(n)
    vector<int> a(10, 3);   //初始化长度为10,值都为3的整型vector

    size()  //返回元素个数(一般容器都有, O(1)复杂度)
    empty()  //返回是否为空(一般容器都有,O(1)复杂度)
    clear()  //清空
    front()/back() //front返回第一个数,back返回最后一个数
    push_back()/pop_back() //向vector最后插入一个数 / 删除最后一个数
    begin()/end() //迭代器,类型名为vector<int>::iterator (用auto让编译器推断也行) begin指向第0个数的位置,end指向最后一个数后面的位置
    //迭代器支持 --, ++ 返回有序序列的前驱和后继(*(it-1)、*(it+1)),时间复杂度 O(logn)
    [];  //可以像数组那样随机访问
    if(a < b) ...  //支持比较运算,按字典序

pair

pair<int, int> p  //#include<iostream>

    p = make_pair(6, 66); // 或者p = {6, 66}; 进行初始化
    p.first  //第一个元素
    p.second  //第二个元素
    if(p1 < p2)  //支持比较运算,以first为第一关键字,以second为第二关键字(字典序)

    //pair数组可以排序,pair可以嵌套定义pair<int, pair<int, int>> 可以存储一组三个值

string

string,字符串  //#include<cstring>
    size()/length()  //返回字符串长度
    empty()
    clear()
    assign(字符数组指针, 长度) //将字符数组的值赋给string类型,长度参数可以省略
    substr(起始下标,(子串长度))  //返回子串, 子串长度超出原串范围也只到原串的末尾,第二个参数可以省略
    c_str()  //返回字符串所在字符数组的起始地址  可以用于字符串拷贝和printf方式打印
    a += b; //string支持直接追加,如果是字符数组的要用append()函数

queue

queue, 队列  //#include<queue>
    size()
    empty()
    push()  //向队尾插入一个元素
    front()  //返回队头元素
    back()  //返回队尾元素
    pop()  //弹出队头元素
    //没有清空 重新构造即可
    //queue是容器的容器,底层容器默认是 std::deque

priority_queue

priority_queue, 优先队列,默认是大根堆  //#include<queue>
    size()
    empty()
    push()  //插入一个元素
    top()  //返回堆顶元素
    pop()  //弹出堆顶元素
    定义成小根堆的方式:priority_queue<int, vector<int>, greater<int>> q;

stack

stack, 栈  //#include<stack>
    size()
    empty()
    push()  //向栈顶插入一个元素
    top()  //返回栈顶元素
    pop()  //弹出栈顶元素

deque

deque, 双端队列(队头队尾都可以插入删除,支持随机访问,加强版vector,速度慢)  //#include<deque>
    size()
    empty()
    clear()
    front()/back()  //返回第一个、最后一个元素
    push_back()/pop_back()  //向最后插入一个元素,删除最后一个元素
    push_front()/pop_front()  //向开头插入一个元素,删除第一个元素
    begin()/end()  //迭代器
    []; //支持随机访问

set、map、multi 、unordered_

set, map, multiset, multimap, 基于平衡二叉树(红黑树),动态维护有序序列  //#include<set>  #include<map>
//set 不能有重复元素(插入重复元素的操作会被忽略),multiset可以有重复元素

    size();
    empty();
    clear();
    begin()/end();

    set/multiset
        insert()  //插入一个数
        find()  //查找一个数,返回迭代器 如果不存在返回end()迭代器 ,end迭代器指向最后一个数后面的位置
        count()  //返回某一个数的个数
        erase()
            //(1) 输入是一个数x,删除所有x   O(k + logn), k为x的个数
            //(2) 输入一个迭代器,删除这个迭代器
        lower_bound()/upper_bound()
            //lower_bound(x)  返回大于等于x的最小的数的迭代器  返回end()表示没找到
            //upper_bound(x)  返回大于x的最小的数的迭代器

    map/multimap
        insert()  //插入的数是一个pair,map是把两个元素做映射 或者直接 m[key] = value
        erase()  //输入的参数是pair或者迭代器
        find() //输入的参数是要查找的key
        [];  //下标为pair中的first,返回值为pair中的second
        //注意multimap不支持此操作,因为一对多无法确定值是多少。 时间复杂度是 O(logn)
        lower_bound()/upper_bound()

    //除size、empty、clear外的操作的代价基本上都是O(log n)
unordered_set, unordered_map, unordered_multiset, unordered_multimap  //#include<unordered_set>   #include<unordered_map>
    //哈希表主要用unoredered_set、unordered_map实现,这两种容器能够提供基础的插入、删除和查找操作
    //和上面类似,增删改查的时间复杂度是 O(1)
    //不支持 lower_bound()/upper_bound(), 迭代器的++,--   (因为内部是无序的)

bitset

bitset, 位集   //利用bit来表示数据
    bitset<10000> s;  //定义一个长度为10000的bitset
    ~, &, |, ^  //支持位运算
    >>, <<  //支持移位
    ==, !=
    [];  //随机访问任意一位

    count()  //返回有多少个1

    any()  //判断是否至少有一个1
    none()  //判断是否全为0

    set()  //把所有位置成1
    set(k, v)  //将第k位变成v
    reset()  //把所有位变成0
    flip()  //等价于~
    flip(k) //把第k位取反

DFS、BFS

  • DFS:

    • 尽可能地向下层搜,当不能向下搜时回溯一层,重复这一过程
    • stack 用栈保存深搜路径,代价为\(O(h)\),h 为 🌳 高
    • 不具有最短路性质
  • BFS:

    • 尽可能地在当前层搜,当这一层搜完了扩展到下一层,重复这一过程
    • queue 用队列来存储一层的所有节点,代价为\(O(2^h)\),h 为 🌳 高
    • 具有最短路性质(一开始搜到的点离起始点最近 👈 如果边的权重都为 1 的话)

普通深搜

DFS 相关重要概念 👉 回溯(恢复现场)、✂️ 枝(不用走到子节点,减少暴搜代价)
暴搜(遍历所有方案,每次得到一条深搜路径),最重要的是设计搜索顺序,得到一颗搜索树,使到叶子节点的路径为最终方案
爆搜时间复杂度一般很高,应注意剪枝

深搜路径的保存应当使用栈,但我们的深搜函数往往递归调用自身,就构成了函数调用栈,由系统来维护

深搜解决 1~n 全排列
img

int n;
int path[N];  //函数调用的过程就相当于栈
bool st[N];  //全排列不能用用过的点
void dfs(int u)
{
    if(u == n)  //判断是否为叶节点
    {
        for(int i = 0; i < n; i ++) printf("%d ", path[i]);
        puts("");
        return;
    }

    for(int i = 1; i <= n; i ++)
    {
        if(!st[i])
        {
            path[u] = i;
            st[i] = true;
            dfs(u + 1);  //深搜
            st[i] = false;  //恢复现场
        }
    }
}
int main()
{
    cin >> n;
    dfs(0);
    return 0;
}

深搜解决 n 皇后问题(将 n 个皇后放到 n*n 的棋盘)

第一种暴搜顺序:按每行放一个皇后,类似于全排列的问题

int n;
char g[N][N];
bool col[N], dg[2 * N], udg[2 * N];//列、对角线、反对角线

void dfs(int u)//与全排列思路类似,枚举每一行皇后的位置
{
    if(u == n)  //到达叶子节点 输出方案
    {
        for(int i = 0; i < n; i ++) puts(g[i]);
        puts("");
        return;
    }

    for(int i = 0; i < n; i ++)
        if(!col[i] && !dg[u + i] && !udg[n - u + i])  //能放皇后
        {
            g[u][i] = 'Q';
            col[i] = dg[u + i] = udg[n - u + i] = true;
            dfs(u + 1);  //搜索下一层
            col[i] = dg[u + i] = udg[n - u + i] = false;  //回溯,复现场
            g[u][i] = '.';
        }
}

int main()
{
    cin >> n;
    for(int i = 0; i < n; i ++)
        for(int j = 0; j < n; j ++)
            g[i][j] = '.';

    dfs(0);  //一开始没有行放了皇后
    return 0;
}

第二种暴搜顺序:对每个格子暴搜放与不放(剪枝)
img

int n;
char g[N][N];
bool row[N], col[N], dg[2 * N], udg[2 * N];//行(新增)、列、对角线、反对角线

void dfs(int x, int y, int s)
{
    if(y == n) y = 0, x ++; //当暴搜出界时,调整到下一行

    if(x == n) //当所有格子都搜完时
    {
        if(s == n) //如果方案可行 输出
        {
            for(int i = 0; i < n; i ++) puts(g[i]);
            puts("");
        }
        return;
    }

    // 不放皇后搜下一层
    dfs(x, y + 1, s);

    // 放皇后搜下一层 , 本身是不放皇后深搜的回溯
    if(!row[x] && !col[y] && !dg[x + y] && !udg[n - y + x])
    {
        g[x][y] = 'Q';
        row[x] = col[y] = dg[x + y] = udg[n - y + x] = true; // 可以用连等号
        dfs(x, y + 1, s +1);
        row[x] = col[y] = dg[x + y] = udg[n - y + x] = false; //回溯,恢复现场
        g[x][y] = '.';
    }
}
int main()
{
    cin >> n;
    for(int i = 0; i < n; i ++)
        for(int j = 0; j < n; j ++)
            g[i][j] = '.';

    dfs(0, 0, 0);  //一开始从0,0格开始暴搜,已经放了0个皇后
    return 0;
}

普通宽搜

用队列来保存宽搜节点,存在宽搜模板
img

走迷宫
img
可以从起点出发进行宽搜,用一个d[N][N]数组来存储每个节点到起点的距离,每次搜到一个节点令其距离为父节点距离的值+1,因为宽搜具有最短路性质,该值即为最短路的长度

#include<cstring>
#include<iostream>
#include<algorithm>

using namespace std;

typedef pair<int, int> PII;

const int N = 110;

int n, m;
int g[N][N];
int d[N][N];
PII q[N * N];

int bfs()
{
    int hh = 0, tt = 0; // 初始时有一个起点一个元素
    q[0] = {0, 0};

    int dx[4] = {-1, 0, 1, 0}, dy[4] = {0, 1, 0, -1};

    while(hh <= tt)
    {
        auto t = q[hh ++];

        for(int i = 0; i < 4; i ++)
        {
            int x = t.first + dx[i], y = t.second + dy[i];
            if(x >= 0 && x < n && y >= 0 && y < m && g[x][y] == 0 && d[x][y] ==  -1)
            {
                d[x][y] = d[t.first][t.second] + 1;
                q[++ tt] = {x, y};
            }
        }
    }

    return d[n - 1][m - 1];
}

int main()
{
    cin >> n >> m;

    for(int i = 0; i < n; i ++)
        for(int j = 0; j < m; j ++)
            cin >> g[i][j];

    cout << bfs() << endl;
}

算法关系(最短路、bfs、dp):

  • 最短路问题可以用 Dijkstra、Bellman-Ford 等算法解决
  • 边权都相同的最短路问题可以用 BFS 解决
  • dp 问题被视为一种特殊的(无环的)最短路问题
  • 最短路算法的时间复杂度高于 dp

树与图的存储

考虑树和图的问题,只需考虑有向图即可
因为树是特殊的(无环连通)图、无向图是特殊的(有 a 到 b 的边就一定有 b 到 a 的边)有向图

考虑有向图的存储:

  1. 邻接矩阵 👉 用 g[a,b]来存储 a 到 b 的边的信息,浪费空间,适合稠密图
  2. 邻接表 👉 用 n 个单链表来保存每个节点的出边 👉 参考单链表小节

单链表

// 对于每个点k,开一个单链表,存储k所有可以走到的点。h[k]存储这个单链表的头结点
int h[N], e[M], w[M], ne[M], 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);

树与图的深度优先遍历

和深搜思路类似
img

深度优先遍历一颗树可以知道子树的大小

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);
        //回溯、没有现场需要恢复
    }
}

树与图的宽度优先遍历

和宽搜思路类似
img

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);
        }
    }
}

拓扑排序

拓扑序:将所有节点排序,使得如果图中有 a 到 b 的边,那么 a 排在 b 前面
可以证明有向无环图(拓扑图)一定有拓扑序列,反之一定没有
img
用宽搜求图拓扑序

如何获得拓扑序?

  1. 有向无环图一定有入度为 0 的点(反证,假设没有入度为 0 的点那么可以从某个点开始一直沿着有向边反着找邻居,因为无环,每次都能找到一个新的点,但点是有限的,该步骤进行 n + 1 次时,根据抽屉原理,路径中一定有点是重复的,和无环假设矛盾)
  2. 所有入度为 0 的点一定位于序列最开头,将他们放到序列最开头
  3. 接下来删掉这些点的出边(因为已经把它们放到了最开头,出边带来的限制一定满足,可以删去)
  4. 有向无环图删掉若干边之后还是有向无环图,如果图不为空,重复上述步骤,如果为空,那么所有点已经加入序列,得到拓扑序

img

bool topsort() //求拓扑序,同时返回是否有拓扑序
{
    int hh = 0, tt = -1;

    // d[i] 存储点i的入度
    for (int i = 1; i <= n; i ++ )
        if (!d[i])
            q[ ++ tt] = i;

    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)  //并不需要真的删掉边 只要减少对应点的入度来标示其限制减少即可
                q[ ++ tt] = j;
        }
    }

    // 如果所有点都入队了,说明存在拓扑序列;否则不存在拓扑序列。
    return tt == n - 1;
}

最短路

最短路问题:

  • 单源最短路(求一个点到所有点的最短路)
    • 所有边权都为正数
      • 朴素 Dijkstra 👉 时间复杂度\(O(n^2 + m)\),适合于 m 是 O(n^2)数量级的稠密图
      • 堆优化 Dijkstra 👉 \(O(mlog n)\),适合于 n 较大的稀疏图
    • 存在负权边
      • Bellman-Ford 👉 \(O(nm)\)
      • SPFA 👉 平均状态下\(O(m)\),最坏情况下\(O(nm)\),是 Bellman-Ford 算法的优化版本
  • 多源汇最短路(求任意两点的最短路)👉 Floyd 算法 \(O(n^3)\)

n 表示点数,m 表示边数

根据数据范围总能找到一个最适合的算法套用,故最短路问题的难点在于建图(如何定义点和边,如何将原问题抽象为最短路问题)

朴素 Dijkstra 算法

基于贪心,每次将距离源点最近的点加入已知最短路的集合(贪心),同时更新所有点的距离
一开始集合 s 为空,第一次会将源点加入 s
img
朴素 Dijkstra 算法适用于稠密图,稠密图使用邻接矩阵来存

int g[N][N];  // 存储每条边   如果没有对应边g[i][j]的值应该为正无穷,g[i][i]的值应为0
int dist[N];  // 存储1号点到每个点的最短距离
bool st[N];   // 存储每个点的最短路是否已经确定

// 求1号点到n号点的最短路,如果不存在则返回-1
int dijkstra()
{
    //初始化距离
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;
    //此时s集合为空

    for (int i = 0; i < n - 1; i ++ )  //迭代n - 1次,每次新确定到一个点的最短路,因为每次迭代的最后会更新距离,那么只需要迭代n - 1次即可
    {
        int t = -1;     // 在还未确定最短路的点中,寻找距离最小的点   朴素版本这一步总的复杂度为O(n^2),堆优化这一步总的复杂度可以优化为O(n)
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))  //找到不在s中的距离最近的点,第一次迭代一定是1号点
                t = j;

        // 用t更新其他点的距离
        for (int j = 1; j <= n; j ++ )
            dist[j] = min(dist[j], dist[t] + g[t][j]);  //朴素版本这一步中的复杂度为O(m),堆优化版本变为O(mlog n )

        st[t] = true;  //将t加入到s中
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

堆优化版 Dijkstra

只是用堆数据结构对朴素 Dijkstra 算法进行了优化
img
可以手写堆,但该堆要求能支持修改任意元素的操作,较复杂
如果使用优先队列就不需要手写堆,修改操作可以用向堆中增加元素来代替,这样会导致算法实际的复杂度变为 O(mlog m)

因为是稀疏图,使用邻接表来存图

typedef pair<int, int> PII;

int n;      // 点的数量
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边   其中e[i]存储边的入点,w[i]存储边的权重,h[i]存储以i为出点的所有边构成的单链表的头结点的下标
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();  //可以pop,因为该点要么在s集合中,要么即将加入s集合,不需要在小根堆中存储了

        int ver = t.second, distance = t.first;

        if (st[ver]) continue;  //如果已经在s集合中则跳过
        st[ver] = true;  //加入s集合

        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 算法

基于离散数学的一些知识
img
迭代 k 次,得到的 dist 数组是从 1 号点到任意点的路径长度不超过 k 的最短路长度
这里备份的作用是保证在更新 dist 数组时只基于上一次迭代的结果,否则可能出现 dist 数组中的某一元素在更新时基于前面更新的 dist 元素的情况
时间复杂度\(O(nm)\) 👉 除了计算路径长度不超过 k 的最短路,更常使用的算法是 SPFA

因为贝尔曼-福特算法每次不需要考虑顺序的遍历所有边,所以 Bellman-Ford 算法在存图时可以用最朴素的方法 👉 结构体数组来存

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 ++ ) //迭代n次
    {
        memcpy(backup, dist, sizeof dist); //先备份上一次迭代的结果

        for (int j = 0; j < m; j ++ )  //遍历所有边
        {
            int a = edges[j].a, b = edges[j].b, w = edges[j].w;
            dist[b] = min(dist[b], backup[a] + w);
        }
    }

    if (dist[n] > 0x3f3f3f3f / 2) return -1;  //因为距离为正无穷的点之间可能存在负权边,导致dist略小于规定的正无穷
    return dist[n];
}

SPFA 算法

对 Bellman-Ford 算法的更新进行了优化
因为松弛操作
img
只有当上一次迭代 dist[ a ]变小了,才有可能真正更新,所以我们可以用一个队列来存储每次迭代变小的 dist,按照宽搜类似的方法来更新
img
因为需要遍历某一点的所有边,故用邻接表来存图
时间复杂度一般为 O(m),最坏为 O(nm)
可以发现和 Dijkstra 算法很像,实际上应该用 Dijkstra 算法(n^2)解决的正权图问题一般也可以用 SPFA(n ~ nm)过掉

spfa 判断负环,引入一个 cnt 数组,每次更新距离时,更新 cnt。
如果 cnt 数组中存在大于 n 的元素,根据抽屉原理,最短路中一定存在环,又因为是从 1 到某一点的最短路那么就一定是负环
如果想判断图中负环的存在性(即使这个负环从 1 号点到不了),那么初始时可以将图中所有点加入到队列中

int n;      // 总点数
int h[N], w[N], e[N], ne[N], idx;       // 邻接表存储所有边
int dist[N];        // 存储每个点到1号点的最短距离
bool st[N];     // 存储每个点是否在队列中,防止队列中出现重复的点  ⚠️这里st数组的含义与之前有区别

// 求1号点到n号点的最短路距离,如果从1号点无法走到n号点则返回-1
int spfa()
{
    memset(dist, 0x3f, sizeof dist);
    dist[1] = 0;

    queue<int> q;
    q.push(1);
    st[1] = 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];
                if (!st[j])     // 如果队列中已存在j,则不需要将j重复插入
                {
                    q.push(j);
                    st[j] = true;
                }
            }
        }
    }

    if (dist[n] == 0x3f3f3f3f) return -1;
    return dist[n];
}

Floyd 算法

基于动态规划 👉 状态 d(i,j,k)表示当只考虑前 k 个点时 i 到 j 的最短距离 状态转移方程为 d(i,j,k) = min(d(i,j,k-1), d(i,k,k-1) + d(k,j,k-1))
用邻接矩阵来存图

初始化:
    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]);   //更新d[i][j]
}

最小生成树

最小生成树问题用于解决如何用最小的代价,构建一个子图使图中所有点都连通
最小生成树问题一般只涉及无向图
img
稠密图用朴素 Prim,稀疏图用 Kruskal 算法(更好写,且 mlog m 与 mlog n 是同一数量级)

朴素 Prim

Prim 算法和 Dijkstra 算法的步骤非常类似
img
唯一不同的是这里距离不是到起点的距离(Dijkstra),而是到集合的距离(Prim) 点到集合的距离是该点与集合内的点之间的边的权重的最小值
因为稠密图,用邻接矩阵存图
时间复杂度为 \(O(n^2 + m)\) , 也就是 \(O(n^2)\)

int n;      // n表示点数
int g[N][N];        // 邻接矩阵,存储所有边
int dist[N];        // 存储其他点到当前最小生成树的距离
bool st[N];     // 存储每个点是否已经在生成树中


// 如果图不连通,则返回INF(值是0x3f3f3f3f), 否则返回最小生成树的树边权重之和
int prim()
{
    memset(dist, 0x3f, sizeof dist);

    int res = 0;
    for (int i = 0; i < n; i ++ )//迭代n次,因为每次迭代加入一个点,最小生成树要加入n个点,n - 1 条边
    {
        int t = -1;
        for (int j = 1; j <= n; j ++ )
            if (!st[j] && (t == -1 || dist[t] > dist[j]))  //找不在集合内的距离最小的点
                t = j;

        if (i && dist[t] == INF) return INF;  //如果不是第一个点且到集合的距离最近的点的距离为INF,说明图不连通,返回INF

        if (i) res += dist[t]; //只要不是第一个点,就将代价加入res中    注意先累加代价,再更新距离
        st[t] = true;  //将点加入集合

        for (int j = 1; j <= n; j ++ ) dist[j] = min(dist[j], g[t][j]);   // 更新距离
    }

    return res;
}

Kruskal 算法

Kruskal 算法基于快排和并查集,思路也非常简单
img
因为该算法遍历所有边,不需要按点进行归类,我们用结构体来存储边即可
时间复杂度为 \(O(mlog m)\)

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;  //少于n - 1 条边则该图不连通
    return res;
}

二分图:染色法、匈牙利算法

二分图:可以将图的所有点划分为两部分,使得图中所有边都连接图的不同部分的点,也即图能被二染色

img

染色法

染色法可以判断一个图是不是二分图
一个图是二分图当且仅当图中不含奇数环(奇数环不能被二染色,同时如果图中没有奇数环,可以对图中的每个连通块从一个点开始进行二染色而不出现矛盾)
染色过程一定如下图所示
img
img
遍历所有点进行染色,如果所有点都能染上颜色不出现矛盾,那么就是二分图
时间复杂度为 \(O(n + m)\)
统一用邻接表存图即可

int n;      // n表示点数
int h[N], e[M], ne[M], idx;     // 邻接表存储图
int color[N];       // 表示每个点的颜色,-1表示未染色,0表示白色,1表示黑色

// 参数: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] == -1) //如果子节点没染色
        {
            if (!dfs(j, !c)) return false;  //给子节点染相反的颜色
        }
        else if (color[j] == c) return false;    //如果子节点和当前节点的颜色相同,说明有环,返回false
    }

    return true;
}

bool check()
{
    memset(color, -1, sizeof color);
    bool flag = true;
    for (int i = 1; i <= n; i ++ ) //对每个连通块进行染色
        if (color[i] == -1)
            if (!dfs(i, 0))    //dfs返回false,置flag为false
            {
                flag = false;
                break;
            }
    return flag;
}

匈牙利算法

匈牙利算法可以判断二分图中两部分点的最大匹配数
也即下图中男生女生配对数的最大值(不许脚踏两只船 😡)
img
img
每个男生都尝试所有可能(如果没有可以直接匹配的对象,就找到已经匹配了的女生的对象尝试让他换一个女生匹配)
理论上时间复杂度为 \(O(nm)\) ,但实际运行效果很好

int n1, n2;     // n1表示第一个集合中的点数,n2表示第二个集合中的点数
int h[N], e[M], ne[M], idx;     // 邻接表存储所有边,匈牙利算法中只会用到从第一个集合指向第二个集合的边,所以这里只用存一个方向的边
int match[N];       // 存储第二个集合中的每个点当前匹配的第一个集合中的点是哪个
bool st[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]))   // 如果这个女生还没匹配,或者这个女生的对象能找到其他人
            //  递归调用find函数时不会该男生不会再考虑同一女生,因为她被标记为已遍历了
            {
                match[j] = x;  // 那么可以匹配,记录一下匹配关系
                return true;
            }
        }
    }

    return false;  // 该男生对所有有好感的女生都没办法直接或间接匹配
}

// 求最大匹配数,依次枚举第一个集合中的每个点能否匹配第二个集合中的点
int res = 0;
for (int i = 1; i <= n1; i ++ )  // 对每个男生尝试匹配
{
    memset(st, false, sizeof st);  // 注意初始时st数组置空,该数组可以保证每个女生只会考虑一次
    if (find(i)) res ++ ;  //如果匹配成功 结果加1
}

数论

质数

质数:大于 1 且不能被 1 或其本身以外的数整除的自然数

试除法判断 x 是否为质数 👉 考虑 小于等于\(\sqrt{x}\)的所有数是不是 x 的约数

bool is_prime(int x)
{
    if (x < 2) return false;
    for (int i = 2; i <= x / i; i ++ )  // 注意判断条件写成 i <= x / i
    // 如果写成sqrt(x) 会比较慢 , 如果写成 i * i <= n 当n接近int的最大值时,左边可能会出现溢出而变为负数
        if (x % i == 0)
            return false;
    return true;
}

试除法分解质因数 👇

任意合数都可以分解为其质因数的乘积,形如\(x = p_1^{a_1} \cdots p_k^{a_k}\)
暴力做法:枚举所有可能质因数,对每个质因数枚举其次数
实现方式:从小到大枚举从 2 到 n - 1 的所有数,如果 x 能被某个数整除,那么这个数就是 x 的质因数

注意虽然我们枚举所有数(包括合数),但从小到大枚举的过程中如果 x 能被 i 整除,i 一定是质数
这是因为此时 x 中不会包含 2 ~ i-1 的因子(因为我们已经枚举过了),但 x 能被 i 整除,则 i 中也不包含 2~i-1 的因子,则 i 是一个质数
又因为 x 显然最多只包含一个大于\(\sqrt{x}\)的质因子,这样我们就只需要枚举 2 到\(\sqrt{x}\)的所有数,最后如果 x 还大于 1 那么此时的 x 就是那个大于\(\sqrt{x}\)的质因子

void divide(int x)
{
    for (int i = 2; i <= x / i; i ++ ) //枚举所有可能质因数
        if (x % i == 0)  // 是质因数
        {
            int s = 0;
            while (x % i == 0) x /= i, s ++ ;  // 枚举次数
            cout << i << ' ' << s << endl;
        }
    if (x > 1) cout << x << ' ' << 1 << endl;  // 可能存在一个大于√x的质因子
    cout << endl;
}

朴素筛法(埃式筛法) 👉 从小到大枚举每个数,如果当前的数 i 没有被前面的数筛掉(没有从 2 ~ i - 1 的因子),那么它是一个质数,筛掉它的所有倍数。最后得到的结果序列就是质数序列

时间复杂度为$ \frac{n}{2} + \frac{n}{3} + \frac{n}{5} + \cdots + \frac{n}{2 \sim n 中最大的质因数} $, \(O(n\log(\log 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;  // 筛掉它的倍数
    }
}

线性筛法:埃式筛法中合数可能被多次筛掉,而线性筛法保证合数只被它的最小质因子筛掉

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;   // 如果是质数,存入结果序列

        for (int j = 0; primes[j] <= n / i; j ++ )  // 根据当前的i,从小到大枚举质数
        {
            st[primes[j] * i] = true;
            if (i % primes[j] == 0) break;  // 这一步能够确保primes[j] 小于等于 i的最小质因子,故在上一步筛的过程中 primes[j] 一定是 primes[j] * i 的最小质因子,故符合线性筛法的要求
            // 任何合数 x 都可以表示为 x = p * c , 其中 c ≥ 2, p是其最小质因子 , 故在枚举的过程中一定会被筛掉
        }
    }
}

约数

试除法求 x 的约数 👉 枚举 1 ~ \(sqrt(x)\) 进行试除

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 = p_1^{c_1} * p_2^{c_2} * ... *p_k^{c_k}\)
则其约数的形式就是 \(p1^{\beta_1} * p2^{\beta_2} * ... *pk^{\beta_k}\)
对于 N 有一下结论
约数总数: \((c_1 + 1) * (c_2 + 1) * ... * (c_k + 1)\) PS: int 范围内约数总数最多的数其约数总数是 1500 左右
约数之和: \((p_1^0 + p_1^1 + ... + p_1^{c_1}) * ... * (p_k^0 + p_k^1 + ... + p_k^{c_k})\) 该式展开就是 N 的所有的约数的累加

可以用 map 容器来存储<质因数,指数>对

求最大公约数 👉 欧几里得算法(辗转相除法)
思路:如果 d 是 a 和 b 的最大公约数,且 a>b,即 d 能量尽 a 和 b,那么我们可以认为 d 也能量尽 a - c * b 也即 a % b,所以 d = gcd(a,b) = gcd(a % b,b)

int gcd(int a, int b)
{
    return b ? gcd(b, a % b) : a;  // b如果不等于0 就递归调用公式,等于0则gcd(a, 0) = 0
}

欧拉函数

欧拉函数是 1~N 中与 N 互质的数的个数(互质是指两个数的最大公约数为 1)

给出以下结论:
如果\(N = p_1^{\alpha_1}*p_2^{\alpha_2}*...*p_k^{\alpha_k}\),则\(\phi(N) = N * (1 - \frac{1}{p_1}) * (1 - \frac{1}{p_2}) * ... * (1 - \frac{1}{p_k})\)

可以理解为
对 N 分解质因数后筛去与 N 不互质的数
img
依此类推
$\phi(N) = N - \frac{N}{p1} - \frac{N}{p_2} - ... - \frac{N}{p_k} + \frac{N}{p_1p2} + \frac{N}{p_1p_3} + ... + \frac{N}{p{i}p_{j}} - \frac{N}{p_1p2*p_3}- ... - \frac{N}{p{i}_p_{j}...*p_{k}} ... $
最后的结果就是上面的结论

用公式求
n 的欧拉函数 👉 时间复杂度为 O(\(\sqrt{n}\)),算法瓶颈在分解质因数那一步中

int phi(int x)
{
    int res = x;
    for (int i = 2; i <= x / i; i ++ )  //在分解质因数算法的基础上做
        if (x % i == 0)
        {
            res = res / i * (i - 1);  //根据公式计算欧拉函数
            while (x % i == 0) x /= i;
        }
    if (x > 1) res = res / x * (x - 1);

    return res;
}

用筛法求
1 ~ n 中每个数的欧拉函数 👉 时间复杂度为 O(n)

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]) // 如果是质数p,那么其欧拉函数的值就是p - 1
        {
            primes[cnt ++ ] = i;
            euler[i] = i - 1;
        }
        for (int j = 0; primes[j] <= n / i; j ++ ) // 从小到大枚举质数
        {
            int t = primes[j] * i;
            st[t] = true;
            if (i % primes[j] == 0)  // 当前枚举的质数是i的最小质因子时,那么t和i就有完全相同的质因子,只是质因子的次数不同,根据公式有这样的关系式,euler[t] = euler[i] * primes[j]
            {
                euler[t] = euler[i] * primes[j];
                break;
            }
            euler[t] = euler[i] * (primes[j] - 1);  // 如果枚举到的质数不是i的质因子,则t相较i就多了一种质因子,则有eluer[t] = euler[i] * primes[j] * (1 - 1 / primes[j]) = euler[i] * (primes[j] - 1)
        }
    }
}

欧拉定理:如果 a 和 n 互质,则有\(a^{\phi(n)} \equiv 1 (mod \ n),其中\phi就是欧拉函数\)
推论 👉 \(a^{p - 1} \equiv 1 (mod \ p)\) ,也即费马小定理

快速幂

快速地求 \(a^k \ mod \ p\)
已知如果\(a \ mod\ p = c_1 \ ,b \ mod\ p = c_2,\ 则 (a*b)\ mod\ p = (c_1*c_2)\ mod\ p\)
思路:反复平方 👉 预处理出 \(a\ mod\ p、a^2 \ mod\ p、a^4 \ mod\ p、a^8 \ mod\ p、...\)
\(a^k \ mod \ p = a^{2^{x_1}+2^{x_2}+2^{x_3}+...+2^{x_t}}\ mod\ p = [(a^{2^{x_1}}\ mod\ p)*(a^{2^{x_2}}\ mod\ p)*...*(a^{2^{x_t}}\ mod\ p)]\ mod\ p\),只需要将 k 转换成 2 进制数即可
时间复杂度为 \(O(log \ k)\)

求 m^k mod p,时间复杂度 O(logk)。

int qmi(int m, int k, int p)
{
    int res = 1 % p, t = m;
    while (k) // 当k不是0时
    {
        if (k&1) res = res * t % p; //取k的二进制的末位
        t = t * t % p; // 反复平方
        k >>= 1; //删掉k的末位
    }
    return res;
}

扩展欧几里得算法

已知裴蜀定理:对于任意一对正整数 a, b,一定存在非零整数 x, y,使得 ax + by = gcd(a, b) = 用 a 和 b 加整数系数能凑出来的最小正整数

因为 gcd(a, b)一定能整除 ax+by,则 ax+by 是 gcd(a, b)的倍数,同时扩展欧几里得算法给出了一种构造方法使得 ax+by 可以取到 gcd(a, b)。裴蜀定理得证

// 求x, y,使得ax + by = gcd(a, b)
int exgcd(int a, int b, int &x, int &y)
{
    if (!b)
    {
        x = 1; y = 0;  // 边界条件 1*a + 0*b = a = gcd(a,0)
        return a;
    }
    int d = exgcd(b, a % b, y, x);
    y -= (a/b) * x;
    return d;
    //递归函数中得到的系数满足 y * b + x * (a % b) = gcd(b,a%b)
    //即y * b + x * (a - a / b * b) = gcd(b, a%b) = gcd(a,b)
    //则展开合并得到 x * a + [y - (a/b) * x] * b = gcd(a,b)
}

扩展欧几里得只求出系数 x、y 的一组解,通解可以由得到的这组解推出
img

扩展欧几里得算法可以用于求解方程 👉 \(ax \equiv b\ (mod\ m) 等价于 ax + ym = b\) 用扩展欧几里得算法求解即可

类似地,如果上面问题中的 b 是 1,就是求 \(a\) 的逆元,如果 \(m\)是质数,可以直接应用费马小定理来求,否则可以用扩展欧几里得算法来求

中国剩余定理

求解如图所示的同余方程组
img
中国剩余定理指出:
定义\(M = m_1 * m_2 * m_3 * ... * m_n\)
\(M_i = \frac{M}{m_i}\)\(M_i^{-1}满足M_i*M_i^{-1} \equiv 1 (mod \ m_i)\)
则方程组的解可以构造为\(x = a_1*M_1*M_1^{-1} + a_2 * M_2 * M_2^{-1} + a_3 * M_3 * M_3^{-1} + ... + a_n * M_n * M_n^{-1}\)
该解显然满足方程组中的所有条件

高斯消元

高斯消元可以用来求解多元线性方程组
时间复杂度为 O(\(n^3\))
img
通过初等行列变换 将 n 个 n 元方程的方程组转化为上三角形式
img
恰好为完美的阶梯形 👉 唯一解
出现 0 = 非零 👉 无解
出现 0 = 0 的方程 👉 无穷多组解

算法步骤:
img
变成阶梯型后可以很容易地倒推出唯一解
img

// a[N][N]是增广矩阵
int gauss()
{
    int c, r;  // 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;  //如果该列的系数全为0,枚举当前行的下一列

        for (int i = c; i <= n; i ++ ) swap(a[t][i], a[r][i]);      // 将绝对值最大的行换到最顶端
        for (int i = n; i >= c; i -- ) a[r][i] /= a[r][c];      // 将当前行的首位变成1  注意最后改变a[r][c]的值为1
        for (int i = r + 1; i < n; i ++ )       // 用当前行将下面所有的列消成0
            if (fabs(a[i][c]) > eps)  // 如果系数不是0
                for (int j = n; j >= c; j -- )
                    a[i][j] -= a[r][j] * a[i][c];  // 同样最后改变a[i][c]的值

        r ++ ;
    }

    if (r < n)
    {
        for (int i = r; i < n; i ++ )
            if (fabs(a[i][n]) > eps)   // 如果 0 = 非零
                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; // 有唯一解
}

组合计数

\(a , b < 2000\)
递推法求组合数 👉 \(O(n^2)\)
\(C_a^b = C_{a - 1}^b + C_{a - 1}^{b - 1}\)

// 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;
        else c[i][j] = (c[i - 1][j] + c[i - 1][j - 1]) % mod;

\(a , b < 10^5\)
预处理阶乘求组合数 👉 \(O(n \log n)\)

首先预处理出所有阶乘取模的余数fact[N],以及所有阶乘取模的逆元infact[N]
如果取模的数是质数,可以用费马小定理求逆元
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;
}

\(a , b < 10^{18}\), 即 LL
利用 lucas 定理求阶乘 👉 \(O(\log_p n)\)
lucas 定理的证明了解即可
img

若p是质数,则对于任意整数 1 <= m <= n,有:
    C(n, m) = C(n % p, m % p) * C(n / p, m / p) (mod p)

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;
}

当答案要求不 mod 一个数,而是用高精度输出时
思路:对组合数的计算公式分解质因数来化简(就无需实现高精度除法了),用高精度乘法进行计算质因数的乘积

img
注意到 1~a 中的某个数是\(p^2\)的倍数,那么这个数一定是\(p\)的倍数,那么在计算 p 的次数时,用上面的公式恰好加 2 次。

当我们需要求出组合数的真实值,而非对某个数的余数时,分解质因数的方式比较好用:
    1. 筛法求出范围内的所有质数
    2. 通过 C(a, b) = a! / b! / (a - b)! 这个公式求出每个质因子的次数。 n! 中p的次数是 n / p + n / p^2 + n / p^3 + ...
    3. 用高精度乘法将所有质因子相乘

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;
        }
    }
}


int get(int n, int p)       // 求n!中的次数
{
    int res = 0;
    while (n)
    {
        res += n / 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);
}

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]);

卡特兰数
示例:火车进栈、合法括号序列、满足条件的 01 序列(给定 n 个 0 和 n 个 1,它们按照某种顺序排成长度为 2n 的序列,满足任意前缀中 0 的个数都不少于 1 的个数的序列的数量)
img
化简后得到结果为\(\frac{C_{2n}^{n}}{n + 1}\),即卡特兰数

容斥原理

公式:\([ \left| \bigcup_{i=1}^{n} A_i \right| = \sum_{i=1}^{n} |A_i| - \sum_{1 \leq i < j \leq n} |A_i \cap A_j| + \sum_{1 \leq i < j < k \leq n} |A_i \cap A_j \cap A_k| - \ldots + (-1)^{n+1} |A_1 \cap A_2 \cap \ldots \cap A_n| ]\)

该公式右边用到了共计$C_n^1 + C_n^2 + C_n^3 + \cdots + C_n^{n-1} + C_n^n = 2^n - C_n^0 = 2^n -1 $ 个集合

我们可以用 n 位二进制数来表示这些集合,其中第 i 位为 1 表示集合 i 被选中,第 i 位为 0 表示集合 i 不被选中。通过位运算 i >> j & 1 来判断 i 的第 j 位是不是 1

简单博弈论

Nim 游戏

给定 N 堆物品,第 i 堆物品有 Ai 个。两名玩家轮流行动,每次可以任选一堆,取走任意多个物品,可把一堆取光,但不能不取。取走最后一件物品者获胜。两人都采取最优策略,问先手是否必胜。
我们把这种游戏称为 NIM 博弈。把游戏过程中面临的状态称为局面。整局游戏第一个行动的称为先手,第二个行动的称为后手。若在某一局面下无论采取何种行动,都会输掉游戏,则称该局面必败。
所谓采取最优策略是指,若在某一局面下存在某种行动,使得行动后对面面临必败局面,则优先采取该行动。同时,这样的局面被称为必胜。我们讨论的博弈问题一般都只考虑理想情况,即两人均无失误,都采取最优策略行动时游戏的结果。
NIM 博弈不存在平局,只有先手必胜和先手必败两种情况。
先手必胜是指能够做某种操作,使得操作后的状态是先手必败的
先手必败是指无论做任何操作,操作后的状态都是先手必胜的,也即无法让对手是先手必败的
定理: NIM 博弈先手必胜,当且仅当 A1 ^ A2 ^ … ^ An != 0 其中 ^ 为异或运算符。

  • 一个简单的例子,如果有两个堆,A1 = 2,A2 = 3,那么先手必胜,后手必败。因为先手可以从 A2 中取一个石子,使两堆石子变成相同的状态,之后对手做什么操作,只需要在另一堆石子中做同样的操作即可。
  1. 最终的状态是 0 ^ 0 ^ 0 ^ 0 ^ ...... ^ 0 = 0,异或值为 0。
  2. 如果当前状态异或值为 \(x \ne 0\),若 \(x\) 的二进制表示中最高位的 1 在第 \(k\) 位,则一定存在 $A_i $ 的第 \(k\) 位为 1 , 我们从 \(A_i\) 堆中取出 \(A_i - (A_i ^ x)\) 个石子(高于 k 位不参与计算) , 就能使异或值变为 0 。
  3. 如果当前状态异或值为 0 ,无论如何操作异或值都会变为非 0 。

公平组合游戏 ICG

若一个游戏满足:

  1. 由两名玩家交替行动;
  2. 在游戏进程的任意时刻,可以执行的合法行动与轮到哪名玩家无关;
  3. 不能行动的玩家判负;
    则称该游戏为一个公平组合游戏。
    NIM 博弈属于公平组合游戏,但城建的棋类游戏,比如围棋,就不是公平组合游戏。因为围棋交战双方分别只能落黑子和白子,胜负判定也比较复杂,不满足条件 2 和条件 3。

有向图游戏

给定一个有向无环图,图中有一个唯一的起点,在起点上放有一枚棋子。两名玩家交替地把这枚棋子沿有向边进行移动,每次可以移动一步,无法移动者判负。该游戏被称为有向图游戏。
任何一个公平组合游戏都可以转化为有向图游戏。具体方法是,把每个局面看成图中的一个节点,并且从每个局面向沿着合法行动能够到达的下一个局面连有向边。

Mex 运算

设 S 表示一个非负整数集合。定义 mex(S)为求出不属于集合 S 的最小非负整数的运算,即:
mex(S) = min{x}, x 属于自然数,且 x 不属于 S

SG 函数

在有向图游戏中,对于每个节点 x,设从 x 出发共有 k 条有向边,分别到达节点 y1, y2, …, yk,定义 SG(x)为 x 的后继节点 y1, y2, …, yk 的 SG 函数值构成的集合再执行 mex(S)运算的结果,即:
SG(x) = mex({SG(y1), SG(y2), …, SG(yk)})
特别地,整个有向图游戏 G 的 SG 函数值被定义为有向图游戏起点 s 的 SG 函数值,即 SG(G) = SG(s)。
img
根据 Mex 操作的定义,如果当前 SG 函数的值 ≠0,则说明它的出边能到达 0 节点,如果当前 SG 函数的值为 0,则说明它所有出边都不能到达 0 节点。
终点没有出边,即不能做任何操作了,且定义终点的 SG 为 0。
先手拿到 SG≠0 局面的人,一定能保证交给后手 SG=0 的局面,而后手拿到 SG=0 局面,只能交给先手 SG≠0 的局面,故此时先手必胜。反之,如果先手拿到 SG=0 的局面,只能交给后手 SG≠0 的局面,故此时先手必败。

有向图游戏的和

设 G1, G2, …, Gm 是 m 个有向图游戏。定义有向图游戏 G,它的行动规则是任选某个有向图游戏 Gi,并在 Gi 上行动一步。G 被称为有向图游戏 G1, G2, …, Gm 的和。
有向图游戏的和的 SG 函数值等于它包含的各个子游戏 SG 函数值的异或和,即:
SG(G) = SG(G1) ^ SG(G2) ^ … ^ SG(Gm)

定理

有向图游戏的某个局面必胜,当且仅当该局面对应节点的 SG 函数值大于 0。
有向图游戏的某个局面必败,当且仅当该局面对应节点的 SG 函数值等于 0。

动态规划

动态规划没有算法模板(代码很简单

dp 问题的解题思路:
img
核心思想是定义状态和分情况讨论

dp 问题的优化一般是对动态规划的代码或者方程做等价变形,故一定要先将基本的 dp 方程写出来

背包问题

背包问题:给定一个容量为 V 的背包,和 n 个物品,每个物品有一个体积\(v_i\)和一个价值\(w_i\),求将这些物品装入背包中,使得背包中物品总体积小于背包容量,且价值最大。

  • 01 背包问题
    每件物品只能用 1 次,求最大价值
    img
    根据 \(f[i][j] = max(f[i - 1][j], f[i - 1][j - v[i]] + w[i])\) 通过改变枚举 i,j 的顺序,我们可以将 2 维数组转化为 1 维数组同时保证变化前后 dp 方程等价 👉 倒序枚举 j,这样得到 \(f[j] = max(f[j], f[j - v[i]] + w[i])\) 等式左右的 \(f[j]\) 并不相同 ,前者是考虑第 i 件物品的 \(f[i][j]\) 后者是 \(f[i-1][j]\), 倒序枚举 \(j\) 是为了保证此时 f[j - v[i]] 还没被更新仍然等价于 \(f[i - 1][j - v[i]]\),如果正序则是 \(f[i][j - v[i]]\) 方程就不等价了

  • 完全背包问题
    每件物品可以用无限次,求最大价值
    img
    优化 👉 三维变两维,不需要再枚举 k 了
    img
    进一步优化空间 👉 二维数组变一维数组 \(f[i][j] = max(f[i - 1][j], f[i][j - v[i]] + w[i])\) 正序枚举 j 则可以直接写成 \(f[j] = max(f[j], f[j - v[i]] + w[i])\)

  • 多重背包问题
    每个物品最多用\(s_i\)个,求最大价值
    img
    与完全背包不同
    img
    这里的 \(f[i][j - v]\)\(f[i][j]\) 多出了最后一项,如果这最后一项较大我们将无从得知前面部分的 max,因此行不通

    优化 👉 不从 0 枚举到 s ,而是采用倍增的思想,将其转化为枚举 \(1, 2, 4, 8, ..., 2^k, s - 2^k\) 个物品打包在一块时的 01 背包问题,显然对这些打包物品的选择可以遍历 0 ~ s 的所有情况。时间复杂度从 \(O(NVS) \rightarrow O(NVlog S)\)
    优化空间 👉 因为转化为了 01 背包问题,可以按照 01 背包的优化方式用一维数组来存
    注意这里的物品种类应该开成 NlogS , 因为对每种物品进行打包后最后会有 logS 种打包物品

  • 分组背包问题
    物品有 n 组,每组最多用 1 个,求最大价值
    集合划分为:从第 i 组中选第 k 个物品
    img
    同样可以从两维数组优化为一组数组,倒序枚举 j

线性 DP

依照明显的线性顺序进行 dp
e.g.
数字三角形 使得从顶层到底层路径上的数值和最大
img

最长上升子序列 求数值严格单调递增的子列的长度最长是多少 例如 218 的上升子列是 18、28
img
集合通过以 f[i]结尾的子列的倒数第二个数是序列的第几个数来划分

最长公共子序列 求两字符串的最长公共子序列的长度最长是多少 例如 acbd 和 abedc 的最长公共子序列为 abd,长度为 3
img
集合用子序列是否包含 \(a[i], b[j]\) 来进行划分
注意 只有子集合 00 和 11 与 \(f[i-1,j-1] f[i,j]\) 等价,而中间两个子集合与其表示并不等价,因为根据定义 \(f[i-1, j]\) 只能保证子序列一定不包含 \(a[i]\) 但不一定包含 \(b[j]\) 也就是说 \(f[i-1][j]\) 与我们划分的子集合 01 并不等价 而是包含我们划分的子集合 01 ,且 \(f[i-1][j]\) 实际上也包含了子集合 00。但因为是求最大值,只要保证不漏即可,因此 it works!

区间 DP

状态为一个区间的 dp 问题
该类问题枚举时一般按照区间长度从 1 到 n 进行枚举,这样才能保证 dp 方程右边都是已经计算过的
e.g.
石子合并 经典的合并问题,但要求只有相邻的石子才能合并
img
状态划分是按最后合并时区间的分界点 k 来进行划分,即最后是将 \([i,k] [k+1, j]\) 这两堆进行合并 $ k = i, i + 1, i + 2, ..., j - 1$

计数类 DP

数位统计 DP

关键在于分情况讨论

e.g.
计数问题 输出 0 ~ 9 中每个数出现的次数
img
可以用 count 函数来统计 1 ~ n 中 x 出现的次数,对于 x = 0, 1, 2, ..., 9 可以类似的思路来统计。而计算 [a, b]区间中 x 出现的次数与前缀和类似,直接作差
img
对于每个 x,采用分情况讨论,分别讨论 x 在某一位上出现的次数,类似于集合划分
img
注意如果 x 为 0 那么 x 在第 4 位出现 0 要求高位不能为全 0,否则前导 0 会被消去
img

状态压缩 DP

将状态表示中的整数看作二进制数,二进制数每一位是 0 是 1 表示不同的情况,即为状态压缩 DP

求切割方案数
img
思路:当横向方格放完之后,纵向方格就确定了。所以方案数就是摆放横向方格的方案数。用 \(f[i,j]\) 表示摆放第 i 列时,j 表示的若干行被占用了。img例如摆放第 2 列时,发现第 1 行、第 5 行被占用了,变成二进制数就是 10010,即对应 f[2, 18]。\(f[i,j]\) 可以由若干可行的 \(f[i - 1,k]\) 转移过来,且 \(f[i,j]\) 的值就是它们的和。可行状态应满足,j 与 k 不能冲突,且摆放完第 i 行后剩余的空格可以用纵向方格覆盖。
img

每条边有一定的距离,求最短 Hamilton 路径
img
img
集合依照路径中走过的倒数第二个点进行划分

树形 DP

给定一个树,每个节点有一个权重,要求选若干个点,使得这些点之间没有直接的父子节点,求权值的最大值
img
状态计算 f[u, 0] 代表不选 u,那么选不选其子节点就都可以 所以 f[u, 0] = 对所有 u 的子节点 j 的 max(f[j, 0] + f[j, 1]) 求和。f[u, 1] 代表选择了 u,那么一定不能选其子节点 f[u, 0] = 对所有 u 的子节点 j 的 f[j, 0] 求和。

根据状态转移方程,我们应该从叶子节点开始算起,一步一步增大子树,所以按 dfs 序进行计算

记忆化搜索

每道 dp 题都有集合划分的步骤,也就是说不考虑效率问题都可以写成递归形式,且递归形式更容易理解。
记忆化搜索用于解决递归形式的效率问题。

e.g.
滑雪问题 给定一个矩阵,代表高度,规定只能从高处向低处滑,可以向左向右向上向下滑,试求出滑雪轨迹长度的最大值(起点任意)
img
思路简单,但枚举顺序很难搞

用记忆化搜索实现 👉 同样定义数组来保存 \(f[i][j]\),使用 dp(i, j)来计算出 \(f[i][j]\) 并返回。
dp 函数形如:

int dp(int i, int j)
{
    int &v = f[i][j]; // 通过引用使得v等价于f[i][j]
    if(v != -1) return v; // 如果v不为-1,说明已经计算过,直接返回(f数组要初始化为-1)

    ....

    v = ..... dp(?, ?); // 递归地调用,计算子集合的值
    return v;
}

贪心

代码简单,算法正确性的证明很难

区间问题

区间问题的贪心问题,一般是先排序(左端点、右端点、双关键字...)之后尝试性分析几个例子,最后对算法进行严格地证明

e.g.
区间选点
img
贪心解法
img
假设当前的区间为 a,选择了它的右端点 A,则之后枚举的区间 b 要么左端点小于 A,则就会被包含,要么左端点大于 A,则两个区间不重叠,至少要选两个点。

区间分组
img
贪心解法
img
该解法保证当增加新组时,遍历到的区间的左端点与前面的所有组都有交集,也即前面的所有组两两之间也有交集,所以至少要有这些组才能将他们全部分开,故贪心解是最优解。

区间覆盖
img
贪心解法
img

Huffman 树

e.g.
合并果子
太经典了,略

Huffman 树可以用小根堆来实现

排序不等式

e.g.
排队打水
img
img
顺序和不小于乱序和,乱序和不小于逆序和

绝对值不等式

e.g.
货舱选址
img
img
\(\mid \shortmid a\shortmid -\shortmid b\shortmid \mid \leqslant \mid a\pm b\mid \leqslant \mid a\mid +\mid b\mid\)
奇数个时取中位数,偶数个取中间两个数之间的任何均可

推公式

e.g.
刷杂技的牛
img
如果最优解存在 wi+si 逆序,可以发现交换逆序后的解更优,与最优解假设矛盾,故按 wi+si 从小到大排即为最优解
img

时空复杂度分析

img

优雅写法

头文件这样写
img
或者

#include<bits/stdc++.h>

注意考虑数组大小,一般作为宏定义给出数组长度,且要比临界值稍大,数组也一般定义在 main 函数之外

const int N=5010;
int a[N];

对 a[3]数组排序

sort(a,a+3)

二维数组对四个方向进行遍历

int dx[4] = {1, 0, -1, 0}, dy[4] = {0, 1, 0, -1};
for(int i = 0; i < 4; i ++)
{
    int x = t1 + dx[i], y = t2 + dy[i];
    // .......
}

求多个数中的最大(小)值

max({a,b,c,d,......}) 或 min({a,b,c,d,......})

int 的范围-2,147,483,648~2,147,483,647,差不多 2e9,注意超出时要开 long long int

typedef long long LL

将数组置-1

memset(h, -1, sizeof h)

邻接表定义模板

int h[N], e[M], w[M], ne[M], idx;

邻接表中加边的模板

void add(int a, int b, int c)
{
    e[ibx] = b, w[idx] = c, ne[idx] = h[a], h[a] = idx ++ ;
}

邻接表表示的图 dfs 模板

int dfs(int u)
{
    int res = 1;
    st[u] = true;

    for(int i = h[u]; ~i; i = ne[i])
    {
        int j = e[i];
        if( st[j] ) continue;

        res += dfs(j);

    }

    return res;
}

字符串相关函数:参考博客C 语言常用字符串操作函数整理(详细全面)

字符串翻转

string s;
cin >> s;
reverse(s.begin(), s.end() );
cout << s << endl;

用小根堆实现三叉哈夫曼数

#include <iostream>
#include <cstring>
#include <algorithm>
#include <queue>

using namespace std;

typedef long long LL;

int main()
{
    int n;
    scanf("%d", &n);
    priority_queue<LL, vector<LL>, greater<LL>> heap; //这行代码创建了一个最小堆(min-heap)的优先队列,存储的元素类型为 LL,使用 std::vector<LL> 作为底层容器,而 std::greater<LL> 是一个函数对象,它定义了元素的比较规则,确保在队列中的元素按升序排列
    //定义一个最大堆  priority_queue<int> maxHeap;
    //std::vector 是 C++ 标准库提供的动态数组容器

    if (n % 2 == 0) heap.push(0);  //不足三叉补0
    while (n -- )
    {
        int x;
        scanf("%d", &x);
        heap.push(x);
    }

    LL res = 0;
    while (heap.size() > 1)
    {
        LL sum = 0;
        for (int i = 0; i < 3; i ++ )
        {
            sum += heap.top();
            heap.pop();
        }
        res += sum;
        heap.push(sum);
    }

    printf("%lld\n", res);
    return 0;
}

浮点数判断是否为整数

scanf("%d%d", &a, &b);
int t = pow((LL) a * b, 1.0 / 3) + 1e-8;  //对a * b开立方根,判断立方根是否为整数,加 1e-8来避免精度问题 e.g. 3.999999....
if (t * (LL)t * t == (LL)a * b)
    puts("Yes");
  • 当输入数据较多时(超过 10w),用 scanf 来读入

下面的循环会执行 n 次(当 n 后续没有用时)

while(n --)
{

}

大写字符转化为小写

for(auto &c : s) {  // 这是一个 for 循环,它使用范围-based for 循环(也称为 foreach 循环)来遍历字符串 s 中的每个字符。auto 关键字用于自动推断迭代变量 c 的类型。& 表示引用,这样循环中对字符的修改会影响到原始字符串
    c += 32;
}

pro:提升 cin 的读取速度
cons:不能再用 scanf()了

cin.tie(0);  //绑定空指针
ios::sync_with_stdio(false);  //让cin和标准输入输出不再同步

\(cin、cout(1394 ms) > cin、cout + sync = false(1182 ms) > cin、cout + sync = false + cin.tie(0)(144 ms) >scanf、printf(133 ms)\)
img

  • 10 亿内的最大质数是 999999937,且相邻质数之间的差值均不超过 290。

枚举字符串中元素

for(int i = 0; str[i]; i ++)
{

}

定义 PII 数据类型一对数据

typedef pair<int, int> PII;
//以区间为例
PII block;
//则这个pair的第一个数据为 block.first 第二个数据为 block.second
int l, r;
cin >> l >> r;
block = {l, r};

用 scanf 读字符

char op[2];
scanf("%s", op); //scanf读字符串的时候会自动忽略换行 空格之类, 但如果作为char来读就会读进去
//接下来对op[0]进行操作即可

将整型数组置为 0x3f3f3f3f

memset(h, 0x3f, sizeof h);  //因为memset是按字节来写的,每个int的四个字节都会被置为0x3f
//置0 则每个字节都是全0,4个字节的结果还是0   置-1 则每个字节都是全1,4个字节的结果还是-1

将单个字符转成字符串

string tmp;
tmp = string(1, 'a'); //其中1表示重复次数,'a'是要重复的字符


//首次定义
string tmp(5, 'A');
string tmp("Hello");
  • 如果用到 \(i - 1\) 那么我们使用的下标一般从 1 开始,如果下标不会用到 \(i - 1\) 则一般从 0 开始

C++运算符重载
函数类型 operator 重载运算符(形参表)
{
函数体;
}
例如重载小于号,使其可以比较结构体 Range 中的 r 成员

bool operatot < (const Range &W)  const  //const Range &W 表示常量引用, 后一个const表示函数不会修改当前对象的成员变量
{
    return r < W.r;  //当前对象的成员变量r是否小于参数对象W的成员r
}

算法思路

  • 破环成链,1 ~ n 的环转换为 1 ~ 2n 的链,这样环上任何连续的段都能在链上找到
  • 区间求和操作次数多,可以用前缀和简单处理
  • 计算所有可能(合法)的结果数,一般组合数(推公式)或者 DP
  • DP 明确状态的定义和属性,推出计算公式
    img
  • 根据数据规模反推算法复杂度,c++一般操作次数为\(10^7 \sim 10^8\)img
  • 转化要求的算式
    img
  • 对于数据范围较小的那类数据可以考虑枚举
  • 对于数据范围很大的问题考虑推公式(用数学将问题转化得很简单)或者二分
  • dfs 实际是搜索方式,通过在每层 dfs 时加条件可以用来搜索满足要求的所有方案(路径)
  • debug 方法: printf 或 cout 大法 、 注释代码法(re、segment fault)
  • 如果代码中数组出现越界,什么错误都可能出现(不一定只是 re 或 segment fault)
posted @ 2023-11-28 20:56  webliu6  阅读(116)  评论(0)    收藏  举报