代码开场
int SeqSearch(int *a, int n, int key) {
for (int i = 1; i <=n; i++) {
if (a[i] == key) {
return i;
}
}
return 0;
}
这是一个很简单的一个查找代码了,很基础,没有什么难度。时间复杂度:O(n)。查找过程是从数组中第一个记录开始,逐个进行记录的关键字和给定值比较,如果某个记录与key相等,查找成果;如果直接到最后一个,则数组中没有记录,查找失败。
1、顺序表查找
上面的代码的查找算法,我们通常叫做顺序表查找算法,又叫做线性查找。
1.1 优化
上面的代码看似简单,其实还可以进行优化。优化的点在于:每次循环时都需要对i是否越界进行判断。我们可以设置一个哨兵:
int SeqSearch2(int *a, int n, int key) {
int i = n;
a[0] = key;//把数组下标0位置当做哨兵
while (a[i] != key) {//循环从数组尾部开始
i--;
}
return i;//如果返回0,说明没有找到
}
从数组尾部开始查找,把0位置设置为key,也就是哨兵。如果a[i]中有key,返回i;如果没有返回0,也就是返回哨兵的位置。
这个时间复杂度和普通版一样,都是O(n),很多朋友可能会问,这也没有啥优化的啊?其实在数据量比较多的时候,这种方式会提高很大的效率。这也是一个很好的编程技巧哦!当然,哨兵不一定要在数组开始的地方,也可以放在末尾。
2、有序表查找
之前的例子对数组是否是有序没有要求。如果数组有序,在查找中是很有帮助的。
2.1 折半查找
折半查找又称二分查找。基本思想:有序表中,取中间值作为比较对象。若key等于中间值,则找到了;若小于中间值,则在以中间值进行分割的左半区域进行查找;若大于中间值,则在右半区域进行查找。如图:
代码
int BinarySearch(int *a, int n, int key) {
int low = 1;
int high = n;
while (low <= high) {
int mid = (low + high) / 2;
if (key < a[mid]) {
high = mid - 1;
} else if (key > a[mid]) {
low = mid + 1;
} else {
return mid;
}
}
return 0;
}
代码依然很简单,但是效率很高。时间复杂度O(log n)。这个可远远的好于顺序表查找,这个就是有序的好处。
对于静态查找,一次排序后再无变化,这样的算法是非常好的。但对于频繁执行插入或者删除操作的数据来说,维护有序排序也是一个不小的工作量,这种情况下就不建议使用了。
现在有一个问题,为什么一定要折半呢?如果在一个很大的有序数组中,存放0~10000的数据,我只要查找5,我们自然会想到从头开始是查找是比较快的方法。折半查找还是有优化的空间的。我们继续学习插值查找。
2.2 插值查找
折半查找中获取中间值代码
int mid = (low + high) / 2;
进行一个简单的转换
int mid = low + (high-low) / 2;
mid等于最低下标+最高下标与最低下标差值的一半。我们的目的是修改这个一半(二分之一,1/2),改进公式:
a[11]={0,1,16,24,35,45,65,78,88,94,99}
low=1
high=10
key=16
如果用折半查找,需要四次。如果用公式公式计算:
代码
int InterpolationSearch(int *a, int n, int key) {
int low = 1;
int high = n;
while (low <= high) {
//公式
int mid = low + (high - low) * (key - a[low]) / (a[high] -a[low]);
if (key < a[mid]) {
high = mid - 1;
} else if (key > a[mid]) {
low = mid + 1;
} else {
return mid;
}
}
return 0;
}
通过上面的例子可以看出,插值查找对于数据是平均分配的情况下,要由于折半查找很多。但是如果遇到分布不均匀的,例如:{0,1,2,2000,2001,......,9999997,9999998}。这样的数据,就不合适了。
2.3 斐波那锲查找
斐波那锲查找是利用黄金分割点原理来实现的。
代码
int F[100];
int Fibonacci_Search(int *a, int n, int key) {
//初始最低下标
int low = 1;
//初始最高下标
int high = n;
//根据数组个数n,获取斐波那契个数组下标。k对应的值,就是在a数组中个数
int k = 0;
while (n > F[k]-1) {
k++;
}
//对数组个数按照斐波那契的值补齐,用最后一个数组元素值补齐
for (int i = n; i < F[k]-1; i++) {
a[i] = a[n];
}
while (low <= high) {
//获取中间点的值
int mid = low + F[k-1]-1;
if (key < a[mid]) {//主键小于中间值
high = mid-1;//调整最高下标
k = k-1;//调整k,用于不断缩小范围
} else if (key > a[mid]) {//主键大于中间值
low = mid + 1;//调整最低下标
k = k-2;//调整k
} else {
if (mid <= n) {//正常范围
return mid;
} else {//说明是补齐的部分,直接返回n
return n;
}
}
}
return 0;
}
解读
- 程序开始,a={0,1,16,24,35,47,59,62,73,88,99},n=10,key=59,F={0,1,1,2,3,5,8,13,21,34,...}
- 数组长度n对应的斐波那契数列中的位置,F[6]< n < F[7],得到k=7
- F[k]=F[7]=13,而a的长度n=10,需要对a数组进行扩展,也就是增加a[11],a[12]。我们将a中的最大值,也就是a[10]的值99赋给a[11],a[12]
- 和折半类似,开始循环
- 获取mid值,mid = low + F[k-1]-1 = 1+F[7-1]-1=1+8-1=8
- key=59,a[mid]=a[8]=73,主键小于中间值,调整,high=mid-1=8-1=7和k=k-1=6
- 再次循环,mid = low + F[k-1]-1 = 1+F[6-1]-1=1+5-1=5。a[mid]=a[5]=47<key=59,调整,low=mid+1=5+1=6,k=k-2=4。注意此时是k-2。
- 再次循环mid = low + F[k-1]-1 = 6+F[4-1]-1=6+2-1=7。a[mid]=a[7]=62>key=59,调整,high=6,k=4-1=3
- 再次循环,mid=6+F[3-1]-1=6。此时a[6]=59=key,返回6,程序运行完成。
问题
- 为什么对数组要进行补充? 如果key=99,此时查找循环第一次,mid=8与上面例子相同。第二次循环,mid=11,a[11]数组越界了。我们是用斐波那锲数列作为分割值,所以a数组的长度要满足分割值对应的长度就得进行补充。这就是为什么要对数组进行补齐的原因。
- k-1和k-2是什么意思?
斐波那锲数列中的值F[k]=F[k-1]+F[k-2],所以如图:
当key<a[mid]时,新范围是low~mid-1,此时的范围个数为F[k-1]-1个;当key>a[mid]时,新范围是mid+1~high,此时范围F[k-2]-1个。也就是说,查找的记录在右侧,左侧的数据都不用再查找了。
优缺点
时间复杂度上与折半一样,都是O(log n)。但平均性能上要由于折半查找。会有一种极端的情况,如果key=1,那么市政都处于左侧半区查找,这种情况效率要低于折半查找。
还要一点,在求mid的时候,折半查找进行了加减乘除运算(mid = low + (high-low) / 2),而斐波那契查找只进行了加减法(mid = low + F[k-1]-1),在海量数据查找时,这种细微的差别可能会影响最终的查找效率。