3n + 1 問題——引發的緩存思考
問題描述
有這樣一個規律:對任意大於 1 的自然數 n,如果 n 爲奇數,將其變化爲 3n + 1;如果 n 爲偶數,將其變化爲 n/2。經過若干次變化後,一定會使 n 變爲 1。
如果 n = 5:
- 5 -> 16 -> 8 -> 4 -> 2 -> 1
如果 n = 3:
- 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1
現在,輸入一個整數 n,請計算出總的變化次數。例:n = 5,l = 5;n = 3,l = 7。(l 表示變化次數)
方法 1(實時計算)
最簡單處理方式是遞歸。由於每一次 n/2 或者 3n-1 操作後,變化次數 l 會加 1,可以得出公式模型爲:f(n) = f(n-1) + 1。遞歸函數必須有出口,當 n = 1 時,會進入 1 -> 4 -> 2 -> 1 -> 4 … 這樣的死循環。所以出口條件是 n <= 1。最終得到代碼如下。
size_t solve_3nplus1(size_t n)
{
if (n <= 1)
return 0;
return solve_3nplus1( (n%2 == 0) ? n/2 : 3*n+1 ) + 1;
}
使用:
int main()
{
cout << solve_3nplus1(3) << endl;
cout << solve_3nplus1(5) << endl;
}
/* 輸出:*/
7
5
方法 2(結果緩存)
事實上,你會發現 方法1 是一種實時計算。也就是說,第一次 n = 3 的時候,程序會 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1,第二次 n = 3 的時候,程序還是會 3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1,於是這中間多了許多重複計算。
提升程序效率的一種、衆所周知的方式是緩存。常見的緩存處理方式是,在覈心處理邏輯(solve_3nplus1)之上,套一層緩存邏輯(solve_3nplus1_cache)。緩存層負責將計算結果保存下來,每次外部接口請求時,緩存層會先去緩存裏看看,“我有沒有計算過 n = 3 呀”,有就直接將結果返回;沒有,那麼調用 solve_3nplus1 計算出結果。向外部返回結果的同時,拷貝一份結果放在緩存裏。
完整代碼如下:
/* 計算邏輯 */
size_t solve_3nplus1(size_t n)
{
if (n <= 1)
return 0;
return solve_3nplus1( (n%2 == 0) ? n/2 : 3*n+1 ) + 1;
}
/* 緩存邏輯 */
size_t solve_3nplus1_cache(vector<size_t>& v, size_t n)
{
size_t l = 0;
/* 避免越界訪問 */
while (n >= v.size()) {
n = (n%2 == 0) ? n/2 : 3*n+1;
++l;
}
if (v[n] == 0) {
cout << "實時計算... n = " << n << endl;
v[n] = solve_3nplus1(n);
}
else
cout << "緩存中取得... n = " << n << endl;
return v[n] + l;
}
用簡單的測試用例進行功能測試:
int main()
{
vector<size_t> v(6);
cout << solve_3nplus1_cache(v, 3) << endl;
cout << solve_3nplus1_cache(v, 5) << endl;
cout << solve_3nplus1_cache(v, 3) << endl;
}
/* 輸出:*/
實時計算... n = 3
7
實時計算... n = 5
5
緩存中取得... n = 3
7
瞧上去功能是實現了,但是哪裏不對勁。得捋捋:
- 3 -> 1變化中,完整過程是:3 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1。
- 5 -> 1變化中,完整過程是:5 -> 16 -> 8 -> 4 -> 2 -> 1。
看得出,n = 3 與 n = 5 時有部分是重合的,更準確說,n = 3 包含了 n = 5。結論顯而易見:程序還是在做重複計算。這是因爲,上述代碼僅僅對計算結果緩存,而非計算過程。
方法 3(過程緩存)
3n + 1 問題顯然是符合過程緩存的,因爲過程中產生的數據能夠推導出目標結果(這句話會不會太抽象?總之結合 n = 3、n = 5 就能明瞭許多)。
size_t solve_3nplus1_cache(vector<size_t>& v, size_t n);
size_t solve_3nplus1(vector<size_t>& v, size_t n)
{
v[1] = 1; /* 設置出口 */
return solve_3nplus1_cache(v, n) - 1; /* 由於v[1]=1, 所以最後需要減1 */
}
size_t solve_3nplus1_cache(vector<size_t>& v, size_t n)
{
size_t l = 0;
/* 防越界 */
while (n >= v.size()) {
n = (n%2 == 0) ? n/2 : 3*n+1;
++l;
}
if (v[n] == 0)
v[n] = solve_3nplus1_cache(v, (n%2 == 0) ? n/2 : 3*n+1) + 1;
else if (n != 1) /* 避免 n = 1 的影響 */
cout << "從緩存中取得... n = " << n << endl;
return v[n] + l;
}
與 方法2 的不同之處是,方法3 把遞歸過程中計算出來的所有數據都保存下來,而不是隻保留最終結果。
測試代碼:
int main()
{
vector<size_t> v(20);
cout << solve_3nplus1(v, 3) << endl;
cout << solve_3nplus1(v, 5) << endl;
cout << solve_3nplus1(v, 3) << endl;
cout << "v[i] = ";
for (int i=0; i<v.size(); ++i) {
cout << v[i] << " ";
}
cout << endl;
}
/* 輸出:*/
7
從緩存中取得... n = 5
5
從緩存中取得... n = 3
7
v[i] = 0 1 2 8 3 6 0 0 4 0 7 0 0 0 0 0 5 0 0 0
* *
- 注:容器 v 中存儲的次數 l 要比實際值大 1。
方法3 比 方法2 在緩存上做得更極致。但是不可避免的是,方法3 中,緩存層與業務層基本耦合在一起,代碼可閱讀性較差。雖說不是不可以優化,但對程序員的編碼能力要求更高了。
最後
3n + 1 問題可以說是一個很簡單的問題,但在過程中引發的“緩存”思想卻很重要。
—— 其實更像是無聊人的胡思亂想而已!但少閒人如吾一人者耳……