搜索站内内容

← 返回文章列表

算法随笔:Morris 遍历、快速排序与荷兰国旗

从二叉树的线索化遍历到 LC 912 的三路快排,再到颜色分类,理解原地算法如何交换空间、时间与边界复杂度。

云雾在层叠的山岭之间流动

这几种算法表面上没有共同点:一个遍历二叉树,一个排序数组,一个整理三种颜色。但它们都在做同一件事:用更精确的不变量管理原地修改,从而省掉额外空间或避免退化路径。写对主循环不难,真正容易出错的是“指针何时移动”“区间是否还包含边界”“临时结构何时恢复”。

Morris 中序遍历:借用右指针,再原样归还

二叉树中序遍历的常规写法需要递归栈或显式栈,空间是树高 O(h)。Morris Traversal 通过临时把当前节点的中序前驱右指针指回当前节点,建立一条返回路径;当第二次回到当前节点时,再把这条线索拆掉。于是整个过程只用 O(1) 额外空间。

LeetCode Hot 100 中的 94. 二叉树的中序遍历 可以用它完成。规则是:当前节点没有左孩子,访问自己并向右;有左孩子则找到左子树最右节点。若它的右指针为空,连回当前节点并向左;若已经连回当前节点,说明左子树已经处理完,断开连接、访问当前节点、再向右。

public List<Integer> inorderTraversal(TreeNode root) {
    List<Integer> ans = new ArrayList<>();
    TreeNode cur = root;
    while (cur != null) {
        if (cur.left == null) {
            ans.add(cur.val);
            cur = cur.right;
        } else {
            TreeNode pred = cur.left;
            while (pred.right != null && pred.right != cur) pred = pred.right;
            if (pred.right == null) {
                pred.right = cur;       // 第一次到达:建立返回线索
                cur = cur.left;
            } else {
                pred.right = null;      // 第二次到达:恢复树形结构
                ans.add(cur.val);
                cur = cur.right;
            }
        }
    }
    return ans;
}

时间仍是 O(n):虽然有寻找前驱的内层循环,但每条边至多被走常数次。最重要的约束是恢复 pred.right = null,遗漏它会改变原树结构,后续遍历可能陷入环。Morris 适合极端空间限制;日常业务代码若可读性优先,栈写法往往更合适。

Lomuto 分区与 LC 912 的退化问题

快速排序的核心不是“选一个 pivot 然后递归”,而是分区后建立区间不变量。Lomuto 分区把最后一个元素作为 pivot,扫描时维护 [left, i) 都小于 pivot,遇到小元素就扩展该区间;扫描结束后交换 pivot 到 i

int partition(int[] a, int left, int right) {
    int pivot = a[right];
    int i = left;
    for (int j = left; j < right; j++) {
        if (a[j] < pivot) {
            int t = a[i]; a[i] = a[j]; a[j] = t;
            i++;
        }
    }
    int t = a[i]; a[i] = a[right]; a[right] = t;
    return i;
}

它非常容易理解,但对于 LeetCode 912. 排序数组,如果输入已接近有序、pivot 恰好总是最小或最大元素,分区会退化成 n-10,时间 O(n²),递归深度也可能溢出。重复元素同样糟糕:所有等于 pivot 的元素会不断落入同一侧。

实践中的修正有三层:随机选择 pivot,优先递归较短一边以控制栈深度,以及使用三路分区把等于 pivot 的元素一次性跳过。后者特别适合 912 这类大量重复数字的输入。

三路快排与荷兰国旗不变量

三路分区维护四段:[left, lt) 小于 pivot,[lt, i) 等于 pivot,[i, gt] 尚未处理,(gt, right] 大于 pivot。扫描到小元素,交换到左区并同时移动 lti;扫描到大元素,交换到右区,只移动 gt,因为换过来的元素还没检查;相等时只移动 i

void quickSort3(int[] a, int left, int right) {
    if (left >= right) return;
    int pivot = a[left + (int) (Math.random() * (right - left + 1))];
    int lt = left, i = left, gt = right;
    while (i <= gt) {
        if (a[i] < pivot) swap(a, lt++, i++);
        else if (a[i] > pivot) swap(a, i, gt--);
        else i++;
    }
    quickSort3(a, left, lt - 1);
    quickSort3(a, gt + 1, right);
}

荷兰国旗算法正是这个三分思想的更直接版本。LeetCode Hot 100 的 75. 颜色分类 中,数组只有 0、1、2;将 0 放左、2 放右、1 留在中间即可。它的关键细节是遇到 2 后不能移动当前指针,因为从右端交换来的数可能仍是 02

public void sortColors(int[] nums) {
    int zero = 0, cur = 0, two = nums.length - 1;
    while (cur <= two) {
        if (nums[cur] == 0) swap(nums, zero++, cur++);
        else if (nums[cur] == 2) swap(nums, cur, two--);
        else cur++;
    }
}

