AcWing 算法基础课第一节基础算法1排序、二分

1、该系列为ACWing中算法基础课,已购买正版,课程作者为yxc
2、y总培训真的是业界良心,大家有时间可以报一下
3、为啥写在这儿,问就是oneNote的内存不够了QAQ


本节内容:排序(快排、归并排序)、二分(整数二分、浮点数二分)

一、排序

排序算法中稳定:如果说原序列中两个值是相同的,排完序后两个值的位置不发生变化则是稳定的,可能发生变化就是不稳定的。
推荐阅读:十大排序算法(力扣)十大排序算法总结(ACWing)
在这里插入图片描述

1.1 快排

  • 快排基于分治的思想,是不稳定的算法,但是如果将快排的 a i a_{i} ai变为 < a i , i > <a_{i},i> <ai,i>的二元组(相同的数变为不同的数),就是稳定的算法;
  • 时间复杂度为O(NlogN),最坏N^2
  • 快排的步骤:
      1. 确定分界点:
      • 确定分界点的常用方式:(1) 直接取左边界q[l];(2) 取两端的中间点q[(l+r)/2],(3) 取右边界q[r];(4) 取随机点;
        请添加图片描述
      1. 调整区间:(假设确定x为分界点)满足小于等于x的在左边,大于等于x的在右边;
      1. 递归:递归的处理左右两段区间;

=============================================================

  • 调整区间的简单实现方法1(暴力做法但时间复杂度是线性的):

    • 1、先开两个额外的数组 a[],b[]
    • 2、扫描整个区间 q[l-r],小于等于x插到a,大于x插到b;
    • 3、将数组 a[],b[]的数据放入q[]中;
  • 调整区间的实现方法2(双指针):

    • 1、用两个指针i,j,从两边同时向中间走;
    • 2、先开始移动ii指向的数字小于x,则i后移一位;直到如果数字大于等于x(应该放在右边),则停止移动i,开始持续移动j
    • 3、当j指向的数大于x,持续移动j;当j指向的数小于xi,j的数字错位(此时i指向的数需要放到右边,j指向的数需要放到左边),交换i,j指向的两个数字,并继续移动i,j,直到两个指针相遇;(注意,这里不需要交换的两个数字在新的区间里是有顺序的,它的顺序会在下一次递归里进行排序)

快排代码实现:

在C++输入大量的数字时建议用scanf,而不是cin;同理,JAVA中不用sanner,而是BufferedReader

#include <iostream>

using namespace std;

const int N = 100010;

int q[N];

void quick_sort(int q[], int l, int r)
{
    if (l >= r) return;// 判断边界:没有数或只有1个数就return

    int i = l - 1, j = r + 1, x = q[l + r >> 1];// 两侧和中间的指针
    while (i < j)
    {
        do i ++ ; while (q[i] < x); // 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);
}

int main()
{
    int n;
    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]);
    
    return 0;
}
  • 注意循环while (q[i] < x)不能写为q[i]<=x ,否则会一直满足<=关键值得这个条件,就会发生数组越界(如49,59,88, 37,98,97,68,54,31,3,其中x=98)

  • 两个指针交换完之后都要移动一格,因此设计ij都在边界外,不管三七二十一先移动左右指针,再判断;

  • x可以取q[l]q[(l+r)/2]q[r]

  • 如果在quick_sort(q, l, j); quick_sort(q, j + 1, r);时取x = r ;或在quick_sort(q, l, i-1); quick_sort(q, i, r);时取x = l 会面临边界造成的quick_sort()函数递归死循环的问题。例如长度为2的区间[1, 2],如果取x = q[l],那么划分结束后会分成一个长度是0的区间quick_sort(q, l, i-1)——>quick_sort(q, 0, -1)和一个长度是2的区间quick_sort(q, i, r)——>quick_sort(q, 0, 1),就无限递归了。具体代码如下:

  • j为中间点:
    以j为中间点

  • i为中间点:
    以i为中间点

  • 注意:由于使用do-while循环,所以ij一定会自增!!,使得循环会继续下去,但是如果采用while循环(ij的初始化做出对应的变更),ij在特殊情况下不自增的话,循环就会卡死;

  • 在循环中产生的边界问题

    • quick_sort(a, l, j); quick_sort(a, j + 1, r);为什么不能换成 quick_sort(a, l, i); quick_sort(a, i + 1, r)(即将 j+1 换成 i+1 作为中间区域的边界 / 为什么不能将 i-1 换为 j-1)?
      • 比如 x=3, 一段数 ,2,4,5,*,此时i走到了4下面,j走到了2下面,quick_sort(a, l, i); 中,左边区间包括了一个大于x=3的4,会导致排序错误。所以如果是 i 的话,i 要包括在右边区间里面,也就是 quick_sort(a, l, i-1); quick_sort(a, i, r);
    • do i++; while(q[i] < x)do j--; while(q[j] > x)不能用q[i] <= xq[j] >= x
      • 假设q[l..r]全相等则执行完do i++; while(q[i] <= x);之后,i会自增到r+1;然后继续执行q[i] <= x 判断条件,造成数组下标越界(但这貌似不会报错) 并且如果之后的q[i] <= x (此时i > r) 条件也不幸成立,就会造成一直循环下去,造成内存超限(Memory Limit Exceeded)
    • while循环代替do-while:用while(q[i] < x) i++;while(q[j] > x) j--;判断, 当q[i]q[j]都为 x 时, ij 都不会更新,导致 while 陷入死循环。(应改为while(q[i] <= x) i++; while(q[j] >= x) j--;)。可以代替为以下代码,这样即使循环结束时q[i]==q[j]==xi,j也能更新。
