算法入门

一.算法的定义

任何代码片段都可以被称作是算法,这也就是说算法其实就是完成一组任务的指令.算法的优点在于要么速度很快,要么解决一些很有趣的问题,要么兼而有之.并且算法可以应用于任何编程语言中.

二.什么人适合学算法

学算法的人必须要懂得一定的数学知识,具体点就是线性代数的知识.只要你能做出这样一道题,那么恭喜你,可以学算法.

f(x) = x * 10;f(5) = ?;

三.二分查找

假设存在一个有序列表,比如1,2,3...100.如果我要你猜测我心中所想的数字是这个有序列表中的哪个数字,你会怎么猜呢?

最简单的办法就是从1到100挨着猜,这样一算,那么很明显,假如我想的是数字100,你有可能就要猜100次.如此一来,岂不是很浪费时间.

那么,同样的道理,如果在一行有100个字符的字符串中查找某个字母,你是不是也要查找100次呢?

其实有更快速的办法,就第一个例子而言,试想,如果我们第一次就把100拆一半,也就是50,然后猜测50,根据我的回答来做下一步的确定.也许我回答小了,那么猜测的范围就会在50到100之间了,如果是大了,那么猜测的范围也就到了1到50之间,依次类推,我们把每次猜测都分成一半,即100 -> 50 -> 25 -> 13->7->4->2->1,如此一来,我们最多猜7次就能猜中我心中想的数字.

像这样的把元素分一半来查找就叫二分查找.而通过二分查找实现的算法就叫二分算法,简称二分法.

一般地,我们把包含n个元素的列表,用二分查找最多需要log2^n步.

也许你可能不记得对数的概念了,但你应该记得幂的概念.而对数log10^100相当于将多少个10的乘积结果为100.答案自然是2个,即10*10=100.因此log10^100 = 2;

总的来说,对数就是幂运算的逆运算.

仅当列表有序的时候,我们才可以用二分法来进行查找吗?那倒不一定,其实我们可以将这些列表的元素存储在一个数组中,就好像,我们把一个东西放在桶里一样,然后给这些桶标上顺序,而顺序则是从0开始编号的,第一个桶是0,第二个桶是1,依次类推.

比如有这样一个有序数组[1,2,3...100];我想要查找75这个数字的索引,很明显75这个数字对应的索引就是74.我们可以使用二分法编写代码,如下(这里以JavaScript代码为例,其它语言的代码也可以的):

var arr = [];
for(var i = 1;i <= 100;i++){
    arr.push(i);
}
var checknum = 75;
//定义第一个索引与最后一个索引
var low = 0;
var high = arr.length - 1;
//我们查找数字只能在两者之间查找,使用循环,只要范围没有缩小到只剩一个元素,都可以继续使用二分法拆成一半来查找
while(low <= high){
    //索引取一半,如果不是整数则向下取整
    var center = Math.floor((low + high) / 2);
    //最终结果一定是索引所对应的元素
    var result = arr[center];
    //判断结果是否等于想要的结果
    if(result === checknum ){
        console.log(center);
    }else if(result < checknum){
            //如果取得索引对应元素值小于猜的值,则修改最低索引的值,以缩小猜测范围
            low = center + 1;
    }else if(result > checknum){
            //如果取得索引对应元素值大于猜的值,则修改最大索引的值,以缩小猜测范围
            high = center - 1;  
    }else{
        console.log(null);
    }
}

得出最终结果就是74.

我以如下图来展示上例代码的算法:

数组和数组元素对应的值.如下:

function  getArrIndex(arr,result){
    var low = 0,high = arr.length - 1,center = Math.floor((low + high) / 2);
    while(low <= high){
        if(arr[center] === result){
            return center;
        }else if(arr[center] < result){
            low = center + 1;
        }else if(arr[center] > result){
            high = center - 1;
        }else{
            return '没有这个索引值';
        }  
    } 
}
//测试
getArrIndex([1,4,6,8,10],6);//2

四.大O表示法

在前面,我总结到二分算法,并且在二分算法中也提到过运行时间.一般说来,我们在写程序时都会选择效率最高的算法,以最大限度的减少运行时间或者占用空间.

