程式設計也快樂第三期解答(三)

黃志斌發表於2013-04-13

前兩天的演算法1演算法2分別處理兩個和多個水壺的情況,但都是有缺陷的,它們並不保證在給定的問題有解的情況下一定能夠找到解答。今天我們要介紹的演算法3只能處理兩隻水壺,但是它在給定的問題有解的情況下一定可以找到解答。

水壺(Kettle.cs)

 1: using System;
 2: 
 3: namespace Skyiv.Ben.Pond
 4: {
 5:   sealed class Kettle
 6:   {
 7:     public string Name { get; private set; }  // 水壺的名稱
 8:     public int Capacity { get; private set; } // 最大容量
 9:     public int Current { get; private set; }  // 當前水量
10:     public bool IsEmpty { get { return Current == 0; } }
11:     public bool IsFull { get { return Current == Capacity; } }
12:     
13:     public Kettle(string name, int capacity)
14:     { // 建構函式
15:       Name = name;
16:       Capacity = capacity;
17:     }
18:     
19:     public void Clean()
20:     { // 倒空水壺
21:       Current = 0;
22:     }
23:     
24:     public void FullUp()
25:     { // 裝滿水壺
26:       Current = Capacity;
27:     }
28:     
29:     public void Move(Kettle other)
30:     { // 將水倒入另一隻壺中,直至本壺空或另一壺滿
31:       var value = Math.Min(Current, other.Capacity - other.Current);
32:       Current -= value;
33:       other.Current += value;
34:     }
35:     
36:     public override string ToString()
37:     { // 報告水壺自身的狀態
38:       return string.Format("[{0}:{1}/{2}]" , Name, Current, Capacity);
39:     }
40:   }
41: }

這個新的水壺類Kettle較以前有了小小的修改:

  • 增加了IsEmptyIsFull兩個屬性(第 10~11 行)。這只是為了方便,因為它們都是其他屬性的計算結果。
  • 表現水壺合法動作的三個方法(CleanFullUpMove)原來的返回值是表示其動作的字串,現改為沒有返回值,這更符合語義了。原來是為了簡化演算法才返回表示其自身動作的字串的。

所以說,這個水壺類不做修改也行的,只不過演算法更繁瑣一點罷了。

