Python 算法知识点从基础到进阶

最近因为要找工作了,要开始刷算法题了,但是工作几年好多都忘记了,所以给自己写了一个算法 刷题从基础到进阶总结,让自己把之前丢失的算法知识找回来。

这一套笔记,我建议按照 Python 语法回顾 → 常用数据结构 → 核心算法思想 → 经典题型 → 综合面试训练 的顺序展开。

适合人群: 有几年开发经验、曾经学过算法,但因为长期从事业务开发,对算法和数据结构逐渐生疏,准备重新刷 LeetCode、参加技术面试的程序员。也适合有代码基础,准备学习整套算法体系的大学生

每个章节完成后,我会在每个章节末尾放几个题目来给大家复习巩固

第一章:Python 算法基础

1.1 Python 算法高频语法

这一节只讲刷算法题真正需要的 Python 语法,不展开 Web 开发、异步编程、装饰器等内容。

1.1.1 变量、运算符与整除

复制代码
a = 10
b = 3

print(a + b)   # 13 加法
print(a - b)   # 7  减法
print(a * b)   # 30 乘法
print(a / b)   # 3.333... 真除法
print(a // b)  # 3  向下取整除法
print(a % b)   # 1  取模
print(a ** b)  # 1000 幂运算

笔试重点 :/ 和 // 的区别。

在二分查找中,我们经常这样计算中间位置:

复制代码
mid = (left + right) // 2

注意:Python 的 // 是向负无穷取整,而不是简单去掉小数部分。

复制代码
print(-7 // 2)  # -4
print(int(-7 / 2))  # -3

Python 的整数没有固定的 32 位或 64 位范围限制,但大整数运算也并非完全没有成本。

1.1.2 条件判断与循环

复制代码
# 条件判断
if a > b:
    print("a 更大")
elif a == b:
    print("相等")
else:
    print("b 更大")

# 遍历 0 到 4
for i in range(5):
    print(i)

# 遍历 2 到 7(不包含 8)
for i in range(2, 8):
    print(i)

# 从 5 递减到 1
for i in range(5, 0, -1):
    print(i)

# while 循环
left, right = 0, 10
while left < right:
    left += 1

刷题中,range()、for、while 是最常见的循环形式。

1.1.3 列表 List

Python 的 list 是刷算法题最常使用的容器之一,可以理解为动态数组。

复制代码
nums = [10, 20, 30, 40, 50]

# 访问
print(nums[0])     # 10
print(nums[-1])    # 50

# 切片,左闭右开
print(nums[1:4])   # [20, 30, 40]
print(nums[::-1])  # 反转后的新列表

# 添加元素
nums.append(60)

# 删除末尾元素
nums.pop()

# 获取长度
n = len(nums)

# 遍历元素
for num in nums:
    print(num)

# 遍历索引和值
for i, num in enumerate(nums):
    print(i, num)

算法题中的高频操作:

操作 写法 时间复杂度
索引访问 nums[i] O(1)
末尾添加 nums.append(x) 均摊 O(1)
末尾删除 nums.pop() O(1)
头部删除 nums.pop(0) O(n)
查找元素 x in nums O(n)
排序 nums.sort() O(n log n)
切片 nums[a:b] O(k)

其中 k 为切片长度。

一个常见的二维数组陷阱:

复制代码
# 错误:每一行引用的是同一个列表
grid = [[0] * 3] * 3

# 正确:每一行都是独立列表
grid = [[0] * 3 for _ in range(3)]

这是动态规划、矩阵题中非常容易出现的 Bug。

1.1.4 字典 Dict:哈希表

哈希表是算法面试中出现频率非常高的数据结构。

它最重要的作用是:使用额外空间,换取更快的查找速度。

复制代码
count = {}

nums = [1, 2, 2, 3, 3, 3]

for num in nums:
    count[num] = count.get(num, 0) + 1

print(count)  # {1: 1, 2: 2, 3: 3}

常见操作:

复制代码
d = {"a": 1, "b": 2}

d["a"]              # 获取
d.get("c", 0)       # 不存在时返回 0
d["c"] = 3          # 插入或更新
del d["a"]          # 删除

for key, value in d.items():
    print(key, value)

一般情况下,字典的查询、插入、删除平均为 O(1),最坏情况可能退化。

1.1.5 集合 Set:快速判断是否存在

复制代码
seen = set()

seen.add(3)
seen.add(5)

print(3 in seen)  # True
print(4 in seen)  # False

seen.remove(3)    # 不存在会抛异常
seen.discard(10)  # 不存在也不会报错

典型用途是数组去重:

复制代码
nums = [1, 2, 2, 3, 3, 4]
unique = set(nums)

注意:集合不保证按照原列表顺序输出。如果题目要求保留首次出现的顺序,可以考虑:

复制代码
unique = list(dict.fromkeys(nums))

1.1.6 字符串 String

Python 字符串不可变。

复制代码
s = "hello"

print(s[0])       # h
print(s[::-1])    # olleh
print(s[:3])      # hel

print(s.lower())
print(s.upper())

print("".join(["a", "b", "c"]))  # abc

如果需要频繁构建字符串:

复制代码
chars = []

for c in "hello":
    chars.append(c)

result = "".join(chars)

比在循环里不断拼接字符串更容易获得稳定的线性性能。

1.1.7 刷题必备的 Python 标准库

工具 作用 典型使用场景
collections.Counter 统计元素频率 字母异位词
collections.defaultdict 自动提供默认值 分组、邻接表
collections.deque 双端队列 BFS、单调队列
heapq 最小堆 Top K、优先队列
bisect 二分插入位置 有序数组
functools.cache 缓存函数结果 记忆化搜索

例如:

复制代码
from collections import Counter, defaultdict, deque
from heapq import heappush, heappop
from bisect import bisect_left

# 计数
print(Counter("aabbc"))

# 默认字典
graph = defaultdict(list)
graph[1].append(2)

# 双端队列
q = deque([1, 2, 3])
q.append(4)
print(q.popleft())  # 1

# 最小堆
heap = []
heappush(heap, 5)
heappush(heap, 2)
print(heappop(heap))  # 2

# 二分查找插入点
nums = [1, 3, 5, 7]
print(bisect_left(nums, 4))  # 2

1.2 刷题必备的数据结构

这一节先建立数据结构的直觉,后续章节再分别深入讲解。

1.3 时间复杂度与空间复杂度

1.3.1 什么是时间复杂度?

时间复杂度衡量的是:随着输入规模 n 增大,算法执行的操作次数如何增长。

1.3.2 O(1):常数时间

复制代码
def get_first(nums):    return nums[0]

1.3.3 O(n):线性时间

复制代码
def find_max(nums):
    ans = nums[0]

    for num in nums:
        ans = max(ans, num)

    return ans

1.3.4 O(n²):平方时间

复制代码
def print_pairs(nums):
    n = len(nums)

    for i in range(n):
        for j in range(n):
            print(nums[i], nums[j])

1.3.5 O(log n):对数时间

复制代码
def binary_search(nums, target):
    left, right = 0, len(nums) - 1

    while left <= right:
        mid = (left + right) // 2

        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid - 1

    return -1

1.3.6 空间复杂度

空间复杂度主要关注算法使用的额外内存如何随输入规模增长。

复制代码
# 额外空间 O(1)
def sum_nums(nums):
    total = 0
    for num in nums:
        total += num
    return total


# 额外空间 O(n)
def copy_nums(nums):
    result = []
    for num in nums:
        result.append(num)
    return result

注意递归函数也需要考虑调用栈空间,例如深度为 n 的递归,调用栈可能占用 O(n) 空间。

1.4 算法思维:从暴力解到优化解

这是整套笔记最需要反复训练的部分。

不要一开始就背最优解,而应该训练自己形成如下流程:

举一个最经典的例子。

题目:两数之和(LeetCode 1)

给定整数数组 nums 和整数 target,返回两个元素的下标,使它们的和等于 target。

假设恰好存在一个答案,不能重复使用同一个元素。

输入:

复制代码
nums = [2, 7, 11, 15]
target = 9

输出:

复制代码
[0, 1]

因为 nums[0] + nums[1] == 9。

解法一:暴力枚举

最直接的想法是检查每一对元素。

复制代码
def two_sum(nums, target):
    n = len(nums)

    for i in range(n):
        for j in range(i + 1, n):
            if nums[i] + nums[j] == target:
                return [i, j]

    return []

print(two_sum([2, 7, 11, 15], 9))

分析:

  • 外层循环枚举第一个元素。

  • 内层循环枚举第二个元素。

  • 判断两个元素之和是否等于目标值。

时间复杂度 O(n²),额外空间复杂度 O(1)。

解法二:哈希表优化

核心思想:对于每个 num,我们不需要遍历之前所有的元素,只需要检查 target - num 有没有出现过。

复制代码
def two_sum(nums, target):
    seen = {}

    for i, num in enumerate(nums):
        need = target - num

        if need in seen:
            return [seen[need], i]

        seen[num] = i

    return []

print(two_sum([2, 7, 11, 15], 9))

哈希表解法执行过程

优化后的复杂度:

解法 时间复杂度 额外空间
双重循环 O(n²) O(1)
哈希表 平均 O(n) O(n)

1.5 算法中的常见边界问题

很多错误不在算法思路,而在边界条件。

问题 常见错误 需要注意
空数组 直接访问 nums[0] 先判断是否为空
单元素数组 假设至少有两个元素 考虑 n = 1
重复元素 错误地去重 判断题目是否允许重复
负数 误用 // 的行为 Python 向下取整
二分查找 左右边界更新错误 统一使用明确的区间规则
递归 缺少终止条件 注意递归深度
浮点数 直接比较相等 适当使用误差容忍
数组遍历 遍历时删除元素 防止跳过元素

第二章:数组与字符串

从这一章开始,正式进入算法刷题。

数组与字符串是整个算法体系的基础。 后面的双指针、滑动窗口、二分查找、前缀和、动态规划,都与本章的知识密切相关。

不过,这一章不急着学习复杂的算法技巧,而是重点恢复三项能力:

  • 遍历能力: 能正确地扫描、查找、统计和更新数组。

  • 索引能力: 熟悉下标、区间、原地修改,避免边界错误。

  • 优化思维: 从直接模拟开始,逐渐学会利用前缀和、额外空间和数组自身的性质优化代码。

2.1.1 数组简单介绍

数组是将一组元素按照顺序组织起来的数据结构,可以通过索引访问元素。

需要注意,Python 的 list 与 C/C++ 中的固定类型数组并不完全相同。

Python list 实际上是一个支持动态扩容的引用数组,存储的是对象引用,而不是直接连续存放所有对象的值。

2.1.2 数组操作与复杂度

操作 Python 写法 时间复杂度
读取元素 nums[i] O(1)
修改元素 nums[i] = x O(1)
尾部添加 nums.append(x) 均摊 O(1)
尾部删除 nums.pop() O(1)
指定位置插入 nums.insert(i, x) O(n)
删除首元素 nums.pop(0) O(n)
查找元素 x in nums O(n)
排序 nums.sort() O(n log n)
复制数组 nums.copy() O(n)

为什么 nums.pop(0) 是 O(n)?

因为删除第一个元素后,后面的引用都需要向前移动。

这意味着:

复制代码
while nums:    
    nums.pop(0)

如果原数组长度为 n,整体复杂度会达到 O(n²)。

如果需要频繁从两端插入或删除,应该考虑 collections.deque。

2.2 数组遍历:所有数组算法的起点

2.2.1 三种最常见的遍历方式

复制代码
nums = [10, 20, 30, 40]

# 方式一:只需要元素
for num in nums:
    print(num)

# 方式二:需要下标
for i in range(len(nums)):
    print(i, nums[i])

# 方式三:同时需要下标和值
for i, num in enumerate(nums):
    print(i, num)

什么时候使用哪一种?

  • 只进行求和、统计、判断时,优先使用 for num in nums。

  • 需要修改原数组、比较相邻元素时,使用索引遍历。

  • 需要同时访问位置和值时,使用 enumerate()。

2.2.2 示例:寻找第二大元素

题目:给定整数数组,返回第二大的不同数值,如果不存在则返回 None。

例如:

复制代码
nums = [5, 8, 3, 8, 6]# 返回 6,而不是 8

最容易想到的是排序:

复制代码
def second_max(nums):
    unique = sorted(set(nums), reverse=True)
    return unique[1] if len(unique) >= 2 else None

但排序不是必须的。

我们可以在一次遍历中维护两个变量:

  • first:当前最大的元素。

  • second:当前第二大的不同元素。

    def second_max(nums):
    first = None
    second = None

    复制代码
      for num in nums:
          if first is None or num > first:
              second = first
              first = num
    
          elif num != first and (
              second is None or num > second
          ):
              second = num
    
      return second

    print(second_max([5, 8, 3, 8, 6])) # 6
    print(second_max([-5, -2, -9])) # -5
    print(second_max([3, 3, 3])) # None

时间复杂度 O(n),额外空间 O(1)。

这里出现了一个非常重要的算法思想:

在遍历过程中维护某种状态,就有机会避免重复遍历或排序。

这种思维会在后面的贪心算法、动态规划中反复出现。

2.3 原地修改数组:学会区分读写位置

原地修改是数组题的高频考点。

所谓原地修改,就是尽量直接利用原数组存放结果,不再额外创建一个等长数组。

先从一道经典题开始。

例题 1:移除元素(LeetCode 27)

给定数组 nums 和整数 val,原地移除所有值等于 val 的元素,返回剩余元素数组。这里频繁的删除会导致时间复杂度为O(n^2)

解法一:创建新数组
复制代码
def remove_element(nums, val):
    result = []

    for num in nums:
        if num != val:
            result.append(num)

    nums[:len(result)] = result
    return len(result)

这个方法容易理解,但需要 O(n) 额外空间。

解法二:原地覆盖

我们可以只使用一个额外变量 slow。

  • fast:负责遍历原数组。

  • slow:负责指向下一个应该写入的位置。

    def remove_element(nums, val):
    slow = 0

    复制代码
      for fast in range(len(nums)):
          if nums[fast] != val:
              nums[slow] = nums[fast]
              slow += 1
    
      return slow

    nums = [3, 2, 2, 3]
    k = remove_element(nums, 3)

    print(k) # 2
    print(nums[:k]) # [2, 2]

F 表示 fast(读取位置),S 表示 slow(写入位置)。绿色背景表示已确认的有效前缀。

时间复杂度 O(n),额外空间复杂度 O(1)。

关键理解:循环不变量

在每次循环开始前,nums[0:slow] 始终保存了已经处理过的、需要保留的元素。

这叫作循环不变量。

有了这个不变量,我们就能解释代码为什么正确,而不只是知道它能通过样例。

严格来说,本例属于同向双指针技巧。这里先用于理解原地覆盖,第四章会系统讲解双指针。

2.4 字符串的本质与常见处理方式

2.4.1 Python 字符串不可变

字符串对象创建之后,其内容不能被修改。

复制代码
s = "hello"
s[0] = "H"  # TypeError

字符串不可变,指的是字符串对象创建之后,其内容不能被修改。

但这并不意味着变量 s 不能重新赋值。

复制代码
s = "hello"
s = "H" + s[1:]

print(s)  # Hello

频繁的拼接操作并不推荐,看这段代码:

复制代码
s = ""for i in range(10000):    s += "a"

第 1 次:a

第 2 次:aa

第 3 次:aaa

第 4 次:aaaa

第 5 次:aaaaa

......

如果每次拼接都需要创建新字符串,并复制已有内容,那么总共需要处理的字符数量近似为:

复制代码
chars = []

for i in range(10000):
    chars.append("a")

s = "".join(chars)

print(len(s))

这种方式通常具有 O(n) 的总时间复杂度,其中 n 是最终字符串长度。

2.4.2 字符串常用操作

操作 代码 说明
获取长度 len(s) 字符数量
获取字符 s[i] 索引访问
字符串反转 s[::-1] 创建新字符串
转小写 s.lower() 返回新字符串
分割 s.split() 返回列表
连接 "".join(arr) 拼接字符串
查找 s.find("abc") 返回首次位置或 -1
判断字母数字 c.isalnum() 返回布尔值
判断数字 c.isdigit() 返回布尔值

2.4.3 示例:反转字符串

如果只是输出反转后的字符串:

复制代码
def reverse_string(s):    return s[::-1]

但如果题目要求原地修改字符数组:

复制代码
def reverse_chars(chars):    left = 0    right = len(chars) - 1    while left < right:        chars[left], chars[right] = (            chars[right], chars[left]        )        left += 1        right -= 1

这里使用了左右双指针。

它体现了一个非常重要的区别:

返回反转后的新字符串,与原地修改数组,是两个不同的要求。

2.5 前缀和:将重复计算变成快速查询

前缀和是数组算法中的核心优化技巧之一。

我要求数组每三个的前缀和,例如:

复制代码
nums = [2, 4, 1, 7, 3]

2.5.1 暴力解法

复制代码
def range_sum(nums, left, right):
    total = 0

    for i in range(left, right + 1):
        total += nums[i]

    return total

单次查询最坏 O(n)。

如果有 q 次查询,总时间复杂度最坏可达到 O(nq)。

2.5.2 前缀和的核心思想

我们提前计算:

也就是说,prefix[i] 表示原数组前 i 个元素的和。

为了方便计算,我们特意增加一个初始元素 prefix[0] = 0。

因为:

  • prefix[right+1] 包含了从 0 到 right 的所有元素。

  • prefix[left] 包含了从 0 到 left - 1 的所有元素。

  • 两者相减,就只剩 [left, right] 区间。

2.5.4 代码实现

复制代码
class NumArray:
    def __init__(self, nums):
        n = len(nums)
        self.prefix = [0] * (n + 1)

        for i in range(n):
            self.prefix[i + 1] = (
                self.prefix[i] + nums[i]
            )

    def sumRange(self, left, right):
        return (
            self.prefix[right + 1]
            - self.prefix[left]
        )


obj = NumArray([2, 4, 1, 7, 3])

print(obj.sumRange(1, 3))  # 12
print(obj.sumRange(0, 4))  # 17

这就是 LeetCode 303. 区域和检索 - 数组不可变 的核心解法。

复杂度分析:

操作 时间复杂度
构建前缀和 O(n)
单次区间求和 O(1)
q 次查询总成本 O(n + q)
额外空间 O(n)

2.5.5 前缀和适用的场景

看到这些题目描述时,可以优先思考前缀和:

  • 多次查询某一段连续数组的和。

  • 统计某一区间内满足条件的元素数量。

  • 计算连续子数组的和是否满足条件。

  • 需要快速获取二维矩阵某个矩形区域的和。

一个容易混淆的点是:

前缀和特别适合数组内容不变、查询很多的场景。

如果元素经常更新,普通前缀和需要重新维护,后面会学习树状数组、线段树等更适合动态更新的结构。

2.6 差分数组:高效处理区间修改

前缀和解决的是区间查询。

那么,如果我们要频繁修改某个区间呢?

例如:

复制代码
nums = [0, 0, 0, 0, 0]

我们需要执行:

  • 区间 [1, 3] 全部加 2。

  • 区间 [2, 4] 全部加 3。

直接循环当然可以,但如果有成千上万次区间更新,效率会很差。

2.6.1 暴力解法

复制代码
def add_range(nums, left, right, val):
    for i in range(left, right + 1):
        nums[i] += val

单次区间更新最坏 O(n)。

2.6.2 差分思想

我们不直接修改整个区间,而是记录变化发生的边界。

对于闭区间 [left, right] 加上 val:

复制代码
diff[left] += val
diff[right + 1] -= val

最后对 diff 求前缀和,就能恢复出修改结果。

复制代码
def range_add(n, updates):
    diff = [0] * (n + 1)

    for left, right, val in updates:
        diff[left] += val
        diff[right + 1] -= val

    nums = [0] * n
    current = 0

    for i in range(n):
        current += diff[i]
        nums[i] = current

    return nums


updates = [
    (1, 3, 2),
    (2, 4, 3)
]

print(range_add(5, updates))
# [0, 2, 5, 5, 3]

为什么这样能实现区间加法?

因为 diff[left] += val 意味着从 left 开始增加 val,而 diff[right + 1] -= val 意味着从 right 的下一位开始取消这次增量。

复杂度

假设 n 为数组长度,q 为区间修改次数:

  • 创建差分数组 O(n)。

  • 执行 q 次区间更新 O(q)。

  • 恢复最终数组 O(n)。

总时间复杂度为 O(n + q),额外空间复杂度为 O(n)。

注意:普通差分数组适用于先批量更新,最后统一得到结果的场景,不是说它能同时支持任意在线区间查询都为 O(1)。

2.7 二维数组与矩阵

数组题并不总是一维的。

图论、动态规划、岛屿问题、矩阵遍历都会涉及二维数组。

2.7.1 创建二维数组

复制代码
rows = 3
cols = 4

grid = [[0] * cols for _ in range(rows)]

二维数组的访问方式:

复制代码
grid[0][0] = 1
grid[1][2] = 5

2.7.2 二维数组遍历

复制代码
grid = [
    [1, 2, 3],
    [4, 5, 6],
    [7, 8, 9]
]

rows = len(grid)
cols = len(grid[0])

for i in range(rows):
    for j in range(cols):
        print(grid[i][j])

如果有 m 行、n 列,需要访问每个元素一次,则时间复杂度为 O(mn)。

2.7.3 四方向移动

矩阵搜索中,经常需要访问当前位置的上下左右四个方向。

复制代码
directions = [
    (-1, 0),  # 上
    (1, 0),   # 下
    (0, -1),  # 左
    (0, 1)    # 右
]

二维网格与四方向移动

当前

当前位置为 (r, c),四个相邻位置分别为 (r-1,c)、(r+1,c)、(r,c-1)、(r,c+1)。

遍历代码:

复制代码
def get_neighbors(grid, r, c):
    rows = len(grid)
    cols = len(grid[0])

    directions = [
        (-1, 0),
        (1, 0),
        (0, -1),
        (0, 1)
    ]

    result = []

    for dr, dc in directions:
        nr = r + dr
        nc = c + dc

        if 0 <= nr < rows and 0 <= nc < cols:
            result.append((nr, nc))

    return result

这个模板会在后续的 DFS、BFS、岛屿数量、最短路径问题中反复使用。

题目练习

27. 移除元素 - 力扣(LeetCode)

88. 合并两个有序数组 - 力扣(LeetCode)

344. 反转字符串 - 力扣(LeetCode)

303. 区域和检索 - 数组不可变 - 力扣(LeetCode)

相关推荐
梦想不只是梦与想26 分钟前
PyTorch、CPU、GPU、CUDA
人工智能·pytorch·python·深度学习·机器学习·cuda
数字融合28 分钟前
透明化视频孪生危化安全管控解决方案
人工智能·python·安全
罗西的思考35 分钟前
【轻量化复刻大模型 / KIMI K3】KDA 模块交互式演示
人工智能·算法·机器学习
FRing0339 分钟前
vue2干货总结3(computed计算属性、watch监听器)
开发语言·前端·vue.js·vue2
Fatbobman(东坡肘子)2 小时前
快速消失的硬盘容量 -- 肘子的 Swift 周报 #160
开发语言·人工智能·redis·ios·swiftui·iphone·swift
揽星河..2 小时前
【C语言】底层程序设计
c语言·开发语言
JavaPub-rodert2 小时前
从零搭建通用管理后台:ShiyuAdmin 的 Go、React 与 RBAC 实践
开发语言·react.js·golang
别问!问就是全会2 小时前
Jupyter Notebook 安装包下载|单元格分块运行,图表直接内嵌
ide·python·其他·jupyter
深蓝AI2 小时前
Python 缓存击穿实测:16 个并发请求如何只回源 1 次
python