めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

081 - Friendly Group(★5)の解説

何の話かというと

atcoder.jp

上記の問題の別解です。

公式解説 では、身長と体重を 2 次元にプロットして、身長と体重を対称に扱う解法が示されています。あえてこれらを非対称に扱うことも可能です。

身長だけの場合

仮に、身長についてだけ考えればよいとすれば、割と簡単な問題です。事前にソートした上で、いわゆる「尺取り法」で実装できます。

  A.sort()
  head = tail = 0
  a_max = a_min = A[head]

  max_len = 0
  while True:
    while a_max - a_min > K:
      tail += 1
      a_min = A[tail]
    while a_max - a_min <= K:
      max_len = max(max_len, head - tail + 1)
      head += 1
      if head == N:
        print(max_len)
        return
      a_max = A[head]

体重を含めた場合

上記の尺取り法の中では、「身長の観点で許容されるグループ」が自然に網羅されています。許容されるグループの中で、人数が最大になるものを検索する形になります。

そこで、「身長の観点で許容されるグループ」のそれぞれに対して、そのメンバーから「体重の観点でも許容されるサブグループ」を構成するという方法が考えられます。

極端な方法としては、「身長の観点で許容されるグループ」のそれぞれに対して、体重についての尺取り法を実行するというやり方が考えられます。

atcoder.jp

上記の実装では、tail を固定した状態で、head を伸ばしていき、最大限に伸ばし切ったタイミングで、体重についての尺取り法を呼び出すという実装になっています。いくつか TLE していますが、方向性としては悪くなさそうです。

TLE の 1 つの要因として、体重についての尺取り法を呼び出す際に、対象となるサブグループのコピーが発生する点がちょっと重い気がします。

そこで、体重に関しては、配列 num_B[i] を用意して、今考えている「身長の観点で許容されるグループ」の中で、

・num_B[i] = 体重が i 〜 i + K の範囲の人数

をトラッキングする様にします。こうすれば、max(num_B) として、身長・体重の両方について許容される最大人数を得ることができます。max(B) を計算するタイミングを最小限になるようにうまく調整すると無事に AC します。

atcoder.jp

074 - ABC String 2(★6)の解説

何の話かというと

atcoder.jp

上記の問題は、公式解説にもあるように、具体例を試しながら隠された規則性を発見する必要があります。どのような試行錯誤で、正解に至れるのか、一例を紹介します。

まずはしらみつぶし

まずは、しらみつぶしで、文字列が変化する様子を観察します。キューを用いた全件探索を実装します。

import sys, copy
from collections import defaultdict, deque

def main(f):
  N = int(f.readline())
  S = f.readline().strip()
  q = deque()
  q.append((0, S)) # count, string
  c_max = 0
  while q:
    c, s = q.pop()
    print(c,':', s)
    c_max = max(c, c_max)
    for i in range(len(s)):
      tmp = s.translate(str.maketrans('abc', 'bca'))
      if s[i] == 'b':
        s = tmp[:i] + 'a' + s[i+1:]
        q.append((c+1, s))
      if s[i] == 'c':
        s = tmp[:i] + 'b' + s[i+1:]
        q.append((c+1, s))

  print(c_max)

with open('input.txt', 'r') as f:
  main(f)
# input
3
aba

# output
0 : aba
1 : baa
2 : aaa
2

これを見ると文字列の右から順に、a が増えていって、最後はすべて a になって終わります。

ここで変換のルールを思い出すと、ある位置の文字を c -> b -> a と変更すると共に、それより前の文字をローテーションします。逆にいうと、c -> b -> a と変更する対象より後ろは、何も変化しません。したがって、ある位置から後ろがすべて a になれば、その部分はもはや変化することができないのです。

ということは、できるだけ長く変更を続けるには、前の方の文字を優先的に変更して、後ろの方の文字はできるだけ変更しない方がよさそう、と気づきます。

イメージでいうと、n 桁の数字を減らしていく作業に似ています。減らす操作をできるだけ長く続けるには、1 ずつ減らすのがベストです。

(1) 下の桁の値を 1 ずつ減らしていき、それ以上減らせなくなった(つまり 0 になった)ら、1つ上の桁を 1 減らす。
(2) 上の桁を 1 減らすと、その下の桁の数字はすべて 9 にもどる。

これって、変更した文字の手前をすべてローテーションする、という作業を (2) に対応させると、非常によく似ていることがわかります。

前を優先的に変更する

というわけで、しらみつぶしではなく、前の方の文字を優先的に変更するというロジックで組み直してみます。

import sys