写这类题时,不要只背交换顺序。先在纸上写出每个指针两侧分别代表什么,再检查每一种交换是否会把“未检查元素”带回当前位置。Morris 的线索、三路快排的等值区、荷兰国旗的中间区,本质上都是靠不变量把临时混乱控制在一个明确范围内。

为什么普通快排在 912 上经常翻车

题目本身不禁止 O(n²),但测试数据会专门覆盖递增、递减、全相同、重复值极多与接近有序等输入。若总取首元素或末元素作 pivot,递增数组每轮只能把一个元素放到最终位置,递归树变成长度为 n 的链。Java 的递归栈随之加深,既会超时,也可能栈溢出。

随机 pivot 不是把最坏情况从理论上消灭,而是让对手难以稳定构造最坏输入;三路分区则真正改善重复值场景。工程实现还可以在小区间改用插入排序,并始终先递归较小半边、用循环处理较大半边,将额外栈深控制在 O(log n)

void quickSortSafe(int[] a, int left, int right) {
    while (left < right) {
        int[] middle = partition3(a, left, right); // [left, middle[0]) < p, (middle[1], right] > p
        if (middle[0] - left < right - middle[1]) {
            quickSortSafe(a, left, middle[0] - 1);
            left = middle[1] + 1;
        } else {
            quickSortSafe(a, middle[1] + 1, right);
            right = middle[0] - 1;
        }
    }
}

这里的细节是区间长度比较,而不是随意选一边递归。把大区间留给 while,调用栈永远只保存较小区间。面试中写不出完整优化没有关系,但要能说明 Lomuto 对重复值的退化、随机 pivot 的目的和三路分区的收益。

三路分区最常见的指针错误

a[i] > pivot 时交换 a[i]a[gt],只能 gt--,不能 i++。因为右边换回来的新元素来自未知区,它还没有被归类。相反,a[i] < pivot 时与 lt 交换后可以移动两个指针:原 lt 位置必然属于等于 pivot 的区,换到 i 后无需再次检查。

可以用几个极小数组手推不变量:[2,0,1] 检查右侧交换后的重检;[1,1,1] 检查等值区是否一次完成;[0,2,0,2] 检查左右边界是否越界;空数组与单元素数组检查循环条件。原地算法的正确性大多藏在这些最小反例里。

Morris 遍历的另一种视角

Morris 并不是“没有栈”,而是把本来压进栈的返回信息暂时编码进树的空指针。中序遍历在第二次遇到节点时访问自己;先序遍历则在第一次建立线索时访问自己。后序遍历更复杂,需要在第二次遇到节点时反转一段右边界并恢复,因此它很适合检验对线索恢复的理解。

它也揭示了一个工程取舍:为了 O(1) 额外空间,算法会短暂修改输入结构。若树可能被并发读取、异常中断后无法保证恢复、或可读性比常数空间更重要,显式栈通常更安全。复杂度从来不是唯一指标;可恢复性和副作用范围同样是算法设计的一部分。

用不变量审查代码

写完之后,可以逐条问自己:循环开始时,哪些区间已经确定?每一个分支结束后,这个条件仍成立吗?退出时,未处理区是否为空?Morris 的前驱指针是否都被清空?三路快排的递归区间是否严格缩小?颜色分类中 cur 是否只越过已检查元素?

这种审查方式比记住模板更可靠。模板换成 Hoare 分区、双指针去重或链表分割后,不变量依旧能带路;只要能说清每个指针守护的区域,就能在边界变化时自己推导出代码。

复杂度之外还要看数据分布

同样标注为平均 O(n log n) 的快排,在不同数据分布下表现差异很大。全相等数组是检验分区策略的试金石:Lomuto 会反复处理几乎完整的区间,三路分区一次就把所有元素放进等值区。近乎有序数组则提醒我们 pivot 选择不能依赖位置。算法题里的随机化不是装饰,它是在输入未知甚至可能带有对抗性时,避免确定策略被轻易击穿。

对于 912,库排序通常更稳妥,因为生产级实现会结合插入排序、堆排序兜底与缓存友好的细节;手写快排的意义在于掌握分区、递归边界与退化原因。面试要求“原地排序”时,再根据重复值比例与最坏情况约束选择双路或三路方案。

一组值得保留的测试

任何排序实现至少跑过:空数组、一个元素、两个逆序元素、全部相同、严格递增、严格递减、含负数、极大重复值与随机长数组。将结果和 Arrays.sort 对拍,是发现边界错误的高效办法。树遍历则可构造只有左链、只有右链、完全树和左右子树交错的树,遍历后再次检查树结构没有被修改。

这些测试不是为了凑覆盖率,而是在验证不变量是否经得住最容易错的边界。能主动构造反例,才算真正掌握了原地算法。

FIELD NOTES / DISCUSS

文章讨论

读完后,欢迎留下你的补充、疑问或不同看法。

247 浏览

全部评论 (0)

正在读取评论…

    GUEST IDENTITY

    设置访客身份

    评论、回复与留言板将复用这份身份。

    选择头像