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

文章目录

    • 基础算法(一)
      • 排序
        • 快排
          • 衍生题目:求第k个数
        • 归并
          • 衍生题目:逆序对的数量
      • 二分
        • 整数二分
        • 浮点数二分

基础算法(一)

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

排序

快排,归并的时间复杂度都是 O ( n l o g n ) O(nlogn) O(nlogn),可以这样想,他们的思想都是分治,而分治在代码实现上是通过递归去做的,他们的递归层数都是 l o g n logn logn 层,每一层的处理都是 n n n,所以复杂度是 O ( n l o g n ) O(nlogn) O(nlogn)

快排

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

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

  • 基本思路

    1. 选取一个基准值x

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

    2. ⭐️根据基准值,调整区间,使得左半边区间的值全都≤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
    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]
    另外,上面的代码是采用的do while 循环,如果改写成while循环,需要注意一个问题,比如改成如下

void quick_sort(int q[], int l, int r) {
	if (l >= r) return ;
	int x = q[l + r >> 1], i = l, j = r;
	while (i < j) {
		while (q[i] < x) i++;
		while (q[j] > x) j--;
		if (i < j) {
			swap(q[i], a[j])
			i++;
			j--;
		}
	}
	quick_sort(q, l, j);
	quick_sort(q, j + 1, r);
}

提交会发现WA,可以尝试用例 83, 67, 98, 90, 67, 128, 116, 133, 11, 60,第一次划分出来得到如下结果(其中基准值选取了67)
60, 11, 67, 90, 98, 128, 116, 133, 67, 83,划分结束后i = j = 3,而q[3] = 90。此时由于已经不满足i < j这个条件,所以会直接退出while循环,这会导致q[3] 这个位置没有判断其和基准值的关系,导致递归时的左侧区间[0, 3],并不满足所有的值 <= 基准值 这一条件,于是最终排序出来的结果就是 11 60 67 90 67 83 98 133 116 128,是错误的。
可以这样修改代码,对i == j时退出循环的情况额外加一下判断

void quick_sort(int a[], int l, int r) {
	if (l >= r) return ;
	int x = a[l + r >> 1], i = l, j = r;
	while (i < j) {
		while (a[i] < x) i++;
		while (a[j] > x) j--;
		if (i < j) {
			std::swap(a[i], a[j])
			i++;
			j--;
		}
	}
	// 对 i == j 的情况额外加一下判断
	while (a[j] > x) j--;
	while (a[i] < x) i++;
	quick_sort(a, l, j);
	quick_sort(a, j + 1, r);
}

而如果用do while循环进行操作,每次交换完2个位置后,是在下一次 while 循环时才会移动左右指针ij,而不是在本次循环就移动i, j指针,所以不会在两个指针相遇时漏判。如果用while的写法,可以这样写

void quick_sort(int a[], int l, int r) {
	if (l >= r) return ;
	int x = a[l + r >> 1], i = l - 1, j = r + 1;
	while (i < j) {
		while (a[++i] < x) ;
		while (a[--j] > x) ;
		if (i < j) std::swap(a[i], a[j]);
	}
	quick_sort(a, l, j);
	quick_sort(a, j + 1, r);
}

其实和do while 是差不多的,只要记住,在循环中,要先移动左右两个指针,然后再进行判断

  • 算法模板

    // 任选一种模板进行记忆即可, 下面采用: 基准值取中间位置, 递归时使用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个数
借用快排的思路,写出快速选择算法(找分界点然后切分数组,缩小查找范围),时间复杂度 O ( n ) O(n) O(n)
时间复杂度可以这样想:第一层时,需要处理n次,然后会期望将数组切分成左右两半边,然后会选择其中一半区间,我们每次切分都期望将区间缩小为一半,所以第二层,需要处理n/2次,同理,第三层需要处理n/4。所以总的运算次数就是:
n + n/2 + n/4 + .... 这个结果是 <= 2n,所以总的时间复杂度就是 O ( n ) O(n) O(n)

#include
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. ⭐️将左右两部分区间合并成一个区间(将2个有序数组,合并成1个有序数组,使用双指针即可)

  • 算法题目

    Acwing - 787 归并排序

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

关于为什么能用归并排序来求逆序对。
首先,根据逆序对的定义,我们容易想到,什么时候不存在逆序对呢?显然,当整个数组有序时,逆序对的数量为0。什么时候会出现逆序对呢?只有两个数的位置关系,和这两个数的数值的大小关系,不一致时,才出现逆序对,而排序算法中,总是会调整逆序对的相对位置,来使得整个数组有序,所以排序算法可以在排序的过程中,统计出逆序对的数量。比如冒泡排序中,交换的总次数其实就是整个数组中逆序对的个数,因为只有每出现一个逆序对的时候才需要进行一次交换。但是冒泡排序的复杂度比较高,而我们恰好发现,归并排序也可以统计出逆序对的数量。
归并排序的过程中,总是会将当前区间分为左右两半,我们可以将整个区间中的逆序对分为3种情况:

  • 逆序对的2个数都出现在左半区间
  • 逆序对的2个数都出现在右半区间
  • 逆序对的2个数一个出现在左半区间,一个出现在右半区间

在归并排序中,递归的边界是当前区间长度为1,此时区间内的逆序对的数量明显是0。在递归的倒数第二层,我们是合并两个长度为1的区间,那么这个区间内的逆序对,一定全部都是第三种情况,即逆序对的2个数跨了左右两个区间,只需要统计这种情况的逆序对个数即可,而统计跨左右区间的逆序对时,左右区间内部是否排序,不影响逆序对的个数,而统计出的结果就是当前区间内的逆序对的个数,于是在倒数第三层递归时,左右两半区间的逆序对的个数都已经求解完毕,也只需要统计第三种逆序对的个数即可,于是,每层归并,都能统计出当前区间内的逆序对个数。

二分

整数二分
  • 算法模板

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

你可能感兴趣的:(算法,Acwing算法基础课,算法)