PHP编程之数组排序

PHP里的数组实际上是一个有序映射。不管是队列、数组、栈还是字典,在使用PHP编程时,你都可以统一使用PHP的数组。这一节,我们只要探讨关于PHP数组的排序,因为数据的排序这块,是很多项目都会使用到的。虽然数据库也可以很方便进行排序,但在复杂、动态的业务规则下,更多需要在PHP代码层进行处理。因此加深对PHP数组的理解,对项目开发将大有禆益。

一道面试题引发的思考

在我当前任职的公司里,有这样一道面试题,关于数组排序的。简化和提炼一下,题目是:
请按以下规则,对专辑的歌曲进行排序。
1、按播放次数,从高到低排序。
2、如果播放次数相同,则按收藏人数从高到低排序。
3、如果收藏人数相同,则按下载次数从高到低排序。
并假设有专辑歌曲列表如下:

歌曲

播放次数

收藏次数

下载次数

恰似你的温柔

1000

900

800

丁香花

2000

700

1500

突然的自我

2000

800

1300

夜空中最亮的星

800

1000

700

通常,对于这道题,会有三类答案。
第一类,就是一开始就扎进大学时代的排序算法。想着从头到尾再重新实现一套冒泡排序算法,或者快速排序算法。如果能达到性能最优,并且能准确无误快速实现也是不错的做法。但这更多是偏理论化,实际业务需求开发中,很少需要自己再重新实现通用的排序算法。相反,我们只需要会加以使用即可。
所以,第二类答案是,通过最少的代码,快速实现业务需求。不管你是使用封装好的第三方开源类库,还是PHP原生态提供的数组排序函数,只要能实现以上排序规则即可。这时,可以使用array_multisort()函数。这个排序函数功能比较强大,因此理解起来会有点吃力。根据官方文档的说明,array_multisort() 可以用来一次对多个数组进行排序,或者根据某一维或多维对多维数组进行排序。
例如,这里,可以这样实现:

<?php
// 示例数据
$songs = array(
    array('title' => '恰似你的温柔', 'play' => 1000, 'like' => 900, 'download' => 800),
    array('title' => '丁香花', 'play' => 2000, 'like' => 700, 'download' => 500),
    array('title' => '突然的自我', 'play' => 2000, 'like' => 800, 'download' => 1300),
    array('title' => '夜空中最亮的星', 'play' => 800, 'like' => 1000, 'download' => 700),
);

// 初始化辅助数据
$playTimes = $likeTimes = $downloadTimes = array();

foreach ($songs as $it) {
    $playTimes[] = $it['play'];
    $likeTimes[] = $it['like'];
    $downloadTimes[] = $it['download'];
}

// 用一行代码,根据多维对多维数组进行排序
array_multisort(
    $playTimes, SORT_DESC, SORT_NUMERIC,        // 排序规则1
    $likeTimes, SORT_DESC, SORT_NUMERIC,        // 排序规则2
    $downloadTimes, SORT_DESC, SORT_NUMERIC,    // 排序规则3
    $songs
);

print_r($songs);

最后,运行上面代码,可以看到结果输出是:

Array
(
    [0] => Array
        (
            [title] => 突然的自我
            [play] => 2000
            [like] => 800
            [download] => 1300
        )
    [1] => Array
        (
            [title] => 丁香花
            [play] => 2000
            [like] => 700
            [download] => 500
        )
    [2] => Array
        (
            [title] => 恰似你的温柔
            [play] => 1000
            [like] => 900
            [download] => 800
        )
    [3] => Array
        (
            [title] => 夜空中最亮的星
            [play] => 800
            [like] => 1000
            [download] => 700
        )
)

除开前面的示例数据,中间只需要简单的循环一遍,用于初始化辅助的数据,就可以便捷地实现了上述三个规则的排序。但这还不是最优的。我们再来看下第三类答案。
在揭晓第三类答案之前,我们不妨先简单来回顾一下以前在中学时代,当遇到求解一元二次(或更高次方)方程式时,当时我们是怎么解决的? 例如这一条方程式:

