刷了一段時間劍指offer的算法題,收獲了很多,其中對遞歸的印象尤其深。遞歸給我一種四兩撥千斤的感覺。
背景
遞歸,一種能夠裝逼的編程技巧。一個過程或函數在其定義或說明中有直接或間接調用自身的一種方法,它通常把一個大型復雜的問題層層轉化為一個與原問題相似的規模較小的問題來求解,遞歸策略只需少量的程序就可描述出解題過程所需要的多次重復計算,大大地減少了程序的代碼量,在算法題中有廣泛的應用。
掌握遞歸的4個要點
寫好一個遞歸算法,主要是把握好如下三個方面:
1. 正確的時機退出。
遞歸很容易造成死循環,而造成死循環的原因就是沒有正確地退出遞歸。
2. 遞歸調用語句的位置
遞歸函數中,位于遞歸調用前的語句和各級被調函數具有相同的順序.如果在遞歸調用語句后面的語句,則按逆序執行,下面會有具體講解。
3. 需要重復執行的邏輯。
遞歸的精髓就是loop,也是遞歸的核心思想,出現重復邏輯一定是必不可少的。但是重復的邏輯需要抽象。抽象出來一個干凈利落的可循環邏輯對程序編寫的幫助很大。
4. 控制邏輯邊界。
控制邊界保證了程序在正確的范圍運行。因為抽象出來的邏輯在一個正確范圍內保證其可以遞歸執行。寫程序也經常因為邊界把控的不準確容易留下bug,比如說數組越界會造成Crash。合理控制邊界,可以提高遞歸效率,避免不必要的調用,進一步還可以幫助遞歸正確退出。
如何正確退出遞歸
在遞歸調用語句前面加上邏輯判斷,在特定情況下不再遞歸調用,以此結束遞歸。舉個栗子,讓程序員寫一個按順序打印數組,普通程序員一般會咔咔咔寫下如下代碼:
std::vector<int>array = {1,2,3,4,5,6,7,8,9};
for (int i = 0; i < array.size() ; i++) {
printf("%d",array[i]);
}
// 輸出結果:123456789
上面的代碼一目了然,這當然不是我們需要的效果,而文藝程序員是怎么干的?下面的例子使用遞歸的方式按順序打印數組,并且準確地在邊界處結束遞歸。
int main(int argc, const char * argv[]) {
std::vector<int>array = {1,2,3,4,5,6,7,8,9};
printfNumber(array, 0);
return 0;
}
// 順序打印
void printfNumber(std::vector<int>array, int i) {
if (i == array.size()) return;
printf("%d",array[i++]);
printfNumber(array, i);
}
// 輸出結果:123456789
上面的代碼乍一看遞歸的方式比使用for循環的方式需要更多的代碼,但是在比較復雜的調用中,遞歸才是簡潔代碼,四兩撥千斤的高手。
遞歸調用語句的位置
遞歸調用語句的位置十分敏感,決定了其他語句什么時候執行,上面我們知道了如何按順序打印數組,現在我們只需要將printf語句移至遞歸語句的后面,執行printf語句的順序將倒置過來。
// 逆序打印
void reversePrintfNumber(std::vector<int>array, int i) {
if (i == array.size()) return;
reversePrintfNumber(array, ++i);
printf("%d",array[--i]);
}
// 輸出結果reverse:987654321
總結下來就是:在循環調用中,遞歸語句前面的函數都是按順序執行,遞歸語句后面的函數都是逆序執行(也就是在最后一個循環的語句反而最先被調用),就是這么簡單。
找出重復執行的邏輯
翻轉二叉樹很好地詮釋了遞歸的魅力,只需一個臨時變量,幫助左右子樹交換即可,子樹的子樹翻轉交給下一個遞歸循環過程。
/*
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
TreeNode(int x) :
val(x), left(NULL), right(NULL) {
}
};*/
class Solution {
public:
// 翻轉二叉樹
void Mirror(TreeNode *pRoot) {
if (pRoot == NULL) return;
TreeNode *tempNode = pRoot->left;
pRoot->left = pRoot->right;
pRoot->right = tempNode;
// 翻轉左子樹
Mirror(pRoot->left);
// 翻轉右子樹
Mirror(pRoot->right);
}
};
控制邏輯邊界
查找機器人運動范圍(劍指offer全文最后一道算法題),每次基于當前點,可以向上下左右四個方向移動,已經走過的路徑不再次計數。而下面的例子加入了移動方向進行判斷,如果剛剛是走過得位置,則不再去探索,同時已經走過的路徑也不再進行探索,這樣可以減少不必要的重復操作,提高運行效率,這就是合理控制邏輯邊界。這里使用多種邏輯邊界組合幫助遞歸正確退出。
#pragma mark - 記錄機器人的運動范圍
int movingCount(int threshold, int rows, int cols)
{
for (int i = 0; i < rows; i++) {
std::vector<int> row;
for (int j = 0; j < cols; j++) {
row.push_back(0);
}
recordHistory.push_back(row);
}
recordPath(threshold, 2,0,0);
printf("----%d",count);
return count;
}
/* 記錄路徑
direct
1
4 0 2
3
*/
int count = 0;
std::vector<std::vector<int>> recordHistory;
void recordPath(int threshold, int direct, int positionX, int positionY) {
if (recordHistory[positionY][positionX] != 1 && getSum(positionX)+getSum(positionY) <= threshold) {
recordHistory[positionY][positionX] = 1;
std::vector<int> rowCount = recordHistory[0];
count++;
// 上
if (positionY >= 1 && direct != 3) {
recordPath(threshold, 1, positionX,positionY-1);
}
// 右
if (positionX < rowCount.size()-1 && direct != 4) {
recordPath(threshold, 2, positionX+1,positionY);
}
// 下
if (positionY < recordHistory.size()-1 && direct != 1) {
recordPath(threshold, 3, positionX,positionY+1);
}
// 左
if (positionX >= 1 && direct != 2) {
recordPath(threshold, 4, positionX-1,positionY);
}
}
}
//計算位置的數值
int getSum(int number) {
int sum=0;
while(number>0) {
sum+=number%10;
number/=10;
}
return sum;
}
最后,雖然遞歸有邏輯簡單,代碼清晰的優點,但是并不建議首先考慮用遞歸解決問題。好的程序還是需要通過深入解析寫出更快捷、更巧妙的算法,而不是把問題交給機器暴力解決。當然遞歸加剪枝可以避開一些不必要的搜索,不過大部分還是有替代的辦法。
我不認識的某些遠古老司機對遞歸的評價
老司機A:遞歸可讀性好這一點,對于初學者可能會反對。實際上遞歸的代碼更清晰,但是從學習的角度要理解遞歸真正發生的什么,是如何調用的,調用層次和路線,調用堆棧中保存了什么,可能是不容易。但是不可否認遞歸的代碼更簡潔。一般來說,一個人可能很容易的寫出前中后序的二叉樹遍歷的遞歸算法,要寫出相應的非遞歸算法就比較考驗水平了,恐怕至少一半的人搞不定。所以說遞歸代碼更簡潔明了
老司機B:遞歸其實是方便了程序員難為了機器。它只要得到數學公式就能很方便的寫出程序。優點就是易理解,容易編程。但遞歸是用棧機制實現的(c++),每深入一層,都要占去一塊棧數據區域,對嵌套層數深的一些算法,遞歸會力不從心,空間上會以內存崩潰而告終,而且遞歸也帶來了大量的函數調用,這也有許多額外的時間開銷。所以在深度大時,它的時空性就不好了。
重要指示
我追求的是極限裝逼,開玩笑啦!以上內容是我自己的理解,僅供參考,現在圈內環境太惡劣,寫個文章都有可能被針對,連大神都不能避免,甚至有公開撕逼、針鋒相對的,太可怕了??。