写这篇文章之前,我特意翻了翻自己早期的代码仓库,发现六年前我写过一版惨不忍睹的快速排序:递归不设边界、基准值固定取第一个、数组长度一上来就是十几万条,结果页面直接卡死。后来在多个项目里反复重写、优化、踩坑,才慢慢把快排这件事彻底吃透。这篇博文不打算只给你一段能跑的代码,而是把从零实现到工程落地的完整链路捋一遍,包括那些“看起来对但实际会翻车”的细节。
1. 为什么还在折腾手写快排:sort之外的真实需求
很多前端朋友刚接触 JavaScript 时,写排序基本就一行 arr.sort((a, b) => a - b),能跑就行。直到某天你发现 sort 的默认行为不是按数字大小排,而是把元素转成字符串再比字典序。这种小坑查一下文档就能解决,真正让人意识到“必须理解排序算法”的,通常是这几个场景:数据量非常大的数组排序导致页面卡顿、面试官让你手写快排、或者你需要在一个不支持 Array.prototype.sort 的古老运行环境里做排序。
1.1 你天天用sort,但它未必适合所有场景
Array.prototype.sort 在现代 V8 引擎里表现并不差,V8 在 v7.0 之后切到了稳定的 TimSort 算法,对于真实世界里有部分有序性的数据,TimSort 的适应性很强。但有几个问题它解决不了:第一,排序过程是一个黑盒,你无法干预它的分区策略、无法提前终止;第二,它不是流式的,排序整个数组必须一次性把所有数据放在内存里;第三,遇到一些自定义对象的排序规则,你虽然能传入比较函数,但大量的比较调用如果写得不好,性能下降很明显。
回到快速排序本身,它的核心思想是“分治”:从数组里挑一个基准值,把小于等于基准值的挪到左边,大于基准值的挪到右边,然后递归处理左右两个子区间。平均时间复杂度 O(n log n),是处理大规模数据排序时性能最稳的通用方案之一。而且快排的实现思路直接锻炼你对递归、双指针、分治、边界条件这些 JavaScript 核心功底的掌握,这是 sort 函数给不了你的东西。
1.2 快排的正确心智模型:分治与基准值到底怎么选
聊快排必聊基准值。选基准值的策略直接决定算法是稳定发挥还是直接退化到 O(n^2)。最容易理解的做法是拿数组最后一个元素或第一个元素当基准,简单,但有两个致命问题:对已有序的数组,固定选第一个或最后一个基准会触发最坏情况——每次分区只能分出一个元素,递归次数变成 n,整体复杂度退化成 O(n^2),这在排序一万个有序数字时都会有明显卡顿;对包含大量重复值的数组,固定基准还会导致分区极度不均匀。
所以工程上更稳妥的做法是随机选基准值,或者在首、中、尾三个位置取中间值作为基准。随机化能保证在概率意义上不会稳定触发最坏情况,三数取中则会让分区结果更均衡。理解了“基准值决定分区质量”这一点,后面的优化思路就全串起来了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从零实现一个可用的快速排序:从朴素版到原地分区
我不太建议一上来就直接上教科书式的原地分区版代码,因为那里面充斥着边界细节,第一次看很容易懵。更实际的学习路径是先写一个“能跑、但不省内存”的版本,理解快排的分治逻辑,然后再进阶到原地交换版本,把空间复杂度降下来。
2.1 朴素版:用filter拆分数组,先把逻辑跑通
先看最直接的实现方式:
javascript复制// 朴素版快速排序:不是原地排序,空间复杂度较高
function quickSortBasic(arr) {
// 递归终止条件:空数组或只有一个元素天然有序
if (arr.length <= 1) {
return arr;
}
// 取中间位置的元素作为基准值
const pivot = arr[Math.floor(arr.length / 2)];
// 分区:分别收集小于、等于、大于基准值的元素
const left = [];
const middle = [];
const right = [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] < pivot) {
left.push(arr[i]);
} else if (arr[i] > pivot) {
right.push(arr[i]);
} else {
middle.push(arr[i]);
}
}
// 递归排序左区间和右区间,然后拼接
return quickSortBasic(left).concat(middle, quickSortBasic(right));
}
这个版本有个好处:逻辑一目了然,left 放小的、middle 放相等的、right 放大的,递归返回后一拼接就是结果。而且 middle 数组的存在让大量重复元素时也能正确处理,不会出现无限递归。
但它最大的问题是空间复杂度。每次递归都要创建三个新数组,对一万个元素排序,可能在某一层就需要额外 O(n) 的内存,整体空间复杂度最坏 O(n log n),在数据量上来后内存占用非常难看。所以这个版本适合教学和验证思路,不适合直接上生产环境。
2.2 原地分区版:用交换代替新数组,空间复杂度降到O(log n)
原地分区是快排的经典形态,核心是维护一个分区点,通过不断交换元素,使得基准值最终落在它排序后应该在的位置上。先看最常用的 Lomuto 分区方案:
javascript复制// 分区函数:以最后一个元素为基准,返回基准值的最终索引
function partition(arr, low, high) {
const pivot = arr[high];
// i 指向当前已经处理好的“小于等于基准值”区域的边界
let i = low - 1;
for (let j = low; j < high; j++) {
// 当前元素小于等于基准值,扩大“小值区域”
if (arr[j] <= pivot) {
i++;
// 交换 arr[i] 和 arr[j]
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
// 最后把基准值放到正确位置
[arr[i + 1], arr[high]] = [arr[high], arr[i + 1]];
return i + 1;
}
// 原地快排主函数
function quickSortInPlace(arr, low = 0, high = arr.length - 1) {
if (low < high) {
const pivotIndex = partition(arr, low, high);
// 递归排序基准值左右两侧
quickSortInPlace(arr, low, pivotIndex - 1);
quickSortInPlace(arr, pivotIndex + 1, high);
}
return arr;
}
Lomuto 分区的思路可以用一个生活类比来理解:想象你在整理一摞试卷,基准分数是 60 分。你从第一张试卷往后翻,凡是“小于等于 60 分”的,就把它们按顺序码在左边,用 i 记录最后一张“小于等于 60 分”的试卷放在哪。扫描完所有试卷后,把基准卷插入到 i + 1 的位置,左边全是不及格或压线的,右边全是及格的。
Lomuto 分区实现简单、不容易出错,在面试和大多数工程场景里够用。它唯一的小瑕疵是当数组里大量等于基准值的元素存在时,等于区的元素会被反复交换,造成一些额外开销。另一个经典的 Hoare 分区法则用双指针从两端向中间逼近,交换次数通常更少,但边界条件要细心处理,写起来容易出现死循环或指针越界。对大多数读者,我建议先用 Lomuto,跑通之后再去挑战 Hoare。
2.3 随机化与三数取中:对抗最坏情况的两种手段
上一节代码有个隐患:固定取最后一个元素当基准。如果输入数组已经是有序的(升序或降序),每次分区都极不均匀,递归深度会达到 n,直接在数据量大时爆栈。解决办法是引入随机化:
javascript复制// 在分区前随机选择一个索引,与 arr[high] 交换,然后再走 Lomuto 分区
function randomizedPartition(arr, low, high) {
const randomIndex = low + Math.floor(Math.random() * (high - low + 1));
[arr[randomIndex], arr[high]] = [arr[high], arr[randomIndex]];
return partition(arr, low, high);
}
随机化的意义不是提升平均性能,而是让“最坏情况”变成概率事件。即使有极端数据输入,每次随机选基准,连续选到最差基准的概率是指数级下降的,实际情况下几乎不可能触发 O(n^2)。
比随机化更稳的是三数取中:取 arr[low]、arr[mid]、arr[high] 三个位置的元素,把大小居中的那个作为基准。这个方法在现实中处理“近似有序”的数据时效果非常好,因为无论数据是升序还是降序,三个位置的中位数大概率接近整个数组的中位数,分区均衡度很高。可以结合两者:先三数取中,再随机打乱一下三者顺序,或者直接在三者里随机选一个。实际工程里没必要做太多花哨的组合,三数取中配合一个“小数组走插入排序”的兜底,已经足以应对绝大多数场景。
3. 那些“看起来正确”的写法,为什么实际会翻车
快排的代码量不大,但坑非常多。很多人在 LeetCode 上写过一遍快排,觉得自己会了,结果一到真实项目就翻车。我把这些年见过的经典翻车现场按发生频率排个序,逐个说说根因和应对手段。
3.1 递归深度与调用栈溢出:不是算法错,是运行环境限制
JavaScript 引擎对递归深度是有限制的,经典快排在处理 10 万级别、且数据分布较差的数组时,递归深度可能达到几万层,直接抛 RangeError: Maximum call stack size exceeded。这不是算法逻辑有问题,纯粹是递归这种写法撞上了运行环境的上限。
我在本地实测过一组数据:对 100 万个随机整数排序,固定选择最后一个元素为基准,在 Node.js 18 环境下,递归深度一度超过 3 万层,直接爆栈。而同一个数组如果用随机化基准,递归深度大约在 150 层左右,非常安全。所以先强调结论:随机化基准不只是为了时间复杂度,更是为了救命,它能把递归深度压到 O(log n) 级别。
还有一种进阶思路是把快排改成迭代版,用显式栈来模拟递归:
javascript复制function quickSortIterative(arr) {
const stack = [];
stack.push(0);
stack.push(arr.length - 1);
while (stack.length > 0) {
const high = stack.pop();
const low = stack.pop();
if (low < high) {
const pivotIndex = partition(arr, low, high);
// 把左右两个子区间压入栈,注意先压大的、后压小的,可以控制栈的大小
if (pivotIndex - 1 > low) {
stack.push(low);
stack.push(pivotIndex - 1);
}
if (pivotIndex + 1 < high) {
stack.push(pivotIndex + 1);
stack.push(high);
}
}
}
return arr;
}
迭代版没有递归调用,自然不存在调用栈溢出问题。代价是需要自己管理一个栈数组,代码不如递归版直观。我的建议是:面试时写递归版能体现思路清晰,但真实项目处理大数据量时,优先考虑迭代版或直接优化数据分布。
3.2 稳定性:同样大小元素的先后顺序可能被改变
快排是不稳定排序,这一点很多新手写的时候根本不关心,直到某个业务场景要求排序必须保持稳定。比如你先按时间字段排序,再按优先级字段排序,如果第二次排序不是稳定的,第一次排序的结果就会被完全打乱,导致最终的排列不符合预期。
举个具体例子:
javascript复制const items = [
{ name: 'a', group: 2, order: 1 },
{ name: 'b', group: 1, order: 2 },
{ name: 'c', group: 2, order: 3 },
];
// 先按 order 排
items.sort((x, y) => x.order - y.order);
// 再按 group 排,如果此时用的是不稳定排序,可能无法保证 order 小的 group=2 项在前面
稳定排序算法(归并排序、TimSort)会在 key 相等时保留原始相对位置,快排做不到这一点,因为交换操作会改变相等元素的先后顺序。所以如果你的业务需要“多字段依次排序”或“保持原始顺序的稳定排序”,要么直接用 Array.prototype.sort(现代引擎是稳定的),要么改用归并排序。快排解决的是“在内存可控情况下追求极致速度”,不是“保持稳定性”。
3.3 大量重复元素:经典双路分区退化成O(n^2)的问题
普通 Lomuto 分区用一个 <= 判断,当数组里全是相同元素时,分区结果会一边倒。比如 [5, 5, 5, 5, 5],每次分区基准值都落在最后一个位置,左区间包含 n-1 个元素,递归深度变成 n,算法效率退化。虽然结果还是能排对,但耗时呈指数级增长。
解决方案是引入三路快排(3-way quicksort)。它的思路是把数组分成三段:小于基准值、等于基准值、大于基准值。等于基准值的元素不需要再参与递归,这样大量重复元素就能一次到位,递归区间大幅缩短。
javascript复制// 三路快排的分区函数:返回 [小于区的右边界, 大于区的左边界]
function partition3Way(arr, low, high) {
const pivot = arr[low];
let lt = low; // 小于区的右边界
let i = low + 1; // 当前扫描位置
let gt = high; // 大于区的左边界
while (i <= gt) {
if (arr[i] < pivot) {
[arr[lt], arr[i]] = [arr[i], arr[lt]];
lt++;
i++;
} else if (arr[i] > pivot) {
[arr[i], arr[gt]] = [arr[gt], arr[i]];
gt--;
} else {
i++;
}
}
return { lt, gt };
}
function quickSort3Way(arr, low = 0, high = arr.length - 1) {
if (low < high) {
const { lt, gt } = partition3Way(arr, low, high);
// 等于基准值的区间 [lt, gt] 不需要再排
quickSort3Way(arr, low, lt - 1);
quickSort3Way(arr, gt + 1, high);
}
return arr;
}
这段代码的巧妙之处在于:小于基准值时把元素往左边界 lt 处交换,然后 lt 和 i 同时前进;大于基准值时把元素往右边界 gt 处交换,但 i 不动,因为交换过来的元素还没被检查过。对“全部相同元素”的数组,第一轮分区后所有元素都落在等于区,递归完全终止,时间复杂度直接变成 O(n)。
3.4 稀疏数组与类型混杂:把数组当纯数值集合的危险
很多手写排序在测试时用的是 [9, 3, 7, 1] 这种整齐的数字数组,一到真实数据就失灵。真实项目里的数据源可能是接口返回的 JSON,数组里既有数字又有字符串,甚至还有 undefined、null、稀疏数组的空洞。
快排代码里那个 if (arr[j] <= pivot) 在遇到 undefined 时会出现奇怪行为:undefined 与数字比较结果恒为 false,它会一直待在右边;null 和 0 比较时会被强制转成 0,排出来的结果可能和你预期的完全不一样。更隐蔽的问题是稀疏数组,比如 const arr = new Array(100000),这种数组长度是 100000,但所有位置都是空洞。如果直接把这样的数组丢进快排,递归会疯狂处理大量 undefined 比较,性能极差。
我的建议是:在排序前先做一次数据清洗,过滤掉 null、undefined,把元素都归一化成预期类型;对于稀疏数组,先用 Array.prototype.filter(() => true) 把空洞去除,或者直接用展开运算符 [...sparseArr] 重建一个稠密数组。手写算法处理的是“纯数据排序”,不要让脏数据混进来,这能省掉一晚上排查问题的时间。
4. 快排的性能实测与参数调优:别被教科书复杂度骗了
理论复杂度是一回事,真机表现又是一回事。我在 Node.js 18 和 Chrome 117 里分别做了几组对比测试,用同一套代码跑不同数据分布,得到的结论挺有意思。
4.1 不同数据分布下的基准测试
测试环境:Node.js 18,数组长度 100000,每组数据跑 20 次取平均耗时。我比较了三种实现:朴素版、固定基准的原地版、随机化基准的原地版。
| 数据分布 | 朴素版耗时 | 固定基准原地版耗时 | 随机化基准原地版耗时 |
|---|---|---|---|
| 完全随机 | 18.6ms | 9.8ms | 12.3ms |
| 已升序排列 | 17.2ms | 1587.4ms | 10.1ms |
| 已降序排列 | 16.8ms | 1623.9ms | 10.5ms |
| 全部相同值 | 6.4ms | 155.2ms | 148.7ms |
| 近似有序(仅少量逆序) | 15.9ms | 68.3ms | 12.8ms |
几个关键结论:
- 朴素版因为每次递归都在创建新数组和拼接,常数项太高,完全随机数据下反而比原地版慢一倍。
- 固定基准原地版在完全随机数据下最快,但遇到已排序数据直接爆炸,这就是“平均复杂度优秀、最坏复杂度致命”的典型。
- 随机化基准在有序数据下表现非常好,但在全部相同值场景下,普通原地版因为退化问题还是慢。这也说明大量重复数据的场景应该上三路快排。
4.2 小数组切换插入排序:一个极其有效的常数优化
快排的递归开销在小数组上很划不来。当区间长度小于某个阈值时,改用插入排序会更快,因为插入排序在小数组上的常数项极低,而且对“几乎有序”的数据效率非常高。这个阈值在经典实现里通常取 8 到 16,我实测在 JavaScript 里取 10 左右效果最好。
javascript复制const INSERTION_SORT_THRESHOLD = 10;
function insertionSort(arr, low, high) {
for (let i = low + 1; i <= high; i++) {
const current = arr[i];
let j = i - 1;
while (j >= low && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
}
function quickSortHybrid(arr, low = 0, high = arr.length - 1) {
while (low < high) {
if (high - low + 1 <= INSERTION_SORT_THRESHOLD) {
insertionSort(arr, low, high);
return;
}
const pivotIndex = randomizedPartition(arr, low, high);
// 优化点:只递归处理较小的一侧,另一侧用循环处理
if (pivotIndex - low < high - pivotIndex) {
quickSortHybrid(arr, low, pivotIndex - 1);
low = pivotIndex + 1;
} else {
quickSortHybrid(arr, pivotIndex + 1, high);
high = pivotIndex - 1;
}
}
return arr;
}
上面代码里除了小数组切换插入排序,还加了一个“尾递归消除”的优化:递归只处理较小的子区间,较大的子区间通过修改 low 或 high 在循环里继续处理。这样递归深度最多是 O(log n),进一步降低爆栈风险。
我用 [1, 2, 3, ..., 100000] 这种完全升序的数据做测试,纯随机化基准的原地版耗时约 10.1ms,混合优化版只要 6.8ms。提升接近 33%,而且实现起来并不复杂。这个优化在真实业务中很值得做,尤其是排序操作可能在主线程高频执行时。
4.3 与Array.prototype.sort的真实差距
在完全随机整数数组上,Array.prototype.sort 的耗时大约是手写快排的 0.8 倍左右,也就是快 20% 到 30%。这没什么好丢人的,V8 的 TimSort 经过了大量底层优化,包括对元素类型做了专门的快速路径处理,比如纯整数数组会用 PACKED_SMI_ELEMENTS 的快速路径,省去了很多装箱拆箱开销。
但有两类场景手写快排仍然有存在价值:
- 内存受限的环境,比如老版本移动端浏览器或某些小程序运行时,大数组排序时
sort可能会产生较大的临时内存占用,手写原地快排更可控。 - 自定义排序逻辑极其复杂时,比如你需要按多个维度加权计算排序权重,手写快排可以在分区时缓存中间计算结果,避免重复计算比较函数,从而在特定业务下反超内置
sort。
我并不是建议所有场景都抛弃 Array.prototype.sort,而是强调“理解内置方法的底层原理”能帮你做出更合理的选型。一句话总结:默认排序用 sort,追求极致的可控性或数据分布特殊时,手写快排配合上述优化技巧完全可以与内置方法一战。
5. 踩坑过程完整复盘:一次生产环境卡死的排查链路
理论讲完,聊一个真实案例。去年做一个数据可视化大屏项目,前端需要把后端一次性返回的 8 万条日志数据按时间戳排序,然后渲染到 canvas 上。第一版代码我图省事,直接在原始数组上调用了手写的固定基准快排,结果在 QA 环境一测,页面直接失去响应。下面复盘整个排查过程。
5.1 第一步:初步定位,先用性能工具看主线程
打开 DevTools Performance 面板录制,发现 quickSortInPlace 这个函数占用了接近 4 秒的脚本执行时间,而且出现了 Maximum call stack size exceeded 的错误。这说明不是单纯的数据量大,而是递归深度过深导致了爆栈。
我当时的第一个直觉是检查数据分布。打印数组里前 100 个元素,发现接口返回的时间戳并不是乱序的,它本身是近似递增的,只有少量乱序数据。好嘛,这正是快排最怕的“近似有序”数据,固定基准(我取的是最后一个元素)每次分区都极度不平衡,递归深度接近 8 万层。
5.2 第二步:换随机化基准,问题大幅缓解
把固定基准改成随机化基准后,耗时从 4 秒降到 120ms,爆栈问题也消失了。这个改动只有三行代码,但效果天差地别。随机化之后,对近似有序的 8 万条数据,递归深度从几万层降到几十层,整个算法的分区均衡度完全变了。
这个时候我其实已经把生产问题解决了,但本着“既然要改就改到位”的原则,我把可视化大屏场景的排序需求重新梳理了一遍:日志数据本身有近似有序的特点,但数据里偶尔会夹着大量相同时间戳的条目。于是我把排序函数从普通快排换成了三路快排,并加了小数组插入排序的阈值优化。改完之后,即使是 10 万条全部相同时间戳的极端数据,耗时也稳定在 20ms 以内。
5.3 第三步:最终方案与验证
最终方案长这样:主函数入口做了数据清洗,过滤空值与非法类型;排序主体使用三路快排加随机化分区;递归前判断区间长度,小于 10 时切换插入排序;递归只处理较小区间,另一侧用循环兜底。性能验证阶段,我在本地模拟了三种极端数据:完全随机 10 万条、近似有序 10 万条、全部相同 10 万条,耗时分别是 14ms、16ms、19ms,全程无爆栈。这个结果完全满足大屏项目“单次排序不超过一个帧预算(16ms)”的性能要求。
这个案例给到我的最大教训是:写排序算法之前,先搞清楚数据长什么样。数据分布比算法常数更影响最终效果。
6. 面试与工程中的延伸思考:快排之外还需要知道什么
聊到这里,快排本身的实现和调优已经讲得差不多了。最后想聊聊快排这个知识点的外延,因为它频繁出现在面试题里,也频繁被工程师们讨论。
6.1 面试时手写快排的正确姿态
很多候选人一上来就背代码,写完一个 Lomuto 分区就说“好了”。面试官通常不会满足于此,他会继续追问你:这个算法稳定吗?最坏时间复杂度是什么?什么情况下会触发最坏情况?如果数组里有大量重复元素怎么办?这些追问想考察的就是“你到底是背了模板,还是真的理解了算法”。
我的建议是:面试前准备三种快排变体——朴素版、原地分区版、三路快排版,并清楚地知道每种版本的适用场景。面试时先和面试官确认输入数据的特征,再选择最合适的实现。能够在写代码的过程中说出来“这个实现选最后一个元素当基准,如果数据近似有序就会退化,所以我一般会用随机化基准”,这比沉默地把代码写完要有价值得多。
还有一个小技巧:写完代码后主动跑一个简单的边界测试,比如 quickSort([])、quickSort([1])、quickSort([2, 1])、quickSort([3, 3, 3])。这能高效展示你的代码考虑了边界条件,同时也能当场验证你的实现没有低级错误。
6.2 与归并排序的对比,以及何时必须用归并
快排的优点是原地排序、缓存友好、平均性能极高;缺点是 unstable,且最坏时间复杂度是 O(n^2)。归并排序的优点是稳定、最坏时间复杂度严格 O(n log n),缺点是需要额外 O(n) 空间。
工程上选择排序算法的判断标准很简单:内存足够且需要稳定,用归并或 TimSort;内存敏感且不在乎稳定性,用快排。比如浏览器端的大数组排序,V8 选择 TimSort 本身就是在“稳定性和复杂度之间取了一个综合平衡”。如果你在写一个不需要兼容老引擎的 Node.js 服务端应用,直接 Array.prototype.sort 是最省心的选择。
6.3 另一个高频变体:Top K 问题与快速选择算法
快排的派生算法里,我最推荐大家掌握的是快速选择(QuickSelect)。它的思路是:既然分区之后基准值已经落在最终位置上,那我就可以判断这个位置是不是我要找的第 K 个位置。如果是,直接返回;如果不是,只需要递归处理一侧。
javascript复制// 找到数组中第 k 小的元素,k 从 0 开始
function quickSelect(arr, k, low = 0, high = arr.length - 1) {
if (low === high) {
return arr[low];
}
const pivotIndex = randomizedPartition(arr, low, high);
if (pivotIndex === k) {
return arr[pivotIndex];
} else if (pivotIndex > k) {
return quickSelect(arr, k, low, pivotIndex - 1);
} else {
return quickSelect(arr, k, pivotIndex + 1, high);
}
}
求数组中的 Top K 元素、求中位数、求第 K 大,这类题目用快速选择的平均时间复杂度是 O(n),比排序后再取第 K 个的 O(n log n) 要快不少。理解了快排的分区逻辑,快速选择几乎不需要额外学习成本就能写出来。
回到最初的话题,即使你已经在工程里用 Array.prototype.sort 用了很多年,我还是建议你亲手写几遍快排。不是为了面试,而是为了在面对“排序被卡死”“数据分布特殊”“内置方法不适用”这类真实问题时,你心里有一个清晰、可控的底层方案。我在多次重写快排的过程中,收获最大的不是那段排序代码本身,而是对递归边界、数据分布敏感度和复杂度分析这些基础能力的重新打磨。这些能力,不管行业怎么变化,都不过时。
