常见算法
对数据的常见操作
前缀和
前缀和,是一个序列的前n项和,可以理解为高中的数列的前n项和。如果给你一个数组,要求你求出这个数组中某段区间的和,我们可以用遍历区间的方式来求和,下面是求得区间和的朴素算法。
1 |
|
但是这样做,如果求多个区间和就要多次遍历,这样时间复杂度会很高,索性我们在输入数据的同时,构造前缀和数组S[]
使得每一个S[]
数组的下标表示一段从1开始的连续的区间和(也可以从数组下标0开始),下面是代码。
1 |
|
当需要求得一个区间的和的时候,可以利用前缀和的思想,例如给一个数组a[5] = {1,2,3,4,5}
,要求区间2-3
的和。由S[]
数组的性质我们可以得到,S[3]-S[1] = a[2]+a[3](展开就知道了)
,所以说对于任意的区间[l,r]
,有区间和 = S[r] - S[l-1]
。
下面是根据推断得到的代码
1 |
|
二维前缀和
对于二维前缀和,可以理解为求取坐标范围内值的和。这里只是预处理,方便我们接下来自由计算两个区间内的前缀和:(x1,y1) - (x2,y2)
。
这里可以理解为倒着的x,y
轴,简单来说需要计算二维数组的范围值。枚举到i,j
坐标的时候,对其左,上方向接触到墙数组范围进行求和,然后记录到s数组制定的i,j
数组位置。
在简单的构造了S
二维前缀和,我们来讨论计算二维数组的随意区间和。
我们用图形来理解,需要求得红色区间值的和应该怎么做。直接用最大的蓝色矩阵减去绿色和紫色小条就行。减去完发现多减了一个a[i][j]
,加上即可,如下图所示。
外围蓝色矩形面积s[i][j] = 绿色面积 s[i - 1][j] + 紫色面积 s[i][j - 1] - 重复加的红色的面积 s[i - 1][j - 1] + 小方块的面积 a[i][j]
。
因此得出公式
s[i][j] = s[i - 1][j] + s[i][j - 1 ] + a[i][j] - s[i - 1][j - 1]
接下来回归问题去求以(x1,y1)
为左上角和以(x2,y2)
为右下角的矩阵的元素的和。
紫色面积是指 (1, 1)
左上角到(x1 - 1, y2)
右下角的矩形面积 ,黄色面积是指(1, 1)
左上角到(x2, y1 - 1)
右下角的矩形面积。
不难推出:
绿色矩形的面积 = 整个外围面积s[x2, y2] - 黄色面积s[x2, y1 - 1] - 紫色面积s[x1 - 1, y2] + 重复减去的红色面积 s[x1 - 1, y1 - 1]
以(x1, y1)
为左上角,(x2, y2)
为右下角的子矩阵的和为
s[x2, y2] - s[x1 - 1, y2] - s[x2, y1 - 1] + s[x1 - 1, y1 - 1]
然后我找到了一个图可以很好的描述这类问题:原链接在这里
首先给定一个原数组a:a[1], a[2], a[3],,,,,, a[n];
然后我们构造一个数组b : b[1], b[2], b[3],,,,,, b[i];
使得 a[i] = b[1] + b[2] + b[3] + ,,,,,, + b[i]
也就是说,a
数组是b
数组的前缀和数组,反过来我们把b
数组叫做a
数组的差分数组。换句话说,每一个a[i]都是b数组中从头开始的一段区间和
。构造差分b
数组最为直接的方法如下:
由于a
数组对应索引是,b
数组从1
开始到索引的前缀和。那么我们只要改变对应的b[l](l是左索引)
,就可以对后面产生连锁反应。
举个例子,b
数组是1 2 3 4 5 6 7
只需要将1这个数据+3
那么包含1+3
的前缀和都会+3
.同理,如果在4
这里-3
那么之后包含4-3
的前缀和都会-3
,所以说包含1和4的前缀和不会发生变化。
b[l] + c
,效果使得a
数组中a[l]
及以后的数都加上了c
,但只要求l
到r
区间加上 c
, 因此还需要执行 b[r + 1] - c
,让a
数组中a[r + 1]
及往后的区间再减去c
(绿色部分),这样对于a[r]
以后区间的数相当于没有发生改变。
因此我们得出一维差分结论:给a
数组中的[l, r]
区间中的每一个数都加上c
,只需对差分数组b
做 b[l] + = c, b[r+1] - = c
。时间复杂度为O(1)
, 大大提高了效率。
这里需要注意的是,我们开始拿了a[]
数组来构造b
数组。所以说我们执行完insert
函数,要用b
数组来构造回我们的a
数组。
1 | //模版代码 |
二维差分
结合一维差分的用途,可以容易的想到,二维差分主要是用于快速将一个区块中的所有元素都加上 d
。在这里可以把二维数组当做,y个x的一维数组。
对于一维差分,两个操作
- 在指定区间开始时加上对应的c
- 在指定区间结束+1减去对应的c
假定我们已经构造好了b
数组,类比一维差分,我们执行以下操作来使被选中的子矩阵中的每个元素的值加上c
b[x1][y1] + = c ;
b[x1][y2+1] - = c;
b[x2+1][y1] - = c;
b[x2+1][y2+1] + = c;
其实就和一维差分一样,我们在当前的块+c,那么只要包含的这个块的前缀和都会+c.最终需要的结果是a这个数组,也就是我们的前缀和数组嘛。
对于上述的操作我是这样理解的:
b[x1][y1] += c;
对应图1 ,让整个a数组中蓝色矩形面积的元素都加上了c。
b[x1][y2 + 1] -= c;
对应图2 ,让整个a数组中绿色矩形面积的元素再减去c,使其内元素不发生改变。
b[x2 + 1][y1] -= c;
对应图3 ,让整个a数组中紫色矩形面积的元素再减去c,使其内元素不发生改变。
b[x2 + 1][y2 + 1] += c;
对应图4,让整个a数组中红色矩形面积的元素再加上c,红色内的相当于被减了两次,再加上一次c,才能使其恢复。
这四个步骤,本质上是让包含我们处理过的b数组区域的前缀和+c或者-c
我们将上述操作封装成一个插入函数:
1 | void insert(int x1,int y1,int x2,int y2,int c) |
但是二维差分不像一维数组的差分构造。不可能像一维作差得出我们的二维差分数组,这里太难记了。这里介绍一种比较巧妙的方法:将差分矩阵中每个元素一个一个的插进去。
我们可以先假想a
数组为空,那么b
数组一开始也为空,因此每次让以(i,j)
为左上角到以(i,j)
为右下角面积内元素(其实就是一个小方格的面积)去插入 c = a[i][j]
,等价于原数组a中(i,j) 到(i,j)
范围内 加上了 a[i][j]
,因此执行n*m
次插入操作,就成功构建了差分b数组.
1 | for(int i = 1;i <= n;i++) |
构造处理过后的b数组公式(最终的结果):
1 | b[i][j] = a[i][j] − a[i − 1][j] − a[i][j − 1] + a[i −1 ][j − 1] |
1 |
|
双指针
双指针,指的是在遍历对象的过程中,不是普通的使用单个指针进行访问,而是使用两个相同方向(快慢指针)或者相反方向(对撞指针)的指针进行扫描,从而达到相应的目的。换言之,双指针法充分使用了数组有序这一特征,从而在某些情况下能够简化一些运算。
对撞指针
对撞指针是指在数组中,将指向最左侧的索引定义为左指针(left)
,最右侧的定义为右指针(right)
,然后从两头向中间进行数组遍历。
对撞数组适用于连续数组和字符串,也就是说当你遇到题目给定连续数组和字符床时,应该第一时间想到用对撞指针解题。
1 | //伪代码 |
1 | //实现函数 |
快慢指针
快慢指针也是双指针,但是两个指针从同一侧开始遍历数组,将这两个指针分别定义为快指针(fast)
和慢指针(slow)
,两个指针以不同的策略移动,直到两个指针的值相等(或其他特殊条件)为止,如 fast 每次增长两个,slow 每次增长一个。
一般快慢指针用于维护区间
1 | //伪代码 |
哈希表*
哈希表的实现
实现方法可以看看,实际上用的是
stl
中的hash
,可以直接理解成输入数据 -> f(x) ->映射为另一数据
,不需要太过于纠结怎么实现的原理
散列表(Hash Table)是一种特殊的数据结构,它最大的特点就是可以快速实现查找、插入和删除。数组的最大特点就是:寻址容易,插入和删除困难;而链表正好相反,寻址困难,而插入和删除操作容易。结合两者的优点,做出一种寻址、插入和删除操作同样快速容易的数据结构。这就是哈希表创建的基本思想。
hash表本质是数组,散列的数据集中到一个数组上,用最少资源按照一定的逻辑存储我们的数据
说人话就是大范围索引存储改为小范围索引存储
哈希函数:也称为是散列函数,是Hash表的映射函数,它可以把任意长度的输入变换成固定长度的输出,该输出就是哈希值。
哈希函数相当于排列规则,我们可以依靠这个排列规则排列键的顺序,同时也可以用这个排列规则寻找我们的值.
如果用日常生活来举例的话,哈希表转换结果就是我们的字典目录一样。
哈希表和哈希函数的标准定义:若关键字为k,则其值存放在h(k)的存储位置上。由此,不需比较便可直接取得所查记录。称这个对应关系f为哈希函数,按这个思想建立的表为哈希表。
设计出一个简单、均匀、存储利用率高的散列函数是散列技术中最关键的问题。
但是,一般散列函数都面临着冲突的问题。两个不同的关键字,由于散列函数值相同,因而被映射到同一表位置上。该现象称为冲突(Collision)或碰撞。发生冲突的两个关键字称为该散列函数的同义词(Synonym)。
哈希表的实现就是映射函数构造,看某个元素具体属于哪一个类别。如何构造我们要考虑两个问题:
- n个数据原仅占用n个地址,虽然散列查找是以空间换时间,但仍希望散列的地址空间尽量小。
- 无论用什么方法存储,目的都是尽量均匀地存放元素,以避免冲突。
直接定位法
Hash(key) = a·key + b (a、b为常数)
优点:以关键码key的某个线性函数值为哈希地址,不会产生冲突.
缺点:要占用连续地址空间,空间效率低。
例:关键码集合为{100,300,500,700,800,900}
, 选取哈希函数为Hash(key)=key/100
, 则存储结构(哈希表)如下:
这种方法计算最简单,且不会产生冲突。适合于关键字分布基本连续的情况,如果关键字分布不连续,空位较多,则会造成存储空间的浪费。
举一个例子,假设有一个记录了从 1 岁到 100 岁的人口数字统计表。其中年龄为关键字,哈希函数取关键字自身,如下表所示。
比如想要查询 25 岁的人有多少,则只要查询表中第 25 项即可。
这种方法一般不使用,除非是我们查询的表比较简单的情况之下。
除留余数法
Hash(key) = key mod p (p是一个整数)
特点:以关键码除以p的余数作为哈希地址。
关键:如何选取合适的p?
技巧:若设计的哈希表长为m,则一般取p≤m且为质数 (也可以是不包含小于20质因子的合数)。
取p≤m且为质数是为了少开区间,mod质数是为了输出不同区间的值,让数据更加散乱。
这种方法是最常见构造散列表的方法
乘余取整法
Hash(key) = [B*( A*key mod p ) ]
下取整 (A、B均为常数,且0<A<1,B为整数)
特点:以关键码key乘以A,取其小数部分,然后再放大B倍并取整,作为哈希地址。
例:欲以学号最后两位作为地址,则哈希函数应为: H(k)=100*(0.01*k % p )
其实也可以用法2实现:H(k)=k % 100
这也是一种简单且常用的哈希函数方法。其关键点在于p
的选择。根据经验而言,一般 p
取素数或者 m
,这样可以尽可能的减少冲突。(可以理解p为限定映射区域)
比如我们需要将 7 个数 [432, 5, 128, 193, 92, 111, 88] 存储在 11 个区块中(长度为 11 的数组),通过除留余数法将这 7 个数应分别位于如下地址:
比如432,对11取余数,余数为3,放在03位置
数字分析法
特点:某关键字的某几位组合成哈希地址。所选的位应当是:各种符号在该位上出现的频率大致相同。
例:有一组(例如80个)关键码,其样式如下:
平方取中法
特点:对关键码平方后,按哈希表大小,取中间的若干位作为哈希地址。
理由:因为中间几位与数据的每一位都相关。(乘法的性质)
例:2589的平方值为6702921,可以取中间的029为地址。
折叠法
特点:将关键码自左到右分成位数相等的几部分(最后一部分位数可以短些),然后将这几部分叠加求和,并按哈希表表长,取后几位作为哈希地址。
适用于:每一位上各符号出现概率大致相同的情况。
法1:移位法 ── 将各部分的最后一位对齐相加。
法2:间界叠加法──从一端向另一端沿分割界来回折叠后,最后一位对齐相加。
例:元素42751896, 用法1: 427+518+96=1041 用法2: 427 518 96—> 724+518+69 =1311
哈希表优化冲突*
Hash表解决冲突的方法主要有以下几种:
开放定址法(开地址法)、 链地址法(拉链法)、 再哈希法(双哈希函数法)、 建立一个公共溢出区,而最常用的就是开发定址法和链地址法。
开放寻址法
开放寻址法:又称开放定址法,当哈希碰撞发生时,从发生碰撞的那个单元起,按照一定的次序,从哈希表中寻找一个空闲的单元,然后把发生冲突的元素存入到该单元。这个空闲单元又称为开放单元或者空白单元。
就是映射的位置有数了,就在这个位置的下一个位置存储,如果还有继续查找下一个位置以此类推。
查找时,如果探查到空白单元,即表中无待查的关键字,则查找失败。开放寻址法需要的表长度要大于等于所需要存放的元素数量,非常适用于装载因子较小(小于0.5)的散列表。
开放定址法的缺点在于删除元素的时候不能真的删除,否则会引起查找错误,只能做一个特殊标记,直到有下个元素插入才能真正删除该元素。
可以把开放寻址法想象成一个停车问题。若当前车位已经有车,则继续往前开,直到找到一个空停车位。
设计思路:有冲突时就去寻找下一个空的哈希地址,只要哈希表足够大,空的哈希地址总能找到,并将数据元素存入。
含义:一旦冲突,就找附近(下一个)空地址存入。
开放寻址法的基本函数是:
1 | Hi(key)=(H(key) + f(i)) MOD m, i=0,1,2,…, k(k<=m-1), |
其中,m 为散列表长度,一般为素数;
H(key) 为散列函数,用于计算索引,key为关键字值;
f(i) 为增量序列,用于解决冲突,且
f(0) = 0
,i为已经尝试计算索引的次数。当散列值
H0(key)
发生冲突时,再计算H1(key)
……,直到不冲突为止。
实现步骤
得到给定的
key
;根据函数计算得
hashValue
;若不冲突,则把关键字值存入下标为
hashValue
的桶;若冲突,则使 i++ ,也就是往后找,直到找到第一个空桶并填入当前key。若到了尾部则循环到前面。
线性探查法
冲突函数:是i
的一次多项式,典型取法为f(i)=i
。
线行探查法(Linear Probing)是开放定址法中最简单的冲突处理方法,它从发生冲突的单元起,依次判断下一个单元是否为空,当达到最后一个单元时,再从表首依次判断。直到碰到空闲的单元或者探查完全部单元为止。
对于一个散列表,在散列过程中,某些元素形成一些区块,这种现象称作一次聚集(primary clustering)
。就是说,散列到区块中的任何关键字都需要多次探测才可以解决哈希碰撞,然后,把该关键字添加到相应区块的桶中。
下面是一道例题用线性探查实现开放寻址法
1 |
|
拉链法
基本思想:将具有相同哈希地址的记录链成一个单链表,m个哈希地址就设m个单链表,然后用一个数组将m个单链表的表头指针存储起来,形成一个动态的结构。
注:有冲突的元素可以插在表尾,也可以插在表头
例:设{ 47, 7, 29, 11, 16, 92, 22, 8, 3, 50, 37, 89 }
的哈希函数为: Hash(key)=key mod 11
, 用拉链法处理冲突,则建表如下图所示。
1 | /*拉链法实现上诉题目*/ |
双散列*
冲突函数:f(i) = i * hash2(key)
,典型取法是令hash2(key)=PRIME – (key % PRIME)
,其中 PRIME 是小于散列表大小的质数。
双散列(double hashing
)使用两个散列函数H(key)和hash2(key)
。hash2(key)
也以关键字为自变量,产生一个l至m-1
之间的、并和m互素的数(即m不能被该数整除)作为探查序列的地址增量(即步长)。
要扩充哈希表,首先必须找下一个新的且够大(大约2倍)的质数,然后必须考虑重哈希的成本。我们不可能原封不动的拷贝,必须要检验旧表格中的每个元素,计算其在新表格中的位置,然后再插入到新表格中。
平方探查法*
冲突函数:是i的二次多项式,典型取法为f(i)=i^2。
平方探测法(Quadratic Probing)
即是发生冲突时,用发生冲突的单元H(key)
, 加上 1²、 2²等,即H(key) + 1²,H(key) + 2²,H(key) + 3²...直到找到空闲单元。f(i)也可以构造为:±i^2,i=1,2,3,...,k
。
在实际操作中,平方探测法不能探查到全部剩余的桶。不过在实际应用中,散列表如果大小是素数,并且至少有一半是空的,那么,总能够插入一个新的关键字。若探查到一半桶仍未找一个空闲的,表明此散列表太满,应该重哈希。平方探测法是解决线性探测中一次聚集问题的解决方法,但是,她引入了被称为二次聚集的问题——散列到同一个桶的那些元素将探测到相同的备选桶。下面的技术将会排除这个遗憾,不过要付出计算一个附加的哈希函数的代价。
字符串哈希
构造唯一数据表示字符串,可以理解为 f(x) = y。就是通过某种方法转化,使得两个毫无相关的数据产生关系。但是为了将映射关系进行一一对应,也就是,一个字符串对应一个数字,那么一个数字也对应一个字符串。
用字符串Hash的目的是,我们如果要比较一个字符串,我们不直接比较字符串,而是比较它对应映射的数字,这样子就知道两个子串是否相等。从而达到,子串的Hash值的时间复杂度为 O(1)
,进而可以利用空间换时间来节省时间复杂度。我们希望这个映射是一个单射,所以问题就是如何构造这个Hash函数,使得他们成为一个单射。
构造字符串hash
简单例子,假设给你一个数字1166
,形式上你只知道它只是1和6
的组合,但你知道它代表的实际是1*10^3+1*10^2+6*10^1+6*10^0
。
同理,给你一个字符串,要把它转换为数字,就可以先把每一个字符都先对应一个数字,然后把它们按照顺序乘以进制(Base
)的幂进行相加,然后这个数可能很大,所以一般会取余数(MOD
)。前面说过了,取模操作实际上是限制了数的长度。
根据上面的理解,其实将字符串映射成数字,和平时的将一个二进制,变为一个十进制数。相类似,把字符串当成,某种进制的数据,把字符串拆成很多位,然后对每一个位置的字符都进行哈希操作,再用进制的乘法操作将他们拼接起来。
这里我举个例子:11111,映射为一个值。这里假设值是
O
,映射的进制是10嘛那么
O = 1*10000+1*1000+1*100+1*10+1*1
. 这样就可以映射了。应对于:
abcde
。指定一个进制p
,那么映射结果就是:
o = a*p^4+b*p^3+c*p^2+d*p^1+e*p^0
。这就是一个最基础的字符串映射。
先定义以下:给定一个字符串 S = s1s2s3...sn
对于每一个si
就是一个字母,那么我们规定: idx(si) = si-'a'+1
(当然也可以直接用其ASCII
值)。构造字符串Hash
总共有三种方法。每一种方法,主要都是用使用 Base 和 MOD
(都要求是素数),一般都是Base < MOD
,同时将Base和MOD
尽量取大即可,这种情况下,冲突(即不同字符串却有着相同的hash
值)的概率是很低的。自然溢出方法,对于自然溢出方法,定义 Base
,而MOD
对于自然溢出方法,就是 unsigned long long
整数的自然溢出
怎么构建我们的字符串前缀?
- 将整个数组 当做是一个p进制的数。 通过这样的方式,将我们的字符串变成数字进行操作,但是我们这个数组转化完成可能会非常大,那么我们就mod一个大的数据,通过这样的方法映射到小区间中。
- 字符串哈希完全不考虑冲突的情况
p = 131 or 13331 - Q = 2e64
这样可以忽略冲突 (溢出等价于mod 2e64
)
1 | //相当于将字符型映射为整型(本来就是 将字符串映射成一个p进制的数据 - p一般为质数) |
高精度
在平常的实现中,高精度数字利用字符串表示,每一个字符表示数字的一个十进制位。因此可以说,高精度数值计算实际上是一种特别的字符串处理。
读入字符串时,数字最高位在字符串首(下标小的位置)。但是习惯上,下标最小的位置存放的是数字的 最低位,即存储反转的字符串。这么做的原因在于,数字的长度可能发生变化,但我们希望同样权值位始终保持对齐(例如,希望所有的个位都在下标 [0]
,所有的十位都在下标 [1]
……);同时,加、减、乘的运算一般都从个位开始进行(回想小学的竖式运算),这都给了「反转存储」以充分的理由。(还有一个很重要的点,倒序可以消除前导0)
存储与读入
我们读入的数据是一个很长很长的数字,我们可以用字符串存储或者使用数组存储。下面是读入模板:
1 | //初始化 |
打印模版
1 | //打印数据 |
高精度加法计算
1 |
|
高精度减法计算
减法和加法一样,只不过进位变为了借位。
1 | void sub(int a[], int b[], int c[]) { |
不过需要注意,竖式计算的减法,需要上面大于下面:
1 |
|
高精度乘法计算
有两种情况,一种是小数据*大数据
,另一种就是大数据*大数据
。如果是第一种,直接把小的数据当成一个值,直接乘大数据
,再对大精度
的每一位的处理就行。
1 | void mul_short(int a[], int b, int c[]) { |
这种方法,需要a,b
两个数据不在同一数量级,如果在同一数量级会导致数据溢出。
第二种方法是竖式乘法,竖式乘法的本质是计算若干个a*bi*10^i
的和,例如计算1337 * 42
其实是1337*2*10^0 + 1337*4*10^1
。
1 | void mul(int a[], int b[], int c[]) { |
高精度除法计算*
1 | // 被除数 a 以下标 last_dg 为最低位,是否可以再减去除数 b 而保持非负 |
二分
二分查找的思路很简单,我们需要找的数据都是在数组中嘛,只要将这个数组分为两个部分,然后在左边找不到就到右边找,然后再把剩下的数组分为两部分,以此类推。但是需要注意一些细节,Knuth 大佬(发明 KMP 算法的那位)都说二分查找:思路很简单,细节是魔鬼.
二分查找采用双指针的形式,来对我们数组序列进行遍历。二分查找只使用于排好序的数组,不适用于其他。掌握二分查找的关键是,要明白数组是怎么分成两部分,两部分的边界是什么组成的。
暴力
暴力就是按照对应序列一个一个找,直到找到目标为止,没有找到就返回-1
1 | int a[N]; |
这可以用蓝红划分数组来解释,假设我们数组开始全是灰色(没有被遍历),设计一个蓝色指针指向数组的最左边,从左往右扫到蓝红边界,知道扫到目标值。
然后也同样,也可以在最右边设计一个红色指针,从右往左扫直到扫到蓝红边界,直到扫到我们的目标值。
这样的算法是十分低效的,原因是因为指针移动速度缓慢,每次只能在数组中搜索到一个值的信息。
二分细节
在上面,我们用蓝红表示二分数组中两个不同的区域:
如果我们在数组中间发现一个指针为蓝色,那么很显然这个指针之前的颜色全为蓝色:
同样的我们在右侧发现了一个红色区域块,就可以推断出其右侧区域都是红色
通过这样不断操作,直到找到我们的蓝红边界:
我们为什么要找蓝红边界,因为二分有几个常见的问题需要我们解决,假设ans是我们需要找到的二分值,对这个值我们有如下问题:
- 找到第一个
>=ans
的元素 - 找到最后一个
<ans
的元素 - 找到第一个
>ans
的元素 - 找到最后一个
<=ans
的元素
对于这个值暂时不去讨论,我们先搞定拓展红蓝色的代码:
1 | //伪代码 |
开始l
指针指向蓝色区域,r
指针指向红色区域,循环直到达到边界,保持l,r
颜色不发生改变,遍历结束l,r
就达到了蓝红边界处。二分查找的时间复杂度是o(log n)
,也就是一直在折半。
为什么l
区域初始化为-1
,这是因为我们需要再数组中分蓝和红区域,如果将l
初始化为1,恰好数组中全是红色区域,这样就矛盾了。同理,r
也不可以取r-1
这个位置,也会导致矛盾。
同时验证一下m是否都在数组中,因为l
最小值是-1
,r
最小值是1
(这里r最小值是因为 l+1 = r
的时候会直接退出,如果r = 0
那么就会直接跳出循环) ,m的最小值也就为 (1 + 1 )/2= 0
。以此类推,l
的最大值应该是N-2
(N1
跳出循环体循环),那么r
的最大值也是为N
(边界嘛)。所以说最大值是(N-2+N-1)/2 = N/2
更新指针的时候,都是指向蓝红边界的位置,如果我们改变为l = m+1
会导致区域发生错误,我们就用更新为m和r
的模版就行,不会搞乱自己。
死循环问题:全部问题都会归类为第一种退出循环的方式
1 | //判断值 用m比对我们需要的值 - 需要的值是在左边还是在右边 |
1 |
|
STL
中的二分
stl
中,有基于二分查找原理所构造的函数体:
lower_bound()
函数用于在指定区域内查找不小于目标值的第一个元素,也就是查找和目标值相等或者比目标值大的数据。用法:
1 | //在[first,last]中查找不小于val的元素 |
upper_bound()
函数,用于指向查找范围内大于目标值的第一个元素,用法:
1 | //查找[first, last)区域中第一个大于 val 的元素。 |
二分答案
使用模版来解决二分答案问题,一般构造二分答案用两个步骤
- 分析问题,构造
check
函数 - 通过问题,决定二分的输出
与二分查找不同,二分答案的目标值是给一系列条件限制的。所以说我们需要check函数来构造符合题意的分割方式。
可以理解为,二分查找是在一维的数组中找到指定的值,二分答案是在二维的数组中找到指定的值。这里的维度表示约束变量,也就是说二分答案的时候我们有两个约束条件来考虑。
在满足两个条件的情况之下,绿色部分是我们的可选区域,也就是我们的值在这一个范围之内考虑,我们也可以通过考虑在变量约束情况之下,构造的check函数的单调性,来构造我们的check。下面是构造的模版代码:
1 | bool check(int x){ |
同理,最小化答案也是一样的。只不过我们找的可行区域是不一样的。
1 | bool check(int x){ |
在函数图像上我们可以看见,我们要求的的最大值和最小值,可以是在我们x轴的左侧也可以是在右侧。这里要看我们构造的check函数的单调性是递增还是递减的。
例如找最大值,递增函数,也就是x和y同增同减,那么往往最大值是在右侧;递减函数,也就是x越大,y越小。那么最大值往往是在左侧。但是x和y在题目中谁是谁怎么划分呢?
emmmm
那只能用题目来理解了:
先判断,最终输出的结果是什么,题目要求我们输出l
的最大值,那就说明了这道题是一个最大化的二分答案。(但是不知道啥时候该用二分哈哈哈)
既然是最大值,那么答案一定是在右边咯,所以说find
函数确定了,然后x也确定了就是我们每段木头的最大值。再分析约束条件,题目要求把我们的木头切割成k段,那么y找到了,它的约束条件也就是y>=k咯(切割的段数要满足)。严谨一点,我们打一个表(这里直接照搬了)
分析x和y构成的函数,发现x越大那么y越小。说明是一个减函数。
观察图像,我们很容易就可以知道我们的l
应该是在满足y的情况下尽可能靠右。这里x的区间呢,也就是[0,max]。为什么是这个区间呢,原因是如果我们不加y限制,那么我们想切多长就切多长呗。
1 |
|
顺序也就是:
- 第一,分析二分答案确定 x
- 第二,确定函数是增函数还是减函数,然后确定求的答案是最大化还是最小化
- 第三,根据第一第二构造我们的 check() 函数