【问题标题】:Reversing Lists Splices Python Optimization (USACO February 2020 Bronze Question 3 "Swapity Swap")Reversing Lists Splices Python Optimization(USACO 2020 年 2 月铜题 3 “Swapity Swap”)
【发布时间】:2021-10-29 16:02:18
【问题描述】:

我正在尝试解决一个涉及反转列表拼接的问题,但我遇到了测试用例的时间限制问题,即 4 秒。问题:

Farmer John 的 N 头奶牛 (1≤N≤100) 排成一列。左边第 i 头奶牛有标签 i,每个 1≤i≤N。 Farmer John 为奶牛制定了一项新的晨练程序。他告诉他们准确地重复以下两步过程 K (1≤K≤1000000000) 次:

当前位置 A1…A2 的奶牛从左至右的顺序颠倒了它们的顺序 (1≤A1

评分: 测试用例 2-3 满足 K≤100。 测试用例 4-13 不满足其他约束。

输入格式(文件 swap.in): 输入的第一行包含 N 和 K。第二行包含 A1 和 A2,第三行包含 B1 和 B2。

输出格式(文件 swap.out): 在输出的第 i 行,在练习程序结束时从左侧打印第 i 头奶牛的标签。

样本输入:

7 2
2 5
3 7

样本输出:

1
2
4
3
5
7
6

最初,奶牛的顺序是从左到右 [1,2,3,4,5,6,7]。在该过程的第一步之后,顺序为 [1,5,4,3,2,6,7]。处理第二步后,顺序为[1,5,7,6,2,3,4]。第二次重复这两个步骤会产生样本的输出。

理论上,您可以通过找到程序重复的点,然后模拟反向k % frequency 次来解决此问题,其中frequency 是模拟唯一的次数。 但我的问题是当输入是:

100 1000000000
1 94
2 98

我的程序运行时间超过 100 秒。这个输入特别耗时,因为它运行的迭代次数最多,而且frequency 非常高。

当前代码:

fin = open("swap.in", 'r')
line = fin.readline().strip().split()
n = int(line[0])
k = int(line[1])
nums = [[int(x)-1 for x in fin.readline().strip().split()]for i in range(2)]
fin.close()
repeated = []
cows = [i for i in range(1, n+1)]
repeat = False
while not repeat:
    for i in nums:
        cows[i[0]:i[1]+1] = reversed(cows[i[0]:i[1]+1])
        if cows[i[0]:i[1]+1] in repeated :
            frequency = len(repeated)-1
            repeat = True
        repeated.append(cows[i[0]:i[1]+1])

cows = [i for i in range(1, n+1)]
for _ in range(k%frequency):
    for i in nums:
        cows[i[0]:i[1]+1] = reversed(cows[i[0]:i[1]+1])

fout = open("swap.out", 'w')
for i in cows:
    fout.write(str(i) + "\n")
fout.close()

如果有人知道解决此问题的方法,请发布答案。如果有任何不清楚的地方,请发表评论。

【问题讨论】:

  • 我重新阅读了代码,看看您是如何尝试实现循环检测的。您是否期望 for i in nums 循环在 repeat 设置为 True 时立即退出,因为 outer while not repeat: 循环?循环不是那样工作的。 while not repeat: 条件只会在 that 循环的每次迭代中检查一次,这意味着内部循环的完整运行。
  • 如果你正在做竞赛题并试图仔细考虑算法,我认为你不需要被告知像break 这样的基础知识。所以我认为这只是竞争压力造成的一个简单的疏忽。
  • @KarlKnechtel 不,我故意没有休息。如果你确实休息了,把频率=len(repeated)-1改成frequency = len(repeated),当你用输入案例100 31279131 1 100 2 99尝试时它不起作用我目前也没有参加比赛,这只是一个练习问题.
  • “他告诉他们重复以下两步过程恰好 K (1≤K≤109) 次”。那么为什么在那个测试用例中 K = 1000000000?
  • @blhsing 抱歉,这应该是指数。 10日到9日。

标签: python algorithm optimization simulation reverse


【解决方案1】:

据我了解,@btilly's algorithm 的另一个实现是在我意识到@MichaelSzczesny 发布他的之前写的:

n, k, a1, a2, b1, b2 = map(int, '''100 1000000000
1 94
2 98'''.split())
*positions, = *mapped, = range(n)
for bound in map(slice, (a1 - 1, b1 - 1), (a2, b2)):
    mapped[bound] = reversed(mapped[bound])
mapping = dict(zip(mapped, positions))
cycles = []
pool = set(positions)
while pool:
    current = pool.pop()
    cycle = [current]
    while True:
        current = mapping[current]
        if current == cycle[0]:
            break
        pool.remove(current)
        cycle.append(current)
    cycles.append(cycle)
result = [0] * n
for cycle in cycles:
    for i, position in enumerate(cycle):
        result[cycle[(k + i) % len(cycle)]] = position + 1
print(*result)

这个输出:

71 2 3 74 10 76 7 8 79 15 81 12 13 84 20 86 17 18 89 25 91 22 23 94 30 96 27 28 1 35 4 32 33 6 40 9 37 38 11 45 14 42 43 16 50 19 47 48 21 55 24 52 53 26 60 29 57 58 31 65 34 62 63 36 70 39 67 68 41 75 44 72 73 46 80 49 77 78 51 85 54 82 83 56 90 59 87 88 61 95 64 92 93 66 5 69 97 98 99 100

时间统计显示,这比@MichaelSzczesny 的实现要快一些: https://replit.com/@blhsing/EquatorialRemorsefulMath

【讨论】:

  • 我的实现额外计算:N次添加到访问的set,N次检查当前奶牛是否已经在set中,同时提取循环。从pool 集合中删除使用过的项目可以节省 2*N 的工作量。否则非常相似的实现。
  • 也许计算N次模比计算一次模并为每个循环构造一个移位列表(需要3个新列表)更快。
  • 确实如此。实现@btilly 的算法的方法非常微妙的差异和非常微小的性能差异。不过,itertools.islice 和 itertools.chain 可以避免为每个周期构建 3 个新列表。
【解决方案2】:

这是我理解为btilly's 循环方法的另一个版本。工作 Python 代码提交给USACO:

import collections

def f(n, k, ai, aj, bi, bj):
  # A cycle necessarily has even parity as both A and B
  # must be run. We know a cycle is complete when the
  # element returns to the start and the parity is even.
  cycles = collections.defaultdict(list)

  # Get cycles
  for i in range(1, n + 1):
    j = i
    parity = 0
    first_cycle = 1

    while first_cycle or j != i or parity:
      # A
      if not parity:
        j = j if (j < ai or j > aj) else aj - j + ai
      # B
      else:
        j = j if (j < bi or j > bj) else bj - j + bi
        first_cycle = 0

      if parity: 
        cycles[i].append(j)

      parity ^= 1

  new_list = [None] * n

  for i in range(1, n + 1):
    idx = cycles[i][(k - 1) % len(cycles[i])]
    new_list[idx-1] = str(i)

  file = open("swap.out", "w")
  file.write("\n".join(new_list))
  file.close()


file = open("swap.in","r")
data = file.readlines()

[n, k] = map(int, data[0].split())
[ai, aj] = map(int, data[1].split())
[bi, bj] = map(int, data[2].split())
  
f(n, k, ai, aj, bi, bj)


"""
ai = 2
aj = 5
bi = 3
bj = 7

n = 7
k = 2
"""

"""
1
2
4
3
5
7
6
"""

【讨论】:

  • 当你设置ai = 1aj = 94bi = 2bj = 98n = 100k = 1000时,它会输出错误的答案。
  • 您的实现有一些非常低效的地方,因为它比@MichaelSzczesny 和我对btilly 算法的实现慢了30 倍以上。时序演示可以在这里找到:replit.com/@blhsing/EquatorialRemorsefulMath
  • @blhsing 很酷,感谢您的测试。对,它单独计算并记录每个元素的周期,而不是仅仅记录周期本身。
  • @blhsing 现在优化得更好了。这里:replit.com/@gl_dbrqn/EquatorialRemorsefulMath
  • @blhsing DillonDavis 0.4136610489949817; blhsing (tbilly) 0.20110821699927328; MichaelSzczesny (tbilly) 0.22961177600518567; גלעד ברקן (tbilly) 0.19056547000218416
【解决方案3】:

您可以摆脱查找。这计算了两次必要的 5680 交换,但为字典节省了额外的空间。在 google colab 实例上,此解决方案的运行速度比 @blhsing solution 快 33%。

n, k, a1, a2, b1, b2 = [int(x) for x in
    '''
    100 1000000000
    1 94
    2 98
    '''.split()]
a1 -= 1
b1 -= 1

cows = list(range(1, n+1))
rot = cows[:]
s = k

while k:
    rot[a1:a2] = reversed(rot[a1:a2])
    rot[b1:b2] = reversed(rot[b1:b2])
    k -= 1
    if rot == cows:
        print(f'found frequency {s-k}')
        k %= s-k

print(*rot)

输出

found frequency 29640
71 2 3 74 10 76 7 8 79 15 81 12 13 84 20 86 17 18 89 25 91 22 23 94 30 96 27 28 1 35 4 32 33 6 40 9 37 38 11 45 14 42 43 16 50 19 47 48 21 55 24 52 53 26 60 29 57 58 31 65 34 62 63 36 70 39 67 68 41 75 44 72 73 46 80 49 77 78 51 85 54 82 83 56 90 59 87 88 61 95 64 92 93 66 5 69 97 98 99 100

@btilly's approach

我对他的方法进行了编码,使 @dillondavis 的第一个解决方案的运行时间提高了 4 倍(320 µs / 78.9 µs)

n, k, a1, a2, b1, b2 = [int(x) for x in
    '''
    100 1000000000
    1 94
    2 98
    '''.split()]
a1 -= 1
b1 -= 1
cows = list(range(n))
cows[a1:a2] = reversed(cows[a1:a2])
cows[b1:b2] = reversed(cows[b1:b2])

visited = set()
runs = []
for i in cows:
    if i not in visited:
        run = [i]
        nex = cows[i]
        while nex != i:
            run.append(nex)
            nex = cows[nex]
            visited.add(nex)
        runs.append(run)
for i in runs:
    r = k % len(i)
    for x,y in zip(i, i[r:]+i[:r]):
        cows[x] = y+1
print(*cows)

输出

71 2 3 74 10 76 7 8 79 15 81 12 13 84 20 86 17 18 89 25 91 22 23 94 30 96 27 28 1 35 4 32 33 6 40 9 37 38 11 45 14 42 43 16 50 19 47 48 21 55 24 52 53 26 60 29 57 58 31 65 34 62 63 36 70 39 67 68 41 75 44 72 73 46 80 49 77 78 51 85 54 82 83 56 90 59 87 88 61 95 64 92 93 66 5 69 97 98 99 100

【讨论】:

  • 好点。它总是会先循环到原始位置,然后再重复其他位置,因此除了原始位置之外,没有必要寻找其他任何东西。 +1
  • 最简单的逻辑。非常容易理解!谢谢!
  • 啊,打败我了 :-)。但是使用bytearray 和[::-1] 而不是list 和reversed 仍然可以使它快4 倍。 @blhsing 同样可以让他们的速度提高 3-4 倍(另外使用 bytes 而不是 tuple)。