很简单,我们会先化简,再求解。即会先化简成为我们熟悉的一元一次方程:

两边求平方根,得到:

所以,最后答案是x=3或x=-1。这样是不是很简单了?
同样的道理,如果是对于三维的排序我们很陌生,或者说无从下手,那么如果这只是一道一维数组的排序呢,我们是不是可以很简单地处理?
这就是我们所说的,第三类答案——思路最简单的解决方案。这也是通常所说的降维。有了新的思路,再来解决就难了。关键点在于,我们要找到一种唯一映射,使得:

然后再按照此映射规则,将三维的比较,降为一维的比较,最后再进行数组排序,就能达到同样的效果。以下是鉴于当前示例数据的参考实现。

<?php
// 三维降一维
$points = array();
foreach ($songs as $it) {
    $points[] = 1000000 * $it['play'] + 1000 * $it['like'] + $it['download'];
}

// 再排序
array_multisort($points, SORT_DESC, SORT_NUMERIC, $songs);

对数组排序的理解

关于PHP数组的排序函数有好几个,但通常开发同学只记得sort(),ksort(),更多其他的排序函数就记不住,或者没有印象了。下面将分享如何快速记住这些排序函数的技巧。
从官方文档摘录的,对数组排序的函数有:

array_multisort()
asort()
arsort()
krsort()
ksort()
natcasesort()
natsort()
rsort()
shuffle()
sort()
uasort()
uksort()
usort()

全部列出来,有13个之多。那怎么记得住呢?其实,在理科里,都是有技巧的,要靠理解,而非死记硬背。就像数学公式一样,要活学活用。
我们都知道,PHP数组由键和值组成,而排序顺序可以是升序,或者是降序。根据这两个维度,我们可以将上面13个排序函数进行分类。如下所示:

升序

降序

自定义

自然排序

其他

对值排序

sort()、asort()

rsort()、arsort()

usort()、uasort()

natsort()、natcasesort()

shuffle()、array_multisort()

对键排序

ksort()

krsort()

uksort()

对这样的分类清晰后,接着下再来看怎么记住这结函数名称。可以发现,除了随机排序函数的名称为shuffle()外,其他全部排序函数都是以“sort()”结尾的。不难发现,函数名称中的这些字母表示的意思分别是:
k:表示键,key的缩写
a:表示键值关联的保持(数字类型的不保持),associative的缩写
r:表示逆向或者翻转,reverse的缩写
u:表示用户自定义,user的缩写
然后,再从最原始的sort()函数开始,若加上首字母a则表示保持索引关系的排序,若加上首字母k则表示对键排序。由此构成第一梯度排序函数:
sort()、asort()、ksort()
这三个排序函数,若全部在“sort”前加上字母r,则表示降序排序。从而构成第二梯度排序函数:
rsort()、arsort()、krsort()
如果在第一梯度的三个排序函数最前面加上首字母u,则更简单了。直接表示用户自定义排序系列的函数,即第三梯度排序函数:
usort()、uasort()、uksort()
最后,剩下的4个函数排序可归为第四梯度排序函数,即综合型的排序,有:
natsort()、natcasesort()、shuffle()、array_multisort()
通过这样的整理,估计你能在更短的时间内,对PHP的数组排序函数有更深刻的理解。甚至乎,可以逐渐明白设计PHP这门语言当时的初衷是多么的巧妙。

排序陷阱

即便记得住这些排序函数,并不代表就能正确使用。因为,在实际项目开发中,会发现有很多开发工程师会不加思索就使用他们一直熟悉的某个排序函数,而不考虑具体的业务场景以及所运行的上下文环境。包括我在内,曾经也犯了这样的错。尤其在大型企业级系统中,任何一个小点,在大数据、高并发下都会被放得更大。下面举一个具体的例子来说明这一点。
除了sort()函数外,大家平时使用比较多的也许数usort()函数了,因为它可以允许你自定义排序规则。自然而然,对于下面这个场景,需要对每个学生的考试分数从高到低进行排名时,出于惯性,就会很自然继续使用usort()函数。简短的实现代码是:

