返回

k次相邻交换序列数?详解逆序数与动态规划

python

相邻 K 次交换能得到多少种序列?深入解析排列、逆序数与动态规划

咱们直接来看问题:给一个从 1 到 n 的自然数组成的初始序列 A = [1, 2, ..., n]。进行恰好 k 次 相邻元素交换,能得到多少种不同的序列 (记作 S1)?如果进行至多 k 次 相邻元素交换,又能得到多少种不同的序列 (记作 S2)?相邻交换指的是交换索引 ii+1 处的元素。

比如,n = 3, k = 2

  • 初始序列是 [1, 2, 3]
  • 恰好 2 次相邻交换后,可能得到:
    • [1, 2, 3] -> [1, 3, 2] -> [1, 2, 3]
    • [1, 2, 3] -> [2, 1, 3] -> [2, 3, 1]
    • [1, 2, 3] -> [1, 3, 2] -> [3, 1, 2]
    • 观察发现,最终不同的序列有 [1, 2, 3], [2, 3, 1], [3, 1, 2] 这 3 种。所以 S1 = 3。
  • 至多 2 次相邻交换后:
    • 0 次交换:[1, 2, 3] (1 种)
    • 1 次交换:[2, 1, 3], [1, 3, 2] (2 种) (注意: [3,2,1] 从 [1,2,3] 需要 3 次交换 [1,2,3]->[1,3,2]->[3,1,2]->[3,2,1])
    • 2 次交换:[1, 2, 3], [2, 3, 1], [3, 1, 2] (3 种,其中 [1, 2, 3] 重复计算)
    • 合并去重:[1, 2, 3], [2, 1, 3], [1, 3, 2], [2, 3, 1], [3, 1, 2] 共 5 种。(原文例子中的 S2=6 可能是基于其他计算路径或有误,我们重点关注计算方法)。

网上有些解释和代码(比如题目中链接指向的)让人有点懵,特别是最后那个对某个数组求和的操作,为啥加起来就是答案了呢? 这篇文章咱们就来把这个问题掰扯清楚。

核心概念:邻接交换与逆序数

解决这个问题的关键在于理解邻接交换逆序数 (Inversion Number) 之间的联系。

啥是逆序数?

一个排列 P 的逆序数是指序列中所有满足 i < jP[i] > P[j] 的数对 (P[i], P[j]) 的总数。简单说,就是有多少个“大数排在了小数前面”的情况。
比如,排列 [3, 1, 2] 的逆序数是 2,因为有逆序对 (3, 1)(3, 2)
初始序列 [1, 2, ..., n] 是完全有序的,它的逆序数是 0。

邻接交换对逆序数的影响?

这是核心!每进行一次相邻元素的交换,排列的逆序数要么增加 1,要么减少 1。

  • 如果交换的是 P[i]P[i+1],且 P[i] < P[i+1],那么交换后 P[i+1] 放到了 P[i] 前面,逆序数增加 1。
  • 如果交换的是 P[i]P[i+1],且 P[i] > P[i+1],那么交换后本来存在的一个逆序对 (P[i], P[i+1]) 消失了,逆序数减少 1。

重要推论:

  1. 从初始序列 [1, ..., n] (逆序数为 0) 得到某个排列 P,所需要的最少相邻交换次数,正好等于 P 的逆序数 inv(P)
  2. 从逆序数为 0 的状态开始,经过 k 次相邻交换,最终得到的排列 P 的逆序数 inv(P) 一定满足:
    • inv(P) <= k (因为每次交换最多让逆序数变化 1,不可能凭空产生超过 k 的逆序数)
    • inv(P)k 具有相同的奇偶性 。也就是说,inv(P) % 2 == k % 2。因为每次交换都让逆序数 +1-1,奇偶性跟着变,交换 k 次后,最终逆序数的奇偶性必然和 k 一致。

重新定义问题

