巡回セールスマン問題のいくつかの解法を試してみた
巡回セールスマン問題を勉強したので、教科書に載っているいくつかの解法を実際に試してみました。
言語はGoで書いています。
教科書は次のものを使っています。
巡回セールスマン問題とは
巡回セールスマン問題とは
ネットワーク[G;d]の全ての点において, すべての点を一度ずつ訪問して元に戻る巡回路の中で最短のものを求める
問題です。今回はとくに、平面上の巡回セールスマン問題と呼ばれる、平面上にn個の点が存在し、それらの二点の距離がユークリッド距離で定められているような場合を考えます。つまり、Gは完全グラフです。
アルゴリズム
巡回セールスマン問題は一般のグラフに対しては、NP困難です。(このあたりはよくわかってないです...)
ですので、近似的なアルゴリズムでよりよい解を求めることになります。
教科書に載っている近似アルゴリズムは次の通りです。
- ランダム法(random method)
- 最近近傍法(nearest neighbor method)
- 挿入法(insertion method)
- 最近挿入法(nearest insertion method)
- 最遠挿入法(farthest insertion method)
- 最廉挿入法(cheapest insertion method)
- (局所探索法(local search)による改善)
この記事では局所探索法による改善は行わないことにします(そこが一番大切な気もしますが)。
グラフの頂点数
問題例
競技プログラミングの鉄則の中のB23 - Traveling Salesman Problemを解くことを考えます。とくに、テストケースのin14.txtをためします。
15
776 273
218 998
839 577
975 670
192 465
90 329
493 586
285 494
441 175
445 612
560 777
784 266
570 778
982 130
452 599
想定される出力は
3441.451976970622
です。(
実装
簡単な説明とコード、計算量を記載していきます。より高速化できる実装法があれば教えてください。
基本的にmain
関数の中身だけを記載します。入出力のために色々追加しているので詳しくはテンプレートをご確認ください。
AtCoder用テンプレート
package main
import (
"bufio"
"fmt"
"os"
"strconv"
"strings"
)
func main() {
}
func sum[T int | float64](arr ...T) T {
sum := arr[0]
for i := 1; i < len(arr); i++ {
sum += arr[i]
}
return sum
}
func pow(x, n int) int {
res := 1
for n > 0 {
if n&1 == 1 {
res = res * x
}
x = x * x
n >>= 1
}
return res
}
func lcm(a, b int) int {
return a / gcd(a, b) * b
}
func gcd(a, b int) int {
if b == 0 {
return a
}
return gcd(b, a%b)
}
func abs[T int | float64](x T) T {
if x < 0 {
return -x
}
return x
}
func min[T int | float64](arr ...T) T {
min := arr[0]
for _, v := range arr {
if v < min {
min = v
}
}
return min
}
func max[T int | float64](arr ...T) T {
max := arr[0]
for _, v := range arr {
if v > max {
max = v
}
}
return max
}
func ceilDiv(a, b int) int {
if b < 0 {
return ceilDiv(-a, -b)
}
if a < 0 {
return a / b
}
return (a + b - 1) / b
}
func floorDiv(a, b int) int {
if b < 0 {
return floorDiv(-a, -b)
}
return (a - positiveMod(a, b)) / b
}
func positiveMod(a, b int) int {
return (a%b + b) % b
}
var _stdInReader = bufio.NewReader(os.Stdin)
func readLine() string {
line, _ := _stdInReader.ReadString('\n')
return strings.TrimSpace(line)
}
func readString() string {
return readLine()
}
func readInt() int {
strs := readString()
num, _ := strconv.Atoi(strs)
return num
}
func readLong() int64 {
strs := readString()
num, _ := strconv.ParseInt(strs, 10, 64)
return num
}
func readStrings() []string {
line := readLine()
return strings.Split(line, " ")
}
func readInts() []int {
strs := readStrings()
arr := make([]int, len(strs))
for i := 0; i < len(strs); i++ {
arr[i], _ = strconv.Atoi(strs[i])
}
return arr
}
func readLongs() []int64 {
strs := readStrings()
arr := make([]int64, len(strs))
for i := 0; i < len(strs); i++ {
arr[i], _ = strconv.ParseInt(strs[i], 10, 64)
}
return arr
}
func readDoubles() []float64 {
strs := readStrings()
arr := make([]float64, len(strs))
for i := 0; i < len(strs); i++ {
arr[i], _ = strconv.ParseFloat(strs[i], 64)
}
return arr
}
func scanStringVariables(args ...*string) {
n := len(args)
scanned := 0
for n > scanned {
inputSlice := readStrings()
m := len(inputSlice)
if m == 0 || m > n-scanned {
fmt.Print("Something wrong in scanStringVariables !!!")
return
}
for i := 0; i < m; i++ {
*args[i+scanned] = inputSlice[i]
}
scanned += m
}
}
func scanIntVariables(args ...*int) {
n := len(args)
scanned := 0
for n > scanned {
inputSlice := readInts()
m := len(inputSlice)
if m == 0 || m > n-scanned {
fmt.Printf("m %d n %d scanned %d. ", m, n, scanned)
fmt.Print("Something wrong in scanIntVariables !!!")
return
}
for i := 0; i < m; i++ {
*args[i+scanned] = inputSlice[i]
}
scanned += m
}
}
func scanLongVariables(args ...*int64) {
n := len(args)
scanned := 0
for n > scanned {
inputSlice := readLongs()
m := len(inputSlice)
if m == 0 || m > n-scanned {
fmt.Print("Something wrong in scanIntVariables !!!")
return
}
for i := 0; i < m; i++ {
*args[i+scanned] = inputSlice[i]
}
scanned += m
}
}
func write(arg ...interface{}) { fmt.Print(arg...) }
func writeLine(arg ...interface{}) { fmt.Println(arg...) }
func writeFormat(f string, arg ...interface{}) { fmt.Printf(f, arg...) }
DPによる解法(最適解)
- const[n][i]: すでに訪れた都市の集合が
であって、最後にいる都市がn であるときの、合計コストの最小値。i
と定義することで、DPで解くことができます。
DPのコード
func main() {
n := readInt()
XY := make([][2]int, n)
for i := 0; i < n; i++ {
scanIntVariables(&XY[i][0], &XY[i][1])
}
D := make([][]float64, n)
for i := 0; i < n; i++ {
D[i] = make([]float64, n)
for j := 0; j < n; j++ {
D[i][j] = math.Sqrt(float64((XY[i][0]-XY[j][0])*(XY[i][0]-XY[j][0]) + (XY[i][1]-XY[j][1])*(XY[i][1]-XY[j][1])))
}
}
inf := float64(1 << 60)
cost := make([][]float64, 1<<n)
for i := 0; i < 1<<n; i++ {
cost[i] = make([]float64, n)
for j := 0; j < n; j++ {
cost[i][j] = inf
}
}
cost[0][0] = 0
for b := 0; b < 1<<n; b++ {
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
if i == j { // 同じ頂点には移動しない
continue
}
if (b>>j)&1 == 1 { // すでに j に訪問済み
continue
}
s := b | 1<<j // j を訪問済みにする
cost[s][j] = min(cost[s][j], cost[b][i]+D[i][j])
}
}
}
writeLine(cost[(1<<n)-1][0])
}
計算量は、
ランダム法
ランダムに頂点の順序を決め、その順序通りに巡回したときの距離を求めるという操作を有限時間繰り返し、その中で最小のものを近似解として出力します。
ここでは、制限時間は1980msとします。
ランダム法のコード
func main() {
n := readInt()
XY := make([][2]int, n)
for i := 0; i < n; i++ {
scanIntVariables(&XY[i][0], &XY[i][1])
}
D := make([][]float64, n)
for i := 0; i < n; i++ {
D[i] = make([]float64, n)
for j := 0; j < n; j++ {
D[i][j] = math.Sqrt(float64((XY[i][0]-XY[j][0])*(XY[i][0]-XY[j][0]) + (XY[i][1]-XY[j][1])*(XY[i][1]-XY[j][1])))
}
}
arr := make([]int, n)
for i := 0; i < n; i++ {
arr[i] = i
}
s := time.Now()
ans := math.Inf(1)
for {
rand.Shuffle(n, func(i, j int) {
arr[i], arr[j] = arr[j], arr[i]
})
tmp := 0.0
for i := 0; i < n; i++ {
tmp += D[arr[i]][arr[(i+1)%n]]
}
ans = min(ans, tmp)
if time.Since(s).Milliseconds() > 1980 {
break
}
}
writeLine(ans)
}
計算量は、一回あたり
5回試してみた結果は以下の通りです。
3886.654694326012
3857.1945450443095
3855.2302193326327
3798.6987806854304
3825.7162365425474
最近近傍法
適当に選んだ始点から初めて、最後に訪問した点から未探索の点への経路のうち、最短のものを選ぶことを繰り返す貪欲法です。最後に始点と終点を結ぶ辺の距離の影響が大きくなるという欠点があります。
最近近傍法のコード
type Edge struct {
to int
cost float64
}
func main() {
n := readInt()
XY := make([][2]int, n)
for i := 0; i < n; i++ {
scanIntVariables(&XY[i][0], &XY[i][1])
}
D := make([][]float64, n)
for i := 0; i < n; i++ {
D[i] = make([]float64, n)
for j := 0; j < n; j++ {
D[i][j] = math.Sqrt(float64((XY[i][0]-XY[j][0])*(XY[i][0]-XY[j][0]) + (XY[i][1]-XY[j][1])*(XY[i][1]-XY[j][1])))
}
}
G := make([][]Edge, n)
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
if i == j {
continue
}
G[i] = append(G[i], Edge{to: j, cost: D[i][j]})
}
}
for i := 0; i < n; i++ {
sort.Slice(G[i], func(i2, j int) bool {
return G[i][i2].cost < G[i][j].cost
})
}
ans := math.Inf(1)
for s := 0; s < n; s++ {
visitted := make([]bool, n)
d := 0.0
v := s
for i := 0; i < n-1; i++ {
visitted[v] = true
for j := 0; j < n; j++ {
if visitted[G[v][j].to] {
continue
}
d += G[v][j].cost
v = G[v][j].to
break
}
}
d += D[v][s]
ans = min(ans, d)
}
writeLine(ans)
}
計算量は
始点によって結果が変わるので、すべての点から試して、最短のものを出力します。
点0から点14をそれぞれ始点とした時の結果は次のとおりです。
4218.713265419705
3590.3604916024965
4237.482537209514
3864.957618054309
3912.91183951565
4018.657496514672
3472.9910227302225
3576.5487065612483
3950.7198960559463
3760.038040661286
3590.3604916024965
4239.70276247377
3621.152688942333
4301.324099674285
3786.3704735417805
最短なのは点6を始点としたときで、3472.9910227302225
です。ランダム法よりはかなり良い結果が得られました。
挿入法
こちらも貪欲法の一種で、部分巡回路
を最小にするような
- 最近挿入法
- 最遠挿入法
- 最廉挿入法
があります。ここでは、最近挿入法を試してみようと思います。最近挿入法は、部分巡回路
を最小にする
最近挿入法のコード
type Edge struct {
from int
to int
cost float64
}
type Node struct {
value int
prev *Node
next *Node
}
type UniqueDoublyLinkedList struct {
head *Node
tail *Node
accessor map[int]*Node
len int
}
func newLinkedList() *UniqueDoublyLinkedList {
return &UniqueDoublyLinkedList{
head: nil,
tail: nil,
accessor: make(map[int]*Node),
}
}
func (ll *UniqueDoublyLinkedList) add(value int) {
node := &Node{value: value}
if ll.head == nil {
ll.head = node
ll.tail = node
} else {
ll.tail.next = node
node.prev = ll.tail
ll.tail = node
}
ll.accessor[value] = node
ll.len++
}
func (ll *UniqueDoublyLinkedList) insertAfter(preValue int, value int) {
preNode := ll.accessor[preValue]
node := &Node{value: value}
node.prev = preNode
node.next = preNode.next
if preNode == ll.tail {
ll.tail = node
} else {
preNode.next.prev = node
}
preNode.next = node
ll.len++
ll.accessor[value] = node
}
func (ll *UniqueDoublyLinkedList) has(value int) bool {
_, ok := ll.accessor[value]
return ok
}
func main() {
n := readInt()
XY := make([][2]int, n)
for i := 0; i < n; i++ {
scanIntVariables(&XY[i][0], &XY[i][1])
}
D := make([][]float64, n)
for i := 0; i < n; i++ {
D[i] = make([]float64, n)
for j := 0; j < n; j++ {
D[i][j] = math.Sqrt(float64((XY[i][0]-XY[j][0])*(XY[i][0]-XY[j][0]) + (XY[i][1]-XY[j][1])*(XY[i][1]-XY[j][1])))
}
}
G := make([][]Edge, n)
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
G[i] = append(G[i], Edge{i, j, D[i][j]})
}
}
res := 0.0
nonVisited := make(map[int]bool)
for i := 0; i < n; i++ {
nonVisited[i] = true
}
pi := newLinkedList() // 部分巡回路
s := 0
v := s
pi.add(v)
delete(nonVisited, v)
que := priorityqueue.New[Edge](func(a, b Edge) int {
if a.cost < b.cost {
return -1
} else if a.cost > b.cost {
return 1
}
return 0
}) // 挿入する辺の候補
for _, e := range G[v] {
que.Push(e)
}
for !que.Empty() {
e := que.Pop()
if pi.has(e.to) {
continue
}
if pi.len == 1 {
res += e.cost
pi.add(e.to)
} else {
u := pi.head
minCost := math.Inf(1)
minV := -1
for u.next != nil {
cost := D[u.value][e.to] + D[e.to][u.next.value] - D[u.value][u.next.value]
if cost < minCost {
minCost = cost
minV = u.value
}
u = u.next
}
res += minCost
pi.insertAfter(minV, e.to)
}
delete(nonVisited, e.to)
v = e.to
for u := range nonVisited {
que.Push(G[v][u])
}
}
res += D[pi.tail.value][s]
writeLine(res)
}
かなり雑な実装になりました。挿入を行うために連結リストを用いました。すべての頂点の値はユニークなので、挿入を定数時間で行うためにmap
で直接ノードにアクセスできるようにしています。また、挿入する
計算量は大きく見積もっても
今回の問題例に対する出力は
3441.451976970623
で、最適解を得ることができました。
N=150 )
おまけ(入力はランダムに作成した次を用います。
input
150
3892 -5182
24 5203
-6981 1752
6011 1659
2037 4086
-4522 3455
825 -4313
3677 175
-1063 -6756
-9948 -4369
-1610 7499
2510 6130
-7403 -2122
-7770 -8959
1134 2583
9670 -635
6735 2212
-8641 8333
-8847 3569
8085 7334
-2093 2507
-8248 -6616
395 -3256
9188 8271
2388 6100
8940 -8988
-5426 -5545
8879 911
-4357 -864
2394 -4992
-1784 1219
3875 4033
6409 3843
2567 6673
-1936 8450
-9515 844
5417 1166
6281 3503
-9788 -4932
7533 -172
-9603 232
5399 3788
1495 6147
-3731 7457
-4988 -500
9199 4551
-8171 -5191
920 166
-5810 2833
4383 7259
7067 -3149
6753 7877
-730 -6274
-3100 -9303
3238 1466
9657 -4907
5177 -5193
8081 -1567
-9577 -3469
8393 -3330
-2914 -9827
-7304 1287
8859 9484
-8028 2896
1962 7024
-7150 -6095
-5960 5027
-4018 -493
-2573 -57
-3145 6131
2326 -4624
219 -9358
6334 -3410
9808 -629
490 3251
-4036 -50
5974 -6034
9533 8221
3568 9945
8690 2291
9444 3286
8356 -872
5213 9676
-6692 9072
3121 5334
4698 -6635
5765 9585
-6990 -614
-2988 5271
-4967 -12
-2604 8409
82 -5961
-1824 2646
-4322 -9678
-2198 -5055
-7389 3061
-4864 5168
-6223 -7519
9712 -9537
-448 468
-3291 5
3527 6021
-5999 80
-1004 5706
5632 5521
5931 -7466
1355 -1063
-429 1219
6093 4189
4374 -7568
8447 -8160
9871 -9711
4913 9974
-9245 -9697
-1781 -4598
7112 6853
-9424 3651
4842 -1225
-7435 6643
3630 3979
9636 2467
6412 -3221
9994 5081
1299 4130
-9370 5638
-9913 9787
7515 -4810
-2434 9842
2397 3112
-3490 5646
-1172 712
-7687 -8665
-7553 6628
2061 -8665
4324 2821
-5327 4737
3603 9989
-4455 -3441
8462 9621
7083 5086
-2537 -2589
-8554 7211
-9789 8131
5786 -7554
1517 -1680
5349 -8529
9014 5967
9313 -5376
-5860 -2507
-3568 9774
結果は以下の通りです。
アルゴリズム | 実行時間 | 出力 |
---|---|---|
ランダム法 | 1989ms | 1334675.015424154 |
最近近傍法 | 11ms | 222276.1101097709 |
最近挿入法 | 668ms | 242387.82579042445 |
まとめ
最近挿入法の実装がかなり無駄が多い気がします。もっとシンプルにしたり、計算量の改善ができる方法があれば教えてください。
教科書を読むだけではイメージし辛いアルゴリズムも、実際に実装することで流れが理解できたので、これからも続けていこうと思います。
最後まで読んでいただきありがとうございました。見苦しいコードが多くてすみません。
Discussion