前面二分算法中说到,如果挨着简单的查找从1到100的列表,最多需要猜测100次,如果列表的长度更大呢?比如是1亿,那我们就要猜测1亿次.换句话说,最多需要猜测的次数与列表长度相同,这样所用到的时间,我们称之为线性时间.

而二分查找则不同,100次我们仅需猜测log2^100 ≈ 7次.而如果是1亿,我们也只需猜测log2^100000000 ≈ 26次.这样通过二分查找所需要的时间,我们称之为对数时间.

我们可以利用一种特殊的表示法来表示这种运行时间,即大O表示法.大O表示法指出算法的速度有多快.

在了解什么是大O表示法之前,我们先来看一个例子:

假设有100个元素的列表,我们使用二分查找大约需要7次(因为log2^100≈7),如果每一次大约为1毫秒.二分查找就需要7毫秒,而简单查找呢?简单查找就需要100毫秒,大约是二分查找的15倍左右.

那么假如有1亿个元素的列表呢,使用简单查找就是大约1亿毫秒,而二分查找则需要26毫秒(log2^100000000)左右就可以了.如此一来,简单查找比二分查找慢的多,而且简单查找足足是二分查找的将近400万倍.

这说明一个什么问题呢?

这说明算法的运行时间是以不同的速度增加的,也就是说随着元素的增多,二分查找所需要的额外时间并不多,而简单查找却要多很多.我们把这种情况称之为增速.仅仅知道算法的运行时间是不行的,我们还需要知道运行时间随着列表的增加而增加,即增速,大O表示法的用武之地就在于此.

一般的,拥有n个列表,简单查找需要执行n次操作,我们用大O表示法表示为O(n).当然单位不一定是毫秒或者秒,大O表示法也就指出了算法的增速.而使用二分查找,我们可以表示为O(log2^n).

再比如一个示例:假设我们要画一个16X16的网格图,简单查找就是一个一个的画,结果需要画256次.而如果我们把一张纸对折,再对折,再对折,再对折,每对折一次,格子数就会增加,对折一次画一次,如此一来,顶多需要log2^256,也就是8次就可以画出来.

我们用大O表示法,就可以表示成:简单查找为O(n)的运行时间,而O(log2^n)则是二分查找所需时间.

大O表示法指出了最糟情况下的时间.什么意思呢?再看一个示例:

如果你要在一本书中使用简单查找查找一篇文章,所需要的时间就是O(n).这意味着在最糟糕的情况下,必须查找一本书中的每一页.如果要查找的文章刚好在第一页,一次就能找到,那么请问简单查找的运行时间是O(1)还是O(n)呢?
答案自然是O(n)呢.因为大O表示法指出了最糟糕情况下的运行时间.如果只用1次就能翻到,那这是最佳情况.

从这些案例当中,我们得出了如下结论:

1.算法的速度指的并非时间,而是增速
2.谈论算法的速度时,我们应该说的是随着速度的增加,运行时间将以什么样的速度增加
3.算法的运行时间用大O表示法表示
4.O(log2^n)比O(n)快,当搜索的元素越多,前者快的越多

事实上,还有另一种算法.即O(!n).也就是阶乘算法。来看一个案例:假如一个人要去4个城市旅行,并且还要确保旅程最短.利用排列与组合的知识得出,前往4个城市大约需要执行24次操作,而如果是5个城市,则大约需要执行120次操作.也就是说随着城市的增加,大约需要执行!n次操作.虽然这确实是一种算法,但这种算法很糟糕。

五.选择排序算法

在理解选择排序算法的原理之前,我们需要了解大O表示法,数组与链表等概念。

经常与计算机接触,相信都会听到内存这一个词,那么何为内存呢?我们用一个很形象的比喻来说明这个问题。

假设有一个柜子,柜子有很多抽屉,每个抽屉能放一些东西。也就是说,当我们往抽屉里放东西的时候,柜子就保存了这些东西。一个东西放一个抽屉,两个东西放两个抽屉。计算机内存的工作原理就如此,计算机的内存更像是许多抽屉的集合。

需要将数据存储到计算机中,你会请求计算机提供存储空间,然后计算机就会给你一个存储地址。在存储多项数据的时候,有两种数据结构,计算机可以存储,那便是数组和链表。

