Acwing - 算法基础课 - 笔记(一)

2,972 阅读5分钟

基础算法(一)

本节讲解的是排序和二分,排序讲解了快排归并,二分讲解了整数二分浮点数二分

排序

快排

quick_sort(int q[], int l, int r)

q是待排序数组,l是待排序区间的左边界,r是右边界

  • 基本思路

    1. 选取一个基准值x

      可以取左边界的值q[l],或右边界的值q[r],或者中间位置的值q[l + r >> 1]

    2. :star:根据基准值,调整区间,使得左半边区间的值全都≤x,右半边区间的值全都≥x

      采用双指针,左指针i从左边界l开始,往右扫描,右指针j从右边界r开始,往左扫描。

      当满足条件q[i] < x时,i右移;直到不满足条件时,i停下;开始移动j

      当满足条件q[j] > x时,j左移;直到不满足条件时,j停下;交换q[i]q[j]

      i右移一位,j左移一位。

      重复上面的操作,直到ij相遇。此时左半区间的数都满足≤x,且左半区间的最后一个数的下标为j,右半区间的数都满足≥x,且右半区间的第一个数的下标为iij之间的关系为:i = j + 1i = j

    3. 对左右两边的区间,做递归操作

      递归操作[l, j][j + 1, r]区间,或者[l, i - 1][i, r]区间即可

  • 算法题目

    Acwing - 785 快速排序

    #include<iostream>
    using namespace std;
    const int N = 100010;
    int n;
    int q[N];
    
    void quick_sort(int q[], int l, int r) {
        if(l >= r) return; // 递归退出条件, 不要写成 l == r, 可能会出现 l > r 的情况,可以尝试用例[1,2]
        int x = q[l + r >> 1], i = l - 1, j = r + 1; // 这里 i 置为 l - 1, j 置为 r + 1, 是因为下面更新i, j 时采用了do while循环,
        while(i < j) { // 这里不要写成 i <= j
            do i++; while(q[i] < x);// 不能写为 <= x , 那样写 i 可能会越界, 考虑 [1,1,1]。 
            // 因为基准值x是数组中的一个数,i从左往右移动的过程中,一定会遇到这个数x,此时不满足小于条件, i 一定会停下,也就变相保证了 i 不会越界。
            do j--; while(q[j] > x);// 不能写为 >= x , 因为 j 可能会越界, 原因同上
            if(i < j) swap(q[i], q[j]); // 若 i 和 j 还未相遇, 则交换2个数
        }
        quick_sort(q, l, j);
        quick_sort(q, j + 1, r);
    }
    
    int main() {
        scanf("%d", &n);
        for(int i = 0; i < n; i++) scanf("%d", &q[i]);
        
        quick_sort(q, 0, n - 1);
        for(int i = 0; i < n; i++) printf("%d ", q[i]);
    }
    
  • 注意要点

    当区间划分结束后,左指针i和右指针j的相对位置,只有2种情况

    • i = j + 1
    • i = j(此时ij指向的元素,恰好等于基准值x

    若用j来作为区间的分界,则[l, j] 都是≤x[j + 1, r]都是≥x

    若用i来作为区间的分界,则[l, i - 1]都是≤x[i, r]都是≥x

    当取i作为分界的话,基准值x不能取到左边界q[l],否则会出现死循环,比如用例[1,2]。此时基准值可以取q[r],或者q[l + r + 1 >> 1],注意取中间位置的数时,要加个1,避免l + r >> 1的结果为l

    当取j作为分界的话,基准值x不能取到右边界q[r],否则会出现死循环。此时基准值可以取q[l],或者q[l + r >> 1]

  • 算法模板

    // 任选一种模板进行记忆即可, 下面采用: 基准值取中间位置, 递归时使用j作分界
    void quick_sort(int q[], int l, int r) {
        if(l >= r) return;
        int x = q[l + r >> 1], i = l - 1, j = 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);
    }
    
  • 思想总结

    1. 选一个值x
    2. 以该值为分界点,将数组分为左右两部分,左边的全都≤x,右边的全都≥x
    3. 对左右两部分进行递归操作
衍生题目:求第k个数

练习题:Acwing - 786 第k个数

#include<iostream>
using namespace std;

const int N = 1e5 +10;
int n, k;
int q[N];

// 选取[l, r]区间内数组q第k小的数
int quick_select(int q[], int l, int r, int k) {
    if(l == r) return q[l]; // 找到答案
    int x = q[l + r >> 1], i = l - 1, j = r + 1;
    while(i < j) {
        while(q[++i] < x);
        while(q[--j] > x);
        if(i < j) swap(q[i], q[j]);
    }
    int left = j - l + 1;
    if(k <= left) return quick_select(q, l, j, k);
    else return quick_select(q, j + 1, r, k - left);
}

int main() {
    scanf("%d%d", &n, &k);
    for(int i = 0; i < n; i++) scanf("%d", &q[i]);
    
    printf("%d", quick_select(q, 0, n - 1, k));
    return 0;
}

归并

merge_sort(int q[], int l, int r)

  • 基本思路

    1. 确定分界点,一般是中间位置

    2. 从分界点将数组切成两半,对左右两部分做递归排序

    3. :star:将左右两部分区间合并成一个区间(将2个有序数组,合并成1个有序数组,使用双指针即可)

  • 算法题目

    Acwing - 787 归并排序

    #include<iostream>
    using namespace std;
    const int N = 1e6 + 10;
    int n;
    int q[N], tmp[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 i = l, j = mid + 1, k = 0;
        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];
    }
    
    int main() {
        scanf("%d", &n);
        for(int i = 0; i < n; i++) scanf("%d", &q[i]);
        merge_sort(q, 0, n - 1);
        for(int i = 0; i < n; i++) printf("%d ", q[i]);
    }
    
