本篇文章你能学到什么:
1.二分查找法的原理及 PHP 实现
2.选择排序的原理及 PHP 实现
3.快速排序的原理及 PHP 实现
4.三种算法的时间复杂度分析及比较
5.剖析 大 O 表示法
前言:聊聊为什么要学算法,我相信大多数人工作是很少用的到算法的,如果我问你为什么要学算法,普遍的回答一般说是为了面试,没错,很现实,但是其实算法不但但只是为了面试过关,同时也是可以增强思维能力,理解能力,好比我们读书考试,为什么要读书,为了考大学,为了找好工作,但是你有没有发觉,读书让一个人气质也发生了改变,对世界的理解也更加的透彻了。人之所以为人,就是因为思想,算法就是让你的思想更上一层楼的途径之一。废话不多说,来吧一起学吧!
本书内容参考资料《算法图解》,欲深入了解的同学可自行阅读该书。
原理:二分法可以说是最为简单的算法之一了,稍微学过点算法的人都应该知道,首先,它有一个前提,就是必须是有序的集合,为什么要有序,等我讲完你就清楚了
其实它更类似于生活中的一种排除法,猜数字的游戏可能大家都玩过,如果有1-100 让你猜一个数字,然后人家只会告诉你是偏大还是偏小或者是正确,你会怎么猜呢,老实的人估计会从1-100从头开始喊吧,但是聪明的你肯定不会这样,你可以先猜个中间数50,如果偏小,那么数字肯定在51-100之间,1-50就可以不用猜了,再猜个75,如果偏大,那么75-100也可以不用去猜了,依次递推,100个数字我们不需要老实的猜一百遍,而最多只需要猜 log2(100) 次即可,没错,这就是二分法的精髓,但是如果数字不是有序的,我们这种投机取巧的方式不就没办法用了吗,哈哈哈,就是这个道理
PHP实现代码,包括递归版和循环版,没用IDE手打的可能对不齐
= $high) return $low;
// 取中间的那个数
$middle = (int)($low + $high) /2;
//如果 这个值大于中间那个,那么不要前面那段了
if ($v > $arr[$middle]) {
$low = $middle;
}else if($v < $arr[$middle]){ //不要后面那段
$high = $middle;
}else{ //等于直接返回,就是查到了
return $middle;
}
return search($v,$arr,$low,$high);
}
/**二分查找-循环法 */
function serarch($v,$arr)
{
$low = 0;
$high = count($arr);
while(1) {
$middle = (int)($low + $high)/2;
if ($v > $arr[$middle]) {
$low = $middle;
}else if ($v < $arr[$middle]) {
$high = $middle;
}else {
$middle = $middle;
break; //找到了跳出循环
}
}
return $middle;
}
我们可以发现,每次执行都会将候选人砍掉一半,这样我们很容易明白,假设有 n 个候选人,我们最多要砍 log2(n) 次,就只会剩下一个了,所以它的时间复杂度是 O(log2(n)), 实际上,我们真正执行的次数要比这个小
原理:选择排序算法的思想就是,从一个数组中每次弹出一个最小元素放到一个新数组中,然后再剩下的数组中重复此操作。没看懂不要紧,听我细细说来,既然我们要排序,那么肯定是想要一个从小排到大(或从大排到小的)数组吧,我们可以这么想,假设有相同的五张纸片,分别写着 1,2,3,4,5,而且是乱序的。那么就有这么一种策略,首先我们先从这五个找到最小的那一个(最惨最多也是五次吧),找到 1 这张纸片后把它放到抽屉的最左边,然后就剩下 4 张了,再找最小的那个(最惨最多也是四次吧),找到 2 后我们把它放到 1 的右边,然后按照这种规律继续找,全部排完我们最多总共找了 5 + 4 + 3 + 2 + 1 = 15 次,这也是一种取巧的方式,妙处就是把候选人逐渐的变少(剔除对结果没有影响的元素)。比起无脑的循环(5 * 5 = 25次),大大减少了排序的次数,按照这种想法,我们很容易可以实现出这样的代码。
PHP 实现代码如下:
= $arr[$i]) {
$minIndex = $i;
}
} echo $minIndex;
return $minIndex;
}
/** 选择排序 -循环法 */
function selectSort(array $arr)
{
$newArr = [];
$length = count($arr);
// for 循环每次判断条件都会重新执行,如果用count($arr),$arr的改变会影响下一次循环
for ($i= 0; $i <$length; $i++) {
$minIndex = getMinNumIndex($arr);
$newArr[] = $arr[$minIndex];
if (count($arr) >1) array_splice($arr,$minIndex,1); //切割数组,目的是剔除那个元素
}
return $newArr;
}
这里我并没有去使用一个数组进行替换的方式去实现选排,而是用一个新的数组承载了每次获得的元素,这种增加空间复杂度的方式可以帮助我们更好的实现并理解这种算法,市面上也有一种交换元素位置的方式,把每次找到的元素都移到数组的最左边,实际上这种方式我觉得既增加的算法的复杂性,也并没有多大的优化空间,我们来判断一下,这种算法的时间复杂度吧,我们可以发现如果有 n 个元素,这种算法每次循环平均需要 (n + (n-1) + (n-2) + ...) /2 = n(n+1)/2= (n^2)/2 + 1/2 ,一般来说我们不计算常数,只考虑 n 的最大算值,即 n^2, 所以选择排序的时间复杂度为 O(n^2)
原理:快速排序是排序算法性能较好的一种,他的算法思维用来学习递归的思想是恰好符合的,那么何为快排呢,实际上他是一种分而治之的方式。
首先快排的第一步是选择一个基准点,这个基准点是关系到快排实际执行的效率的,但是一般来说我们不会刻意的去选择基准点,因为要选择一个优秀的基准点也是需要一定量的计算的,一般来说我们就用数组的第一个元素。我们可以先看看有三个元素的数组排序是怎么实现的,假设有这么一个数组:[5,2,6] ,我们选择第一个元素 5 作为基准值,把比 5 小的放到一个数组中,再把比 5 大的放到一个数组中,可以获得这样的两个数组和一个元素,[2], 5, [6], 此时们只要对它进行合并,就变成了 [2,5,6] ,这是快排的最简单的示例了,
接下来我们再复杂一下,假设数组是这样的:[5,2,7,6] ,按照前面的说法我们可以获得:[2], 5 [7,6],此时你会发现 [7,6] 并不是顺序的,因此我们要对 [7,6] 再按照规则分一下, 基准值是 第一个元素 7,可以获得 [6] , 7 , [] , 然我们用这三个代入替换掉前面的 [7,6] ,可以获得 [2] , 5 , [6] ,7 , [] ,将这些合并成一个数组,不就是 [2,5,6,7] 了,哈哈哈,这就是快排的原理,我们可以发现,获得的数组要不要进行下一次划分的决定条件时,该数组是否只有一个元素或者是一个空数组(基线条件),否则就会再执行一次,而且执行的步骤是完全一样的,这刚好符号递推思想的基线条件 和 递归条件,基线条件和递归条件是互斥的,基线条件满足就不需要再递归,递推条件满足就需要进一步递推 ,因此我们可以看看实现的代码。
PHP 实现代码如下:
$base) { //比基准值大的放右边
$right[]= $arr[$i];
}else{ // 比基准值小的放左边,即使是相等也可以放左边
$left[]= $arr[$i];
}
}
// 合并数组,对获得的左右两边数组进行递归操作
return array_merge(qSort($left),[$base],qSort($right));
}
我们想探究快排的时间复杂度前,需要先去明白两个概念,平均情况和最糟情况,因为快速排序的性能高度依赖我们选择的基准值,而且快速排序不会检查输入的数组是否已经有序,因此会有两种极端的情况,假设有个数组是这样的:[ 1,2,3,4,5,6,7,8 ]
我们分析一下选用不同的基准值标准会产生什么后果;
方案1:假如我们每次选中的基准值都是第一个元素,会产生如下的调用栈:
(图片来源于《算法图解》)
方案2:假设我们每次都选中间的数作为基准值,则会产生下面的调用栈:
(图片来源于《算法图解》)
我们可以发现,当我们选择方案 1 的时候,每次执行获得的两个子数组,总有一个是空的,这种情况下,会造成递推次数远远的增多,我们可以看到,实际上这个长度为 8 的数组实际上递归了 8 次,这种便是最糟的情况,这种的栈长为O(n)
当我们去选择方案 2 的时候,可以看到每次执行都会尽量分成两边相等个数的数组,就是尽可能的均匀分布,此时的调用栈长度应为 O(log2 n), 这时候你可能会问题, 数组长度为 8 ,栈长是 4,公式算出来不是 3吗? 这是为什么呢?实际上我们应该这么看,每次的拆分都是一个分成两个,如上图,第一次执行产生了两个数组,第二次执行,这两个数组又各自分成两个数组,所以实际上执行的次数 2^n ,所以程序方法调用次数应该是 log2(n) 次,
实际上这种栈长并不是递归的次数,那每次递归的时间复杂度是多少呢,实际上在调用栈的每一层都会涉及对应形参数组的长度(n),因此,快排的算法的时间复杂度是 递归次数 * 每次递归的时间
此种情况下,快排在平均情况和最糟情况下的时间复杂度分别为 O(n)* O(log2 n) = O(n*log2 n) 和 O(n) * O(n) = O(n^2)
下面放出三种算法的时间复杂度横线图:
(图片来源于《算法图解》)
其中横坐标是时间,纵坐标是数组的长度,先看看两种查找算法,我们可以发现二分法的算法时间随着 n 的增加并不会像简单循环查找拥有那么大的 增速,而两种排序算法,在 n 的逐渐增大,快排也逐渐体现出比选择排序更低的增速优势
实际上,这只是一种大概的图示,可以让我们更好的理解算法之间的差距,他们并不是完全准确的
实际上,在用大 O 表示法O(n), n 实际上是 c * n,其中 c 是一个固定的时间量,被称为常量,实际上这个常量可能会考虑到计算机的CPU运算率等很多因素,例如同样的算法,在不同的计算机跑时间也是不同的,但是实际上,对好的算法结果差距极小,你可以将 n 和 10n代入快排和选排中,画出的曲线几乎接近。
算法之路漫长,是一场马拉松,且行且珍惜!