用Python形象地解決酒缸分酒問題
- 2019 年 10 月 10 日
- 筆記
本文1715字;預計閱讀12分鐘;
最近遇到一個有意思的題目,看上去不相關的兩個事物有著同樣的轉移狀態,並且設定規則後過程可以用程式模擬出來,遂記之。
0,問題提出
你有一個8升的酒罈,裡面裝滿了酒,另外還有兩個分別是5升和3升的空酒罈,3個酒罈都沒有刻度,現在需要倒出正好4升的酒,需要怎麼操作?
從題目來看,我們需要把3個缸的酒倒來倒去,直到某個酒缸裡面是4升酒。這個問題的解法很有趣,我們假設能裝5升酒的罈子叫A,3升的罈子是B,8升的罈子是C,開始的時候我們可以先在A罈子里裝滿酒也可以先在B罈子裝滿酒(只裝一部分我們是沒辦法知道是多少升的,沒有用)。
假設先給A裝滿酒,那麼A,B罈子的狀態就是(5,0),表示這時候是A有5升酒,B有0升,這時候可以的做法是把A中的酒倒到B里,變成(2,3),也可以從C倒3升到B,變成(5,3),但這種情況下一步只能把A或B中的酒倒回C,回到開始狀態了;第三種情況是把A中的酒倒進C里,變成(0,0),更加沒意義了。
1,撞球解法
於是有效做法是從(5,0)狀態變成(2,3)狀態,我們可以想像一個菱形的撞球桌,從一個地方發球,球經過和桌子邊緣的碰撞有一個彈射的路徑。來看一下一個從(5,0)出發的球,在一個5 x 3的撞球桌上,沿三角形邊線方向撞擊撞球,其路徑會是(2,3) (2,0) (0,2) (5,2) (4,3),如圖

撞球彈射路徑
我們之前把(5,0)理解為A壇有5升酒,B壇有0升,那麼從(5,0)到(2,3)到(2,0)就可以理解為這兩個酒罈裡面液體的量的變化,也就是酒罈里的狀態轉移路徑。具體就是從A倒酒進B里,直到B滿了(變成(2,3)),再把B里的酒倒到C里,變成(2,0),把B中剩餘的2升倒到C里,變成(0,2),這時候把B倒滿,變成(5,2),B可以裝3升,目前是2升,所以從A倒酒進B里,B原來是2升,倒滿就是從A倒出了1升,所以A是4升,也就是(4,3),達到要求。
實際中解這類題我們可以畫x*y的菱形手動畫路徑,但我們可以用程式模擬這一過程,下面用Python實現一下。
3,Python 模擬
可以通過計算方向和用反射定律去模擬球的軌跡,也可以取巧只通過路徑去模擬軌跡。首先這個撞球桌是菱形的,出發點在(5,0)或(0,3) (都能得到結果),我們從路徑上看,(5,0)只有一個路徑可以走,而(2,3)有兩條路可以走,分別到達(2,0)或者(5,0),在(0,0)以及(5,3)這兩個點是沒有路徑的。

