【Python】算法基础知识

卷一:基础理论与核心数据结构

第一章:算法的度量衡 —— 时空复杂度分析与Python性能陷阱

在踏上算法探索的征途之前,我们必须先锻造好我们的度量工具。没有度量,就无法比较;没有比较,就无法选择;没有选择,就无法优化。在算法的世界里,这个度量衡就是“时空复杂度”。

1.1 为何需要复杂度分析?—— “跑一下代码看看”的局限性

一个初学者在比较两个算法(例如,两种不同的排序方法)的优劣时,最直观的想法可能是:“我把这两个算法都实现出来,然后用同一个大列表去跑一下,看看哪个花的时间短。”

这是一种基于经验的测试方法,它在某些情况下有用,但作为一种严格的评判标准,它存在着致命的缺陷:

  1. 环境依赖性太强:

    • 硬件差异: 同样的代码,在Intel i9处理器上和在树莓派上运行,其绝对时间天差地别。我们无法得出一个脱离硬件的普适性结论。
    • 软件环境差异: 操作系统、Python解释器的版本、后台运行的其他程序,都会干扰计时的准确性。你这次测量的结果,下次可能就无法复现。
  2. 数据规模的迷惑性:

    • 假设算法A在处理100个元素时耗时0.01秒,算法B耗时0.05秒。我们能说算法A一定优于算法B吗?未必。
    • 可能算法A的复杂度是二次方级别(O(n²)),而算法B是线性对数级别(O(n log n))。当数据规模扩大到100万个元素时,算法A可能需要几个小时,而算法B可能只需要几秒钟。在小数据规模下的“优势”,在大数据面前会变成灾难性的“劣势”。
  3. 数据状态的偶然性:

    • 某些算法的性能与其处理的数据的初始状态密切相关。例如,一个简单的“快速排序”算法,在处理一个已经几乎排好序的列表时,其性能会急剧恶化。如果你碰巧用了一个这样的测试用例,你可能会错误地认为快速排序是一个很差的VBA算法。

因此,我们需要一种与具体硬件、环境无关,能够描述算法效率与数据规模之间增长关系的理论工具。这个工具,就是复杂度分析。它不关心算法执行的绝对时间(例如“跑了0.2秒”),而是关心算法执行时间(或占用空间)的增长趋势

1.2 时间复杂度:代码执行时间的增长趋势

时间复杂度,全称为“渐进时间复杂度”(Asymptotic Time Complexity),它描述的是当输入数据规模n趋向于无穷大时,算法执行所需时间步数的增长级别。我们用**大O表示法(Big O Notation)**来表示。

让我们从一个最简单的例子开始,理解大O是如何推导出来的。

代码示例:计算列表中所有元素的和

def sum_list(data_list):
    """计算一个列表中所有数字的和。"""
    total = 0                      # 执行1次:赋值操作
    for num in data_list:          # 执行n次:其中n是data_list的长度
        total += num               # 执行n次:加法和赋值操作
    return total                   # 执行1次:返回操作

推导过程:

  1. 数出基本操作次数:

    • total = 0 执行了1次。
    • for循环本身,连同其内部的total += num,对于一个长度为n的列表,总共会执行n次。我们粗略地将循环的每次迭代记为2个操作步(一次迭代判断,一次加法赋值)。
    • return total 执行了1次。
    • 所以,总的操作步数 T(n) = 1 + 2*n + 1 = 2n + 2T(n)代表了代码执行的总时间步数与输入规模n的函数关系。
  2. 关注增长趋势,忽略常数、低阶项和系数:

    • 忽略加法常数: 当n变得非常大时(比如10亿),2n + 2中的那个+2变得微不足道,完全可以忽略。公式简化为 T(n) ≈ 2n
    • 忽略乘法系数: 我们关心的是执行时间的增长“级别”,而不是精确的倍数关系。2n的增长趋势和n的增长趋势是同一种类型——线性增长。当n翻倍时,执行时间也大致翻倍。因此,我们把这个系数2也忽略掉。
    • 最终,我们得到这个算法时间复杂度的增长级别是 O(n)

大O表示法的核心:它抓住了一个函数中“最主要”的部分,也就是当n趋于无穷时,增长最快的那个项,并忽略掉所有“修饰性”的常数和系数。

1.2.1 常见的时间复杂度量级(从优到劣)

理解并能识别出这些常见的复杂度量级,是算法学习的第一步。

1. O(1) - 常数时间复杂度 (Constant Time)

这代表算法的执行时间不随输入数据规模n的变化而变化。无论n是10还是10亿,执行时间都保持在一个常数水平。

def get_first_element(data_list):
    """获取列表的第一个元素。"""
    if not data_list:                           # 执行1次:检查列表是否为空
        return None                             # 在列表为空时执行1次:返回None
    return data_list[0]                         # 在列表不为空时执行1次:通过索引获取元素
  • 解释: 无论列表data_list有多长,这个函数都只执行一次索引操作data_list[0]。它的执行时间是一个固定的、与n无关的常数。Python中字典(dict)和集合(set)的查找、插入、删除操作,在平均情况下的时间复杂度也是O(1),这是哈希表数据结构的威力所在。

2. O(log n) - 对数时间复杂度 (Logarithmic Time)

这是非常优秀的一种复杂度。当n增大时,执行时间步数增长得非常缓慢。典型的O(log n)算法,每一步处理都会将待解决问题的规模缩减一个量级(例如,减半)。

经典案例:二分查找

