返回首页返回书籍《Hello 算法》
目录

目录 · CONTENTS

  1. 01序
  2. 02前言
  3. 03关于本书
  4. 04如何使用本书
  5. 05小结
  6. 06初识算法
  7. 07算法无处不在
  8. 08算法是什么
  9. 09小结
  10. 10复杂度分析
  11. 11算法效率评估
  12. 12迭代与递归
  13. 13时间复杂度
  14. 14空间复杂度
  15. 15小结
  16. 16练习
  17. 17数据结构
  18. 18数据结构分类
  19. 19基本数据类型
  20. 20数字编码
  21. 21字符编码
  22. 22小结
  23. 23练习
  24. 24数组与链表
  25. 25数组
  26. 26链表
  27. 27列表
  28. 28内存与缓存
  29. 29小结
  30. 30练习
  31. 31栈与队列
  32. 32栈
  33. 33队列
  34. 34双向队列
  35. 35小结
  36. 36练习
  37. 37哈希表
  38. 38哈希表
  39. 39哈希冲突
  40. 40哈希算法
  41. 41小结
  42. 42练习
  43. 43树
  44. 44二叉树
  45. 45二叉树遍历
  46. 46二叉树数组表示
  47. 47二叉搜索树
  48. 48AVL 树
  49. 49小结
  50. 50练习
  51. 51堆
  52. 52堆
  53. 53建堆操作
  54. 54Top-k 问题
  55. 55小结
  56. 56练习
  57. 57图
  58. 58图
  59. 59图的基础操作
  60. 60图的遍历
  61. 61小结
  62. 62练习
  63. 63搜索
  64. 64二分查找
  65. 65二分查找插入点
  66. 66二分查找边界
  67. 67哈希优化策略
  68. 68重识搜索算法
  69. 69小结
  70. 70练习
  71. 71排序
  72. 72排序算法
  73. 73选择排序
  74. 74冒泡排序
  75. 75插入排序
  76. 76快速排序
  77. 77归并排序
  78. 78堆排序
  79. 79桶排序
  80. 80计数排序
  81. 81基数排序
  82. 82小结
  83. 83练习
  84. 84分治
  85. 85分治算法
  86. 86分治搜索策略
  87. 87构建二叉树问题
  88. 88汉诺塔问题
  89. 89小结
  90. 90练习
  91. 91回溯
  92. 92回溯算法
  93. 93全排列问题
  94. 94子集和问题
  95. 95n 皇后问题
  96. 96小结
  97. 97练习
  98. 98动态规划
  99. 99初探动态规划
  100. 100动态规划问题特性
  101. 101动态规划解题思路
  102. 1020-1 背包问题
  103. 103完全背包问题
  104. 104编辑距离问题
  105. 105小结
  106. 106练习
  107. 107贪心
  108. 108贪心算法
  109. 109分数背包问题
  110. 110最大容量问题
  111. 111最大切分乘积问题
  112. 112小结
  113. 113练习
  114. 114附录
  115. 115编程环境安装
  116. 116一起参与创作
  117. 117术语表
  118. 118参考文献
  119. 119纸质书
第 14 / 119 节

第 14 节 · SECTION 14

空间复杂度

空间复杂度(space complexity)用于衡量算法占用内存空间随着数据量变大时的增长趋势。这个概念与时间复杂度非常类似,只需将“运行时间”替换为“占用内存空间”。

算法相关空间

算法在运行过程中使用的内存空间主要包括以下几种。

  • 输入空间:用于存储算法的输入数据。
  • 暂存空间:用于存储算法在运行过程中的变量、对象、函数上下文等数据。
  • 输出空间:用于存储算法的输出数据。

一般情况下,空间复杂度的统计范围是“暂存空间”加上“输出空间”。

暂存空间可以进一步划分为三个部分。

  • 暂存数据:用于保存算法运行过程中的各种常量、变量、对象等。
  • 栈帧空间:用于保存调用函数的上下文数据。系统在每次调用函数时都会在栈顶部创建一个栈帧,函数返回后,栈帧空间会被释放。
  • 指令空间:用于保存编译后的程序指令,在实际统计中通常忽略不计。

在分析一段程序的空间复杂度时,我们通常统计暂存数据、栈帧空间和输出数据三部分,如下图所示。

算法使用的相关空间

相关代码如下:

Python

