用「图书馆整理书籍」的故事理解空间复杂度

76 阅读5分钟

一、图书馆的故事:空间规划的艺术

有两家图书馆要存放 1000 本书:

  • 图书馆 A:为每本书都准备一个独立的书架(1000 个书架)

  • 图书馆 B:把书分类放在 10 个大书架上,每个书架有 100 层

假设每个书架占地 1 平方米:

  • 图书馆 A 需要 1000 平方米

  • 图书馆 B 只需要 10 平方米

如果书的数量增加到 10000 本:

  • 图书馆 A 需要 10000 平方米(空间随书的数量线性增长)

  • 图书馆 B 只需要 100 平方米(空间增长速度慢得多)

这就是空间复杂度的本质
算法在运行过程中需要的内存空间如何随数据量的增加而变化

二、常见空间复杂度的故事比喻

1. O (1):固定大小的储物柜

超市的储物柜有固定数量的格子,无论今天有 10 个顾客还是 100 个顾客,需要的格子数量都是一样的:

java

public class Locker {
    private static final int SIZE = 100; // 固定100个格子
    private boolean[] lockers = new boolean[SIZE]; // 占用固定空间
    
    public boolean rentLocker() {
        for (int i = 0; i < SIZE; i++) {
            if (!lockers[i]) {
                lockers[i] = true;
                return true; // 租到格子
            }
        }
        return false; // 没有空格子
    }
}

空间复杂度:O(1)
特点:无论处理多少数据,占用的内存空间都是固定的。

2. O (n):为每个人发一件 T 恤

公司要为所有员工发 T 恤,员工数量越多,需要的 T 恤数量就越多:

java

public class TShirtDistributor {
    public void distributeTShirts(int employeeCount) {
        String[] tShirts = new String[employeeCount]; // 为每个员工创建一件T恤
        
        for (int i = 0; i < employeeCount; i++) {
            tShirts[i] = "Size: M, Color: Blue"; // 初始化T恤
        }
        
        // 分发T恤...
    }
}

空间复杂度:O(n)
特点:需要的内存空间与数据量 n 成线性关系。

3. O (n²):学校的座位安排表

学校要为每个班级的每个学生安排座位,用一个二维数组记录:

java

public class School {
    public void arrangeSeats(int classCount, int studentPerClass) {
        String[][] seats = new String[classCount][studentPerClass]; // 二维数组
        
        for (int i = 0; i < classCount; i++) {
            for (int j = 0; j < studentPerClass; j++) {
                seats[i][j] = "Class-" + i + ", Seat-" + j; // 安排座位
            }
        }
    }
}

空间复杂度:O(n²)
特点:如果班级数和学生数都增加,需要的内存空间会呈平方级增长。

4. O (log n):递归查找家谱

在家族树中查找某个祖先,每次只需要记住当前分支的信息:

java

public class FamilyTree {
    public boolean findAncestor(Person person, String targetName) {
        if (person == null) return false;
        if (person.getName().equals(targetName)) return true;
        
        // 递归查找父节点和母节点
        return findAncestor(person.getFather(), targetName) || 
               findAncestor(person.getMother(), targetName);
    }
}

空间复杂度:O(log n)
特点:递归调用会使用栈空间,但由于每次只处理一个分支,空间复杂度是对数级的。

三、如何计算空间复杂度:抓住关键内存分配

计算空间复杂度时,只需关注算法运行过程中创建的额外数据结构,忽略输入数据本身:

java

public void complexMethod(int[] array) {
    // 输入数组array不算在空间复杂度内,因为它是输入数据
    
    // 操作1:O(1) - 固定大小的变量
    int sum = 0;
    
    // 操作2:O(n) - 创建一个与输入数组大小相同的新数组
    int[] copy = new int[array.length];
    for (int i = 0; i < array.length; i++) {
        copy[i] = array[i];
    }
    
    // 操作3:O(n²) - 创建一个二维数组
    int[][] matrix = new int[array.length][array.length];
    for (int i = 0; i < array.length; i++) {
        for (int j = 0; j < array.length; j++) {
            matrix[i][j] = i + j;
        }
    }
    
    // 总空间复杂度:O(1) + O(n) + O(n²) → 保留最高阶项 → O(n²)
}

四、时间复杂度 vs 空间复杂度:天平的两端

很多时候,我们需要在时间和空间之间做出权衡。例如:

问题:判断一个数组中是否有重复元素

方法 1:暴力查找(时间 O (n²),空间 O (1))

java

public boolean hasDuplicates(int[] array) {
    for (int i = 0; i < array.length; i++) {
        for (int j = i + 1; j < array.length; j++) {
            if (array[i] == array[j]) return true;
        }
    }
    return false;
}

特点:不需要额外空间,但时间效率低。

方法 2:使用哈希表(时间 O (n),空间 O (n))

java

public boolean hasDuplicates(int[] array) {
    Set<Integer> set = new HashSet<>();
    for (int num : array) {
        if (set.contains(num)) return true;
        set.add(num);
    }
    return false;
}

特点:时间效率高,但需要额外的哈希表空间。

五、优化空间复杂度的技巧:从图书馆故事中学到的

假设要处理一个包含 100 万个整数的数组,计算每个数的平方:

原始方法(O (n) 空间)
创建一个新数组存储所有平方结果:

java

public int[] calculateSquares(int[] array) {
    int[] result = new int[array.length]; // 需要O(n)空间
    for (int i = 0; i < array.length; i++) {
        result[i] = array[i] * array[i];
    }
    return result;
}

优化方法(O (1) 空间)
直接在原数组上修改,不需要额外空间:

java

public void calculateSquaresInPlace(int[] array) {
    for (int i = 0; i < array.length; i++) {
        array[i] = array[i] * array[i]; // 直接修改原数组
    }
}

六、递归算法的空间复杂度:调用栈的陷阱

递归算法会使用系统栈空间,可能导致空间复杂度升高。例如:

斐波那契数列(递归实现)

java

public int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n-1) + fibonacci(n-2); // 递归调用
}

空间复杂度:O(n)
原因:递归深度最大为 n,每次递归调用需要保存局部变量,因此栈空间为 O (n)。

迭代实现

java

public int fibonacci(int n) {
    if (n <= 1) return n;
    int a = 0, b = 1;
    for (int i = 2; i <= n; i++) {
        int temp = a + b;
        a = b;
        b = temp;
    }
    return b;
}

空间复杂度:O(1)
原因:只需要固定的几个变量,不使用递归栈。

七、总结:空间复杂度是内存使用的指南针

空间复杂度描述了算法的内存使用趋势,帮助我们在设计算法时做出明智选择:

  • 优先选择低空间复杂度的算法(O(1) > O(log n) > O(n) > O(n²))

  • 警惕递归算法的栈空间开销

  • 在时间和空间之间寻找平衡点

记住:好的算法就像高效的图书馆管理员,用最小的空间存放最多的书籍