由乱序播放说开了去-数组的打乱算法Fisher–Yates Shuffle

之前用HTML5的Audio API写了个音乐频谱效果,再之后又加了个播放列表就成了个简单的播放器,其中弄了个功能是'Shuffle'也就是一般播放器都有的列表打乱功能,或者理解为随机播放。

但我觉得随机播放绝对要好实现些,用Math.random()产生一个介于1到歌曲数目之间的随机数便可,然后player.play(随机数)。

而列表的打乱情况要不一样点,一是要呈现到界面,歌曲顺序要随机排,二是播放顺序不变,该哪是哪,只是该位置上的歌曲可能已经变成其他曲目了。抽象出来就是数组元素的重排,那么具体算法就值得探究了。

面对一个新问题时,我首先想到的是前人是否已经给出了问题的答案。正如所料于是发现了这个成熟的Fisher-Yates乱序算法,这是公认经典的洗牌算法了。但事情到此并没有结束。

原文给出了三个循序渐近的例子,下面来看。

一般化方法

原文引入的现实情境是这样的,假如你要洗牌,那么最随机的做法无疑是从牌堆里随便抽一张出来,然后放在一边,之后从剩下的牌里重复之前的操作,直到所有牌都被抽出来放到了另一堆中。抽象到代码世界,按相同的做法,就是随机从数组里取出一个元素,保存到另一个数组,然后重复之,直到原数组中所有元素都处理掉。

原文给出的演示我觉得不够直观,下方是我自己写的动画用以演示此算法过程(也可在这里查看:http://sandbox.runjs.cn/show/1hylhpck ):

下面是按这个思路的一个实现:

function shuffle(array) {
    var copy = [],
        n = array.length,
        i;
    // 如果还剩有元素则继续。。。
    while (n) {
        // 随机抽取一个元素
        i = Math.floor(Math.random() * array.length);
        // 如果这个元素之前没有被选中过。。
        if (i in array) {
            copy.push(array[i]);
            delete array[i];
            n--;
        }
    }
    return copy;
}

我们创建了一个copy数组,然后遍历目标数组,将其元素复制到copy数组里,同时将该元素从目标数组中删除,这样下次遍历的时候就可以跳过这个序号。而这一实现的问题正在于此,即使一个序号上的元素已经被处理过了,由于随机函数产生的数是随机的,所有这个被处理过的元素序号可能在之后的循环中不断出现,一是效率问题,另一个就是逻辑问题了,存在一种可能是永远运行不完!

Note:
Math.random()产生[0,1)的小数
delete 操作只将数组元素的值删除,但不影响数组长度,删除后原来位置的值变为undefined

改进的做法

上面的分析已经看出问题的所在了,所以改进的做法就是处理完一个元素后,我们用Array的splice()方法将其从目标数组中移除同时也更新了目标数组的长度,如此一来下次遍历的时候是从新的长度开始,不会重复处理的情况了。

动画演示(http://sandbox.runjs.cn/show/v6a7gq0f)

function shuffle(array) {
    var copy = [],
        n = array.length,
        i;
    // 如果还剩有元素。。
    while (n) {
        // 随机选取一个元素
        i = Math.floor(Math.random() * n--);
        // 移动到新数组中
        copy.push(array.splice(i, 1)[0]);
    }
    return copy;
}

再次优化的最终版本

上面的做法已经可以了,但上面的改进依然还有提升空间。因为调用splice来删除数组元素会导致删除位置之后的所有元素要做shift操作来向前补充,从而达到将数组长度减小的目的,当然这是在后台自动完成的,但这无疑增加了算法的复杂度。

注意到我们要做的仅仅是将数组元素重新排序,已经取出来的元素和剩下的元素之和一定是等于数组原来的总元素个数的。所以可以考虑不创建新的数组来保存已经抽取的元素,可以这样,随机从数组中抽出一个元素,然后与最后个元素交换,相当于把这个随机抽取的元素放到了数组最后面去,表示它已经是被随机过了,同时被换走的那个元素跑到前面去了,会在后续的重复操作中被随机掉。一轮操作过后,下一轮我们只在剩下的n-1个元素也就是数组的前n-1个元素中进行相同的操作,直到进行到第一个。

动画演示(http://sandbox.runjs.cn/show/jabgttzr):

function shuffle(array) {
    var m = array.length,
        t, i;
    // 如果还剩有元素…
    while (m) {
        // 随机选取一个元素…
        i = Math.floor(Math.random() * m--);
        // 与当前元素进行交换
        t = array[m];
        array[m] = array[i];
        array[i] = t;
    }
    return array;
}

更加简洁的版本

上面介绍的便是在各语言中都广为实现的Fisher-Yates乱序算法。但具体到JavaScript,我们其实可以结合数组自带的sort()方法编写出更简洁的代码来达到目的。中间变量以及值交换什么的都省了,虽然后台实现肯定还是会进行值交换的,但我们不关心,一切交给sort()让它自己处理。但这种方法也只是简洁而以,效果是不如上面介绍的算法的,因为随着数组元素越多,其随机性会变差。

function shuffle(array) {
    return array.sort(function() {
        return Math.random() - 0.5
    });
}

 

REFERENCE

  • Fisher–Yates Shuffle: http://bost.ocks.org/mike/shuffle/
  • Why the Fisher-Yates Shuffle is the best algorithm from Quora: http://www.quora.com/Algorithms/Are-there-any-better-shuffling-algorithms-than-Fisher%E2%80%93Yates-shuffle
  • 45 Useful JavaScript Tips, Tricks and Best Practices http://flippinawesome.org/2013/12/23/45-useful-javascript-tips-tricks-and-best-practices/

你可能感兴趣的:(由乱序播放说开了去-数组的打乱算法Fisher–Yates Shuffle)