很多朋友在初学排序算法时,通常先接触递归版本。递归写起来很爽,逻辑也顺,可一旦数据量变大,程序突然就崩了。这时候你可能会想:有没有一种写法,既能保留递归的清晰,又不担心栈溢出?今天我们就从排序这个例子聊聊非递归排序,顺便把尾递归优化、递归与迭代的关系、以及递归转迭代的通用方法一次性讲透。

一、递归排序为什么让人又爱又恨

递归排序最大的优点就是“照着思想写代码”。比如快速排序,核心思想是“分而治之”,用递归表达就是:左边排一下,右边排一下。归并排序也是,把数组分成两半,各自排好,再合并。这种自顶向下的思考方式,跟我们人类的思维习惯非常接近。

可是递归也有一个绕不开的代价:每一次函数调用,系统都要在内存里开出一块区域,叫做“栈帧”。栈帧里放着这个函数的局部变量、参数、返回地址等等。递归多深,系统栈就得叠多高。排序一个十万级别的数组,如果划分得特别不均匀,递归深度可能达到十万层。很多语言默认的调用栈根本扛不住,直接抛出“栈溢出”错误。

二、调用栈:递归背后的“隐形山”

2.1 什么是调用栈

你可以把调用栈想象成食堂里一摞盘子。每次调用一个函数,就像往上面摞一个盘子;函数返回,相当于拿走一个盘子。盘子摞得太高,风一吹就倒,这就是栈溢出。每个盘子就是前面说的栈帧。

在递归排序中,每一层递归不但要保存自己的参数,还要保存当前执行到哪一行,这样递归返回时才知道下一步干什么。比如递归快排里,先递归排序左半部分,等左半部分完成后,还得回到原来的位置继续处理右半部分。这个“回到原来的位置”的信息,就存在栈帧里。

2.2 递归排序的栈消耗

我们来看一个典型的递归快排实现。

// 技术栈:JavaScript
// 分区函数:把数组分成“小于轴点”和“大于轴点”两块
function partition(arr, left, right) {
    const pivot = arr[right];          // 选最右边的元素当轴点
    let i = left;                      // i 指向小于区间的末尾

    for (let j = left; j < right; j++) {
        if (arr[j] < pivot) {
            [arr[i], arr[j]] = [arr[j], arr[i]]; // 交换
            i++;
        }
    }

    // 把轴点放到正确位置
    [arr[i], arr[right]] = [arr[right], arr[i]];
    return i;                          // 返回轴点下标
}

// 递归快速排序
function quickSortRecursive(arr, left = 0, right = arr.length - 1) {
    if (left >= right) return;          // 递归出口

    const pivotIndex = partition(arr, left, right); // 获取轴点位置

    quickSortRecursive(arr, left, pivotIndex - 1);  // 排序左区间
    quickSortRecursive(arr, pivotIndex + 1, right); // 排序右区间
}

这段代码逻辑很漂亮,但极端情况下,比如数组已经有序,而且每次选最后一个元素做轴点,那么每次划分都只能排除一个元素,递归深度直接变成n。n是十万的时候,系统栈要存放十万个栈帧,每个栈帧即使只占几十字节,加起来也是好几兆,甚至超过默认栈大小。这就是递归排序的“隐形山”。

三、尾递归优化:救星还是安慰剂?

3.1 什么是尾递归

尾递归是指递归调用是函数的最后一个操作,并且递归调用的结果直接返回,不再参与其他计算。比如计算阶乘时,普通递归是“先递归再乘”,尾递归则是“先乘再递归”,把累乘结果当成参数传下去。

下面演示一下尾递归风格的阶乘。

// 技术栈:JavaScript
// 普通递归:计算 n! 要先递归,返回后再乘以 n
function factorialNormal(n) {
    if (n <= 1) return 1;
    return n * factorialNormal(n - 1); // 返回后还要做乘法
}

// 尾递归:把每一步的结果缓存在 total 里
function factorialTail(n, total = 1) {
    if (n <= 1) return total;
    return factorialTail(n - 1, n * total); // 递归调用是最后一步
}

普通递归的每一层都需要保留“n”,因为要等递归返回后做乘法。而尾递归的每一层不再需要保留当前帧的任何信息,因为结果已经通过参数传下去了。如果编译器做了尾递归优化,可以复用当前栈帧,把递归变成循环,避免栈增长。

3.2 尾递归优化确实有用

在支持尾递归优化的语言里,尾递归几乎等价于循环。比如 Scala、Kotlin 等 JVM 语言的某些实现,或者开启了优化标志的编译器,都可以把尾递归编译成字节码循环。这样,上面的 factorialTail 在 n 很大时也不会爆栈。

3.3 尾递归优化替代不了迭代的原因

