[20210425]什么?号称世界上最难的数独居然没有坚持到2秒
0 前言
有一段時間,Yogurt 比較沉迷數獨游戲,所以在手機上下了一個叫『數獨Sudoku益智腦訓練軟件』的 App。從初級到困難玩了個遍,困難級和專業級的比較花時間,所以也不怎么玩。但是玩久了之后就有點厭倦了,總會想有什么辦法可以讓數獨自動玩,我就輕松了(歪,人家是讓你訓練腦子的好不好)。
1 什么是數獨(規則)
數獨在百度百科上的介紹是這樣的:
數獨(shù dú)是源自18世紀瑞士的一種數學游戲。是一種運用紙、筆進行演算的邏輯游戲。玩家需要根據9×9盤面上的已知數字,推理出所有剩余空格的數字,并滿足每一行、每一列、每一個粗線宮(3*3)內的數字均含1-9,不重復
數獨盤面是個九宮,每一宮又分為九個小格。在這八十一格中給出一定的已知數字和解題條件,利用邏輯和推理,在其他的空格上填入1-9的數字。使1-9每個數字在每一行、每一列和每一宮中都只出現一次,所以又稱 “九宮格” 。
簡單的說就是每一行、每一列、以及每個小九宮格里的總共 81 個數字都要填滿,但是每一行、每一列、以及每個小九宮格里的數字只能出現一次。規則還是相對比較簡單的。
2 解題思路
從個人玩了一段時間的 “經驗” 來看,就其實就兩種方法,一是根據不重復的條件直觀看出來的;二是隱藏比較深的需要標記候選詞才能排查出來的。
例如:紅色的 8 填在這個位置是因為其他的 8 占用了行或者列中的一個單元格,根據不重復的要求,我們只能排除這些區域,剩下唯一空出來的單元格就 100% 是填 8 了。
候選詞就是根據一個單元格里還能填什么數字為前提,根據現有條件一個個的備注在單元格中,通過比對,判斷,嘗試等多種方式來尋找那些通過只感官無法直接發現的數字。
比如上圖中紅框標注的 4。直接通過眼睛來看是看不出來的,但是通過對備選數字的排查,最終可以排除通過對其他的區域不符合規則的數字進行排除,進而證明紅框標注的位置只能填 4。
3 如何用計算機來玩數獨?
從某種程度上來說,計算機除了不能生孩子(現在也有用計算機來生產計算機了),啥事兒都能干。但,有個前提——首先是你知不知道要怎么做!
根據數獨的填寫規則,Yogurt 打算結合 直觀出結果 和 候選數字出結果 這兩種方法來對數獨進行解答。
這里 Yogurt 用的是 Python 來解題。
標題中的『世界上號稱最難數獨居然沒有堅持到 2 秒』的結論由以下環境下的 Jupyter Notebook 輸出,經過多次測試,實際計算時間在 1.51 - 2.2 秒之間,如直接使用命令行調用 Python 來執行則為 2 - 3秒之間。
| 操作系統 | Windows 10 專業工作站版 |
| CPU | I5-10210U |
| 內存 | 16 GB |
| Python 版本 | 3.8.3 |
| IPython 版本 | 7.16.1 |
| Jupyter Notebook 版本 | 6.0.3 |
| 編輯器 | Visual Studio Code |
3.1 引入庫
import numpy as np # 主要計算庫 from datetime import datetime # 獲取起止時間 from collections import Counter # 計算候選數字的出現頻次3.2 導入題目
# 初始化 def Initialize(self, data: list):# 初始數據dataArray = np.array(data)return dataArray3.3 計算備選數字
從根本上講,計算機根本不知道什么是直觀,什么是不直觀。因此我們需要告訴計算機判斷的依據。
這里 Yogurt 直接對為空的單元格進行候選數字的計算,而最終計算結果有且僅有一個候選數字的就肯定是當前條件下的唯一解。因此這個單元格只能填這一個數字。
根據數獨的填寫規則,分別對 行、列、九宮格 等三種形式進行了計算,逐層排除多余候選詞,最終結果為該單元格中當前條件下的所有候選數字。
又由于隨著數獨的難度增大后,可能無法通過一次計算直接得到所有的唯一候選數字,因此在排除到最后再也無法排除的情況下,我們需要選擇候選結果最少的一個用來試填,嘗試能不能解開題目。具體的后面會講到。
# 備選數字 def BackupNumber(self, dataArray):# 初始備選數據backupItem = ''.join([str(x) for x in range(1, 10)])backupRow = [backupItem for x in range(9)]backupShape = [backupRow for x in range(9)]backupArray = np.array(backupShape)# 去除已填寫數字的備選backupArray = np.where(dataArray == 0, backupArray, '')# 計算備選數字# 行備選for rowIndex in range(9):dataItem = dataArray[rowIndex]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list(dataItem)).replace('0', ''))for item in usedDataList:for colIndex in range(9):backupItem = backupArray[rowIndex, colIndex]backupArray[rowIndex, colIndex] = backupItem.replace(item, '')# 列備選for colIndex in range(9):dataItem = dataArray[:,colIndex]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list(dataItem)).replace('0', ''))for item in usedDataList:for rowIndex in range(9):backupItem = backupArray[rowIndex, colIndex]backupArray[rowIndex, colIndex] = backupItem.replace(item, '')# 九宮格備選for rowIndex in range(0, 9, 3):for colIndex in range(0, 9, 3):dataItem = dataArray[rowIndex : rowIndex + 3, colIndex : colIndex + 3]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list([*dataItem.flat])).replace('0', ''))for item in usedDataList:for bkRowIndex in range(rowIndex, rowIndex + 3):for bkColIndex in range(colIndex, colIndex + 3):backupItem = backupArray[bkRowIndex, bkColIndex]backupArray[bkRowIndex, bkColIndex] = backupItem.replace(item, '')# 選出備選數字中唯一結果# 行for rowIndex in range(9):bkItem = backupArray[rowIndex]bkData = ''.join(list(bkItem))numberFrequency = sorted(dict(Counter(bkData)).items(), key = lambda x:x[1])onlyNumberList = []for item in numberFrequency:number, counts = itemif counts == 1:onlyNumberList.append(number)else:breakfor item in onlyNumberList:for colIndex in range(9):backupItem = backupArray[rowIndex, colIndex]if len(backupItem) > 1:if item in backupItem:backupArray[rowIndex, colIndex] = item# 列for colIndex in range(9):bkItem = backupArray[:,colIndex]bkData = ''.join(list(bkItem))numberFrequency = sorted(dict(Counter(bkData)).items(), key = lambda x:x[1])onlyNumberList = []for item in numberFrequency:number, counts = itemif counts == 1:onlyNumberList.append(number)else:breakfor item in onlyNumberList:for rowIndex in range(9):backupItem = backupArray[rowIndex, colIndex]if len(backupItem) > 1:if item in backupItem:backupArray[rowIndex, colIndex] = item# 九宮格for rowIndex in range(0, 9, 3):for colIndex in range(0, 9, 3):bkItem = backupArray[rowIndex : rowIndex + 3, colIndex : colIndex + 3]bkData = ''.join(list([*bkItem.flat]))numberFrequency = sorted(dict(Counter(bkData)).items(), key = lambda x:x[1])onlyNumberList = []for item in numberFrequency:number, counts = itemif counts == 1:onlyNumberList.append(number)else:breakfor item in onlyNumberList:for bkRowIndex in range(rowIndex, rowIndex + 3):for bkColIndex in range(colIndex, colIndex + 3):backupItem = backupArray[bkRowIndex, bkColIndex]if len(backupItem) > 1:if item in backupItem:backupArray[bkRowIndex, bkColIndex] = item# 挑出第一個備選最少的結果backupLeast = {}for rowIndex in range(9):for colIndex in range(9):item = backupArray[rowIndex, colIndex]if backupLeast == {}:backupLeast = {'rowIndex': rowIndex,'colIndex': colIndex,'value': item}else:if item != '':if backupLeast['value'] == '':backupLeast = {'rowIndex': rowIndex,'colIndex': colIndex,'value': item}if len(backupLeast['value']) > len(item):backupLeast = {'rowIndex': rowIndex,'colIndex': colIndex,'value': item}return backupLeast, backupArray3.4 驗證計算結果
由于候選數字是根據當前未填數字計算出來的,因此如果題目本身有問題,或者在計算過程中出現填錯的情況,例如試填的時候。當計算機發現填錯的時候,需要終止當前計算,執行其他的操作,直到解題結束為止。
# 檢驗結果是否正確, 主要檢驗指定區域內是否存在重復數據 def NumberCheck(self, dataArray):# 行檢驗for rowIndex in range(9):dataItem = dataArray[rowIndex]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list(dataItem)).replace('0', ''))if len(usedDataList) > 0:if Counter(usedDataList).most_common()[0][1] > 1:return False# 列檢驗for colIndex in range(9):dataItem = dataArray[:,colIndex]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list(dataItem)).replace('0', ''))if len(usedDataList) > 0:if Counter(usedDataList).most_common()[0][1] > 1:return False# 九宮格檢驗for rowIndex in range(0, 9, 3):for colIndex in range(0, 9, 3):dataItem = dataArray[rowIndex : rowIndex + 3, colIndex : colIndex + 3]dataItem = dataItem.astype(np.str)usedDataList = list(''.join(list([*dataItem.flat])).replace('0', ''))if len(usedDataList) > 0:if Counter(usedDataList).most_common()[0][1] > 1:return Falsereturn True3.5 根據唯一備選結果填寫數字
# 根據唯一備選結果填寫數字 def WriteNumber(self, dataArray, backupArray):onlyBackupArray = backupArray.copy()onlyBackupArray = np.where(onlyBackupArray == '', 0, onlyBackupArray)onlyBackupArray = onlyBackupArray.astype(np.int)onlyBackupArray = np.where(onlyBackupArray <= 9, onlyBackupArray, 0)dataArray = np.where(dataArray == 0, onlyBackupArray, dataArray)dataCode = dataArray.astype(np.str)dataCode = ''.join([*dataCode.flat])return dataCode, dataArray3.6 試填數字
前面 3.3 有講到,隨著數獨難度的提高,可能會出現沒有唯一候選數字的情況。這時候我們就需要讓計算機對多個候選詞進行嘗試性的填寫,并以此為準,后面的計算均在此基礎上進行,直到 3.4 的檢查失敗為止。
當試填數字出錯的時候,我們需要將試填數字之后所填寫的數字全部去除,回到試填前的樣子。因此在試填之前就要保留當前的數獨記錄。當全部試填數字都用完的時候就要及時將其記錄刪除,表示已經無需再回退了,另一個試填數字再出錯就只能從上一次試填開始計算了
# 填入測試數字 def TestWriteNumber(self, dataArray, backupLeast, parseHistoryDict, parseLocationList):rowIndex = backupLeast['rowIndex']colIndex = backupLeast['colIndex']value = backupLeast['value']numberLocation = '{}{}'.format(str(rowIndex), str(colIndex))if numberLocation not in parseLocationList:parseLocationList.append(numberLocation)parseHistoryDict[numberLocation] = {'rowIndex': rowIndex,'colIndex': colIndex,'value': value,'dataArray': dataArray}if len(parseLocationList) > 0:numberLocation = parseLocationList[-1]testObj = parseHistoryDict[numberLocation]testRowIndex = testObj['rowIndex']testColIndex = testObj['colIndex']testValue = testObj['value']testDataArray = testObj['dataArray']if testObj['value'] == '':parseLocationList = parseLocationList[:-1]parseHistoryDict.pop(numberLocation)else:testNumber = testValue[0]testObj['value'] = testValue.replace(testNumber, '')dataArray = testDataArray.copy()dataArray[testRowIndex, testColIndex] = testNumberif testObj['value'] == '':parseLocationList = parseLocationList[:-1]parseHistoryDict.pop(numberLocation)return dataArray, parseHistoryDict, parseLocationList3.7 還原執行歷史
# 還原執行歷史 def ResetDataArray(self, dataArray, parseHistoryDict, parseLocationList):if len(parseLocationList) > 0:numberLocation = parseLocationList[-1]testObj = parseHistoryDict[numberLocation]testDataArray = testObj['dataArray']dataArray = testDataArray.copy()return dataArray, parseHistoryDict, parseLocationList3.8 執行解題(完整代碼)
上面的 7 步都是分解動作,將他們都連貫起來,就可以對世界上最難的數獨發起進攻了。
執行結果
-------- 解題開始 --------解題時長: 0:00:01.560295 計算周期: 644 測試次數: 90 解題結果: [[8 1 2 7 5 3 6 4 9][9 4 3 6 8 2 1 7 5][6 7 5 4 9 1 2 8 3][1 5 4 2 3 7 8 9 6][3 6 9 8 4 5 7 2 1][2 8 7 1 6 9 5 3 4][5 2 1 9 7 4 3 6 8][4 3 8 5 2 6 9 1 7][7 9 6 3 1 8 4 5 2]]-------- 解題結束 --------4 解題流程
這里呢 Yogurt 對執行過程做了循環周期的限制,限制了 1000 次,其目的是防止有些題目根本就是無解的,無限制的解題絲毫沒有任何意義。同時,號稱世界上最難的數獨也不過是執行了 644 個周期而已,其他的數獨又何德何能超過 1000 次周期呢?
(如果有,當我沒說)
5 后記
說起來,做這個數獨解題算法只不過是無聊時的一個樂子而已。但是在做這個算法的過程中,還是收獲了不少。不在算法,而是一些思維方式上的東西。
因為 Yogurt 最近也在負責一個關于銷售預測的項目,雖然不是很深入,但隱隱的感覺后期如果要將預測結果真正用到實處的話,這次做數獨的經驗也許能夠有所幫助。一直以來,預測都是基于現有條件來得出未來某個時期內的結果或趨勢將會是什么,但這樣的預測只不過是被動地在接受未來可能會發生的一切而已,并不能帶來什么實質性的改變。但在做這個數獨算法時,采用了試填的概念,結果不合適就返回最初,再用下一個數字來嘗試。相當于從結果出發,反推過程。
那么如果在實際的業務預測過程中,當預測結果與目標不一致時,我們為了達成這個目標還需要多少努力?此時我們其實是知道最終想要的結果的,就像我們知道數獨的結果就是 81 個單元格全部填滿,且行、列、九宮格內的數字不得重復一樣。通過一定的規則,結合最終目標反推后面每一步應該達成哪些小目標,進而達成總目標。通過計算,可以得到比較確切的數字或指標,反向來指導業務,并推進達成。
目前為止可能還比較難,后面慢慢嘗試看看能不能做到。
以上就是本期的全部內容,下期再見。
總結
以上是生活随笔為你收集整理的[20210425]什么?号称世界上最难的数独居然没有坚持到2秒的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: TCP协议与IP协议
- 下一篇: 数学分析(9): 不定积分