程式設計也快樂第三期解答(三)
前兩天的演算法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
較以前有了小小的修改:
- 增加了
IsEmpty
和IsFull
兩個屬性(第 10~11 行)。這只是為了方便,因為它們都是其他屬性的計算結果。 - 表現水壺合法動作的三個方法(
Clean
、FullUp
和Move
)原來的返回值是表示其動作的字串,現改為沒有返回值,這更符合語義了。原來是為了簡化演算法才返回表示其自身動作的字串的。
所以說,這個水壺類不做修改也行的,只不過演算法更繁瑣一點罷了。
水(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 行首先記錄最終完成時的各水壺的水量。注意,這時沒有記錄解答步驟(還沒開始解答呢)。這使得
actions
和currents
錯開一位,正好符合我們的要求,因為倒推時解答步驟和水量正好是錯開一位的。 - 第 38~50 行的主迴圈是本演算法的關鍵,它負責具體實施倒推的步驟。
- 正如前面說過的,迴圈的結束條件是兩隻水壺均為空。(第 38 行)
- 在主迴圈中,剛好有一隻水壺為空(第 45~49 行的內層迴圈不算)。這體現為第 40 行的斷言語句。
- 如果一壺空,另一壺滿,則倒空之,大功告成。(第 41~42 行)
- 否則,必有一壺為空,另一壺不空也不滿,則裝滿空壺。(第 43~44 行)
- 然後從剛裝滿的壺倒入另一壺,直到本壺為空。其間如果另一壺滿了,則倒空之。(第 45~49 行)
- 請注意在這個主迴圈中並不是所有的動作都是合法的,要小心。有些動作,比如倒空一個未滿的水壺,在
演算法1
和演算法2
中可以做,但在本演算法的這個主迴圈中是不能做的。因為這是倒推步驟中的動作,它相當於正向步驟中的向水壺中加水但沒加滿,這是不允許的。未滿的水壺只能由另一個水壺向它倒水得到,完成倒水動作後另一個水壺必定是空壺。
- 第 70~86 行的三個和
Kettle
類同名的方法(FullUp
、Clean
和Move
)的作用也相同,只不過是倒推過程的動作而已,並且還記錄解答步驟。 - 第 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.cs
和StringExtensions.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 )
---- 大功告成 --------
仔細研究這個例子有助於理解本演算法。
相關文章
- 程式設計也快樂第三期解答(四)程式設計
- 程式設計也快樂第三期解答(二)程式設計
- 程式設計也快樂第三期解答(一)程式設計
- 程式設計也快樂第3期SQL程式碼程式設計SQL
- 程式設計也快樂: 兩隻水壺 C程式碼 搜尋版程式設計C程式
- 程式設計師節快樂程式設計師
- 使用Google Guava快樂程式設計GoGuava程式設計
- 程式設計師的快樂生活程式設計師
- 快樂指南:程式設計師版程式設計師
- 1024!程式設計師節快樂!程式設計師
- 女程式設計師們!節日快樂!程式設計師
- 程式設計師如何祝自己生日快樂程式設計師
- 程式設計學習之路:痛並快樂著程式設計
- 程式設計師的快樂:那些小細節程式設計師
- 讓程式設計快樂起來的過程程式設計
- 程式設計師快樂器之JAVA程式碼生成工具程式設計師Java
- 編碼也快樂:兩隻水壺F#程式
- 編碼也快樂:兩隻水壺Scheme程式Scheme
- 編碼也快樂:兩隻水壺C#程式C#
- 華為大佬:做一個快樂的程式設計師程式設計師
- 調查:是什麼讓程式設計師快樂?程式設計師
- 快樂Node程式設計師的10個習慣程式設計師
- 《Ruby基礎教程(第4版)》:快樂程式設計程式設計
- 在程式設計中體驗純粹的快樂程式設計
- 編碼也快樂:兩水壺的故事之JS程式JS
- 五線譜入門,程式設計師也可以玩音樂程式設計師
- 快樂的星期天:Scratch少兒趣味程式設計程式設計
- 程式設計師保持天天快樂的6個習慣程式設計師
- 程式設計師的燈下黑:沒學會快樂程式設計師
- 傳播正能量——做一個快樂的程式設計師程式設計師
- 《快樂碼農》第5期 大話程式設計師面試程式設計師面試
- 編碼也快樂活動:撲克牌排序排序
- 編碼也快樂!撲克牌排序JAVA排序Java
- Java程式設計師面試題及解答Java程式設計師面試題
- 也談程式設計改革程式設計
- 《Python程式設計練習與解答》之程式設計概論Python程式設計
- 聖誕節快樂:來自程式設計師們的問候程式設計師
- 程式設計師保持快樂活躍的6個好習慣程式設計師