def binary_search(sorted_list, target):
    """在一个排好序的列表中使用二分查找来寻找目标值。"""
    low = 0                                     # 执行1次:初始化low指针
    high = len(sorted_list) - 1                 # 执行1次:初始化high指针

    while low <= high:                          # 循环的次数是关键,我们称之为k次
        mid = (low + high) // 2                 # 执行k次:计算中间位置
        guess = sorted_list[mid]                # 执行k次:获取中间值

        if guess == target:                     # 执行k次:比较操作
            return mid                          # 最多执行1次:找到目标,返回索引
        if guess > target:                      # 执行k次:比较操作
            high = mid - 1                      # 执行k次:缩小查找范围的上界
        else:                                   # 执行k次:比较操作
            low = mid + 1                       # 执行k次:缩小查找范围的下界
    
    return None                                 # 最多执行1次:未找到目标,返回None
  • 解释: 为什么是O(log n)?假设列表长度为n
    • 第1次查找后,剩余的查找范围是 n/2
    • 第2次查找后,剩余的查找范围是 n/4
    • k次查找后,剩余的查找范围是 n / 2^k
    • 当查找结束时,剩余范围是1,即 n / 2^k = 1
    • 通过数学变换,得到 n = 2^k,进而得到 k = log₂(n)
    • 因此,循环的执行次数kn的对数成正比。底数是2还是10在Big O表示法中被忽略,统一记为O(log n)

3. O(n) - 线性时间复杂度 (Linear Time)

这是非常常见的一种复杂度,意味着算法的执行时间与输入规模n成正比。我们最开始的sum_list例子就是O(n)

代码示例:线性查找

def linear_search(data_list, target):
    """在列表中进行线性查找。"""
    for i in range(len(data_list)):             # 循环会执行n次
        if data_list[i] == target:              # 比较操作会执行n次(最坏情况下)
            return i                            # 如果找到,则提前返回
    return None                                 # 如果循环结束仍未找到,返回None
  • 解释: 在最坏的情况下(目标元素在列表末尾或根本不存在),这个算法需要完整地遍历整个列表,检查n个元素。

4. O(n log n) - 线性对数时间复杂度 (Log-Linear Time)

这是算法领域的一个“黄金标准”,尤其是在排序领域。许多最高效的、基于比较的排序算法(如归并排序、快速排序的平均情况)都具有这个复杂度。它比O(n^2)快得多,但比O(n)慢。

  • 直观理解: 想象一下,你有一个任务,需要对n个元素中的每一个都执行一次O(log n)的操作。那么总的复杂度就是 n * O(log n),即 O(n log n)
  • 归并排序的例子: 归并排序会递归地将列表分成两半(这个过程的深度是log n层),在每一层,它都需要对总共n个元素进行合并操作。因此,总复杂度是 O(n log n)。我们将在排序章节详细实现它。

5. O(n²) - 平方时间复杂度 (Quadratic Time)

n较大时,这种复杂度的算法会变得非常慢。它通常涉及到对数据集的嵌套循环。

代码示例:找出列表中的重复元素(朴素方法)

def find_duplicates_naive(data_list):
    """使用嵌套循环来查找列表中的重复元素。"""
    duplicates = []                             # 执行1次:初始化结果列表
    n = len(data_list)                          # 执行1次:获取列表长度
    for i in range(n):                          # 外层循环执行n次
        for j in range(i + 1, n):               # 内层循环执行的次数与i相关
            # 当i=0时,内层循环n-1次
            # 当i=1时,内层循环n-2次
            # ...
            # 总次数约等于 (n-1)+(n-2)+...+1 = n*(n-1)/2 = 0.5n² - 0.5n
            if data_list[i] == data_list[j]:    # 比较操作
                if data_list[i] not in duplicates: # 检查是否已记录
                    duplicates.append(data_list[i]) # 添加到结果列表
    return duplicates                           # 返回结果
  • 解释: 内层循环的执行总次数约为 n²/2。根据大O表示法,我们忽略系数1/2和低阶项-0.5n,得到时间复杂度为O(n²)

6. O(2ⁿ) - 指数时间复杂度 (Exponential Time)

这是一种非常糟糕的复杂度,算法的执行时间会随着n的增加而爆炸式增长。这类算法通常涉及到对问题所有可能子集的蛮力搜索。

经典案例:斐波那契数列的朴素递归实现

def fibonacci_recursive(n):
    """使用朴素递归计算斐波那契数列的第n项。"""
    if n <= 1:                                  # 递归的基线条件
        return n
    # 关键问题在这里:每次调用都会产生两个新的递归调用
    return fibonacci_recursive(n - 1) + fibonacci_recursive(n - 2)
  • 解释: 调用fibonacci_recursive(5)会触发fib(4)fib(3)fib(4)又会触发fib(3)fib(2)。你会发现fib(3)被重复计算了。这种递归调用的总次数大致是2^n的量级,形成一个巨大的、充满重复计算的递归树。

7. O(n!) - 阶乘时间复杂度 (Factorial Time)

这是最差的一类复杂度,只在n非常非常小的情况下才可用。它通常出现在需要计算所有排列组合的问题中。

经典案例:旅行商问题(TSP)的蛮力解法

  • 问题描述: 一个商人要访问n个城市,每个城市只访问一次,最后回到出发城市。如何找到最短的路线?
  • 蛮力解法: 列出所有可能的访问顺序(排列),计算每条路线的总长度,然后找出最短的。
  • 复杂度: n个城市的排列组合有 (n-1)! 种。这是一个O(n!)级别的算法。当城市数量达到20个时,计算量就已经是天文数字了。

1.3 空间复杂度:算法占用的额外内存

空间复杂度(Space Complexity)衡量的是算法在运行过程中,除了存储输入数据本身所占用的空间之外,还需要额外占用的内存空间与输入规模n之间的增长关系。

