二分法的学习及刷题

本文围绕二分算法展开,介绍了二分法的定义、基本思想和时间复杂度,阐述了其在有序数组查找、确定值位置等方面的应用场景,说明了应用二分的判断方法和解题步骤,并通过多个例题,如Acwing的数的范围、数的三次方等题目,展示了二分算法的具体运用。

二分的学习及刷题

1.什么是二分

  • 二分法 是一种常见的算法,它也被称为二分查找或折半查找。该算法是用于在有序数组中查找特定值的一种搜索算法。

    二分法的基本思想是:将有序数组分成两个部分,然后判断目标值可能存在于哪个部分,再对该部分进行二分查找,直到找到目标值或者确定目标值不存在为止。

    二分法的时间复杂度:O(log n)

  • 例子

    • 给定一个有序数组 arr[7] = {1,3,6,9,12,32,44} ,又给定一个要查询的数字 number ,求 number 在数组中的位置。

      假定 number = 32 ,用二分法来查找的话,首先我们要定义三个变量int l = 0, r = 6, mid = (l+r)/2 ,接着判断一下 numberarr[mid] 大小关系,若 number > arr[mid] 说明number在mid的右边,所以左指针 l 右移到 mid+1;若 number < arr[mid] 说明number在mid的左边,所以右指针 r 左移到 mid-1。继续使 mid = (l+r)/2 继续判断 numberarr[mid] 大小关系,直到找到 number

      代码如下

      #include <iostream>
      using namespace std;
      int ans;
      
      int main()
      {
          int len
          int number;
          int arr[len];
          cin >> len ;	//输入数组的长度
          for(int i = 0; i < len-1; i++) cin >> arr[i];//输入数组
          cin >> number;	//输入要查找的数字
          int l = 0, r = len-1;
          while(l <= r)
          {
          	mid = (l + r)/2;
              if(number == arr[mid]) ans = mid;
              else if(number > arr[mid])
              {
                  l = mid + 1;
              }
              else if(number < arr[mid])
              {
                  r = mid ;
              }
          }
          cout << ans;
          return 0;
      }
      

2.二分法的应用场景

  • 二分法应用场景
    • 有序数组的查找: 最常见的应用场景就是在有序数组中查找特定元素。由于二分查找每次可以将搜索范围减半,因此其时间复杂度为O(log n),远远快于线性搜索。
    • 查找某个值的位置或插入位置: 当需要确定某个值在数组中的位置,或者在有序数组中插入某个值时,二分查找也非常实用。
    • 寻找边界: 有时候需要找到满足某种条件的最小或最大的元素位置,二分查找可以高效地解决这类问题。
    • 旋转数组的搜索: 在旋转数组(部分有序数组)中查找特定元素时,可以先确定有序的部分,然后再在有序部分上运用二分查找。
    • 数值范围的搜索: 在一定数值范围内查找满足特定条件的元素,比如在一组整数中查找第一个大于等于某个值的元素。

3. 应用二分的判断及二分模版

  • 判断是否可用二分法:

    1、是否具有二段性

    2、是否具有单调性

    3、快速判断某个数是否在某一堆数里面出现过

  • 二分法解题步骤:

    1、通过二分的数据搜索范围,确定 rl 初始值

    2、确定二分 rl 指针的更新条件

    3、确定rl 指针的更新操作

    • 第一种情况:目标值不可能是 arr[l]
      r = mid; 或者 l = mid + 1;
    • 第二种情况:目标值不可能是 arr[r]
      l = mid; 或者 r = mid - 1;

    4、 mid 的更新操作

    • r = mid 时,更新 mid = (r + l) / 2
    • l = mid 时,更新 mid = (r + l + 1) / 2加一的原因是:求右边界要加一个1的原因在于当 rl 相邻时,若不加1就除于二,求得的 mid 就是 l,然后判断 arr[mid] <= kl=mid ,此时 l 没有改变位置,l 将永远不能等于 r 进入死循环。

    5、确定二分循环的跳出条件

    • 一般为 l < r (根据题目要求修改)