3種路徑的示意圖
我們再分析路徑的規律,x橫坐標的最大值,y是縱坐標最大值,設n球在橫坐標的位置,m為在縱坐標上的位置,對於點設n球在橫坐標的位置,m為在縱坐標上的位置,對於點(n,0),n不等於0和不等於x時,都有兩條路,且從一條路過來必然下一步要走另一條路,這兩條路的規律是(n,y)以及(0,n),當n大於y時,到不了(0,n), 只能到(n-y,y);根據這些規律模擬出邊界上各個頂點對應的路,程式碼如下:
t=8 e=4 x,y=5,3 if y>x: x,y=y,x #保證x>y #從路徑來看,一個點要麼沒有路徑,要麼有1條路徑,要麼2條,沒有其他情況; #0條對應:(0,0) & (x,y) #1條:(x,0) & (0,y) #2條:(n,m) 0<n<x 0<m<y r={} r[(0,0)]=[] r[(x,y)]=[] r[(x,0)]=[(x-y,y)] r[(0,y)]=[(y,0)] for n in range(1,x): if n<y: r[(n,0)]=[(0,n),(n,y)] else: r[(n,0)]=[(n-y,y),(n,y)] k=n+y if k>x: r[(n,y)]=[(n,0),(x,n+y-x)] else: r[(n,y)]=[(n,0),(n+y,0)] for m in range(1,y): #m必然要小於x if m<x: 不需要 r[(0,m)]=[(m,0),(x,m)] r[(x,m)]=[(0,m),(x+m-y,y)]
我們就可以從一個點(5,0)出發,看具體的效果:
w=[x,0] #上一個點 s=[x,0] plst=[s] #開始 while s[0]!=e and s[1]!=e: ss=(s[0],s[1]) sw=r[ss] slen=len(sw) if slen==1: w=s.copy() s[0]=sw[0][0] s[1]=sw[0][1] elif slen==2: if sw[0][0]==w[0] and sw[0][1]==w[1]: w=s.copy() s[0]=sw[1][0] s[1]=sw[1][1] elif sw[1][0]==w[0] and sw[1][1]==w[1]: w=s.copy() s[0]=sw[0][0] s[1]=sw[0][1] else: print(s,sw,slen) else: print(s,sw,slen) plst.append(s) print(plst)
當x=5,y=3時,以上程式碼輸出 [[5,0],[2,3],[2,0],[0,2],[5,2],[4,3]]
,代表A,B兩個酒缸之內各有多少酒。
4,turtle可視化
路徑我們可以通過上面的程式碼算出來,從而得到這題的結果,但不夠形象,我們通過Python的turtle庫把這一過程畫出來,turtle是Python內置的一個畫圖庫,通過goto、left、right等命令控制一個小海龜(turtle)在畫布(canvas)上爬行,從而得到各種圖案,可以拿來繪製分形圖案、小豬佩奇等。這次的模擬路徑可視化也可以用pygame庫。
正常的畫布是一個直角坐標系,我們這裡需要一個縱軸和橫軸之間是60度的斜坐標系,因此通過以下函數轉換:
def drawToXY(x,y,turtle=turtle,sin=sin,pc=50): #sin=sin(60°)=sqrt(3)/2; pc轉換因子 turtle.goto(x*pc+0.5*y*pc,pc*sin*y) #直角坐標轉斜坐標
通過 drawToXY()
我們可以很方便地繪製出撞球桌的外框:
def drawDiamond(x,y,turtle=turtle,pc=pc,sin=sin): #畫邊界 turtle.pensize(3) #佔3個像素的筆寬 drawToXY(x,0,turtle,sin) drawToXY(x,y,turtle,sin) drawToXY(0,y,turtle,sin) drawToXY(0,0,turtle,sin) #外框 turtle.pensize(1) #默認筆寬
繪製一個5 x 3的外框效果如下:

繪製外框之後再加上內部點的連接,就像把一個(X,Y)的方格變形為菱形,並加上邊界坐標,在turtle上寫文本通過語句 turtle.write(txt,font=("微軟雅黑",size,"normal"))
實現。

繪製外框
之後便可以繪製從(5,0)出發的球的路徑了:
def drawPlst(plst,turtle=turtle,t2=t2): dt=25 dy=200 k=0 for p in plst: drawText(-180,dy-k*dt,str(p),t2,size=14) #在另一側寫下經過的邊界點 drawToXY(p[0],p[1],t1) k+=1 def drawText(x,y,txt,turtle=turtle,size=14): turtle.penup() turtle.goto(x,y) turtle.pendown() turtle.write(txt, font=("微軟雅黑", size, "normal")) plst=plst=[(5,0),(2,3),(2,0),(0,2),(5,2),(4,3)] t1.color("red") #t1.speed(2) #繪製速度,[0,10] 數值越大速度越快 drawPlst(plst,t1,t2)

從(0,3)出發的效果:

拓展題目
我們在河邊分別有一個7升的水桶和5升的水桶,都沒有刻度,如何用最少的次數裝6升的水出來?
分析一下我們知道,最少次數的方式就是我們撞球路徑的做法,之前我們對酒缸C的處理和一條容量無限的河本質是一樣的。
所以這題的解法用程式模擬效果就是這樣:

還可以通過枚舉解決:給一個7升和一個5升的桶,可以得到[1,7+5]之間哪些容量的水。
相關程式碼同步於https://github.com/QLWeilcf/ LcfsPythonWork/blob/ master/ waterTankSimulator.py。戳閱讀原文可達。