def main(f):
  N = int(f.readline())
  s = list(f.readline().strip())
  while s[-1] == 'a' and len(s) > 1:
    s.pop()

  c = 0
  while True:
    print(c, ':', s)
    update = False
    for i in range(len(s)):
      if s[i] == 'c':
        s[i] = 'b'
        c += 1
        update = True
        break
      if s[i] == 'b':
        s[i] = 'a'
        if s[-1] == 'a':
          s.pop()
        c += 1
        update = True
        break
      s[i] = 'b'
    if not update:
      break

  print(c)

with open('input.txt', 'r') as f:
  main(f)

ここでは、文字列をリストに変換して、(文字列をコピーするのではなく)リストを直接変更していくように実装を変えています。また、結果を見やすくするために、末尾の a は削除していきます。

# input
5
baaca

# output
0 : ['b', 'a', 'a', 'c']
1 : ['a', 'a', 'a', 'c']
2 : ['b', 'b', 'b', 'b']
3 : ['a', 'b', 'b', 'b']
4 : ['b', 'a', 'b', 'b']
5 : ['a', 'a', 'b', 'b']
6 : ['b', 'b', 'a', 'b']
7 : ['a', 'b', 'a', 'b']
8 : ['b', 'a', 'a', 'b']
9 : ['a', 'a', 'a', 'b']
10 : ['b', 'b', 'b']
11 : ['a', 'b', 'b']
12 : ['b', 'a', 'b']
13 : ['a', 'a', 'b']
14 : ['b', 'b']
15 : ['a', 'b']
16 : ['b']
17 : []
17

問題で与えられたサンプルに対して、確かに正しい答えが得られています。

さきほど、数字を減らしていく作業との類似を指摘しましたが、出力を下から上に読むと、数字を 1 ずつ増やしていく操作にも見えてきます。実際、17 -> 16 -> ... -> 2 までの流れは、a = 0, b = 1 とした 2 進数で、0 〜 1111 まで数える作業とぴったり一致しています。c がなければ、単純な 2 進数への置き換えで答えが得られることになります。

c を含む場合を探ってみる

では、c は、この計算ルールにどのように影響しているのでしょうか? c を複数入れた例で様子を見ます。

# input
5
acbcb

# output
0 : ['a', 'c', 'b', 'c', 'a', 'b']
1 : ['b', 'b', 'b', 'c', 'a', 'b']
2 : ['a', 'b', 'b', 'c', 'a', 'b']
3 : ['b', 'a', 'b', 'c', 'a', 'b']
4 : ['a', 'a', 'b', 'c', 'a', 'b']
5 : ['b', 'b', 'a', 'c', 'a', 'b']
6 : ['a', 'b', 'a', 'c', 'a', 'b']
7 : ['b', 'a', 'a', 'c', 'a', 'b']
8 : ['a', 'a', 'a', 'c', 'a', 'b']
9 : ['b', 'b', 'b', 'b', 'a', 'b']
10 : ['a', 'b', 'b', 'b', 'a', 'b']
11 : ['b', 'a', 'b', 'b', 'a', 'b']
12 : ['a', 'a', 'b', 'b', 'a', 'b']
13 : ['b', 'b', 'a', 'b', 'a', 'b']
14 : ['a', 'b', 'a', 'b', 'a', 'b']
15 : ['b', 'a', 'a', 'b', 'a', 'b']
16 : ['a', 'a', 'a', 'b', 'a', 'b']
17 : ['b', 'b', 'b', 'a', 'a', 'b']
18 : ['a', 'b', 'b', 'a', 'a', 'b']
19 : ['b', 'a', 'b', 'a', 'a', 'b']
20 : ['a', 'a', 'b', 'a', 'a', 'b']
21 : ['b', 'b', 'a', 'a', 'a', 'b']
22 : ['a', 'b', 'a', 'a', 'a', 'b']
23 : ['b', 'a', 'a', 'a', 'a', 'b']
24 : ['a', 'a', 'a', 'a', 'a', 'b']
25 : ['b', 'b', 'b', 'b', 'b']
26 : ['a', 'b', 'b', 'b', 'b']
27 : ['b', 'a', 'b', 'b', 'b']
28 : ['a', 'a', 'b', 'b', 'b']
29 : ['b', 'b', 'a', 'b', 'b']
30 : ['a', 'b', 'a', 'b', 'b']
31 : ['b', 'a', 'a', 'b', 'b']
32 : ['a', 'a', 'a', 'b', 'b']
33 : ['b', 'b', 'b', 'a', 'b']
34 : ['a', 'b', 'b', 'a', 'b']
35 : ['b', 'a', 'b', 'a', 'b']
36 : ['a', 'a', 'b', 'a', 'b']
37 : ['b', 'b', 'a', 'a', 'b']
38 : ['a', 'b', 'a', 'a', 'b']
39 : ['b', 'a', 'a', 'a', 'b']
40 : ['a', 'a', 'a', 'a', 'b']
41 : ['b', 'b', 'b', 'b']
42 : ['a', 'b', 'b', 'b']
43 : ['b', 'a', 'b', 'b']
44 : ['a', 'a', 'b', 'b']
45 : ['b', 'b', 'a', 'b']
46 : ['a', 'b', 'a', 'b']
47 : ['b', 'a', 'a', 'b']
48 : ['a', 'a', 'a', 'b']
49 : ['b', 'b', 'b']
50 : ['a', 'b', 'b']
51 : ['b', 'a', 'b']
52 : ['a', 'a', 'b']
53 : ['b', 'b']
54 : ['a', 'b']
55 : ['b']
56 : []
56

