1.5 复杂度分析入门

什么是算法复杂度

想象你正在比较不同的路线去上学:走路、骑自行车、坐公交车。你会关心什么?可能是时间(需要多久)和成本(需要多少钱)。算法复杂度就是类似的概念,它帮助我们评估算法的效率。

算法复杂度是衡量算法执行效率的指标,主要包括:

  • 时间复杂度:算法执行所需的时间随输入规模增长的变化趋势
  • 空间复杂度:算法执行所需的额外空间随输入规模增长的变化趋势

生活中的例子:

  • 时间复杂度就像是估计做一道菜需要多长时间,随着食材量的增加,烹饪时间如何变化
  • 空间复杂度就像是估计做这道菜需要多大的厨房空间,随着食材量的增加,所需空间如何变化

大O表示法

大O表示法是描述算法复杂度的数学符号,表示算法执行时间(或空间)与输入规模之间的关系。它关注的是算法性能的增长趋势,而不是具体的执行时间。

为什么使用大O表示法?因为具体的执行时间会受到硬件、编程语言等因素的影响,而增长趋势是算法本身的特性。

生活中的例子:

  • 如果你说"我洗一件衣服需要5分钟,洗10件衣服需要50分钟",这就是一个线性关系,可以用O(n)表示
  • 如果你说"我做一个三明治需要3分钟,做10个三明治也只需要3分钟(因为可以同时做)",这就是一个常数关系,可以用O(1)表示

常见的时间复杂度

O(1):常数时间复杂度

无论输入数据有多少,算法的执行时间都是固定的。

例子:

  • 数组的索引访问:array[5]无论数组有多大,访问特定位置的元素所需时间都是相同的
  • 哈希表的查找:理想情况下,哈希表的查找操作是O(1)的

生活中的例子:

  • 无论书架上有多少本书,如果你知道具体位置,拿一本特定的书所需时间基本相同
// O(1)示例
public int getFirst(int[] array) {
    return array[0];  // 无论数组多大,这个操作都是常数时间
}
O(log n):对数时间复杂度

随着输入规模的增加,执行时间以对数方式增长。这是非常高效的算法。

例子:

  • 二分查找:每次比较后,搜索范围减半
  • 平衡二叉搜索树的查找操作

生活中的例子:

  • 查字典:你不会从第一页开始一页页查找,而是会根据字母顺序,每次打开字典,缩小一半的查找范围
// O(log n)示例 - 二分查找
public int binarySearch(int[] sortedArray, int target) {
    int left = 0;
    int right = sortedArray.length - 1;
    
    while (left <= right) {
        int mid = left + (right - left) / 2;
        
        if (sortedArray[mid] == target) {
            return mid;
        } else if (sortedArray[mid] < target) {
            left = mid + 1;
        } else {
            right = mid - 1;
        }
    }
    
    return -1;  // 没找到
}
O(n):线性时间复杂度

执行时间与输入规模成正比。

例子:

  • 遍历数组或链表
  • 线性搜索

生活中的例子:

  • 检查一叠卡片中是否有特定的卡片,你需要一张张检查,卡片数量翻倍,时间也翻倍
// O(n)示例 - 线性搜索
public int linearSearch(int[] array, int target) {
    for (int i = 0; i < array.length; i++) {
        if (array[i] == target) {
            return i;
        }
    }
    return -1;  // 没找到
}
O(n log n):线性对数时间复杂度

比线性时间复杂度略差,但仍然是高效的算法。

例子:

  • 高效的排序算法,如归并排序、快速排序(平均情况)

生活中的例子:

  • 给一副扑克牌排序:你可能会先粗略分组(分成几堆),然后再细致排序每一堆,最后合并
// O(n log n)示例 - 归并排序(简化版)
public void mergeSort(int[] array) {
    if (array.length <= 1) return;
    
    // 分割数组
    int mid = array.length / 2;
    int[] left = Arrays.copyOfRange(array, 0, mid);
    int[] right = Arrays.copyOfRange(array, mid, array.length);
    
    // 递归排序
    mergeSort(left);
    mergeSort(right);
    
    // 合并结果
    merge(array, left, right);
}
O(n²):平方时间复杂度

执行时间与输入规模的平方成正比。这类算法在输入规模较大时效率较低。

例子:

  • 简单的排序算法,如冒泡排序、插入排序
  • 嵌套循环遍历

生活中的例子:

  • 比较班级中每两个学生的身高:如果有n个学生,你需要进行n×(n-1)/2次比较