数组就是一系列元素的列表集合。比如,你要写一个待办事项的应用程序(前端术语也可以说是todolist)。那么你就需要将许多待办事项存储到内存中。使用数组意味着内存是紧密相连的,为何如此说呢?

数组的元素自带编号,每个元素的位置,我们也叫做索引。例如:

[10,20,30,40]这一个数组,元素20的索引或者说所处的位置是1。因为数组的编号都是从0开始的,这可能会让很多新手程序员搞不明白。几乎所有编程语言对数组的编号都是从0开始的,绝对不止JavaScript这一门语言。

假设,你要为以上[10,20,30,40]再添加一个元素,很明显,利用JavaScript提供的数组方法,你只能在之前或者最末又或者中间插入元素。但在内存中却不是这样。还是用一个比喻来说明。

相信每个人都有看电影的经历,试想当你到了电影院之后,找到地方之后就坐下,然后你的朋友来了,本想靠着你坐,但是靠着你的位置都被人占领了。你的朋友就不得不转移位置,在计算机中,请求计算机重新分配一个内存,然后才能转移到另一个位置。如此一来,添加新元素的速度就会很慢。

那么,有没有办法解决呢?

似乎,很多人都这样做过,由一个人占领位置之后,然后把旁边的预留座位也给占领,不仅仅是在电影中,在公交车上抢座位也是如此。这种办法,我们暂且称之为"预留座位"。

这在计算机中也同样适用。我们在第一次请求计算机分配内存的时候,就请求计算机分配一些空余的内存,也就是"预留座位"出来。假定有20个"预留座位",这似乎是一个不错的措施。但你应该明白,这个措施,也存在缺点:

你额外请求的位置有可能根本就用不上,还将浪费内存。比如你选了座位,结果你的朋友没有来,其他人也不知道你的朋友不会来,也就不会坐上去,那么这个座位就空下来了。

如果来的人超过了20个之后,你预留的座位又不够,这就不得不继续重新请求转移。

针对这种问题,我们可以使用链表来解决。

链表也是一种数据结构,链表中的元素可存储在内存的任何地方。并且链表中 每一个元素都存储了下一个元素的地址,从而使一系列随机的内存串联在一起。

这就好像一个扫雷游戏,当你扫中一个,这个就会提醒你的四周格子有没有地雷,从而好作判断,让你是否选择点击下一个格子。因此,在链表中添加一个元素很容易:只需要将元素放入内存,并将这个元素的内存存储地址放到前一个元素中。

而且,使用链表还不用移动元素。因为只要有足够的内存空间,计算机就能为链表分配内存。比如如果你要为数组分配100个位置,内存也仅有100个位置,但元素不能紧靠在一起,在这样的条件下,我们是无法为数组分配内存的,但链表却可以。

链表好像自动就会说,“好吧,我们分开来坐这些位置”。

但链表也并非没有缺点。我们再做一个比喻:

假如你看完了一本小说的第一章觉得第一章不好看,想跳过几章去看,但并没有这样的操作,因为一般都是下一章下一章的看的,这意味着你要看第五十章,就要点下一章49次,这样真的很烦。(点目录不算)

链表也正是存在这一个问题,你在读取链表的最后一个元素时,你需要先读取上一个元素的存储地址,然后根据上一个元素的地址来读取最后这个元素。如果你是读取所有的元素,那么链表确实效率很高,但如果你是跳跃着读取呢?链表效率就会很低。

这也正是链表的缺点所在。但数组就不同,因为数组有编号,意味着你知道数组每个元素的内存地址,你只需要执行简单的数学运算就能推算出某个元素的位置。

利用大O表示法,我们可以表示将数组和链表的运行时间表示出来,如下:

      数组   链表
读取: O(1)    O(n)
插入: O(n)    O(1)

其中O(1)称作常量时间,O(n)则是线性时间。

如果你要删除元素呢?链表显然也是更好的选择,因为你只需要修改前一个元素指向的地址即可。

那么问题来了,数组与链表究竟哪种数据结构用的最多呢?

要看情况。但数组显然用的更多,因为数组支持随机访问。元素的访问有两种方式:顺序访问随机访问。顺序访问意味着 你需要挨着顺序一个一个的访问元素,而随机访问则不必如此。因为数组有编号,所以在随机访问上,数组更能体现它的优势。