另一种快排方法
int partition( int[] nums, int left, int right)
{
 int pivot = nums[left]; // 左边第一个数为基准数
 int i = left + 1;
 int j = right;
 while(true)
 {  
   //向右遍历扫描
   while(i <= j && nums[i] <= pivot) i++;
   //向左遍历扫描
   while(i <= j && nums[j] => pivot) j--;
   if(i >= j)
     break;
   //交换
   int temp = nums[i];
   nums[i] = nums[j];
   nums[j] = temp;
 }
 //把nums[j]和基准数交换
 nums[left] = nums[j];
 nums[j] = povit;
 return j;
}

void quickSort(int[] nums, int l, int r) {
    // 子数组长度为 1 时终止递归
    if (l >= r) return;
    // 哨兵划分操作
    int i = partition(nums, l, r);
    // 递归左(右)子数组执行哨兵划分
    quickSort(nums, l, i - 1);
    quickSort(nums, i + 1, r);
}

// 调用
int main()
{
    vector<int> nums = { 4, 1, 3, 2, 5, 1};
    quickSort(nums, 0, nums.size() - 1);
    for (int i = 0; i < nums.size(); i ++ ) printf("%d ", nums[i]);
    return 0;
}
  • 快排的边界问题:取中间区域的值作为基准值也需要考虑向上还是向下取整的问题,想要用 i - 1 和 i 的话,需要用 x = q[l + r + 1 >> 1]x = q[r]作为基准值;用 j 和 j + 1 的时候,需要用 x = q[l + r >> 1]x = q[l]

============================================================================================

  • java模板1

    import java.util.*;
    import java.io.*;
    public class Main{
        public static void main(String[] args) throws IOException{
            BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
            int n = Integer.parseInt(reader.readLine());
            int[] arr = new int[n];
            String[] strs = reader.readLine().split(" ");
            for(int i = 0; i < n; i++){
                arr[i] = Integer.parseInt(strs[i]);
            }
            quickSort(arr, 0, arr.length - 1);
            for(int i = 0; i < arr.length; i++){
                System.out.print(arr[i] + " ");
            }
            reader.close();
        }
    
        public static void quickSort(int[] arr, int start, int end){
            if(start < end){
                int low = start;
                int high = end;
                int stard = arr[start];
                while(low < high){
                    while(low < high && stard <= arr[high]){
                        high--;
                    }
                    arr[low] = arr[high];
                    while(low < high && arr[low] <= stard){
                        low++;
                    }
                    arr[high] = arr[low];
                }
                arr[low] = stard;
                quickSort(arr, start, low);
                quickSort(arr, low+1 ,end);
            }
        }
    }
    
  • java模板2

    public class Main {
        public static void main(String[] args) throws IOException {
    		//     输入
            BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
            int n = Integer.parseInt(reader.readLine());
            int[] arr = new int[n];
            String[] strs = reader.readLine().split(" ");
            for( int i=0; i<n;i++){
                arr[i] = Integer.parseInt(strs[i]);
            }
            quickSort(arr, 0, arr.length-1);
    
            //   输出
            for(int i = 0; i< arr.length;i++){
                System.out.print(arr[i]+ " ");
            }
            reader.close();
        }
    
    
        public static void quickSort(int[] arr, int start , int end) {
            if (start >= end) return;
            int left = start - 1, right = end + 1, mid = arr[start+((end-start) >> 1)];
            while (left < right) {
                do {
                    left++;
                } while (arr[left] < mid);
                do {
                    right--;
                } while (arr[right] > mid);
                if (left < right) {
                    int tmp = arr[left];
                    arr[left] = arr[right];
                    arr[right] = tmp;
                }
            }
            quickSort(arr, start, right);
            quickSort(arr, right + 1, end);
        }
    }
    
  • 问题证明:快速排序算法的证明与边界分析

  • 快排虽然每次划分的区间不一定恰好是N/2,但是期望是N/2的,此情况下其递归的层数期望也是 l o g 2 N log_{2}N log2N的。