有了逆序数的概念,我们就可以把原问题转换一下:

  • S1 (恰好 k 次交换): 我们要找的是所有 n 个元素的排列 P,它们满足两个条件:
    1. 该排列 P 可以通过 某个 次数(不一定是 k 次)的相邻交换从初始序列得到,且其最少交换次数(也就是逆序数 inv(P))小于或等于 k
    2. 这个逆序数 inv(P) 的奇偶性必须和 k 相同 (inv(P) % 2 == k % 2)。
  • S2 (至多 k 次交换): 我们要找的是所有 n 个元素的排列 P,它们满足:可以通过最多 k 相邻交换从初始序列得到。根据上面的推论,这等价于寻找所有逆序数 inv(P) 小于或等于 k 的排列 P

看起来,核心就是计算有多少种排列具有特定范围和特定奇偶性的逆序数。

解决方案:动态规划计算逆序数

计算具有特定逆序数的排列数量,是一个经典问题,可以用动态规划解决。

dp[i][j] 表示:由 1ii 个数字组成的所有排列中,逆序数恰好j 的排列有多少种。

状态转移怎么想?

考虑我们已经知道了 dp[i-1] 的所有值(即用 1i-1 组成各种逆序数的排列数量)。现在要构造 1i 的排列。我们可以把数字 i 插入到 1i-1 的某个排列 P' 中。

数字 i 是当前最大的数。把它插入到 P' 的不同位置,会产生不同的新增逆序数:

  • 插入到末尾:不与任何 1i-1 的数形成逆序对,新增 0 个逆序。
  • 插入到倒数第二位:与原末尾的数形成 1 个逆序对,新增 1 个逆序。
  • ...
  • 插入到最前面:与 i-1 个数都形成逆序对,新增 i-1 个逆序。

假设 P' 的逆序数是 j'。把 i 插入到 P' 的某个位置(可以产生 l 个新的逆序,0 <= l <= i-1),得到的新排列 P 的逆序数就是 j = j' + l

所以,要计算 dp[i][j],我们需要累加所有能产生 j 个逆序数的情况。即,考虑所有 i-1 的排列,其逆序数为 j',然后把 i 插入进去,产生 l 个新逆序,使得 j' + l = j
这等价于:
dp[i][j] = dp[i-1][j] + dp[i-1][j-1] + ... + dp[i-1][j - min(j, i-1)]

这个求和看着有点慢,可以优化。观察递推关系:
dp[i][j] = dp[i-1][j] + dp[i-1][j-1] + ... + dp[i-1][j-(i-1)] (假设 j >= i-1)
dp[i][j-1] = dp[i-1][j-1] + dp[i-1][j-2] + ... + dp[i-1][j-i]

两式相减(注意边界条件):
dp[i][j] = dp[i][j-1] + dp[i-1][j] (如果 j < i)
dp[i][j] = dp[i][j-1] + dp[i-1][j] - dp[i-1][j-i] (如果 j >= i)

这就是计算 dp[i][j] 的优化递推式。

边界条件:
dp[1][0] = 1 (排列 [1] 只有 1 种,逆序数为 0)

最大逆序数:
n 个元素的最大逆序数发生在排列 [n, n-1, ..., 1],值为 n * (n-1) / 2。所以 j 的范围是 0n * (n-1) / 2。不过,我们只需要计算到 k 就够了。

如何用 DP 表计算 S1 和 S2?

当我们计算出 dp[n] 这一行的所有值(即 dp[n][0], dp[n][1], ..., dp[n][k])后:

  • 计算 S1: 需要逆序数 j <= kj % 2 == k % 2
    所以 S1 = sum(dp[n][j]),其中 j 取遍 0, 1, ..., k 中所有与 k 奇偶性相同的值。
    等价于 S1 = dp[n][k % 2] + dp[n][(k % 2) + 2] + ... + dp[n][k] (或者最后一个小于等于 k 的同奇偶项)。

  • 计算 S2: 需要逆序数 j <= k
    所以 S2 = sum(dp[n][j]),其中 j 取遍 0, 1, ..., k
    等价于 S2 = dp[n][0] + dp[n][1] + ... + dp[n][k]

