《編程之美》讀書筆記: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.3_pancake_f
1
//1.3_pancake_f.cpp by flyingheart # qq.com
2
#include<iostream>
3
#include<fstream>
4
#include<vector>
5
#include<algorithm>
6
#include<ctime>
7
using namespace std;
8
9
class Pancake
{
10
public:
11
Pancake()
{}
12
void print() const;
13
void process(); //顯示最優解的翻轉過程
14
int run(const int cake_arr[], int size, bool show=true);
15
void calc_range(int na, int nb);
16
17
private:
18
Pancake(const Pancake&);
19
Pancake& operator=(const Pancake&);
20
inline bool init(const int cake_arr[], int& size);
21
void search_cake(int size, int step, int least_swap_old);
22
void reverse_cake(int index)
{ //翻轉0到index間的烙餅
23
++count_reverse;
24
std::reverse(&cake[0], &cake[index + 1]);
25
}
26
27
bool next_search_cake(int pos, int size, int step, int least_swap)
28
{
29
if (least_swap + step >= get_min_swap()) return true;
30
cake_swap[step] = pos;
31
reverse_cake(pos);
32
search_cake(size,step,least_swap);
33
reverse_cake(pos);
34
return false;
35
}
36
37
int get_min_swap() const
{ return result.size();}
38
39
void output(int i, const std::string& sep, int width) const
{
40
cout.width(width);
41
cout << i << sep;
42
}
43
44
void output(const std::string& sep, int width) const
{
45
cout.width(width);
46
cout << sep;
47
}
48
49
vector<int> cake_old; //要處理的原烙餅數組
50
vector<int> cake; //當前各個烙餅的狀態
51
vector<int> result; //最優解中,每次翻轉的烙餅位置
52
vector<int> cake_swap; //每次翻轉的烙餅位置
53
vector<int> cake_order; //第step+1次翻轉時,翻轉位置的優先順序
54
int min_swap_init; //最優解的翻轉次數初始值
55
int count_search; //search_cake被調用次數
56
int count_reverse; //reverse_cake被調用次數
57
};
58
59
60
void Pancake::print() const
61

{
62
int min_swap = get_min_swap();
63
if (min_swap == 0) return;
64
cout << "minimal_swap initial: " << min_swap_init
65
<< " final: "<< min_swap
66
<< "\nsearch/reverse function was called: " << count_search
67
<< "/" << count_reverse << " times\nsolution: ";
68
for (int i = 0; i < min_swap; ++i) cout << result[i] << " ";
69
cout<< "\n\n";
70
}
71
72
void Pancake::process()
73

{
74
int min_swap = get_min_swap();
75
if (min_swap == 0) return;
76
cake.assign(cake_old.begin(), cake_old.end());
77
int cake_size = cake_old.size();
78
const int width = 3, width2 = 2 * width + 3;
79
output("No.", width2);
80
for (int j = 0; j < cake_size; ++j) output(j," ",width);
81
cout << "\n";
82
output("old:", width2);
83
84
for (int j = 0; j < cake_size; ++j) output(cake[j]," ",width);
85
cout << "\n";
86
87
for (int i = 0; i < min_swap; ++i)
{
88
reverse_cake(result[i]);
89
output(i + 1," ",width);
90
output(result[i],": ",width);
91
for (int j = 0; j < cake_size; ++j) output(cake[j]," ",width);
92
cout << "\n";
93
}
94
cout << "\n\n";
95
}
96
97
bool Pancake::init(const int cake_arr[], int& size)
98

{
99
result.clear();
100
if (cake_arr == NULL) return false;
101
cake_swap.resize(size * 2);
102
cake_order.resize(size * size * 2);
103
count_search = 0;
104
count_reverse = 0;
105
cake_old.assign(cake_arr,cake_arr + size);
106
//去除末尾已就位的烙餅,修正烙餅數組大小。
107
while (size > 1 && size - 1 == cake_arr[size - 1]) --size;
108
if (size <= 1) return false;
109
110
cake.assign(cake_arr,cake_arr + size);
111
for (int j = size - 1; ;)
{ //計算一個解作為min_swap初始值。
112
while(j > 0 && j == cake[j]) --j;
113
if (j <= 0) break;
114
int i = j;
115
while (i >= 0 && cake[i] != j) --i;
116
if (i != 0)
{
117
reverse_cake(i);
118
result.push_back(i);
119
}
120
reverse_cake(j);
121
result.push_back(j);
122
--j;
123
}
124
cake.assign(cake_arr,cake_arr + size); //恢復原來的數組
125
cake.push_back(size); //多放一個烙餅,避免后面的邊界判斷
126
cake_swap[0] = 0; //假設第0步翻轉的烙餅編號為0
127
min_swap_init= get_min_swap();
128
return true;
129
}
130
131
int Pancake::run(const int cake_arr[], int size, bool show)
132