// O(n²)示例 - 冒泡排序
public void bubbleSort(int[] array) {
    for (int i = 0; i < array.length; i++) {
        for (int j = 0; j < array.length - i - 1; j++) {
            if (array[j] > array[j + 1]) {
                // 交换元素
                int temp = array[j];
                array[j] = array[j + 1];
                array[j + 1] = temp;
            }
        }
    }
}
O(2^n):指数时间复杂度

执行时间随输入规模呈指数增长。这类算法通常只适用于小规模输入。

例子:

  • 递归计算斐波那契数列(未优化版本)
  • 穷举所有子集

生活中的例子:

  • 尝试破解一个密码:如果密码长度是n,且每位有10种可能(0-9),则可能的组合有10^n种
// O(2^n)示例 - 未优化的斐波那契数列计算
public int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

如何分析算法复杂度

分析算法复杂度是一项重要技能,可以通过以下步骤进行:

1. 识别基本操作

确定算法中的基本操作,即执行次数与输入规模相关的操作。通常是:

  • 赋值操作
  • 算术运算
  • 比较操作
  • 数组索引访问
  • 函数调用等
2. 确定操作执行次数与输入规模的关系

分析基本操作的执行次数如何随输入规模n的变化而变化。

例如,对于简单的循环:

for (int i = 0; i < n; i++) {
    // 某操作
}

这个循环中的操作执行了n次,所以时间复杂度是O(n)。

对于嵌套循环:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        // 某操作
    }
}

内部操作执行了n×n = n²次,所以时间复杂度是O(n²)。

3. 使用大O表示法表示最终结果

在确定了操作次数与输入规模的关系后,使用大O表示法表示最终结果。记住以下规则:

  • 只保留增长最快的项:如果时间复杂度是3n² + 2n + 1,简化为O(n²)
  • 忽略常数系数:如果时间复杂度是5n,简化为O(n)
  • 关注最坏情况:通常我们分析的是算法在最坏情况下的性能
实例分析

让我们分析一个简单的算法:

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

分析:

  1. 基本操作是比较nums[i] == nums[j]
  2. 外层循环执行n次,内层循环平均执行(n-1)/2次
  3. 总操作次数约为n×(n-1)/2,即O(n²)

因此,这个算法的时间复杂度是O(n²)。

复杂度分析的重要性

为什么要学习复杂度分析?

  1. 选择合适的算法:了解不同算法的复杂度,可以根据实际需求选择最合适的算法
  2. 预测性能瓶颈:通过复杂度分析,可以预测算法在大规模数据下的性能表现
  3. 优化代码:了解复杂度后,可以有针对性地优化代码,提高效率

记住,在实际应用中,常数因子和低阶项也可能影响实际性能,尤其是在输入规模较小时。但对于大规模输入,渐近复杂度(大O表示法)是最重要的考量因素。

1.6 编程语言中的数据类型

在学习数据结构之前,我们需要了解编程语言中的基本数据类型,它们是构建复杂数据结构的基础,就像砖块是建造房屋的基础一样。

基本数据类型

基本数据类型是编程语言内置的、不可再分的数据类型,就像元素周期表中的原子一样。

整数类型

整数类型用于表示没有小数部分的数值。

在不同的编程语言中,整数类型可能有不同的大小和范围:

  • Javabyte(8位)、short(16位)、int(32位)、long(64位)
  • Pythonint(可以表示任意大小的整数)
  • C/C++char(8位)、short(16位)、int(通常32位)、long(至少32位)、long long(至少64位)

生活中的例子:

  • 整数类型就像是计数器,只能显示整数,不能显示小数
  • 不同大小的整数类型就像不同大小的停车场,能容纳的车辆数量不同
// Java中的整数类型示例
byte smallNumber = 127;        // -128到127
short mediumNumber = 32767;    // -32768到32767
int regularNumber = 2000000000; // 约-21亿到21亿
long bigNumber = 9000000000000000000L; // 非常大的范围
浮点类型

浮点类型用于表示带小数部分的数值。

常见的浮点类型:

  • Java/C/C++float(32位)、double(64位)
  • Pythonfloat(通常是64位)

浮点数的精度有限,可能会有舍入误差,这是由于二进制无法精确表示某些十进制小数。

生活中的例子:

  • 浮点数就像是带小数点的温度计,可以显示36.5°C这样的温度
  • 浮点数的精度限制就像是尺子的刻度,无法测量比最小刻度更精确的长度
