题目描述

✅ 324. 摆动排序 II

image-20260928223530804

题意分析

重新排列数组,使它严格满足 nums[0] < nums[1] > nums[2] < nums[3]...。偶数下标是谷,奇数下标是峰,所有相邻比较都不允许相等;每个原元素的出现次数必须保持,结果直接写回输入数组。

题目保证存在合法排列,因此不需要处理无解输入。数组可能包含重复值,不能只做到小于等于、大于等于的非严格摆动。结果不要求唯一,基础解法可以使用额外空间,进阶再考虑线性时间和常数额外空间。

解法:排序 + 交错填充

核心思路

[!blue]

先排序,把较小的 ceil(n / 2) 个元素作为谷,较大的 floor(n / 2) 个元素作为峰。谷比峰多一个或数量相同,正好对应偶数位置和奇数位置的数量。

两部分都从大到小取:小半区从 (n - 1) / 2 向左读,填入偶数位置;大半区从 n - 1 向左读,填入奇数位置。这样把小半区中较大的值放在靠左谷位,把大半区中较小的值留在靠右峰位,避免分界附近的相等值被紧挨着配对。

每个峰对应的左谷,在排序数组中的下标相差 floor(n / 2)。若两者相等,排序后的中间至少有 floor(n / 2) + 1 个相同值。偶数长度下,这已经超过能用其他元素隔开的数量;奇数长度下,若某值恰好多于一半,有解就要求它占全部谷位、其他值都更大,因此也不会产生相等的峰谷配对。这里需要用到题目保证有解。

所以每个峰严格大于它的左谷。谷值本身又按降序填充,下一个右谷不大于左谷,于是峰也严格大于右谷,两侧不等式同时成立。

读取排序副本,写入独立结果缓冲,最后复制回输入数组。读写分离可以避免尚未使用的排序元素被覆盖;这是一种清晰的基础方法,排序成本和线性辅助空间在后面的进阶中再优化。

解题步骤

  1. 复制输入并排序,保留独立的读取来源。
  2. 令小半区指针为 (n - 1) / 2,大半区指针为 n - 1。
  3. 按结果下标递增填充:偶数位置取小半区当前值,奇数位置取大半区当前值,各自向前移动指针。
  4. 将结果缓冲复制回 nums。

代码实现

class Solution {
    // 排序后把数组分成较小一半和较大一半,奇数位放较大值,偶数位放较小值。
    public void wiggleSort(int[] nums) {
        int n = nums.length;
        int[] sorted = nums.clone();

        Arrays.sort(sorted);

        // 较小半包含全部偶数槽位,两个半区都从后向前取
        int left = (n - 1) / 2;
        int right = n - 1;

        int[] res = new int[n];

        for (int i = 0; i < n; i++) {
            if (i % 2 == 0) {
                res[i] = sorted[left--];
            } else {
                res[i] = sorted[right--];
            }
        }

        // 填充完成后统一写回输入
        System.arraycopy(res, 0, nums, 0, n);
    }
}
import "sort"

func wiggleSort(nums []int) {
    // 排序后把数组分成较小一半和较大一半,奇数位放较大值,偶数位放较小值。
    n := len(nums)
    sorted := make([]int, n)
    copy(sorted, nums)
    sort.Ints(sorted)

    // 较小半包含全部偶数槽位,两个半区都从后向前取
    left := (n - 1) / 2
    right := n - 1

    res := make([]int, n)
    for i := 0; i < n; i++ {
        if i%2 == 0 {
            res[i] = sorted[left]
            left--
        } else {
            res[i] = sorted[right]
            right--
        }
    }

    // 填充完成后统一写回输入
    copy(nums, res)
}

复杂度分析

  • 时间复杂度:$O(n\log(n + 1))$,排序主导,交错填充和写回都是线性操作。
  • 空间复杂度:$O(n)$,保存排序副本与结果缓冲。

关键点总结

[!green]

  • 较小半区负责谷,较大半区负责峰,两边都倒序读取以拉开相等分界值。
  • 峰大于左谷,右谷又不大于左谷,所以同一个峰的两侧都满足严格不等式。
  • 写回输入与常数辅助空间是不同要求,基础方法使用线性缓冲。

解法二:中位数 + 虚拟下标三路划分

核心思路

[!blue]

不必完成全排序,只需找到按升序排列后下标 n / 2 的中位数 median。使用随机快速选择,每次原地分成小于、等于、大于基准的三段,只继续处理包含目标下标的一侧;目标落入相等段时就得到中位数,期望只需线性时间。

接下来把大于中位数的元素放在峰位,小于中位数的元素放在谷位,相等元素填剩余位置。为让一次连续的三路划分直接作用于这些分散下标,定义虚拟位置 index(i) = (1 + 2 * i) % (n | 1)。n | 1 是不小于 n 的奇数,这个映射会先依次访问全部奇数下标,再依次访问全部偶数下标,且每个位置恰好访问一次。

在虚拟顺序上维护四段:[0, left) 大于中位数,[left, scan) 等于中位数,[scan, right] 尚未处理,(right, n) 小于中位数。大值换到虚拟前端并同时推进 left、scan;小值换到虚拟后端,只减少 right,继续检查换回的未知值;相等时只推进 scan。

大于中位数的数量不会超过奇数峰位数量,因此它们都会进入峰位;小于中位数的数量也不会超过偶数谷位数量,因此从虚拟末端放置时都会进入谷位。剩下的相等值位于虚拟顺序中部,在实际数组上对应较右的剩余峰位与较左的剩余谷位。

