目录

题目描述

1505. 最多 K 次交换相邻数位后得到的最小整数

题意分析

给定一个数字字符串 num 和整数 k,最多可以做 $k$ 次「交换相邻两位」的操作,问能得到的字典序最小的数字串是什么。

「只能交换相邻位」这条约束决定了代价模型:把某一位从下标 $i$ 搬到下标 $j$($j < i$),恰好需要 $i - j$ 次交换,且搬运过程中它跨过的那些位会整体右移一格、相对顺序保持不变。所以一次「远距离取数」的代价就是它当前与目标之间隔了多少个还没被取走的字符。

要求字典序最小,天然是从高位往低位逐位确定:高位小一点带来的收益,压倒后面所有位的任何差异。所以问题变成:为答案的第 $1$ 位挑一个花得起的最小数字,然后第 $2$ 位,依此类推。

数据规模是关键信号:$n \le 3 \times 10^4$,而 $k$ 最大到 $10^9$。$k$ 巨大意味着 $k$ 足够大时可以直接把整串排序成升序,所以算法不能依赖 $k$ 的大小;$n$ 三万说明 $O(n^2)$ 的 $9 \times 10^8$ 有点悬,配合「树状数组」这个标签,目标应当是 $O(10 \cdot n \log n)$。

边界要注意:数字只有 $0$ 到 $9$ 十种,同一个数字可能出现很多次;相同数字之间永远应该取最靠左的那个,因为取左边的代价严格更小而结果字符完全相同。以及 $k$ 可能在中途耗尽,此后每一位只能原样取剩余中最靠左的字符(代价为 $0$ 的那个)。

解法:贪心 + 树状数组

核心思路

暴力做法是模拟:每次在剩余字符串里找「能在 $k$ 步内移到最前面的最小数字」,找到后真的把它挪过来并删除。找一次 $O(n)$,删一次也要 $O(n)$ 的数组搬移,总代价 $O(n^2) = 9 \times 10^8$,且常数不小,在困难题的时限下不稳。瓶颈有两个:一是「找最小可达数字」扫了整个剩余串,二是「真的搬运」维护了物理数组。

第一个瓶颈好解决:数字只有十种。对每个数字 $d$ 预先存下它在原串中出现的所有下标(升序队列),那么「$d$ 的最靠左未取实例」就是队首,$O(1)$ 取得。逐位构造答案时,从 $d = 0$ 到 $9$ 依次试,第一个花得起的就是当前位的最优解——因为字典序下更小的数字压倒一切。

第二个瓶颈才是本题的核心。关键观察是:不需要真的搬运字符,只需要算出代价。设某个字符的原始下标是 $idx$(1-indexed),它此刻要被搬到答案的当前位。它需要跨过的,正是原串中位于它左侧、且尚未被取走的字符个数。已被取走的字符早就离开了它的左侧,不再构成阻碍。于是代价公式为 $cost = (idx - 1) - (\text{已取走的字符中下标} < idx \text{的个数})$

这就把问题转成了一个可以用树状数组维护的动态前缀计数:树状数组的第 $t$ 位记 $1$ 表示原下标 $t$ 的字符已被取走,query(idx) 给出前 $idx$ 个位置中已取走的数量。取走一个字符时做一次单点加一,查询代价时做一次前缀和,都是 $O(\log n)$。

于是全程的不变量是:答案已构造出 $i-1$ 位;树状数组中标记为 $1$ 的恰好是这 $i-1$ 个字符的原始下标;每个数字队列的队首是该数字剩余实例中最靠左的那个;$k$ 是剩余可用的交换次数。每一轮从 $0$ 到 $9$ 找第一个满足 $cost \le k$ 的数字,扣掉代价、追加字符、打标记、弹队首,不变量得以维持。

贪心的正确性还需要一句说明:为什么选中某个数字后,被它跨过的那些字符的相对顺序不受影响?因为相邻交换只把它一格一格往左挪,被跨过的字符各自右移一位,彼此之间的先后不变。所以后续的代价计算仍然只依赖「左侧未取字符数」这一个量,贪心的每一步都独立于历史路径。

