ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原地排序算法:双指针与计数法实战解析

原地排序算法:双指针与计数法实战解析

1. 题目解析与需求拆解

这道题目要求我们对一个仅包含0、1、2三种元素的数组进行原地排序,使得所有0排在前面,1居中,2在末尾。题目有几个关键约束条件:

  • 必须原地排序,不能使用额外空间(空间复杂度O(1))
  • 不能直接调用语言内置的排序函数
  • 数组长度n的范围是1到300

在实际工程中,这类问题常见于需要快速分类的场景,比如电商平台根据商品状态(未上架/在售/已下架)进行筛选,或者日志系统中根据优先级(低/中/高)快速过滤信息。

2. 计数排序法详解

2.1 算法原理与实现

计数法的核心思想是先统计每种颜色出现的次数,然后按照0、1、2的顺序重新填充数组。这种方法的优势在于思路直观,容易理解和实现。

class Solution { public void sortColors(int[] nums) { int red = 0, white = 0, blue = 0; // 第一次遍历:统计各颜色数量 for (int num : nums) { if (num == 0) red++; else if (num == 1) white++; else blue++; } // 第二次遍历:重新填充数组 Arrays.fill(nums, 0, red, 0); Arrays.fill(nums, red, red + white, 1); Arrays.fill(nums, red + white, nums.length, 2); } }

2.2 时间复杂度分析

  • 第一次遍历:O(n)时间统计颜色数量
  • 第二次填充:虽然看起来是三个操作,但每个元素只被写入一次,总体仍是O(n)
  • 总时间复杂度:O(2n) = O(n)

注意:Arrays.fill()内部实现也是线性时间,但因为它只涉及内存写入,实际运行时会比常规遍历快很多。

2.3 适用场景与局限性

计数法特别适合:

  • 元素取值范围很小且已知的情况
  • 需要稳定排序的场景(虽然本题不要求)
  • 对代码可读性要求较高的场景

局限性在于:

  • 需要两次遍历(虽然时间复杂度相同)
  • 对于元素取值范围大的情况不适用

3. 双指针法深度解析

3.1 算法原理与实现

双指针法通过维护两个指针p0和p2,分别表示下一个0应该存放的位置和下一个2应该存放的位置。遍历数组时,将遇到的0交换到p0位置,将遇到的2交换到p2位置。

class Solution { public void sortColors(int[] nums) { int p0 = 0, p2 = nums.length - 1; for (int i = 0; i <= p2; ) { if (nums[i] == 2) { swap(nums, i, p2--); } else if (nums[i] == 0) { swap(nums, i++, p0++); } else { i++; } } } private void swap(int[] nums, int i, int j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; } }

3.2 关键点与易错点

  1. 循环条件:必须是i <= p2而非i < nums.length。因为p2后面的元素已经确定是2,无需再处理。

  2. 交换顺序:遇到2时要先处理,因为从p2位置交换回来的元素可能是0或1,需要后续处理。

  3. 指针移动:

    • 交换0后,i和p0都要前进(因为交换回来的元素只能是1)
    • 交换2后,只有p2后退,i不前进(因为交换回来的元素需要重新检查)

3.3 时间复杂度分析

  • 单次遍历:每个元素最多被交换两次(一次到头部,一次到尾部)
  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

4. 两种方法的对比与选择

特性计数法双指针法
遍历次数2次1次
交换操作无交换,直接填充需要交换元素
代码复杂度简单中等
适用性元素范围小通用性更强
稳定性稳定不稳定

实际选择建议:

  • 面试中优先展示双指针法(更能体现算法思维)
  • 工程中如果元素种类固定且少,计数法更可靠
  • 如果要求稳定排序,只能选择计数法

5. 常见问题与调试技巧

5.1 边界条件测试

  1. 最小输入测试:

    Input: [0] Output: [0] Input: [1] Output: [1] Input: [2] Output: [2]
  2. 已排序输入:

    Input: [0,0,1,1,2,2] Output: [0,0,1,1,2,2]
  3. 极端分布:

    Input: [2,2,2,0,0,0] Output: [0,0,0,2,2,2]

5.2 调试技巧

  1. 打印指针位置:

    System.out.println("i=" + i + " p0=" + p0 + " p2=" + p2);
  2. 可视化交换过程:

    初始: [2,0,2,1,1,0] i=0: 交换2到末尾 → [0,0,2,1,1,2] (p2=4) i=0: 是0,交换到p0 → [0,0,2,1,1,2] (p0=1) i=1: 是0,交换到p0 → [0,0,2,1,1,2] (p0=2) i=2: 是2,交换到p2 → [0,0,1,1,2,2] (p2=3) i=2: 是1,不处理

5.3 性能优化

  1. 对于双指针法,当p0和p2相遇时可以提前终止:

    while (i <= p2 && p0 <= p2) { // ...原有逻辑... }
  2. 对于计数法,如果检测到已经有序可以提前返回:

    boolean isSorted = true; for (int i = 1; i < nums.length; i++) { if (nums[i] < nums[i-1]) { isSorted = false; break; } } if (isSorted) return;

6. 实际工程应用扩展

虽然题目简单,但类似的分类思想在工程中有广泛应用:

  1. 日志分级处理:将DEBUG/INFO/WARNING/ERROR日志分开处理
  2. 订单状态筛选:待支付/已支付/已发货/已完成订单分类
  3. 用户画像:根据活跃度将用户分为高/中/低活跃组

在更复杂的场景中,可以扩展为:

  • 多指针分类(3种以上类别)
  • 结合快速排序的分区思想
  • 并行化处理大规模数据

我在实际项目中曾用类似方法处理过千万级用户标签数据,通过将分类逻辑改为非递归实现并加入批量处理,性能提升了40%。关键是要根据数据特征选择合适的分区策略,有时简单的计数法反而比复杂算法更高效。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进