《字典顺序最小的神奇排列》 | 豆包MarsCode AI刷题

58 阅读7分钟

问题理解

小R需要构造一个从 1 到 N 的整数排列 A,使得通过计算每个位置 i(从 0 开始)上的元素与其索引的和 A[i] + i,恰好生成 K 个不同的整数。题目要求返回字典顺序最小的满足条件的排列 A。已知在给定的约束下,至少存在一个符合要求的排列。

示例解释

  1. 测试用例 1

    • 输入:N = 4K = 3

    • 输出:[1, 2, 4, 3]

    • 解释:

      • 计算 A[i] + i

        • A[0] + 0 = 1 + 0 = 1
        • A[1] + 1 = 2 + 1 = 3
        • A[2] + 2 = 4 + 2 = 6
        • A[3] + 3 = 3 + 3 = 6
      • 生成的不同整数为 {1, 3, 6},共 3 个,满足 K = 3

  2. 测试用例 2

    • 输入:N = 5K = 2

    • 输出:[1, 5, 4, 3, 2]

    • 解释:

      • 计算 A[i] + i

        • A[0] + 0 = 1 + 0 = 1
        • A[1] + 1 = 5 + 1 = 6
        • A[2] + 2 = 4 + 2 = 6
        • A[3] + 3 = 3 + 3 = 6
        • A[4] + 4 = 2 + 4 = 6
      • 生成的不同整数为 {1, 6},共 2 个,满足 K = 2

  3. 测试用例 3

    • 输入:N = 6K = 4

    • 输出:[1, 2, 3, 6, 5, 4]

    • 解释:

      • 计算 A[i] + i

        • A[0] + 0 = 1 + 0 = 1
        • A[1] + 1 = 2 + 1 = 3
        • A[2] + 2 = 3 + 2 = 5
        • A[3] + 3 = 6 + 3 = 9
        • A[4] + 4 = 5 + 4 = 9
        • A[5] + 5 = 4 + 5 = 9
      • 生成的不同整数为 {1, 3, 5, 9},共 4 个,满足 K = 4

数据结构选择

  • 列表(List) :用于存储最终的排列 A
  • 集合(Set) :用于跟踪当前排列中生成的不同的 A[i] + i 的值。
  • 布尔数组(Boolean Array) :用于标记哪些数字已经被使用过,确保排列的唯一性。
  • 优先队列(Priority Queue):直接使用递归和回溯方法:用于生成字典顺序最小的排列。

算法步骤

为了生成字典顺序最小的满足条件的排列 A,我们可以按照以下步骤进行:

  1. 初始化

    • 创建一个空的排列 A
    • 创建一个布尔数组 used 长度为 N + 1,用于标记数字 1 到 N 是否被使用过,初始时全部为 False
    • 创建一个集合 current_sums 来存储当前排列中生成的不同的 A[i] + i 的值。
    • 初始化变量 unique_count 为 0,用于记录当前排列中不同的 A[i] + i 的数量。
  2. 递归生成排列

    • 从位置 0 开始,尝试将 1 到 N 的数字依次放入排列中,确保每次选择的数字是未被使用的,并尽可能小,以保持字典顺序最小。

    • 对于每个位置 i,选择一个未被使用的数字 x,并计算 x + i。有两种情况:

      • 如果 x + i 不在 current_sums 中,那么放入 x 后,unique_count 增加 1,并将 x + i 添加到 current_sums
      • 如果 x + i 已经存在于 current_sums 中,则直接放入 x,不改变 unique_count
    • 继续递归下一位置,直到排列长度为 N

    • 在递归过程中,如果 unique_count 超过 K,则剪枝,停止当前路径的搜索。

    • 如果排列完成时,unique_count == K,则返回当前排列作为答案。

  3. 剪枝策略

    • 在递归过程中,如果剩余的位置不足以达到所需的 K,则停止当前路径的搜索。
    • 优先选择较小的数字,以确保字典顺序最小。
  4. 终止条件

    • 成功生成长度为 N 的排列,并且 unique_count == K
    • 如果遍历完所有可能仍未找到满足条件的排列,则返回空列表(根据题目描述总存在至少一个解,因此不需要处理无解的情况)。

代码实现

def solution(n: int, k: int) -> list:

    def backtrack(position, current_permutation, used, current_sums, unique_count):
        # 如果已经完成排列
        if position == n:
            if unique_count == k:
                return current_permutation.copy()
            else:
                return None
     
        # 尝试从1到n的数字
        for num in range(1, n + 1):
            if not used[num]:
                new_sum = num + position
                increment = 0
                
                # 判断是否是新的唯一和
                if new_sum not in current_sums:
                    increment = 1

                # 早期剪枝,如果当前唯一和数超过k,不继续深入
                if unique_count + increment > k:
                    continue

                # 标记数字为已使用
                used[num] = True
                current_permutation.append(num)
                if increment:
                    current_sums.add(new_sum)

                # 递归下一位置
                result = backtrack(position + 1, current_permutation, used, current_sums, unique_count + increment)

                if result is not None:
                    return result  # 找到答案,返回

                # 回溯
                used[num] = False
                current_permutation.pop()
                if increment:
                    current_sums.remove(new_sum)

        return None  # 无法找到满足条件的排列

    # 初始化
    used = [False] * (n + 1)
    current_permutation = []
    current_sums = set()
    unique_count = 0

    # 开始回溯
    result = backtrack(0, current_permutation, used, current_sums, unique_count)

    if result:
        return result
    else:
        return []  # 根据题目描述,总是存在至少一个解