解题步骤

  • 预处理十个下标队列 pos[0..9],把每个字符的下标(存成 $i+1$ 转 1-indexed)按出现顺序压入对应队列。用队列而非集合,是因为同一数字必须优先取最左实例——代价更小而字符相同,绝无理由取更右的;队列的先进先出正好表达这个顺序。存 1-indexed 是为了直接喂给树状数组(树状数组不能接受下标 $0$,idx & -idx 为 $0$ 会死循环)。
  • 建一个大小为 $n$ 的树状数组,初始全 $0$,表示还没有任何字符被取走。
  • 外层循环 $i$ 从 $1$ 到 $n$,每轮确定答案的第 $i$ 位。从高位到低位是字典序最小的必然要求:任何让高位变小的代价都值得付。
  • 内层从 $d = 0$ 递增到 $9$,跳过空队列。递增顺序保证第一个可行的就是最小可行数字,找到即 break,不必比较后面更大的数字。
  • 取队首下标 idx,计算 moved = bit.query(idx)cost = idx - 1 - movedidx - 1 是原串中它左侧的字符总数,moved 是其中已被取走的(含它自己?不含——它自己尚未被标记,所以 query(idx) 统计的都是左侧已取字符),两者相减正是当前实际要跨过的字符数,也就是相邻交换次数。
  • cost <= k,则扣除 k -= cost,把字符追加到结果,执行 bit.add(idx, 1) 打上已取标记,弹出队首,然后 break。三个更新缺一不可:不扣 $k$ 会超预算;不打标记会让后续字符的代价被高估(把已经走掉的字符仍算作阻碍);不弹队首会重复使用同一个字符。
  • 若十个数字都试完仍没有 break,说明 $k$ 已不足以移动任何数字——但这种情况不会真的发生:代价最小的候选是所有剩余字符中最靠左的那个,它的 cost 恰为 $0$,必然满足 cost <= k。所以每轮一定能产出一位,循环 $n$ 轮后答案长度恰为 $n$。
  • 返回拼接好的字符串

num = "4321"k = 4 走一遍:

预处理:pos[4] = [1]pos[3] = [2]pos[2] = [3]pos[1] = [4](1-indexed 下标)。树状数组全 $0$,$k = 4$。
第 1 位:试 $d = 0$ 空;$d = 1$ 队首 idx = 4moved = query(4) = 0cost = 3,$3 \le 4$ 成立。取它:$k = 1$,答案 "1",标记位置 $4$,弹出。
第 2 位:$d = 1$ 队空;$d = 2$ 队首 idx = 3moved = query(3) = 0(位置 4 的标记不在前 3 个里),cost = 2,$2 > 1$,放弃;$d = 3$ 队首 idx = 2cost = 2 - 1 - 0 = 1,$1 \le 1$ 成立。取它:$k = 0$,答案 "13",标记位置 $2$,弹出。
第 3 位:$d = 2$ 队首 idx = 3moved = query(3) = 1(位置 2 已取),cost = 3 - 1 - 1 = 1,$1 > 0$,放弃;$d = 4$ 队首 idx = 1moved = query(1) = 0cost = 0,成立。取它:$k = 0$,答案 "134",标记位置 $1$。
第 4 位:$d = 2$ 队首 idx = 3moved = query(3) = 2(位置 1、2 已取),cost = 3 - 1 - 2 = 0,成立。答案 "1342"
返回 "1342"

第 3 位那一步最能说明树状数组的作用:数字 $2$ 原本在下标 $3$,左边有两个字符,但其中位置 $2$ 的 3 已经被取走了,所以真实阻碍只剩一个,代价从 $2$ 降到 $1$。如果不做这个扣减,会误判它太贵而错过。到第 4 位时左边两个都走光了,代价降为 $0$。

代码实现