// 浮点类型示例
float price = 19.99f;  // 需要f后缀
double pi = 3.14159265359;  // 更精确的小数

// 浮点数精度问题示例
System.out.println(0.1 + 0.2);  // 输出0.30000000000000004,而不是0.3
字符类型

字符类型用于表示单个字符,如字母、数字、符号等。

  • Java/C/C++char
  • Python:没有专门的字符类型,使用长度为1的字符串

在内部,字符通常使用ASCII或Unicode编码存储。

生活中的例子:

  • 字符就像是印刷机中的单个活字
  • 或者像是拼音中的单个声母或韵母
// 字符类型示例
char letter = 'A';  // 单引号表示字符
char digit = '7';
char symbol = '@';
布尔类型

布尔类型只有两个可能的值:真(true)和假(false),用于表示逻辑条件。

  • Java/C++/Pythonboolean/bool

生活中的例子:

  • 布尔类型就像是电灯开关,只有开和关两种状态
  • 或者像是是非题,只有"是"和"否"两个选项
// 布尔类型示例
boolean isRaining = true;
boolean hasPassed = false;

// 在条件语句中使用
if (isRaining) {
    System.out.println("带伞");
} else {
    System.out.println("不用带伞");
}

复合数据类型

复合数据类型是由基本数据类型或其他复合数据类型组合而成的,就像分子是由原子组成的。

数组

数组是最基本的复合数据类型,用于存储同一类型的多个元素。

特点:

  • 固定大小(在大多数语言中)
  • 连续内存分配
  • 通过索引快速访问元素

生活中的例子:

  • 数组就像是一排停车位,每个位置都有编号,可以停放同类型的车辆
  • 或者像是一栋公寓楼的门牌号,按顺序排列
// 数组示例
int[] scores = new int[5];  // 创建一个可以存储5个整数的数组
scores[0] = 95;  // 第一个元素
scores[1] = 88;
scores[2] = 72;
scores[3] = 90;
scores[4] = 83;

// 数组遍历
for (int i = 0; i < scores.length; i++) {
    System.out.println("第" + (i+1) + "个学生的分数:" + scores[i]);
}
结构体/类

结构体和类用于将不同类型的数据组合成一个单元。

  • C:使用struct
  • Java/C++/Python:使用class

生活中的例子:

  • 结构体/类就像是一个人的档案,包含姓名、年龄、地址等不同类型的信息
  • 或者像是一本书的信息,包含书名、作者、出版日期等
// Java中的类示例
public class Student {
    String name;
    int age;
    double gpa;
    
    // 构造函数
    public Student(String name, int age, double gpa) {
        this.name = name;
        this.age = age;
        this.gpa = gpa;
    }
}

// 使用类
Student alice = new Student("Alice", 20, 3.8);
System.out.println(alice.name + "的GPA是" + alice.gpa);
指针/引用