首先,并不是所有递归函数都能改写成尾递归。拿快速排序来说,它有两个递归调用,而且两个调用都必须执行,不是“只有最后一步”。你就算把其中一个递归放到最后,另一个递归仍然占据栈帧,所以它本质上不是尾递归。有一种技巧叫“尾递归快排”,通过手动选择只递归短区间、用循环处理长区间,来把栈深度控制在 logn 级别,但这不是严格的单一尾递归。

其次,很多编程环境根本不支持尾递归优化。JavaScript 虽然规范里提到过尾调用优化,但主流浏览器真正实现的并不多。你写了漂亮尾递归,跑起来照样爆栈。

最后,迭代的控制粒度更细。用循环你可以自由地管理临时变量、提前退出、跳过某些步骤,甚至暂停恢复。递归则受限于函数调用的模型,想中途记录状态或者控制栈上限,往往还要借助自定义栈。

所以我的结论是:尾递归优化是一个好的优化手段,但它既不能拯救所有递归,也不能取代迭代。特别是在“排序”这种需要处理大数据的场景,主动把递归改写成迭代,才是更稳的办法。

四、从栈空间角度比较递归与迭代排序

4.1 快速排序的递归版本栈消耗

递归快排每次调用都会分配固定大小的栈帧。理想情况下,每次划分能把数组对半开,递归深度是 log2(n)。但最坏情况下,递归深度是 n。即使采用随机选轴点,仍存在概率性的深度风险。而且系统栈除了快排自己的帧,还要容纳调用方、其他函数等,所以可用空间更有限。

4.2 迭代快排的栈消耗

迭代快排使用一个显式的数组(或者对象)作为栈。这个栈只保存区间的左右边界,不保存运行时上下文,通常非常轻量。最坏情况下栈里需要保存多少个区间?其实也就是 O(n) 个边界,但每个元素只是两个整数,比完整栈帧小很多。而且这个栈在内存的堆区,不像系统调用栈那样容易被卡死。所以迭代快排在极端情况下不容易爆栈,至少我们能控制它。

4.3 归并排序的递归与迭代

归并排序的递归版本深度很稳定,永远是 log2(n),因为它总是对半划分。但每一层递归都要等待两个子递归的结果,所以系统栈上同时存在的栈帧数也差不多是 log2(n)。这其实不算危险。可是递归带来的函数调用开销依然存在。而自底向上的迭代归并,完全不用递归,只用两层循环,栈空间几乎为零,只额外使用一个辅助数组。性能更稳定,更适合处理超大规模数据。

下面这段代码演示自底向上的归并排序。它先合并长度为1的小块,再合并长度为2、4、8……直到整个数组有序。

// 技术栈:JavaScript
// 合并两个有序区间:arr[left...mid] 和 arr[mid+1...right]
function merge(arr, left, mid, right) {
    const temp = [];                 // 临时数组,用来存合并结果
    let i = left;                    // 左区间起点
    let j = mid + 1;                 // 右区间起点

    // 依次取出较小的数放进 temp
    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp.push(arr[i]);
            i++;
        } else {
            temp.push(arr[j]);
            j++;
        }
    }

    // 把左边剩下的全放进去
    while (i <= mid) {
        temp.push(arr[i]);
        i++;
    }

    // 把右边剩下的全放进去
    while (j <= right) {
        temp.push(arr[j]);
        j++;
    }

    // 把临时数组的内容复制回原数组
    for (let k = 0; k < temp.length; k++) {
        arr[left + k] = temp[k];
    }
}

// 迭代版归并排序:自底向上
function mergeSortIterative(arr) {
    const n = arr.length;
    let size = 1;                     // 当前合并区间的半长度

    while (size < n) {
        for (let left = 0; left < n; left += size * 2) {
            const mid = Math.min(left + size - 1, n - 1);   // 左区间结束
            const right = Math.min(left + size * 2 - 1, n - 1); // 右区间结束

            if (mid < right) {
                merge(arr, left, mid, right); // 合并两个区间
            }
        }
        size *= 2;                    // 每次翻倍,直到覆盖全数组
    }
}

这个迭代版在栈空间上几乎不消耗递归调用栈,辅助数组也是显式分配的,不会出现“一不注意就栈溢出”的尴尬。

五、递归转迭代的通用方法

既然递归在栈空间上有这些风险,把递归改成迭代就成了一个实用技能。下面分享三个通用套路。

5.1 方法一:用显式栈模拟系统调用

最直接的办法就是自己准备一个栈,把原来递归里需要保存的“参数”和“执行到哪一步”的信息存进去。以快速排序为例,递归需要保存的是左右边界。那么我们在栈里就放区间边界,用 while 循环不停地取出区间、处理区间、再把新的区间压回栈中。