水(Water.cs)

  1: using System;
  2: using System.IO;
  3: using System.Linq;
  4: using System.Diagnostics;
  5: using System.Collections.Generic;
  6: using Skyiv.Extensions;
  7: 
  8: namespace Skyiv.Ben.Pond
  9: {
 10:   sealed class Water
 11:   {
 12:     TextWriter writer;    // 往哪寫?
 13:     int target;           // 要達到的目標
 14:     Kettle[] kettles;     // 水壺們: 最少兩個
 15:     List<string> actions; // 記錄解答步驟的列表
 16:     List<int[]> currents; // 記錄各水壺水量的列表
 17:     
 18:     public Water(TextWriter writer, int limit, int target, params int[] capacitys)
 19:     { // 建構函式:本演算法無視步數限制,所以忽略了(limit)引數
 20:       this.writer = writer;
 21:       this.target = target;
 22:       if (target < 0) throw new ArgumentException("目標水量不能小於零");
 23:       kettles = new Kettle[capacitys.Length];
 24:       for (var i = 0; i < capacitys.Length; i++)
 25:       {
 26:         if (capacitys[i] < 0) throw new ArgumentException("水壺的容量不能小於零");
 27:         kettles[i] = new Kettle(((char)('A' + i)).ToString(), capacitys[i]);
 28:       }
 29:     }
 30:     
 31:     public void Solve()
 32:     { // 尋找解決步驟
 33:       Trace.Listeners.Add(new TextWriterTraceListener(writer)); // -d:TRACE
 34:       writer.Write("目標:{0} 水壺們:", target);
 35:       foreach (var kettle in kettles)  writer.Write(kettle);
 36:       writer.WriteLine(); // 以上三行輸出題目的條件
 37:       Initialize();
 38:       while (!kettles[0].IsEmpty || !kettles[1].IsEmpty)
 39:       { // 演算法3(只使用前兩隻水壺):往回倒推,如果最後得到兩隻空壺就成功了
 40:         Trace.Assert(kettles[0].IsEmpty || kettles[1].IsEmpty);// 剛好一壺為空
 41:         if (kettles[1].IsEmpty && kettles[0].IsFull) { Clean(0); break; }
 42:         if (kettles[0].IsEmpty && kettles[1].IsFull) { Clean(1); break; }
 43:         int i = kettles[0].IsEmpty ? 0 : 1, j = 1 - i;
 44:         FullUp(i); // 挑選一隻空壺,裝滿之 (此時必有一壺為空,另一壺不空也不滿)
 45:         while (!kettles[i].IsEmpty)
 46:         { // 從剛裝滿的壺倒入另一壺,直到本壺為空
 47:           Move(i, j);
 48:           if (kettles[j].IsFull) Clean(j); // 如另一壺滿了,則倒空之
 49:         }
 50:       }
 51:       Report();
 52:     }
 53:     
 54:     void Initialize()
 55:     { // 檢查是否有解,並設定某隻水壺的水量為目標水量
 56:       actions = new List<string>();
 57:       currents = new List<int[]>();
 58:       var imax = (kettles[0].Capacity > kettles[1].Capacity) ? 0 : 1;
 59:       var gcd = MathExtensions.Gcd(kettles[0].Capacity, kettles[1].Capacity);
 60:       if (gcd != 0 && target %  gcd != 0 || target > kettles[imax].Capacity)
 61:       {
 62:         writer.WriteLine("*** 無解(本演算法僅使用前兩個水壺) ***");
 63:         Environment.Exit(0);
 64:       }
 65:       kettles[imax].FullUp(); // 本行和下一行用於設定某隻水壺的水量為目標水量
 66:       kettles[imax].Move(new Kettle(null, kettles[imax].Capacity - target));
 67:       Mark(null); // 記錄最終完成時各水壺的水量
 68:     }
 69:     
 70:     void FullUp(int n)
 71:     { // 倒推步驟:裝滿(n)壺,解答步驟:倒空(n)壺
 72:       kettles[n].FullUp();
 73:       Mark("倒空" + kettles[n].Name);
 74:     }
 75:     
 76:     void Clean(int n)
 77:     { // 倒推步驟:倒空(n)壺,解答步驟:裝滿(n)壺
 78:       kettles[n].Clean();
 79:       Mark("裝滿" + kettles[n].Name);
 80:     }
 81:     
 82:     void Move(int n, int m)
 83:     { // 倒推步驟:從(n)壺倒入(m)壺,解答步驟:從(m)壺倒入(n)壺
 84:       kettles[n].Move(kettles[m]);
 85:       Mark("從" + kettles[m].Name + "倒入" + kettles[n].Name);
 86:     }
 87:     
 88:     void Mark(string action)
 89:     { // 記錄當前步驟和各水壺的水量
 90:       if (action != null) actions.Add(action);
 91:       currents.Add(kettles.Select(x => x.Current).ToArray());
 92:     }
 93:     
 94:     void Report()
 95:     { // 報告解答步驟
 96:       for (var i = actions.Count - 1; i >= 0; i--)
 97:       {
 98:         writer.Write("{0, 3}: ", actions.Count - i);
 99:         writer.Write("{0} (", actions[i].ChinesePadRight(8));
100:         foreach (var current in currents[i]) writer.Write("{0,2} ", current);
101:         writer.WriteLine(")"); // 下一行不是必須的,只是為了縮短解答步驟
102:         if (currents[i][0] == target || currents[i][1] == target) break;
103:       }
104:       writer.WriteLine("---- 大功告成 --------");
105:     }
106:   }
107: }