有了前面的知识,现在,咱们来学习选择排序算法吧!
假设你的计算机存储了一些视频,对于每个视频的播放次数,你都做了记录,比如:

视频1:50
视频2:35
视频3:25
视频4:55
视频5:60

现在,你要做的就是将播放次数从少到多按顺序排列出来。该如何做呢?

首先,你肯定需要遍历这个列表,找出播放次数最少的,然后添加到新的一个列表中去,并将这个添加的元素在原来列表中删除,然后,你再次这样做,将播放次数第二少的找出来,依次类推……

最后,你就会得到一个有序列表

视频3:25
视频2:35
视频1:50
视频4:55
视频5:60

编写代码如下:

//用一个数组表示播放次数即可
var arr = [50,35,25,55,60];
// 编写一个函数,参数传入排序的数组
function selectSort(arr){
    //获取传入数组的长度
    var len = arr.length;
    //定义最小索引与数组每一项元素变量
    var minIndex,ele;
    for(var i = 0;i < len - 1;i++){
            //最小索引等于当前i值,相当于初始化值
            minIndex = i;
            //初始化每一项
            ele= arr[i];
         for(var j = 1;j < len;j++){
                //获取相邻数,比较大小,得到最小数索引
                if(arr[j] < arr[minIndex]){
                        minIndex = j;
                }
         }
         //将得到的最小数排列在最前面
        arr[i] = arr[minIndex];
        //与最小数做比较的值放在最小数所处的位置
        arr[minIndex] = ele;
    }
    return arr;
}
//测试:
selectSort(arr);//[25,35,50,55,60]

下面我们来测试一下代码运行时间。对每个元素进行查找时,意味着每个元素都要查找一次,所以运行时间为O(n),需要找出最小元素,又要检查每个元素,这意味着又要O(n)的运行时间。因此需要的总时间为O(nxn)=O(n^2)。这也就是说,需要执行n次操作。

选择排序只是一种很灵巧的算法,但还称不上速度最快,速度最快的是快速排序法。

六.递归算法

JavaScript递归一直让许多开发者又爱又恨,因为它很有趣但也很难.最经典的莫过于一个阶乘函数呢,如下:

function fn(num){
    if(num <= 1){
       num = 1;
    }else{
       num = num * fn(num - 1);
    }
    return num;
}
fn(5);//5*4*3*2*1= 120


面对如上的一个结果,许多开发者不免疑惑,为何会是这样的结果.这个咱们暂且不解释,咱们先来用一个生活中常见的例子来分析:

假设有一个盒子,盒子里面又包含盒子,盒子里面再包含盒子,一直包含n个盒子,那第n个盒子中有一本书.如果让你找到这本书,你会如何查找?

以下是一个示意方法:

首先是定义一个盒子:

var box = {
        box:{
            box:{
                box:{
                    ......
                }
            }
        }
}

其次只要盒子不空,我们就取出一个盒子,如下:

if(box !== {}){
   box = box.box;
}

现在,咱们再来做判断,如果取出的盒子里面存在书,那么说明已经找到了,不必在继续取盒子,即:

//假定书变量
var book = 'book';
if(box !== {}){
        box = box.box;
        if(box === book){
             console.log('已经找到了')!
        }else{
             box = box.box.box;
             //......
        }
}

可如果不是书,那么就继续取盒子,即如上的else代码块中的语句.

通常,咱们可以用一个循环来完成这样的操作,如下:

var box = {
   // ......
}
var book = 'book';
for(var key in box){
        if(box !== {}){
                box = box[key];
                if(box[key] === book){
                  console.log('已经找到了');
                }else{
                    box[key] = box[key][key]
                    //......
                }
        }
}

但似乎这样做有很大的缺点,因为一旦涉及到最里面层数太多,则需要循环n次.这不太合适,结果会取决于循环.因此,我们可以定义一个函数,然后为函数传参,反复的让函数自己调用自己,这样,结果就取决于程序而不是循环了.如下:

//传入box对象
var box = {
    //......
}
var book = 'book';
function checkOutBook(box){
    //遍历box对象
    for(var key in box){
        if(box !== {}){
                if(box[key] === book){
                    console.log('找到了');
                }else{
                     //反复调用函数,直到找到书为止
                    box = checkOutBook(box[key]);
                }
        }
    }
}

