目录

题目描述

剑指 Offer 45. 把数组排成最小的数

image-20250420030654688

image-20241107211521456

题意分析

给定一个非负整数数组,要把所有数字首尾相接拼成一个字符串,在所有可能的拼接顺序中,返回字典意义上最小的那一个。每个数字必须完整地用上一次,不能拆开、不能省略、不能改动内部数位的顺序,我们唯一能决定的只有这些数字之间的先后次序。

交付形式是字符串而不是数字,这一点很重要。它意味着结果长度可能远超 64 位整数的表示范围,也意味着比较的标准是逐字符的字典序,而不是数值大小。

约束里的关键信号是:所有拼接方案得到的字符串长度完全相同(就是所有数字位数之和),因为不管怎么排,每个数字都要贡献自己的全部数位。等长字符串比大小等价于从左到右逐位比较,谁先出现较小的字符谁就更小。这把「求最小拼接数」从一个数值问题彻底转化成了一个排列的字典序问题,也是后续所有推导的地基。

边界情形:数组只有一个元素时直接返回它的字符串形式;所有元素相同时任何顺序结果都一样;元素中含 0 时要格外小心,比如 [0, 1] 的最小拼接是 "01" 而不是 "1",因为题目要的是拼接串本身,不是它作为数字的规范写法。

解法:字符串拼接比较排序

核心思路

把每个数字转成字符串。对两个字符串 xy,只有 xyyx 两种相邻顺序:

  • xy < yxx 应排在 y 前;
  • xy > yxy 应排在 x 前;
  • 若二者相等,交换它们不会改变最终结果。

因此比较规则是 (x + y).compareTo(y + x)。它也可以理解为比较 xy 各自无限重复后的字典序,所以满足传递性,可以安全交给排序算法。

正确性可用交换论证说明:若某个排列中相邻的 xy 满足 xy > yx,交换后公共前缀和后缀都不变,中间部分从 xy 变为更小的 yx,整个结果一定变小。不断消除这样的逆序对,最终得到按上述规则排序的序列;此时已不存在能让结果变小的相邻交换,因此拼接结果最小。

全程在字符串上比较和拼接,既不会发生整数溢出,也会按题意保留前导零。

解题步骤

  1. nums 中每个整数转换为字符串。
  2. x + y < y + x 的规则升序排序。
  3. 按排序结果依次拼接并返回。

[3, 30, 34, 5, 9] 为例:"30" + "3" = "303" 小于 "3" + "30" = "330",所以 303 前;其余元素同理,排序为 [30, 3, 34, 5, 9],结果是 "3033459"

面试时应重点解释为什么不能按数值或普通字典序排序,以及上面的相邻交换为什么保证全局最优。

代码实现

class Solution {
    public String minNumber(int[] nums) {
        String[] values = new String[nums.length];
        for (int i = 0; i < nums.length; i++) {
            values[i] = String.valueOf(nums[i]);
        }

        java.util.Arrays.sort(
                values,
                (x, y) -> (x + y).compareTo(y + x)
        );

        StringBuilder answer = new StringBuilder();
        for (String value : values) {
            answer.append(value);
        }
        return answer.toString();
    }
}
import (
    "sort"
    "strconv"
    "strings"
)

func minNumber(nums []int) string {
    values := make([]string, len(nums))
    for i, num := range nums {
        values[i] = strconv.Itoa(num)
    }

    sort.Slice(values, func(i, j int) bool {
        return values[i]+values[j] < values[j]+values[i]
    })

    var answer strings.Builder
    for _, value := range values {
        answer.WriteString(value)
    }
    return answer.String()
}

复杂度分析

  • 时间复杂度:$O(nd \log n)$。n 是数字个数,d 是数字的最大位数;排序进行 $O(n \log n)$ 次比较,每次拼接比较最多处理 $O(d)$ 个字符。
  • 空间复杂度:$O(nd)$。字符串数组和最终结果共保存所有数字的字符;排序还需要至多 $O(n)$ 的辅助空间。

关键点总结

  • 比较的不是 xy,而是 xyyx
  • 局部规则能得到全局最优,依据是相邻交换论证。
  • 比较器满足传递性;xy == yx 时二者顺序不影响答案。
  • 结果可能很长且可能有前导零,不能转成整数。

易错点总结

  • 按数值升序[3, 30] 会得到 "330",正确答案是 "303"
  • 按普通字符串字典序"3" 会排在 "30" 前,仍然错误。
  • 比较器方向写反(y + x).compareTo(x + y) 求的是最大拼接数。
  • 把结果转成数值:长结果会溢出,[0, 1] 还会错误地丢掉前导零。
  • String += 反复拼接:会重复复制已有前缀,应使用 StringBuilder

相似题目

题目 难度 考察点
179. 最大数 中等 比较方向相反求最大拼接,且需处理全零输入拼出 "000" 的特判
451. 根据字符出现频率排序 中等 比较键来自额外统计出的频次,而不是元素之间的两两拼接
937. 重新排列日志文件 中等 多级比较器:先分类字母日志与数字日志,再按内容和标识符依次定序
1356. 根据数字二进制下 1 的数目排序 简单 主键是位计数、次键是数值的双关键字排序,比较逻辑无需证明传递性