這個Water類是演算法的核心。 程式中已經有很好的註釋了,現就演算法的思路解釋如下:

  • 我們的演算法3只處理前兩隻水壺,忽略輸入條件中的其他水壺(如果有的話)。
  • 如果目標水量在大水壺的容量之內並且是兩隻水壺容量的最大公約數的整數倍則問題有解,否則無解。(第 58~64 行)。
  • 本演算法採用倒推法來解答,首先設定某隻水壺的水量為目標水量,然後一步步往回倒推,如果最後得到兩隻空壺就成功了。
  • 程式中第 65~66 行就是用於設定某隻水壺的水量為目標水量的。它先裝滿目標水壺,然後再將水倒一些到一個虛擬的恰當容量的水壺中去。使用這個巧妙的方法,就不用給Kettle類增加設定水量的方法了。
  • 由於採用倒推法,所以需要記錄解答步驟以及各水壺的水量。第 15~16 行的例項欄位就是幹這個的,它們在第 56~57 行被初始化。第 88~92 行的Mark方法就是用來記錄當前步驟和各水壺的水量的。
  • 第 67 行首先記錄最終完成時的各水壺的水量。注意,這時沒有記錄解答步驟(還沒開始解答呢)。這使得actionscurrents錯開一位,正好符合我們的要求,因為倒推時解答步驟和水量正好是錯開一位的。
  • 第 38~50 行的主迴圈是本演算法的關鍵,它負責具體實施倒推的步驟。
    • 正如前面說過的,迴圈的結束條件是兩隻水壺均為空。(第 38 行)
    • 在主迴圈中,剛好有一隻水壺為空(第 45~49 行的內層迴圈不算)。這體現為第 40 行的斷言語句。
    • 如果一壺空,另一壺滿,則倒空之,大功告成。(第 41~42 行)
    • 否則,必有一壺為空,另一壺不空也不滿,則裝滿空壺。(第 43~44 行)
    • 然後從剛裝滿的壺倒入另一壺,直到本壺為空。其間如果另一壺滿了,則倒空之。(第 45~49 行)
    • 請注意在這個主迴圈中並不是所有的動作都是合法的,要小心。有些動作,比如倒空一個未滿的水壺,在演算法1演算法2中可以做,但在本演算法的這個主迴圈中是不能做的。因為這是倒推步驟中的動作,它相當於正向步驟中的向水壺中加水但沒加滿,這是不允許的。未滿的水壺只能由另一個水壺向它倒水得到,完成倒水動作後另一個水壺必定是空壺。
  • 第 70~86 行的三個和Kettle類同名的方法(FullUpCleanMove)的作用也相同,只不過是倒推過程的動作而已,並且還記錄解答步驟。
  • 第 94~105 行的Report方法從記錄的解答步驟中倒著報告出來。

擴充套件方法(MathExtensions.cs)

 1: namespace Skyiv.Extensions
 2: {
 3:   public static class MathExtensions
 4:   {
 5:     public static int Gcd(int a, int b)
 6:     { // 返回 a 和 b 的最大公約數
 7:       for (int t; b != 0; a = b, b = t) t = a % b;
 8:       return a;
 9:     }
10:   }
11: }

這就是求最大公約數的歐幾里得輾轉相除法,你懂的。 :)

演算法3的正確性

上面的Gcd方法的使用減法而不使用除法的版本如下(也是歐幾里得的貢獻):

1:     public static int Gcd(int a, int b)
2:     { // 返回 a 和 b 的最大公約數
3:       if (a == 0) return b;
4:       while (b != 0)
5:         if (a > b) a -= b;
6:         else b -= a;
7:       return a;
8:     }

把第 4~6 行的迴圈和前面的Water類中第 45~49 行的內層迴圈對比,就會明白演算法3為什麼是正確的了。因為演算法3只要能夠在有限步結束而不陷入死迴圈,就表示找到一個解答。這裡的減法就對應著演算法3中的倒水的動作。

編譯檔案(Makefile)

CSC = dmcs
OPT = -d:TRACE
SRC = ConsoleRunner.cs Water.cs Kettle.cs StringExtensions.cs MathExtensions.cs

ConsoleRunner.exe : $(SRC)
    $(CSC) -out:$@ $(OPT) $(SRC)

在上述檔案中:

  • 這裡的 -d:TRACE 定義了一個巨集,為的是前面的斷言語句,請參見前面的Water.cs中第 33 行和第 40 行。如果不定義這個巨集,前面的第 40 行語句是不起作用的。
  • 其餘的 C# 程式(ConsoleRunner.csStringExtensions.cs)均沒有修改,請參見前兩篇文章。

編譯和執行

