Go Book / 5 Data Structure & Algorithms / 16 基本查找算法:二分查找算法

16 基本查找算法:二分查找算法

二分查找算法

原理

二分查找算法也叫折半法查找法,要求待查找的列表必须是按关键字大小有序排列的顺序表。 查找过程如下所示: (1)将表中间位置记录的关键字与查找关键字比较,如果两者相等则表示查找成功;否则利用中间位置记录将表分成前、后两个子表。 (2)如果中间位置记录的关键字大于查找关键字,则进一步查找前一个子表,否则查找后一个子表。 (3)重复以上过程,一直到找到满足条件的记录为止时表明查找成功。 (4)如果最终子表不存在,则表明查找不成功。

性能分析

接下来用平均查找长度来分析二分查找算法的性能,我们可以使用一个被称为判定树的二叉树来描述二分查找过程。首先验证树中的每一个节点对应表中的一个记录,但是节点值不是用来记录关键字的,而是用于记录在表中的位置序号的。根节点对应当前区间的中间记录,左子树对应前一个子表,右子树对应后一个子表。当折半查找成功时,关键字的比较次数不会超过判定树的深度。因为判定树的叶节点和所在层次的差是1,所以n个节点的判定树的深度与n个节点的完全二叉树的深度相等,都是(log2 n)+1。这样,折半查找成功时,关键字比较次数最多不超过(log2 n)+1。相应地,当折半查找失败时,其整个过程对应判定树中从根节点到某个含空指针的节点的路径。所以当折半查找成功时,关键字比较次数也不会超过判定树的深度(log2 n)+1。可以假设表的长n=2h -1,相应的判定树一定是深度为h的满二叉树,log2 (n+1)。在此假设将长度为n的表分成b块,每块含有s个元素,即b=n/s。又假设每个记录的查找概率相等,则折半查找成功时的平均查找长度为:

二分查找算法性能分析.png

二分查找方法具有比较次数少、查找速度快及平均性能好的优点。缺点是要求待查表为有序表,且插入、删除困难。因此,折半查找方法适用于不经常变动而查找频繁的有序列表。

算法实现
说明:

元素必须是有序的,如果是无序的则要先进行排序操作。

基本思想:

二分查找基于分治思想,在实现上可以使用递归或迭代,首先用给定值k先与中间结点的关键字比较,中间结点把线形表分成两个子表,若相等则查找成功;若不相等,再根据k与该中间结点关键字的比较结果确定下一步查找哪个子表,这样递归进行,直到查找到或查找结束发现表中没有这样的结点。

复杂度分析: 最坏情况下,关键词比较次数为log2(n+1),且期望时间复杂度为O(log2n);

注:二分查找的前提条件是需要有序表顺序存储,对于静态查找表,一次排序后不再变化,二分查找能得到不错的效率。 但对于需要频繁执行插入或删除操作的数据集来说,维护有序的排序会带来不小的工作量,那就不建议使用。

Go语言描述

最简单的版本:即序列中忽略重复元素的位置情况:

// 迭代版本
func BinarySearch1(arr []int, v int) int {
	start, end := 0, len(arr)-1
	for start <= end {
		mid := start + (end-start) / 2
		// fmt.Println("mid:",mid)
		if arr[mid] == v {
			return mid
		} else if arr[mid] > v {
			// 左边
			end = mid - 1
		} else {
			// 右边
			start = mid + 1
		}
	}

	return -1
}

// 递归版本
func BinarySearch2(arr []int, v, start, end int) int {
	if start <= end {
		mid := (start + end) / 2
		// fmt.Println("mid:",mid)
		if arr[mid] == v {
			// 找到
			return mid
		} else if arr[mid] > v {
			// 左边递归
			return BinarySearch2(arr, v, start, mid-1)
		} else {
			// 右边递归
			return BinarySearch2(arr, v, mid+1, end)
		}
	}
	return -1
}

二分查找算法的变体

以上的算法实现是没考虑重复元素的严格情况的,如需考虑重复元素且返回特定的索引,有以下变体:

变体一:查找第一个值等于给定值的元素
// 变体一:查找第一个值等于给定值的元素
func BinarySearch3(arr []int, v int) int {
	start, end := 0, len(arr)-1
	for start <= end {
		mid := start + (end-start)/2
		// fmt.Println("mid:",mid)
		if arr[mid] == v {
			if mid == 0 || arr[mid-1] != v {
				return mid
			} else {
				end = mid - 1
			}
		} else if arr[mid] > v {
			// 左边
			end = mid - 1
		} else {
			// 右边
			start = mid + 1
		}
	}

	return -1
}
变体二:查找最后一个值等于给定值的元素
// 变体二:查找最后一个值等于给定值的元素
func BinarySearch4(arr []int, v int) int {
	start, end := 0, len(arr)-1
	for start <= end {
		mid := start + (end-start)/2
		if arr[mid] == v {
			if mid == end || arr[mid+1] != v {
				return mid
			} else {
				end = mid - 1
			}
		} else if arr[mid] > v {
			// 左边
			end = mid - 1
		} else {
			// 右边
			start = mid + 1
		}
	}

	return -1
}

关于二分查找算法的局限性

二分查找的时间复杂度是O(logn),查找数据的效率非常高。不过,并不是什么情况下都可以用二分查找,它的应用场景是有很大局限性的。那 什么情况下适合用二分查找,什么情况下不适合呢?

  • 首先,二分查找依赖的是顺序表结构,简单点说就是数组。 那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。我们在线性表讲过,数组按照下标随机访问数据的时间复杂度是O(1),而链表随机访问的时间复杂度是O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。 二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。

  • 其次,二分查找针对的是有序数据。 二分查找对这一点的要求比较苛刻,数据必须是有序的。如果数据没有序,我们需要先排序,排序的时间复杂度最低是O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排 序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据呢?对于这种需求就需要特定的数据结构了,如二叉查找树就是针对这种需求的。

  • 再次,数据量太小不适合二分查找。 如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。比如我们在一个大小为10的数组中查找一个元素,不管用二分查找还是顺序遍历,查 找速度都差不多。只有数据量比较大的时候,二分查找的优势才会比较明显。 不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过300的字符串,如此长 的两个字符串之间比对大小,就会非常耗时。我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势。

  • 最后,数据量太大也不适合二分查找。 二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有1GB大小的数据,如果希望用数组来存储,那就需要 的连续内存空间。 注意这里的“连续”二字,也就是说,即便有2GB的内存空间剩余,但是如果这剩余的2GB内存空间都是零散的,没有连续的1GB大小的内存空间,那照样无法申请一个1GB大小的数组。而我们的二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。