开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 5 天,点击查看活动详情
背景
年后,笔者恢复了刷力扣题的节奏,每周会刷 2~4 道题。在这个过程中,笔者对时间复杂度 O(logN) 的算法流程总是不大理解,于是专门去查阅了一些资料(尤其是 左程云老师 的算法课程,对笔者帮助很大),沉淀了知识,最终对 O(logN) 的计算有了深入的认识,现分享出来,希望对掘友们有所帮助🍺
为了解释清楚 O(logN) 时间复杂度的计算过程,笔者以 二分法 为例展开说明。
二分法
二分法是一个经典的算法,考虑这么一个查找问题:
给你一个升序的数组 nums,里面都是数字,再给你一个数字 target,请在 nums 里检查 target 是否存在,如果存在则返回 target 的下标,如果不存在则返回 -1。
暴力解决
这道题如果暴力解决的话,就是将 nums 从左到右遍历一遍,每走一步就比较当前数字和 target 是否相等,如果相等就返回 true,如果不相等就继续走(重复比较策略)..。如果走到末尾还没有相等的情况出现,说明 target 不在 nums 里,返回 false。
假设数据规模是 N。
对于这样的算法流程,考虑最坏情况,target 是数组的最后一个元素,那么就需要遍历 N 个元素才有结果,时间复杂度是 O(N)。
「有序」的利用
仔细研究此查找问题会注意到,升序这个条件在暴力解法里没有用到,这个条件是很关键的,有了它才能执行今天的主角 ---- 二分法。
二分法解决此查找问题的流程是这样的:
-
准备两个指针
L,R,分别指向数组开头和末尾。 -
计算
L,R的中间坐标M,规则是向下取整。 -
比较
nums[M]和target, 有3种情况:nums[M] === target,返回M即可。nums[M] < target,由于数组升序,M前面的元素只会更比target小,所以直接让L前进到M+1位置。nums[M] > target,由于数组升序,M后面的元素只会更比target大,所以直接让R后退到M-1位置。
-
重复
步骤2,3,直到返回M或者L和R错过去(L>R)。
计算复杂度
以数组长度 16 为例,执行前文所述二分查找的流程,假设最坏情况:
砍半次数 剩余待查找数据
0 16 砍一半,再对比 nums[M] 和 target, 此过程时间复杂度 O(1)
1 8 砍一半,再对比, O(1)
2 4 砍一半,再对比, O(1)
3 2 砍一半,再对比, O(1)
4 1 对比, O(1)
每次都是对半砍,总共砍了 4 次,每次砍都只是来一次 O(1) 的对比,所以这个流程的时间复杂度取决于砍半的次数。
计算砍半的次数也就是从 16 开始,要砍多少次才能到达1?
我们可以反过来想:从 1 开始,要乘以多少个2,才能到达 16 ?
1 2^0
2 2^1
4 2^2
8 2^3
16 2^4
要乘以多少个2,这个2的个数,就是右边这一列中2的指数。
求2的指数的数学公式是:以 2 为底,结果的对数,把结果(数据规模)从 16 上升到 N,公式就是:
而在算法中,以 2 为底, N 的对数简写为 logN,写成时间复杂度,那就是 O(logN)。
总结
本文以二分法入手,介绍了时间复杂度 O(logN) 的计算方法,二分查找在力扣上也有详细的讨论,地址是 力扣704.二分查找,掘友们有兴趣可以去查阅更多内容。
最后,附上笔者实现的二分查找的代码,希望大家多多指教:
function search(nums, target) {
let L = 0;
let R = nums.length - 1;
// L,R 相等时也要进来,因为 L/R 上一轮刚刚移动过,这一轮是在「还没检查的位置」。
while (L <= R) {
// 1. 不用 (L+R)/2, 是因为 L+R 可能会溢出。
// 2. 用 >> 1 即能「加快速度」,又实现了「向下取整」。
const M = L + ((R - L) >> 1);
if (nums[M] === target) {
return M;
}
else if (nums[M] < target) {
// M 位置的数一定比 target 小了,所以 L 直接前进到 M+1
L = M + 1;
}
else {
// M 位置的数一定比 target 大了,所以 R 直接后退到 M-1
R = M - 1;
}
}
// 二分查找完了也没找到
return -1;
};