4.例题

  • 【Acwing 789.数的范围】 (二分)

    分析

    • 根据题意:要求我们输入一个有序数组,输入一个查询数k,接着输出k的左右下标边界。具体情况如下:

      在这里插入图片描述

      对于上述的情况我们知道题目的意思是要我们寻找查询数 k 的左右边界。

    • 查找一个数或是边界问题,我们可以通过二分法进行查找。对于左右边界我们可以先用二分法找左边界,再用一次二分法找右边界。在使用二分法查询的时候,我们首先定义一个左右指针 r l ,计算出中指针 mid ,接着判断 arr[mid] k 的大小关系,来改变 r l 的值。

    • 时间复杂度分析:数组长度 N 最大值为100000,要查询的数最多有 q 10000,对于每个数的查找,查找其左边界需要用时为 log N 查找出两个边界所用时间就是 2log N 。所以总的时间是 2log N * q = 2 * lg100000 / lg2 * 10000 = 330000,远小于1亿所以不会超时,方法可行。

    设计思路

    • 对于左边界,当 arr[mid] >= k 时,我们将 r = mid, 当 arr[mid] < k 时,我们将 l = mid + 1 ;直到r==l 此时 l 的值就是k的左边界。下图是一个升序的数组查询左边界的模拟过程:

      在这里插入图片描述

    • 对于右边界也是相同的二分查询法。当 arr[mid] <= k 时,我们将 l = mid, 当 arr[mid] >= k 时,我们将 r = mid - 1 ;直到r==l 此时 r 的值就是k的右边界。

    • 细节问题:在用 r l 计算 mid 的大小的时候,对于查询左右边界时有所不同。求左边界时有 mid = r+l >> 1 ,求右边界时有 mid = r+l+1 >> 1。求右边界要加一个1的原因在于当 rl 相邻时,若不加1就除于二,求得的 mid 就是 l,然后判断 arr[mid] <= k l令 l=mid ,此时 l 没有改变位置,l 将永远不能等于 r 进入死循环。

    代码

  • #include <iostream>
    using namespace std;
    
    const int N = 100010;
    int n, q;
    int arr[N];
    
    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[r] == k)
            {
                cout << l << " ";
                r = n-1;
                //接着查找右边界
                while(l < r)
                {
                    int mid = l + r + 1 >> 1;//加一的原因是当rl相邻的时候,若不加1,mid==l,l=mid无法移动l指针,这样rl永远都不可能相等
                    if(arr[mid] <= k) l = mid;
                    else r = mid - 1;
                }
            }
            else cout << "-1 -1" << endl;
        }
        return 0;
    }
    
  • 【Acwing 790.数的三次方】 (二分)

    分析

    • 按照题目要求就是输入一个浮点数 n ,输出其三次方根,结果保留6位小数。
    • 求一个数的三次方根我们的第一反应可能是直接用cmath库中的库函数pow 来实现,但这个函数有个问题就是当 n 为负数且所求的三次方根接近于零但不等于零的时候会返回 NAN ,因此不建议使用 pow 函数。
    • 对于 n 题目给定了范围为 -10000 ~ 10000 ,而 n 的三次方根就在这个范围里面,因此我们可以考虑用 二分法 不断地去逼近 n 的三次方根。

    设计思路

    • 首先定义双精度浮点型左右指针和中指针,并赋予初始 rl 的值确定数据范围。
    • 确定二分跳出循环的条件:当 r - l < 1e-8 说明此时 rl 的值已经很接近 n 的三次方根,且 rl 小数点后8位相同。
    • 移动 rl 指针缩小数据范围:当 mid * mid * mid > n 说明mid比n的三次方根要大,因此将右指针 r 更新为 mid ;当 mid * mid * mid < n 说明mid比n的三次方根要小,因此将左指针 l 更新为 mid
    • 最后用printf函数格式化输出 r 或者 l 即可。

    代码

  • #include <iostream>
    using namespace std;
    
    double n;
    
    int main()
    {
        cin >> 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("%lf", l);
        return 0;
    }
    
  • 【Acwing 730.机器人跳跃问题】(二分,递推)

    分析

    • 根据题目要求,要返回一个最小初始能量值 E 使得可以成功完成游戏。
    • 这个初始能量值 E 的取值范围是 1 ~ 1e5 ,因此我们可以想到一个最简单粗暴的方法,就是枚举取值范围里面的每一个数,并对每一个数判断其是否能完成游戏。
    • 直接暴力枚举,我们可以先分析一下该方案的时间复杂度,要从 1~1e5 进行枚举,枚举每一个数要对数组进行判断,数组里最多的元素个数为 1e5 ,因此总的时间为 1e10 超过了一个亿,该方法会超时。
    • 对于枚举的问题,我们可以考虑二分,dfs,dp,贪心。分析题目,对于最终的结果 E ,我们知道比 E 大的数值一定可以完成游戏,比 E 小的数值一定不能成功完成游戏,因此数据具有二段性,故可以用**二分法**进行枚举。

    设计思路

    • 确定 rl 的初始值。由数据范围可知 l = 0, r = 1e5

    • 更新 rl 值的判断条件。定义一个返回值为bool类型的函数check,输入的参数是E,判断输入的 E 是否可以完成游戏。若 check 返回为 true 说明输入的E可以完成游戏,因此对右指针 r 进行更新,否则对左指针 l 进行更新。

    • 对于rl的 更新操作更新 r,由于此时 check 函数为 true ,因此当前的 r 可能为最终的 E ,也有可能是在 E 的右边,因此 r = mid更新 l,由于此时 check 函数为 false ,因此当前的 l 不可能为最终的 E ,同时 l 一定在 E 的左边,因此 l = mid + 1

    • 对于 mid 的更新操作。mid 的更新操作取决于rl的 更新操作,由于 r = mid 因此 mid = (r + l)/2

    • 二分循环的条件。当 l < r 时,说明还没找到最终的初始能量 E 。跳出循环时,则 l == r ,此时有 E == l == r

    • check函数的编写。通过题目意思我们可以得到以下的递推公式:

      在这里插入图片描述

      上式中的 k 表示第几个建筑,通过上面的递推式子我们可以求出机器人在第 k 座建筑的能量值 E ,判断每一个E是否有出现小于零的情况,若有返回false,若无返回true(由于E是每次都是上一次的两倍,极有可能空间溢出,但我们发现当E大于数组中的最大值,遍历到后面的建筑的E一定大于零,因此当E大于数组最大值就直接返回true,这样可以解决E空间溢出的问题)

    代码

    #include <iostream>
    using namespace std;
    
    const int N = 1e5 + 10;
    int n;
    int h[N];
    
    bool check(int e)
    {
        for(int i = 0; i < n; i++)
        {
            e = 2*e-h[i];//递推公式
            if(e < 0) return false;
            if(e > 1e5) return true;//当能量E大于数组最大的数时,说明后面的E不可能有小于零的情况,直接返回true。此步是为了防止E的空间溢出出现负数的情况
            }
        }
        return true;
    }
    
    int main()
    {
        cin >> n;
        for(int i = 0; i < n; i++) scanf("%d", &h[i]);
        
        int l = 0, r = 1e5;
        while(l < r)
        {
            int mid = (l + r) /2;
            if(check(mid)) r = mid;
            else l = mid + 1;
        }
        cout << l;
        return 0;
    }
    
  • 【Acwing 1221.四平方和】

    分析

    • 根据题目意思,就是输入一个整数 N ,输出 N 的四个整数 a b c d ,该四个整数满足 N = a^2 + b^2 + c^2 + d^2
    • 由于输入的整数 N 的范围是确定的:0~5e6 ,由上式知道 a b c d 是一定小于 N ,所以 a b c d 的范围就是确定的,我们可以暴力枚举 从大到小a b c d 这四个整数,判断哪一组数据满足 N = a^2 + b^2 + c^2 + d^2 输出即可。
    • 我们分析一下以上暴力枚举的时间复杂度:O(&n^4&),很显样这样肯定会爆时间,其实遍历前面三个数可以通过 N 计算出的 d, 判断一下d的范围即可,这样的时间复杂度是O(&n^3&)。但这样的时间复杂度还是很勉强。因此我们只能枚举两个整数。
    • 由于最多只能枚举两个整数,我们可以通过空间换时间的方式实现题目的要求。我们可以先枚举 c d ,将 c^2 + d^2 的结果全都存储起来;再枚举 a b ,将 a^2 + b^2 的结果全都存储起来,最后从头到尾判断哪一组 a^2 + b^2 加上哪一组 c^2 + d^2 等于 N 。时间复杂度大致是O(3*n^2),该方法可行。
    • 对于上述的问题,我们可以简化为:如何在 c^2 + d^2 的数组中找到一个数等于 a^2 + b^2 。像这种如何快速判断一个数在某一堆数中是否出现过,我们一般考虑两个做法:1、二分法(O(logN),2、哈希表(O(1))。因此本道题也可以用二分法哈希做。

    设计思路:(基于二分法)

    • 首先定义一个结构体 Sum ,用于存储两个数和两数的平方和int c d s

    • 接着枚举 c d ,并将它们及它们的平方和存储到结构体数组 sum[N]

    • 然后对结构体数组中的每个结构体进行排序,平方和小的排在前面,相同平方和的结构体,字典序小的排在前面

    • 再枚举 a b ,一边枚举一边开始二分查找对应的那组满足题意的 c d

      1、确定左右指针 lr 的初始值l = 0, r = m-1 (m是结构体数组的长度)。

      2、确定 rl 的更新条件:当 sum[mid].s >= n-a*a-b*b,说明要查找的那组 c dmid 的左边,因此更新 r ;当 sum[mid].s < n-a*a-b*b,说明要查找的那组 c dmid 的右边,因此更新 l

      3、对于 rl 的更新操作r = midl = mid + 1

      4、二分查找的循环条件l < r 。当 l == r 时,对 sum[l].s 进行判断,若 sum[mid].s == n-a*a-b*b 说明当前查找出来的这组 c d 是合法的;否则,说明不存在这样一组合法的 a b c d,继续枚举新的 a b ,接着进行二分查找对应的 c d

    代码

    • 二分法:

      #include <iostream>
      #include <algorithm>
      using namespace std;
      
      const int N = 5e6+10;
      int n, m;
      
      struct Sum
      {
          int s, c, d;
          bool operator< (const Sum &t)const
          {
              if (s != t.s) return s < t.s;
              if (c != t.c) return c < t.c;
              return d < t.d;
          }
      }sum1[N];
      
      int main()
      {
          cin >> n;
          for(int c = 0; c * c <= n; c++)
              for(int d = c; d * d + c * c <= n; d++)
              {
                  sum[m++] = {d * d + c * c, c, d};
              }
              
          sort(sum, sum+m);//排序是为了当两数平方和相等时,字典序最小的排在最前面,方便后面二分查找。
          
          for(int a = 0; a * a <= n; a++)
              for(int b = a; a * a + b * b <= n; b++)
              {
                  int l = 0, r = m-1;
                  int t = n - a * a - b * b;
                  while(l < r)
                  {
                      int mid = (r+l)/2;
                      if(sum[mid].s >= t) r = mid;//sum[mid].s相同的值可能有多个,但我们取最左边的那个,因为字典序最小
                      else l = mid + 1;
                  }
                  
                  if(sum[l].s == t)
                  {
                      printf("%d %d %d %d", a, b, sum[l].c, sum[l].d);
                      return 0;
                  }
              }
      }
      
    • 暴力枚举法:

      #include <iostream>
      #include <cmath>
      
      using namespace std;
      
      const int N = 2500010;
      
      int n;
      
      int main()
      {
          cin >> n;
          for (int a = 0; a * a <= n; a ++ )
              for (int b = a; a * a + b * b <= n; b ++ )
                  for (int c = b; a * a + b * b + c * c <= n; c ++ )
                  {
                      int t = n - a * a - b * b - c * c;
                      int d = sqrt(t);
                      if (d * d == t)
                      {
                          printf("%d %d %d %d\n", a, b, c, d);
                          return 0;
                      }
                  }
      }
      
    • 哈希法:

      #include <iostream>
      #include <unordered_map>
      
      #define x first
      #define y second
      
      using namespace std;
      
      typedef pair<int, int> PII;
      
      const int N = 2500010;
      
      int n, m;
      unordered_map<int, PII> S;
      
      int main()
      {
          cin >> n;
      
          for (int c = 0; c * c <= n; c ++ )
              for (int d = c; c * c + d * d <= n; d ++ )
              {
                  int t = c * c + d * d;
                  if (S.count(t) == 0) S[t] = {c, d};
              }
      
          for (int a = 0; a * a <= n; a ++ )
              for (int b = 0; a * a + b * b <= n; b ++ )
              {
                  int t = n - a * a - b * b;
                  if (S.count(t))//查询哈希表中是否存在t
                  {
                      printf("%d %d %d %d\n", a, b, S[t].x, S[t].y);
                      return 0;
                  }
              }
      
          return 0;
      }
      
  • 【Acwing 1227.分巧克力】

    分析

    • 根据题目意思,要求我们要输出巧克力最大的分割边长,同时要满足 K 位小朋友都要分到。
    • 已知巧克力的边长取值范围是从 1~1e5 ,所以巧克力的最大分割长度范围也是 1~1e5 ,既然知道范围了,我们第一个想法就是进行暴力枚举,从 1 作为最大分割长度开始枚举,统计分割后的巧克力块数,若大于人数 K ,继续枚举;若枚举到分割后巧克力块数小于 K 的分割长度时,输出该长度减一(由于分割长度越大,块数越少,因此比最大分割长度还要大的长度,块数一定是不满足人数 K 的)。
    • 分析一下暴力枚举的时间复杂度:设最大分割长度为 XX 最大可取到1e5 ,每枚举一个分割长度 X 都要对所有的巧克力进行分割并统计分割后的块数,巧克力块数最大为 1e5 。因此暴力枚举用到的最大时间为 1e5*1e5 = 1e10 > 1e9 ,故暴力枚举的方法会爆时间。
    • 枚举问题我们还是可以考虑二分法。首先我们考虑一下数据是否具有二段性,对于最大分割长度 X 来说,比它大的,一定不符合题意;比它小的一定满足题意,因此数据具有二段性。故可采用二分法来枚举 X
    • 分析二分法的时间复杂度:枚举最大分割长度 X 所用的最大时间为 log1e5 = 17 ,遍历每个巧克力统计分割后的块数所用的最大时间是 1e5 ,因此总共用的最大时间为 17e5,远小于1亿,故该方案可行。

    设计思路

    • 定义两个数组用于存储每个巧克力的宽和长

    • 编写一个bool类型的check函数,传入的参数是所枚举到的分割长度值 X ,判断传入的 X 是否满足题意,若是,返回true;否则返回false。

    • 用二分法查找出最大分割长度 X

      1、确定左右指针 lr 的初始值:由数据范围可知l = 1, r = 1e5

      2、确定 rl 的更新条件:当 check(mid) == true,说明要查找的最大分割长度为当前 mid 或在 mid 的右边,因此更新 l ;当 check(mid) == false,说明要查找的最大分割长度在 mid 的左边,因此更新 r

      3、对于 rl 的更新操作l = midr = mid - 1

      4、对于 mid 的更新操作:由于是 l = mid 因此更新方式为 mid = (r + l + 1)/2

      5、二分查找的循环条件l < r 。当 l == r 时,对 sum[l].s 进行判断,若 sum[mid].s == n-a*a-b*b 说明当前查找出来的这组 c d 是合法的;否则,说明不存在这样一组合法的 a b c d,继续枚举新的 a b ,接着进行二分查找对应的 c d

    • check 函数的编写:遍历每一块巧克力,每块巧克力的分割块数为(h[i]/x) * (w[i]/x),因此统计所有的分割块数后,判断与 K 的关系,若 cnt >= k 返回true;若 cnt < k,返回false。

    代码

    #include <iostream>
    using namespace std;
    
    const int N = 1e5+10;
    int n, k;
    int h[N], w[N];
    
    bool check(int x)
    {
        int cnt = 0;
        for(int i = 0; i < n; i++)
        {
            cnt += (h[i]/x) * ( w[i]/x);
            if(cnt >= k) return true;
        }
        return false;
    }
    
    int main()
    {
        scanf("%d %d", &n, &k);
        for(int i = 0; i < n; i++)
        {
            scanf("%d %d", &h[i], &w[i]);
        }
        
        int l = 1, r = 1e5;
        while(l < r)
        {
            int mid = l + r + 1 >> 1;
            if(check(mid)) l = mid;
            else r = mid -1;
        }
        printf("%d", l);
        return 0;
    }
    
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值