如此一来,递归的原理我们就能清楚了,递归就是层层递进,反复的调用自己,就拿函数来说,就是反复的调用自己,直到条件不满足时,则递归停止.

现在,咱们再来分析以上的阶乘函数:

函数需要传入一个数值参数,然后我们对这个参数做判断,如果这个参数小于等于1,则让这个参数等于1.如果不满足,则执行这个参数,等于这个参数与这个参数减1的乘积.

fn(5) => 这意味着num = 5=>num=5 <= 1 (条件不成立) => num = 5 * fn(4) => 

num = 4 => num = 4 <= 1(条件不成立) => num = 5 * 4 * fn(3) => num = 3 => num = 3 <= 1(条件不成立) => num = 5 * 4 * 3 * fn(2) => num = 2 => num=2 <= 1(条件不成立) => num = 5 * 4 * 3 * 2 * fn(1) => num = 1 => num = 1 <= 1(条件成立) => num = 1 => 最终结果就是num = 5 * 4 * 3 * 2 * 1 = 120;


结合最后return语句返回num,则不难得出结果.我们尝试用循环来完成阶乘:

function fn(num){
        var i = 0,result = 1;
        while(i < num){
              if(i <= 1){         
                   i = 1;
               }else{
                    result *= i;
               }      
         }
        return result;
}
fn(5);//120

就性能上而言,递归并不比循环好,但递归胜在比循环更好理解.也就是说递归更容易让程序被人理解.

递归函数有什么特点呢?

我们来看一个递归函数:

function fn(){
   var i = 10;
   console.log(i);
   fn(i - 1);
}

运行如上的函数,你会发现程序会一直无限循环下去,直到死机都还会运行.因此,在编写递归算法的时候,我们要告诉程序何时停止递归.

递归有两个条件:基线条件递归条件.递归条件指的就是函数调用自己,而基线条件则指的是停止递归的条件,如函数不再调用自己.

比如:

function fn(){
  var i = 10;
  console.log(i);
  //基线条件
  if(i <= 1){
     i = 1;
   }else{
    //递归条件
     fn(i - 1);
   }
}

栈:栈是一种数据结构,前面讲到数组和链表的时候,曾说过,元素的插入,删除和读取.其中插入也被称作是压入,删除和读取也被叫做弹出.而这种能够插入并能够删除和读取的数据结构就叫栈.也就是说数组是一种栈,链表也是一种栈.

就拿如上的示例来说:

function fn(){
  var i = 10;
  console.log(i);
  //基线条件
  if(i <= 1){
     i = 1;
   }else{
     //递归条件
     fn(i - 1);
   }
}

变量i被分的一块内存,当每次调用函数fn()的时候,而每次i都会减一,这也意味着每次都会分的一块内存.计算机用一个栈来表示这些内存,或者也可以说是内存块.当调用另一个函数的时候,当前函数就已经运行完成或者处于暂停状态.

栈被用于存储多个函数变量,也被叫做调用栈.虽然我们不必跟踪内存块,由于栈已经帮我们做了.再来看一个简单的示例:

function greet(){
   console.log('hello');
}
greet();
function bye(){
    console.log('bye');
}
bye();

首先调用函数greet(),后台就会创建一个变量对象,打印出'hello'字符串,此时栈被调用.也存储了一个变量对象,相当于该字符串被分了一块内存.紧接着调用bye()函数,也被分配了一个内存.

虽然使用栈很方便,但也有缺点,那就是不要使用栈存储太多的信息,因为这可能会占用你电脑很多内存.

七.快速排序算法

算法中有一个重要的思想,那就是分而治之(D&C,divide and conquer),这是一种著名的递归式问题解决方法。

理解分而治之,意味着你将进入算法的核心。快速排序算法是这个思想当中第一个重要的算法。

我们举一个示例来说明这个思想。

假设要将一个长为1000,宽为800的矩形分成均匀的正方形,并保证这些正方形尽可能的大。

我们应该如何做呢?

我们可以使用D&C策略来实现,D&C算法是递归的。要解决这个问题,我们需要将过程分为两个步骤。如下:

找出D&C算法的基线条件,条件要尽可能的简单。

不断将问题分解,直到满足基线条件为止。

