A*(エースター)

A*(エースター)

A*(エースター)アルゴリズムは、ダイクストラ法を発展させたアルゴリズムで、ゴールから遠ざかるような無駄な経路は探索しないように工夫することで処理を高速化します。

解き方・ソースコード

ゴールから遠ざかっていることを判断するため、現在値からゴールへのコストの推定値を考えます。

今回はコストの推定値として、マンハッタン距離が与えられているものとします。(21行目)

マンハッタン距離は、各座標の差の絶対値を使うためどの経路でも同じ距離が得られます。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import heapq

def astar(edges, nodes, goal):
dist = [float('inf')] * len(nodes)
dist[0] = 0
q = []
heapq. heappush( q, [0, [0]])

while len(q) > 0:
_, u = heapq.heappop(q)
last = u[-1]
if last == goal:
return u
for i in edges[last]:
if dist[i[0]] > dist[last] + i[1]:
dist[i[0]] = dist[last] + i[1]
# 推定値を踏まえてコストを算出
heapq.heappush(q, [dist[last] + i[1] + nodes[i[0]], u + [i[0]]])
return []

# コストの推定値
nodes = [ 10, 14, 10, 10, 9, 9, 5, 0, 9, 8, 6, 4, 7, 3 ]

# 辺のリスト(終点とコストのリスト)
edges = [ [[4, 1], [5, 1]],
[[2, 12], [3, 4], [4, 15]],
[[1, 12], [9, 2], [11, 6]],
[[1, 4], [5, 3], [8, 3]],
[[1, 15], [0, 1], [6, 6]],
[[0, 1], [3, 3], [6, 4]],
[[4, 6], [5, 4], [10, 1]],
[[11, 4], [13, 5]],
[[3, 3], [9, 1], [10, 5]],
[[2, 2], [8, 1], [12, 1]],
[[6, 1], [8, 5], [13, 3]],
[[2, 6], [7, 4], [12, 5]],
[[9, 1], [11, 5], [13, 6]],
[[7, 5], [10, 3], [12, 6]] ]

print('解:', astar( edges, nodes, 7))

[実行結果]

解: [0, 5, 6, 10, 13, 7]

A*(エースター)アルゴリズムを使って最短経路を求めることができました。

3目並べ(ミニマックス法)

3目並べ(ミニマックス法)

前回は3目並べをランダムでプレイするコードを実装しました。

今回はミニマックス法を使って3目並べをプレイしてみます。

ミニマックス法は相手が自分にとって最も不利になる手を指すと仮定して、最善の手を探す方法です。

解き方・ソースコード

相手が手番の場合は選択できる手の中から最も評価値の高い手を選び、自分が手番の場合は選択できる手の中から最も評価値の低い手を選びます。

ゲームの評価値としては、勝てば $ 1 $ ポイント、負ければ $ -1 $ ポイント、引き分けであれば $ 0 $ ポイントとします。(minmax関数)

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
goal = [0b111000000, 0b000111000, 0b000000111,  # 横に3つ並んだケース
0b100100100, 0b010010010, 0b001001001, # 縦に3つ並んだケース
0b100010001, 0b001010100] # 斜めに3つ並んでケース

# 3つが並んだか判定
def check(player):
for mask in goal:
if player & mask == mask:
return True
return False

# ミニマックス法
def minmax(p1, p2, turn):
if check(p2):
if turn: # 自分の手番のときは勝ち
return 1
else:
return -1 # 相手の手番のときは負け

board = p1 | p2
if board == 0b111111111: # すべて置いたら引き分けで終了
return 0

w = [i for i in range(9) if (board & (1 << i)) == 0]

if turn: # 自分の手番の時は最小値を選ぶ
return min([minmax(p2, p1 | (1 << i), not turn) for i in w])
else: # 相手の手番の時は最大値を選ぶ
return max([minmax(p2, p1 | (1 << i), not turn) for i in w])

# 交互に置く
def play(p1, p2, turn):
if check(p2): # 3つ並んでいたら出力して終了
print('勝負あり')
print([bin(p1), bin(p2)])
return

