AcWing 算法基础课笔记 1.基础算法


排序

快速排序

基本思想

基于分治。
第一步 确定分界点x:取左边界q[l],或者取中间值q[(l+r)/2],或者取右边界q[r],也可以随机。
第二步 调整区间(较难部分):让小于等于x的数在一个区间,大于x的在另一个区间
在这里插入图片描述
第三步 递归处理左右两端

平均时间复杂度: O(nlogn)
每层期望是 n/2 ,递归深度 logn,故平均时间复杂度 O(nlogn)

思路讲解

思路1(暴力解法,需要额外空间放a b):
在这里插入图片描述
思路2:(较优美的解法):
使用双指针,从数组两端向中间靠拢。指针 i 从左端找大于等于 x 的数,指针 j 从右端找小于等于 x 的数,然后swap二者,直至 i 和 j 相遇。
在这里插入图片描述

快排模板

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

递归 quick_sort(q, l, j), quick_sort(q, j + 1, r); 中 j 也可以换成用 i 的写法,但是要注意 x 的取值边界问题。

归并排序

基本思想

基于分治,以中间为分界。
第一步 确定分界点 mid = ( l + r ) / 2
第二步 递归排序 left,right。
第三步 归并——合二为一。(较难步骤)

时间复杂度: O(nlogn)
在这里插入图片描述

思路

left 和 right 一一比较,将较小的数放进归并数组 res 中,当一个数组走到头后,将另一个数组的剩下部分直接贴到 res 的后面。
在这里插入图片描述
Example:
在这里插入图片描述
排序算法的稳定性是指:对于原数组中相同的数,若排序后这些相同数的顺序不发生改变,则该算法是稳定的。

快排是不稳定的,归并是稳定的。

归并模板

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

二分

整数二分

基本思想

有单调性一定可以二分,但是可以二分的题目不一定非要有单调性。
找到一个边界将区间划分为两部分,使得一部分满足,另一部分不满足。
在这里插入图片描述
这里的两个边界点就对应两个模板写法。

第一种情况:红色边界点
check (mid) 判断 mid 是否满足红颜色的性质。注意 mid = ( l + r + 1) / 2 以及更新区间时的 mid 和 mid-1。
在这里插入图片描述

第二种情况:绿色边界点
check (mid) 判断 mid 是否满足绿颜色的性质。注意更新区间时的 mid 和 mid+1。
在这里插入图片描述

注意点

如果是 l = mid ,就要在 mid 中补上 +1。
如果是 r = mid ,就不用补 +1。
补上+1的原因在于:
如果不补上的话,当 l = r - 1,由于C++是下取整的,所以mid = 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;
}
// 区间[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;
}

浮点数二分

基本思想

double 可以直接除而不会取整,所以不用在意边界问题,较为简单。
判断条件一般为 r - l >= 1e-6.
次数一般取 保留小数点位数+2,例如保留5位小数,就是1e-7.

也可以不用判断,直接 for 循环100次,相当于除以 2 的100次方,得到的位数足够。

浮点数二分模板

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

double bsearch_3(double l, double r)
{
    
    
    const double eps = 1e-6;   // eps 表示精度,取决于题目对精度的要求
    while (r - l > eps)
    {
    
    
        double mid = (l + r) / 2;
        if (check(mid)) r = mid;
        else l = mid;
    }
    return l;
}

高精度

一般分四种:(大整数指位数(length) ⩽ \leqslant 106。小整数指数值 ⩽ \leqslant 109, 这里一般考虑 ⩽ \leqslant 10000)
两个大整数相加:A+B
两个大整数相减:A-B
一个大整数乘以一个小整数:A*a
一个大整数除以一个小整数:A/a
(两个大整数相乘相除太复杂不常考,这里不考虑,浮点数也不讲,同样用的少。)
在这里插入图片描述

前置知识:大整数的存储

对于一个大整数,通常用数组来存,这里从低位开始存较好。
原因是:因为整数相加要进位,当最高位要进位的时候,我们在数组的末尾使用 push_back() 加一位即可,较方便。反之,在头部加一位要将整个数组后移,较麻烦。
在这里插入图片描述

两个大整数相加

对于存在数组中的两个大整数:A[ ]、B[ ],相加时如下,这里 t 存储进位1:
在这里插入图片描述

高精度加法模板

// 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];
        if (i < B.size()) t += B[i];
        C.push_back(t % 10);
        t /= 10;
    }

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

两个大整数相减

对于存在数组中的两个大正整数:A[ ]、B[ ],相减时如下,这里 t 存储借位1:
在这里插入图片描述
这里用大数减小数,若A < B,则计算 -(B-A)。因为A和B存在数组里,不能直接比大小,所以需要写一个 cmp 函数进行比较,判断用谁减谁。

注意点