{
133
if (! init(cake_arr, size)) return 0;
134
int least_swap = 0;
135
//size = cake.size() - 1;
136
for (int i = 0; i < size; ++i)
137
if (cake[i] - cake[i + 1] + 1u > 2) ++least_swap;
138
if (get_min_swap() != least_swap) search_cake(size, 0, least_swap);
139
if (show) print();
140
return get_min_swap();
141
}
142
143
void Pancake::search_cake(int size, int step, int least_swap_old)
144

{
145
++count_search;
146
while (size > 1 && size - 1 == (int)cake[size - 1]) --size; //去除末尾已就位的烙餅
147
int *first = &cake_order[step * cake.size()];
148
int *last = first + size;
149
int *low = first, *high = first + size;
150
151
for (int pos = size - 1, last_swap = cake_swap[step++]; pos > 0; --pos)
{
152
if (pos == last_swap) continue;
153
int least_swap = least_swap_old ;
154
if (cake[pos] - cake[pos + 1] + 1u <= 2) ++least_swap;
155
if (cake[0] - cake[pos + 1] + 1u <= 2) --least_swap;
156
157
if (least_swap + step >= get_min_swap()) continue;
158
if (least_swap == 0)
{
159
cake_swap[step] = pos;
160
result.assign(&cake_swap[1], &cake_swap[step + 1]);
161
return;
162
}
163
164
//根據least_swap值大小,分別保存pos值,并先處理使least_swap_old減小1的翻轉
165
if (least_swap == least_swap_old) *low++ =pos;
166
else if (least_swap > least_swap_old) *--high =pos;
167
else next_search_cake(pos, size, step, least_swap);
168
}
169
170
//再處理使least_swap_old不變的翻轉
171
for(int *p = first; p < low; p++)
172
if (next_search_cake(*p, size, step, least_swap_old)) return;
173
174
//最后處理使least_swap_old增加1的翻轉
175
for(int *p = high; p < last; p++)
176
if (next_search_cake(*p, size, step, least_swap_old + 1)) return;
177
}
178
179
void Pancake::calc_range(int na, int nb)
180

{
181
if (na > nb || na <= 0) return;
182
clock_t ta = clock();
183
static std::vector<int> arr;
184
arr.resize(nb);
185
unsigned long long total_search = 0;
186
unsigned long long total_reverse = 0;
187
for (int j = na; j <= nb; ++j)
{
188
for (int i = 0; i < j; ++i) arr[i] = i;
189
int max = 0;
190
unsigned long long count_s = 0;
191
unsigned long long count_r = 0;
192
clock_t tb = clock();
193
while (std::next_permutation(&arr[0], &arr[j]))
{
194
int tmp = run(&arr[0],j,0);
195
if (tmp > max) max = tmp;
196
count_s += count_search;
197
count_r += count_reverse;
198
}
199
total_search += count_s;
200
total_reverse += count_r;
201
output(j, " ",2);
202
output(max," time: ",3);
203
output(clock() - tb," ms ",8);
204
cout << " search/reverse: " << count_s << "/" << count_r << "\n";
205
}
206
cout << " total search/reverse: " << total_search
207
<< "/" << total_reverse << "\n"
208
<< "time : " << clock() - ta << " ms\n";
209
}
210
211
int main()
212

{
213
int aa[10]=
{ 3,2,1,6,5,4,9,8,7,0};
214
//int ab[10]={ 4,8,3,1,5,2,9,6,7,0};
215
// int ac[]={1,0, 4, 3, 2};
216
Pancake cake;
217
cake.run(aa,10);
218
cake.process();
219
//cake.run(ab,10);
220
//cake.process();
221
//cake.run(ac,sizeof(ac)/sizeof(ac[0]));
222
//cake.process();
223
cake.calc_range(1,9);
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。