一、线性枚举
1、线性枚举定义
线性枚举指的就是遍历某个一维数组(顺序表)的所有元素,找到满足条件的那个元素并且返回,返回值可以是下标,也可以是元素本身。
由于是遍历的,穷举了所有情况,所以一定是可以找到解的,一些资料上也称之为 暴力算法 (Brute Force)。接下来,我们通过一个例子来理解 线性枚举。
2、举例说明
【例题1】给定一个单调不降的有序数组 arr 和 一个值 x,要求找到大于 x 的最小数的下标。
3、算法分析
我们从这个问题中提取几个关键字并分类如下:
1. 前提:单调不降、有序;
2. 条件:大于 x、最小数;
3. 返回结果:下标;
1)前提
前提就是问题给定时的初始数组需要满足的先天性条件,保证数据是能够符合这个前提的。这里的前提是 数组一定是有序的,且是单调不降的,即 数组下标大的数 不会比 数组下标小的数 更小。
2)条件
这个问题中的条件有两个:
1. 大于 x;
2. 值最小;
我们如果仔细分析一下这个问题,就可以发现,正因为这里的数组是单调不降的,所以,一旦满足 某个数大于 x,之后的所有数必然都满足 大于 x 这个条件。所以我们必然可以把数组分成两部分:一部分是 大于 x 的、一部分是 不大于 x 的。
3)返回结果
这里的返回结果要求是下标,而我们遍历操作也是通过遍历数组的下标进行的,所以找到满足条件的,返回下标即可。
4、画解枚举
接下来,我们通过一组实际的数据来解释这个问题:arr = [1, 3, 4, 6, 6, 6, 7, 8, 9]
1)划分
对于这个数组,当 x = 6 时,我们将数组分成两部分,大于 6 的部分用 绿色 表示,不大于 6 的部分用 红色 表示。这么表示的目的,主要是为了方便记忆,联想一下 红绿灯,绿色代表可以通行,即 "大于6" 这个条件满足;红色代表禁止通行,即条件不满足。
2)游标
设定一个游标,初始时指向数组的第 0 个元素(C语言中数组下标从 0 开始计数)。
游标,顾名思义,就是游动的下标。你也可以叫指针,我之所以没有称之为指针,是不想它和C语言中的指针概念混淆。
3)遍历
遍历就是判断当前游标指向的元素是否是绿色的,如果是绿色的直接返回,因为它一定是大于 x 且值最小的;如果不是,则增加游标的值,继续下一次判断,直到数组遍历完毕。如下图所示:
数字 7 就是我们要找到 大于 6 的最小数,它的下标为 6。
4)详解
int isGreen(int val, int x) { // (1)
return val > x;
}
int findFirstBiggerThan(int * arr, int arrSize, int x) {
int i;
for (i = 0; i < arrSize; ++i) { // (2)
if (isGreen(arr[i], x)) { // (3)
return i;
}
}
return arrSize; // (4)
}
(1) int isGreen(int val, int x) 这个函数代表条件是否满足,满足返回 1,否则返回 0;这里的条件便是 val > x。
(2) 下标从小到大,从 0 开始遍历数组 arr;
(3) 一旦遇到大于 x 的数,则返回它的下标,因为是下标从小往大遍历的,所以第一个找到满足条件的数一定是值最小的;
(4) 如果找不到,说明所有的数都是小于等于 x 的,直接返回数组长度;
5、举一反三
接下来,我们来看看线性枚举的其它几种问法。
【例题2】给定一个单调不降的有序数组如下:[1, 3, 4, 6, 6, 6, 7, 8, 9]。要求找到以下元素:
(1) > 6 的 最小数 的下标位置;
(2) ≥ 6 的 最小数 的下标位置;
(3) < 6 的 最大数 的下标位置;
(4) ≤ 6 的 最大数 的下标位置;
对于这四个问题,我们可以发现它们的答案如下所示:
1)大于 x 的最小数的下标
将数组按照条件进行划分,然后利用上文提到的 findFirstBiggerThan 函数求解即可。
2)大于等于 x 的最小数的下标
我们把问题做个变形,将问题变成找 大于等于 x 的最小数的下标(比之前的问题多了一个等于)。按照条件划分的结果应该是包含 6 本身的,所以如下图所示:
遍历数组的部分不变,只不过条件变成了 大于等于。C语言实现如下:
int isGreen(int val, int x) {
return val >= x; // (1)
}
int findFirstBiggerEqualThan(int * arr, int arrSize, int x) {
int i;
for (i = 0; i < arrSize; ++i) {
if (isGreen(arr[i], x)) {
return i;
}
}
return arrSize;
}
(1) 将原先的 > 号改成 >= 即可;
3)小于 x 的最大数的下标
上面两个问题能理解的话,我们再来看一个问题,如何找到 小于 x 的最大数的下标 ,要求下标最大,那么我们在枚举的过程中,如果发现一个大于等于 x 的数,那么后续都不用枚举了,并且需要返回这个数的前一个位置。条件划分如下图所示:
我们要做的是返回红色中的最大下标,C语言实现如下:
int isGreen(int val, int x) {
return val >= x; // (1)
}
int findLastSmallThan(int * arr, int arrSize, int x) {
int i;
for (i = 0; i < arrSize; ++i) {
if (isGreen(arr[i], x)) {
return i - 1;
}
}
return arrSize - 1;
}
(1) 大于等于 x 时, isGreen 成立;
(2) 由于我们要做的是返回红色中的最大下标,所以一旦遇到大于等于 x 的数(即绿色的情况),则返回它的前一个下标;
(3) 如果找不到,则返回 arrSize - 1,即所有数都是红色的,则最大下标就是数组的最后一个元素的下标;
4)小于等于 x 的最大数的下标
我们把问题继续做变形,将问题变成找 小于等于 x 的最大数的下标(比之前的问题多了一个等于)。划分如下图所示:
遍历数组的部分不变,只不过条件变成了 大于,我们要做的是返回红色中的最大下标,C语言实现如下:
int isGreen(int val, int x) {
return val > x; // (1)
}
int findLastSmallEqualThan(int * arr, int arrSize, int x) {
int i;
for (i = 0; i < arrSize; ++i) {
if (isGreen(arr[i], x)) {
return i - 1;
}
}
return arrSize - 1;
}
(1) 将原先的 >= 号改成 > 即可;
6、时间复杂度
以上的内容就是线性枚举的几种常见情况,也就是无脑遍历所有情况,并且在满足条件的第一时间退出循环,当数组长度为 n 时,算法的时间复杂度为 O(n),比较低效,有没有更加高效的算法呢?
接下来出场的,就是本文的主角 —— 二分枚举。
二、二分枚举
1、二分枚举定义
二分枚举,也叫二分查找,指的就是给定一个区间,每次选择区间的中点,并且判断区间中点是否满足某个条件,从而选择左区间继续求解还是右区间继续求解,直到区间长度不能再切分为止。
由于每次都是把区间折半,又叫折半查找,时间复杂度为 O(logn),和线性枚举的求解结果一直,但是高效许多,返回值可以是下标,也可以是元素本身。
2、举例说明
【例题3】只有两种颜色的数组 arr ,左边部分为红色用 0 表示,右边部分为绿色用 1 表示,要求找到下标最小的绿色元素的下标。
如图所示,下标最小的绿色元素的下标为 3,所以应该返回 3。
3、算法分析
1)目标
对于这个问题,当我们拿到这个数组的时候,第一个绿色位置在哪里,我们是不知道的,所以,现在的目标就是要通过二分枚举找到红色区域和绿色区域的边界。
2)游标
利用线性枚举的思路,我们引入游标的概念,只不过需要两个游标,左边一个红色游标,右边一个绿色游标。并且游标初始位置都在数组以外,对于一个 n 个元素的数组,红色游标初始位置在 -1,绿色游标初始位置在 n。
3)二分
我们将两个游标相加,并且除 2,从而得到游标的中点,并且判断中点所在位置的颜色,发现是绿色的,这说明从 中点游标 到 绿色游标 的元素都是绿色的。如下图所示:
于是,我们可以把 绿色游标 替换成 中点游标,如下图所示:
这样就完成了一次二分,区间相比之前,缩小了一半。注意,我们要求的解,一定永远在 红色游标 和 绿色游标 之间。
然后,我们继续将两个游标相加,并且除 2,从而得到游标的中点,并且判断中点所在位置的颜色,发现是红色的,这说明从 红色游标 到 中点游标 的元素都是红色的。如下图所示:
于是,我们可以把 红色游标 替换成 中点游标 ,如下图所示:
同样上述算法,再经过两次二分以后,我们得到了如下结果:
这个时候,这个时候 红色游标 和 绿色游标 的位置一定相差 1,并且 绿色游标 的位置就是我们这个问题要求的解。
4)时间复杂度
由于每次操作都是将区间减小一半,所以时间复杂度为 O(logn)。
4、源码详解
那么接下来,我们来看下,如何用 C语言来 实现这个问题。
1)条件判定
判断一个元素是绿色还是红色,我们可以单独用一个函数来实现,根据题意,当值为 1 时代表绿色,值为 0 时代表红色,C语言实现如下:
int isGreen(int val) {
return val == 1;
}
2)二分枚举模板
接下来的二分枚举模板可以解决大部分二分枚举的问题,请妥善保管。
int binarySearch(int * arr, int arrSize, int x) {
int l = -1, r = arrSize; // (1)
int mid;
while (r - l > 1) { // (2)
mid = l + (r - l) / 2; // (3)
if (isGreen(arr[mid], x)) // (4)
r = mid; // (5)
else
l = mid; // (6)
}
return r; // (7)
}
(1) l 代表 红色游标,r 代表 绿色游标;
(2) 当区间长度大于 2 的时候,二分缩小区间,这一步被称为 区间收敛;
(3) mid 为计算出来的区间 [l, r] 的中点;
(4) 判断区间中点对应的元素是 绿色 还是 红色 ;
(5) 如果 中点元素 是 绿色,则从 中点 到 r 的值都为 绿色,用 中点 替换 绿色游标;
(6) 如果 中点元素 是 红色,则从 l 到 中点 的值都为 红色,用 中点 替换 红色游标;
(7) 这个地方是模板需要变通的地方,如果需要返回红色边界,那么应该返回 l;反之,如果需要返回绿色边界,则应该返回 r。这个问题中,是后者。
5、细节解析
1)迭代的过程
整个二分的过程是一个不断迭代区间的过程,并且 红色游标 指向的元素始终是 红色 的;绿色游标 指向的元素始终是 绿色 的。迭代的过程就是不断向 红绿边界 逼近的过程。
2)结束条件
迭代结束时,红色游标 和 绿色游标 刚好指向 红绿边界,且区间长度为 2。
3)游标初始值
为什么 红色游标 初始值为 -1,绿色游标 初始值为 n ?
能否将 红色游标 初始化为 0,绿色游标 初始化为 n-1 ? 答案是否定的,试想一下,如果数据元素都是绿色,红色游标 初始化为 0 就违背了 " 红色游标 指向的元素始终是 红色 的 " 这个条件;反之,如果元素都是红色的,也有类似问题。
4)中点位置
由于中点的位置是需要去访问数组来获取值的,所以必须满足始终在 [0, n) 区间范围内。
中点位置计算公式为:
l 的最小值为 -1,r 的最小值为 l+2,所以 mid 的最小值就是:
r 的最大值为 n,l 的最大值为 r-2,所以 mid 的最大值就是:
综上所述,中点的下标位置始终在 [0, n) 区间范围内。
5)死循环
上面的程序模板是否会进入死循环?
我们可以这么来看,当区间为 2 时,循环结束。当区间为 3 时,它一定可以变成区间为 2 的情况,当区间为4时,一定可以变成区间为 2 或者 3 的情况,也就是任何一种情况下,区间一定会减小,并且当等于 2 时,循环结束。所以不会造成死循环。
索引存储结构和分块查找
索引表中的每一项称为索引项,索引项的一般形式是:(关键字,地址)
关键字唯一标识一个结点,地址为该关键字元素在数据表中的存储地址,整个索引表按关键字有序排列。
按关键字k的查找过程:
分块查找(块间有序,块内无序
块间有序:分成若干子表,要求每个子表中的数值都比后一块中数值小(但子表内部未必有序)。
将各子表中的最大关键字构成一个索引表,索引表中包含每个子表的起始地址(即头指针)。
二叉排序树(BST)
二叉排序树(Binary Sort Tree,简称BST)又称二叉查找(搜索)树,其定义为:二叉排序树或者是空树,或者是满足如下性质的二叉树:
若它的左子树非空,则左子树上所有结点值(默认为结点关键字)均小于根结点值。
若它的右子树非空,则右子树上所有结点值均大于根结点值。
左、右子树本身又各是一棵二叉排序树。
递归查找算法SearchBST()如下(在二叉排序树bt上查找关键字为k的记录,成功时返回该结点指针,否则返回NULL):
typedef struct node
{ KeyType key; //关键字项
InfoType data; //其他数据域
struct node *lchild,*rchild; //左右孩子指针
} BSTNode;
BSTNode *SearchBST(BSTNode *bt,KeyType k)
{
if (bt==NULL || bt->key==k) //递归出口
return bt;
if (k<bt->key)
return SearchBST(bt->lchild,k); //在左子树中递归查找
else
return SearchBST(bt->rchild,k); //在右子树中递归查找
}
int InsertBST(BSTNode *&p,KeyType k)
{ if (p==NULL) //原树为空, 新插入的记录为根结点
{ p=(BSTNode *)malloc(sizeof(BSTNode));
p->key=k;p->lchild=p->rchild=NULL;
return 1;
}
else if (k==p->key) //存在相同关键字的结点,返回0
return 0;
else if (k<p->key)
return InsertBST(p->lchild,k); //插入到左子树中
else
return InsertBST(p->rchild,k); //插入到右子树中
}
BSTNode *CreatBST(KeyType A[],int n) //返回树根指针
{ BSTNode *bt=NULL; //初始时bt为空树
int i=0;
while (i<n)
{ InsertBST(bt,A[i]); //将A[i]插入二叉排序树T中
i++;
}
return bt; //返回建立的二叉排序树的根指针
}
平衡二叉树(AVL)
若一棵二叉树中每个结点的左、右子树的高度至多相差1,则称此二叉树为平衡二叉树(AVL)。
平衡因子:该结点左子树的高度减去右子树的高度。
如果在一棵AVL树中插入一个新结点,就有可能造成失衡,此时必须重新调整树的结构,使之恢复平衡。我们称调整平衡过程为平衡旋转。
调整操作可归纳为4种情况。
函数类二分
x 的平方根
剑指 Offer II 072. 求平方根
有效的完全平方数
排列硬币
第一个错误的版本
H 指数 II
乘法表中第k小的数
数组类二分
二分查找
剑指 Offer 53 - I. 在排序数组中查找数字 I
在排序数组中查找元素的第一个和最后一个位置
寻找比目标字母大的最小字母
搜索插入位置
剑指 Offer II 068. 查找插入位置
区间内查询数字的频率
统计「优美子数组」
两数之和 II - 输入有序数组
剑指 Offer II 006. 排序数组中两个数字之和
和为s的两个数字
两数之和
统计有序矩阵中的负数
检查整数及其两倍数是否存在
排序矩阵查找
采购方案
早餐组合
交换和
分割数组的最大值
找出给定方程的正整数解
比较字符串最小字母出现频次
同时运行 N 台电脑的最长时间
二分答案
丑数 III
第 k 个缺失的正整数
尽可能使字符串相等
制作 m 束花所需的最少天数
最大连续1的个数 III
水位上升的泳池中游泳
剑指 Offer II 073. 狒狒吃香蕉
每个小孩最多能分到多少糖果
爱吃香蕉的珂珂
分配给商店的最多商品的最小值
花园的最大总美丽值
找出第 k 小的距离对