1.2 归并排序

  • 归并的基本思想也是分治,时间复杂度为O(Nlog(N)),且算法是稳定的。

  • 归并的步骤:

    • 找分界点,以中间点为分界线,归并是先递归再分别处理(快排是先分两边再递归)
    • 递归排序左边和右边,两边变为有序的链表;
    • 归并,将两个有序的数组合并为一个有序的数组;
      在这里插入图片描述
  • 归并两个有序数组的方法(暴力方法)

    • 比较两个指针位置的数的大小,找到两个数组中的最小值,直到一个指针到达终点,可以把剩下的补到结果里(新开一个存结果的数组);
    • 在归并两个数组过程中,一个指针最多扫描一半的数组,因此总共扫描长度为O(N),每个元素只会被比较一次,时间复杂度为O(N);该步骤只是算法中的递归一次执行的;请添加图片描述
  • 算法时间复杂度O(Nlog(N))的具体计算:数组为n,需要除 log ⁡ 2 n \log_{2}n log2n次才能除到1,因此共有 log ⁡ 2 n \log_{2}n log2n层,每一层的时间复杂度为O(n),因此总共O(Nlog(N))
    请添加图片描述

  • C++模板

    #include <iostream>
    using namespace std;
    
    const int N = 1000010;
    
    int q[N], tmp[N];
    void merge_sort(int q[], int l, int r)
    {
        if (l >= r) return; // 元素个数是1或没有元素;
    
        int mid = l + r >> 1;
    
        merge_sort(q, l, mid), merge_sort(q, mid + 1, r);
    	// 归并
        int k = 0, i = l, j = mid + 1; 
        // k是当前已经合并的数目,ij是左右指针
        
        while (i <= mid && j <= r)
            if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ]; // 如果左半部分现在指的数小于右半部分,tmp[k] = q[i]; k++, j++;
            else tmp[k ++ ] = q[j ++ ];  // 右半部分现在指的数小于左半部分;
        // 处理未循环完的部分;
        while (i <= mid) tmp[k ++ ] = q[i ++ ];
        while (j <= r) tmp[k ++ ] = q[j ++ ];
    
    	// 复制结果,将tmp[0..r-l+1]复制到q[l..r]中
        for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j];
    }
    
    int main()
    {
        int n;
        scanf("%d", &n);
        for (int i = 0; i < n; i ++ ) scanf("%d", &a[i]);
    
        merge_sort(a, 0, n - 1);
    
        for (int i = 0; i < n; i ++ ) printf("%d ", a[i]);
    
        return 0;
    }
    
  • 归并排序求逆序对:

    • 将所有的逆序对分为三大类:
    • (1)两个数同时出现在左半边;
    • (2)两个数同时出现在右半边;
    • (3)一个在左半边,一个在右半边;

    在这里插入图片描述在这里插入图片描述

    • 假定归并排序的函数可以在整个区间排好序的同时,求出区间内部所有逆序对的个数(Sj = mid-i+1)从imid都比j大。逆序对的个数最坏情况下是n*(n-1)/2
      在这里插入图片描述
      #include <iostream>
      
      using namespace std;
      
      typedef long long LL;
      
      const int N = 100010;
      int n;
      int q[N], tmp[N];
      
      LL merge_sort(int l, int r)
      {
          if(l>=r) return 0; // 或(l==r)
      
          int mid = l+r >> 1;
          
          LL res = merge_sort(l, mid) + merge_sort(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++];            // 右半部分现在指的数小于左半部分(i到mid的数都大于j);
                  res+=mid-i+1;
              }
          
          //处理未循环完的部分
          while(i<=mid) tmp[k++] = q[i++];
          while(j<= r) tmp[k++] = q[j++];
          
          //物归原主,i循环原始数组,j循环临时数组
          for (int i = l, j=0; i <= r; i ++, j++ ) q[i] = tmp[j];
          
          return res;
      }
      
      int main()
      {
          cin >> n;
          for (int i = 0; i < n; i ++) cin >> q[i];
          
          cout << merge_sort(0, n-1) << endl;
          
          return 0;
      }
      

