
簡介基于Python與PyQt5實現(xiàn)的多智能體博弈AI五子棋項目是一份面向計算機相關(guān)專業(yè)學(xué)生畢業(yè)設(shè)計、課程設(shè)計或期末大作業(yè)的完整源碼資料。項目覆蓋人機博弈、深度搜索與α-β剪枝等核心AI算法代碼附有注釋適合新手閱讀與二次開發(fā)也方便快速部署體驗。壓縮包共17個文件包含4個Python源碼文件、1個PyQt界面文件(.ui)、1個資源文件(.qrc)以及9張PNG圖片、1個ICO圖標(biāo)和1份Markdown文檔整體大小約5.39MB。Python源碼承載游戲邏輯與AI決策UI和資源文件負(fù)責(zé)界面呈現(xiàn)文檔說明有助于理解項目結(jié)構(gòu)項目結(jié)構(gòu)清晰適合展示博弈樹搜索過程。目前已有338人學(xué)習(xí)/瀏覽。通過這份資料讀者可以獲得完整的項目源代碼、界面與圖標(biāo)素材、啟動入口和README說明既能直接運行體驗人機對戰(zhàn)也能依據(jù)注釋和文檔梳理性剪枝、深搜等AI實現(xiàn)思路為畢業(yè)答辯或課程匯報提供扎實參考。1. 多智能體博弈五子棋一個能落在簡歷上的畢設(shè)題目如果你在找畢業(yè)設(shè)計題目大概率會盯著“基于PythonPyQt5實現(xiàn)多智能體博弈AI五子棋游戲人機博弈深搜α-β剪枝”這種長標(biāo)題琢磨它是把界面、AI、搜索算法串成一個能玩的程序還是給外行看的包裝詞我的判斷是這個題目很適合做課設(shè)和畢設(shè)因為每個詞都能拆成獨立章節(jié)最后又能合起來交付一個交互項目。項目核心價值不在“會下五子棋”而在一條完整的落地鏈路用Python和PyQt5搭建圖形界面用深搜實現(xiàn)AI決策再用α-β剪枝把性能拉到可玩范圍最后把算法分析寫進文檔。它同時覆蓋人機博弈、雙AI對弈和算法對比適合有Python基礎(chǔ)但沒寫過GUI、希望在搜索算法上補課的人。2. 用PyQt5把棋盤畫出來最小可交互界面2.1 為什么用PyQt5而不是控制臺或網(wǎng)頁做五子棋的第一版我見過不少同學(xué)用控制臺打印棋盤邏輯照樣能跑但答辯時總覺得少點東西。PyQt5的優(yōu)勢在于它和Python環(huán)境配合非常直接先確認(rèn)python安裝和pip正常然后執(zhí)行pip install pyqt5就能開始寫不需要像前端那樣再裝一套Node或調(diào)跨域問題。如果沒裝成功多數(shù)是源太慢換國內(nèi)pip鏡像即可裝完之后要重啟一下IDE否則vscode的python環(huán)境可能識別不到新的第三方包。另一個我看中的點是PyQt5的信號槽機制。棋盤點擊、AI落子、線程通信都可以用信號和槽串起來比tkinter的command回調(diào)更清晰。QPainter繪制15路棋盤和棋子很快整個窗口重繪一次毫秒級別不會成為后續(xù)深搜的性能瓶頸。所以我的項目結(jié)構(gòu)從一開始就分成三層Board類只管棋盤二維數(shù)組和勝負(fù)判斷AI搜索類只讀棋盤并返回落子坐標(biāo)MainWindow負(fù)責(zé)鼠標(biāo)事件和重繪。這樣“人機博弈”和“多智能體博弈”共用一個Board接口后面雙AI對弈時不需要為窗口操作費心。代碼上最大程度避免在UI里直接操作棋盤數(shù)據(jù)。2.2 棋盤繪制與落子坐標(biāo)換算下面是一段可以直接運行的PyQt5最小界面。它不包含AI只是用來驗證環(huán)境和確認(rèn)坐標(biāo)映射代碼里有注釋。import sys from PyQt5.QtWidgets import QApplication, QMainWindow from PyQt5.QtGui import QPainter, QColor from PyQt5.QtCore import Qt class GobangWindow(QMainWindow): def __init__(self, n15): super().__init__() self.n n self.cell 40 self.margin 30 self.board [[0] * n for _ in range(n)] # 0空地 1黑子 2白子 self.size self.margin * 2 self.cell * (self.n - 1) self.setWindowTitle(PyQt5 五子棋) self.setFixedSize(self.size, self.size) def cx(self, c): return self.margin c * self.cell def cy(self, r): return self.margin r * self.cell def paintEvent(self, event): painter QPainter(self) painter.fillRect(self.rect(), QColor(235, 176, 89)) painter.setPen(QColor(0, 0, 0)) for i in range(self.n): y self.cy(i) painter.drawLine(self.margin, y, self.size - self.margin, y) painter.drawLine(y, self.margin, y, self.size - self.margin) for r in range(self.n): for c in range(self.n): if self.board[r][c] 1: painter.setBrush(QColor(0, 0, 0)) painter.drawEllipse(self.cx(c) - 13, self.cy(r) - 13, 26, 26) elif self.board[r][c] 2: painter.setBrush(QColor(255, 255, 255)) painter.setPen(QColor(0, 0, 0)) painter.drawEllipse(self.cx(c) - 13, self.cy(r) - 13, 26, 26) def mousePressEvent(self, event): if event.button() ! Qt.LeftButton: return x, y event.x(), event.y() # 像素坐標(biāo)換算成棋盤坐標(biāo)round讓點擊點自動吸附到最近的交叉點 c round((x - self.margin) / self.cell) r round((y - self.margin) / self.cell) if 0 r self.n and 0 c self.n: self.board[r][c] 1 self.update() if __name__ __main__: app QApplication(sys.argv) win GobangWindow() win.show() sys.exit(app.exec_())我對代碼里的幾個參數(shù)說明一下。n15是棋盤路數(shù)對應(yīng)標(biāo)準(zhǔn)五子棋cell40是每格邊長窗口尺寸是3024014620像素在筆記本上剛好完整顯示。margin30控制棋盤到窗口邊緣的距離如果想做截圖再調(diào)到40。注意setFixedSize而不是setGeometry它能讓窗口不可拉伸避免棋盤被拖動變形。點擊事件的坐標(biāo)換算是最容易錯的地方。鼠標(biāo)x、y是相對于窗口左上角的像素值而棋盤落子要用行列號。公式是“先減邊距再除以格子寬度”也就是c(x-margin)/cell取整要用round而不是int。原因很簡單如果用戶點在第7列中心偏右一點int會把0.8變成0落子唰地跳到上一格round則吸附到最近的交叉點。邊緣的落子手感全靠這一步。這段代碼里的board二維數(shù)組就是后面深搜要操作的數(shù)據(jù)。界面歸界面數(shù)據(jù)歸數(shù)據(jù)后面寫AI時你不需要在這個類里動任何繪圖代碼。2.3 從“人”這一側(cè)接出博弈流程光能落子還不夠鼠標(biāo)點擊后要能轉(zhuǎn)入AI決策。下面只列出新增字段和方法替換2.2里的同名方法即可class GobangWindow(QMainWindow): def __init__(self, n15): # 棋盤、尺寸、paintEvent等初始化見2.2節(jié) self.current_turn 1 self.locked False def mousePressEvent(self, event): if self.locked: return if event.button() ! Qt.LeftButton: return x, y event.x(), event.y() c round((x - self.margin) / self.cell) r round((y - self.margin) / self.cell) if 0 r self.n and 0 c self.n and self.board[r][c] 0: self.board[r][c] 1 # 玩家先手 self.update() self.locked True self.ai_move() def ai_move(self): # 先放一個占位用QTimer延遲300毫秒避免事件循環(huán)卡頓 from PyQt5.QtCore import QTimer QTimer.singleShot(300, self.place_ai_stone) def place_ai_stone(self): # 占位邏輯找第一個空位放白子后續(xù)替換成AI搜索結(jié)果 for r in range(self.n): for c in range(self.n): if self.board[r][c] 0: self.board[r][c] 2 self.locked False self.update() return這個流程里的locked標(biāo)志很關(guān)鍵。如果沒有它玩家會在AI思考時連續(xù)點擊給同一個棋盤同時寫入黑白子坐標(biāo)全部錯亂。我這里用全局的lockedtrue鎖住界面等AI通過place_ai_stone落子后再解鎖。QTimer.singleShot(300, ...)是演示時最常見的做法它的作用是讓界面先刷新一次再執(zhí)行AI邏輯。真正做答辯版本建議換成第4.1里的QThread方案因為深搜耗時時讓主線程同步等待會直接把事件循環(huán)堵死。這段代碼跑通后你已經(jīng)完成了人機博弈的“人”側(cè)。接下來把place_ai_stone里的循環(huán)換成真正的深搜搜索結(jié)果就是完整的AI落子鏈路。3. 深搜與α-β剪枝從枚舉全盤到秒出結(jié)果3.1 多智能體博弈先建立一個零和搜索模型多智能體博弈聽上去復(fù)雜五子棋其實是最適合拆開的例子。這里的“智能體”不是指深度學(xué)習(xí)模型而是指能獨立讀棋盤、做決策的單元玩家是一個智能體AI是另一個。雙AI對戰(zhàn)時黑棋和白棋各是一個智能體。它們共享同一個棋盤輪流改變環(huán)境狀態(tài)目標(biāo)完全相反所以這是一個典型零和博弈。在搜索的角度看當(dāng)前局面是根節(jié)點AI落子的每個空位都產(chǎn)生一個分支對手的每個回應(yīng)又繼續(xù)產(chǎn)生分支最終長成一棵博弈樹。深搜要做的是從根一直往深處探索到搜索深度上限或分出勝負(fù)然后一層一層回傳評價值。因為雙方目標(biāo)相反一層取最大下一層取最小這就是極大極小搜索也是α-β剪枝算法的基礎(chǔ)。需要提醒一點這里的“深搜”和“圖的深度優(yōu)先遍歷”不一樣。圖遍歷只要訪問過節(jié)點就可以不再訪問博弈搜索里每個節(jié)點狀態(tài)不同不能簡單去重所以遞歸返回后必須恢復(fù)現(xiàn)場否則兄弟分支會看到被修改過的棋盤。3.2 深搜的遞歸核心不剪枝版本先算對我一般先寫一個不剪枝的minimax確認(rèn)評估函數(shù)和恢復(fù)邏輯沒錯再加入剪枝。評估函數(shù)可以先用最簡單的連續(xù)子計數(shù)讓AI先“有反應(yīng)”def evaluate_board(board, player): direction [(1, 0), (0, 1), (1, 1), (1, -1)] score 0 for r in range(15): for c in range(15): if board[r][c] ! player: continue for dr, dc in direction: cnt 1 for step in range(1, 5): nr, nc r dr * step, c dc * step if 0 nr 15 and 0 nc 15 and board[nr][nc] player: cnt 1 else: break if cnt 2: score cnt * cnt return score請不要迷信這個評分。它的作用是“把棋盤狀態(tài)映射成數(shù)值”如果棋形沒識別搜索再深也很難走出活三。但它足以跑通深搜等整體流程穩(wěn)定后再升級。然后是minimax核心def minimax(board, depth, is_maximizing, ai_color, human_color): winner check_winner(board) if winner ai_color: return 100000 depth if winner human_color: return -100000 - depth if depth 0: return evaluate_board(board, ai_color) - evaluate_board(board, human_color) candidates get_candidates(board) if not candidates: return 0 if is_maximizing: best -float(inf) for r, c in candidates: board[r][c] ai_color best max(best, minimax(board, depth - 1, False, ai_color, human_color)) board[r][c] 0 return best else: best float(inf) for r, c in candidates: board[r][c] human_color best min(best, minimax(board, depth - 1, True, ai_color, human_color)) board[r][c] 0 return best注意幾個邊界。勝負(fù)判斷要優(yōu)先于depth0否則“已經(jīng)贏了但還沒到深度”會繼續(xù)搜索返回100000depth而不用固定100000是為了讓更早獲勝的分支得分更高AI不會在勝局里故意拖延。遞歸嘗試落子后必須撤銷這是深搜最容易翻車的位置如果漏掉board[r][c]0后面的候選點會基于臟棋盤計算輕則算出錯誤著法重則遞歸棧溢出。參數(shù)方面搜索深度我一般從2開始調(diào)。深度2是最基礎(chǔ)的反應(yīng)深度4配合后面要說的候選點限制已經(jīng)能走出像樣的防守深度6則要依賴α-β剪枝和合理評分。深度不是越大越好因為每多一層時間會變得更長AI落子超過5分鐘會讓演示翻車。3.3 加α-β剪枝兩個閾值讓深搜不再全遍歷不剪枝的minimax在空棋盤上幾乎不可用。以深度4、候選點10個為例最壞要搜10^4個局面雖然這只是理論上限已經(jīng)能把Python跑出明顯卡頓。α-β剪枝算法的思路是在深搜過程中維護兩個閾值alpha是MAX層目前已經(jīng)能保證的下界beta是MIN層目前能接受的上界。當(dāng)某個分支讓betaalpha成立說明后面的搜索不會影響根節(jié)點決策可以整棵跳過。在minimax上改成帶剪枝版本def minimax_ab(board, depth, alpha, beta, is_maximizing, ai_color, human_color): winner check_winner(board) if winner ai_color: return 100000 depth if winner human_color: return -100000 - depth if depth 0: return evaluate_board(board, ai_color) - evaluate_board(board, human_color) candidates get_candidates(board) if not candidates: return 0 if is_maximizing: best -float(inf) for r, c in candidates: board[r][c] ai_color val minimax_ab(board, depth - 1, alpha, beta, False, ai_color, human_color) board[r][c] 0 best max(best, val) alpha max(alpha, best) if beta alpha: break return best else: best float(inf) for r, c in candidates: board[r][c] human_color val minimax_ab(board, depth - 1, alpha, beta, True, ai_color, human_color) board[r][c] 0 best min(best, val) beta min(beta, best) if beta alpha: break return best代碼只比原版多了兩個參數(shù)和兩個if break但性能差距可能非常大。這里最關(guān)鍵的是“誰更新誰”alpha只能MAX節(jié)點更新beta只能MIN節(jié)點更新。如果我在MAX里誤寫了beta剪枝條件會因為邊界值錯亂而提前剪掉正確分支AI會突然下出莫名其妙的棋。接下來是剪枝的性價比候選點順序。同一個局面先搜一個“較好的”位置還是先搜一個“明顯差”的位置alpha/beta收斂速度完全不同。我習(xí)慣在生成候選點時就排序順便把候選列表限制在一個小集合里def get_candidates(board, max_candidates10): points set() for r in range(15): for c in range(15): if board[r][c] 0: continue for dr in range(-2, 3): for dc in range(-2, 3): nr, nc r dr, c dc if 0 nr 15 and 0 nc 15 and board[nr][nc] 0: points.add((nr, nc)) if not points: return [] scored [] for r, c in points: # 簡單啟發(fā)式優(yōu)先落在棋子密集的附近 score 0 for dr in range(-1, 2): for dc in range(-1, 2): nr, nc r dr, c dc if 0 nr 15 and 0 nc 15: if board[nr][nc] 1: score 50 elif board[nr][nc] 2: score 30 scored.append((score, r, c)) scored.sort(reverseTrue) return [(r, c) for _, r, c in scored[:max_candidates]]這函數(shù)做的事有兩件第一只考慮現(xiàn)有棋子兩格范圍內(nèi)的空位把225個空位收斂到幾十個第二按簡單啟發(fā)式倒序排列保證α-β先搜索有價值的分支。max_candidates是候選點數(shù)量上限我常用10深度大時可以縮到8。如果你發(fā)現(xiàn)AI總在邊角落子多半是把max_candidates放太寬或啟發(fā)式分值沒區(qū)分黑白只看自己棋子的權(quán)重卻忘了考慮對手。這樣搜索層用minimax_ab候選點用get_candidates評估函數(shù)用evaluate_board三者串起來就是深搜α-β剪枝的完整閉環(huán)。第4章我會梳理這幾部分各自最常踩的坑。4. 避坑與常見問題從“能跑”變成“能答辯”4.1 界面卡死AI搜索不能放在主線程現(xiàn)象點擊后窗口白塊鼠標(biāo)拖動無反應(yīng)過幾秒才恢復(fù)。原因在mousePressEvent里直接調(diào)用minimax_ab遞歸計算把PyQt5事件循環(huán)占滿了。解決用QThread。代碼class AIWorker(QThread): move_done pyqtSignal(int, int) def __init__(self, board_snapshot, depth, parentNone): super().__init__(parent) self.board_snapshot board_snapshot self.depth depth def run(self): r, c ai_search(self.board_snapshot, self.depth) self.move_done.emit(r, c)窗口側(cè)連接信號worker AIWorker([row[:] for row in self.board], depth) worker.move_done.connect(self.on_ai_move) worker.start()注意復(fù)制board。為什么UI線程還會繼續(xù)paintEvent它讀到的棋盤對象如果被AI線程修改繪制內(nèi)容會跳變。我會把“搜索用的快照”做成只讀AI線程只輸出一個坐標(biāo)再由主線程更新正式棋盤。如果你偷懶直接傳self.board偶爾會出現(xiàn)棋子和棋盤點錯位而且很難復(fù)現(xiàn)。4.2 加了剪枝卻沒提速節(jié)點順序比剪枝本身更重要現(xiàn)象把minimax換成minimax_ab后時間幾乎沒降節(jié)點數(shù)也沒變。原因候選點沒排序alpha/beta很難觸發(fā)或者剪枝條件寫錯例如在MAX層卻比較beta。解決先在get_candidates里按啟發(fā)式排序再在遞歸入口放一個計數(shù)器驗證。剪枝的正確性有個很實用的驗證方法同一局面跑“不剪枝”和“剪枝”兩版兩者最佳落子坐標(biāo)必須完全一致。如果出現(xiàn)不一致說明剪枝代碼邊界寫錯了而不是“剪枝改變了策略”。計數(shù)器代碼search_cnt 0 def minimax_ab(...): global search_cnt search_cnt 1 # 其余邏輯保持不變在遞歸函數(shù)入口自增跑一個中盤局面分別統(tǒng)計剪枝前后次數(shù)。如果次數(shù)沒掉一個數(shù)量級回去改搜索順序如果時間降了幾十倍而節(jié)點數(shù)沒降多少可能是評估函數(shù)耗時小、minimax_ab遞歸外開銷占主導(dǎo)這時優(yōu)先減少候選點數(shù)。4.3 候選點全遍歷225的空位會把深搜拖死現(xiàn)象棋盤只下了兩三子AI就已經(jīng)想得滿頭汗深度調(diào)到4直接等不出結(jié)果。原因get_candidates未做約束每次遞歸都掃描全部225個空位。解決只在已有棋子的2格范圍內(nèi)找空位并取前10~15個。搜一個固定的中盤局面候選點通常會降到30個以內(nèi)再截斷到10個深度4最多一萬次葉節(jié)點評估配合剪枝實際會少很多。這張取舍直接決定AI速度比優(yōu)化評估函數(shù)更立竿見影。如果棋盤上一枚棋子都沒有g(shù)et_candidates會返回空列表。這時我一般讓AI直接下天元附近而不是強行搜索。這種邊界要寫進文檔否則評審試一下AI先手卻不下棋印象分很受影響。4.4 評估函數(shù)只看“連續(xù)子數(shù)”AI沒有進攻意識現(xiàn)象AI會應(yīng)“馬上要成五的點”但不會主動制造活三評優(yōu)時總選一個看似安穩(wěn)但沒威脅的點。原因評分只用了連子長度的平方?jīng)]有區(qū)分活形和眠形。比如一個被兩邊堵死的眠三和兩邊都空的活三對局意義完全不同。解決給棋形加權(quán)。常見做法是棋形權(quán)重成五100000活四8000沖四2000活三800眠三100活二20把上面evaluate_board里的連續(xù)計數(shù)換成統(tǒng)計每種棋形的數(shù)量乘以不同權(quán)重。答辯時把這張權(quán)重表放進文檔說明“深搜負(fù)責(zé)找路線評估函數(shù)負(fù)責(zé)判斷路線價值”比空談算法更有說服力。4.5 “多智能體”被追問題目里每個詞都要有落點現(xiàn)象演示時只打開人機對戰(zhàn)評委問“多智能體體現(xiàn)在哪”答不上來。原因題目里的多智能體博弈沒有對應(yīng)可玩功能。解決在菜單里加一個“AI對戰(zhàn)”模式黑棋用深搜剪枝白棋用隨機或貪心策略讓兩個智能體自行下完一整局并在界面下方打印落子數(shù)、搜索深度、剪枝前后節(jié)點數(shù)。這樣既能把“多智能體”落地還能順帶收集勝率數(shù)據(jù)。文檔里就寫兩個獨立決策單元共享棋盤狀態(tài)各自優(yōu)化自身收益構(gòu)成一個零和博弈系統(tǒng)。這一條回答能直接把題目里的三個關(guān)鍵詞全部引出來。5. 驗證剪枝效果用節(jié)點數(shù)讓答辯眼前一亮5.1 用節(jié)點數(shù)和雙AI勝率驗證算法代碼from copy import deepcopy board load_midgame_board() # 讀一個中盤局面 cnt_no_prune search_stats(deepcopy(board), depth4, use_pruneFalse) cnt_prune search_stats(deepcopy(board), depth4, use_pruneTrue) print(no prune:, cnt_no_prune) print(prune:, cnt_prune)search_stats是一個把minimax包了一層計數(shù)的函數(shù)。注意兩版必須用同一個board對象深拷貝不能先跑一版再跑另一版時棋盤已經(jīng)被改掉另外只統(tǒng)計葉節(jié)點還是所有節(jié)點要在文檔里說明否則數(shù)據(jù)對不上。我的習(xí)慣是統(tǒng)計“進入遞歸的總次數(shù)”因為這個數(shù)最直觀且不受機器波動影響。第二個驗證是雙AI勝率。讓黑棋AI用剪枝白棋AI用隨機落子連續(xù)跑20局看黑棋勝率。如果剪枝實現(xiàn)有問題黑棋勝率不會比隨機高多少如果棋力正常勝率應(yīng)該接近100%。比較兩件事剪枝前后節(jié)點數(shù)顯著下降剪枝后的棋力不下降這才算有效優(yōu)化。5.2 把參數(shù)表寫進文檔文檔說明別照抄代碼要寫清楚四個數(shù)搜索深度、候選點數(shù)、評分權(quán)重、剪枝開關(guān)。我用過一個最簡單的方式寫一個CONFIG.py集中管理這些參數(shù)文檔里放“depth4max_candidates10成五100000活四8000……”表格并說明不同參數(shù)組合在演示機上耗時約多少秒。答辯時直接展示這一頁比講十分鐘原理更有效。我現(xiàn)在做AI相關(guān)項目第一件事就是給搜索加一個節(jié)點計數(shù)開關(guān)。沒有計數(shù)剪枝效果全靠感覺就是玄學(xué)有了計數(shù)你能一眼看出優(yōu)化是不是真的發(fā)生了。希望幫到你。本文還有配套的精品資源點擊獲取