详细解释

  1. 回溯函数 backtrack

    • 参数

      • position:当前正在填充的位置(从 0 开始)。
      • current_permutation:当前已经填充的排列。
      • used:布尔数组,标记数字是否已经被使用。
      • current_sums:集合,存储当前排列中生成的 A[i] + i 的不同值。
      • unique_count:当前排列中不同 A[i] + i 的数量。
    • 步骤

      • 如果当前 position == n,表示排列已完成。检查 unique_count 是否等于 k,如果是,则返回当前排列。
      • 遍历数字 1 到 n,选择未被使用的数字 num
      • 计算 new_sum = num + position
      • 如果 new_sum 不在 current_sums 中,意味着选择 num 后,unique_count 将增加 1。如果此时 unique_count + 1 > k,则剪枝,不继续选择该数字。
      • 标记 num 为已使用,添加到 current_permutation 中。如果 new_sum 是新的,则将其添加到 current_sums 中。
      • 递归调用 backtrack 填充下一个位置。
      • 如果在递归过程中找到满足条件的排列,则直接返回该排列。
      • 如果未找到,则进行回溯,撤销选择,继续尝试下一个数字。
  2. 回溯策略

    • 字典顺序最小:在尝试数字时,从小到大遍历 1 到 n,确保尽早选择较小的数字,进而构造字典顺序最小的排列。
    • 剪枝:如果当前的 unique_count 加上即将增加的 1 超过 k,则不选择该数字,避免不必要的递归,提升效率。
  3. 终止条件

    • 成功找到满足条件的排列,立即返回。
    • 根据题目描述,至少存在一个解,因此不需要处理无解的情况。
  4. 复杂度分析

    • 时间复杂度:最坏情况下,时间复杂度为 O(n!),因为需要遍历所有可能的排列。然而,由于回溯和剪枝的存在,实际运行时间会显著减少,特别是对于较小的 n
    • 空间复杂度O(n),用于存储当前排列、使用标记和当前生成的和。

为什么答案是这样的

以第一个测试用例为例:

  • 输入n = 4k = 3
  • 期望输出[1, 2, 4, 3]

详细过程

  1. 开始填充位置 0

    • 尝试 num = 1

      • A[0] + 0 = 1 + 0 = 1unique_count 从 0 增加到 1
      • 标记 1 为已使用,current_sums = {1}current_permutation = [1]
    • 递归填充位置 1

  2. 填充位置 1

    • 尝试 num = 2

      • A[1] + 1 = 2 + 1 = 3unique_count 从 1 增加到 2
      • 标记 2 为已使用,current_sums = {1, 3}current_permutation = [1, 2]
    • 递归填充位置 2

  3. 填充位置 2

    • 尝试 num = 3

      • A[2] + 2 = 3 + 2 = 5unique_count 从 2 增加到 3
      • 标记 3 为已使用,current_sums = {1, 3, 5}current_permutation = [1, 2, 3]
      • 达到 unique_count = k = 3
    • 递归填充位置 3

  4. 填充位置 3

    • 尝试 num = 4

      • A[3] + 3 = 4 + 3 = 7unique_count 将从 3 增加到 4,超过 k,剪枝。
    • 尝试下一个未使用的数字 3(已使用,跳过)。

    • 尝试 num = 4

      • 同上,剪枝。
    • 尝试下一个未使用的数字 4,剪枝。

    • 回溯到位置 2,撤销选择 3

  5. 回溯到位置 2

    • 尝试下一个未使用的数字 4

      • A[2] + 2 = 4 + 2 = 6unique_count 从 2 增加到 3
      • 标记 4 为已使用,current_sums = {1, 3, 6}current_permutation = [1, 2, 4]
    • 递归填充位置 3

  6. 填充位置 3

    • 尝试 num = 3

      • A[3] + 3 = 3 + 3 = 6unique_count 保持 3
      • 标记 3 为已使用,current_sums 不变,current_permutation = [1, 2, 4, 3]
    • 达到排列长度 4,且 unique_count = k = 3,成功找到符合条件的排列 [1, 2, 4, 3]

这个过程确保了每一步都选择最小的数字,同时控制了生成的不同和数不超过 K,最终得到字典顺序最小的满足条件的排列。