From ce4923f34f8909a7744f6daabceaa4c1a03594c3 Mon Sep 17 00:00:00 2001 From: Esther van Enckevort Date: Wed, 3 Jun 2020 19:23:22 +0200 Subject: [PATCH] Initial implementation. --- .gitignore | 6 + Package.swift | 28 ++++ README.md | 3 + Sources/SudokuKit/Board.swift | 203 +++++++++++++++++++++++++ Sources/SudokuKit/Solver.swift | 48 ++++++ Tests/LinuxMain.swift | 7 + Tests/SudokuKitTests/BoardTests.swift | 98 ++++++++++++ Tests/SudokuKitTests/SolverTests.swift | 56 +++++++ 8 files changed, 449 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/SudokuKit/Board.swift create mode 100644 Sources/SudokuKit/Solver.swift create mode 100644 Tests/LinuxMain.swift create mode 100644 Tests/SudokuKitTests/BoardTests.swift create mode 100644 Tests/SudokuKitTests/SolverTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3c8e33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +/.build +/Packages +/*.xcodeproj +xcuserdata/ +.swiftpm/ diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2e1241c --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version:5.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "SudokuKit", + products: [ + // Products define the executables and libraries produced by a package, and make them visible to other packages. + .library( + name: "SudokuKit", + targets: ["SudokuKit"]), + ], + dependencies: [ + // Dependencies declare other packages that this package depends on. + // .package(url: /* package url */, from: "1.0.0"), + ], + targets: [ + // Targets are the basic building blocks of a package. A target can define a module or a test suite. + // Targets can depend on other targets in this package, and on products in packages which this package depends on. + .target( + name: "SudokuKit", + dependencies: []), + .testTarget( + name: "SudokuKitTests", + dependencies: ["SudokuKit"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..64febd6 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# SudokuKit + +A description of this package. diff --git a/Sources/SudokuKit/Board.swift b/Sources/SudokuKit/Board.swift new file mode 100644 index 0000000..c54f918 --- /dev/null +++ b/Sources/SudokuKit/Board.swift @@ -0,0 +1,203 @@ +import Foundation + +public struct Board { + private var board: [Int] + private let boardWidth = 9 + private let boardRange = 0..<81 + private let squareWidth = 3 + + public func getSquare(for index: Int) -> [Int] { + precondition((0..<81).contains(index), "Index must be in range 0..<81") + let squareInRow = index % boardWidth / 3 + let topLeftIndex = squareInRow * 3 + (index / 27) * 27 + let offsets = [0, 1, 2, 9, 10, 11, 18, 19, 20] + var elements = Array.init(repeating: 0, count: 9) + for (index, offset) in offsets.enumerated() { + elements[index] = board[topLeftIndex + offset] + } + return elements + } + + public func getRow(for index: Int) -> [Int] { + precondition((0..<81).contains(index), "Index must be in range 0..<81") + var elements = Array.init(repeating: 0, count: 9) + let row = index / boardWidth + let startIndex = row * boardWidth + for index in 0..<9 { + elements[index] = board[startIndex + index] + } + return elements + } + + public func getColumn(for index: Int) -> [Int] { + precondition((0..<81).contains(index), "Index must be in range 0..<81") + let startIndex = index % boardWidth + var elements = Array.init(repeating: 0, count: boardWidth) + for offset in 0..<9 { + elements[offset] = board[startIndex + offset * boardWidth] + } + return elements + } + + public subscript(index: Int) -> Int { + get { + board[index] + } + set(newValue) { + precondition((0..<81).contains(index), "Index must be in range 0..<81") + board[index] = newValue + } + } + + public init(board: [Int]) { + precondition(board.count == 81, "Board must have exactly 81 elements.") + self.board = board + } + + public func isValidSolution() -> Bool { + let numbers = Set([1, 2, 3, 4, 5, 6, 7, 8, 9]) + for index in stride(from: 0, through: 80, by: 9) { + let row = Set(getRow(for: index)) + if numbers != row { + return false + } + } + for index in 0..<9 { + let column = Set(getColumn(for: index)) + if numbers != column { + return false + } + } + for index in [0, 3, 6, 27, 30, 33, 54, 57, 60] { + let square = Set(getSquare(for: index)) + if numbers != square { + return false + } + } + return true + } + + public init?() { + guard let board = Board.resolve(board: Board.fill(), from: 0) else { + return nil + } + self.board = board + } + + static private func resolve(board: [Int], from position: Int) -> [Int]? { + print("resolving \(position)") + guard position < board.count else { + return board + } + var result: [Int]? + let seen = collectSeenNumbers(for: position, on: board) + if seen.contains(board[position]) { + print("\(position) swap") + result = swapNumber(at: position, on: board, excluding: seen) + print("\(position) swap \(result != nil ? "successful" : "failed")") + } else { + print("\(position) step") + result = resolve(board: board, from: position + 1) + if result != nil { + print("\(position + 1) resolved") + return result + } else { + print("\(position) force swap") + result = swapNumber(at: position, on: board, excluding: seen) + print("\(position) force swap \(result != nil ? "successful" : "failed")") + } + } + print("\(position) done") + if let result = result { + print(Board(board: result)) + } else { + print("nil") + } + return result + } + + static private func collectSeenNumbers(for position: Int, on board: [Int]) -> Set { + var seen = Set() + // Row + let rowStart = (position / 9) * 9 + for index in rowStart..) -> [Int]? { + var searchPos = position + var result: [Int]? + repeat { + if searchPos % 3 == 2 { + searchPos += 7 + } else { + searchPos += 1 + } + guard searchPos < board.count && Board.inSameSquare(position, searchPos) else { + print("no candidates for swap of \(position)") + print("\(Board(board: board))") + print("Seen: \(seen)") + return nil + } + if !seen.contains(board[searchPos]) { + var newBoard = board + newBoard.swapAt(position, searchPos) + result = resolve(board: newBoard, from: position + 1) + if let result = result { + return result + } + } + } while result == nil + print("no candidates for swap of \(position)") + print("\(Board(board: board))") + return nil + } + + static private func inSameSquare(_ first: Int, _ second: Int) -> Bool { + let column1 = first % 9 / 3 + let row1 = first / 27 + let column2 = second % 9 / 3 + let row2 = second / 27 + return column1 == column2 && row1 == row2 + } + + private static func fill() -> [Int] { + var numbers = Array(stride(from: 1, through: 9, by: 1)) + var board = Array(repeating: 0, count: 81) + for index in 0..<81 { + if index % 9 == 0 { + numbers.shuffle() + } + let indexInBox = ((index / 3) % 3) * 9 + ((index % 27) / 9) * 3 + (index / 27) * 27 + (index % 3); + board[indexInBox] = numbers[index % 9] + } + return board + } +} + +extension Board: CustomStringConvertible { + public var description: String { + var workingCopy = "" + for index in 0..<81 { + workingCopy += " \(board[index] )" + if index % 9 == 2 || index % 9 == 5 { + workingCopy += "|" + } + if index % 9 == 8 { + workingCopy += "\n" + } + if index % 27 == 26 && index != 80 { + workingCopy += "------+------+------\n" + } + } + return workingCopy + } +} diff --git a/Sources/SudokuKit/Solver.swift b/Sources/SudokuKit/Solver.swift new file mode 100644 index 0000000..8c14411 --- /dev/null +++ b/Sources/SudokuKit/Solver.swift @@ -0,0 +1,48 @@ +public struct Solver { + public init() { + } + + public func solve(puzzle: Board) -> [Board] { + return solve(startingFrom: 0, puzzle: puzzle) + } + + private func solve(startingFrom index: Int, puzzle: Board) -> [Board] { + precondition((0..<81).contains(index), "startingFrom index must be in range 0..<81") + var boards = [Board]() + var startIndex = index + while puzzle[startIndex] != 0 { + startIndex += 1 + guard startIndex < 81 else { + guard puzzle.isValidSolution() else { + return [] + } + return [puzzle] + } + } + + for number in 1...9 { + let results = place(number: number, at: startIndex, on: puzzle) + boards.append(contentsOf: results) + } + return boards + } + + private func place(number: Int, at index: Int, on board: Board) -> [Board] { + guard !board.getSquare(for: index).contains(number) else { + return [] + } + guard !board.getColumn(for: index).contains(number) else { + return [] + } + guard !board.getRow(for: index).contains(number) else { + return [] + } + var newBoard = board + newBoard[index] = number + + if index == 80 { + return [newBoard] + } + return solve(startingFrom: index + 1, puzzle: newBoard) + } +} diff --git a/Tests/LinuxMain.swift b/Tests/LinuxMain.swift new file mode 100644 index 0000000..ba237b0 --- /dev/null +++ b/Tests/LinuxMain.swift @@ -0,0 +1,7 @@ +import XCTest + +import SudokuKitTests + +var tests = [XCTestCaseEntry]() +tests += SudokuKitTests.allTests() +XCTMain(tests) diff --git a/Tests/SudokuKitTests/BoardTests.swift b/Tests/SudokuKitTests/BoardTests.swift new file mode 100644 index 0000000..26c6407 --- /dev/null +++ b/Tests/SudokuKitTests/BoardTests.swift @@ -0,0 +1,98 @@ +import Foundation +import XCTest +import SudokuKit + +final class BoardTests: XCTestCase { + + var board: Board! + override func setUp() { + board = Board(board: [ + 0, 0, 8, 2, 0, 0, 9, 0, 3, + 3, 4, 2, 0, 9, 5, 0, 0, 7, + 1, 9, 7, 0, 0, 0, 0, 0, 4, + 0, 0, 5, 3, 1, 2, 4, 7, 9, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 2, 0, 0, 0, 7, 4, 5, 0, 0, + 0, 2, 0, 0, 0, 1, 0, 0, 5, + 0, 7, 0, 0, 0, 6, 8, 9, 1, + 8, 0, 0, 4, 3, 0, 7, 0, 6 + ]) + } + func testGetSquare() { + let square1 = [0, 0, 8, 3, 4, 2, 1, 9, 7] + let square9 = [0, 0, 5, 8, 9, 1, 7, 0, 6] + let square1Indices = [0, 1, 2, 9, 10, 11, 18, 19, 20] + let square9Indices = [60, 61, 62, 69, 70, 71, 78, 79, 80] + for index in square1Indices { + let result = board.getSquare(for: index) + XCTAssert(result == square1, "Result for index \(index) must be equal to square1.") + } + for index in square9Indices { + let result = board.getSquare(for: index) + XCTAssert(result == square9, "Result for index \(index) must be equal to square9.") + } + } + + func testGetRow() { + let row1 = [0, 0, 8, 2, 0, 0, 9, 0, 3] + let row9 = [8, 0, 0, 4, 3, 0, 7, 0, 6] + for index in 0..<9 { + let result = board.getRow(for: index) + XCTAssert(result == row1, "Result for index \(index) must be equal to row1.") + } + for index in 72..<81 { + let result = board.getRow(for: index) + XCTAssert(result == row9, "Result for index \(index) must be equal to row9.") + } + } + + func testGetColumn() { + let column1 = [0, 3, 1, 0, 0, 2, 0, 0, 8] + let column9 = [3, 7, 4, 9, 0, 0, 5, 1, 6] + for index in stride(from: 0, through: 72, by: 9) { + let result = board.getColumn(for: index) + XCTAssert(result == column1, "Result for index \(index) must be equalt to column1.") + } + for index in stride(from: 8, through: 80, by: 9) { + let result = board.getColumn(for: index) + XCTAssert(result == column9, "Result for index \(index) must be equal to column9.") + } + } + + func testSubscript() { + let results = [ + 0, 0, 8, 2, 0, 0, 9, 0, 3, + 3, 4, 2, 0, 9, 5, 0, 0, 7, + 1, 9, 7, 0, 0, 0, 0, 0, 4, + 0, 0, 5, 3, 1, 2, 4, 7, 9, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 2, 0, 0, 0, 7, 4, 5, 0, 0, + 0, 2, 0, 0, 0, 1, 0, 0, 5, + 0, 7, 0, 0, 0, 6, 8, 9, 1, + 8, 0, 0, 4, 3, 0, 7, 0, 6 + ] + for index in 0..<81 { + let value = board[index] + XCTAssert(value == results[index], "Result for index \(index) must be equal to the value in the array for the same index.") + } + for index in 0..<81 { + board[index] = 255 + XCTAssert(board[index] == 255, "Board value must be successfully set to a different value.") + } + } + + func testGenerateBoard() { + for iteration in 0..<10_000 { + print("========= \(iteration) =========") + guard let board = Board() else { + XCTFail("Should have generated a solution.") + return + } + if !board.isValidSolution() { + print(board) + XCTFail("Generated board should be a valid solution.") + return + } + } + } +} diff --git a/Tests/SudokuKitTests/SolverTests.swift b/Tests/SudokuKitTests/SolverTests.swift new file mode 100644 index 0000000..4e054ef --- /dev/null +++ b/Tests/SudokuKitTests/SolverTests.swift @@ -0,0 +1,56 @@ +import Foundation +import XCTest +import SudokuKit + +final class SolverTests: XCTestCase { + private let solution = [ + 5, 6, 8, 2, 4, 7, 9, 1, 3, + 3, 4, 2, 1, 9, 5, 6, 8, 7, + 1, 9, 7, 8, 6, 3, 2, 5, 4, + 6, 8, 5, 3, 1, 2, 4, 7, 9, + 7, 3, 4, 9, 5, 8, 1, 6, 2, + 2, 1, 9, 6, 7, 4, 5, 3, 8, + 9, 2, 6, 7, 8, 1, 3, 4, 5, + 4, 7, 3, 5, 2, 6, 8, 9, 1, + 8, 5, 1, 4, 3, 9, 7, 2, 6 + ] + private let board = Board(board: [ + 0, 0, 8, 2, 0, 0, 9, 0, 3, + 3, 4, 2, 0, 9, 5, 0, 0, 7, + 1, 9, 7, 0, 0, 0, 0, 0, 4, + 0, 0, 5, 3, 1, 2, 4, 7, 9, + 0, 0, 0, 0, 0, 0, 0, 0, 0, + 2, 0, 0, 0, 7, 4, 5, 0, 0, + 0, 2, 0, 0, 0, 1, 0, 0, 5, + 0, 7, 0, 0, 0, 6, 8, 9, 1, + 8, 0, 0, 4, 3, 0, 7, 0, 6 + ]) + + + func testSolve() { + let solver = Solver() + let results = solver.solve(puzzle: board) + XCTAssert(results.count == 1, "Solver should return exactly one result, but received \(results.count) results.") + if results.count == 1 { + for index in 0..<81 { + XCTAssert(results[0][index] == solution[index], "Solver solution should match given solution.") + } + } + } + + func testSolveSolvedPuzzle() { + let solver = Solver() + let solved = Board(board: solution) + let results = solver.solve(puzzle: solved) + XCTAssert(results.count == 1, "Solver should return exactly one result, but received \(results.count) results.") + } + + func testSolveInvalidPuzzle() { + var invalid = solution + invalid[80] = 7 + let invalidBoard = Board(board: invalid) + let solver = Solver() + let results = solver.solve(puzzle: invalidBoard) + XCTAssert(results.count == 0, "Solver should return no solutions, actually returned \(results.count) solutions.") + } +}