class Solution {
    public String minInteger(String num, int k) {
        int n = num.length();
        Deque<Integer>[] pos = new Deque[10];
        for (int i = 0; i < 10; i++) {
            pos[i] = new ArrayDeque<>();
        }

        for (int i = 0; i < n; i++) {
            pos[num.charAt(i) - '0'].add(i + 1);
        }

        Fenwick bit = new Fenwick(n);
        StringBuilder sb = new StringBuilder();

        for (int i = 1; i <= n; i++) {
            for (int d = 0; d <= 9; d++) {
                if (pos[d].isEmpty()) {
                    continue;
                }

                int idx = pos[d].peekFirst();
                int moved = bit.query(idx);
                int cost = idx - 1 - moved;

                if (cost <= k) {
                    k -= cost;
                    sb.append((char) ('0' + d));
                    bit.add(idx, 1);
                    pos[d].pollFirst();
                    break;
                }
            }
        }

        return sb.toString();
    }

    private static class Fenwick {
        private final int[] tree;

        Fenwick(int n) {
            tree = new int[n + 1];
        }

        void add(int idx, int delta) {
            while (idx < tree.length) {
                tree[idx] += delta;
                idx += idx & -idx;
            }
        }

        int query(int idx) {
            int sum = 0;
            while (idx > 0) {
                sum += tree[idx];
                idx -= idx & -idx;
            }
            return sum;
        }
    }
}
func minInteger(num string, k int) string {
    n := len(num)
    pos := make([][]int, 10)
    for i := 0; i < n; i++ {
        d := int(num[i] - '0')
        pos[d] = append(pos[d], i+1)
    }

    bit := newFenwick(n)
    res := make([]byte, 0, n)

    for i := 1; i <= n; i++ {
        for d := 0; d <= 9; d++ {
            if len(pos[d]) == 0 {
                continue
            }
            idx := pos[d][0]
            moved := bit.query(idx)
            cost := idx - 1 - moved
            if cost <= k {
                k -= cost
                res = append(res, byte('0'+d))
                bit.add(idx, 1)
                pos[d] = pos[d][1:]
                break
            }
        }
    }

    return string(res)
}

type Fenwick struct {
    tree []int
}

func newFenwick(n int) *Fenwick {
    return &Fenwick{tree: make([]int, n+1)}
}

func (f *Fenwick) add(idx int, delta int) {
    for idx < len(f.tree) {
        f.tree[idx] += delta
        idx += idx & -idx
    }
}

func (f *Fenwick) query(idx int) int {
    sum := 0
    for idx > 0 {
        sum += f.tree[idx]
        idx -= idx & -idx
    }
    return sum
}

复杂度分析

  • 时间复杂度:$O(10 \cdot n \log n)$。外层构造 $n$ 位;每位最多试 $10$ 个数字,每次试探做一次 $O(\log n)$ 的前缀和查询;选中后再做一次 $O(\log n)$ 的单点更新和 $O(1)$ 的队列弹出。$n = 3 \times 10^4$ 时约 $10 \times 3 \times 10^4 \times 15 = 4.5 \times 10^6$ 次基本操作,非常宽裕。
  • 空间复杂度:$O(n)$。十个下标队列合计存 $n$ 个下标;树状数组占 $n + 1$ 个整数;结果缓冲区 $n$ 个字符。数字种类是常数 $10$,不随输入增长。

关键点总结

  • 求字典序最小/最大时,一律从最高位开始逐位贪心:高位的收益无条件压倒低位,所以每一位都取「在预算内能取到的最优字符」,取定后不再回头。
  • 值域很小(这里只有十个数字)时,把「找最优候选」从扫描剩余序列改成「按值域从小到大试十次」,能把 $O(n)$ 的查找降成 $O(1)$ 的队首访问。这是值域小的题目最常见的加速点。
  • 相同字符只取最左实例,是可以直接断言的贪心:结果字符相同而代价严格更小,没有任何理由取右边的。凡是「取哪个都一样但代价不同」的场景都适用。
  • 不要真的搬运数据,只维护「代价所依赖的统计量」。本题把 $O(n)$ 的数组删除替换成树状数组上的一次单点加,是把模拟题转成数据结构题的典型手法;能识别「代价 = 左侧未删除元素个数」这个公式,是整道题的分水岭。
  • 面试视角:面试官会分三步考。先问贪心策略(逐位取最小可达数字),再问「怎么算代价」——这里必须主动说出「要扣掉左侧已被取走的字符数」,这是唯一的难点;最后问用什么结构维护,答树状数组做动态前缀计数,并说明单次 $O(\log n)$。如果被追问「不用树状数组行不行」,可以答用线段树或分块,$n$ 很小时直接 $O(n)$ 扫也行。