class Node:
    """类"""
    def __init__(self, x: int):
        self.val: int = x              # 节点值
        self.next: Node | None = None  # 指向下一节点的引用

def function() -> int:
    """函数"""
    # 执行某些操作...
    return 0

def algorithm(n) -> int:  # 输入数据
    A = 0                 # 暂存数据(常量,一般用大写字母表示)
    b = 0                 # 暂存数据(变量)
    node = Node(0)        # 暂存数据(对象)
    c = function()        # 栈帧空间(调用函数)
    return A + b + c      # 输出数据

C++

/* 结构体 */
struct Node {
    int val;
    Node *next;
    Node(int x) : val(x), next(nullptr) {}
};

/* 函数 */
int func() {
    // 执行某些操作...
    return 0;
}

int algorithm(int n) {        // 输入数据
    const int a = 0;          // 暂存数据(常量)
    int b = 0;                // 暂存数据(变量)
    Node* node = new Node(0); // 暂存数据(对象)
    int c = func();           // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

Java

/* 类 */
class Node {
    int val;
    Node next;
    Node(int x) { val = x; }
}

/* 函数 */
int function() {
    // 执行某些操作...
    return 0;
}

int algorithm(int n) {        // 输入数据
    final int a = 0;          // 暂存数据(常量)
    int b = 0;                // 暂存数据(变量)
    Node node = new Node(0);  // 暂存数据(对象)
    int c = function();       // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

C#

/* 类 */
class Node(int x) {
    int val = x;
    Node next;
}

/* 函数 */
int Function() {
    // 执行某些操作...
    return 0;
}

int Algorithm(int n) {        // 输入数据
    const int a = 0;          // 暂存数据(常量)
    int b = 0;                // 暂存数据(变量)
    Node node = new(0);       // 暂存数据(对象)
    int c = Function();       // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

Go

/* 结构体 */
type node struct {
    val  int
    next *node
}

/* 创建 node 结构体  */
func newNode(val int) *node {
    return &node{val: val}
}

/* 函数 */
func function() int {
    // 执行某些操作...
    return 0
}

func algorithm(n int) int { // 输入数据
    const a = 0             // 暂存数据(常量)
    b := 0                  // 暂存数据(变量)
    newNode(0)              // 暂存数据(对象)
    c := function()         // 栈帧空间(调用函数)
    return a + b + c        // 输出数据
}

Swift

/* 类 */
class Node {
    var val: Int
    var next: Node?

    init(x: Int) {
        val = x
    }
}

/* 函数 */
func function() -> Int {
    // 执行某些操作...
    return 0
}

func algorithm(n: Int) -> Int { // 输入数据
    let a = 0             // 暂存数据(常量)
    var b = 0             // 暂存数据(变量)
    let node = Node(x: 0) // 暂存数据(对象)
    let c = function()    // 栈帧空间(调用函数)
    return a + b + c      // 输出数据
}

JS

/* 类 */
class Node {
    val;
    next;
    constructor(val) {
        this.val = val === undefined ? 0 : val; // 节点值
        this.next = null;                       // 指向下一节点的引用
    }
}

/* 函数 */
function constFunc() {
    // 执行某些操作
    return 0;
}

function algorithm(n) {       // 输入数据
    const a = 0;              // 暂存数据(常量)
    let b = 0;                // 暂存数据(变量)
    const node = new Node(0); // 暂存数据(对象)
    const c = constFunc();    // 栈帧空间(调用函数)
    return a + b + c;         // 输出数据
}

TS

/* 类 */
class Node {
    val: number;
    next: Node | null;
    constructor(val?: number) {
        this.val = val === undefined ? 0 : val; // 节点值
        this.next = null;                       // 指向下一节点的引用
    }
}

/* 函数 */
function constFunc(): number {
    // 执行某些操作
    return 0;
}

function algorithm(n: number): number { // 输入数据
    const a = 0;                        // 暂存数据(常量)
    let b = 0;                          // 暂存数据(变量)
    const node = new Node(0);           // 暂存数据(对象)
    const c = constFunc();              // 栈帧空间(调用函数)
    return a + b + c;                   // 输出数据
}

Dart

/* 类 */
class Node {
  int val;
  Node next;
  Node(this.val, [this.next]);
}

/* 函数 */
int function() {
  // 执行某些操作...
  return 0;
}

int algorithm(int n) {  // 输入数据
  const int a = 0;      // 暂存数据(常量)
  int b = 0;            // 暂存数据(变量)
  Node node = Node(0);  // 暂存数据(对象)
  int c = function();   // 栈帧空间(调用函数)
  return a + b + c;     // 输出数据
}

Rust

use std::rc::Rc;
use std::cell::RefCell;

/* 结构体 */
struct Node {
    val: i32,
    next: Option<Rc<RefCell<Node>>>,
}

/* 创建 Node 结构体 */
impl Node {
    fn new(val: i32) -> Self {
        Self { val: val, next: None }
    }
}

/* 函数 */
fn function() -> i32 {      
    // 执行某些操作...
    return 0;
}

fn algorithm(n: i32) -> i32 {       // 输入数据
    const a: i32 = 0;               // 暂存数据(常量)
    let mut b = 0;                  // 暂存数据(变量)
    let node = Node::new(0);        // 暂存数据(对象)
    let c = function();             // 栈帧空间(调用函数)
    return a + b + c;               // 输出数据
}

C

/* 函数 */
int func() {
    // 执行某些操作...
    return 0;
}

int algorithm(int n) { // 输入数据
    const int a = 0;   // 暂存数据(常量)
    int b = 0;         // 暂存数据(变量)
    int c = func();    // 栈帧空间(调用函数)
    return a + b + c;  // 输出数据
}

Kotlin

/* 类 */
class Node(var _val: Int) {
    var next: Node? = null
}

/* 函数 */
fun function(): Int {
    // 执行某些操作...
    return 0
}

fun algorithm(n: Int): Int { // 输入数据
    val a = 0                // 暂存数据(常量)
    var b = 0                // 暂存数据(变量)
    val node = Node(0)       // 暂存数据(对象)
    val c = function()       // 栈帧空间(调用函数)
    return a + b + c         // 输出数据
}

Ruby

### 类 ###
class Node
    attr_accessor :val      # 节点值
    attr_accessor :next     # 指向下一节点的引用

    def initialize(x)
        @val = x
    end
end

### 函数 ###
def function
    # 执行某些操作...
    0
end

### 算法 ###
def algorithm(n)        # 输入数据
    a = 0               # 暂存数据(常量)
    b = 0               # 暂存数据(变量)
    node = Node.new(0)  # 暂存数据(对象)
    c = function        # 栈帧空间(调用函数)
    a + b + c           # 输出数据
end

推算方法

空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。

而与时间复杂度不同的是,我们通常只关注最差空间复杂度。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。

观察以下代码,最差空间复杂度中的“最差”有两层含义。

  1. 以最差输入数据为准:当 $n < 10$ 时,空间复杂度为 $O(1)$ ;但当 $n > 10$ 时,初始化的数组 nums 占用 $O(n)$ 空间,因此最差空间复杂度为 $O(n)$ 。
  2. 以算法运行中的峰值内存为准:例如,程序在执行最后一行之前,占用 $O(1)$ 空间;当初始化数组 nums 时,程序占用 $O(n)$ 空间,因此最差空间复杂度为 $O(n)$ 。

Python

def algorithm(n: int):
    a = 0               # O(1)
    b = [0] * 10000     # O(1)
    if n > 10:
        nums = [0] * n  # O(n)

C++

void algorithm(int n) {
    int a = 0;               // O(1)
    vector<int> b(10000);    // O(1)
    if (n > 10)
        vector<int> nums(n); // O(n)
}

Java

void algorithm(int n) {
    int a = 0;                   // O(1)
    int[] b = new int[10000];    // O(1)
    if (n > 10)
        int[] nums = new int[n]; // O(n)
}

C#

void Algorithm(int n) {
    int a = 0;                   // O(1)
    int[] b = new int[10000];    // O(1)
    if (n > 10) {
        int[] nums = new int[n]; // O(n)
    }
}

Go

func algorithm(n int) {
    a := 0                      // O(1)
    b := make([]int, 10000)     // O(1)
    var nums []int
    if n > 10 {
        nums := make([]int, n)  // O(n)
    }
    fmt.Println(a, b, nums)
}

Swift

func algorithm(n: Int) {
    let a = 0 // O(1)
    let b = Array(repeating: 0, count: 10000) // O(1)
    if n > 10 {
        let nums = Array(repeating: 0, count: n) // O(n)
    }
}

JS

function algorithm(n) {
    const a = 0;                   // O(1)
    const b = new Array(10000);    // O(1)
    if (n > 10) {
        const nums = new Array(n); // O(n)
    }
}

TS

function algorithm(n: number): void {
    const a = 0;                   // O(1)
    const b = new Array(10000);    // O(1)
    if (n > 10) {
        const nums = new Array(n); // O(n)
    }
}

Dart

void algorithm(int n) {
  int a = 0;                            // O(1)
  List<int> b = List.filled(10000, 0);  // O(1)
  if (n > 10) {
    List<int> nums = List.filled(n, 0); // O(n)
  }
}

Rust

fn algorithm(n: i32) {
    let a = 0;                              // O(1)
    let b = [0; 10000];                     // O(1)
    if n > 10 {
        let nums = vec![0; n as usize];     // O(n)
    }
}

C

void algorithm(int n) {
    int a = 0;               // O(1)
    int b[10000];            // O(1)
    if (n > 10)
        int nums[n] = {0};   // O(n)
}

Kotlin

fun algorithm(n: Int) {
    val a = 0                    // O(1)
    val b = IntArray(10000)      // O(1)
    if (n > 10) {
        val nums = IntArray(n)   // O(n)
    }
}

Ruby

def algorithm(n)
    a = 0                           # O(1)
    b = Array.new(10000)            # O(1)
    nums = Array.new(n) if n > 10   # O(n)
end

在递归函数中,需要注意统计栈帧空间。观察以下代码:

Python

def function() -> int:
    # 执行某些操作
    return 0

def loop(n: int):
    """循环的空间复杂度为 O(1)"""
    for _ in range(n):
        function()

def recur(n: int):
    """递归的空间复杂度为 O(n)"""
    if n == 1:
        return
    return recur(n - 1)

C++

int func() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
    for (int i = 0; i < n; i++) {
        func();
    }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
    if (n == 1) return;
    recur(n - 1);
}

Java

int function() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
    for (int i = 0; i < n; i++) {
        function();
    }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
    if (n == 1) return;
    recur(n - 1);
}

C#

int Function() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void Loop(int n) {
    for (int i = 0; i < n; i++) {
        Function();
    }
}
/* 递归的空间复杂度为 O(n) */
int Recur(int n) {
    if (n == 1) return 1;
    return Recur(n - 1);
}

Go

func function() int {
    // 执行某些操作
    return 0
}

/* 循环的空间复杂度为 O(1) */
func loop(n int) {
    for i := 0; i < n; i++ {
        function()
    }
}

/* 递归的空间复杂度为 O(n) */
func recur(n int) {
    if n == 1 {
        return
    }
    recur(n - 1)
}

Swift

@discardableResult
func function() -> Int {
    // 执行某些操作
    return 0
}

/* 循环的空间复杂度为 O(1) */
func loop(n: Int) {
    for _ in 0 ..< n {
        function()
    }
}

/* 递归的空间复杂度为 O(n) */
func recur(n: Int) {
    if n == 1 {
        return
    }
    recur(n: n - 1)
}

JS

function constFunc() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
function loop(n) {
    for (let i = 0; i < n; i++) {
        constFunc();
    }
}
/* 递归的空间复杂度为 O(n) */
function recur(n) {
    if (n === 1) return;
    return recur(n - 1);
}

TS

function constFunc(): number {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
function loop(n: number): void {
    for (let i = 0; i < n; i++) {
        constFunc();
    }
}
/* 递归的空间复杂度为 O(n) */
function recur(n: number): void {
    if (n === 1) return;
    return recur(n - 1);
}

Dart

int function() {
  // 执行某些操作
  return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
  for (int i = 0; i < n; i++) {
    function();
  }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
  if (n == 1) return;
  recur(n - 1);
}

Rust

fn function() -> i32 {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
fn loop(n: i32) {
    for i in 0..n {
        function();
    }
}
/* 递归的空间复杂度为 O(n) */
fn recur(n: i32) {
    if n == 1 {
        return;
    }
    recur(n - 1);
}

C

int func() {
    // 执行某些操作
    return 0;
}
/* 循环的空间复杂度为 O(1) */
void loop(int n) {
    for (int i = 0; i < n; i++) {
        func();
    }
}
/* 递归的空间复杂度为 O(n) */
void recur(int n) {
    if (n == 1) return;
    recur(n - 1);
}

Kotlin

fun function(): Int {
    // 执行某些操作
    return 0
}
/* 循环的空间复杂度为 O(1) */
fun loop(n: Int) {
    for (i in 0..<n) {
        function()
    }
}
/* 递归的空间复杂度为 O(n) */
fun recur(n: Int) {
    if (n == 1) return
    return recur(n - 1)
}

Ruby

def function
    # 执行某些操作
    0
end

### 循环的空间复杂度为 O(1) ###
def loop(n)
    (0...n).each { function }
end

### 递归的空间复杂度为 O(n) ###
def recur(n)
    return if n == 1
    recur(n - 1)
end

函数 loop() 和 recur() 的时间复杂度都为 $O(n)$ ,但空间复杂度不同。

  • 函数 loop() 在循环中调用了 $n$ 次 function() ,每轮中的 function() 都返回并释放了栈帧空间,因此空间复杂度仍为 $O(1)$ 。
  • 递归函数 recur() 在运行过程中会同时存在 $n$ 个未返回的 recur() ,从而占用 $O(n)$ 的栈帧空间。

常见类型

设输入数据大小为 $n$ ,下图展示了常见的空间复杂度类型(从低到高排列)。

$$ \begin{aligned} & O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline & \text{常数阶} < \text{对数阶} < \text{线性阶} < \text{平方阶} < \text{指数阶} \end{aligned} $$

常见的空间复杂度类型

常数阶 $O(1)$

常数阶常见于数量与输入数据大小 $n$ 无关的常量、变量、对象。

需要注意的是,在循环中初始化变量或调用函数而占用的内存,在进入下一循环后就会被释放,因此不会累积占用空间,空间复杂度仍为 $O(1)$ :

[file]{space_complexity}-[class]{}-[func]{constant}

线性阶 $O(n)$

线性阶常见于元素数量与 $n$ 成正比的数组、链表、栈、队列等:

[file]{space_complexity}-[class]{}-[func]{linear}

如下图所示,此函数的递归深度为 $n$ ,即同时存在 $n$ 个未返回的 linear_recur() 函数,使用 $O(n)$ 大小的栈帧空间:

[file]{space_complexity}-[class]{}-[func]{linear_recur}

递归函数产生的线性阶空间复杂度

平方阶 $O(n^2)$

平方阶常见于矩阵和图,元素数量与 $n$ 成平方关系:

[file]{space_complexity}-[class]{}-[func]{quadratic}

如下图所示,该函数的递归深度为 $n$ ,在每个递归函数中都初始化了一个数组,长度分别为 $n$、$n-1$、$\dots$、$2$、$1$ ,平均长度为 $n / 2$ ,因此总体占用 $O(n^2)$ 空间:

[file]{space_complexity}-[class]{}-[func]{quadratic_recur}

递归函数产生的平方阶空间复杂度

指数阶 $O(2^n)$

指数阶常见于二叉树。观察下图,层数为 $n$ 的“满二叉树”的节点数量为 $2^n - 1$ ,占用 $O(2^n)$ 空间:

[file]{space_complexity}-[class]{}-[func]{build_tree}

满二叉树产生的指数阶空间复杂度

对数阶 $O(\log n)$

对数阶常见于分治算法。例如归并排序,输入长度为 $n$ 的数组,每轮递归将数组从中点处划分为两半,形成高度为 $\log n$ 的递归树,使用 $O(\log n)$ 栈帧空间。

再例如将数字转化为字符串,输入一个正整数 $n$ ,它的位数为 $\lfloor \log_{10} n \rfloor + 1$ ,即对应字符串长度为 $\lfloor \log_{10} n \rfloor + 1$ ,因此空间复杂度为 $O(\log_{10} n + 1) = O(\log n)$ 。

权衡时间与空间

理想情况下,我们希望算法的时间复杂度和空间复杂度都能达到最优。然而在实际情况中,同时优化时间复杂度和空间复杂度通常非常困难。

降低时间复杂度通常需要以提升空间复杂度为代价,反之亦然。我们将牺牲内存空间来提升算法运行速度的思路称为“以空间换时间”;反之,则称为“以时间换空间”。

选择哪种思路取决于我们更看重哪个方面。在大多数情况下,时间比空间更宝贵,因此“以空间换时间”通常是更常用的策略。当然,在数据量很大的情况下,控制空间复杂度也非常重要。