【解决方案4】:

无论K 可能是什么,这是一种O(N) 工作的方法。

简明解释:用循环符号重写排列。使用循环符号生成答案。 (除了我们不需要完整的循环符号。

为了说明,我将使用您的示例,但我会在 999 步后找到排列。

正如您所注意到的,[1,5,7,6,2,3,4] 是 A 然后 B 的一次迭代的结果。以这种形式计算,显然需要 O(N) 工作。写成这样更方便一点:

{
    1: 1,
    2: 5,
    3: 6,
    4: 7,
    5: 2,
    6: 4,
    7: 3
}

同样,这个翻译需要O(N) 工作。

现在让我们计算一个部分答案。我们以[0,0,0,0,0,0,0]' where 0 开头,表示“未知”。

第一步,我们找到1 -&gt; 1,所以第一个循环就是(1)。围绕这个循环 999 次再次给我们(1),所以我们现在有[1,0,0,0,0,0,0]。

第二步,我们找到2 -&gt; 5 -&gt; 2(注意,我们只是在查找中查找这些,所以每个都是O(1) 工作)所以第二个循环是(2, 5)。围绕这个循环 999 步意味着我们可以填写 2 个值。我们现在有[1,5,0,0,2,0,0]。

第三步,我们发现3 -&gt; 6 -&gt; 4 -&gt; 7 -&gt; 3。所以第三个循环是(3, 6, 4, 7)。绕这 999 次就像向前走了 3 步,所以现在我们可以填写:[1,5,7,6,2,3,4]。

当我们检查其余的数字时,我们发现它们都被填写了。所以我们的答案是[1,5,7,6,2,3,4]。

通常,每个数字都是长度为j 的循环的一部分。当我们找到一个循环时,我们用O(j) 工作来找到这个循环,然后对于另一个O(j) 工作,我们填写那个循环发生了什么的答案。之后我们将点击 j-1 填充的元素并跳过它们。所以我们得到n 元素用于O(n) 工作,每个元素摊销O(1) 工作。

结果是O(N)努力寻找最终答案。

【讨论】:

  • 所以你只是对每个数字单独排序?
  • 我不确定您是否打算在描述中暗示这一点,但循环不是“常规”排列循环。例如,描述中的示例有 2 -> 5 -> 5(从 B 的中间开始停留)-> 2 -> 2(因为 B 不影响 2 而停留)等
  • @גלעדברקן A 和 B 都是排列。因此 BA(做 A 然后做 B)是一个排列。我们正在对那个排列进行K 迭代。而且因为 A 和 B 总是一起发生,所以我们没有任何理由关心 A 已经发生而 B 还没有发生的中间状态。因此,我们谈论的是具有规则排列周期的真实排列。
  • @CoderTang 是的。但是当你做这个数字时,你会填写它的整个周期。
  • 我习惯于将排列视为从一个索引到另一个索引的奇异映射,其中循环仅包含每个索引中的一个。我很高兴有机会扩展这个概念。
【解决方案5】:

让我们先谈谈我们如何以数学方式解决这个问题,然后以编程方式制定解决方案。

假设每个位置的牛由变量Pi.j 表示,其中i 是牛索引,j 是交换迭代。这些变量都将包含一个与该奶牛的唯一 ID 对应的整数。此外,为简单起见,我们将只考虑每次迭代的单个反转操作,并在以后扩展以包括两者。

从 P0.0(第 0 个位置,第 0 次迭代)开始,我们想开始定义一些方程,以便在下一次迭代中为我们提供给定位置的奶牛。如果奶牛在反转区域之外,这是微不足道的;牛没有改变。如果它在反转区域内,我们将要根据反转区域的端点计算新奶牛的先前位置。明确:

  • 如果在反转区域之外:Pi.(j+1) = Pi.j
  • 否则:Pi.(j+1) = P(e1+e2-i).j,其中e1 和e2 是区域端点

现在,根据我们的规则,我们实际上可以写出一个方程组:

P0.b = 1 * P0.a
P1.b = 1 * P1.a
...
P4.b = 1 * P7.a  // reversal region starts
P5.b = 1 * P6.a
...

从这里,我们可以将这些问题系统转换为转换矩阵MA,当它乘以奶牛 ID 向量时,将在反转后返回一个新的奶牛 ID 向量。

这就是事情变得有趣的地方。我们可以对另一个反转区域做同样的事情,制作第二个矩阵MB,并将其与MA 相乘,得到一个整体矩阵M,它同时进行了两个反转。从这里,我们可以将矩阵提高到K 的幂(迭代次数),以获得单个矩阵,该矩阵将在所有反转发生后计算每个位置的奶牛。

现在,在这一点上,您可能会质疑这种方法的性能 - 毕竟,我们正在将 100x100 矩阵提升到一定的幂 K 到 109!好吧,我们有一些技巧可以让这更快。首先,请注意,一头奶牛,任何一头奶牛,我们都可以通过对所有反转操作的洗牌进行逻辑推理并确定其结束位置,而无需知道其他奶牛在哪里/其他奶牛在哪。

这意味着对于任何给定时间的任何给定位置,该位置的奶牛可以由任何其他迭代(例如 P12.7(位置 12 迭代 7)上的另一个位置精确定义)可以通过知道奶牛在迭代中的相应位置,例如迭代 5- P8.5)。

