《編程之美》讀書筆記:1.3 一摞烙餅的排序
問題:
星期五的晚上,一幫同事在希格瑪大廈附近的“硬盤酒吧”多喝了幾杯。程序員多喝了幾杯之后談什么呢?自然是算法問題。有個同事說:“我以前在餐館打工,顧客經常點非常多的烙餅。店里的餅大小不一,我習慣在到達顧客飯桌前,把一摞餅按照大小次序擺好——小的在上面,大的在下面。由于我一只手托著盤子,只好用另一只手,一次抓住最上面的幾塊餅,把它們上下顛倒個個兒,反復幾次之后,這摞烙餅就排好序了。我后來想,這實際上是個有趣的排序問題:假設有n塊大小不一的烙餅,那最少要翻幾次,才能達到最后大小有序的結果呢?”
你能否寫出一個程序,對于n塊大小不一的烙餅,輸出最優化的翻餅過程呢?
n個烙餅經過翻轉后的所有狀態可組成一棵樹。尋找翻轉最少次數,相當于在樹中搜索層次最低的某個節點。
由于每層的節點數呈幾何數量級增長,在n較大時,使用廣度優先遍歷樹,可能沒有足夠的內存來保存中間結果(考慮到每層的兩個節點,可以通過旋轉,移位等操作互相轉換,也許每層的狀態可以用一個函數來生成,這時可以采用廣度優先方法。),因而采用深度優先。但這棵樹是無限深的,必須限定搜索的深度(即最少翻轉次數的上限值),當深度達到該值時不再繼續往下搜索。最少翻轉次數,必然小等于任何一種翻轉方案所需的翻轉次數,因而只要構造出一種方案,取其翻轉次數即可做為其初始值。最簡單的翻轉方案就是:對最大的未就位的烙餅,將其翻轉,再找到最終結果中其所在的位置,翻轉一次使其就位。因此,對編號在n-1和2之間的烙餅,最多翻轉了2*(n-2)次,剩下0和1號烙餅最多翻轉1次,因而最少翻轉次數的上限值是:2*(n-2)+1=2*n-3(從網上可搜索到對該上限值最新研究結果:上限值為18/11*n),當然,最好還是直接計算出采用這種方案的翻轉次數做為初始值。
減少遍歷次數:
1 減小“最少翻轉次數上限值”的初始值,采用前面提到的翻轉方案,取其翻轉次數為初始值。對書中的例子{3,2,1,6,5,4,9,8,7,0},初始值可以取10。
2 避免出現已處理過的狀態一定會減少遍歷嗎?答案是否定的,深度優先遍歷,必須遍歷完一個子樹,才能遍歷下一個子樹,如果一個解在某層比較靠后位置,若不允許處理已出現過的狀態時,可能要經過很多次搜索,才能找到這個解,但允許處理已出現過的狀態時,可能會很快找到這個解,并減小“最少翻轉次數的上限值”,使更多的分支能被剪掉,從而減少遍歷。比如說,兩個子樹A、B,搜索子樹A,100次后可得到一個解對應翻轉次數20,搜索子樹B,20次后可得到翻轉次數為10的解,不允許處理已出現過的狀態,就會花100次遍歷完子樹A后,才開始遍歷B,但允許翻轉回上一次狀態,搜索會在A、B間交叉進行,就可能只要70次找到子樹B的那個解(翻轉次數為10+2=12),此時,翻轉次數比較少,能減少更多的搜索,搜索次數明顯減少。以書中的{3,2,1,6,5,4,9,8,7,0}為例,按程序(1.3_pancake.cpp),不允許翻轉回上次狀態時需搜索195次,而允許翻轉回上次狀態時只要搜索116次。
3 如果最后的幾個烙餅已經就位,只須考慮前面的幾個烙餅。對狀態(0,1,3,4,2,5,6),編號為5和6的烙餅已經就位,只須考慮前5個烙餅,即狀態(0,1,3,4,2)。如果一個最優解,從某次翻轉開始移動了一個已經就位的烙餅,且該烙餅后的所有烙餅都已經就位,那么,對這個解法,從這次翻轉開始得到的一系列狀態,從中移除這個烙餅,得到新的狀態,可以設計出一個新的解法對應這系列新的狀態。該解法所用的翻轉次數不會比原來的多。
4 估計每個狀態還需要翻轉的最少次數(即下限值),加上當前的深度,如果大等于上限值,就無需繼續遍歷。這個下限值可以這樣確定:從最后一個位置開始,往前找到第一個與最終結果位置不同的烙餅編號(也就是說排除最后幾個已經就位的烙餅),從該位置到第一個位置,計算相鄰的烙餅的編號不連續的次數,再加上1。每次翻轉最多只能使不連續的次數減少1,但很多人會忽略掉這個情況:最大的烙餅沒有就位時,必然需要一次翻轉使其就位,而這次翻轉卻不改變不連續次數。(可以在最后面增加一個更大的烙餅,使這次翻轉可以改變不連續數。)如:對狀態(0,1,3,4,2,5,6)等同于狀態(0,1,3,4,2),由于1、3和4、2不連續,因而下限值為2+1=3。下限值也可以這樣確定:在最后面增加一個已經已就位的最大的烙餅,然后再計算不連續數。如:(0,1,3,4,2),可以看作(0,1,3,4,2,5),1和3 、4和2 、2和5這三個不連續,下限值為3。
5多數情況下,翻轉次數的上限值越大,搜索次數就越多。可以采用貪心算法,通過調整每次所有可能翻轉的優先順序,盡快找到一個解,從而減少搜索次數。比如,優先搜索使“下限值”減少的翻轉,其次是使“下限值”不變的翻轉,最后才搜索使“下限值”增加的翻轉。對“下限值”不變的翻轉,還可以根據其下次的翻轉對“下限值”的影響,再重新排序。由于進行了優先排序,翻轉回上一次狀態能減少搜索次數的可能性得到進一步降低。
6 其它剪枝方法:
假設第m次翻轉時,“上限值”為min_swap。
如果在某個位置的翻轉得到一個解(即翻轉次數為m),則其它位置可以不搜索(因為在其它位置的翻轉,能得到的最少翻轉次數必然大等m)。
如果在某個位置的翻轉后,“下限值”為k,并且 k+m>=min_swap,則對所有的使新“下限值”kk大等于k的翻轉,都有 kk+m>=min_swap,因而都可以不搜索。
另外,由于翻轉時,只有兩個位置的改變才對“下限值”有影響,因而可以記錄每個狀態的“下限值”,翻轉時,通過幾次比較,就可以確定新狀態的“下限值”。(判斷不連續次數時,最好寫成 -1<=x && x<=1, 而不是x==1 || x==-1。對于 int x; a<=x && x<=b,編譯器可以將其優化為 unsigned (x-a) <= b-a。)
結果:
對書上的例子{3,2,1,6,5,4,9,8,7,0}:
|
翻轉回上次狀態 |
搜索函數被調用次數 |
翻轉函數被調用次數 |
1.3_pancake_2 |
不允許 |
29 |
66 |
1.3_pancake_2 |
允許 |
33 |
74 |
1.3_pancake_1 |
不允許 |
195 |
398 |
1.3_pancake_1 |
允許 |
116 |
240 |
(這個例子比較特殊,代碼1.3_pancake_2.cpp(與1.3_pancake_1.cpp的最主要區別在于,增加了對翻轉優先順序的判斷,代碼下載),在不允許翻轉回上次狀態、取min_swap的初始值為2*10-2=18時,調用搜索函數29次,翻轉函數56次)。
另外,對1.3_pancake_2.cpp的第148行做個簡單的改動:
for (int pos=1, last_swap=cake_swap[step++]; pos<size; ++pos){
改為:
for (int pos=size-1, last_swap=cake_swap[step++]; pos>0; ++pos){
只是改變了搜索順序,但卻極大提升了搜索效率。對書上的例子,搜索次數進一步降到11次(實際上前六次搜索找到了一個解,后而的幾次用于判斷這個解是是最優解)。遍歷所有可能的排列求第1個……第10個烙餅數所用的總時間,也由原來的38秒降到21秒。


1

2

3

4

5

6

7

8

9



10

11



12

13

14

15

16

17

18

19

20

21

22



23

24

25

26

27

28



29

30

31

32

33

34

35

36

37



38

39



40

41

42

43

44



45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61



62

63

64

65

66

67

68

69

70

71

72

73



74

75

76

77

78

79

80

81

82

83

84

85

86

87



88

89

90

91

92

93

94

95

96

97

98



99

100

101

102

103

104

105

106

107

108

109

110

111



112

113

114

115

116



117

118

119

120

121

122

123

124

125

126

127

128

129

130

131

132



133

134

135

136

137

138

139

140

141

142

143

144



145

146

147

148

149

150

151



152

153

154

155

156

157

158



159

160

161

162

163

164

165

166

167

168

169

170

171

172

173

174

175

176

177

178

179

180



181

182

183

184

185

186

187



188

189

190

191

192

193



194

195

196

197

198

199

200

201

202

203

204

205

206

207

208

209

210

211

212



213



214

215

216

217

218

219

220

221

222

223

224

225

226

補充:
在網上下了《編程之美》“第6刷”的源代碼,結果在編譯時存在以下問題:
1 Assert 應該是 assert
2 m_arrSwap 未被定義,應該改為m_SwapArray
3 Init函數兩個for循環,后一個沒定義變量i,應該將i 改為 int i
另外,每運行一次Run函數,就會調用Init函數,就會申請新的內存,但卻沒有釋放原來的內存,會造成內存泄漏。
書上程序的低效主要是由于進行剪枝判斷時,沒有考慮好邊界條件,可進行如下修改:
1 if(step + nEstimate > m_nMaxSwap) > 改為 >=。
2 判斷下界時,如果最大的烙餅不在最后一個位置,則要多翻轉一次,因而在LowerBound函數return ret; 前插入一行:
if (pCakeArray[nCakeCnt-1] != nCakeCnt-1) ret++; 。
3 n個烙餅,翻轉最大的n-2烙餅最多需要2*(n-2)次,剩下的2個最多1次,因而上限值為2*n-3,因此,m_nMaxSwap初始值可以取2*n-3+1=2*n-2,這樣每步與m_nMaxSwap的判斷就可以取大等于號。
4 采用書上提到的確定“上限值”的方法,直接構建一個初始解,取其翻轉次數為m_nMaxSwap的初始值。
1和2任改一處,都能使搜索次數從172126降到兩萬多,兩處都改,搜索次數降到3475。若再改動第3處,搜索次數降到2989;若采用4的方法(此時初始值為10),搜索次數可降到1045。