我们知道如果将长1000,宽800的长方形分成最大的正方形应该是800x800。然后余下200X800的长方形。按照相同的分法,我们又可以将其分为200X200正方形与200X600的长方形,然后再分为200X200与200X400的正方形与长方形,最后实际上最大的并且均匀的正方形就是200X200的正方形。

第一次讲到的二分查找其实也是一种分而治之的思想。我们再来看一个例子:

假设有一个[2,4,6]的数组。你需要将这些数字相加,并返回结果。使用循环可能很容易解决这个问题,如下:

var arr = [2,4,6],total = 0;
for(var i = 0;i < arr.length;i++){
    total += arr[i];
}

但如何使用递归算法来实现呢?

首先,我们需要找出这个问题的基线条件。我们知道如果数组中没有元素,那就代表总和为0,如果有一个元素,那么总和就是这个元素的值。因此基线条件就出来了。

每次递归调用,我们都会减少一个元素,并且离空数组或者只包含一个元素的数组很近。如下:

sum([2,4,5]) => 2 + sum([4,6]) => 2 + 4 + sum([6]) => 2 + 4 + 6 = 12

因此,我们可以编写如下的代码:

//基线条件
if(sum.length <= 1){
    //求和
}else{
    //递归
}

你可能会想,能够使用循环轻易的解决问题的,干嘛还要使用递归。如果你能理解函数式编程,那么就明白了。因为函数式编程并没有循环的说法,而实现求和的方式只能是使用递归算法。

前面之所以会提到分而治之思想,是因为接下来的快速排序算法需要按照这种思想去理解.快速排序算法选择排序算法(也被叫做冒泡排序算法)快的多.我们需要知道,什么样的数组需要进行排序,什么样的数组不需要进行排序.很显然当数组没有元素或者只有一个元素时,我们无需对数组进行排序.

空数组:[]
只有一个元素的数组:[2];

像如上的数组,我们没必要排序,因此接下来的函数中,我们可以如此写:

function quickSort(arr){
    if(arr.length < 2){
        return arr;
    }
}

当数组的元素超过两个呢,比如[12,11].我们可能都会这样想,检查第一个元素是否比第二个元素大,然后确定是否交换位置.而如果是三个元素呢,甚至更多元素呢?我们是否还能这样做呢?

我们可以采用分而治之的思想,即D&C策略.将数组分解.直到满足基线条件.这也就是说,我们会采用递归原理来实现.快速排序算法的工作原理就是从数组当中选择一个元素,这个元素也被叫做基准值.

然后我们就可以根据这个基准值找出比基准值大或者比基准值小的元素,这样被称作分区.进行分区之后,你将会得到:

一个比基准值小而组成的子数组.
一个包含基准值的数组.
一个比基准值大而组成的子数组.

当然这里得到的所有子数组都是无序的,因为如果得到的是有序的,那么排序将会比较容易.直接将分解后的数组利用concat()方法给合并,就能得出排序结果呢.

基准值的选择是不确定的,这也就意味着你可以选择最中间的数,也可以选择第一个数,甚至是最后一个数都可以.比如一个数组[5,2,3,1,4];

数组当中的五个元素你都可以当作基准值,而每一个基准值所分区出来的子数组也会有所不同.

我们通过以上的多种类推和归纳就能得出最终的结果.而这种证明算法行之有效的方式就叫做归纳证明.归纳证明结合快速排序算法,可以让我们的算法变得生动有趣.

现在,我们来实现快速排序的算法吧,代码如下:

function quickSort(arr){
    //定义一个空数组接收排序后的数组
    var newArr = [];
    //当数组的长度小于2时,不需要进行排序
    if(arr.length < 2){
        return arr;
    }else{
        //定义一个基准值,这里就取中间值吧
        var standIndex = Math.floor(arr.length / 2);//由于可能数组长度不是偶数,所以,需要取整
        //使用基准值所对应的元素值,由于要最后合并三个数组所以这里采用splice()方法取得基准值所组成的数组
        var standNum = arr.splice(standIndex,1);
        //接下来,我们需要定义两个空数组,用于保存以基准值作为分区的最小子数组和最大子数组
        var minArr = [],maxArr = [];
        //循环数组将每一个元素与基准值进行比较,小则添加到最小子数组中,大则添加到最大子数组中
        for(var i = 0,len = arr.length;i < len;i++){
                if(arr[i] < standNum){
                    minArr.push(arr[i]);
                }else{
                    maxArr.push(arr[i]);
                }
        }
        //循环完成之后合并三个子数组,当然这里需要递归合并,直到数组长度小于2为止
        newArr = quickSort(minArr).concat(standNum,quickSort(maxArr));
    }
    //返回合并后的新数组
    return newArr;
}
//测试
quickSort([1,3,2,4,5]);//[1,2,3,4,5]