board = p1 | p2
if board == 0b111111111: # すべて置いたら引き分けで終了
print('引き分け')
print([bin(p1), bin(p2)])
return

# 置ける場所を探す
w = [i for i in range(9) if (board & (1 << i)) == 0]
# 各場所に置いたときの評価値を調べる
r = [minmax(p2, p1 | (1 << i), True) for i in w]
print('選択可能なマス目', w)
print('マス目の評価値', r)

# 評価値が一番高いマス目を取得する
j = w[r.index(max(r))]
print('選択するマス目', j)
print('----------------------------')
play(p2, p1 | (1 << j), not turn) # 手番を入れ替えて次を探す

play(0, 0, True) # プレイ開始

[実行結果]

選択可能なマス目 [0, 1, 2, 3, 4, 5, 6, 7, 8]
マス目の評価値 [0, 0, 0, 0, 0, 0, 0, 0, 0]
選択するマス目 0
----------------------------
選択可能なマス目 [1, 2, 3, 4, 5, 6, 7, 8]
マス目の評価値 [-1, -1, -1, 0, -1, -1, -1, -1]
選択するマス目 4
----------------------------
選択可能なマス目 [1, 2, 3, 5, 6, 7, 8]
マス目の評価値 [0, 0, 0, 0, 0, 0, 0]
選択するマス目 1
----------------------------
選択可能なマス目 [2, 3, 5, 6, 7, 8]
マス目の評価値 [0, -1, -1, -1, -1, -1]
選択するマス目 2
----------------------------
選択可能なマス目 [3, 5, 6, 7, 8]
マス目の評価値 [-1, -1, 0, -1, -1]
選択するマス目 6
----------------------------
選択可能なマス目 [3, 5, 7, 8]
マス目の評価値 [0, -1, -1, -1]
選択するマス目 3
----------------------------
選択可能なマス目 [5, 7, 8]
マス目の評価値 [0, -1, -1]
選択するマス目 5
----------------------------
選択可能なマス目 [7, 8]
マス目の評価値 [0, 0]
選択するマス目 7
----------------------------
選択可能なマス目 [8]
マス目の評価値 [0]
選択するマス目 8
----------------------------
引き分け
['0b10011100', '0b101100011']

結果は引き分けとなりました。

3目並べでは両者が最善の手を選べば、確実に引き分けになります。

3目並べ(ランダム)

3目並べ

3目並べは、3×3のマス目に〇と×を交互に書いて、縦・横・斜めのいずれかに3つを並べたほうが勝ちとなるゲームです。

解き方・ソースコード

ビット演算を使って3目並べを実装してみます。

先手と後手を別々の変数で保持します。

また、2進数の各桁を9か所のマスに割り当てます。

縦3マス、横3マスを2進数の1次元配列に対応づけると、空いているマスは先手と後手の変数に対してOR演算を行うと簡単にチェックできます。
(すべてのマスが埋まっていると、OR演算の結果として全てのビットが1になります。)


勝負の判定は、同じ記号が3つ並んだ場合で行うため、3つ並んだパターンを事前に用意しておきます。(goal変数)

このパターンとAND演算を行った結果が、パターンと同じであれば3つ並んだと判定できます。(check関数)


プレイ方法として、コンピュータ同士の対戦で、マス目の空いているところにランダムに置いていくことにします。
(実行するたびに結果が変わります。)

空いている場所(マス目)は、現在の盤面(双方のOR演算した結果)に対して1桁ずつAND演算を行い、0となったものを探します。(28行目)

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import random

goal = [0b111000000, 0b000111000, 0b000000111, # 横に3つ並んだケース
0b100100100, 0b010010010, 0b001001001, # 縦に3つ並んだケース
0b100010001, 0b001010100] # 斜めに3つ並んでケース

# 3つが並んだか判定
def check(player):
for mask in goal:
if player & mask == mask:
return True
return False

# 交互に置く
def play(p1, p2):
if check(p2): # 3つ並んでいたら出力して終了
print('勝負あり')
print([bin(p1), bin(p2)])
return

board = p1 | p2
if board == 0b111111111: # すべて置いたら引き分けで終了
print('引き分け')
print([bin(p1), bin(p2)])
return