出力結果を下から上に見ると、右から順に c が現れていることがわかります。一番右の c が現れる 1 つ前(出力で言うと、1行下)までを見ると、さきほどの2進数計算で 1 ずつ増えていって、

・c より前(c の位置を含む)は、すべて b
・c より後(c の位置は含まない)は、もとの文字列のまま

という値にたどり着いています。したがって、「上記で決まる 2 進数 + 1」回の(逆向きの)操作で、1 つ目の c が出現する所までいけるのです。

では、その上の行はどうなっているでしょうか? 一番右の c 以降は変化しませんので、c より前だけを見ればよいことに気が付きます。そして、一番右の c 以降を切り捨てて考えれば、次の c が現れるまで、同じルールの計算になっています。

つまり、右から順に c が出現する位置を探しながら、c が出現するごとに、先の「上記で決まる 2 進数 + 1」の値を加えていけばOKとわかります。さっくり実装すると、こんな感じですね。

import sys

def main(f):
  N = int(f.readline())
  s = list(f.readline().strip())

  c = 0
  bits = 0
  for i in range(len(s)-1, -1, -1):
    if s[i] == 'c':
      for j in range(i, -1, -1):
        bits = bits * 2 + 1
      c += bits + 1
      bits = 0
      continue
    if s[i] == 'a':
      bits = bits * 2 + 0
    if s[i] == 'b':
      bits = bits * 2 + 1
  c += bits
  print(c)

with open('input.txt', 'r') as f:
  main(f)

atcoder.jp

072 - Loop Railway Plan(★4)の解説

何の話かというと

atcoder.jp

上記の問題をネタに「キューを用いた全件探索」の話をします。

キューを用いた全件探索

enakai00.hatenablog.com

上記の記事では、キューを用いて木構造データを探索するという話をしています。木構造データに限らず、複数の選択肢を網羅的に探索する際には、同様の手法が利用できます。

大雑把な考え方は、次の通りです。

(1) 処理中のデータについて、「次にやるべき処理」に複数の選択肢があり、選択によって処理後のデータが変わり、その先の処理がブランチしていく、というような状況を考えます。

(2) 処理後のそれぞれのデータを個別に用意して、それらをキューに詰め込んでおきます。

(3) キューからデータを取り出して、次の処理を行います。処理結果を再びキューに詰め込みます。

これを繰り返すことにより、複数の選択肢の組み合わせパターンをすべて網羅することができます。ただし、処理対象のデータが多い場合、(2) でデータの複製が発生するので、この部分の処理時間が長くなります。そのため、探索範囲が広い場合は、データの複製量を減らすなどの工夫が必要です。(Python のリストは参照渡しの Mutable オブジェクトなので、明示的に copy.copy / copy.deepcopy する必要がある点に注意してください。)

下記の問題は、探索範囲を制限する、データの複製を必要な場合に限定する、という両方の工夫を詰め込んだ例になります。

enakai00.hatenablog.com

そもそも探索量が少ない場合

冒頭の問題は、フィールドのサイズが 16 x 16 と小さいので、すべてのデータをコピーしながら全件探索しても十分に間に合うと予想できます。公式解説 では、バックトラックが紹介されていますが、愚直にすべてのパターンを検索しても大丈夫です。つまり、次に進めるすべての方向に処理をブランチして、キューに詰め込んでいきます。

また、出発地点についてもすべてのパターンを検索する必要がありますが、すでに発見された経路に含まれる位置は、出発地点にする必要はありません。これらを実装すると下記になります。

atcoder.jp