衍生题目:逆序对的数量

练习题:Acwing - 788: 逆序对的数量

#include<iostream>
using namespace std;

typedef long long LL;
const int N = 1e5 + 10;
int n;
int q[N], tmp[N];

// 返回区间[l, r]中的逆序对的数量
LL merge_sort(int q[], int l, int r) {
    if(l >= r) return 0;
    int mid = l + r >> 1;
    LL cnt = merge_sort(q, l, mid) + merge_sort(q, mid + 1, r); // 计算左右区间内各自的逆序对数量
    // 合并时, 计算左右两个区间中的数组成的逆序对
    int i = l, j = mid + 1, k = 0;
    while(i <= mid && j <= r) {
        if(q[i] <= q[j]) tmp[k++] = q[i++];
        else {
            cnt += mid - i + 1;
            tmp[k++] = q[j++];
        }
    }
    while(i <= mid) tmp[k++] = q[i++];
    while(j <= r) tmp[k++] = q[j++];
    for(int i = l, k = 0; i <= r; i++, k++) q[i] = tmp[k];
    return cnt;
}

int main() {
    scanf("%d", &n);
    for(int i = 0; i < n; i++) scanf("%d", &q[i]);
    LL cnt = merge_sort(q, 0, n - 1);
    printf("%lld", cnt);
    return 0;
}

二分

整数二分

  • 算法模板

    int binary_search_1(int l, int r) {
        while(l < r) {
            int mid = l + r >> 1;
            if(check(mid)) r = mid;
            else l = mid + 1;
        }
        return l;
    }
    
    int binary_search_2(int l, int r) {
        while(l < r) {
            int mid = l + r + 1 >> 1;  // 当下面是 l = mid 这样来更新的话,这里计算mid时要多加1,否则会出现边界问题
            if(check(mid)) l = mid;
            else r = mid - 1;
        }
        return l;
    }
    
  • 二分的本质

    注意:二分的本质不是单调性。单调性可以理解为函数单调性,如一个数组是升序排列或降序排列,此时可以用二分来查找某一个数的位置。

    有单调性一定可以二分,但没有单调性,也有可能能够二分。

    二分的本质是边界。假设给定一个区间,如果能够根据某个条件,将区间划分为左右两部分,使得左半边满足这个条件,右半边不满足这个条件(或者反之)。此时就可以用二分来查找左右两部分的边界点。

    注意左右两半部分的边界不是同一个点,而是相邻的2个点,因为是整数二分(离散的)。上面的2个算法模板,就分别对应了求左半部分的边界(上图红色区域最右边的点),和求右半部分的边界(上图绿色区域最左边的点)

    比如,我们要找上图中左边红色部分的边界点,我们取mid = l + r >> 1,判断一下q[mid]是否满足条件x,若满足,说明mid位置在红色区域内,我们的答案在mid右侧(可以取到mid),即[mid, r],则此时更新l = mid;若q[mid]不满足条件x,则说明mid位置在右边的绿色区域,我们的答案在mid左侧(不能取到mid),即[l, mid - 1],此时更新r = mid - 1

    注意,当采用l = midr = mid - 1这种更新方式时,计算mid时,要加上1(向上取整),即mid = l + r + 1 >> 1。否则,在l = r - 1时,计算mid时若不加1,则mid = l + r >> 1 = l,这样更新l = mid,就是l = l,会导致死循环。所以要向上取整,采用mid = l + r + 1 >> 1

    同理,当采用r = midl = mid + 1这种更新方式时,计算mid时不能加1,在l = r - 1时,若计算mid时加1,则mid = l + r + 1 >> 1 = r,这样更新r = mid。就是r = r,会导致死循环。

    简单的记忆就是,仅当采用l = mid这种更新方式时,计算mid时需要加1。

练习题:Acwing-789:数的范围

#include<iostream>
using namespace std;
const int N = 100010;
int arr[N];
int n,q;

int main() {
    scanf("%d%d", &n, &q);
    for(int i = 0; i < n; i++) scanf("%d", &arr[i]);
    
    while(q--) {
        int k;
        scanf("%d", &k);
        
        int l = 0, r = n - 1;
        while(l < r) {
            int mid = l + r >> 1;
            if(arr[mid] >= k) r = mid;
            else l = mid + 1;
        }
        if(arr[l] != k) printf("-1 -1\n");
        else {
            printf("%d ", l);
            l = 0, r = n - 1;
            while(l < r) {
                int mid = l + r + 1 >> 1;
                if(arr[mid] <= k) l = mid;
                else r = mid - 1;
            }
            printf("%d\n", l);
        }
    }
}

浮点数二分

相比整数二分浮点数二分无需考虑边界问题,比较简单。

当二分的区间足够小时,可以认为已经找到了答案,如当r - l < 1e-6 ,停止二分。

或者直接迭代一定的次数,比如循环100次后停止二分。

练习题:Acwing-790:数的三次方根

#include<iostream>
using namespace std;
int main() {
    double n;
    scanf("%lf", &n);
    double l = -10000, r = 10000;
    
    while(r - l > 1e-8) {
        double mid = (l + r) / 2;
        if(mid * mid * mid >= n) r = mid;
        else l = mid;
    }
    printf("%.6f", l);
}