From a36add23440c53d791b4b5fe8807b59141a031d1 Mon Sep 17 00:00:00 2001 From: Tyson Brown Date: Wed, 10 Apr 2024 01:04:22 -0400 Subject: [PATCH] Initial commit. --- .gitignore | 1 + cmd/maze/main.go | 59 ++++ go.mod | 3 + internal/solver/bounded/bounded.go | 73 +++++ .../bounded_tracking/bounded_tracking.go | 86 +++++ internal/solver/problem/problem.go | 50 +++ internal/solver/solver.go | 33 ++ internal/solver/unbounded/unbounded.go | 174 ++++++++++ .../unbounded_tracking/unbounded_tracking.go | 187 +++++++++++ maze/maze.go | 269 ++++++++++++++++ maze/problem.go | 304 ++++++++++++++++++ solve.go | 108 +++++++ 12 files changed, 1347 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/maze/main.go create mode 100644 go.mod create mode 100644 internal/solver/bounded/bounded.go create mode 100644 internal/solver/bounded_tracking/bounded_tracking.go create mode 100644 internal/solver/problem/problem.go create mode 100644 internal/solver/solver.go create mode 100644 internal/solver/unbounded/unbounded.go create mode 100644 internal/solver/unbounded_tracking/unbounded_tracking.go create mode 100644 maze/maze.go create mode 100644 maze/problem.go create mode 100644 solve.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41c0517 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/maze.png \ No newline at end of file diff --git a/cmd/maze/main.go b/cmd/maze/main.go new file mode 100644 index 0000000..c75aeb1 --- /dev/null +++ b/cmd/maze/main.go @@ -0,0 +1,59 @@ +package main + +import ( + "flag" + "image" + "image/png" + "log" + "math/rand" + "os" + "time" + + "smariot.com/tsp" + "smariot.com/tsp/maze" +) + +func main() { + var ( + w, h int + seed int64 + imagePath string + ) + + flag.IntVar(&w, "w", 20, "width of the maze") + flag.IntVar(&h, "h", 15, "height of the maze") + flag.StringVar(&imagePath, "o", "maze.png", "output image path") + flag.Int64Var(&seed, "seed", rand.Int63(), "random seed") + flag.Parse() + + t := time.Now() + m := maze.Make(image.Rect(0, 0, w, h), rand.New(rand.NewSource(seed))) + log.Printf("created maze in %v", time.Since(t)) + + t = time.Now() + path, err := tsp.Solve(maze.NewProblem(m.Size, m.Start, m.End, m.Right, m.Down), 0) + log.Printf("solved maze in %v", time.Since(t)) + + if err != nil { + log.Printf("failed to solve maze: %v", err) + } + + t = time.Now() + image := m.Draw(path) + log.Printf("drawn maze in %v", time.Since(t)) + + f, err := os.Create(imagePath) + if err != nil { + log.Fatalf("failed to create image file: %v", err) + } + + t = time.Now() + if err := png.Encode(f, image); err != nil { + log.Fatalf("failed to write image: %v", err) + } + log.Printf("wrote image in %v", time.Since(t)) + + if err := f.Close(); err != nil { + log.Fatalf("failed to close image file: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a6ac26b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module smariot.com/tsp + +go 1.22.1 diff --git a/internal/solver/bounded/bounded.go b/internal/solver/bounded/bounded.go new file mode 100644 index 0000000..6aa7ad6 --- /dev/null +++ b/internal/solver/bounded/bounded.go @@ -0,0 +1,73 @@ +package bounded + +import ( + "container/heap" + + "smariot.com/tsp/internal/solver/problem" +) + +type minHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + items []State +} + +func (h minHeap[P, State]) Len() int { + return len(h.items) +} + +func (h minHeap[P, State]) Less(i, j int) bool { + return h.problem.OptimisticLess(h.items[i], h.items[j]) +} + +func (h minHeap[P, State]) Swap(i, j int) { + h.items[i], h.items[j] = h.items[j], h.items[i] +} + +func (h *minHeap[P, State]) Push(x any) { + state := x.(State) + h.items = append(h.items, state) +} + +func (h *minHeap[P, State]) Pop() any { + n := len(h.items) + state := h.items[n-1] + h.items = h.items[:n-1] + return state +} + +type solver[P problem.Problem[State], State comparable] struct { + minHeap[P, State] +} + +func (s *solver[P, State]) Push(state State) { + heap.Push(&s.minHeap, state) +} + +func (s *solver[P, State]) Pop() (State, bool) { + if s.Len() == 0 { + var zero State + return zero, false + } + + return heap.Pop(&s.minHeap).(State), true +} + +func (s *solver[P, State]) Reset() { + for _, state := range s.items { + s.problem.Discard(state) + } + + s.items = s.items[:0] +} + +// Returns a solver for bounded problems. +// +// This solver does not track states. Submitting a state multiple times will +// result in multiple copies being stored, and multiple calls to problem.Discard. +func New[P problem.Problem[State], State comparable](problem P) *solver[P, State] { + return &solver[P, State]{ + minHeap: minHeap[P, State]{ + problem: problem, + }, + } +} diff --git a/internal/solver/bounded_tracking/bounded_tracking.go b/internal/solver/bounded_tracking/bounded_tracking.go new file mode 100644 index 0000000..25d6fe3 --- /dev/null +++ b/internal/solver/bounded_tracking/bounded_tracking.go @@ -0,0 +1,86 @@ +package bounded_tracking + +import ( + "container/heap" + + "smariot.com/tsp/internal/solver/problem" +) + +type minHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + known map[State]int + items []State +} + +func (h minHeap[P, State]) Len() int { + return len(h.items) +} + +func (h minHeap[P, State]) Less(i, j int) bool { + return h.problem.OptimisticLess(h.items[i], h.items[j]) +} + +func (h minHeap[P, State]) Swap(i, j int) { + h.items[i], h.items[j] = h.items[j], h.items[i] + h.known[h.items[i]] = i + h.known[h.items[j]] = j +} + +func (h *minHeap[P, State]) Push(x any) { + state := x.(State) + h.items = append(h.items, state) + h.known[state] = len(h.items) +} + +func (h *minHeap[P, State]) Pop() any { + n := len(h.items) + state := h.items[n-1] + h.items = h.items[:n-1] + delete(h.known, state) + return state +} + +type solver[P problem.Problem[State], State comparable] struct { + minHeap[P, State] +} + +func (s *solver[P, State]) Push(state State) { + if i, ok := s.known[state]; ok { + // The state is already in the heap, update its position instead. + heap.Fix(&s.minHeap, i) + return + } + + heap.Push(&s.minHeap, state) +} + +func (s *solver[P, State]) Pop() (State, bool) { + if s.Len() == 0 { + var zero State + return zero, false + } + + return heap.Pop(&s.minHeap).(State), true +} + +func (s *solver[P, State]) Reset() { + for _, state := range s.items { + s.problem.Discard(state) + } + + s.items = s.items[:0] + clear(s.known) +} + +// Returns a solver that for bounded problems that can update their states. +// +// Submitting a state that is already in the heap will update its position, +// rather than adding it again. problem.Discard will only be invoked once. +func New[P problem.Problem[State], State comparable](problem P) *solver[P, State] { + return &solver[P, State]{ + minHeap: minHeap[P, State]{ + problem: problem, + known: make(map[State]int), + }, + } +} diff --git a/internal/solver/problem/problem.go b/internal/solver/problem/problem.go new file mode 100644 index 0000000..b896356 --- /dev/null +++ b/internal/solver/problem/problem.go @@ -0,0 +1,50 @@ +package problem + +type Problem[State comparable] interface { + // Discards the given state, allowing any resources associated with it to be released. + Discard(state State) + + // Returns true if the first state is more likely to be a better solution than the second state, + // assuming the best case scenario. This is used to determine the best state to expand next. + // + // If this were to compare the distance traveled so far, this would be equivalent to Dijkstra's algorithm. + // + // If you added a heuristic to estimate the remaining distance, this would be equivalent to A*. + // + // For the traveling salesman problem, you might use traveled distance plus half of the remaining greedy tour length as a lower bound (optimistic) estimate. + OptimisticLess(a State, b State) bool + + // Returns true if the first state is more likely to be a better solution than the second state, + // assuming the worst case scenario. This is used to determine if the worst state to discard when at capacity. + // + // This can be equivalent to OptimisticLess in many cases. + // + // For the traveling salesman problem, you might use traveled distance plus the remaining greedy tour length as an upper bound (pessimistic) estimate. + // + // You generally want to penalize states with a lot of uncertainty about their actual cost, especially + // for something like the traveling salesman problem where finding the optimal solution is impractical. + + // We could maybe handle finding the optimal TSP solution for 100 points, but definitely not 1000. + // For that, you'd likely want to cluster your points (maybe using k-means) into a manageable number of groups, + // and recursively solve the problem on each group. + PessimisticLess(a State, b State) bool +} + +type ProblemStateUpdates[State comparable] interface { + Problem[State] + + // If this function returns true, then the solver needs to keep track of + // known states so that it can update their relative order when they're resubmitted. + // + // If a problem doesn't implement this method, then true is assumed by default. + StateUpdates() bool +} + +func RequiresStateUpdates[P Problem[State], State comparable](p Problem[State]) bool { + if p, ok := p.(ProblemStateUpdates[State]); ok { + return p.StateUpdates() + } + + // return true as a safe default. + return true +} diff --git a/internal/solver/solver.go b/internal/solver/solver.go new file mode 100644 index 0000000..6ce0b7d --- /dev/null +++ b/internal/solver/solver.go @@ -0,0 +1,33 @@ +package solver + +import ( + "smariot.com/tsp/internal/solver/bounded" + "smariot.com/tsp/internal/solver/bounded_tracking" + "smariot.com/tsp/internal/solver/problem" + "smariot.com/tsp/internal/solver/unbounded" + "smariot.com/tsp/internal/solver/unbounded_tracking" +) + +type Solver[State comparable] interface { + Push(State) + Pop() (State, bool) + Reset() +} + +// New creates a new state solver for the given problem and capacity. +// +// A capacity of 0 implies no limit, and we won't maintain a max heap. +// +// If P implements ProblemStateUpdates, then the solver will keep track of known states. +func New[P problem.Problem[State], State comparable](p P, capacity int) Solver[State] { + switch { + case capacity == 0 && problem.RequiresStateUpdates[P](p): + return bounded_tracking.New(p) + case capacity == 0: + return bounded.New(p) + case problem.RequiresStateUpdates[P](p): + return unbounded_tracking.New(p, capacity) + default: + return unbounded.New(p, capacity) + } +} diff --git a/internal/solver/unbounded/unbounded.go b/internal/solver/unbounded/unbounded.go new file mode 100644 index 0000000..fccb2ea --- /dev/null +++ b/internal/solver/unbounded/unbounded.go @@ -0,0 +1,174 @@ +package unbounded + +import ( + "container/heap" + + "smariot.com/tsp/internal/solver/problem" +) + +type heapEntry[State comparable] struct { + state State + minIndex int + maxIndex int +} + +type minHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + entries []heapEntry[State] + indexes []int +} + +func (h minHeap[P, State]) Len() int { + return len(h.indexes) +} + +func (h minHeap[P, State]) Less(i, j int) bool { + return h.problem.OptimisticLess(h.entries[h.indexes[i]].state, h.entries[h.indexes[j]].state) +} + +func (h minHeap[P, State]) Swap(i, j int) { + h.entries[h.indexes[i]].minIndex = j + h.entries[h.indexes[j]].minIndex = i + h.indexes[i], h.indexes[j] = h.indexes[j], h.indexes[i] +} + +func (h *minHeap[P, State]) Push(x any) { + index := x.(int) + h.entries[index].minIndex = len(h.indexes) + h.indexes = append(h.indexes, index) +} + +func (h *minHeap[P, State]) Pop() any { + n := len(h.indexes) + index := h.indexes[n-1] + h.entries[index].minIndex = -1 + h.indexes = h.indexes[:n-1] + return index +} + +type maxHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + entries []heapEntry[State] + indexes []int +} + +func (h maxHeap[P, State]) Len() int { + return len(h.indexes) +} + +func (h maxHeap[P, State]) Less(i, j int) bool { + return h.problem.PessimisticLess(h.entries[h.indexes[j]].state, h.entries[h.indexes[i]].state) +} + +func (h maxHeap[P, State]) Swap(i, j int) { + h.entries[h.indexes[i]].maxIndex = j + h.entries[h.indexes[j]].maxIndex = i + h.indexes[i], h.indexes[j] = h.indexes[j], h.indexes[i] +} + +func (h *maxHeap[P, State]) Push(x any) { + index := x.(int) + h.entries[index].maxIndex = len(h.indexes) + h.indexes = append(h.indexes, index) +} + +func (h *maxHeap[P, State]) Pop() any { + n := len(h.indexes) + index := h.indexes[n-1] + h.entries[index].maxIndex = -1 + h.indexes = h.indexes[:n-1] + return index +} + +type solver[P problem.Problem[State], State comparable] struct { + minHeap[P, State] + maxHeap maxHeap[P, State] + free []int +} + +func (s *solver[P, State]) Push(state State) { + if len(s.free) == 0 { + // if this is worse than the worst state, discard it. + if !s.problem.PessimisticLess(state, s.entries[s.maxHeap.indexes[0]].state) { + s.problem.Discard(state) + return + } + + // otherwise, discard and replace the worst state. + index := s.maxHeap.indexes[0] + s.problem.Discard(s.entries[index].state) + s.entries[index].state = state + heap.Fix(&s.minHeap, s.entries[index].minIndex) + heap.Fix(&s.maxHeap, 0) + return + } + + index := s.free[len(s.free)-1] + s.free = s.free[:len(s.free)-1] + + s.entries[index].state = state + + heap.Push(&s.minHeap, index) + heap.Push(&s.maxHeap, index) +} + +func (s *solver[P, State]) Pop() (State, bool) { + if s.Len() == 0 { + var zero State + return zero, false + } + + index := heap.Pop(&s.minHeap).(int) + s.free = append(s.free, index) + + heap.Remove(&s.maxHeap, s.entries[index].maxIndex) + + return s.entries[index].state, true +} + +func (s *solver[P, State]) Reset() { + for _, index := range s.minHeap.indexes { + s.problem.Discard(s.entries[index].state) + s.free = append(s.free, index) + } + + s.minHeap.indexes = s.minHeap.indexes[:0] + s.maxHeap.indexes = s.maxHeap.indexes[:0] +} + +// Returns a solver for unbounded problems. +// +// It maintains both a min and a max heap, and will automatically discard states once it reaches a maximum capacity. +// +// It doesn't keep track of known states. Submitting a state multiple times will result in multiple copies being stored, +// and problem.Discard being called multiple times. +func New[P problem.Problem[State], State comparable](problem P, capacity int) *solver[P, State] { + if capacity <= 0 { + panic("unbounded.New: capacity must be greater than 0") + } + + free := make([]int, capacity) + entries := make([]heapEntry[State], capacity) + + for i := 0; i < capacity; i++ { + free[i] = capacity - i - 1 + entries[i].minIndex = -1 + entries[i].maxIndex = -1 + } + + indexes := make([]int, capacity*2) + + return &solver[P, State]{ + free: free, + minHeap: minHeap[P, State]{ + problem: problem, + entries: entries, + indexes: indexes[0:0:capacity], + }, + maxHeap: maxHeap[P, State]{ + problem: problem, + entries: entries, + indexes: indexes[capacity : capacity : capacity*2], + }, + } +} diff --git a/internal/solver/unbounded_tracking/unbounded_tracking.go b/internal/solver/unbounded_tracking/unbounded_tracking.go new file mode 100644 index 0000000..6a1489e --- /dev/null +++ b/internal/solver/unbounded_tracking/unbounded_tracking.go @@ -0,0 +1,187 @@ +package unbounded_tracking + +import ( + "container/heap" + + "smariot.com/tsp/internal/solver/problem" +) + +type heapEntry[State comparable] struct { + state State + minIndex int + maxIndex int +} + +type minHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + entries []heapEntry[State] + indexes []int +} + +func (h minHeap[P, State]) Len() int { + return len(h.indexes) +} + +func (h minHeap[P, State]) Less(i, j int) bool { + return h.problem.OptimisticLess(h.entries[h.indexes[i]].state, h.entries[h.indexes[j]].state) +} + +func (h minHeap[P, State]) Swap(i, j int) { + h.entries[h.indexes[i]].minIndex = j + h.entries[h.indexes[j]].minIndex = i + h.indexes[i], h.indexes[j] = h.indexes[j], h.indexes[i] +} + +func (h *minHeap[P, State]) Push(x any) { + index := x.(int) + h.entries[index].minIndex = len(h.indexes) + h.indexes = append(h.indexes, index) +} + +func (h *minHeap[P, State]) Pop() any { + n := len(h.indexes) + index := h.indexes[n-1] + h.entries[index].minIndex = -1 + h.indexes = h.indexes[:n-1] + return index +} + +type maxHeap[P problem.Problem[State], State comparable] struct { + problem problem.Problem[State] + entries []heapEntry[State] + indexes []int +} + +func (h maxHeap[P, State]) Len() int { + return len(h.indexes) +} + +func (h maxHeap[P, State]) Less(i, j int) bool { + return h.problem.PessimisticLess(h.entries[h.indexes[j]].state, h.entries[h.indexes[i]].state) +} + +func (h maxHeap[P, State]) Swap(i, j int) { + h.entries[h.indexes[i]].maxIndex = j + h.entries[h.indexes[j]].maxIndex = i + h.indexes[i], h.indexes[j] = h.indexes[j], h.indexes[i] +} + +func (h *maxHeap[P, State]) Push(x any) { + index := x.(int) + h.entries[index].maxIndex = len(h.indexes) + h.indexes = append(h.indexes, index) +} + +func (h *maxHeap[P, State]) Pop() any { + n := len(h.indexes) + index := h.indexes[n-1] + h.entries[index].maxIndex = -1 + h.indexes = h.indexes[:n-1] + return index +} + +type solver[P problem.Problem[State], State comparable] struct { + minHeap[P, State] + maxHeap maxHeap[P, State] + known map[State]int + free []int +} + +func (s *solver[P, State]) Push(state State) { + if i, ok := s.known[state]; ok { + // The state is already in the heap, update its position instead. + heap.Fix(&s.minHeap, s.entries[i].minIndex) + heap.Fix(&s.maxHeap, s.entries[i].maxIndex) + return + } + + if len(s.free) == 0 { + // if this is worse than the worst state, discard it. + if !s.problem.PessimisticLess(state, s.entries[s.maxHeap.indexes[0]].state) { + s.problem.Discard(state) + return + } + + // otherwise, discard and replace the worst state. + index := s.maxHeap.indexes[0] + delete(s.known, s.entries[index].state) + s.problem.Discard(s.entries[index].state) + s.entries[index].state = state + s.known[state] = index + heap.Fix(&s.minHeap, s.entries[index].minIndex) + heap.Fix(&s.maxHeap, 0) + return + } + + index := s.free[len(s.free)-1] + s.free = s.free[:len(s.free)-1] + + s.entries[index].state = state + s.known[state] = index + + heap.Push(&s.minHeap, index) + heap.Push(&s.maxHeap, index) +} + +func (s *solver[P, State]) Pop() (State, bool) { + if s.Len() == 0 { + var zero State + return zero, false + } + + index := heap.Pop(&s.minHeap).(int) + s.free = append(s.free, index) + + heap.Remove(&s.maxHeap, s.entries[index].maxIndex) + delete(s.known, s.entries[index].state) + + return s.entries[index].state, true +} + +func (s *solver[P, State]) Reset() { + for _, index := range s.minHeap.indexes { + s.problem.Discard(s.entries[index].state) + s.free = append(s.free, index) + } + + s.minHeap.indexes = s.minHeap.indexes[:0] + s.maxHeap.indexes = s.maxHeap.indexes[:0] + clear(s.known) +} + +// Returns a solver for unbounded problems, where states can be updated. +// +// It maintains both a min and a max heap, and will automatically discard states once it reaches a maximum capacity. +// +// Submitting a state that is already in the heap will update its position in the heap. +func New[P problem.Problem[State], State comparable](problem P, capacity int) *solver[P, State] { + if capacity <= 0 { + panic("unbounded.New: capacity must be greater than 0") + } + + free := make([]int, capacity) + entries := make([]heapEntry[State], capacity) + + for i := 0; i < capacity; i++ { + free[i] = capacity - i - 1 + entries[i].minIndex = -1 + entries[i].maxIndex = -1 + } + + indexes := make([]int, capacity*2) + + return &solver[P, State]{ + free: free, + known: make(map[State]int), + minHeap: minHeap[P, State]{ + problem: problem, + entries: entries, + indexes: indexes[0:0:capacity], + }, + maxHeap: maxHeap[P, State]{ + problem: problem, + entries: entries, + indexes: indexes[capacity : capacity : capacity*2], + }, + } +} diff --git a/maze/maze.go b/maze/maze.go new file mode 100644 index 0000000..29e4d18 --- /dev/null +++ b/maze/maze.go @@ -0,0 +1,269 @@ +package maze + +import ( + "image" + "image/color" + "image/draw" + "math/rand" +) + +type cell uint8 + +const ( + // cellRight is set if movement to the right is allowed. + cellRight cell = 1 << iota + + // cellDown is set if movement down is allowed. + cellDown +) + +type Maze struct { + Size image.Rectangle + Start, End image.Point + + // the cells in the maze, starting at size.Min and going left-to-right, top-to-bottom. + cells []cell +} + +// if cells at i and j belong to different neighborhoods, they will be merged +// and true will be returned. Otherwise, false will be returned. +func tryJoinNeighborhoods(neighborhood []*[]int, i, j int) bool { + if aN, bN := neighborhood[i], neighborhood[j]; aN != bN { + // prefer the larger set absorbing the smaller set. + if len(*aN) < len(*bN) { + aN, bN = bN, aN + } + + // aN is now the larger set, and bN is the smaller set. + + // append the smaller set to the larger set. + *aN = append(*aN, *bN...) + + // update the membership of the smaller set to point to the larger set. + for _, member := range *bN { + neighborhood[member] = aN + } + + return true + } + return false +} + +// Make creates a new maze of the given size, using the given random number generator. +// +// If r is nil, the default random number generator will be used. +func Make(size image.Rectangle, r *rand.Rand) Maze { + if size.Dx() <= 0 || size.Dy() <= 0 { + panic("invalid maze size") + } + + if r == nil { + // create a new random number generator if + // one wasn't provided. + r = rand.New(rand.NewSource(rand.Int63())) + } + + w, h := size.Dx(), size.Dy() + neighborhoods := make([]*[]int, w*h) + cells := make([]cell, w*h) + + // each cell begins initially disconnected, alone in its own set. + for i := range cells { + neighborhoods[i] = &[]int{i} + } + + // create a list of all edges in the maze. + // there are (w-1)*h horizontal edges, and w*(h-1) vertical edges. + // the first (w-1)*h edges are horizontal, and the rest are vertical. + edges := make([]int, (w-1)*h+w*(h-1)) + for i := range edges { + edges[i] = i + } + + // shuffle the list of edges, this will be the order in which we attempt to join cells. + r.Shuffle(len(edges), func(i, j int) { + edges[i], edges[j] = edges[j], edges[i] + }) + + // repeatedly join cells until all cells are in the same set. + for _, edge := range edges { + if edge < (w-1)*h { + // join right + // we need to convert the edge index into coordinates, and then back into a cell index. + i := edge%(w-1) + edge/(w-1)*w + if tryJoinNeighborhoods(neighborhoods, i, i+1) { + cells[i] |= cellRight + } else { + // already joined, skip to the next edge. + continue + } + } else { + // join down + // we can mostly use the edge index unmodified, we just need to subtract the length of the horizontal edges. + i := edge - (w-1)*h + if tryJoinNeighborhoods(neighborhoods, i, i+w) { + cells[i] |= cellDown + } else { + // already joined, skip to the next edge. + continue + } + } + + // if we reach this point, then two neighborhoods were joined. If all cells are in the same neighborhood, + // then we can stop trying to merge the remaining edges. + if len(*neighborhoods[0]) == w*h { + break + } + } + + var start, end image.Point + + // choose a start and end point on opposite sides of the maze. + switch r.Intn(2) { + case 0: // left/right + start = image.Pt(0, r.Intn(h)).Add(size.Min) + end = image.Pt(w-1, r.Intn(h)).Add(size.Min) + + case 1: // top/bottom + start = image.Pt(r.Intn(w), 0).Add(size.Min) + end = image.Pt(r.Intn(w), h-1).Add(size.Min) + } + + if r.Intn(2) == 0 { + // swap start and end points. + start, end = end, start + } + + return Maze{ + Size: size, + Start: start, + End: end, + cells: cells, + } +} + +func (m Maze) coordToIndex(p image.Point) int { + return p.X - m.Size.Min.X + (p.Y-m.Size.Min.Y)*m.Size.Dx() +} + +// Returns true if you can move to the right from the given point. +func (m Maze) Right(p image.Point) bool { + return p.In(m.Size) && m.cells[m.coordToIndex(p)]&cellRight != 0 +} + +// Returns true if you can move down from the given point. +func (m Maze) Down(p image.Point) bool { + return p.In(m.Size) && m.cells[m.coordToIndex(p)]&cellDown != 0 +} + +// Returns true if you can move to the left from the given point. +func (m Maze) Left(p image.Point) bool { + return m.Right(p.Sub(image.Pt(1, 0))) +} + +// Returns true if you can move up from the given point. +func (m Maze) Up(p image.Point) bool { + return m.Down(p.Sub(image.Pt(0, 1))) +} + +// Returns a drawing of the maze, mostly for debugging purposes. +// An optional path can be drawn on top of the maze. +func (m Maze) Draw(path []image.Point) *image.RGBA { + const cellSize = 16 + const markerRadius = 4 // the radius of the start and end markers. + const wallRadius = 2 // half the thickness of the wall lines. + const pathRadius = 2 // half the thickness of the path line. + + backgroundColor := image.NewUniform(color.Black) + wallColor := image.NewUniform(color.White) + startColor := image.NewUniform(color.RGBA{0, 255, 0, 255}) + endColor := image.NewUniform(color.RGBA{255, 0, 0, 255}) + pathColor := image.NewUniform(color.RGBA{0, 0, 255, 255}) + + leftWall := image.Rect(0, 0, 0, cellSize).Inset(-wallRadius) + topWall := image.Rect(0, 0, cellSize, 0).Inset(-wallRadius) + rightWall := leftWall.Add(image.Pt(cellSize, 0)) + bottomWall := topWall.Add(image.Pt(0, cellSize)) + + marker := image.Rect(cellSize/2-markerRadius, cellSize/2-markerRadius, cellSize/2+markerRadius, cellSize/2+markerRadius) + + img := image.NewRGBA(image.Rect(0, 0, m.Size.Dx()*cellSize+wallRadius*2, m.Size.Dy()*cellSize+wallRadius*2)) + + // fill the entire image with the background color. + draw.Draw(img, img.Bounds(), backgroundColor, image.Point{}, draw.Src) + + for y := m.Size.Min.Y; y < m.Size.Max.Y; y++ { + for x := m.Size.Min.X; x < m.Size.Max.X; x++ { + + p := image.Pt(x, y) + cellP := p.Sub(m.Size.Min).Mul(cellSize).Add(image.Pt(wallRadius, wallRadius)) + + draw := func(rect image.Rectangle, color *image.Uniform) { + draw.Draw(img, rect.Add(cellP), color, image.Point{}, draw.Src) + } + + if p == m.Start { + draw(marker, startColor) + } + + if p == m.End { + draw(marker, endColor) + } + + if !m.Right(p) { + draw(rightWall, wallColor) + } + + if !m.Down(p) { + draw(bottomWall, wallColor) + } + + if !m.Left(p) { + draw(leftWall, wallColor) + } + + if !m.Up(p) { + draw(topWall, wallColor) + } + } + } + + if len(path) > 1 { + pathDrawOffset := image.Pt(cellSize/2+wallRadius, cellSize/2+wallRadius) + + draw := func(i, j int) { + // fake drawing a line between the two points by treating them as points + // of a rectangle, and drawing that rectangle instead. + // + // this only works because we're assuming there are no diagonal movements. + rect := image.Rectangle{ + path[i].Sub(m.Size.Min).Mul(cellSize), + path[j].Sub(m.Size.Min).Mul(cellSize), + }.Canon().Inset(-pathRadius).Add(pathDrawOffset) + + draw.Draw(img, rect, pathColor, image.Point{}, draw.Src) + } + + start := 0 + + // cheating by assuming adjacent points in the path are adjacent cells, + // and movements are only horizontal or vertical. + for i := 1; i < len(path); i++ { + if path[start].X == path[i].X || path[start].Y == path[i].Y { + // while points are on the same horizontal or vertical line, + // skip the intermediate points. + continue + } + + // all the points before this one were on the same line, so draw them + // as a single line. + draw(start, i-1) + start = i - 1 + } + + // any remaining points we haven't drawn yet are also on the same line. + draw(start, len(path)-1) + } + + return img +} diff --git a/maze/problem.go b/maze/problem.go new file mode 100644 index 0000000..1caac26 --- /dev/null +++ b/maze/problem.go @@ -0,0 +1,304 @@ +package maze + +import ( + "cmp" + "fmt" + "image" +) + +type problemCellState uint8 + +const ( + // zero value, for unvisited cells. + stateUnvisited problemCellState = iota + + // we considered the cell and set its heuristic, but then the movement + // test failed, we don't know how to reach it still. distance is still uninitialized. + stateUnexplored + + // this is the starting cell. + // distance is 0. + stateStart + + // the cell was reached via the respective direction. + // distance is the number of cells traversed to reach this cell. + stateRight + stateDown + stateLeft + stateUp + + // the number of directions, for array bounds. + // not used as a state itself. + stateLen +) + +var stateStrings = [stateLen]string{ + "unvisited", + "unexplored", + "start", + "right", + "down", + "left", + "up", +} + +// lookup table to reverse the direction. +// +// used to backtrack from the end to the start once the maze is solved. +var dirReverse = [stateLen]problemCellState{ + stateUnvisited, // stateUnvisited + stateUnexplored, // stateUnexplored + stateStart, // stateStart + stateLeft, // stateRight + stateUp, // stateDown + stateRight, // stateLeft + stateDown, // stateUp +} + +// lookup table to advance in the given direction. +var dirAdvance = [stateLen]image.Point{ + {0, 0}, // stateUnvisited + {0, 0}, // stateUnexplored + {0, 0}, // stateStart + {1, 0}, // stateRight + {0, 1}, // stateDown + {-1, 0}, // stateLeft + {0, -1}, // stateUp +} + +// lookup table to offset a point during movement tests +// for left and up, we need to test the cell to the left or above. +var dirTestOffset = [stateLen]image.Point{ + {0, 0}, // stateUnvisited + {0, 0}, // stateUnexplored + {0, 0}, // stateStart + {0, 0}, // stateRight + {0, 0}, // stateDown + {-1, 0}, // stateLeft + {0, -1}, // stateUp +} + +func (d problemCellState) String() string { + if d < stateLen { + return stateStrings[d] + } + + // The Stringer is the only case where we do bounds checking, the other functions can just panic. + return fmt.Sprintf("unknown(%d)", d) +} + +type problemCell struct { + state problemCellState + + // the number of cells traversed to reach this cell. + // only valid if state for stateStart, stateRight, stateDown, stateLeft, or stateUp. + distance uint + + // the optimistic distance estimate to the end point. + // valid for any state except stateUnvisited. + heuristic uint +} + +// A Problem is intended to be used by the Solve function to find a path through a maze. +// +// This is more of a demonstration of how to use the Solve function, a dedicated maze solving +// algorithm would more efficient. In particular, the solver manages a max heap for discarding +// states when at capacity, which a maze solver would not need - a maze has a finite number of cells, +// which for our purposes represent a state, and we'd never exceed (and thus need to keep track +// of and discard) that many states. +type Problem struct { + // the bounds of the maze. + size image.Rectangle + + // a lookup table for the bounds that a cell can be transverse in. + // + // for the movement directions, this will be identical to size, but contracted by one cell. + // for example, legalPositions[stateRight] will have the rightmost column removed, as you can't + // move further right from the rightmost column. + // + // for other possible states, the rectangle will be empty (and realistically, never used). + legalPositions [stateLen]image.Rectangle + + // the functions that test if movement is allowed in the given direction. + // this needs to be combined with dirTestOffset to get the correct cell to test. + testFuncs [stateLen]func(image.Point) bool + + // the start and end points of the maze. + start, end image.Point + + // the cells in the maze, starting at size.Min, going left to right, top to bottom. + cells []problemCell +} + +// convert a coordinate to an index in the cells slice. +// +// it is assumed that c.In(p.size) is true, otherwise this +// will yield a wrong, and possibly out of bounds index. +func (p Problem) coordToIndex(c image.Point) int { + return (c.X - p.size.Min.X) + (c.Y-p.size.Min.Y)*p.size.Dx() +} + +func absDelta(a, b int) uint { + if a > b { + return uint(a - b) + } + return uint(b - a) +} + +// an optimistic distance estimate based on the Manhattan distance to the end point. +func (p Problem) heuristic(c image.Point) uint { + return absDelta(c.X, p.end.X) + absDelta(c.Y, p.end.Y) +} + +// Initializes the maze search problem, and appends the start point to the given slice. +func (p *Problem) Initialize(out []image.Point) ([]image.Point, error) { + p.cells = make([]problemCell, p.size.Dx()*p.size.Dy()) + + p.cells[p.coordToIndex(p.start)] = problemCell{ + state: stateStart, + heuristic: p.heuristic(p.end), + } + + return append(out, p.start), nil +} + +// Appends the next positions to consider from the given position. +func (p Problem) Next(c image.Point, out []image.Point) ([]image.Point, error) { + distance := p.cells[p.coordToIndex(c)].distance + 1 + + for _, d := range [...]problemCellState{stateRight, stateDown, stateLeft, stateUp} { + if !c.In(p.legalPositions[d]) { + // can't move in this direction, stop immediately. + continue + } + + next := c.Add(dirAdvance[d]) + nextIndex := p.coordToIndex(next) + + switch { + case p.cells[nextIndex].state == stateUnvisited: + // calculate the heuristic for the first time and change its state to unexplored. + p.cells[nextIndex].heuristic = p.heuristic(next) + p.cells[nextIndex].state = stateUnexplored + fallthrough + + case p.cells[nextIndex].state == stateUnexplored: + // similar to the unvisited case, but don't calculate the heuristic again. + fallthrough + + // if the cell has been visited, but we found a shorter path to it, then + // update the direction and distance. + case distance < p.cells[nextIndex].distance: + // if we reach this point, then one of the above cases has occurred, and + // this is a cell we're interested in exploring further. + // + // test to make sure we can actually move there. + if p.testFuncs[d](c.Add(dirTestOffset[d])) { + p.cells[nextIndex].state = d + p.cells[nextIndex].distance = distance + out = append(out, next) + } + } + } + + return out, nil +} + +// Returns true if the given position is the end of the maze. +// +// We're also assuming that the point was returned by Initialize or Next, +// otherwise we wouldn't know how to reach the start position, and this +// wouldn't actually be solved. +func (p Problem) Solved(c image.Point) bool { + return c == p.end +} + +// Converts the given position, the one that passed the Solved test above, +// into a list of points that form a path from the start to the end. +func (p Problem) Finish(c image.Point) ([]image.Point, error) { + if c != p.end { + return nil, fmt.Errorf("not at the end: %v", c) + } + + switch p.cells[p.coordToIndex(c)].state { + case stateUnvisited, stateUnexplored: + return nil, fmt.Errorf("no path to the end") + } + + length := p.cells[p.coordToIndex(c)].distance + path := make([]image.Point, length+1) + path[length] = c + + for ; length > 0; length-- { + c = c.Add(dirAdvance[dirReverse[p.cells[p.coordToIndex(c)].state]]) + path[length-1] = c + } + + return path, nil +} + +// This function would be used to release any resources associated with a given search state, +// but since a state for this problem is just a point, and all the actual state information +// is stored in the cells slice, there's nothing to do here. +func (p Problem) Discard(image.Point) { + // nop +} + +// Returns true if state a should be explored before b. +func (p Problem) OptimisticLess(a, b image.Point) bool { + ca := p.cells[p.coordToIndex(a)] + cb := p.cells[p.coordToIndex(b)] + + if r := cmp.Compare(ca.distance+ca.heuristic, cb.distance+cb.heuristic); r != 0 { + return r < 0 + } + + // If the estimated total path length for both states seems to be equal, + // prefer the one with the lower heuristic (closest to the end). + // + // since distance+heuristic for both states is equal, this conversely means that + // we prefer the one with the higher distance traveled so far. + return ca.heuristic < cb.heuristic +} + +// Similar to OptimisticLess, but we scale the heuristic to penalize uncertainty +// about the actual remaining distance. +// +// Note that the solver uses this with a max heap to determine which states to prune when at capacity. +// +// This will typically be similar to OptimisticLess, but with a penalty for uncertainty. +// +// For our purposes, we're just going to use the same heuristic as OptimisticLess. Assuming that you give +// the solver a capacity equal to the number of cells in the maze, it won't need to prune any states. +func (p Problem) PessimisticLess(a, b image.Point) bool { + return p.OptimisticLess(a, b) +} + +// NewMazeProblem creates a new maze problem with the given size, start and end points, +// and well as two functions that test if movement to the right and down is allowed from a given point. +func NewProblem(size image.Rectangle, start, end image.Point, rightTest func(image.Point) bool, downTest func(image.Point) bool) *Problem { + var legalPositions [stateLen]image.Rectangle + + legalPositions[stateRight] = image.Rect(size.Min.X, size.Min.Y, size.Max.X-1, size.Max.Y) + legalPositions[stateDown] = image.Rect(size.Min.X, size.Min.Y, size.Max.X, size.Max.Y-1) + legalPositions[stateLeft] = image.Rect(size.Min.X+1, size.Min.Y, size.Max.X, size.Max.Y) + legalPositions[stateUp] = image.Rect(size.Min.X, size.Min.Y+1, size.Max.X, size.Max.Y) + + var testFuncs [stateLen]func(image.Point) bool + + testFuncs[stateRight] = rightTest + testFuncs[stateDown] = downTest + + // note that the dirTestOffset lookup table has non-zero values for left and up, + // to compensate for the fact that we need to test the cell to the left or above. + testFuncs[stateLeft] = rightTest + testFuncs[stateUp] = downTest + + return &Problem{ + size: size, + legalPositions: legalPositions, + testFuncs: testFuncs, + start: start, + end: end, + } +} diff --git a/solve.go b/solve.go new file mode 100644 index 0000000..f0f1d8b --- /dev/null +++ b/solve.go @@ -0,0 +1,108 @@ +package tsp + +import ( + "errors" + "fmt" + + "smariot.com/tsp/internal/solver" + "smariot.com/tsp/internal/solver/problem" +) + +// Problem represents a search problem. +type Problem[State comparable, Solution any] interface { + // Require a problem to implement the problem.Problem interface. + problem.Problem[State] + + // Appends the initial states to begin the search with. + Initialize(out []State) ([]State, error) + + // Appends the next possible states to the given slice. + // + // Appending an already known state is allowed, if this happens its + // relative order is assumed to have changed. + // + // A state should only be resubmitted if its cost has changed, otherwise + // we'd potentially be stuck in a loop trying to explore the same state over and over. + Next(seed State, out []State) ([]State, error) + + // Returns true if this node represents a complete solution. + Solved(state State) bool + + // Converts the given solved state into a solution that can be returned by the Solve function. + Finish(state State) (Solution, error) +} + +// ErrNoSolution is returned by Solve when all states have been checked without finding a solution. +var ErrNoSolution = errors.New("no solution found") + +// ErrBadCapacity is returned by Solve when the capacity is not positive. +var ErrBadCapacity = errors.New("capacity must be positive") + +// Solves the given problem, returning the best state found. +// +// The capacity is the maximum number of states that can be stored in memory at any given time. +// +// If capacity is greater than 0, then the solver will maintain a max heap and discard states when it reaches capacity. +func Solve[P Problem[State, Solution], State comparable, Solution any](problem P, capacity int) (Solution, error) { + if capacity < 0 { + var zero Solution + return zero, fmt.Errorf("%w, got %d", ErrBadCapacity, capacity) + } + + solver := solver.New(problem, capacity) + + next, err := problem.Initialize(nil) + + // note that we push the states even in the case of an error, + // as it's simplifies the cleanup process. + for _, state := range next { + solver.Push(state) + } + + if err != nil { + solver.Reset() + var zero Solution + return zero, err + } + + for { + state, ok := solver.Pop() + + if !ok { + var zero Solution + return zero, ErrNoSolution + } + + if problem.Solved(state) { + solver.Reset() + solution, err := problem.Finish(state) + problem.Discard(state) + return solution, err + } + + next, err = problem.Next(state, next[:0]) + + // again, we're going to deal with the states we were given first + // before we deal with the error. + resubmitted := false + for _, nextState := range next { + if state == nextState { + resubmitted = true + } + + solver.Push(nextState) + } + + // problem is allowed to resubmit the state. If it did, then it's either somewhere in the heap, + // or it already discarded it due to being at capacity. In either case, we shouldn't discard it again. + if !resubmitted { + problem.Discard(state) + } + + if err != nil { + solver.Reset() + var zero Solution + return zero, err + } + } +}