代码实现

下面是用 Python 实现这个 DP 过程,并计算 S1 和 S2。注意处理大数取模。

def solve_sequences(n, k):
    """
    计算 n 个元素,恰好 k 次 / 至多 k 次相邻交换可达的序列数。
    """
    p = 10**9 + 7 # 模数

    # dp[j] 存储当前元素数量下,逆序数为 j 的排列数
    # 我们使用滚动数组优化空间,只需要 dp_prev (对应 i-1) 和 dp_curr (对应 i)
    # dp 数组的大小需要能容纳最大可能的 k (或 n*(n-1)/2,但 k+1 足够)
    max_inversions_needed = k + 1
    dp_prev = [0] * max_inversions_needed
    dp_curr = [0] * max_inversions_needed

    # Base case: i = 1
    dp_prev[0] = 1

    # 从 i = 2 到 n 迭代
    for i in range(2, n + 1):
        # 优化递推需要前缀和的思想,这里直接累加计算 dp_curr[j]
        current_sum = 0
        dp_curr = [0] * max_inversions_needed # 重置当前行的 dp
        
        max_j_for_i = min(k, i * (i - 1) // 2) # 当前 i 个元素能达到的最大逆序数,不超过 k

        for j in range(max_j_for_i + 1):
            # dp[i][j] = dp[i][j-1] + dp[i-1][j] - dp[i-1][j-i] (j >= i 时)
            # 优化为: current_sum 维护了 sum(dp[i-1][j-l]) for l=0..i-1
            
            # 累加 dp[i-1][j]
            current_sum = (current_sum + dp_prev[j]) % p
            
            # 如果 j >= i,需要减去 dp[i-1][j-i]
            if j >= i:
                current_sum = (current_sum - dp_prev[j - i] + p) % p # +p 防止负数

            dp_curr[j] = current_sum
        
        # 更新 dp_prev 供下一轮迭代使用
        dp_prev = dp_curr[:] # 注意要复制

    # dp_prev 现在存储的是 n 个元素,逆序数为 j 的排列数 (dp[n][j])

    # 计算 S1: sum(dp[n][j]) where j <= k and j % 2 == k % 2
    s1 = 0
    for j in range(k % 2, k + 1, 2): # 从 k%2 开始,步长为 2
        if j < len(dp_prev): # 确保索引不越界
             s1 = (s1 + dp_prev[j]) % p

    # 计算 S2: sum(dp[n][j]) where j <= k
    s2 = 0
    for j in range(k + 1):
        if j < len(dp_prev): # 确保索引不越界
             s2 = (s2 + dp_prev[j]) % p

    # 结果中 S2 应该指所有 inv <= k 的排列总数,这直接是算出来的 s2
    # 而 S1 指的是恰好 k 次交换“能达到”的,这些序列的特征是 inv <= k 且 inv % 2 == k % 2。
    # 因此我们计算出的 s1 就是题目要求的 S1。
    
    # !!!请注意:题目原始示例中 n=3,k=2 的 S2=6 与我们分析的不同(我们算出是5)。
    # 我们的方法基于逆序数理论,是计算有多少种【最终状态】符合条件。
    # 如果问题的确切含义是问 "从初始状态出发,通过 k 步可以到达哪些状态的集合大小" (考虑路径),那会复杂得多,可能涉及图论。
    # 但通常这类问题指的是可达的【不同最终排列】的数量,此时基于逆序数的 DP 是标准解法。
    # 这里我们遵循逆序数DP的计算结果。

    # Tutorialspoint的解释似乎也用了逆序数 DP (`A`/`B`数组) 来算 S1,
    # 但它计算 S2 的方式 (`C`/`D`或其 Python 代码的 `d` 和 `dp` 数组) 不同,
    # 可能是想直接跟踪步数 `k`,或者其对 S2 的定义理解与纯逆序数方法有差异。
    # 用户提供的伪代码混杂了两种思路,容易混淆。我们坚持使用清晰的逆序数 DP 得到结果。
    
    # 我们这里直接返回用逆序数 DP 算出的 S1 和 S2
    return s1, s2


# 示例
n_val = 3
k_val = 2
result_s1, result_s2 = solve_sequences(n_val, k_val)
print(f"n={n_val}, k={k_val}")
print(f"S1 (恰好 k 次交换可达的不同序列数 - 基于逆序数奇偶性): {result_s1}") # 应为 3
print(f"S2 (至多 k 次交换可达的不同序列数 - 基于逆序数 <= k): {result_s2}") # 应为 5 ([1,2,3],[2,1,3],[1,3,2],[2,3,1],[3,1,2])


n_val = 4
k_val = 3
result_s1, result_s2 = solve_sequences(n_val, k_val)
print(f"\nn={n_val}, k={k_val}")
print(f"S1 (恰好 k 次交换可达的不同序列数 - 基于逆序数奇偶性): {result_s1}") 
print(f"S2 (至多 k 次交换可达的不同序列数 - 基于逆序数 <= k): {result_s2}")

为什么加总 DP 值就是答案?

回过头来看最开始的疑问:为啥最后是把 dp 数组(或者用户伪代码里的 A 数组)里的一些值加起来?

原因就在于我们怎么用的 DP:

dp[n][j] 的定义是:用 1n 这些数,能组成的逆序数恰好为 j 的排列的数量。

我们已经把原问题(关于相邻交换次数 k)转化为了关于逆序数 j 的问题:

  • 求 S1 (恰好 k 次),等价于求所有逆序数 j 满足 j <= kj % 2 == k % 2 的排列的总数。
  • 求 S2 (至多 k 次),等价于求所有逆序数 j 满足 j <= k 的排列的总数。

dp[n][j] 完美地给出了每个特定逆序数 j 对应的排列数量。所以,我们只需要把满足 S1 或 S2 对应条件的那些 jdp[n][j] 值加起来,自然就得到了 S1 和 S2 的总数。

例如,计算 S2 就是把逆序数从 0 到 k 的所有排列种类加起来:dp[n][0] + dp[n][1] + ... + dp[n][k]

计算 S1 就是只加那些逆序数 jk 奇偶性相同的项:dp[n][k%2] + dp[n][k%2 + 2] + ... 直到 j > k

这个加和操作,是在我们完成 DP 计算 、得到所有 dp[n][j] 的值之后,根据问题(S1 还是 S2)的要求,对符合条件的 dp 值进行的汇总统计

关于 Tutorialspoint 代码 / 用户伪代码的简要分析

用户问题中提到的伪代码 AB 数组和相关的循环,看起来就是在实现我们上面讨论的优化后的逆序数 DP 计算。A 数组存储当前 ndp[n][j] 值,B 存储上一步 n-1dp[n-1][j] 值。它最终返回 sum of all elements of A[from index k mod 2 to k],这正是我们计算 S1 的方法。

但是,那段伪代码里的 CD 数组及其计算逻辑,以及最终返回语句里的 C[...] 部分,似乎是想用另一种方法计算 S2,或者它可能对应 Tutorialspoint 网站上另一种不同的 DP 实现。这种混搭或者不清晰的表述,是造成困惑的主要原因。我们上面提供的基于单一、清晰的逆序数 DP 方法来计算 S1 和 S2,逻辑上更一致和易于理解。

时空复杂度

  • 时间复杂度: 状态是 dp[i][j],其中 i 从 1 到 nj 从 0 到 k。每个状态的计算是 O(1) 的(使用优化递推)。所以总时间复杂度是 O(n * k)。
  • 空间复杂度: 基本需要 O(n * k) 存储整个 DP 表。使用滚动数组优化后,只需要存储两行(或一行,如果进一步优化),空间复杂度可以降到 O(k)。

最大可能的逆序数是 O(n^2),所以最坏情况下 k 可能达到 O(n^2),此时复杂度是 O(n^3) 时间和 O(n^2) 空间。但通常 k 会远小于 n^2