指针和引用用于存储内存地址,允许间接访问数据。

  • C/C++:使用指针(*)和引用(&
  • Java/Python:只有引用,没有显式指针

生活中的例子:

  • 指针就像是图书馆的索引卡,告诉你书在哪个书架上
  • 或者像是遥控器,可以远程控制电视
// C语言中的指针示例
int number = 42;
int *ptr = &number;  // ptr存储number的地址

*ptr = 100;  // 通过指针修改number的值
printf("%d\n", number);  // 输出100
// Java中的引用示例
Student alice = new Student("Alice", 20, 3.8);
Student reference = alice;  // reference和alice指向同一个对象

reference.age = 21;  // 通过reference修改对象
System.out.println(alice.age);  // 输出21,因为alice和reference指向同一个对象

数据类型与数据结构的关系

数据类型是编程语言提供的基本构建块,而数据结构是我们使用这些构建块创建的更复杂的组织形式。

例如:

  • 使用数组可以构建栈、队列等线性数据结构
  • 使用类和引用可以构建链表、树、图等非线性数据结构

理解基本数据类型是学习数据结构的基础,就像了解砖块和水泥是学习建筑的基础一样。

1.7 本章小结

在本章中,我们介绍了数据结构与算法的基本概念、学习它们的重要性以及学习方法。

我们首先了解了什么是数据结构和算法,以及它们在解决问题中的重要作用。数据结构是组织和存储数据的方式,而算法是解决问题的步骤。

接着,我们探讨了为什么学习数据结构与算法对程序员如此重要:它们可以提升编程能力,帮助解决复杂问题,促进职业发展,并加深对计算机科学的理解。

我们还讨论了如何学习数据结构与算法,包括建立正确的学习心态和采用有效的学习方法。循序渐进、持之以恒和动手实践是成功学习的关键。

然后,我们简要介绍了计算机如何存储和处理数据,包括内存模型和数据的表示方式。这些基础知识有助于我们理解数据结构在计算机中的实现。

我们还学习了算法复杂度分析的基础知识,包括大O表示法和常见的时间复杂度。这些工具帮助我们评估和比较不同算法的效率。

最后,我们介绍了编程语言中的基本数据类型和复合数据类型,它们是构建数据结构的基础。

这些知识为我们后续学习更复杂的数据结构和算法奠定了基础。在接下来的章节中,我们将深入探讨各种具体的数据结构和算法,并学习如何在实际问题中应用它们。

1.8 练习题

  1. 解释数据结构和算法的区别和联系。

    • 思考:它们各自的定义是什么?它们如何协同工作?
    • 提示:可以用生活中的例子来类比,如图书馆的书架组织(数据结构)和查找书籍的方法(算法)。
  2. 为什么学习数据结构与算法对程序员很重要?

    • 思考:它们如何影响代码质量、性能和解决问题的能力?
    • 提示:考虑实际工作中可能遇到的场景,如处理大量数据或优化性能瓶颈。
  3. 分析以下代码片段的时间复杂度:

    for (int i = 0; i < n; i++) {
        sum += i;
    }
    
    • 思考:基本操作是什么?它执行了多少次?
    • 提示:关注循环的执行次数与输入规模n的关系。
  4. 分析以下代码片段的时间复杂度:

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < n; j++) {
            sum += i * j;
        }
    }
    
    • 思考:嵌套循环如何影响时间复杂度?
    • 提示:分析内外层循环的执行次数及其乘积关系。
  5. 思考:在实际编程中,你遇到过哪些可以通过合理选择数据结构来优化的问题?

    • 思考:不同的数据结构如何影响特定操作(如查找、插入、删除)的效率?
    • 提示:考虑常见场景,如频繁查找特定元素、需要保持元素顺序、需要快速判断元素是否存在等。
  6. 手动模拟冒泡排序的过程,对数组[5, 3, 8, 4, 2]进行排序。

    • 思考:每一轮比较和交换后,数组的状态是什么?
    • 提示:按照冒泡排序的规则,相邻元素比较,较大的元素"冒泡"到后面。
  7. 解释计算机内存中的位、字节和字的关系。

    • 思考:它们的大小关系是什么?各自在数据存储中扮演什么角色?
    • 提示:考虑它们如何组织成层次结构,以及在不同数据类型中的应用。
  8. 比较数组和链表的优缺点。

    • 思考:它们在内存分配、元素访问、插入删除操作上有何不同?
    • 提示:考虑时间复杂度和空间使用的角度。

1.9 进一步阅读

为了深入学习数据结构与算法,以下是一些推荐的学习资源:

入门书籍

  • 《算法图解》 - Aditya Bhargava著:通过图解方式直观地解释算法,非常适合初学者。
  • 《大话数据结构》 - 程杰著:用通俗易懂的语言讲解数据结构,适合零基础读者。
  • 《啊哈!算法》 - 啊哈磊著:生动有趣地介绍基础算法,适合算法入门。

进阶书籍

  • 《算法导论》 第一章:算法在计算中的作用 - 这是算法领域的经典教材,深入而全面。
  • 《数据结构与算法分析》 第一章:引论 - 提供了数据结构和算法的系统分析方法。
  • 《编程珠玑》 - Jon Bentley著:关于算法设计与分析的经典案例,强调实用性和效率。

在线资源

  • LeetCode:提供大量算法题目和讨论,是练习算法的好平台。
  • GeeksforGeeks:包含丰富的数据结构和算法教程、文章和练习题。
  • Visualgo:通过可视化方式展示各种数据结构和算法的工作原理。

视频课程

  • MIT OpenCourseWare - Introduction to Algorithms:麻省理工学院的算法入门课程,讲解深入浅出。
  • Coursera - Algorithms Specialization:由斯坦福大学提供的算法专项课程,系统全面。

选择适合自己学习风格和水平的资源,结合实践,循序渐进地学习,将会取得最好的效果。记住,理解概念和动手实践同样重要!

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