对于模板中的 C.push_back((t + 10) % 10); 这里(t + 10) % 10 是将以下两种情况合并起来了。
当 t ⩾ \geqslant 0,说明不需要借位,(t + 10) % 10 得到 t 本身。
当 t < 0,说明需要借位,(t + 10) % 10 得到 t + 10。
在这里插入图片描述
此外,对于类似 123-120 的计算,得到的数组 C 存储的是 003,为了正确输出,我们要将 C 中的高位 0 给pop_back()掉。即:
while (C.size() > 1 && C.back() == 0) C.pop_back();

高精度减法模板

// C = A - B, 满足A >= B, A >= 0, B >= 0
vector<int> sub(vector<int> &A, vector<int> &B)
{
    
    
    vector<int> C;
    for (int i = 0, t = 0; i < A.size(); i ++ )
    {
    
    
        t = A[i] - t;
        if (i < B.size()) t -= B[i];
        C.push_back((t + 10) % 10);
        if (t < 0) t = 1;
        else t = 0;
    }

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

高精度乘低精度

对于 A[ ] * b,个位为 (A0 * b % 10),进位为 t = (A0 * b) / 10向下取整。
对于下一位,为 ( A1 * b + t ) % 10,进位就为 t = ( A1 * b + t ) / 10 向下取整。
以此类推。
这里 b 是直接和 A 里每一位乘,而不是 b 的每一位和 A 的每一位乘。
在这里插入图片描述
举例如下:
在这里插入图片描述
这里乘法使用 while (C.size() > 1 && C.back() == 0) C.pop_back(); 也需要消零。
因为若 11111*0 得到的输出会是 00000,要将高位 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 /= 10;
    }

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

    return C;
}

高精度除以低精度

这里 A[ ] / b,用 r 保存每一位 Ax 除以b之后的余数,商为 Cx。
例如,当 A2 除以 b 的时候,用 A3 除以 b 的余数 r * 10 + A2,得到新的 r’ ,用这个 r’ 除以 b 得到 C2。
最后得到的余数是需要输出的。
在这里插入图片描述
因为先 push_back() 的是答案的最高位,所以最后要 reverse ,将结果调转顺序再返回。
同样,这里也需要去除前导 0 。

高精度除以低精度模板

// A / b = C ... r, A >= 0, b > 0
vector<int> div(vector<int> &A, int b, int &r)
{
    
    
    vector<int> C;
    r = 0;
    for (int i = A.size() - 1; i >= 0; i -- )
    {
    
    
        r = r * 10 + A[i];
        C.push_back(r / b);
        r %= b;
    }
    reverse(C.begin(), C.end());
    while (C.size() > 1 && C.back() == 0) C.pop_back();
    return C;
}

前缀和与差分

前缀和

基本原理

对于原数组 a1、a2、a3、…、an
前缀和数组 Si = a1 + a2 + a3 + … + ai
定义 S0 = 0

第一个问题:如何求 Si
使用一个 for 循环,递归 S[i] = S[i-1] + ai
第二个问题:前缀和有什么作用?
在计算区间 [ l , r ] 内数的和,若直接使用 for 循环,时间复杂度为 O(n)
但是若使用前缀和,可直接计算 Sr - Sl-1

除了一维数组的计算,也可以推导至二维,计算二维数组中 [ aij, alr ] 的和。
二维前缀和数组一般是从 1 开始而不是 0 。
在这里插入图片描述

前缀和模板

一维前缀和:

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

二位前缀和:

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]

例题

在这里插入图片描述

#include <iostream>
using namespace std;

const int N = 100010;
int n, m;
int a[N],s[N];

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++) s[i] = s[i-1] + a[i];
    
    while(m--){
    
    
        int l, r;
        cin>>l>>r;
        cout<<s[r] - s[l - 1]<<endl;
    }
    
    return 0;
}

在这里插入图片描述

#include <iostream>
using namespace std;

const int N = 1010;
int n, m, q;
int s[N][N];

int main(){
    
    
    scanf("%d%d%d", &n, &m, &q);
    for(int i = 1; i <= n; i++){
    
    
        for(int j = 1; j <= m; j++){
    
    
            scanf("%d", &s[i][j]);
        }
    }
    
    for(int i = 1; i <= n; i++){
    
    
        for(int j = 1; j <= m; j++){
    
    
            s[i][j] += s[i-1][j] + s[i][j-1] - s[i-1][j-1];
        }
    }
    
    while(q--){
    
    
        int x1, y1, x2, y2;
        scanf("%d%d%d%d", &x1, &y1, &x2, &y2);
        printf("%d\n", s[x2][y2] - s[x1-1][y2] - s[x2][y1-1] + s[x1-1][y1-1]);
    }
    
    return 0;
}

差分

基本思想