快速排序算法的独特之处在于,其速度取决于选取的基准值。在讨论快速排序算法的运行时间之前,我们来大致讨论一下常见算法的大O运行时间:

clipboard.png

执行10次操作计算得到的,当然这些数据并不准确,之所以提供,只是让我们对这些运行时间的差别有一个认识。事实上,计算机每秒执行的操作远不止如此。(另外还要注意一下关于时间复杂度对数的底数,是不太确定的,比如二分法,底数有可能是2,也有可能是3,视情况而定)。

对于每种运行时间,都会有相关的算法。比如前面所说的选择排序算法,它的运行时间就是O(n^2),所以速度很慢。

当然还有一种合并排序算法,它的运行时间是O(nlogn),比选择排序算法快的多,快速排序算法则比较棘手,因为快速排序算法是根据基准值来判定的,在最糟糕的情况下,快速排序算法的运行时间和选择排序算法运行时间是一样的,也是O(n^2)。

当然在平均情况下,快速排序算法又和合并排序算法的运行时间一样,也是O(nlogn)。

到此为止,也许有人会有疑问?这里的最糟情况和平均情况究竟是什么呢?如果快速排序算法在平均情况下的运行时间是O(nlogn),而合并排序算法的运行时间总是O(nlogn),这不就是说合并排序算法比快速排序算法快吗?那为何不选合并排序算法呢?

当然我们在做合并排序于快速排序算法的比较之前,我们需要先来谈谈什么是合并排序算法。

八.合并排序算法

合并排序算法也叫归并排序算法。其核心也是分而治之的思想,与二分法有点类似,先将数组从中间分开,分成两个数组,依次类推,直到数组的长度为1时停止。然后我们向上回溯,形成一个有序的数组,最后合并成一个有序的数组。

现在,来看归并排序算法实现的代码吧。

function mergeSort(arr) {
        // 如果数组只有一个元素或者无元素则无须排序
        if (arr.length <= 1) {
          return arr;
        }
        var mid = Math.floor(arr.length / 2), //取中间值,将数组截取成2个数组
          left = arr.slice(0, mid),
          right = arr.slice(mid);
        var newArr = [],
          leftArr = mergeSort(left), //这里是最关键的一步,将左右数组递归排序,直到长度为1时,然后合并.
          rightArr = mergeSort(right);
        //判断如果两个数组的长度都为1,则比较第一个元素的值,然后添加到新数组中去
        while (leftArr.length && rightArr.length) {
          if (leftArr[0] < rightArr[0]) {
            newArr.push(leftArr.shift());
          } else {
            newArr.push(rightArr.shift());
          }
        }
        return newArr.concat(leftArr, rightArr);
 }
//测试
console.log(mergeSort([1, 3, 2, 4, 6, 5, 7]));

理解了合并排序算法,现在我们就来比较一下快速排序算法和合并排序算法吧。

九.比较快速排序算法与合并排序算法

我们还是先来看一个示例,假设有如下一个函数:

var list = [1,2,3,4,5];
function printItem(list){
    list.forEach(function(item){
        console.log(item);
    })
}
printItem(list);

以上定义了一个函数,遍历一个数组,然后将数组的每一项在控制台打印出来。既然它迭代了数组列表每个数组项,那么运行时间就是O(n)。现在,我们假设每延迟1s再打印出数组每一项的值,如下:

var list = [1,2,3,4,5];
function printAfterItem(list){
    list.forEach(function(item){
        setTimeout(function(){
            console.log(item);
        },1000)
    })
}
printItem(list);