1. O(1) - 常数空间复杂度

算法所需的额外空间是一个固定的常数,不随n的变化而变化。

def reverse_list_in_place(data_list):
    """原地反转一个列表。"""
    left = 0                                # 需要1个变量空间
    right = len(data_list) - 1              # 需要1个变量空间
    while left < right:
        # 下面的交换操作只在已有的列表空间内进行,没有创建新列表
        temp = data_list[left]              # 需要1个临时变量空间
        data_list[left] = data_list[right]
        data_list[right] = temp
        left += 1
        right -= 1
  • 解释: 无论data_list有多长,我们都只需要left, right, temp这几个固定数量的变量。因此,额外空间是O(1)。这种不产生额外数据副本的算法被称为“原地算法”(In-place Algorithm)。

2. O(n) - 线性空间复杂度

算法所需的额外空间与输入规模n成正比。

def create_copy_and_reverse(data_list):
    """创建一个新的反转后的列表。"""
    new_list = [None] * len(data_list)      # 创建了一个和输入规模n一样大的新列表
    n = len(data_list)
    for i in range(n):
        new_list[i] = data_list[n - 1 - i]
    return new_list
  • 解释: 我们创建了一个new_list,它的大小与data_list完全相同。如果data_list的长度是n,那么额外空间就是O(n)

3. O(n²) - 平方空间复杂度

算法所需的额外空间与n的平方成正比。

代码示例:创建一个邻接矩阵来表示图

def create_adjacency_matrix(n, edges):
    """为n个顶点的图创建邻接矩阵。"""
    # 创建一个 n x n 的二维列表,所有元素初始化为0
    matrix = [[0] * n for _ in range(n)]        # 这里分配了 n*n 的空间
    for u, v in edges:
        matrix[u][v] = 1
        matrix[v][u] = 1 # 假设是无向图
    return matrix
  • 解释: 对于一个有n个顶点的图,邻接矩阵需要n x n的空间来存储任意两个顶点之间是否存在边。因此,空间复杂度是O(n²)

1.4 最好、最坏与平均情况复杂度

同一个算法,在处理不同状态的输入数据时,其性能表现可能大相径庭。

  • 最坏情况时间复杂度 (Worst-case): 算法在任何输入规模为n的数据上,运行时间步数的上界。这是我们最关心、最常讨论的,因为它提供了一个性能保证。无论输入数据多么“不友好”,算法的性能都不会比这个更差。
  • 最好情况时间复杂度 (Best-case): 算法在最“理想”的输入数据上运行的时间复杂度。这个指标通常用处不大,因为它不具有代表性。
  • 平均情况时间复杂度 (Average-case): 假设所有可能的输入数据以等概率出现,算法运行的期望时间复杂度。这个指标能很好地反映算法在现实中的平均表现,但其数学分析通常比最坏情况复杂得多。

以线性查找为例:

  • 最坏情况: O(n)。目标元素在列表的最后一个位置,或者根本不存在。
  • 最好情况: O(1)。目标元素恰好是列表的第一个。
  • 平均情况: O(n)。平均来说,需要查找 n/2 次。根据大O表示法,忽略系数1/2,仍然是O(n)

以快速排序为例:

  • 最坏情况: O(n²)。当每次选择的“基准元”都是当前子数组的最大或最小值时发生,这常见于处理一个已排序的列表。
  • 最好情况: O(n log n)
  • 平均情况: O(n log n)。实践证明,通过随机选择基准元等方法,可以极大概率地避免最坏情况的发生,使得快速排序在平均情况下表现极为出色。

1.5 必须警惕的Python性能陷阱

Python的语法简洁优美,但这种简洁有时会掩盖底层操作的真实成本。不了解这些,很容易写出表面优雅但性能低下的代码。

陷阱一:列表的 + 拼接与 append

# list_concatenation_vs_append.py
import time

n = 100000

# 使用 + 拼接
start_time = time.time()
result_plus = []
for i in range(n):
    result_plus = result_plus + [i] # 每次拼接都会创建一个新的列表
end_time = time.time()
print(f"使用 '+' 拼接 {
     
     n} 次耗时: {
     
     end_time - start_time:.4f} 秒")

# 使用 append
start_time = time.time()
result_append = []
for i in range(n):
    result_append.append(i) # append是原地修改,效率高得多
end_time = time.time()
print(f"使用 'append' {
     
     n} 次耗时: {
     
     end_time - start_time:.4f} 秒")
  • 分析:
    • result = result + [i]: 看起来简单,但+操作符在Python列表中意味着创建一个全新的列表,将result的所有元素复制到新列表中,然后再把[i]的元素也复制过去。这个操作的成本与result的当前长度成正比。在循环中,这个操作的复杂度近似于 1 + 2 + 3 + ... + n,这是一个O(n²)的过程!
    • result.append(i): append方法是原地修改。Python的列表是动态数组,它会预分配一些额外空间。大多数情况下,append只是在末尾填入一个值,是O(1)操作。即使偶尔空间不足需要重新分配和复制整个数组,其“均摊时间复杂度”依然是O(1)。整个循环是O(n)

陷阱二:in 操作符在列表和字典/集合中的天壤之别

# in_list_vs_in_dict.py
import time

n = 100000
data_list = list(range(n))
data_dict = {
   
   i: True for i in range(n)} # 创建一个字典,键是0到n-1
target = n - 1 # 要查找的目标