# 置ける場所(マス目)を探す
w = [i for i in range(9) if (board & (1 << i)) == 0]
# ランダムに置く
r = random.choice(w)
play(p2, p1 | (1 << r)) # 手番を入れ替えて次を探す

play(0, 0) # プレイ開始

[実行結果(勝負がつく場合)]

勝負あり
['0b10101000', '0b1000111']

下段が3つ並んでいるので勝負がついています。

[実行結果(引き分けの場合)]

引き分け
['0b10100101', '0b101011010']

全てのマスが埋まっていて、3つ並んでいる箇所がないため引き分けとなります。

ハノイの塔

ハノイの塔

ハノイの塔というパズルを考えます。

ハノイの塔では、次のルールのもとにすべての円盤を移動します。

🔹大きさの異なる複数の円盤があり、小さな円盤の上に大きな円盤を積むことはできない。
🔹円盤を置ける場所は3か所あり、最初は1か所にすべて積まれている。
🔹円盤を1回に1枚ずつ移動し、すべての円盤を別の場所に移動するまでの回数を調べる。

解き方・ソースコード

$ n $ 枚の円盤を移動するの必要な最小の移動回数と、その移動手段を求める方法を考えます。

小さな円盤の上に大きな円盤は積めないので、$ n $ 枚の円盤を移動するには $ n - 1 $ 枚を移動した後に、最大の1枚を移動し、その上に $ n - 1 $ 枚を移動することになります。


円盤を置ける3か所をそれぞれ $ a,b,c $ とし、$ a $ から $ b $ に円盤を移動することを $ a $ -> $ b $ と表現します。

移動に必要なパラメータは、「円盤の残り枚数」「移動元」「移動先」「経由場所」の4つで、これらを引数とする関数を定義し、その中で移動内容を出力します。

処理する枚数は、標準入力としプログラム実行時に変えられるようにしています。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 引数 n 円盤の残り枚数
# 引数 src 移動元
# 引数 dist 移動先
# 引数 via 経由場所
def hanoi(n, src, dist, via):
global cnt
cnt += 1
if n > 1:
# n - 1枚を移動元から経由場所に移す
hanoi(n - 1, src, via, dist)
print(src + ' -> ' + dist)
# n -1枚を経由場所から移動先に移す
hanoi(n - 1, via, dist, src)
else:
print(src + ' -> ' + dist)

n = int(input()) # 円盤の枚数(標準入力)

cnt = 0 # 移動回数
hanoi(n, 'a', 'b', 'c')
print('解(移動回数):', cnt)

[実行結果(円盤の枚数が3の場合)]

a -> b

a -> c

b -> c

a -> b

c -> a

c -> b

a -> b

解(移動回数): 7

[実行結果(円盤の枚数が4の場合)]

a -> c

a -> b

c -> b

a -> c

b -> a

b -> c

a -> c

a -> b

c -> b

c -> a

b -> a

c -> b

a -> c

a -> b

c -> b

解(移動回数): 15

マージソート

マージソート

マージソートは、ソートしたいデータが入ったリストを2つに分割することを繰り返し、すべてがバラバラになった状態から、これらのリストを統合(マージ)する方法です。

この統合する際に、そのリスト内で値が小さい順に並ぶように実装することで、全体が1つのリストになったときには全ての値がソート済みになっています。

ソースコード

マージソートを実装すると下記のようになります。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
lst = [13, 7, 9, 11, 5, 7, 2, 4, 15, 6]

def merge_sort(lst):
if len(lst) <= 1:
return lst

mid = len(lst) // 2 # 半分の位置を算出
# 再帰的に分割
left = merge_sort(lst[:mid]) # 左側を分割
right = merge_sort(lst[mid:]) # 右側を分割
# 統合
return merge(left, right)

def merge(left, right):
result = []
i, j = 0, 0

while (i < len(left)) and (j < len(right)):
if left[i] <= right[j]: # 左 ≦ 右 の場合
result.append(left[i]) # 左側から1つ取り出して追加
i += 1
else:
result.append(right[i]) # 右側から1つ取り出して追加
j += 1

