咱們一般所說的內存空間,包含了兩個部分:棧空間(Stack space)和堆空間(Heap space)html
當一個程序在執行的時候,操做系統爲了讓進程可使用一些固定的不被其餘進程侵佔的空間用於進行函數調用,遞歸等操做,會開闢一個固定大小的空間(好比 8M)給一個進程使用。這個空間不會太大,不然內存的利用率就很低。這個空間就是咱們說的棧空間,Stack space。算法
咱們一般所說的棧溢出(Stack Overflow)是指在函數調用,或者遞歸調用的時候,開闢了過多的內存,超過了操做系統餘留的那個很小的固定空間致使的。那麼哪些部分的空間會被歸入棧空間呢?棧空間主要包含以下幾個部分:編程
咱們來看下面的這段代碼:
Java:數組
public int f(int n) { int[] nums = new int[n]; int sum = 0; for (int i = 0; i < n; i++) { nums[i] = i; sum += i; } return sum; }
Python:markdown
def f(n): nums = [0]*n # 至關於Java中的new int[n] sum = 0 for i in range(n): nums[i] = i sum += i return sum
C++:編程語言
int f(int n) { int *nums = new int[n]; int sum = 0; for (int i = 0; i < n; i++) { nums[i] = i; sum += i; } return sum; }
根據咱們的定義,參數 n,最後的函數返回值f,局部變量 sum 都很容易的能夠確認是放在棧空間裏的。那麼主要的難點在 nums。函數
這裏 nums 能夠理解爲兩個部分:優化
這裏 nums 這個變量自己,是存儲在棧空間的,由於他是一個局部變量。可是 nums 裏存儲的 n 個整數,是存儲在堆空間
裏的,Heap space。他並不佔用棧空間,並不會致使棧溢出。spa
在大多數的編程語言中,特別是 Java, Python 這樣的語言中,萬物皆對象,基本上每一個變量都包含了變量本身和變量所指向的內存空間兩個部分的邏輯含義。操作系統
來看這個例子:
Java:
public int[] copy(int[] nums) { int[] arr = new int[nums.length]; for (int i = 0; i < nums.length; i++) { arr[i] = nums[i] } return arr; } public void main() { int[] nums = new int[10]; nums[0] = 1; int[] new_nums = copy(nums); }
Python:
def copy(nums): arr = [0]*len(nums) # 至關於Java中的new int[nums.length] for i in range(len(nums)): arr[i] = nums[i] return arr # 用list comprehension實現一樣功能 def copy(nums): arr = [x for x in nums] return arr # 如下至關於Java中的main函數 if __name__ == "__main__": nums = [0]*10 nums[0] = 1 new_nums = copy(nums)
C++:
int* copy(int nums[], int length) { int *arr = new int[length]; for (int i = 0; i < length; i++) { arr[i] = nums[i]; } return arr; } int main() { int *nums = new int[10]; nums[0] = 1; int *new_nums = copy(nums, 10); return 0; }
在 copy 這個函數中,arr 是一個局部變量,他在 copy 函數執行結束以後就會被銷燬。可是裏面 new 出來的新數組並不會被銷燬。
這樣,在 main 函數裏,new_nums 裏纔會有被複制後的數組。因此能夠發現一個特色:
棧空間裏存儲的內容,會在函數執行結束的時候被撤回
簡而言之能夠這麼區別棧空間和堆空間:
new 出來的就放在堆空間,其餘都是棧空間
遞歸深度就是遞歸函數在內存中,同時存在的最大次數。
例以下面這段求階乘的代碼:
Java:
int factorial(int n) { if (n == 1) { return 1; } return factorial(n - 1) * n; }
Python:
def factorial(n): if n == 1: return 1 return factorial(n-1) * n
C++:
int factorial(int n) { if (n == 1) { return 1; } return factorial(n - 1) * n; }
當n=100
時,遞歸深度就是100。通常來講,咱們更關心遞歸深度的數量級,
在該階乘函數中遞歸深度是O(n),而在二分查找中,遞歸深度是O(log(n))。在後面的教程中,咱們還會學到基於遞歸的快速排序、歸併排序、以及平衡二叉樹的遍歷,這些的遞歸深度都是(O(log(n))。注意,此處說的是遞歸深度,而並不是時間複雜度。
首先,函數自己也是在內存中佔空間的,主要用於存儲傳遞的參數,以及調用代碼的返回地址。
函數的調用,會在內存的棧空間中開闢新空間,來存放子函數。遞歸函數更是會不斷佔用棧空間,例如該階乘函數,展開到最後n=1
時,內存中會存在factorial(100), factorial(99), factorial(98) ... factorial(1)
這些函數,它們從棧底向棧頂方向不斷擴展。
當遞歸過深時,棧空間會被耗盡,這時就沒法開闢新的函數,會報出stack overflow
這樣的錯誤。
因此,在考慮空間複雜度時,遞歸函數的深度也是要考慮進去的。
Follow up:
尾遞歸:若遞歸函數中,遞歸調用是整個函數體中最後的語句,且它的返回值不屬於表達式的一部分時,這個遞歸調用就是尾遞歸。(上例 factorial 函數知足前者,但不知足後者,故不是尾遞歸函數)
尾遞歸函數的特色是:在遞歸展開後該函數再也不作任何操做,這意味着該函數能夠不等子函數執行完,本身直接銷燬,這樣就再也不佔用內存。一個遞歸深度O(n)的尾遞歸函數,能夠作到只佔用O(1)空間。這極大的優化了棧空間的利用。
但要注意,這種內存優化是由編譯器決定是否要採起的,不過大多數現代的編譯器會利用這種特色自動生成優化的代碼。在實際工做當中,儘可能寫尾遞歸函數,是很好的習慣。 而在算法題當中,計算空間複雜度時,建議仍是老老實實地算空間複雜度了,尾遞歸這種優化提一下也是能夠,但別太在乎。