// 技术栈:JavaScript
// 非递归快速排序:使用显式栈
function quickSortIterative(arr) {
    const stack = [];                 // 自定义栈,存放待排序区间
    stack.push([0, arr.length - 1]);  // 初始区间:整个数组

    while (stack.length > 0) {
        const [left, right] = stack.pop(); // 取出一个区间

        if (left >= right) continue;       // 区间内没有元素或只有一个,跳过

        const pivotIndex = partition(arr, left, right); // 分区

        // 这里注意压栈顺序:因为栈是后进先出
        // 先压右区间,再压左区间,这样左区间先被取出处理
        if (pivotIndex + 1 < right) {
            stack.push([pivotIndex + 1, right]);
        }
        if (left < pivotIndex - 1) {
            stack.push([left, pivotIndex - 1]);
        }
    }
}

这种方法的优点是能保留原递归的结构,逻辑清晰;缺点是你要手动管理好状态,否则容易漏掉某些区间。

5.2 方法二:把尾递归直接改写成循环

如果递归本身是尾递归,那改起来最简单。把递归调用换成更新循环变量,再用 while 包住函数体即可。比如阶乘:

// 技术栈:JavaScript
// 尾递归改写成循环
function factorialIterative(n) {
    let total = 1;
    while (n > 1) {
        total = total * n;   // 相当于 factorialTail(n - 1, n * total)
        n--;
    }
    return total;
}

对于排序算法来说,纯粹的尾递归其实不多。但我们可以把快排改造成“带循环的递归”,也就是手动选择较短的区间递归,较长的区间用循环处理。这种“混合版”也能让栈深度变成 O(log n)。

// 技术栈:JavaScript
// 混合版快速排序:短区间递归,长区间迭代
function quickSortHybrid(arr, left = 0, right = arr.length - 1) {
    while (left < right) {
        const pivotIndex = partition(arr, left, right);

        // 如果左区间比右区间短,就递归左区间,然后迭代右区间
        if (pivotIndex - left < right - pivotIndex) {
            quickSortHybrid(arr, left, pivotIndex - 1);
            left = pivotIndex + 1;
        } else {
            // 否则递归右区间,然后迭代左区间
            quickSortHybrid(arr, pivotIndex + 1, right);
            right = pivotIndex - 1;
        }
    }
}

这种写法仍然有递归调用,但深度受 O(log n) 限制,适合那些不想彻底改成显式栈的场景。严格说它不完全等价于迭代,但可以看作递归向迭代过渡的中间形态。

5.3 方法三:从“处理状态”的角度重新设计

有些递归算法自带一种对应的迭代方案,甚至比模拟递归更高效。归并排序就是一个典型。递归版是从最大区间往下拆,迭代版则直接最小区间往上合并。这就是“自顶向下”与“自底向上”的区别。

自底向上的归并排序不关心递归树,直接按长度 1、2、4、8……不断合并。整个过程不需要栈,只需要一个循环控制“步长”,一个内层循环控制“区间起点”。我们前面写过的 mergeSortIterative 就是这种思路。所以当你觉得模拟栈很麻烦时,不妨退一步想想:这个算法能不能换一种从底向上的构造方式?往往能打开新思路。

六、完整演示:三种排序代码跑起来

下面我们用一个完整的 JavaScript 程序,同时测试前面写的递归快排、迭代快排和迭代归并排序,并输出每次排序的结果。这样你能更直观地看到它们的行为差异。

// 技术栈:JavaScript
// 完整演示:递归快排、迭代快排、迭代归并排序

// ---------- 分区函数(供快排使用) ----------
function partition(arr, left, right) {
    const pivot = arr[right];
    let i = left;

    for (let j = left; j < right; j++) {
        if (arr[j] < pivot) {
            [arr[i], arr[j]] = [arr[j], arr[i]];
            i++;
        }
    }

    [arr[i], arr[right]] = [arr[right], arr[i]];
    return i;
}

// ---------- 递归快速排序 ----------
function quickSortRecursive(arr, left = 0, right = arr.length - 1) {
    if (left >= right) return;

    const pivotIndex = partition(arr, left, right);

    quickSortRecursive(arr, left, pivotIndex - 1);
    quickSortRecursive(arr, pivotIndex + 1, right);
}

// ---------- 迭代快速排序 ----------
function quickSortIterative(arr) {
    const stack = [];
    stack.push([0, arr.length - 1]);

    while (stack.length > 0) {
        const [left, right] = stack.pop();

        if (left >= right) continue;

        const pivotIndex = partition(arr, left, right);

        if (pivotIndex + 1 < right) {
            stack.push([pivotIndex + 1, right]);
        }
        if (left < pivotIndex - 1) {
            stack.push([left, pivotIndex - 1]);
        }
    }
}