1.3 快速选择算法

  • 快速选择算法的时间复杂度是O(n)(依次处理的区间长度为n+n/2+n/4+... <= 2n)。

  • 快速选择算法的含义是:

    • 首先对数组进行快速排序(找到分界点;左边所有数left <=x, 右边所有数 right >=x,中间值不一定等于x;递归排序 left,递归排序 right)(Sl和Sr是左右两边数的数量)
      在这里插入图片描述

    • 其次选择k,如果k<=Sl ,则递归左半边;反之,k>Sr递归右半边,找k-Sl的数
      在这里插入图片描述

  • 与快排的不同在于快排需要递归两边,快速选择每次只需要递归一边。

  • 举例:给定一个长度为 n 的整数数列,以及一个整数 k,请用快速选择算法求出数列从小到大排序后的第 k 个数。

    do-while循环,这里将第k个数转化为找k-1的下标
    #include <iostream>
    #include <vector>
    
    using namespace std;
    vector<int> a;
    
    int quick_sort(int l, int r, int k) {
       if(l >= r) return a[k];
    
       int x = a[l], i = l - 1, j = r + 1;
       while (i < j) {
           do i++; while (a[i] < x);
           do j--; while (a[j] > x);
           if (i < j) swap(a[i], a[j]);
       }
       if (k <= j) return quick_sort(l, j, k);
       else return quick_sort(j + 1, r, k);
    }
    
    int main() {
       int n, k;
       cin >> n >> k;
       a = vector<int>(n, 0);
       for (int i = 0; i < n; i++) {
           cin >> a[i];
       }
    
       cout << quick_sort(0, n - 1, k - 1) << endl;
    
       return 0;
    }
    
    
    while循环
    #include <iostream>
    
    using namespace std;
    
    const int N = 100010;
    int n, k;
    int q[N];
    
    int quick_sort(int l, int r, int k)
    {
        if(l == r) return q[l];
        
        int x = q[l], 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 sl = j-l+1;
        if(k <= sl) return quick_sort(l, j, k);
        return quick_sort(j+1, r, k-sl);
    }
    
    int main()
    {
        cin >> n >> k;
        for (int i = 0; i < n; i ++ ) cin >> q[i];
        
        cout << quick_sort(0, n-1, k) << endl;
        
        return 0;
    }
    
  • 快速选择算法里可以写if(l == r) return q[l];if(l >= r) return q[l];;但是在快排里必须写if(l == r) return q[l];,因为在快排里区间可能是没有数的(即出现l>r的情况)

二、二分