# 在列表中查找
start_time = time.time()
is_in_list = target in data_list # 'in' 对于列表是线性扫描
end_time = time.time()
print(f"在 {
     
     n} 个元素的列表中查找: {
     
     '找到' if is_in_list else '未找到'}, 耗时: {
     
     end_time - start_time:.6f} 秒")

# 在字典中查找
start_time = time.time()
is_in_dict = target in data_dict # 'in' 对于字典是哈希查找
end_time = time.time()
print(f"在 {
     
     n} 个元素的字典中查找: {
     
     '找到' if is_in_dict else '未找到'}, 耗时: {
     
     end_time - start_time:.6f} 秒")
  • 分析:
    • target in data_list: 为了判断target是否在列表中,Python必须从头到尾逐个检查元素,直到找到匹配项或遍历完整个列表。这是一个O(n)的线性查找。
    • target in data_dict: 字典的in操作利用了哈希表。它会直接计算target的哈希值,通过哈希值几乎可以立即定位到target应该在的位置,然后进行一次比较即可。这是一个平均O(1)的操作。
    • 结论: 当你需要频繁地进行成员资格检查时,使用集合(set)或字典(dict)的键,其性能远超列表。

陷阱三:字符串的拼接

这与列表的+拼接陷阱非常相似。Python的字符串是**不可变(immutable)**的。

# string_concatenation.py

# 坏方法:使用 + 在循环中拼接
def bad_string_join(words):
    result = ""
    for word in words:
        result += word + " " # 每次都会创建新字符串,O(n²)
    return result

# 好方法:使用 join 方法
def good_string_join(words):
    return " ".join(words) # join方法会先计算总长度,一次性构建字符串,O(n)
  • 分析:
    • result += word: 因为字符串不可变,所以这行代码的背后是:创建一个新的、足够大的字符串,将result的旧内容复制过去,再将word的内容复制过去,然后让result变量指向这个全新的字符串。在循环中,这是一个O(n²)的灾难。
    • " ".join(words): join方法是专门为此优化的。它会先遍历一次words列表,计算出最终字符串所需的总长度,然后只分配一次内存,最后将所有单词复制到这个新空间中。这是一个高效的O(n)操作,其中n是所有单词的总长度。

第二章:线性数据结构 —— 序列、链条与栈队的艺术

在构建了坚固的复杂度分析基石之后,我们开始搭建算法大厦的第一层——线性数据结构。所谓“线性”,指的是数据元素之间存在着一对一的、线性的前后关系,就像一串珍珠项链,每个珍珠后面只跟着另一颗珍珠。这种结构虽然简单,却是构建更复杂数据结构和算法的基础。

2.1 数组与Python列表:看似简单,内有乾坤

数组(Array)是几乎所有编程语言都内置的最基本的数据结构。它是一块连续的内存空间,用于存储一系列相同类型的元素。

数组的核心特性:

  • 连续内存: 这是数组最本质、最重要的特征。a[0], a[1], a[2]等元素在物理内存中是紧挨着存放的。
  • 随机访问 (Random Access): 正因为内存连续,计算机可以通过一个简单的数学公式 address(a[i]) = base_address + i * element_size,在O(1)的常数时间内直接计算出任何一个索引i的元素的内存地址,并立即访问它。这是数组最大的优势。

Python中的list:动态数组的实现

Python的list类型,虽然用法上像一个可以存储任何类型、长度可变的“超级数组”,但其底层实现是一个动态数组(Dynamic Array)。它保留了传统数组“连续内存”和“随机访问”的核心优势,同时又巧妙地解决了传统数组长度固定的问题。

动态数组的内部机制:

  1. 预留空间: 当你创建一个list时,Python解释器并不会只分配你当前需要的空间。它会额外预留一些空间,以备未来的append操作。这被称为“超额分配”(Over-allocation)。
  2. append操作: 当你append一个新元素时,如果预留空间足够,Python会直接在末尾的空闲位置放入新元素。这是一个O(1)的操作。
  3. 扩容 (Resizing): 如果预留空间用完了,append操作会触发一次“扩容”。这个过程包括:
    a. 申请一块更大的新内存空间(通常是当前大小的1.5倍或2倍)。
    b. 将旧数组中的所有元素,逐个复制到新的内存空间中。
    c. 释放旧的内存空间。
    d. 在新空间的末尾放入新元素。

这次扩容操作本身是O(n)的,其中n是列表的当前大小。但由于它不是每次append都发生,而是随着列表长度的增长,其发生的频率越来越低,因此,经过均摊(Amortized)分析,append操作的均摊时间复杂度依然是O(1)

2.1.1 列表的基本操作复杂度分析

理解Python列表的底层实现,对于分析其各种操作的性能至关重要。

操作 例子 平均时间复杂度 最坏时间复杂度 解释
索引访问 L[i] O(1) O(1) 基于连续内存的地址计算,始终是常数时间。
索引赋值 L[i] = v O(1) O(1) 同上,直接定位并修改。
末尾追加 L.append(v) O(1) O(n) 均摊为O(1)。最坏情况发生在需要扩容时。
末尾弹出 L.pop() O(1) O(1) 只是移动内部的“size”指针,通常不涉及缩容,极快。
插入元素 L.insert(i,v) O(n) O(n) 在索引i处插入,需要将i之后的所有元素向右移动一位
删除元素 del L[i], L.pop(i) O(n) O(n) 删除索引i的元素,需要将i之后的所有元素向左移动一位
成员检查 v in L O(n) O(n) 线性扫描,从头到尾逐个比较。
切片 (读取) L[i:j] O(k) O(k) k=j-i。需要创建新列表并复制k个元素。
切片 (删除) del L[i:j] O(n-j) O(n-j) 需要将被删除部分之后的所有元素向左移动。
列表拼接 L1 + L2 O(m+n) O(m+n) 创建新列表,并复制L1L2的所有元素。m, n为长度。