这很有用,因为这意味着我们矩阵的每一行都将有一个非零元素,并且该元素的值将是 1(方程组中 1 的系数)所以我们实际上可以将我们的 100x100 矩阵压缩成一个只有 100 个值的数组,说明每行哪一列包含 1。

太好了,我们可以使用这个技巧在O(n^2) 时间内轻松地将矩阵相乘。好吧,我们实际上还可以做得更好——我们实际上可以在O(n) 时间内将它们相乘。对于第一个矩阵中的每个“行”R(只包含列索引,1 值的I),查看第二个矩阵中的Ith 行,并获取其值C .将输出矩阵中我们对应的行R'分配为等于C。

所以我们可以用大约 100 个逻辑步骤将这些矩阵相乘,但我们需要将此矩阵提升到 Kth 次方,最高可达 109。我们刚刚从我们的工作中消除了 100 倍,只是为了把它加回来!嗯,不完全是。有一种众所周知的矩阵求幂方法,称为“平方求幂”,我们巧妙地错开重复乘法和平方结果,以log(K) 迭代/步骤计算M^K。由于它广为人知且有据可查,因此我不会在这里详细介绍。

总的来说,这使我们处于 O(N log K) 时间,还不错!

更新:这是解决问题的功能代码。

def matmul(p, q):
    return [q[I] for I in p]

