【数据结构与算法】二分查找

145 阅读3分钟

1. 二分查找的概念

二分查找,也叫折半查找。其思想是:对一个有序的数据集合, 每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为0。

2. 二分查找的时间复杂度

假设数据大小是n,每次查找后数据都会缩小为原来的一半,也就是会除以2。被查找区间的大小是n,n/2,n/4,....n/2^k。当n/2^k为1时,k的值就是总共缩小的次数。而每次缩小操作只涉及两个数据的大小比较,所以,经过了k次区间缩小操作,时间复杂度就是O(k)。通过计算n/2^k = 1,求得 k= log2n(以2为底,n的对数),所以时间复杂度就是O(logn)。即二分查找的时间复杂度是对数时间复杂度。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级O(1)的算法还要高效。因为即便n非常大,对应的logn也很小。比如n等于2的32次方。大约是42亿。也就是说,如果我们在42亿个数据中用二分查找一个数据,最多需要比较32次。但是用大O标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1)有可能表示的是一个非常大的常量值,比如O(1000),O(10000)。所以,常量级时间复杂度的算法有时候可能还没有O(logn)的算法执行效率高。

3. 二分查找的递归与非递归实现

LeetCode-704-二分查找

题目描述:
给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target,
写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1。

非递归实现

C语言版本

int search(int* nums, int numsSize, int target){
    if (nums == NULL || numsSize == 0) {
        return -1;
    }
    int low = 0;
    int high = numsSize - 1;
    int mid;
    while (low <= high) { // 结束的条件是 low > high
        mid = low + (high - low) / 2;
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            low = mid + 1;
        } else {
            high = mid - 1;
        }
    }
    return -1;
}

这里需要注意几点:

  1. 为什么while循环的条件中是 <=,而不是 < ? 因为初始化 high = numsSize - 1,即最后一个元素的索引,而不是numsSize。这两者的区别是前者的搜索范围相当于两端都是闭区间[low, high],而后者相当于是左闭右开区间[low, high),因为索引numsSize是越界的。

  2. 为什么 low = mid + 1和high = mid - 1? 因为我们上面的代码搜索区间是两端闭区间[low, high],所以当发现mid不是我们要找的target时,由于mid已经找过了,所以需要从这个搜索范围中扣除。因此需要去[mid + 1, high]或者[left, mid - 1]中去查找。

  3. 上面我们mid的取值是 mid = low + (high - low) / 2。为什么不用 mid = (low + high) / 2呢? 因为如果low和high比较大的话,两者之和就有可能会溢出。所以将mid的计算方式改成 mid = low + (high - low) / 2。更进一步,我们还可以优化,将这里除以2的操作转化成位运算 mid = low + ((high-low)>>1)。因为相比除法运算,计算机处理位运算要快得多。

C语言优化版本

int search(int* nums, int numsSize, int target){
    if (nums == NULL || numsSize == 0) {
        return -1;
    }
    int low = 0;
    int high = numsSize - 1;
    int mid;
    while (low <= high) { // 结束的条件是 low > high
        mid = low + ((high - low) >>1);
        if (nums[mid] == target) {
            return mid;
        } else if (nums[mid] < target) {
            low = mid + 1;
        } else {
            high = mid - 1;
        }
    }
    return -1;
}

最后再给出一个Go语言版本:

Go语言版本

func search(nums []int, target int) int {
    low := 0
    high := len(nums) - 1
    var mid int
    for low <= high {
        mid = low + ((high-low) >> 1)
        if nums[mid] == target {
            return mid
        } else if nums[mid] < target {
            low = mid + 1
        } else {
            high = mid - 1
        }
    }
    return -1
}

递归实现

C语言实版本

int searchInner(int *nums, int low, int high, int target) {
    if (low > high) {
        return -1;
    }
    int mid = low + ((high - low) >> 1);
    if (nums[mid] == target) {
        return mid;
    } else if (nums[mid] < target) {
        return searchInner(nums, mid + 1, high, target);
    } else {
        return searchInner(nums, low, mid - 1, target);
    }
}

int search(int* nums, int numsSize, int target) {
    return searchInner(nums, 0, numsSize - 1, target);
}

4. 二分查找的局限性

  1. 二分查找依赖的是顺序表结构,即数组
  2. 二分查找针对的是有序数据,因此只能用在插入、删除操作不频繁,一次排序多次查找的场景中。
  3. 数据量太小不适合二分查找,与直接遍历相比效率提升不明显。但是有一个例外,就是数据之间的比较操作非常费时时,比如数组中存储的都是长度超过300的字符串,那还是使用二分查找来尽量减少比较操作吧。
  4. 数据量太大也不适合二分查找,因为数组需要连续的空间,若数据量太大,往往找不到存储如此规模数据的连续内存空间。