2.1 整数二分

  • 整数二分的本质不是单调性:如果有单调性,那么一定可以二分,但是可以二分的题目不一定非要单调性。

  • 整数二分的本质是边界:假设给定一个区间[l,r],我们在区间中定义了某种性质。使得在右半边区间是满足的,在左半边区间是不满足的,整个区间可以被一分为二,二分可以寻找性质的边界(两个边界都可以)。(即找到一个性质,可以将数据一分为二)
    在这里插入图片描述

  • 整数二分需要注意边界问题。

  • 整数二分有两个模板,分别适用不同的情况。两个模板核心的区别是 int mid = l + r + 1 >> 1;要不要加一,代表了两个区域的边界点;

  • 首先讨论寻找红色区域的边界点:

    • 1、找到中间点mid = (l+r+1)/2,并判断中间值是否满足该性质if (check(mid))checktrue说明满足条件,中间值在红色区间,答案在[mid, r],更新方式是将[l, r]区间更新为[mid, r]区间(l = mid);2、checkfalse说明,mid取在绿色区域,答案在[l, mid-1],更新方式是将[l, r]区间更新为[l, mid-1]区间(r = mid-1)。
      在这里插入图片描述
      在这里插入图片描述
  • 其次讨论寻找绿色的边界点:

    • 1、求mid =(l+r)/2 if (check(mid))是否满足绿色的性质checktrue说明满足条件,中间值在绿色区间,答案在[l, mid],更新方式是将[l, r]区间更新为[l, mid]区间(r = mid);2、checkfalse说明,mid取在红色区域,答案在[mid+1, r],更新方式是将[l, r]区间更新为[mid+1, r]区间(l = mid+1)。
      在这里插入图片描述
  • 因此选择两个模板是看l还是r等于mid

    // check 函数
    bool check(int x) {/* ... */} // 检查x是否满足某种性质
    
    // 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用:
    // mid 属于左半边
    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]时使用:
    // mid 属于右半边
    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要加一:

    • c++整数除法是下取整,当l = r-1时,如果mid = (l+r)/2mid=l,如果寻找红色边界且check返回true那么区间更新为[mid, r], l = mid => l,边界进入死循环;
    • 因此当mid = (l+r+1)/2,相同情况下mid=r不会死循环。
  • 举例(数的范围):

    • 给定一个按照升序排列的长度为 n 的整数数组,以及 q 个查询。对于每个查询,返回一个元素 k 的起始位置和终止位置(位置从 0 开始计数)。如果数组中不存在该元素,则返回 -1 -1
    • 输入格式,第一行包含整数 n 和 q,表示数组长度和询问个数。第二行包含 n 个整数(均在 1∼10000 范围内),表示完整数组。接下来 q 行,每行包含一个整数 k,表示一个询问元素。
    • 输出格式。共 q 行,每行包含两个整数,表示所求元素的起始位置和终止位置。如果数组中不存在该元素,则返回 -1 -1
      在这里插入图片描述
#include <iostream>
#include <cstring>
#include <algorithm>

using namespace std;

const int N = 100010;

int n, m;
int q[N];

int main()
{
    scanf("%d%d", &n, &m);
    for (int i = 0; i < n; i ++ ) scanf("%d", &q[i]);
    
    while(m --)
    {
        int x;
        scanf("%d", &x);
        
        int l = 0, r = n-1;
        
        // 查询左端点
        while(l < r)
        {
            // x以后所有数大于等于x;
            int mid = l+r >> 1;
            if (q[mid] >=x)  r = mid;	
            else  l = mid+1;
        }
        
        if(q[l] != x) cout<< "-1 -1"<< endl;
        // 如果用q[l]>x来判断,当数组中所有数都小于x时,判断就错了
        else 
        {
            cout << l << ' ';
            
            // 查询右端点
            int l = 0, r = n-1;
            while (l<r)
            {
                int mid = l+r+1 >>1;
                if(q[mid] <=x)  l = mid;
                else r = mid - 1;
            }
            cout << l << endl;
        }
    }
    return 0;
}

2.2 浮点数二分

  • 浮点数二分本质上也是一个边界,但没有整除,每次区间可以严格的缩小一半。

  • 当区间长度很小时(r-l<= 10-e^6),认为找到了答案。

  • 浮点数二分需要注意,当求一个浮点数的平方根时,右边界需要扩大,否则二分会产生问题。(如浮点数x=0.01,平方根是0.1,不在0-0.01之间,需要将右边界改为x+1max(1, x),不能直接取右边界为x

  • 举例,开平方:

#include <iostream>

using namespace std;

int main()
{
	double x;
	cin >> x;

	double l = 0, r = 100; // r不能等于x,x的二次方根一定在这个区间里;
	while ( r-l >1e-6)
	{
		double mid= (l+r)/2;
		if(mid*mid >= x) r = mid;
		else l = mid;
	}

	printf("%lf\n", l);
	return 0;
}
  • 保留4位小数需要r-l >1e-6,保留5位小数需要r-l >1e-7;保留6位小数需要r-l >1e-8

  • 浮点数二分的另一种写法,不用精度去表示迭代,而是直接循环100次。

#include <iostream>

using namespace std;

int main()
{
	double x;
	cin >> x;

	double l = 0, r = 100;
	for(int i = 0; i<100, i++)
	{
		double mid= (l+r)/2;
		if(mid*mid >= x) r = mid;
		else l = mid;
	}

	printf("%lf\n", l);
	return 0;
}
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值