稳定排序后统计每条位置边界上向右跨越的元素数,其最大值就是双向冒泡所需轮数。
启发记录: 这题把双向冒泡的动态过程转化为边界跨越的静态统计,再用稳定排序和差分求出最拥挤边界,是一次很有启发性的视角转换。
OJ: luogu
题目 ID: P4375
难度:提高+/省选-
标签:排序稳定排序冒泡排序差分前缀和思维USACOpython
日期: 2026-07-16 17:48
目录
题意
题面算法每轮先从左向右冒泡,再从右向左冒泡,最后检查是否有逆序对。求进入循环并输出 moo 的次数。
思路
直接模拟双向冒泡是
人脑推理:从"分界点"而不是"边界"出发
"跨越边界"是事后总结出的干净概念。比赛中没人一开始就想到这个,但可以从更自然的问题起步。
第 1 步:排序成功后,每个位置 k 的左边应该是什么?
排序后,位置 k 的左边(
第 2 步:初始数组里,这个"分界线"出了什么问题?
选一个位置 k,扫一眼初始数组:左边
以样例 [1,8,5,3,2] 为例,选 k=2:左边 3 个坑位,排好后应是 [1,2,3]。但实际上左边是 [1,8,5],混进了 5 和 8 两个"太大"的,少塞了 2 和 3 两个"太小"的。k=2 这条分界线出了 2 个错位。
第 3 步:冒泡的每轮在干什么?
正向扫描把"太大"的元素往右推,逆向扫描把"太小"的元素往左拉。对于分界点 k,正向扫描要做的就是把不该在左边的元素送出去。
第 4 步:一回合能送出去几个?
纸上演算——选一个 k,追踪第一轮正向扫描。你会发现:左边那些"太大"的元素里,只有最大的那个能一口气冲过 k,其余的被它挡在后面(指针已经扫过去,不会回头)。
所以每轮至多消掉一个错位。如果 k 处有
第 5 步:答案是所有分界点中最糟糕的那个。
对每个 k 统计"左边混进了几个不该在的元素",取最大值——这就是轮数。写成代码时才意识到:“哦,这不就是在统计 k+1 这条边界上有几个元素要跨过去么。“但推理起点是"分界点左边的错位元素数”,这个不需要灵感,只需要问"排序成功后这里应该长什么样”。
热身:纯冒泡怎么算?
先退一步,看题目中的第一个代码(纯正向冒泡)。以样例 [1,8,5,3,2] 稳定排序后各元素的位置:
| 元素 | 原位置 | 排好位置 | 需要向左走 |
|---|---|---|---|
| 1 | 0 | 0 | 0 |
| 8 | 1 | 4 | 0(向右) |
| 5 | 2 | 3 | 0(向右) |
| 3 | 3 | 2 | 1 |
| 2 | 4 | 1 | 3 |
纯冒泡只有正向扫描。大元素可以一口气向右走很远(8 一轮冲到最右),但小元素向左每轮至多挪 1 位——扫描指针一路向右不再回头,小元素被挤到前一位后,指针已经过它了,不会在同一轮再处理它。
第 1 轮: 2 从位置 4 → 位置 3
第 2 轮: 2 从位置 3 → 位置 2
第 3 轮: 2 从位置 2 → 位置 1 ✓ 归位
第 4 轮: 进入循环,无交换,打印 moo 后退出纯冒泡的答案 = max(左移距离) + 1。+1 是最后一次确认有序仍要打印的 moo。样例中 max 左移 = 3,答案 = 4。
双向冒泡:瓶颈从"个体"变成"边界"
双向冒泡多了一个逆向扫描,小元素也能一口气向左走很远了。纯冒泡的 max(左移距离) 不再适用。
关键转变:不再关注"哪个元素最慢",而是关注哪个位置最拥挤。
画一下"边界"——就是数组相邻元素之间的缝隙。样例 [1,8,5,3,2] 有 N+1=6 条边界:
索引: 0 1 2 3 4
[ 1 ] [ 8 ] [ 5 ] [ 3 ] [ 2 ]
^ ^ ^ ^ ^ ^
边界0 边界1 边界2 边界3 边界4 边界5边界 b 的左边是索引
谁需要跨越哪条边界?
稳定排序后,看每个元素需要向右跨越哪几条边界(向左的对称相等,后面解释):
| 元素 | 原 → 排好 | 跨越的边界 |
|---|---|---|
| 5 | 2 → 3 | 边界3 |
| 8 | 1 → 4 | 边界2, 3, 4 |
元素 5 从位置 2 到 3,跨过一条缝(边界3);元素 8 从位置 1 到 4,跨过三条缝(边界2,3,4)。
统计每条边界有几个元素要跨过去:
边界0: 0 边界1: 0 边界2: 1(8)
边界3: 2(5和8) 边界4: 1(8) 边界5: 0最拥挤的是边界3,有 2 个元素等着跨。答案就是 2。
为什么边界3 上的 2 个元素不能一轮跨完?
看第 1 轮正向扫描发生了什么:
[1, 8, 5, 3, 2]
↑ i=1: 8>5 交换 → 8 换到位置2
↑ i=2: 8>3 交换 → 8 换到位置3(8 跨过了边界3!)
↑ i=3: 8>2 交换 → 8 换到位置4
正向后: [1, 5, 3, 2, 8]8 一口气跨过了边界3。但 5 呢?当正向扫描到 5 的时候,指针已经在 8 后面了,8 冲过去后指针跟着过去了,不会回头。所以这轮正向扫描里,5 待在原地没动。
逆向扫描后第 1 轮结束:[1, 2, 5, 3, 8]。5 仍然在边界3 的左边。第 2 轮正向扫描 5 才跨过去:[1,2,3,5,8]。
规律:每轮正向扫描,一条边界至多放一个元素过去——最大的那个会挤在前面挡住小的。所以边界3 上有 2 个元素等着,必须 2 轮。
为什么不用管向左跨越?
以边界3 为例:左边 3 个坑位(索引 0~2),排好后应该是 [1,2,3]。实际上左边有 5 和 8 占了不该占的位置,所以有 2 个正确元素(2 和 3)"被困在"右边。右边少了多少个正确元素 = 左边多塞了多少个错误元素,两边数量永远相等。所以只统计向右的就行。
用差分快速算出最拥挤边界
对于每个需要向右跨越的元素,它跨越的边界区间是 [原位置+1, 排好位置]。这是连续的一段,用差分数组标记区间加一:
# 原位置 2 → 排好 3, 跨越 边界[3,3]
diff[3] += 1; diff[4] -= 1
# 原位置 1 → 排好 4, 跨越 边界[2,4]
diff[2] += 1; diff[5] -= 1前缀和还原后取最大值,就是最拥挤边界的跨越数。
答案 = max(1, 最拥挤边界的跨越数),至少为 1 是因为已经有序时循环仍会进入一次。
纯冒泡与双向冒泡的公式对比
| 纯冒泡 | 双向冒泡 | |
|---|---|---|
| 公式 | ||
| 度量什么 | 每个元素在排序完成后需要向左走多远 | 每个分界点 k 处,左边塞了多少不该在的元素 |
| 为什么 +1 | 逆序最多的元素归位后,还差最后一轮确认有序 | 有序数组仍要进一次循环,所以取 max(1, …) |
| 样例答案 | 4 | 2 |
Python 知识
sorted(range(n), key=lambda i: (values[i], i))明确实现按值、原下标的稳定次序。int.bit_count等技巧不需要出现在正解中;位置跨越用普通差分更直接。max(accumulate(difference))把差分还原和求最大值连在一起。
代码
import sys
from itertools import accumulate
data = iter(map(int, sys.stdin.buffer.read().split()))
n = next(data)
values = [next(data) for _ in range(n)]
difference = [0] * (n + 1)
for sorted_position, original_position in enumerate(
sorted(range(n), key=lambda i: (values[i], i))
):
if original_position < sorted_position:
difference[original_position + 1] += 1
difference[sorted_position + 1] -= 1
print(max(1, max(accumulate(difference))))复杂度
时间复杂度
总结
无需模拟冒泡排序;排序后的位移把"多少轮"转成"最多有多少元素跨过同一边界"。