5. 二分查找变形问题

查找第一个值等于给定值的元素 && 查找最后一个值等于给定值的元素

LeetCode-34-在排序数组中查找元素的第一个和最后一个位置

题目描述:
给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
如果数组中不存在目标值 target,返回 [-1, -1]

题解,这里找右边界和左边界是类似的,这里只讲一个左边界:

  • 如果当前看到的元素恰好等于 target,那么当前元素有可能是target出现的第1个位置,由于我们要找第1个位置,此时我们应该向左边继续查找。当然, 我们此时可以记下这个元素的位置;
  • 如果当前看到的元素严格大于target,那么当前元素一定不是要找的 target出现的第1个位置,第1个位置肯定出现在mid的左边,因此就需要在 [left, mid - 1]区间里继续查找; 如果当前看到的元素严格小于target,那么当前元素一定不是要找的 target出现的第1个位置,第1个位置肯定出现在mid的右边,因此就需要在 [mid + 1, right] 区间里继续查找。

总结一句话就是:如果查找左边界值,就压缩待查找的区间右边界;如果查找右边界值,就压缩待查找的区间左边界。 C语言版本

/**
* Note: The returned array must be malloced, assume caller calls free().
*/
int searchLeftRange(int* nums, int numsSize, int target) {
   int left = 0;
   int right = numsSize - 1;
   int mid;
   int result = -1;
   while (left <= right) {
       mid = left + ((right - left) >>1);
       if (nums[mid] == target) {
           result = mid;
           right = mid - 1; 
       } else if (nums[mid] < target){
           left = mid + 1;
       } else {
           right = mid - 1;
       }
   }
   return result;
}

int searchRightRange(int* nums, int numsSize, int target) {
   int left = 0;
   int right = numsSize - 1;
   int mid;
   int result = -1;
   while (left <= right) {
       mid = left + ((right - left) >>1);
       if (nums[mid] == target) {
           result = mid;
           left = mid + 1; 
       } else if (nums[mid] < target){
           left = mid + 1;
       } else {
           right = mid - 1;
       }
   }
   return result;
}

int *searchRange(int* nums, int numsSize, int target, int* returnSize){
   if (returnSize == NULL) {
       return NULL;
   }
   int *result = (int *)malloc(sizeof(int) * 2);
   if (result == NULL) {
       return NULL;
   }
   result[0] = -1;
   result[1] = -1;
   (*returnSize) = 2;
   if (nums == NULL || numsSize <= 0) {
       return result;
   }


   int leftPos = searchLeftRange(nums, numsSize, target);
   int rightPos = searchRightRange(nums, numsSize, target);
   result[0] = leftPos;
   result[1] = rightPos;
   return result;
}

查找第一个大于等于给定值的元素

题目描述:
给定一个有序的数组,查找第一个大于等于给定值的元素的下标。例如对于数组[3,4,6,7,10].如果查找第一个大于等于5的元素,则是6,所以返回下标2

其实仔细想想,这个题目就是查找左边界。所以和【查找第一个值等于给定值的元素】那题的解题思路是一样的,只是那题是在等于目标值时,更新结果。但是本题则是在大于等于目标值时更新。具体请见代码:

// 查找第一个大于等于target的值的下标(相当于查找左边界那题)
int searchFirstGETargetIndex(int *nums, int numsSize, int target) {
    int left = 0;
    int right = numsSize - 1;
    int mid;
    int result = -1;
    while (left <= right) {
        mid = left + ((right - left) >>1);
        if (nums[mid] >= target) {
            result = mid;
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return result;
}

查找最后一个小于等于给定值的元素

与上题类似,这个题目其实是【查找最后一个值等于给定值的元素】的变形。

// 查找最后一个小于等于target的值的下标(相当于查找右边界那题)
int searchLastLETargetIndex(int *nums, int numsSize, int target) {
    int left = 0;
    int right = numsSize - 1;
    int mid;
    int result = -1;
    while (left <= right) {
        mid = left + ((right - left) >>1);
        if (nums[mid] <= target) {
            result = mid;
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    return result;
}

参考文献

time.geekbang.org/column/intr… github.com/labuladong/… leetcode-cn.com/tag/binary-…