# 残りをまとめて追加
if i < len(left):
result.extend(left[i:]) # 左側の残りを追加
if j < len(right):
result.extend(right[j:]) # 右側の残りを追加
return result

print('解:', merge_sort(lst))

[実行結果]

解: [2, 5, 6, 6, 7, 6, 9, 9, 13, 15]

マージソートの特徴

マージソートの特徴として、メモリに入りきらないような大容量のデータにも使えることが挙げられます。

分割した領域のそれぞれでソートできるため、複数のディスク装置にあるデータをそれぞれでソートしておき、それを結合しながらソート済みのデータを作成することも可能です。

枝狩り

問題

都道府県からいくつかを選んで、その人口の合計が1千万人にもっと近い組み合わせとその人口を求めてください。

各都道府県の人口は、2015年(平成27年)の国勢調査に基づきます。

2015年(平成27年)国勢調査 各都道府県人口 - https://www.stat.go.jp/data/kokusei/2015/kekka.html

解き方・ソースコード

単純に全ての組み合わせを調べると、各都道府県を「選ぶ」「選ばない」の2通りがあり、47都道府県で $ 2^{47} $ 通りを調べなければなりません。

これは現実的ではありません。


そこで枝狩りという手法を使います。

枝刈りとは、探索の途中において必要な条件を満たしているかどうかをチェックすることです。

必要な条件を満たしていなければ、そこで処理を中断します。


目的の1000万人を超えた場合、それ以上追加しても近づくことはありません。

また、それまでに調べた値より1000万人とのズレが大きいときも調べる必要はありません。

そこでこれらの条件を踏まえた上で、北海道から順に「選ぶ」「選ばない」のパターンを枝狩りしながら再帰的に探索します。

ズレを求めるには、1000万人との差の絶対値(abs関数)を使います。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 近づける値
goal = 10000000
# 各都道府県の人口(北海道から沖縄まで)
pref = [ 5381733, 1308265, 1279594, 2333899, 1023119, 1123891, 1914039, 2916976,
1974255, 1973115, 7266534, 6222666, 13515271, 9126214, 2304264, 1066328,
1154008, 786740, 834930, 2098804, 2031903, 3700305, 7483128, 1815865,
1412916, 2610353, 8839469, 5534800, 1364316, 963579, 573441, 694352,
1921525, 2843990, 1404729, 755733, 976263, 1385262, 728276, 5101556,
832832, 1377187, 1786170, 1166338, 1104069, 1648177, 1433566 ]

min_total = 0
def search(total, pos):
global min_total
if pos >= len(pref): # 人口データ探索終了
return
if total < goal: # 枝狩り① 目標人口数を超えていない場合
if abs(goal - (total + pref[pos])) < abs(goal - min_total): # 枝狩り② ズレが少ない場合
min_total = total + pref[pos] # 合計値を更新
search(total + pref[pos], pos + 1) # 選ぶ場合(その都道府県の人口を足す)
search(total, pos + 1) # 選ばない場合(その都道府県の人口を足さない)

search(0, 0)
print('解:', min_total)

[実行結果]

解: 10000000

青森県、宮城県、福井県、岐阜県、鳥取県、徳島県、佐賀県、長崎県の人口を合計するとちょうど10000000人になります。

値渡しと参照渡し

問題1

次のプログラムを実行すると、どのような出力になるでしょうか。

[Google Colaboratory]

1
2
3
4
5
6
7
8
x = 10
def calc(x):
x += 20
return x

print(x)
print(calc(x))
print(x)

解説1

関数calc内で変数xに加算していますが、グローバル宣言されていないため、ローカル変数として処理されます。

引数としてxが指定されているため、呼び出された時点でのxの値が使われ、その値に20を加算して返します。

最初のxは初期設定された10が出力され、次のcalc(x)では引数の10に20を加算した30が出力されます。

最後のxは関数calcによって変更されることはないため、初期設定された10がそのまま出力されます。

[実行結果1]

10

30

10

問題2

次のプログラムを実行すると、どのような出力になるでしょうか。