代码示例:insertdel 的成本演示

# list_insertion_deletion_cost.py
import time

n = 100000

# 在列表头部插入
start_time = time.time()
l_head = []
for i in range(n):
    l_head.insert(0, i) # 每次都在索引0处插入,每次都移动所有已有元素
end_time = time.time()
print(f"在列表头部插入 {
     
     n} 个元素耗时: {
     
     end_time - start_time:.4f} 秒") # 这是一个O(n²)的操作

# 在列表尾部插入 (使用 append)
start_time = time.time()
l_tail = []
for i in range(n):
    l_tail.append(i) # 均摊 O(1)
end_time = time.time()
print(f"在列表尾部追加 {
     
     n} 个元素耗时: {
     
     end_time - start_time:.4f} 秒") # 这是一个O(n)的操作

# 从列表头部删除
l_to_delete = list(range(n))
start_time = time.time()
for _ in range(n):
    l_to_delete.pop(0) # 每次都从索引0处删除,每次都移动所有剩余元素
end_time = time.time()
print(f"从列表头部删除 {
     
     n} 个元素耗时: {
     
     end_time - start_time:.4f} 秒") # 这是一个O(n²)的操作

这个实验的结果会非常清晰地告诉你:对Python列表的头部进行频繁的插入和删除,是极其低效的行为! 这也是为什么我们需要队列(Queue)这种专门为头尾操作优化的数据结构。

2.2 链表:非连续内存的自由舞者

与数组将所有元素紧凑地存放在一块连续内存中不同,链表(Linked List)采取了一种完全不同的、更“自由”的存储策略。

链表的核心思想:

  • 节点 (Node): 链表的基本组成单位是节点。每个节点至少包含两部分信息:
    1. 数据域 (Data): 存储元素本身的数据。
    2. 指针域 (Pointer/Next): 存储下一个节点的内存地址。
  • 非连续存储: 链表的各个节点在内存中可以是任意分布的,它们不需要物理上相邻。
  • 链接关系: 节点之间的逻辑顺序是通过指针域串联起来的。第一个节点被称为“头节点”(Head),它的指针指向第二个节点;第二个节点的指针指向第三个,以此类推。最后一个节点的指针通常指向一个特殊值None(或NULL),表示链表的结束。

2.2.1 从零开始构建一个单向链表

为了深刻理解链表的运作机制,我们必须亲手实现它。

# linked_list_implementation.py

class Node:
    """定义链表的节点类。"""
    def __init__(self, data, next_node=None):
        self.data = data          # 数据域,存储节点的值
        self.next = next_node     # 指针域,存储下一个节点的引用

class SinglyLinkedList:
    """定义一个单向链表类,并实现其核心操作。"""
    def __init__(self):
        """初始化一个空链表。"""
        self._head = None         # 初始化头节点为None,表示链表为空

    def is_empty(self):
        """检查链表是否为空。"""
        return self._head is None # 如果头节点是None,则链表为空

    def prepend(self, data):
        """在链表头部添加一个新节点 (头插法)。O(1)操作。"""
        # 创建一个包含新数据的新节点
        # 这个新节点的 next 指针指向当前的头节点
        new_node = Node(data, self._head) # 新节点的next指向旧的头节点
        # 将链表的头节点更新为这个新创建的节点
        self._head = new_node             # 将链表的头指针更新为新节点
        
    def append(self, data):
        """在链表尾部添加一个新节点 (尾插法)。O(n)操作。"""
        new_node = Node(data) # 创建一个新节点,其next默认为None
        
        if self.is_empty(): # 如果链表是空的
            self._head = new_node # 新节点就是头节点
            return
            
        # 如果链表不为空,需要遍历到最后一个节点
        last_node = self._head # 从头节点开始
        while last_node.next: # 只要当前节点的next不为None,就继续向后移动
            last_node = last_node.next
        
        # 循环结束后,last_node就是最后一个节点
        last_node.next = new_node # 将原来的最后一个节点的next指针指向新节点

    def traverse(self):
        """遍历链表并打印所有节点的数据。"""
        print("遍历链表: ", end="") # 打印前缀
        current = self._head # 从头节点开始
        while current: # 只要当前节点不为None
            print(f"{
     
     current.data} -> ", end="") # 打印当前节点的数据
            current = current.next # 移动到下一个节点
        print("None") # 链表末尾打印None

    def find(self, target):
        """在链表中查找一个值,如果找到则返回True。O(n)操作。"""
        current = self._head # 从头节点开始
        while current: # 遍历
            if current.data == target: # 如果找到了目标值
                return True # 返回True
            current = current.next # 移动到下一个节点
        return False # 如果遍历完都没找到,返回False

    def remove(self, target):
        """从链表中删除第一个匹配目标值的节点。O(n)操作。"""
        if self.is_empty(): # 如果链表为空,直接返回
            return

        # Case 1: 要删除的是头节点
        if self._head.data == target: # 如果头节点就是要删除的目标
            self._head = self._head.next # 直接将头指针指向第二个节点即可
            return
            
        # Case 2: 要删除的是中间或尾部的节点
        prev_node = self._head # prev_node 指向当前节点的前一个节点
        current_node = self._head.next # current_node 指向当前要检查的节点
        
        while current_node: # 遍历
            if current_node.data == target: # 如果找到了要删除的节点
                # “跳过”这个节点
                # 将前一个节点的next指针,直接指向当前节点的下一个节点
                prev_node.next = current_node.next # 实现删除
                return
            
            # 如果没找到,两个指针都向后移动一位
            prev_node = current_node
            current_node = current_node.next