<?php
// 学生数组
$students = array(
    array('name' => '张三', 'point' => 76),
    array('name' => '李四', 'point' => 98),
    array('name' => '小明', 'point' => 95),
    array('name' => '小红', 'point' => 83),
    array('name' => '阿布', 'point' => 88),
);

// 按分数从高到低排序
usort($students, function($left, $right) {
    if ($left['point'] == $right['point']) {
        return 0;
    }

    return $left['point'] > $right['point'] ? -1 : 1;
});

// 输出
print_r($students);

上面代码运行后,能得到正确的排序结果。看起来没什么问题,对吧?是的,确实看起来没什么问题。因为这里只有5个学生。但是,假设开发的系统是大型的系统,假设要排序的学生有广东省的全部高考学生,以2017年为例,广东省高考人数大概是75.7万,这时性能又会如何?
让我们简单来做一个模拟的小实验。用事实结合xhprof性能分析工具得出的数据来说话。
先稍微加以改装,在进行排序的前后加上xhprof的性能分析代码。

// 开始xhprof性能分析
xhprof_enable();

usort($students, function($left, $right) {
    if ($left['point'] == $right['point']) {
        return 0;
    }

    return $left['point'] > $right['point'] ? -1 : 1;
});

// 结束xhprof性能分析
$xhprof_data = xhprof_disable();

关于xhprof的使用,网上已经有很多资料说明,这里不再详细展开。最终,我们可以看到这样的性能分析报告:
对5个学生使用usort()排序

图1 对5个学生使用usort()排序

接下来,我们可以把学生的数量加大一点。先增加到万的级别,通过指数爆炸很容易做到这一点,以上面5个学生为基础,通过对这5个学生的数据进行11次翻倍后,就可以得到10240组数据。把下面造数据的代码放在$students变量声明后即可。

// 5 * (2 ^ 11) = 10240
for ($i = 0; $i < 11;  $i ++) {
    $students = array_merge($students, $students);
}

再来看一下,这时代码运行后的性能分析报告是怎样的。

对一万多个学生使用usort()排序

图2 对一万多个学生使用usort()排序

我们暂时先不来对比这些数据,继续完成最后一批排序——模拟近70万学生的成绩排序。继续把上面循环的次数从原来11次加大到17次,就可以得到655360组数据。这时,你会发现页面响应已经明显变慢了。在我测试的现在,基本使用了13秒。最终的xhprof性能分析报告如下:

第三组分析,对65万多个学生使用usort()排序

图3 第三组分析,对65万多个学生使用usort()排序

最后,通过这三组数据,我们来做个汇总和比较,就能明显发现问题所在了。对比几个关键的性能指标:执行时间、内存使用情况和函数调用次数,可以得到以下表格:

学生数量

执行时间

使用内存

函数调用次数

第一组

5个学生

82毫秒

约6KB

10次

第二组

10,240个学生

149毫秒

约6KB

119,234次

第三组

655,360个学生

约12.9秒

约6KB

11,575,795次

可以发现,随着学生数量的增加,执行时间也明显相应变大。特别对于函数调用次数,增长的幅度更为明显,并且是绝对值。即不管是什么配置的服务器,函数调用次数都是不变的。那么对于第三组,执行了接近12.9秒,时间都到哪里去了呢?
学过操作系统的同学都知道,每次函数的调用都存在上下文切换的开销,频繁的函数调用,会产生频繁的堆栈操作。这也是为什么C/C++语言会支持内联函数。再来看一下第三组的调用链就能知道大部分时间,接近95.9%都花在了函数的调用上。剩下的4.1%时间则花在了自定义函数本身的执行上。

发表评论