[Google Colaboratory]

1
2
3
4
5
6
7
8
a = [10]
def calc(a):
a[0] += 20
return a

print(a)
print(calc(a))
print(a)

解説2

関数calc内では、引数で渡された変数aのリストにおける先頭の要素の値に20を加算して返しています。

引数としてリストが渡された場合は、参照渡しであるためそのリストの内容を書き換えます。

最初のaは初期設定されたリスト[10]が出力され、次のcalc(a)では引数のリストにおいて先頭の要素に20を加算したリスト[30]が出力されます。

最後のaは関数calcによって変更されているため、変更されたリスト[30]が出力されます。

[実行結果2]

[10]

[30]

[30]

問題3/span>

次のプログラムを実行すると、どのような出力になるでしょうか。

[Google Colaboratory]

1
2
3
4
5
6
7
8
a = [10]
def calc(a):
a = [20]
return a

print(a)
print(calc(a))
print(a)

解説3

関数calc内では、引数で渡された変数aのリストを書き換えて返しています。

引数としてリストが渡された場合は、参照渡しですがその内容を書き換えているだけであり元のリストは上書きされません

最初のaは初期設定されたリスト[10]が出力され、次のcalc(a)では書き換えられたリスト[20]が出力されます。

最後のaは関数calcによって変更されることはないため、初期設定されたリスト[10]が出力されます。

[実行結果3]

[10]

[20]

[10]

連続文字の数え上げ(圧縮)

問題

同じ文字が連続する場合、その文字列の出現回数を数えて圧縮するアルゴリズムを考えます。

0と1の2つの文字だけで構成される文字列を、回数だけで表現します。

(FAXの圧縮などで使われている方法です。)

たとえば、「000001111110111000000001111」[5, 6, 1, 3, 8, 4]というリストに変換するプログラムを作成してください。

文字列は必ず「0」から始まるものとし、「1」で始まる場合はリストの先頭を0にします。

解法・ソースコード

処理中の値は0か1のためフラグで管理し、異なる値が現れたときにフラグを反転させます。

同じ値が続いている場合はカウントし、異なる値が現れた時はこれまでのカウント数をリストに追加し、新たにカウントをリセット、フラグを反転します。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
data = '000000111111100111000000001111'

flg = 0
count = 0
result = []
for i in data:
if int(i) == flg:
count += 1
else:
result.append(count)
count = 1
flg = 1 - flg

result.append(count)
print(result)

[実行結果]

[5, 6, 1, 3, 8, 4]

弾性衝突

問題

$ N $ 個の半径 $ R $ センチメートルのボールを使った次のような実験を行います。

上空 $ H $ メートルのところに筒を設置し、ボールを縦にいれます。

(下から $ i $ 番目のボールはその下端が $ H + 2Ri $ にあります。)

実験開始と同時に一番下のボールを落下させ、以後一秒ごとに1つずつボールを落下させます。

空気抵抗などはなく、床やほかのボールとは弾性衝突をします。

この実験の開始後、$ T $ 秒経過時点での各ボールの下端の高さを求めてください。

ただし、重加速度は $ g = 10m/s^2 $ とします。

[条件]

🔹$ 1 \leqq n \leqq 100 $
🔹$ 1 \leqq h \leqq 10000 $
🔹$ 1 \leqq r \leqq 100 $
🔹$ 1 \leqq t \leqq 10000 $

解法・ソースコード

まずは、ボールが1つだけの場合を考えます。

これはただの物理の問題となり、高さ $ H $ から落下するのにかかる時間は下記の通りです。

$$ t = \sqrt{\frac{2H}{g}} $$

したがって、時刻 $ T $ での位置は $ kt \leqq T $ となる最大の $ k $ に対して次のようになります。

🔹kが偶数の場合: $ y = H - {\frac{1}{2}}g(T - kt)^2 $

🔹kが奇数の場合: $ y = H - {\frac{1}{2}}g(kt + t - T)^2 $


次にボールが複数の場合を考えます。

半径 $ R $ が0と仮定します。全てのボールを同一視すると、ボール同士の衝突を無視して全てすり抜けたと見なすことができます。