# --- 使用我们自己实现的链表 ---
my_list = SinglyLinkedList() # 创建一个空链表实例
print(f"链表是否为空? {
     
     my_list.is_empty()}") # 检查是否为空

print("\n在头部添加 10, 20, 30...") # 打印操作描述
my_list.prepend(10) # 链表: 10 -> None
my_list.prepend(20) # 链表: 20 -> 10 -> None
my_list.prepend(30) # 链表: 30 -> 20 -> 10 -> None
my_list.traverse() # 遍历并打印
print(f"链表是否为空? {
     
     my_list.is_empty()}") # 再次检查

print("\n在尾部添加 5...") # 打印操作描述
my_list.append(5) # 链表: 30 -> 20 -> 10 -> 5 -> None
my_list.traverse() # 遍历并打印

print(f"\n查找值 20: {
     
     my_list.find(20)}") # 查找存在的值
print(f"查找值 100: {
     
     my_list.find(100)}") # 查找不存在的值

print("\n删除值 20...") # 打印操作描述
my_list.remove(20) # 删除中间节点
my_list.traverse() # 遍历并打印

print("\n删除值 30 (头节点)...") # 打印操作描述
my_list.remove(30) # 删除头节点
my_list.traverse() # 遍历并打印

print("\n删除值 5 (尾节点)...") # 打印操作描述
my_list.remove(5) # 删除尾节点
my_list.traverse() # 遍历并打印

这个实现过程,能让你深刻理解链表的插入和删除操作是如何通过改变节点的next指针来完成的。尤其是删除操作,需要一个prev_node来“记住”前一个节点,这是链表操作中的一个经典模式。

2.2.2 数组 vs. 链表:世纪对决

现在,我们可以对这两种核心的线性结构进行一次全面的性能对比。

特性 数组 (Python list) 链表 (我们实现的) 优胜者 & 原因
内存布局 连续 分散 数组: 内存连续性带来了缓存友好性,CPU在访问一个元素后,很可能已经把它的邻居也加载到了高速缓存中,后续访问更快。
随机访问 O(1) O(n) 数组: 绝对优势。链表要访问第i个元素,必须从头节点开始,一步一步地跳i次。
头部插入/删除 O(n) O(1) 链表: 绝对优势。链表的头插/头删只需要修改头指针,是常数时间操作。数组需要移动所有元素。
尾部插入 O(1) (均摊) O(n) (朴素实现) 数组: append是O(1)的。我们实现的链表尾插需要遍历到末尾,是O(n)。
中间插入/删除 O(n) O(n) 平手: 两者都需要O(n)。数组慢在移动元素,链表慢在查找要操作的位置。
空间开销 较小 较大 数组: 只需要存储数据本身。链表每个节点都需要额外的空间来存储next指针。

优化链表的尾部插入:
我们上面实现的链表,尾部插入是O(n),这是一个痛点。我们可以通过在链表类中额外维护一个_tail指针,始终指向最后一个节点,来将尾部插入的复杂度优化到O(1)

代码示例:带尾指针的优化链表

class OptimizedSinglyLinkedList:
    """一个带有头尾指针的单向链表,优化了尾部操作。"""
    def __init__(self):
        self._head = None # 头指针
        self._tail = None # 尾指针
        self._size = 0    # 维护一个size属性,获取长度是O(1)

    def __len__(self):
        """让链表支持len()函数。"""
        return self._size

    def append(self, data):
        """优化的尾部插入方法。O(1)操作。"""
        new_node = Node(data) # 创建新节点
        if self._tail: # 如果链表不为空 (即尾指针存在)
            self._tail.next = new_node # 将当前的尾节点的next指向新节点
            self._tail = new_node      # 更新尾指针为这个新节点
        else: # 如果链表为空
            self._head = new_node # 新节点既是头也是尾
            self._tail = new_node
        self._size += 1 # 尺寸加一
    
    # prepend, traverse, find, remove 等方法也需要相应地更新_tail和_size
    # (此处为简化省略,但这是实现完整功能所必需的)

结论与应用场景:

  • 选择数组 (Python list):

    • 当你需要频繁地通过索引随机访问元素时。
    • 当你的主要操作是在列表尾部进行添加和删除时。
    • 当对内存占用比较敏感时。
    • 绝大多数情况下,Python的list都是一个足够好、足够快的选择。
  • 选择链表:

    • 当你的主要需求是频繁地在数据结构的头部进行插入和删除时。这是链表最核心的优势场景。
    • 当你需要实现一个**真正的队列(Queue)**时(我们下一节会讲)。
    • 当你处理的数据量极大,无法在内存中开辟一块巨大的连续空间时,链表的非连续存储特性会成为优势。
    • 当插入和删除操作的频率远高于访问操作时。

2.2.3 链表的变体:双向链表与循环链表

双向链表 (Doubly Linked List):

  • 结构: 每个节点除了有next指针指向下一个节点,还有一个prev指针指向上一个节点。
  • 优势:
    1. 可以双向遍历
    2. 删除一个节点变得更容易。如果给你一个节点的引用node_to_delete,你不需要像单向链表那样从头遍历来找到它的前一个节点。你可以直接通过node_to_delete.prev来找到前驱,然后执行 node_to_delete.prev.next = node_to_delete.nextnode_to_delete.next.prev = node_to_delete.prev 来完成删除。这使得在给定节点引用下的删除操作是O(1)的。
  • 劣势: 每个节点需要额外存储一个prev指针,空间开销更大。插入和删除操作需要同时维护nextprev两个指针,逻辑稍微复杂一点。