Water$ make && mono ConsoleRunner.exe 3 5 6
dmcs -out:ConsoleRunner.exe -d:TRACE ConsoleRunner.cs Water.cs Kettle.cs StringExtensions.cs MathExtensions.cs
目標:3 水壺們:[A:0/5][B:0/6]
  1: 裝滿B    ( 0  6 )
  2: 從B倒入A ( 5  1 )
  3: 倒空A    ( 0  1 )
  4: 從B倒入A ( 1  0 )
  5: 裝滿B    ( 1  6 )
  6: 從B倒入A ( 5  2 )
  7: 倒空A    ( 0  2 )
  8: 從B倒入A ( 2  0 )
  9: 裝滿B    ( 2  6 )
 10: 從B倒入A ( 5  3 )
---- 大功告成 --------

可以看出,這個結果和演算法1演算法2一樣。繼續看幾個結果:

Water$ mono ConsoleRunner.exe 3 5 7
目標:3 水壺們:[A:0/5][B:0/7]
  1: 裝滿B    ( 0  7 )
  2: 從B倒入A ( 5  2 )
  3: 倒空A    ( 0  2 )
  4: 從B倒入A ( 2  0 )
  5: 裝滿B    ( 2  7 )
  6: 從B倒入A ( 5  4 )
  7: 倒空A    ( 0  4 )
  8: 從B倒入A ( 4  0 )
  9: 裝滿B    ( 4  7 )
 10: 從B倒入A ( 5  6 )
 11: 倒空A    ( 0  6 )
 12: 從B倒入A ( 5  1 )
 13: 倒空A    ( 0  1 )
 14: 從B倒入A ( 1  0 )
 15: 裝滿B    ( 1  7 )
 16: 從B倒入A ( 5  3 )
---- 大功告成 --------
Water$ mono ConsoleRunner.exe 7 9 2
目標:7 水壺們:[A:0/9][B:0/2]
  1: 裝滿A    ( 9  0 )
  2: 從A倒入B ( 7  2 )
---- 大功告成 --------
Water$ mono ConsoleRunner.exe 0 0 0
目標:0 水壺們:[A:0/0][B:0/0]
---- 大功告成 --------

還有無解的情況:

Water$ mono ConsoleRunner.exe 3 6 10 3
目標:3 水壺們:[A:0/6][B:0/10][C:0/3]
*** 無解(本演算法僅使用前兩個水壺) ***
Water$ mono ConsoleRunner.exe 7 5 6 7
目標:7 水壺們:[A:0/5][B:0/6][C:0/7]
*** 無解(本演算法僅使用前兩個水壺) ***

最後再來個步驟比較多的:

Water$ mono ConsoleRunner.exe 9 8 15
目標:9 水壺們:[A:0/8][B:0/15]
  1: 裝滿B    ( 0 15 )
  2: 從B倒入A ( 8  7 )
  3: 倒空A    ( 0  7 )
  4: 從B倒入A ( 7  0 )
  5: 裝滿B    ( 7 15 )
  6: 從B倒入A ( 8 14 )
  7: 倒空A    ( 0 14 )
  8: 從B倒入A ( 8  6 )
  9: 倒空A    ( 0  6 )
 10: 從B倒入A ( 6  0 )
 11: 裝滿B    ( 6 15 )
 12: 從B倒入A ( 8 13 )
 13: 倒空A    ( 0 13 )
 14: 從B倒入A ( 8  5 )
 15: 倒空A    ( 0  5 )
 16: 從B倒入A ( 5  0 )
 17: 裝滿B    ( 5 15 )
 18: 從B倒入A ( 8 12 )
 19: 倒空A    ( 0 12 )
 20: 從B倒入A ( 8  4 )
 21: 倒空A    ( 0  4 )
 22: 從B倒入A ( 4  0 )
 23: 裝滿B    ( 4 15 )
 24: 從B倒入A ( 8 11 )
 25: 倒空A    ( 0 11 )
 26: 從B倒入A ( 8  3 )
 27: 倒空A    ( 0  3 )
 28: 從B倒入A ( 3  0 )
 29: 裝滿B    ( 3 15 )
 30: 從B倒入A ( 8 10 )
 31: 倒空A    ( 0 10 )
 32: 從B倒入A ( 8  2 )
 33: 倒空A    ( 0  2 )
 34: 從B倒入A ( 2  0 )
 35: 裝滿B    ( 2 15 )
 36: 從B倒入A ( 8  9 )
---- 大功告成 --------

仔細研究這個例子有助於理解本演算法。

相關文章