// ---------- 合并函数(供归并排序使用) ----------
function merge(arr, left, mid, right) {
    const temp = [];
    let i = left;
    let j = mid + 1;

    while (i <= mid && j <= right) {
        if (arr[i] <= arr[j]) {
            temp.push(arr[i]);
            i++;
        } else {
            temp.push(arr[j]);
            j++;
        }
    }

    while (i <= mid) {
        temp.push(arr[i]);
        i++;
    }

    while (j <= right) {
        temp.push(arr[j]);
        j++;
    }

    for (let k = 0; k < temp.length; k++) {
        arr[left + k] = temp[k];
    }
}

// ---------- 迭代归并排序 ----------
function mergeSortIterative(arr) {
    const n = arr.length;
    let size = 1;

    while (size < n) {
        for (let left = 0; left < n; left += size * 2) {
            const mid = Math.min(left + size - 1, n - 1);
            const right = Math.min(left + size * 2 - 1, n - 1);

            if (mid < right) {
                merge(arr, left, mid, right);
            }
        }
        size *= 2;
    }
}

// ---------- 测试 ----------
const data1 = [9, 2, 8, 1, 7, 3, 6, 4, 5];
const data2 = [...data1]; // 复制一份,避免互相影响
const data3 = [...data1]; // 再复制一份

quickSortRecursive(data1);
quickSortIterative(data2);
mergeSortIterative(data3);

console.log('递归快排结果:', data1.join(','));
console.log('迭代快排结果:', data2.join(','));
console.log('迭代归并结果:', data3.join(','));

运行这段代码,你会看到三个结果都是 1,2,3,4,5,6,7,8,9。说明不同实现虽然内部机制不一样,最终效果是一致的。

七、应用场景与优缺点对比

7.1 递归排序的应用场景

递归排序适合小规模数据,或者在教学、快速验证算法思想时使用。它的代码可读性高,几乎和算法描述一一对应。如果数据规模不大,比如几千个元素,递归快排完全没问题。很多高级语言也提供了递归排序的库实现,内部会自动优化或限制深度。

7.2 迭代排序的应用场景

迭代排序更适合生产环境,特别是大数据量、内存受限、或对性能稳定性要求高的场景。比如处理几百万条记录的日志文件,或者做嵌入式系统中对栈空间要求苛刻的排序,迭代版本能让我们更精确地预测内存占用。另一个典型场景是流式处理:数据不断进来,希望保持有序。自底向上的归并排序天然适合分块合并,所以迭代版本更实用。

7.3 优缺点对比

递归排序的优点是代码短、逻辑直白、容易组合。缺点是调用栈不可控,极端情况下会爆栈,且函数调用本身的性能开销略大。迭代排序的优点是栈空间可预测,大部分情况下不会爆栈,性能更稳;缺点是代码往往更复杂,状态管理更容易出错。两者并没有绝对的好坏,关键看你的约束条件。如果追求开发效率和可读性,递归优先;如果追求稳定和极限性能,迭代优先。

八、注意事项

第一,使用显式栈模拟递归时,压栈顺序决定处理顺序。如果希望保持和原来递归一致(先处理左区间),就需要先把右区间压进去。不然处理顺序反了,排序结果通常没问题,但某些依赖处理顺序的逻辑会有影响。

第二,迭代归并排序要小心边界计算。特别是数组长度不是 2 的幂时,mid 和 right 要取 min,防止数组越界。计算区间时建议多用变量名,少用魔法数字。

第三,不要以为迭代版就一定比递归版快。递归版在某些场景下因为局部性更好、函数内联优化等,可能反而更快。迭代版的主要优势在于“栈安全”和“可预测性”,而不仅是速度。

第四,如果递归深度可控,完全没必要强行改成迭代。比如平衡树相关的遍历,深度只有 logn,递归写起来更舒服。非递归只是工具,不是目的。

第五,JavaScript 这类语言里,数组的 push/pop 是 O(1) 的,适合用来模拟栈。但要注意,如果频繁创建区间数组 [left, right] 会产生大量垃圾对象。为了更高性能,你可以用两个平行栈分别存 left 和 right,或者用索引存。这里为了可读性用了数组,生产环境可以再优化。

九、文章总结

递归排序和迭代排序本质上是同一种算法思想的两种表达方式。递归写起来爽,但栈空间是我们无法忽视的成本。尾递归优化虽然能缓解部分问题,但并不能完全替代迭代,因为它既不能覆盖所有递归,也不保证每个运行环境都支持。从栈空间角度看,递归排序的最大深度取决于划分情况,而迭代排序通过显式栈或自底向上的循环,把栈消耗控制在更安全的范围内。把递归转成迭代并不神秘,核心就是“自己管理状态”:要么用栈模拟系统调用,要么把尾递归改写成循环,要么换一种自底向上的构造方式。掌握这些方法之后,你遇到类似的问题就不必再怕爆栈了。希望今天的分享能让你在写出正确代码的同时,还能多一份对底层机制的理解。