代码片段:双向链表节点定义

class DoublyNode:
    def __init__(self, data, prev_node=None, next_node=None):
        self.data = data
        self.prev = prev_node # 指向前一个节点的指针
        self.next = next_node # 指向后一个节点的指针

循环链表 (Circular Linked List):

  • 结构: 链表的最后一个节点的next指针不再指向None,而是指向头节点,形成一个环。
  • 优势:
    1. 从任何一个节点出发,都可以遍历到整个链表。
    2. 在某些特定算法(如约瑟夫环问题)中非常有用。
    3. 可以用来实现某些循环缓冲区。
  • 劣势: 遍历时需要小心处理终止条件,否则会陷入无限循环。通常需要让遍历指针回到起点来判断循环结束。

2.3 栈:后进先出 (LIFO) 的哲学

栈(Stack)是一种特殊的线性数据结构,它遵循**后进先出(Last-In, First-Out, LIFO)**的原则。你可以把它想象成一摞盘子:你最后放上去的盘子,总是第一个被拿走。

栈的核心操作:

  • push(item): 将一个元素压入栈顶。
  • pop(): 从栈顶弹出一个元素,并返回它。
  • peek() (或 top()): 查看栈顶元素,但不弹出。
  • is_empty(): 检查栈是否为空。

所有这些核心操作的时间复杂度都应该是O(1)

2.3.1 Python中的栈实现

在Python中,有多种方式可以实现一个栈。

方式一:使用Python list
Python的list类型提供了append()pop()方法,它们都作用于列表的尾部,并且都是O(1)(均摊)的。这完美地契合了栈的LIFO特性。因此,使用list来模拟栈是最简单、最直接、也最常见的方式

# stack_using_list.py
stack = [] # 用一个空列表来代表栈

# Push 操作
stack.append('A') # 'A'入栈
stack.append('B') # 'B'入栈
stack.append('C') # 'C'入栈

print(f"当前栈 (列表表示): {
     
     stack}") # 打印栈内容

# Peek 操作 (查看栈顶)
# 列表的最后一个元素就是栈顶
top_element = stack[-1] # 获取列表的最后一个元素
print(f"栈顶元素是: {
     
     top_element}") # 打印栈顶元素

# Pop 操作
popped_item = stack.pop() # 从列表尾部弹出一个元素
print(f"弹出的元素是: {
     
     popped_item}") # 打印弹出的元素
print(f"Pop操作后的栈: {
     
     stack}") # 打印操作后的栈

popped_item = stack.pop() # 再次弹出
print(f"弹出的元素是: {
     
     popped_item}")
print(f"Pop操作后的栈: {
     
     stack}")

# is_empty 操作
is_empty = not stack # 一个空列表在布尔上下文中是False,取反即为True
print(f"栈现在是否为空? {
     
     is_empty}") # 打印是否为空

方式二:使用collections.deque
collections.deque(双端队列,Double-ended Queue)是一个专门为在两端进行快速添加和删除操作而设计的序列类型。它的append()(右端添加)和pop()(右端弹出)操作都是严格的O(1),没有list扩容时那种最坏情况的性能波动。

对于一个严格要求性能稳定性的栈实现,或者当栈的规模可能非常巨大,deque是比list更好的选择。

# stack_using_deque.py
from collections import deque

stack = deque() # 使用deque创建一个栈

# Push 操作 (使用 append)
stack.append('X')
stack.append('Y')
stack.append('Z')

print(f"当前栈 (deque表示): {
     
     stack}")

# Peek 操作
top_element = stack[-1] # deque也支持索引访问最后一个元素
print(f"栈顶元素是: {
     
     top_element}")

# Pop 操作
popped_item = stack.pop()
print(f"弹出的元素是: {
     
     popped_item}")
print(f"Pop操作后的栈: {
     
     stack}")

方式三:使用queue.LifoQueue
queue模块提供的是线程安全的数据结构,用于在多线程编程中安全地传递数据。LifoQueue就是一个后进先出的队列,也就是一个线程安全的栈。如果你是在单线程环境中使用,它会比listdeque慢,因为有额外的锁开销。但如果你在多线程程序中需要一个共享的栈,这应该是你的首选。

2.3.2 栈的经典应用场景

栈的LIFO特性,使得它在计算机科学中无处不在。

应用一:函数调用栈
这是栈最底层、最核心的应用。当你调用一个函数时,操作系统会把这个函数的返回地址、参数、局部变量等信息打包成一个“栈帧(Stack Frame)”,然后压入一个叫做“调用栈”的内存区域。当函数返回时,再从栈顶弹出它的栈帧,恢复到调用前的状态。递归函数之所以能工作,就是依赖于调用栈。如果递归深度太深,会导致调用栈溢出(Stack Overflow)。

应用二:括号匹配
这是一个经典的面试题。如何检查一个字符串中的括号(如(), [], {})是否是成对且正确嵌套的?

# balanced_parentheses.py
def is_balanced(s: str) -> bool:
    """使用栈来检查括号是否平衡。"""
    stack = [] # 使用列表作为栈
    # 创建一个映射,将闭括号映射到其对应的开括号
    mapping = {
   
   ")": "(", "]": "[", "}": "{"}
    
    for char in s: # 遍历字符串中的每个字符
        if char in mapping: # 如果当前字符是一个闭括号
            # 1. 尝试从栈顶弹出一个元素。如果栈为空,说明没有对应的开括号,用一个虚拟值'#'代替。
            top_element = stack.pop() if stack else '#'
            
            # 2. 检查弹出的开括号是否与当前闭括号匹配
            if mapping[char] != top_element: # 如果不匹配
                return False # 括号不平衡,立即返回False
        else: # 如果当前字符是一个开括号
            stack.append(char) # 将其压入栈中
            
    # 循环结束后,如果栈是空的,说明所有开括号都被正确匹配了
    # 如果栈不为空,说明有未被匹配的开括号
    return not stack # 返回栈是否为空的布尔值