衝突によってボールの順序関係は変化しないので、衝突を無視して計算した後で座標をソートすることにより、各ボールの最終的な位置を求めることができます。

また半径 $ R $ が0より大きい場合は、最終的な座標に $ 2Ri $ を足すだけとなります。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#--------- 入力例1 ----------
n = 1 # ボールの個数
h = 10 # ボールの高さ(メートル)
r = 10 # 半径(メートル)
t = 100 # 経過時間(秒)
#--------- 入力例2 ----------
# n = 2 # ボールの個数
# h = 10 # ボールの高さ(メートル)
# r = 10 # 半径(メートル)
# t = 100 # 経過時間(秒)
#----------------------------
import math
g = 10.0 # 重加速度

# 時刻 T でのボールの位置
def calc(t):
if t < 0:
return h
t1 = math.sqrt(2 * h / g)
k = int(t / t1)
if k % 2 == 0:
d = t - k * t1
return h - g * d * d / 2
else:
d = k * t1 + t1 - t
return h - g * d * d / 2

# 各ボールごとの最終位置を算出する
lst = [calc(t - i) for i in range(n)]

for x in sorted(lst):
print('{:.2f}'.format(x), end=' ')
print()

[実行結果(入力例1)]

4.95 

[実行結果(入力例2)]

4.95 10.00

最短経路(幅優先探索)

問題

$ H \times W $ の長方形状のグリッドがあり、各マスは白か黒に塗られています。

上から $ i $ 番目、左から $ j $ 番目のマスを $ (i, j) $ と表すことにします。

左上のマス $ (0,0) $ と右下のマス $ (H -1, W - 1) $ は白色です。

このグリッドの入力データは、$ H $ 個の長さ $ W $ の文字列 $ S_0, S_1, …, S_{H-1} $ として与えられます。

文字列 $ S_i $ の $ j $ 番目の文字は、マス $ (i,j) $ が白色の時は ‘,’(カンマ) であり、黒色の時は ‘#’(シャープ) です。

マス $ (0,0) $ からマス $ (H-1, W-1) $ まで白色マスのみを辿って行くことができる状態を保ちつつ、白色マスを黒色マスに塗り替える場合、黒く塗れるマスの最大個数はいくつになるでしょうか。

解法・ソースコード

この問題は、マス(0, 0)からマス(H - 1, W -1)への最短経路を求めて、最短経路と関係のない白マスをカウントすることで解くことができます。

今回は、最短経路を調べるために幅優先探索を行います。

[Google Colaboratory]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from queue import Queue
#--------- 入力例 -----------
s = ['.....#',
'####..',
'......',
'#.####',
'......']
#----------------------------
h = len(s) # グリッドの高さ
w = len(s[0]) # グリッドの横幅

# マス(x,y)を表すペア
que = Queue()
# dist[x,y]は、マス(x,y)への最短経路長
dist = [[-1] * w for _ in range(h)]

# 幅優先探索の初期条件
que.put((0, 0)) # スタート位置
dist[0][0] = 0

# 幅優先探索
while que.empty() == False:
# キューから要素を取り出す
qx, qy = que.get()

# 上下左右への移動を試す
for dx, dy in [(1, 0), (-1, 0), (0, 1), (0, -1)]:
x, y = qx + dx, qy + dy
# 配列外の参照の場合
if x < 0 or x >= h or y < 0 or y >= w:
continue
# 白マスの場合 かつ マス(x, y)が未探索の場合
if s[x][y] == '.' and dist[x][y] < 0:
# マス(x, y)をキューに追加して、最短経路長を更新
que.put((x, y))
dist[x][y] = dist[qx][qy] + 1

# 白マスの数をカウント
cnt_white = sum(line.count('.') for line in s)

print('解:', cnt_white - dist[h - 1][w - 1] - 1)

[実行結果]

解: 4

次のように4マスを黒く塗り替えることができ、右上から左下への最短経路はそのまま残すことができます。

(変更前)  (変更後)
.....#   .....#
####..   ####.#
...... ➡ #....#
#.####   #.####
......   #.....