今天是第二天,繼續我們的征程。
題目
- 編寫代碼,把字符串中的每個空格替換為
%20
。例如,輸入"hello world.",則輸出"hello%20world."。 - 編寫代碼,給定系數n,求1+2+3+...+n的總和,即
∑
運算符 - 編寫代碼,觀察如下數列,給定系數n,求數列中的第n個數字(tips: 斐波那契數列)。
1 1 2 3 5 8 13 21 34 55 89 ...
字符替換
本題是將空格等特殊字符變為轉義字符的函數,常用于URL編碼中,用來避免URL中可能存在的字符歧義。
/**
* 判斷當前字符是否為普通字符
*/
public static boolean isPlainChar(char c) {
return (c >= '0' && c <= '9') ||
(c >= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
c == '!' || c == '$' || c == '-' || c == '.' || c == '+' ||
c == '*' || c == '\'' || c == '(' || c == ')' || c == ',';
}
public static char[] encodeMap(char c) {
//構造特殊字符映射表
String[] map = new String[256];
map[' '] = "%20";
map['/'] = "%2F";
map['?'] = "%3F";
map['%'] = "%25";
map['#'] = "%23";
map['&'] = "%26";
map['='] = "%3D";
return map[c].toCharArray();
}
public static char[] urlEncode(char source[]) {
int needLength = 0;
for (int i = 0; i < source.length; i++) {
char c = source[i];
if (isPlainChar(c)) {
needLength++;
} else {
needLength += encodeMap(c).length;
}
}
char result[] = new char[needLength];
int resultIndex = 0;
for (int i = 0; i < source.length; i++) {
char c = source[i];
if (isPlainChar(c)) {
result[resultIndex] = c;
resultIndex++;
} else {
char encodeStr[] = encodeMap(c);
System.arraycopy(encodeStr, 0, result, resultIndex, encodeStr.length);
resultIndex += encodeStr.length;
}
}
return result;
}
//===========
//測試代碼
public static void main(String args[]) {
char[] source = "hello world.".toCharArray();
System.out.println(urlEncode(source));
}
累加和 ∑
正向循環解題
要求n個數字的和,則需求出 f(1)+f(2)+...+f(n-1)+f(n)
。
本題中等差數列的公差為1,則f(1) == 1
,f(2) == 2...
public static int sigmaAdd(int n){
int sum = 0;
for (int i = 1;i<=n;i++){
sum+=i;
}
return sum;
}
逆向遞歸求解
通過觀察數列,不難看出數列的如下性質:
f(n) = f(n-1) + 1
我們可以將∑n的問題轉化為 f(n) + f(n-1) +...+ f(2) + f(1)
,則可以使用遞歸來求解
public static int sigmaAdd2(int n) {
if (n > 1) {
return n + sigmaAdd(--n);
} else {
return 1;
}
}
遞歸在算法中的應用非常廣泛,許多看似復雜的多重循環問題,都可以通過遞歸加中止條件寫出較為簡潔的代碼
斐波那契數列
遞歸算法
先來看斐波那契數列的性質:
- 當n=0時,f(n) = 0
- 當0=1時,f(n) = 1
- 當n>1時,f(n) = f(n-2) + f(n-1)
根據其性質,很容易通過遞歸寫出其代碼:
public static int fibonacci(int n) {
if (n <= 0) {
return 0;
} else if (n < 2) {
return 1;
} else {
return fibonacci(n - 1) + fibonacci(n - 2);
}
}
//===========
//測試代碼
public static void main(String args[]){
System.out.println(fibonacci(20));
}
遞歸的代碼雖然簡潔,但簡潔不代表簡單。以求得f(10)為例,需要先求得f(9)和f(8)。同樣,想求得f(9),需要先求得f(8)和f(7)...我們可以用樹形結構來表示這種依賴關系,如下圖所示。
不難發現,樹中有很多結點是重復的,而且重復的結點數會隨著n的增大而急劇增加,這意味著計算量會隨著n的增大而急劇增大。你可以試下用遞歸方式求斐波那契數列的第50項試試,感受一下這樣的遞歸會有多慢。
非遞歸算法
遞歸方法之所以慢,是因為重復計算太多,只需想辦法避免重復計算,即可加快其速度。比如我們可以把之前計算過的結果保存下來,便于下次計算。
比如先根據f(0)和f(1)得到f(2),再根據f(1)和f(2)得到f(3),每次結果均保留,依次類推即可得到第n項的值,其時間復雜度為O(n)。實現代碼如下。
public static long fibonacci2(int n) {
int fib[] = new int[]{0, 1};
if (n < 2) return fib[n];
long fib1 = 1;
long fib2 = 0;
long result = 0;
for (int i = 2; i <= n; i++) {
result = fib1 + fib2;
fib2 = fib1;
fib1 = result;
}
return result;
}
時間對比
基準 | 遞歸方式 | 非遞歸方式 |
---|---|---|
fib(20)耗時 | 0ms | 0ms |
fib(30)耗時 | 4ms | 0ms |
fib(40)耗時 | 383ms | 0ms |
fib(50)耗時 | 44269ms | 0ms |
通過表格可以看到,在時間方面非遞歸方式有著顯著的優勢。除了時間的開銷,遞歸過程中還會創建多個函數棧,每個函數棧都有自己的參數,返回值等信息,因此遞歸也會給棧的內存空間帶來一定的壓力。如StackOverflow
調用棧溢出異常就是由于棧空間不足引起的。
從思路上來講,遞歸采用的是自頂向下的方式,將一個個較大的問題分解為若干個較小的問題(如 f(10) = f(9) + f(8)),但小的問題可能會有多次重復計算。我們可以反其道而行之,采用自底向上的方式,先從小問題開始處理,記錄小問題的結果,然后根據小問題的結果來解答較大的問題,以此類推,得到特定問題的解。
擴展
通過今天的練習,我們分析了遞歸的一些優缺點。在編寫代碼時,要了解遞歸潛在的問題,在一些注重性能的場合,盡量采用循環來代替遞歸,從而提高程序的運行效率。
此外在面試中,編程題通常不會太直白的表現出來,面試官往往會將問題包裝一下,以此來考察我們的分析與建模能力,如下邊幾個問題。
- 上臺階問題,一共有n個臺階,每次可以上1階或2階,那么上到第n階有幾種方法。
- 小馬過河問題,河中有n塊石頭,小馬每次能跳過1塊或2塊石頭,那么跳過n塊石頭有幾種方法。
-
如下圖所示,用左邊的2*1的小矩形橫著或者豎著去覆蓋右邊2*8的大矩形,在不發生重疊的情況下,總共有多少種方法?
矩形覆蓋問題
擴展題答案
前兩個問題基本一致,我們先來分析問題。
假設有n個臺階,則第一步有兩種方法
- 若上1個臺階,則剩下n-1個臺階
- 若上2個臺階,則剩下n-2個臺階
以此類推,不難得出 f(n) = f(n-1)+ f(n-2)
這個式子,是不是很熟悉呢?對的,前兩個問題仍是斐波那契數列相關的應用題,有公式后不難得出其答案。
接下來看第三個問題。
我們先把2*8的覆蓋方法記為f(8)。用第一個2*1的小矩形去覆蓋大舉證的最左邊邊時有兩種選擇:
- 豎著放,則右邊還剩2*7的區域,記為f(7)
- 橫著放,則左下方也只能橫著放一個2*1的小舉證,右邊還剩下 2*6的區域,記為f(6)
因此f(8) = f(7) + f(6),此時可以看出,這仍然是斐波那契數列。
參考書目
《劍指offer》2.4.1