易错点总结

  • 代价直接用 idx - 1 而不扣已取走的数量num = "4321"k = 4 时第 3 位处数字 $2$ 的代价被算成 $2$ 而非 $1$,会跳过它选 $4$,虽然本例结果相同,但在 num = "9438957234785635408"k = 23 这类长串上会持续高估代价,最终答案比最优解大。
  • 选中后忘记 bit.add(idx, 1)num = "4321"k = 4 时第 4 位算出的 cost 仍是 $2$,虽然 $k$ 已为 $0$ 也会因为没有其他候选而被迫选中,但在 $k$ 有余量的用例上会重复扣除本不该付的代价,答案偏大。
  • 忘记弹出队首:同一个字符会被反复选中,num = "4321" 会输出 "1111" 这类由重复字符构成的串。
  • 树状数组下标从 $0$ 开始:把 pos[d].add(i) 而非 i + 1add(0, 1)0 & -0 为 $0$,idx 永不前进,直接死循环。
  • 内层循环从 $9$ 递减到 $0$num = "4321"k = 4 会优先尝试大数字,第一位就选 $4$(代价 $0$),输出 "4321",正确答案是 "1342"
  • 找到可行数字后不 break:会继续尝试更大的数字并再次追加字符,结果串长度超过 $n$ 且内容错乱。
  • 判断条件写成 cost < knum = "4321"k = 3 时第一位数字 $1$ 的代价恰为 $3$,用严格小于会被拒绝,输出 "3421" 之类,正确答案是 "1432"
  • 同一数字取队列中任意实例而非最左num = "11" + "0" 形式的串上会付出多余代价,num = "110"k = 2 若取第二个 $1$ 会浪费一次交换,导致 $0$ 无法前移,输出 "101",正确答案是 "011"
  • kint 但中途累减为负后继续比较:本题 cost <= k 只在非负时通过,k 不会变负;真正的错误是把 k -= cost 写在判断之前,num = "4321"k = 0 时第一位就把 $k$ 扣成 $-3$,后续所有候选都不可行,循环空转产出空串。
  • query(idx) 写成 query(idx - 1):位置 idx 自身尚未被标记,两种写法在本题等价;但若把标记提前到判断之前再用 query(idx),会把自己算进阻碍里,代价整体多 $1$,num = "4321"k = 4 第一位就会拒绝数字 $1$。

相似题目

题目 难度 考察点
402. 移掉 K 位数字 中等 同为逐位构造字典序最小,但操作是删除而非交换,用单调栈而非代价计算
670. 最大交换 中等 只允许一次任意位置交换,从右往左记最大数字位置即可,无需预算管理
316. 去除重复字母 中等 逐位贪心加单调栈,额外约束是每个字符恰好保留一次,需后缀计数判断可弹性
315. 计算右侧小于当前元素的个数 困难 树状数组做动态前缀计数的另一形态,统计的是逆序对而非已删除元素
31. 下一个排列 中等 同样在数位序列上找字典序最近的目标,靠找降序拐点后局部交换与反转
321. 拼接最大数 困难 从两串各取若干位拼接最大,需枚举分配再逐段单调栈取最优并合并
1081. 不同字符的最小子序列 中等 与 316 同构,考的是逐位取最小时如何保证后续仍有解
剑指 Offer 51. 数组中的逆序对 困难 相邻交换排序所需的最少次数就是逆序对数,与本题的代价模型同源