def exp(m, e):
    if e == 1:
        return m

    result = exp(matmul(m, m), e // 2)
    if e % 2:
        result = matmul(m, result)
    return result

def solve(n, k, a1, a2, b1, b2):
    a1, a2, b1, b2 = a1-1, a2-1, b1-1, b2-1

    cows = list(range(1, n+1))

    ma = [a1 + a2 - x if a1 <= x <= a2 else x for x in range(n)]
    mb = [b1 + b2 - x if b1 <= x <= b2 else x for x in range(n)]

    m0 = matmul(mb, ma)
    mk = exp(m0, k)

    return [cows[i] for i in mk]

在使用timeit 对number=100 迭代进行一些基准测试后,这些是我的发现(以秒为单位的计时,越小越好):

contributor      | time (sec)
------------------------------
blhsing          | 7.857691
Michael Szczesny | 5.076418
(mine)           | 0.013314

因此,Michael 改进了 blhsing 的解决方案,在我的机器上运行速度提高了大约 1.5 倍,而我的解决方案的运行速度比 Michael 的解决方案快了大约 381 倍,平均每次运行大约需要万分之一秒。

更新 2:正如这个答案的评论中提到的,上述解决方案仍然可以与 K 扩展,所以最终它会变得难以处理,而且这个问题似乎会发出重复的模式 -我们肯定可以利用它吗?嗯,是的,事实上我们可以。

诀窍在于,在这些奶牛达到我们以前见过的某个先前状态之前,我们只有这么多的方法可以置换它们,然后从那里形成一个无限循环。事实上,由于手头问题的简单性,在我们回到之前的状态(实际上是我们的开始状态,因为要访问任何其他没有首先访问我们的起始状态的奶牛排列意味着有两个不同的状态转换到所述状态,这不可能 - 我们的方程组告诉我们否则)。

所以,如果在奶牛洗牌/反转开始重复的地方找到这个神奇的数字,我们需要做什么,并使用它来将指数(在我们的矩阵求幂中)从 K 减少到 K mod MAGIC。我们实际上将把这个数字称为转换矩阵的乘法阶数。首先,请注意可能有不止一个奶牛轮换位置,每一个都以句点重复。 4 头奶牛的子集可能每 4 次迭代循环一次位置,而 3 头奶牛的另一个子集每 3 次迭代循环一次,这意味着 7 头奶牛一起每 12 次迭代重复它们的初始配置。

更一般地说,我们需要找到每个独立的奶牛洗牌周期,得到它们的长度,并找到这些周期的最小公倍数 (LCM)。一旦我们有了它,就取K mod ,然后将我们的矩阵提升到那个 new 值。这样做的示例代码:

from itertools import count
from functools import reduce
from math import lcm

def order(m):
    cycle_lens = []
    unvisited = set(m)

    while unvisited:
        start = head = unvisited.pop()
        for size in count(1):
            head = m[head]
            if head == start:
                cycle_lens.append(size)
                break
            unvisited.discard(head)

    return reduce(lcm, cycle_lens)

# And inside solve()-
# replace:   mk = exp(m0, k)
# with:      mk = exp(m0, k % order(m0))

【讨论】:

  • 惊人的数学,很好的解释。谢谢。 +1。我确实想知道是否有一种更数学的方法来解决我和 OP 试图找到的“频率”,因为使用你目前的方法,时间成本没有上限,所以如果对 k 没有限制,理论上这种方法会在某些时候,无论k 的大小如何,所花费的时间都比首先找到频率和花费不超过“一个周期”的时间要多。
  • @blhsing 我确实有一个技巧可以将其封为O(n),独立于K,但是对于手头的问题规模,额外的工作实际上并没有提供有意义的加速。为了完整起见,我将扩展我的答案以包含它-也许其他人可以找到一种无需显式循环来计算顺序的方法,从而进一步加快速度。
  • 谢谢!解释得真好!
  • 订单计算是否考虑到单个元素在任意一个周期内可以有多个重复位置?
  • @CoderTang lcm 可以定义为:def lcm(a, b): return a * b // gcd(a, b)
【解决方案6】:

代码性能的主要问题是您使用列表来保存每次迭代中奶牛位置的历史记录以检测循环,这需要 O(n)对于每个使用 in 运算符的成员资格查找。

相反,您可以为此目的使用一个集合,这在成员查找中花费 O(1)。但是由于您仍然需要迭代 k % i 次,其中 i 是循环的长度,以到达循环中的特定位置,这样会更好如果集合是有序的,那么您可以简单地获取集合中的 (k % i) 索引条目,而不必执行那么多次反转。但是由于 set 在 Python 中是无序的,因此您可以改用 dict,其中的键从 Python 3.6 开始是有序的:

from itertools import islice

n, k, a1, a2, b1, b2 = map(int, '''100 1000000000
1 94
2 98'''.split())
cows = list(range(1, n + 1))
history = {}
for i in range(k):
    key = tuple(cows)
    if key in history:
        cows = next(islice(history, k % i, None))
        break
    history[key] = 1
    for bound in map(slice, (a1 - 1, b1 - 1), (a2, b2)):
        cows[bound] = reversed(cows[bound])
print(*cows, sep='\n')

这个输出:

71
2
3
74
10
76
7
8
79
15
81
12
13
84
20
86
17
18
89
25
91
22
23
94
30
96
27
28
1
35
4
32
33
6
40
9
37
38
11
45
14
42
43
16
50
19
47
48
21
55
24
52
53
26
60
29
57
58
31
65
34
62
63
36
70
39
67
68
41
75
44
72
73
46
80
49
77
78
51
85
54
82
83
56
90
59
87
88
61
95
64
92
93
66
5
69
97
98
99
100

演示:https://replit.com/@blhsing/CultivatedPointedArchitect

【讨论】:

    猜你喜欢
    • 2021-11-03
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2023-02-23
    • 2020-07-05
    • 2021-01-15
    • 1970-01-01
    相关资源
    最近更新 更多