第一次,我们知道,打印1,2,3,4,5。而延迟1s打印了之后,则会延迟1s,1,延迟1s,2,延迟1s,3,延迟1s,4,延迟1s,5。这两个函数都是迭代了一个数组,因此它们的运行时间都是O(n)。那么,很显然printItem()函数更快,因为它没有延迟,尽管大O表示法表示这两者的速度相同,但实际上却是printItem()更快,在大O表示法O(n)中的n实际上就是指这样的忽略常量的运行时间。

在这里我们可以定义常量为c,c也就是算法当中的固定时间量,比如上例的1s就是固定的时间量,也被称为是常量。当然第一个函数printItem()也有可能有一个时间常量,比如:10ms * n,而延迟1s之后的printAfterItem()则是:1s * n;

哪个函数的运行速度快自然一目了然。通常来说,是不会考虑这种常量的,因为对于两种算法的大O运行时间不同,这种常量造成的影响无关紧要。就比如二分查找和简单查找。假设二分查找有常量:n * 1s,而简单查找有常量:10ms * n;好,如果根据这个常量来看,也许会认为简单查找的常量是10ms就快得多,但事实上是这样吗?

比如在包含40亿个元素列表中查找某个元素,对于二分查找和简单查找所需时间如下:

简单查找:10ms * 40亿,大约463天。
二分查找:1s * log40亿,大约是32秒。

正如你所看到的,二分查找还是快的多,常量几乎可以忽略不计。

可是有的时候,常量又可能造成巨大的影响,对于快速排序算法和合并排序算法来说,就是如此。实际上快速排序算法的常量比合并排序算法的常量小,因此如果它们的运行时间都是O(nlogn)。那么很显然快速排序算法要更快,尽管快速排序算法有平均情况和最糟情况之分,但实际上平均情况出现的概率要远远大于最糟情况。

到此为止,也许有人会有疑问,什么是平均情况?什么又是最糟情况?

十.平均情况与最糟情况

快速排序算法的性能高度依赖于选择的基准值,假设你选择数组的第一个元素作为基准值,并且要处理的数组是有序的。由于快速排序算法并不检查输入的数组是否有序,它依然会尝试进行排序。

[1,2,3,4,5,6,7,8]
   ↓
[](1)[2,3,4,5,6,7,8]
   ↓
[](2)[3,4,5,6,7,8]
   ↓
[](3)[4,5,6,7,8]
   ↓
[](4)[5,6,7,8]
   ↓
[](5)[6,7,8]
   ↓
[](6)[7,8]
   ↓
[](7)[8]

这样一来,每次都会选择第一个元素作为基准值,就会调用栈8次,最小子数组也始终是空数组,这就导致调用栈非常的长。再来看选择中间元素作为基准值是什么情况呢?

[1,2,3,4,5,6,7,8]
   ↓
[1,2,3](4)[5,6,7,8]
   ↓          ↓
[1](2)[3]  (6)[7,8]
              ↓
           [](7)[8]

因为每次都将数组分成两半,所以不需要那么多的递归调用。很快就达到了递归的基线条件,因此调用栈就短的多。

第一个示例就展示了最糟情况,而第二个示例则展示的是最佳情况。在最糟情况下,栈长为O(n),在最佳情况下,栈长就是O(logn)。

现在来看看栈的第一层,将一个元素作为基准值,并将其它元素划分到两个子数组中去,这就涉及到了数组的8个元素,因此该操作时间就是O(n)。实际上栈的每一层都涉及到了O(n)个元素,因此运行时间也是O(n)。即便是最佳情况选择数组的中间值来划分,栈的每一层也一样涉及到O(n)个元素,因此完成每层所需时间都是O(n)。唯一不同的地方则是第一个示例调用栈的层数是O(n)[从技术术语来说,也就是调用栈的高度是O(n)],而第二个示例调用栈的高度则是O(logn),因此整个算法所需的时间就是O(n) * O(logn) = O(nlogn),而第一个示例所需的时间就是O(n)*O(n) = O(n^2)。这就是最佳情况与最糟情况所需的运行时间。

在这里,我们需要知道的就是最佳情况被看作是平均情况的一种,只要每次随机的选择一个元素作为基准值,那么快速排序的平均运行时间就是O(nlogn)。也正因为如此,快速排序算法在平均情况下,常量比合并排序算法小,也因此快速排序算法就是最快的排序算法之一,也是D&C典范。

十一.有趣的数据结构——散列表

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值