差分是前缀和的逆运算
即根据给出的前缀和求原数组的值
这里根据 A[ ] 数组,构造 B[ ] 数组,使得 A 是 B 的前缀和
在这里插入图片描述
差分有什么作用?
对于将数组 A[ ] 的 [ l , r ] 区间内所有数都 + c,操作 A 数组时,需要遍历一遍,时间复杂度为 O(n)。而操作 B 数组时,只需要将 bl + c ,就可以使的从 a[ l ] 到 a[ n ]的所有数都加 c,再使用 br+1 - c,消除对 a[ r ]之后数的影响,就可以将时间复杂度降至 O(1)。
在这里插入图片描述

对于二维数组的二维差分
在这里插入图片描述

差分模板

一维差分

给区间[l, r]中的每个数加上c:B[l] += c, B[r + 1] -= c

二维差分

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

例题

在这里插入图片描述

#include <iostream>
using namespace std;

const int N = 100010;
int n, m;
int a[N], b[N];

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]);
        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];
        printf("%d ", b[i]);
    }
    
    return 0;
}

在这里插入图片描述

#include <iostream>
using namespace std;

const int N = 1010;
int n, m, q;
int a[N][N], b[N][N];

void insert(int x1, int y1, int x2, int y2, int c){
    
    
    b[x1][y1] += c;
    b[x2+1][y1] -= c;
    b[x1][y2+1] -=c;
    b[x2+1][y2+1] += c;
}

int main(){
    
    
    scanf("%d%d%d", &n, &m, &q);
    for(int i = 1; i <= n; i++)
        for(int j = 1; j <= m; j++){
    
    
            scanf("%d", &a[i][j]);
            insert(i, j, i, j, a[i][j]);
        }
    
    while(q--){
    
    
        int x1, y1, x2, y2, c;
        scanf("%d%d%d%d%d", &x1, &y1, &x2, &y2, &c);
        insert(x1, y1, x2, y2, c);
    }
    
    for(int i = 1; i <= n; i++){
    
    
       for(int j = 1; j <= m; j++){
    
    
             b[i][j] += b[i-1][j] + b[i][j-1] - b[i-1][j-1];
             printf("%d ", b[i][j]);
        }
        puts("");
    }
    
    return 0;    
}

双指针算法

核心思想

对于以往需要双重 for 循环暴力解的题目进行优化
在快排和归并排序中都用到了双指针的思想。
在这里插入图片描述
所以一般做题都是先用暴力解法想思路,再用双指针优化时间复杂度。

双指针算法模板

for (int i = 0, j = 0; i < n; i ++ )
{
    
    
    while (j < i && check(i, j)) j ++ ;

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

位运算

核心思想

讲最常用的两种操作

  1. n 的二进制表示中第 k 位是几?
    第一步 先把第 k 位移到最后一位 n >> k
    第二步 看个位是几 x & 1
    结合一、二步,可得 n >> k & 1

  2. lowbit ( x ):从低位开始返回直至 x 的最后一位 1
    Example:

x lowbit ( x )
1010 10
1010000 10000

lowbit 的实现原理 lowbit(n) = n & -n
因为 -n 是 n 的负数,即为 n 的补码加 1 :-n = ~n + 1
n & -n = n & (~n + 1)

Example:
在这里插入图片描述lowbit() 最基本的作用:可以求二进制中 1 的个数

位运算模板

求n的第k位数字: n >> k & 1
返回n的最后一位1lowbit(n) = n & -n

离散化

这里指整数的离散化,且是保序的离散化
将数组 a[ ] 中的数映射到 0 1 2 3 4 …

  1. a[ ] 中可能存在重复元素:去重

  2. 如何算出某一个数 x 离散化后的映射值 :二分
    在这里插入图片描述

离散化模板

vector<int> alls; // 存储所有待离散化的值
sort(alls.begin(), alls.end()); // 将所有值排序
alls.erase(unique(alls.begin(), alls.end()), alls.end());   // 去掉重复元素

// 二分求出x对应的离散化的值
int find(int x) // 找到第一个大于等于x的位置
{
    
    
    int l = 0, r = alls.size() - 1;
    while (l < r)
    {
    
    
        int mid = l + r >> 1;
        if (alls[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return r + 1; // 映射到1, 2, ...n
}

区间合并

顾名思义,将两个区间有交集的进行合并,且使用较快的实现方式。
两个区间只有端点相同时,也可以合并。
在这里插入图片描述
第一步 按区间左端点排序
第二步 分析三种情况
在这里插入图片描述

区间合并模板

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

    sort(segs.begin(), segs.end());

    int st = -2e9, ed = -2e9;
    for (auto seg : segs)
        if (ed < seg.first)
        {
    
    
            if (st != -2e9) res.push_back({
    
    st, ed});
            st = seg.first, ed = seg.second;
        }
        else ed = max(ed, seg.second);

    if (st != -2e9) res.push_back({
    
    st, ed});

    segs = res;
}


有些较复杂的懒得写特别细,建议AcWing学一下y总的课效果更好。
以上模板、截图均来源:AcWing 的算法基础课
链接:https://www.acwing.com/blog/content/277/

猜你喜欢

转载自blog.csdn.net/qq_45438600/article/details/116795979