# 测试用例
print(f"'()[]{
    
    {}}' 是否平衡? {is_balanced('()[]{}')}") # True
print(f"'([)]' 是否平衡? {
     
     is_balanced('([)]')}")       # False
print(f"'{
    
    {[]}}' 是否平衡? {is_balanced('{
    
    {[]}}')}")   # True
print(f"'(' 是否平衡? {
     
     is_balanced('(')}")           # False
  • 算法思路: 遍历字符串。遇到开括号,就压入栈中。遇到闭括号,就从栈顶弹出一个开括号进行匹配。如果匹配成功,继续;如果栈为空或匹配失败,则字符串不平衡。遍历结束后,如果栈为空,则字符串平衡,否则不平衡。

应用三:逆波兰表达式求值 (后缀表达式)
逆波兰表达式(Reverse Polish Notation, RPN)是一种将运算符写在操作数后面的数学表达式。例如,3 4 + 等价于 3 + 4。求值RPN表达式是栈的完美应用场景。

# evaluate_rpn.py
def eval_rpn(tokens: list[str]) -> int:
    """使用栈来计算逆波兰表达式的值。"""
    stack = [] # 使用列表作为栈
    
    for token in tokens: # 遍历表达式中的每个标记
        if token in "+-*/": # 如果标记是运算符
            # 从栈顶弹出两个操作数
            # 注意顺序:先弹出的是右操作数
            right_operand = stack.pop() # 弹出右操作数
            left_operand = stack.pop()  # 弹出左操作数
            
            # 根据运算符进行计算
            if token == "+":
                result = left_operand + right_operand
            elif token == "-":
                result = left_operand - right_operand
            elif token == "*":
                result = left_operand * right_operand
            else: # token == "/"
                # 注意Python中除法的结果是浮点数,题目通常要求向零取整
                result = int(left_operand / right_operand)
                
            stack.append(result) # 将计算结果压回栈中
        else: # 如果标记是数字
            stack.append(int(token)) # 将其转换为整数并压入栈中
            
    # 遍历结束后,栈中应该只剩下一个元素,即最终结果
    return stack.pop() # 弹出并返回最终结果

# 测试用例
expression1 = ["2", "1", "+", "3", "*"] # (2 + 1) * 3 = 9
expression2 = ["4", "13", "5", "/", "+"] # 4 + (13 / 5) = 4 + 2 = 6
print(f"表达式 {
     
     expression1} 的值是: {
     
     eval_rpn(expression1)}")
print(f"表达式 {
     
     expression2} 的值是: {
     
     eval_rpn(expression2)}")
  • 算法思路: 遍历表达式。遇到数字,就压入栈。遇到运算符,就从栈顶弹出两个数字进行运算,然后将结果再压回栈中。遍历结束后,栈中剩下的唯一一个数字就是最终结果。

2.4 队列:先进先出 (FIFO) 的公平之道

与栈的LIFO原则相对,队列(Queue)遵循的是**先进先出(First-In, First-Out, FIFO)**的原则。这就像在超市排队结账,最先来排队的人,总是第一个结账离开。

队列的核心操作:

  • enqueue(item): 将一个元素加入队尾(入队)。
  • dequeue(): 从队头移除一个元素,并返回它(出队)。
  • peek() (或 front()): 查看队头元素,但不移除。
  • is_empty(): 检查队列是否为空。

同样,所有这些核心操作的时间复杂度都应该是O(1)

2.4.1 Python中的队列实现:为何不能用list

如果我们尝试用Python的list来模拟队列:

  • 入队 (enqueue): 我们可以用list.append(item),这是一个O(1)的操作,很好。
  • 出队 (dequeue): 我们需要从队列的头部移除元素,对应到列表就是索引0的位置。这个操作需要调用list.pop(0)
  • 陷阱: 我们在2.1.1节已经知道,list.pop(0)是一个**O(n)**的操作,因为它需要将所有后续元素向左移动一位。当队列很长时,这会变得极其低效。

结论:永远不要用list来实现一个真正的队列!

正确的实现方式:collections.deque
collections.deque(双端队列)是专门为此而生的。

  • 它支持从**右端(尾部)**快速添加:deque.append(item) (对应 enqueue)
  • 它支持从**左端(头部)**快速移除:deque.popleft() (对应 dequeue)

这两个操作都是严格的O(1)时间复杂度,因为deque底层是用双向链表实现的,对两端的操作都只需要修改头尾指针。

# queue_using_deque.py
from collections import deque

queue = deque() # 使用deque创建一个队列

# Enqueue 操作
queue.append('任务1') # "任务1" 入队
queue.append('任务2') # "任务2" 入队
queue.append('任务3') # "任务3" 入队

print(f"当前队列 (deque表示): {
     
     queue}") # 打印队列内容

# Peek 操作 (查看队头)
# deque的第一个元素就是队头
front_element = queue[0] # 获取deque的第一个元素
print(f"队头元素是: {
     
     front_element}") # 打印队头元素

# Dequeue 操作
dequeued_item = queue.popleft() # 从队列左端(头部)弹出一个元素
print(f"出队的元素是: {
     
     dequeu

你可能感兴趣的:(python,开发语言)