若中位数出现次数不超过一半,这两段相等值之间有足够的其他元素隔开,不会形成相邻的相等峰谷。奇数长度下若中位数出现 (n + 1) / 2 次,合法排列只能把它们全放在谷位,其余元素都更大;此时所有峰位先被大值填满,相等值也只会进入谷位。因而在有解前提下,最终两侧比较都是严格的。

虚拟下标只是访问位置的计算方式,没有新建映射数组。选择中位数与最终划分都在原数组中交换元素,额外只保存常数个下标和基准值。

解题步骤

  1. 用随机三路快速选择找到升序下标 n / 2 对应的中位数。
  2. 初始化虚拟划分边界 left = scan = 0、right = n - 1。
  3. 通过映射读取扫描位置:大值交换到虚拟前端,小值交换到虚拟后端,相等值保留在中间。
  4. 直到 scan > right,全部位置处理完,输入数组已经成为合法摆动排列。

代码实现

class Solution {
    public void wiggleSort(int[] nums) {
        int median = selectMedian(nums);
        int n = nums.length;
        int left = 0;
        int scan = 0;
        int right = n - 1;

        while (scan <= right) {
            int index = virtualIndex(scan, n);
            if (nums[index] > median) {
                swap(nums, virtualIndex(left, n), index);
                left++;
                scan++;
            } else if (nums[index] < median) {
                swap(nums, index, virtualIndex(right, n));
                right--;
            } else {
                scan++;
            }
        }
    }

    private int selectMedian(int[] nums) {
        int target = nums.length / 2;
        int left = 0;
        int right = nums.length - 1;

        while (true) {
            int pivot = nums[left + (int) (Math.random() * (right - left + 1))];
            int less = left;
            int scan = left;
            int greater = right;
            while (scan <= greater) {
                if (nums[scan] < pivot) {
                    swap(nums, scan++, less++);
                } else if (nums[scan] > pivot) {
                    swap(nums, scan, greater--);
                } else {
                    scan++;
                }
            }

            if (target < less) {
                right = less - 1;
            } else if (target > greater) {
                left = greater + 1;
            } else {
                return pivot;
            }
        }
    }

    private int virtualIndex(int index, int n) {
        return (1 + 2 * index) % (n | 1);
    }

    private void swap(int[] nums, int i, int j) {
        int value = nums[i];
        nums[i] = nums[j];
        nums[j] = value;
    }
}
import "math/rand"

func wiggleSort(nums []int) {
    median := selectMedian(nums)
    n := len(nums)
    virtualIndex := func(i int) int {
        return (1 + 2*i) % (n | 1)
    }
    left, scan, right := 0, 0, n-1

    for scan <= right {
        index := virtualIndex(scan)
        if nums[index] > median {
            first := virtualIndex(left)
            nums[first], nums[index] = nums[index], nums[first]
            left++
            scan++
        } else if nums[index] < median {
            last := virtualIndex(right)
            nums[index], nums[last] = nums[last], nums[index]
            right--
        } else {
            scan++
        }
    }
}

func selectMedian(nums []int) int {
    target := len(nums) / 2
    left, right := 0, len(nums)-1
    for {
        pivot := nums[left+rand.Intn(right-left+1)]
        less, scan, greater := left, left, right
        for scan <= greater {
            if nums[scan] < pivot {
                nums[scan], nums[less] = nums[less], nums[scan]
                scan++
                less++
            } else if nums[scan] > pivot {
                nums[scan], nums[greater] = nums[greater], nums[scan]
                greater--
            } else {
                scan++
            }
        }

        if target < less {
            right = less - 1
        } else if target > greater {
            left = greater + 1
        } else {
            return pivot
        }
    }
}

复杂度分析

  • 时间复杂度:期望 $O(n)$,随机快速选择期望为线性,虚拟划分始终为线性;若基准持续产生极端划分,快速选择最坏仍为 $O(n^2)$。
  • 空间复杂度:$O(1)$,快速选择使用循环,划分通过下标映射原地交换,没有递归栈或辅助数组。

关键点总结

[!green]

  • 选择中位数只确定大小分组,不需要给每组内部排序。
  • 虚拟顺序先奇后偶,让连续三路划分对应实际峰谷位置。
  • 相等元素集中在虚拟中段,同时利用输入有解的保证避免相邻相等。

易错点总结

[!yellow]

  • 两个半区升序交错,可能把相等分界值放成相邻位置,破坏严格不等式。
  • 偶数长度时把小半区末端写成 n / 2,会错误多分配一个元素;应使用 (n - 1) / 2。
  • 读取和覆盖同一份排序数据,可能提前改掉尚未使用的元素。
  • 只生成新结果却不写回 nums,调用方看不到重排结果。
  • 虚拟下标直接对偶数 n 取模,只会访问奇数位置,无法覆盖整个数组;模数应为 n | 1。
  • 虚拟划分把小值换到末端后立刻推进扫描下标,会跳过从末端换回的未知元素。
  • 进阶中的随机快速选择只有期望线性时间,不能把最坏复杂度也写成线性。

相似题目

题目 难度 关联与区别
280. 摆动排序 中等 原题只需非严格交替,本题相等元素不能相邻地破坏严格不等式,需要重新分布中位数两侧。
215. 数组中的第K个最大元素 中等 求中位数是三路划分的前置步骤,再配合虚拟下标放入峰谷位置。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2021/79011803
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!