16237 字
81 分钟
6.006 笔记 Part1

Contents#

Lecture 1: Introduction#

December 12, 2024

Problem#

  • 问题是从输入到正确输出的二元关系
  • 通常通过可验证的谓词(性质)来判定正确输出
  • 本课程的研究对象是在大规模通用输入空间上的问题
    • 非通用特例:教室内是否有同一生日的学生?
    • 通用:任意 n 个学生中是否有同一生日的学生?

Algorithm#

  • 定义:将每个输入映射到单一输出的确定性过程
  • 正确性:对每个问题输入都返回正确输出
  • 示例:生日匹配算法
    1. 维护名字和生日的记录(初始为空)
    2. 按序访问每个学生:
      • 如记录中存在相同生日则返回配对
      • 否则将该生日添加到记录
    3. 若未找到匹配则返回None

Correctness#

  • 小规模输入:使用情况分析
  • 大规模输入:必须使用递归或循环
  • 通常需要归纳法(induction)证明
  • 生日匹配算法正确性证明示例:
    • 对记录中的学生数 kk 进行归纳
    • 基础情况:k=0k=0,无匹配,成立
    • 归纳假设:kk 的情况成立
    • 归纳步骤:证明 k+1k+1 的情况

Efficiency#

  • 衡量标准:算法执行的固定时间操作数
  • 输入规模通常用 n 表示
  • 渐进(Asymptotic)符号表示:
    • OO (上界), Ω\Omega (下界), Θ\Theta (紧界)
  • 常见时间复杂度:
    • 常数时间:Θ(1)\Theta(1)
    • 对数时间:Θ(logn)\Theta(\log n)
    • 线性时间:Θ(n)\Theta(n)
    • 线性对数时间:Θ(nlogn)\Theta(n \log n)
    • 平方时间:Θ(n2)\Theta(n^2)
    • 多项式时间:Θ(nc)\Theta(n^c)
    • 指数时间:2Θ(nc)2^{\Theta(n^c)}

Model of Computation#

  • O(1)O(1) 时间内可执行的基本操作
    • 整数运算和逻辑运算
    • 位运算
    • 内存地址读写
  • Word-RAM 模型特定
    • 以地址为索引的数组结构
  • 内存寻址要求
    • 字长 wlog2nw \geq \log_2 n
    • 因为内存地址必须能访问内存中的任意位置

Data Structure#

  • 定义:存储非常量数据并支持一组操作的方式
  • 操作的集合称为接口(Interface)
    • 序列(Sequence):基于外在顺序(第一个、最后一个等)
    • 集合(Set):基于内在顺序(基于元素键的查询)
  • 一个接口可以有多种实现
  • 典型示例:静态数组
    • 初始化:Θ(n)\Theta(n) 时间
    • 读取和写入:Θ(1)\Theta(1) 时间

How to Solve an Algorithm Problem#

  1. 规约到已知问题(使用现有数据结构或算法)
    • 搜索问题
    • 排序算法
    • 最短路径算法
  2. 设计新的递归算法
    • 暴力解法
    • 分治策略
    • 动态规划
    • 贪心算法
    • 增量方法

Lecture 2: Data Structures#

December 12, 2024

Data Structure Interfaces#

  • 数据结构是存储数据的方式,并提供支持数据操作的算法
  • 接口(Interface)是对支持操作的集合(collection),也称为API或ADT
  • 接口是规范(specification):说明支持什么操作(问题)
  • 数据结构是表现(representation):实现如何支持操作(解决方案)

Sequence Interface#

维护项目的外在顺序

Basic Operations#

  1. 容器(container)操作:

    • build(X):从可迭代对象X构建序列
    • len():返回存储项目数量
  2. 静态(static)操作:

    • iter_seq():按序返回所有项目
    • get_at(i):获取第i个项目
    • set_at(i, x):用x替换第i个项目
  3. 动态(dynamic)操作:

    • insert_at(i, x)/delete_at(i):在第 i 位置插入/删除
    • insert_first(x)/delete_first():在开头插入/删除
    • insert_last(x)/delete_last():在末尾插入/删除

Special Interfaces#

  • 栈(Stack):仅支持末尾的插入和删除
  • 队列(Queue):支持末尾插入和开头删除

Set Interface#

维护项目的内在顺序(基于键)

Basic Operations#

  1. 容器操作:

    • build(X)len():同序列接口
  2. 静态操作:

    • find(k):返回键为k的项目
  3. 动态操作:

    • insert(x):插入x(如存在相同键则替换)
    • delete(k):删除并返回键为k的项目
  4. 顺序(order)操作:

    • iter_ord():按键顺序返回项目
    • find_min()/find_max():返回最小/最大键项目
    • find_next(k)/find_prev(k):返回键大于/小于k的最近项目

Special Interface#

  • 字典(Dictionary):不包含顺序操作的集合

Array Sequence#

  • 静态操作高效:get_at(i)set_at(i, x)Θ(1)\Theta(1)
  • 动态操作低效,为 Θ(n)\Theta(n)
    • 需要重新分配空间和移动元素

Linked List Sequence#

  • 头部动态操作高效:insert_firstdelete_firstΘ(1)\Theta(1)
  • 随机访问低效:get_at(i)set_at(i, x)O(n)O(n)

Dynamic Array Sequence#

  • 综合了数组和链表的优点
  • 关键思想:预分配额外空间
  • 填充率(rr):0r10 \leq r \leq 1
    • 当数组填满时,分配 Θ(n)\Theta(n) 的额外空间到末尾使填充率降至给定的 rir_i(如0.5)
    • 下次分配前需要插入 Θ(n)\Theta(n) 个项目
  • 平摊分析:单次操作可能是 Θ(n)\Theta(n),但平均是 Θ(1)\Theta(1)
  • Python列表就是动态数组的实现

Amortized Analysis#

  • 平摊分析是数据结构的一种分析技术:将操作代价分摊到多次操作上
  • 平摊代价 T(n)T(n)kk 次操作总代价小于 kT(n)kT(n)
  • 动态数组末尾插入的平摊时间是 Θ(1)\Theta(1)

Dynamic Array Deletion#

  • 从数组末尾删除元素的时间复杂度为 Θ(1)\Theta(1)
  • 但需要注意空间利用效率的问题,数据结构的大小应保持在 Θ(n)\Theta(n)
  • 数组收缩条件:
    • 当使用率 r<rdr < r_d 时,将数组大小调整为 rir_i
    • 其中 ri<rdr_i < r_d (例如 rd=1/4r_d = 1/4ri=1/2r_i = 1/2
  • 空间使用优化:
    • 可以将额外空间使用限制在 (1+ε)n(1 + \varepsilon)n,对任意 ε>0\varepsilon > 0
    • 例如设置 rd=1/(1+ε)r_d = 1/(1+\varepsilon)ri=1/2r_i = 1/2
  • 对Python列表:
    • appendpop 的均摊时间复杂度为 O(1)O(1)
    • 其他操作可能需要 O(n)O(n) 时间

Time Complexity Table#

OperationsStatic ArrayLinked ListDynamic Array
build(X)O(n)O(n)O(n)O(n)O(n)O(n)
get_at(i)
set_at(i, x)
O(1)O(1)O(n)O(n)O(1)O(1)
insert_first(x)
delete_first()
O(n)O(n)O(1)O(1)O(n)O(n)
insert_last(x)
delet_last()
O(n)O(n)O(n)O(n)O(1)O(1)^*
insert_at(i, x)
delete_at(i)
O(n)O(n)O(n)O(n)O(n)O(n)

^*: 平摊时间

Lecture 3: Sorting#

December 14, 2024

Set Interface#

Basic Operations#

  1. 容器操作

    • build(X):从可迭代对象X构建集合
    • len():返回存储项目数量
  2. 静态操作

    • find(k):返回键为k的存储项
  3. 动态操作

    • insert(x):添加x到集合(如果已存在相同键则替换)
    • delete(k):移除并返回键为k的存储项
  4. 顺序操作

    • iter_ord():按键顺序逐个返回存储项
    • find_min():返回最小键的存储项
    • find_max():返回最大键的存储项
    • find_next(k):返回大于k的最小键存储项
    • find_prev(k):返回小于k的最大键存储项

Array Based Implemment#

  1. 无序(unordered)数组(无序存储)

    • 实现了基本的集合功能但效率不高
    • 所有操作时间复杂度均为O(n)O(n)
  2. 有序(sorted)数组

    • 支持快速查找最大/最小值(在数组首尾)
    • 通过二分查找加速查找操作:O(logn)O(\log n)
    • 但是构建操作需O(nlogn)O(n\log n)时间
OperationsArraySorted Array
build(X)O(n)O(n)O(nlogn)O(n \log n)
find(k)O(n)O(n)O(logn)O(\log n)
insert(x)
delete(k)
O(n)O(n)O(n)O(n)
find_min()
find_max()
O(n)O(n)O(1)O(1)
find_prev(k)
find_next(k)
O(n)O(n)O(logn)O(\log n)

Sorting#

  1. 排序问题定义

    • 输入:nn 个数字的静态数组 AA
    • 输出:AA 的一个有序排列 BB
    • 要求:
      • B是A的置换(permutation)(相同元素不同顺序)
      • B有序(sorted):对所有 i{1,...,n}i \in \{1,...,n\},有 B[i1]B[i]B[i-1] \leq B[i]
    • 示例:[8, 2, 4, 9, 3] → [2, 3, 4, 8, 9]
  2. 排序方法分类

    • 破坏性(destructive) 排序:直接覆盖原数组 AA
    • 原地(in place) 排序:使用 O(1)O(1) 额外空间(隐含是破坏性的)

Permutation Sort#

  1. 算法思想

    • 尝试所有可能的排列(n!n!种)
    • 检查每个排列是否有序(Θ(n)\Theta(n)时间)
  2. 实现示例

def permutation_sort(A):
    for B in permutations(A):    # O(n!)
        if is_sorted(B):         # O(n)
            return B             # O(1)
  1. 分析
    • 正确性:通过穷举所有可能性(暴力解法)
    • 时间复杂度:Ω(n!n)\Omega(n! \cdot n)指数级,效率很低):(

Solving Recurrences#

  1. 代入法(Substitution):

    • 猜测一个解
    • 用代表函数替换
    • 验证递归式是否成立
  2. 递归树法(Recurrence Tree):

    • 绘制表示递归调用的树
    • 对节点的计算量求和
  3. 主定理(Master Theorem):

    • 用于解决多种递归式的公式

Selection Sort#

Basic Concept#

  • 核心思想:找到前缀中最大的数并将其交换到正确位置
  • 工作流程:
    1. A[:i+1] 中找到最大数
    2. 将其与 A[i] 交换
    3. 递归排序前缀 A[:i]
  • 示例:[8,2,4,9,3] → [8,2,4,3,9] → [3,2,4,8,9] → [2,3,4,8,9]

Implementation#

def selection_sort(A, i = None):      # T(i)
    '''Sort A[:i+1]'''
    if i is None: i = len(A) - 1      # O(1)
    if i > 0:                         # O(1)
        j = prefix_max(A, i)          # S(i)
        A[i], A[j] = A[j], A[i]       # O(1)
        selection_sort(A, i - 1)      # T(i-1)

def prefix_max(A, i):                 # S(i)
    '''Return index of maximum in A[:i+1]'''
    if i > 0:                         # O(1)
        j = prefix_max(A, i - 1)      # S(i-1)
        if A[i] < A[j]:               # O(1)
            return j                  # O(1)
    return i                          # O(1)

Complexity Analysis#

  1. prefix_max 分析:

    • 基本情况:i=0i=0 时,只有一个元素,最大值索引就是 ii
    • 归纳:正确性通过比较 A[:i] 的最大值和 A[i] 确保
    • 时间复杂度:S(n)=S(n1)+Θ(1)S(n) = S(n-1) + \Theta(1)
      • 代入法:S(n)=Θ(n)S(n) = \Theta(n)cn=Θ(1)+c(n1)c=Θ(1)cn = \Theta(1) + c(n-1) \Longrightarrow c = \Theta(1)
      • 递归树法:i=0n11=Θ(n)\sum_{i=0}^{n-1} 1 = \Theta(n)
  2. selection_sort分析:

    • 基本情况:i=0i=0 时,单个元素已排序
    • 归纳:每次将最大值放到正确位置,剩余部分递归处理
    • 时间复杂度:T(1)=Θ(1)T(1) = \Theta(1)T(n)=T(n1)+Θ(n)T(n) = T(n-1) + \Theta(n)
      • 代入法:T(n)=Θ(n2)T(n) = \Theta(n^2)cn2=Θ(n)+c(n1)2c(2n1)=Θ(n)cn^2 = \Theta(n) + c(n-1)^2 \Longrightarrow c(2n-1) = \Theta(n)
      • 递归树法:i=0n1i=Θ(n2)\sum_{i=0}^{n-1} i = \Theta(n^2)

Insertion Sort#

Basic Concept#

  • 核心思想:递归排序前缀,然后通过重复交换将新元素插入到正确位置
  • 工作流程:
    1. 递归排序A[:i]
    2. A[i]插入到已排序的A[:i]中正确位置
  • 示例:[8,2,4,9,3] → [2,8,4,9,3] → [2,4,8,9,3] → [2,3,4,8,9]

Implementation#

def insertion_sort(A, i = None):      # T(i)
    '''Sort A[:i+1]'''
    if i is None: i = len(A) - 1      # O(1)
    if i > 0:                         # O(1)
        insertion_sort(A, i - 1)      # T(i-1)
        insert_last(A, i)             # S(i)

def insert_last(A, i):                # S(i)
    '''Sort A[:i+1] assuming sorted A[:i]'''
    if i > 0 and A[i] < A[i-1]:       # O(1)
        A[i], A[i-1] = A[i-1], A[i]   # O(1)
        insert_last(A, i-1)           # S(i-1)

Complexity Analysis#

  1. insert_last 分析:

    • 基本情况:i=0i=0 或元素已在正确位置
    • 归纳:通过递归交换最后两个元素将元素移动到正确位置
    • 时间复杂度:S(1)=Θ(1),S(n)=S(n1)+Θ(1)S(n)=Θ(n)S(1) = \Theta(1), S(n) = S(n-1) + \Theta(1) \Longrightarrow S(n) = \Theta(n)
  2. insertion_sort 分析:

    • 基本情况:i=0i=0 时,单个元素已排序
    • 归纳:归纳假设保证前缀已排序,insert_last 保证新元素正确插入
    • 时间复杂度:T(1)=Θ(1),T(n)=T(n1)+Θ(n)T(n)=Θ(n2)T(1) = \Theta(1), T(n) = T(n-1) + \Theta(n) \Longrightarrow T(n) = \Theta(n^2)

Merge Sort#

Basic Concept#

  • 核心思想:分治策略,递归排序两半并合并
  • 工作流程:
    1. 递归排序数组的前半部分和后半部分
    2. 使用两指针算法合并两个已排序的半部分
  • 示例:[7,1,5,6,2,4,9,3] → [1,7,5,6,2,4,3,9] → [1,5,6,7,2,3,4,9] → [1,2,3,4,5,6,7,9]

Implementation#

def merge_sort(A, a=0, b=None):       # T(b-a=n)
    '''Sort A[a:b]'''
    if b is None: b = len(A)          # O(1)
    if 1 < b - a:                     # O(1)
        c = (a + b + 1) // 2          # O(1)
        merge_sort(A, a, c)           # T(n/2)
        merge_sort(A, c, b)           # T(n/2)
        L, R = A[a:c], A[c:b]         # O(n)
        merge(L, R, A, len(L), len(R), a, b)  # S(n)

def merge(L, R, A, i, j, a, b):       # S(b-a=n)
    '''Merge sorted L[:i] and R[:j] into A[a:b]'''
    if a < b:                         # O(1)
        if (j <= 0) or (i > 0 and L[i-1] > R[j-1]):  # O(1)
            A[b-1] = L[i-1]           # O(1)
            i = i - 1                 # O(1)
        else:
            A[b-1] = R[j-1]           # O(1)
            j = j - 1                 # O(1)
        merge(L, R, A, i, j, a, b-1)  # S(n-1)

Complexity Analysis#

  1. merge 分析:

    • 基本情况:n=0n=0 时,数组为空,正确性显然成立
    • 归纳:假设对 nn 成立,A[b-1] 必须是 LR 中的最大数,由于它们已排序,取最后一项即可
    • 时间复杂度:S(0)=Θ(1)S(0) = \Theta(1)S(n)=S(n1)+Θ(1)S(n)=Θ(n)S(n) = S(n-1) + \Theta(1) \Longrightarrow S(n) = \Theta(n)
  2. merge_sort 分析:

    • 基本情况:n=1n=1 时,单个元素已排序
    • 归纳:对于 k<nk<n 的情况成立,算法递归排序较小的半部分,merge 正确合并
    • 时间复杂度:T(1)=Θ(1)T(1) = \Theta(1)T(n)=2T(n/2)+Θ(n)T(n) = 2T(n/2) + \Theta(n)
      • 代入法:猜测T(n)=Θ(nlogn)T(n) = \Theta(n\log n)cnlogn=Θ(n)+2c(n/2)log(n/2)cnlog(2)=Θ(n)cn\log n = \Theta(n) + 2c(n/2)\log(n/2) \Longrightarrow cn\log(2) = \Theta(n)
      • 递归树分析:完全二叉树,深度 log2n\log_2 nnn 个叶节点
        • ii 层有 2i2^i 个节点,每个节点工作量为 O(n/2i)O(n/2^i)
        • 总工作量:i=0log2n(2i)(n/2i)=i=0log2nn=Θ(nlogn)\sum_{i=0}^{\log_2 n} (2^i)(n/2^i) = \sum_{i=0}^{\log_2 n} n = \Theta(n\log n)

Lecture 4: Hashing#

December 14, 2024

Introduction#

  • 思考:能否比 Θ(logn)\Theta(\log n) 更快地实现Set接口的 find(k)
  • 理论上不能(下界定理),但实际上…可以!?

Comparison Model#

  1. 基本假设:

    • 算法只能通过比较来区分项目
    • 可比较的项目(Comparable items)是一种黑盒对象
      • 只支持成对比较
      • 无法直接访问项目的内部结构
    • 比较运算:<,,>,,=,\lt, \leq, \gt, \geq, =, \neq,返回TrueFalse
  2. 目标:

    • 存储 nn 个可比较项目
    • 支持 find(k) 操作
    • 运行时间下界由比较次数决定

Decision Tree#

  • 任何算法都可以看作操作的决策树(decision tree)
  • 内部节点表示二元比较(binary comparison),分支为TrueFalse
  • 叶节点表示算法终止,得到输出结果
  • 根到叶的路径(root-to-leaf path)代表算法在特定输入上的执行(execution)
  • 每个搜索结果至少需要对应一个叶节点,除 nn 个元素外还需要一种未找到的结果,因此搜索需要n+1\geq n+1个叶节点

Comparison Search Lower Bound#

  1. 比较搜索算法的最坏运行时间:

    • 运行时间 \geq 比较次数 \geq 根到叶路径最长长度 \geq 树高
  2. 二叉树性质:

    • nn 个节点的二叉树最小高度在完全二叉树时达到
    • 高度 log(n+1)1=Ω(logn)\geq \lceil \log(n+1) \rceil - 1 = \Omega(\log n)
    • 有序数组实现达到这个界
  3. 推广:

    • Θ(n)\Theta(n) 个叶节点、最大分支因子 bb 的树高度为 Ω(logbn)\Omega(\log_b n)

如果能用一种操作,使分支因子(或出度)逐渐大于常数 Ω(1)\Omega(1),那么决策树就会更浅,从而加快算法速度(例如B树)


Direct Access Array#

  1. 想法

    • 利用Word-RAM的 O(1)O(1) 时间随机访问
    • 为每个项目分配唯一整数键 kk,范围为 {0,...,u1}\{0,...,u-1\},存储在数组对应位置
  2. 优点:

    • 如果键能放入机器字(u2wu \leq 2^w),则最坏情况下find/动态操作都是 O(1)O(1)
  3. 缺点:

    • 空间复杂度 O(u)O(u),当 nun \ll u 时非常浪费
    • 示例:10字母名字作为键,每个名字1位,需要 261017.6TB26^{10} \approx 17.6 \text{TB} 空间

Hashing#

  • nun \ll u 时,将键映射到更小的范围 m=Θ(n)m = \Theta(n),使用更小的直接访问数组
  • 哈希函数:h(k):{0,...,u1}{0,...,m1}h(k): \{0,...,u-1\} \rightarrow \{0,...,m-1\}
  • 直接访问数组称为哈希表(hash table)h(k)h(k) 称为键 kk 的哈希值

冲突(Collision):

  • mum \ll u 时,由抽屉原理,任何哈希函数都不是单射
  • 必然存在键 a,ba, b 使得 h(a)=h(b)h(a) = h(b) \rightarrow 发生冲突!:(
  • 解决方案:
    1. 存储在数组的其他位置(开放寻址,open addressing
      • 分析复杂,但普遍实用
    2. 存储在支持动态集合接口的其他数据结构中(链接,chaining

Chaining#

  • 核心思想:将碰撞的元素存储在另一个数据结构(链)中

  • 性能分析:

    • 理想情况:键均匀分布,链长度为 n/m=n/Ω(n)=O(1)n/m = n/\Omega(n) = O(1)
    • 最佳情况:链长度为 O(1)O(1) 时,所有操作为 O(1)O(1) 时间
    • 最差情况:多个项目映射到同一位置,如 h(k)=constanth(k)=constant,链长度为 Θ(n)\Theta(n)
  • 需要一个好的哈希函数!什么是好的哈希函数?


Hash Functions#

Division (bad)#

h(k)=(kmodm)h(k) = (k\mod m)
  • 特点:

    • 启发式方法,在键均匀分布时效果好
    • mm 应避免与存储键的模式存在对称性
    • 选择远离2和10的幂(数据模式相关的数)的大素数较好
    • Python使用这种方法的改进版本
  • 局限性:

    • unu \gg n 时,任何哈希函数都存在导致 O(n)O(n) 长度链的输入集
    • 改进思路:不使用固定哈希函数,而是随机(但要谨慎)选择

Universal (good, theoretically)#

hab(k)=(((ak+b)modp)modm)h_{ab}(k) = (((ak + b)\mod p)\mod m)

Concept#

  • 哈希族
H(p,m)={haba,b{0,...,p1} and a0}\mathcal{H}(p,m) = \{h_{ab} | a,b \in \{0,...,p-1\} \text{ and } a \neq 0\}
  • 参数

    • 固定素数 p>up > u
    • a,ba, b{0,...,p1}\{0,...,p-1\} 中选择
  • H\mathcal{H} 是通用(Universal)函数族:对任意 kikj{0,...,u1}k_i \neq k_j \in \{0,...,u-1\},有 PrhH{h(ki)=h(kj)}1/m\text{Pr}_{h \in \mathcal{H}}\{h(k_i) = h(k_j)\} \leq 1/m

Theoretical Analysis#

  1. 定义随机变量 Xij=1X_{ij} = 1,对于 hHh \in \mathcal{H}

    • Xij=1X_{ij} = 1 如果 h(ki)=h(kj)h(k_i) = h(k_j)
    • Xij=0X_{ij} = 0 其他情况
  2. 链长度

    • 位置 h(ki)h(k_i) 的链长度为随机变量 Xi=jXijX_i = \sum_j X_{ij}
    • 期望链长度:
EhH{Xi}1+(n1)/m\mathbb{E}_{h \in \mathcal{H}}\{X_i\} \leq 1 + (n-1)/m
  • 因为 m=Ω(n)m = \Omega(n),载荷因子(load factor) α=n/m=O(1)\alpha = n/m = O(1),所以期望是 O(1)O(1)

Dynamic#

  • n/m1n/m \gg 1 时,使用新的大小 nn 和新的随机选择的哈希函数重建表
  • 分析类似动态数组,成本可以分摊到多个动态操作上
  • 所以哈希表可以在期望平摊O(1)O(1) 时间完成动态集合操作!:)

Complexity Table#

OperationsArraySorted ArrayDirect Access ArrayHash Table
build(X)O(n)O(n)O(nlogn)O(n \log n)O(u)O(u)O(n)O(n)^{\dagger}
find(k)O(n)O(n)O(logn)O(\log n)O(1)O(1)O(1)O(1)^{\dagger}
insert(x)
delete(k)
O(n)O(n)O(n)O(n)O(1)O(1)O(1)O(1)^{*\dagger}
find_min()
find_max()
O(n)O(n)O(1)O(1)O(u)O(u)O(n)O(n)
find_prev(k)
find_next(k)
O(n)O(n)O(logn)O(\log n)O(u)O(u)O(n)O(n)

^*: 平摊时间
^\dagger: 期望时间

Lecture 5: Linear Sorting#

January 3, 2025

Review#

  • 比较搜索的下界:任何有 nn 个节点的决策树的高度 log(n+1)1\geq \lceil \log(n+1) \rceil - 1
  • 使用随机访问索引(random access indexing)可以更快:具有线性分支因子(出度)的操作!
  • 直接访问数组速度快,但可能使用大量空间 Θ(u)\Theta(u)
  • 通过将键空间 uu 映射(哈希)到 m=Θ(n)m = \Theta(n) 来解决空间问题
  • 哈希表提供期望 O(1)O(1) 时间操作,如果是动态操作则是期望均摊时间
  • 期望与输入无关:从通用哈希族中随机选择哈希函数

Complexity Table

Comparison Sort Lower Bound#

  • 比较模型意味着算法决策树是二进制的(常数分支因子)
  • 要求叶子数 LL \geq 可能输出数
  • 树高度下界为 Ω(logL)\Omega(\log L),所以最坏情况运行时间是 Ω(logL)\Omega(\log L)
  • 对于 nn 个元素的数组排序,输出总数是 n!n! 种排列
  • log(n!)=Θ(nlogn)\log(n!) = \Theta(n \log n) (证明)
  • 所以归并排序在比较模型中是最优的

Direct Access Array Sort#

  • 假设所有键都是唯一的非负整数,范围在 {0,...,u1}\{0,...,u-1\} 中,故 nun \leq u
  • 将每个项插入大小为 uu 的直接访问数组,时间 Θ(n)\Theta(n)
  • 按照它们在直接访问数组中出现的顺序返回项,时间 Θ(u)\Theta(u)
  • 如果 u=Θ(n)u = \Theta(n),运行时间为 Θ(n)\Theta(n) :)
def direct_access_sort(A):
	"对具有非负互异键的项进行排序"
	u = 1 + max([x.key for x in A])       # O(n) 找到最大键
	D = [None] * u                        # O(u) 直接访问数组
	for x in A:                           # O(n) 插入项
		D[x.key] = x
	i = 0
	for key in range(u):                  # O(u) 按顺序读出项
		if D[key] is not None:
			A[i] = D[key]
			i += 1
  • 当键在更大范围时(如 u=Ω(n2)<n2u = \Omega(n^2) < n^2
  • 思路:将每个键 kk 表示为元组 (a,b)(a,b),其中 k=an+bk = an + b0b<n0 \leq b < n
  • 这实际上是一个以 nn 为基数的2位数
  • 例如:[17,3,24,22,12][(3,2),(0,3),(4,4),(4,2),(2,2)][32,03,44,42,22](n=5)[17,3,24,22,12] \Rightarrow [(3,2),(0,3),(4,4),(4,2),(2,2)] \Rightarrow [32,03,44,42,22]_{(n=5)}

Tuple Sort#

  • 项的键是相等长度的元组,即 item x.key=(x.k1,x.k2,x.k3,...)\text{item } x.key = (x.k_1, x.k_2, x.k_3, ...)

  • 要按字典序(lexicographically)对所有条目进行排序,第一个键 k1k_1 最重要

  • 排序思路:使用其他辅助排序算法分别对每个键进行排序

  • 类似于在电子表格中按多个列排序

  • 排序顺序:从最不重要到最重要的键进行排序

  • 例如:[32,03,44,42,22][42,22,32,03,44][03,22,32,42,44](n=5)[32,03,44,42,22] \Rightarrow [42,22,32,03,44] \Rightarrow [03,22,32,42,44]_{(n=5)}

  • 想法:使用带辅助直接访问数组排序来对元组 (a,b)(a,b) 排序

  • 问题:即使输入键是唯一的,许多整数可能具有相同的 aabb

  • 需要能够处理重复键并保持输入顺序的排序

  • 要求排序是稳定的(stable):重复键在输出中的顺序与输入相同

  • 直接访问数组排序甚至无法对具有重复键的数组进行排序

Counting Sort#

  • 不是在每个数组索引存储单个项,而是存储一个链,类似哈希!
  • 为了稳定性,链数据结构应该记住项被添加的顺序
  • 使用维护插入顺序的**序列(sequence)**数据结构
  • 插入项 xx 时,用 insert_last 将其插入到索引 x.keyx.key 处的链末尾
  • 排序时,按序列顺序读取所有链,逐个返回项
def counting_sort(A):
    "对具有非负键的项进行排序"
    u = 1 + max([x.key for x in A])      # O(n) 找到最大键
    D = [[] for i in range(u)]           # O(u) 直接访问链数组
    for x in A:                          # O(n) 插入到x.key的链中
        D[x.key].append(x)
    i = 0
    for chain in D:                      # O(u) 按顺序读出项
        for x in chain:
            A[i] = x
            i += 1

Radix Sort#

  • u<n2u < n^2 时,使用带辅助计数排序的元组排序来对元组 (a,b)(a,b) 进行排序

  • 先对最低有效位 bb 排序,然后对最高有效位 aa 排序

  • 稳定性确保之前的排序结果保持有序

  • 运行时间为 O(2n)=O(n)O(2n) = O(n) :)

  • 如果每个键 <nc< n^cc=lognuc = \log_n u 为某个正数),则每个键最多有 cc 个以 nn 为基数的数位

  • 一个 cc 位数可以在 O(c)O(c) 时间内写成 cc 元组

  • 我们在 O(n)O(n) 时间内对每个以 nn 为基的 cc 个数位进行排序

  • 带辅助计数排序的元组排序总共运行时间为 O(cn)O(cn)

  • 如果 cc 是常数,即每个键 nc\leq n^c,则这种排序是线性的 O(n)O(n)

def radix_sort(A):
    "对具有非负键的项进行排序"
    n = len(A)
    u = 1 + max([x.key for x in A])         # O(n) 找到最大键
    c = 1 + (u.bit_length() // n.bit_length())
	
    class Obj: pass
    D = [Obj() for a in A]
    for i in range(n):                      # O(nc) 创建数字元组
	    D[i].digits = []
        D[i].item = A[i]
        high = A[i].key
        for j in range(c):                  # O(c) 创建数字元组
            high, low = divmod(high, n)
            D[i].digits.append(low)
	
    for i in range(c):                      # O(nc) 排序每个数位
        for j in range(n):                  # O(n) 对数位i进行排序
	        D[j].key = D[j].digits[i]
	    counting_sort(D)
	for i in range(n):
		A[i] = D[i].item

Sorting Algorithms Comparison#

Insertion SortSelections SortMerge SortCounting SortRadix Sort
Time O()O(\cdot)n2n^2n2n^2nlognn \log nn+un+un+nlognun+n\log_n u
In-place?YYNNN
Stable?YNYYY
CommentsO(nk)O(nk) for kk-proximateO(n)O(n) swapsstable, optimal comparsionO(n)O(n) when u=O(n)u=O(n)O(n)O(n) when u=O(nc)u=O(n^c)

注:kk-近似序列(kk-proximate sequence)是指数组中的每个元素与其最终排序位置的距离不超过 kk 个位置的序列。换句话说,每个元素最多需要移动 kk 个位置就能到达它在排序后的正确位置。

Lecture 6: Binary Trees I#

January 11, 2025

Binary Trees#

我们想实现序列(Sequence)/集合(Set)接口并且所有的静态、动态和查找操作的时间复杂度为 O(logn)O(\log n)

  • 二叉树是一种基于指针的数据结构,每个节点包含三个指针
  • 每个节点包含四个基本属性:
    • item: 存储的数据
    • parent: 父节点指针
    • left: 左子节点指针
    • right: 右子节点指针
  • node.{item, parent, left, right}
  • 示例:
       ________<A>_____
    __<B>_____       <C>
 __<D>      <E>
<F>
  1. root(根节点): 没有父节点的节点
  2. leaf(叶节点): 没有子节点的节点
  3. depth(深度): 从该节点到根节点的路径长度
  4. height(高度): 从该节点到其最深叶节点的路径长度,或该节点子树中所有节点的最大深度

Idea:设计操作以在根高度 hh 的情况下以 O(h)O(h) 的时间运行,并保持 h=O(logn)h = O(\log n)

Traversal Order#

  • 二叉树具有固有的遍历顺序:
    • 左子树中的所有节点在当前节点之前
    • 右子树中的所有节点在当前节点之后
  • 遍历算法:
    1. 递归遍历左子树
    2. 访问当前节点
    3. 递归遍历右子树
  • 时间复杂度:O(n)O(n),因为每个节点只访问一次
  • 示例:(<F>, <D>, <B>, <E>, <A>, <C>)
  • 目前,遍历顺序相对于存储的项目而言没有意义
  • 随后,将为遍历顺序赋予语义意义,以实现序列/集合接口

Tree Navigation#

Find First#

  • 在节点 <X> 子树的遍历顺序中查找第一个节点(查找最后一个节点是对称的)
  • 如果 <X> 有左子节点,则递归返回左子树中的第一个节点
  • 否则,<X> 就是第一个节点,返回 <X>
  • 时间复杂度为 O(h)O(h),其中 hh 是树的高度
  • 示例:<A> 子树中的第一个节点是 <F>

Find Successor#

  • 在遍历顺序中查找节点 <X> 的后继节点(查找前驱节点是对称的)
  • 如果 <X> 有右子节点,则返回右子树的第一个节点
  • 否则,返回 <X> 的最低祖先,其中 <X> 位于其左子树中
  • 时间复杂度为 O(h)O(h),其中 hh 是树的高度
  • 示例:<B> 的后继节点是 <E><E> 的后继节点是 <A><C> 的后继节点是 None

Dynamic Operations#

更改树的单个项目(仅添加或删除叶子节点),其余部分遍历顺序不变:

  • 在遍历顺序中在另一个节点之后添加一个节点(之前是对称的)
  • 从树中删除一个项目

Insert#

在遍历顺序中,在目标节点 <X> 后插入一个节点 <Y>

  • 如果 <X> 没有右子节点,<Y> 直接作为其右子节点
  • 否则,<Y> 作为 <X> 后继节点(没有左子节点)的左子节点
  • 时间复杂度:O(h)O(h)
  • 示例:在 <E> 前插入节点 <G>
       ______<A>___               ________<A>___
    __<B>___     <C>    =>     __<B>_____     <C>
 __<D>    <E>               __<D>    __<E>
<F>                        <F>      <G>
  • 示例:在 <A> 后插入节点 <H>
       ______<A>___                ________<A>_____
    __<B>_____   <C>     =>     __<B>_____     __<C>
 __<D>    __<E>              __<D>    __<E>   <H>
<F>      <G>                <F>      <G>

Delete#

从目标节点 <X> 的子树中删除 <X>

  • 如果是 <X> 叶节点,直接删除
  • 如果不是叶节点,<X> 有子节点:
    • 有左子节点时,与前驱节点交换并递归
    • 否则与后继节点交换并递归
  • 时间复杂度:O(h)O(h)
  • 示例:删除 <F>(叶节点)
       ________<A>_____             ________<A>_____
    __<B>_____     __<C>    =>   __<B>_____     __<C>
 __<D>    __<E>   <H>           <D>    __<E>   <H>
<F>      <G>                          <G>
  • 示例:删除<A>(非叶节点,向下交换直到为叶节点)
     ________<A>_____           ________<E>_____           _____<E>_____
  __<B>_____     __<C>   =>  __<B>_____     __<C>   =>  __<B>__     __<C>
<D>     __<E>   <H>         <D>    __<G>   <H>         <D>   <G>   <H>
       <G>                        <A>

Application: Set#

  • Idea! Set Binary Tree (a.k.a. Binary Search Tree / BST):

    • 遍历顺序就是键值的升序排列
    • BST性质:对每个节点,其左子树的所有键值 \leq 节点键值 \leq 右子树的所有键值
  • O(h)O(h) 时间内查找具有特定键值的节点:

    1. 如果目标值小于当前节点,在左子树中递归(或返回 None
    2. 如果目标值大于当前节点,在右子树中递归(或返回 None
    3. 相等则返回当前节点
  • 其他集合操作略

Application: Sequence#

  • Idea! Sequence Binary Tree: 遍历顺序表示序列顺序
  • 如何访问一个子树的遍历顺序中的第 ii 个节点,称为 subtree_at(i)
  • 迭代整个遍历顺序可以实现,但是为 O(n)O(n)
  • 但是,如果我们可以计算子树的大小O(1)O(1) 时间,就可以在 O(h)O(h) 时间实现
    1. 比较左子树大小 nLn_Lii
    2. 如果 i<nLi < n_L,在左子树中递归
    3. 如果 i>nLi > n_L,在右子树中递归并调整索引 i=inL1i' = i - n_L - 1
    4. 如果 i=nLi = n_L,返回当前节点
  • 每个节点额外维护子树大小
    • 每个节点 node 添加 node.size 字段
    • 插入叶节点时:所有祖先节点 aa.size 加1
    • 删除叶节点时:所有祖先节点 aa.size 减1
    • 维护成本:O(h)O(h)
  • 序列操作直接源自快速的 subtree_at(i) 操作
  • 初步看来,build(X)需要 O(nh)O(nh) 的时间,但实际上可以在 O(n)O(n) 的时间内完成
    • 将数组的中间元素作为根节点,递归构建左右子树

So Far#

Set Data Structure:

OperationsBinary TreeGoal
build(X)O(nlogn)O(n \log n)O(nlogn)O(n \log n)
find(k)O(h)O(h)O(logn)O(\log n)
insert(x)
delete(k)
O(h)O(h)O(logn)O(\log n)
find_min()
find_max()
O(h)O(h)O(logn)O(\log n)
find_prev(k)
find_next(k)
O(h)O(h)O(logn)O(\log n)

Sequence Data Structure:

OperationsBinary TreeGoal
build(X)O(n)O(n)O(n)O(n)
get_at(i)
set_at(i, x)
O(h)O(h)O(logn)O(\log n)
insert_first(x)
delete_first()
O(h)O(h)O(logn)O(\log n)
insert_last(x)
delet_last()
O(h)O(h)O(logn)O(\log n)
insert_at(i, x)
delete_at(i)
O(h)O(h)O(logn)O(\log n)

Lecture 7: Binary Trees II: AVL#

Janary 11, 2025

Goal#

Set Data Structure:

OperationsBinary TreeAVL
build(X)O(nlogn)O(n \log n)O(nlogn)O(n \log n)
find(k)O(h)O(h)O(logn)O(\log n)
insert(x)
delete(k)
O(h)O(h)O(logn)O(\log n)
find_min()
find_max()
O(h)O(h)O(logn)O(\log n)
find_prev(k)
find_next(k)
O(h)O(h)O(logn)O(\log n)

Sequence Data Structure:

OperationsBinary TreeAVL
build(X)O(n)O(n)O(n)O(n)
get_at(i)
set_at(i, x)
O(h)O(h)O(logn)O(\log n)
insert_first(x)
delete_first()
O(h)O(h)O(logn)O(\log n)
insert_last(x)
delet_last()
O(h)O(h)O(logn)O(\log n)
insert_at(i, x)
delete_at(i)
O(h)O(h)O(logn)O(\log n)

Height Balance#

  • 能在动态操作下维持 O(logn)O(\log n) 高度的二叉树被称为平衡树

    • 有多种平衡方案(红黑树、伸展树、2-3树等)
    • AVL树是第一个被提出的平衡方案(Adelson-Velsky和Landis, 1962年)
  • AVL树通过维持高度平衡来确保 O(logn)O(\log n) 的性能

Rotations#

  • 需要在不改变遍历顺序的情况下减少树的高度,以保持相同的元素序列
  • 通过旋转在保持遍历顺序的同时改变树的结构
     _____<D>__      rotate_right(<D>)      __<B>_____
  __<B>__    <E>            =>             <A>    __<D>__
 <A>   <C>   / \                           / \   <C>   <E>
 / \   / \  /___\           <=            /___\  / \   / \
/___\ /___\          rotate_left(<B>)           /___\ /___\
  • 旋转操作重新连接O(1)O(1)个指针来修改树结构,同时保持遍历顺序

Rotations Suffice#

  • 结论O(n)O(n) 次旋转可以将一个二叉树转换为具有相同遍历顺序的任意其他二叉树
  • 证明:按遍历顺序,重复执行最后一个可能的右旋转,最终得到一个规范链。每次旋转使最后一个节点的深度增加1。最终链中最后一个节点的深度是 n1n-1,所以最多执行 n1n-1 次旋转。反向执行规范旋转即可到达目标树
  • 在这里,规范链(canonical chain)是一种特殊的二叉树形态。所有节点都只有右子节点(或都只有左子节点),形成一个类似于链表的结构,每个节点都只连接到一侧
  • 可以通过使用 O(n)O(n) 次旋转来完全地平衡树,但这样太慢了 :(
  • 我们将在每个操作中都用 O(logn)O(\log n) 的时间保持树的平衡

AVL Trees: Height Balance#

  1. AVL树维持高度平衡(height-balance)(也称为AVL Property

    • 如果一个节点的左右子树高度差最多为1,则称该节点是高度平衡的
    • 定义节点的偏斜度(skew)为其右子树高度减去左子树高度
    • 当节点的偏斜度为-1、0或1时,该节点是高度平衡的
  2. 结论:具有高度平衡节点的二叉树高度 h=O(logn)h = O(\log n)(即 n=2Ω(h)n = 2^{\Omega(h)}

  • 证明:只需证明任何高度为 hh 的树中最少节点数 F(h)F(h) 满足 F(h)=2Ω(h)F(h) = 2^{\Omega(h)}(斐波那契树)

    F(0)=1,F(1)=2,F(h)=1+F(h1)+F(h2)2F(h2)F(h)2h/2F(0) = 1, F(1) = 2, F(h) = 1 + F(h-1) + F(h-2) \geq 2F(h-2) \Longrightarrow F(h) \geq 2^{h/2}

  1. 假设在高度平衡树中添加或删除叶子节点导致不平衡:
    • 只有受影响叶子节点的祖先的子树在高度或偏斜度上发生改变
    • 高度的变化只有 ±1\pm 1,所以偏斜度的绝对值最大为2
    • 处理方法:从叶子节点开始向上到根节点,修复祖先的高度平衡
    • 重复平衡最低的未平衡祖先节点,由对称性假设偏斜度为2

Local Rebalance#

Preconditions#

  • 给定一个二叉树节点 <B>,满足:
    • 其偏斜度为2
    • <B> 的子树中的所有其他节点都是高度平衡的
    • 那么 <B> 的子树可以通过一次或两次旋转达到高度平衡
    • 旋转后 <B> 的高度与之前相同或减少1

Proof#

<B> 的偏斜度为2,所有右子节点 <F> 存在

Case 1: <F> 的偏斜度为0;Case 2: <F> 的偏斜度为1

  • <B>执行左旋转
  __<B>______                     ______<F>____
 <A>    ___<F>___              __<B>___      <G>
 / \   <D>     <G>     =>     <A>    <D>     / \
/___\  / \     / \            / \    / \    /   \
      /___\   /   \          /___\  /___\  /_____\
     /_____\ /_____\               /_____\
  • h=h = height(<A>),则 height(<G>) =h+1= h + 1height(<D>) 在Case 1中为 h+1h + 1,在Case 2中为 hh
  • 旋转后:
    • <B> 的偏斜度在Case 1中为1,在Case 2中为0,所以 <B> 是高度平衡的
    • <F> 的偏斜度为-1,所以 <F> 是高度平衡的
    • <B> 的高度在旋转前是 h+3h + 3,在Case 1后保持 h+3h + 3,在Case 2后变为 h+2h + 2

Case 3: <B> 的右子节点 <F> 的偏斜度为-1,所以 <F> 的左子节点 <D> 存在

  • 先对 <F> 执行右旋转,然后对 <B> 执行左旋转
  __<B>___________                   _____<D>______
 <A>       _____<F>__             __<B>__      __<F>__
 / \    __<D>__    <G>    =>     <A>   <C>    <E>   <G>
/___\  <C>   <E>   / \           / \   /_\    /_\   / \
       /_\   /_\  /___\         /___\ /___\  /___\ /___\
      /___\ /___\
  • h=h = height(<A>),则 height(<G>) =h= h,而 height(<C>)height(<E>) 各为 hhh1h-1
  • 旋转后:
    • <B> 的偏斜度为0或-1,所以是高度平衡的
    • <F> 的偏斜度为0或1,所以是高度平衡的
    • <D> 的偏斜度为0,所以是高度平衡的
    • <B> 的高度从旋转前的 h+3h + 3 变为 h+2h + 2

Global Rebalance#

将高度平衡树 TT 添加或移除一个叶子节点后得到树 TT'。那么 TT' 可以通过最多 O(logn)O(\log n) 次旋转转换成一个高度平衡的树 TT''

Proof#

  • 只有受影响叶子节点的祖先节点在 TT' 中的高度与TT不同
  • 受影响的叶子节点最多有 h=O(logn)h = O(\log n) 个祖先节点,这些祖先的子树可能发生改变
  • <X> 为最低的不高度平衡的祖先节点(偏斜度的绝对值为2)
  • 如果新的叶子节点加入 TT
    • 插入增加了 <X> 的高度,所以属于局部重平衡的Case 2或Case 3
    • 旋转会减少子树高度:一次旋转后就平衡了
  • 如果从 TT 中删除叶子节点
    • 删除减少了 <X> 的一个子节点的高度,而不是 <X> 本身的高度
    • 可能使 <X> 的高度减少1;<X> 的父节点可能现在不平衡
    • 因此可能需要重平衡 <X> 的每个祖先,但最多只有 h=O(logn)h = O(\log n)
    • 需要检验 <X> 的每个祖先是否高度平衡

Computing Height#

  • 如何判断节点 <X> 是否高度平衡?需要计算子树的高度!

  • 如何计算节点 <X> 的高度?朴素算法:

    • 递归计算 <X> 的左右子树的高度
    • 取两个高度的最大值加1
    • 运行时间为 Ω(n)\Omega(n),因为需要递归访问每个节点 :(
  • Idea:用子树高度来增强每个节点

  • 可以通过 <X> 的子节点在 O(1)O(1) 时间内计算 <X> 的高度:

    • O(1)O(1) 时间内查找左右子树的存储高度
    • 取两个高度的最大值加1

Maintenance#

  • 在动态操作期间,随着树形状改变,我们必须维护这个增强信息
  • 在每个子树改变的节点处重新计算子树增强信息:
    • 在旋转操作中以 O(1)O(1) 时间更新重连接的节点(如果祖先不变)
    • 通过遍历树,以 O(h)O(h) 时间更新插入或删除节点的所有祖先

Steps to Augment a Binary Tree#

  • 要用子树属性 P 来增强(augmentation)二叉树,你必须:
    • 声明想要在每个节点 <X> 存储的子树属性 P(<X>)
    • 证明如何在 O(1)O(1) 时间内从 <X> 的子节点的增强信息中计算 P(<X>)
  • 然后存储的属性 P(<X>) 可以在不改变动态操作成本的情况下维护

Application: Sequence#

  • 对于序列二叉树,我们需要知道子树大小原因
  • 仅仅插入/删除叶子节点时这很容易,但现在需要处理旋转
  • 子树大小是一个子树属性,所以可以通过增强来维护
    • 可以通过把子节点的大小相加再加1来计算大小

Application: Sorting#

  • 任何支持顺序访问集合数据结构都定义了一个排序算法:build(或重复 insert)然后 iter
  • 例如,lec 5中的直接访问数组排序
  • AVL排序是一个新的 O(nlogn)O(n \log n) 时间排序算法

Conclusion#

  • 集合AVL树实现所有集合操作的 O(logn)O(\log n) 时间复杂度
    • 除了 build 需要 O(nlogn)O(n \log n) 时间
    • iter 需要 O(n)O(n) 时间
  • 序列AVL树实现所有序列操作的 O(logn)O(\log n) 时间复杂度
    • 除了 builditer 需要 O(n)O(n) 时间

Lecture 8: Binary Heaps#

January 17, 2025

Priority Queue Interface#

  • 主要功能是跟踪多个项目,快速访问/删除最重要的项目
  • 根据 key = priority 排序项目,所以是集合接口
  • 针对集合操作的特定子集优化
    • build(X): 从可迭代对象 X 构建优先队列
    • insert(x): 添加项目 xx 到数据结构
    • delete_max(): 移除并返回最大键值的项目
    • find_max(): 返回最大键值的项目
  • 通常针对 maxmaxminmin 优化,而不是同时优化两者
  • 我们关注 insertdelete_max 操作
    • build 等价于重复 insert
    • find_max 等价于 insert(delete_max())

Priority Queue Sort#

  • 可以将任何优先队列数据结构转换为排序算法
    1. build(A) - 按输入顺序 insert 项目
    2. 重复 delete_min()delete_max() 确定排序顺序
  • 运行时间:Tbuild+nTdelete_maxnTinsert+nTdelete_maxT_{build} + n \cdot T_{delete\_max} \leq n \cdot T_{insert} + n \cdot T_{delete\_max}
  • 部分先前提到的的排序算法可以看作是优先队列排序
Priority Queue
Data Structure
build(A)insert(x)delete_max()TimeIn-place?
Dynamic ArrayO(n)O(n)O(1)O(1)^*O(n)O(n)Selection SortO(n2)O(n^2)Y
Sorted Dynamic ArrayO(nlogn)O(n\log n)O(n)O(n)O(1)O(1)^*Insertion SortO(n2)O(n^2)Y
Set AVL TreeO(nlogn)O(n\log n)O(logn)O(\log n)O(logn)O(\log n)AVL SortO(nlogn)O(n\log n)N
GoalO(n)O(n)O(logn)O(\log n)^*O(logn)O(\log n)^*Heap SortO(nlogn)O(n\log n)Y

^*: 平摊时间

Priority Queue: Set AVL Tree#

  • Set AVL树支持 insert(x), find_min(), find_max(), delete_min()delete_max() 操作,每个操作的时间复杂度都是 O(logn)O(\log n)
  • 因此基于优先队列的排序运行时间是 O(nlogn)O(n \log n)
    • 这实际上就是lec 7结尾中提到的AVL sort
  • 通过子树增强技术可以将 find_min()find_max() 的时间优化到 O(1)O(1)
  • 但这个数据结构比较复杂,并且产生的排序不是原地排序(in-place)
  • 有更简单的数据结构可以实现优先队列,并且能够实现原地 O(nlogn)O(n \log n) 排序
  • 二叉堆(binary heap)和堆排序(heap sort)
    • 本质上是利用二叉树知识,在序列数据结构(数组)之上实现了一个集合数据结构

Priority Queue: Array#

  • 将元素存储在一个无序的动态数组中
  • insert(x):将 xx 追加到末尾,平摊时间复杂度 O(1)O(1)
  • delete_max():查找最大值需要 O(n)O(n),将最大值交换到末尾并移除
  • insert 操作很快,但 delete_max 操作很慢
  • 使用这种方式实现的优先队列排序就是选择排序

Priority Queue: Sorted Array#

  • 将元素存储在一个有序的动态数组中
  • insert(x):将 xx 追加到末尾,然后交换到正确的有序位置,时间复杂度 O(n)O(n)
  • delete_max():从末尾删除,平摊时间复杂度 O(1)O(1)
  • delete_max 操作很快,但 insert 操作很慢
  • 使用这种方式实现的优先队列排序就是插入排序
  • 问题:我们能否在这两种数组优先队列实现之间找到一个折中方案?

Array as a Complete Binary Tree#

  • Idea:将数组解释为完全二叉树,本质还是数组
    • 完全二叉树:
    1. 每一层 ii 最多有 2i2^i 个节点(除最后一层外)
    2. 最后一层的节点都靠左对齐(left-aligned)
    3. 按照从上到下、从左到右的阅读顺序填充
d0          ______O____
d1      ____O____   __O__
d2    __O__   __O   O   O
d3    O   O   O
  • 数组和完全二叉树之间存在一一对应(bijection)的关系
Q = [0,1,2,3,4,5,6,7,8,9]
d0   0                      ->          ______0____
d1     1 2                  ->      ____1____   __2__
d2         3 4 5 6          ->    __3__   __4   5   6
d3                 7 8 9    ->    7   8   9
  • 从完全二叉树的视角来看,包含 nn 个元素的数组的高度为 log2n\lfloor \log_2 n \rfloor,因此它是一个平衡二叉树

Implicit Complete Tree#

  • 完全二叉树的结构是隐式的,不需要显式存储指针
  • 根节点位于索引 0
  • 通过索引计算邻居节点
left(i)=2i+1\text{left}(i) = 2i + 1right(i)=2i+2\text{right}(i) = 2i + 2parent(i)=i12\text{parent}(i) = \Big\lfloor \frac{i-1}{2} \Big\rfloor

Binary Heaps#

  • Idea:在树中保持较大元素在上方,但只需要局部维护这个性质
  • 最大堆性质(Max-Heap Property):对于节点 iiQ[i]Q[j]Q[i] \geq Q[j],其中 j{left(i),right(i)}j \in \{ \text{left}(i), \text{right}(i) \}
  • 最大堆(Max-Heap)定义:满足所有节点都具有最大堆性质的数组
  • 性质:在最大堆中,对每个节点 iisubtree(i) 的任意节点 jj,都有 Q[i]Q[j]Q[i] \geq Q[j]
  • 特别地,最大值总是位于最大堆的根节点

Heap Insert#

  • 将新元素 xxO(1)O(1) 平摊时间追加到数组末尾,使其成为按阅读顺序的下一个叶子节点 ii
  • max_heapify_up(i):与父节点交换,直到满足最大堆性质
    • 检查 Q[parent(i)]Q[i]Q[\text{parent}(i)] \geq Q[i] 是否成立
    • 如果不成立,交换 Q[i]Q[i]Q[parent(i)]Q[\text{parent}(i)],并递归执行max_heapify_up(parent(i))
  • 正确性:
    • 最大堆性质保证所有节点大于等于其后代,除了 Q[i]Q[i] 可能大于其某些祖先(除非 ii 是根节点)
    • 如果需要交换,类似 Q[i]Q[i],相同地保证 Q[parent(i)]Q[\text{parent}(i)] 满足最大堆性质
  • 运行时间:树的高度,即 Θ(logn)\Theta(\log n)

Heap Delete Max#

  • 问题:我们只能轻松地移除动态数组的最后一个元素,但最大值在树的根节点
  • 解决方案:将根节点(i=0i = 0)与最后一个节点(n1n-1)交换
  • max_heapify_down(i):将根节点与较大的子节点交换,直到满足最大堆性质
    • 检查 Q[i]Q[j]Q[i] \geq Q[j] 是否对 j{left(i),right(i)}j \in \{\text{left}(i), \text{right}(i)\} 成立
    • 如果不成立,将 Q[i]Q[i] 与具有最大键值的子节点 Q[j]Q[j] 交换,并递归执行max_heapify_down(j)
  • 正确性:
    • 最大堆性质保证所有节点大于等于后代,除了 Q[i]Q[i] 可能小于某些后代(除非 ii 是叶节点)
    • 如果需要交换,类似 Q[i]Q[i],相同地保证 Q[j]Q[j] 满足最大堆性质
  • 运行时间:树的高度,即 Θ(logn)\Theta(\log n)

Heap Sort#

  • 将最大堆用于优先队列排序,就得到了一个新的排序算法
  • 运行时间是 O(nlogn)O(n \log n),因为每次 insertdelete_max 都需要 O(logn)O(\log n) 时间
  • 这个排序算法通常会包含两个改进

In-place Priority Queue Sort#

  • 最大堆 QQ 是更大的数组 AA 的前缀,需要记录有多少个元素属于堆,即 Q|Q|
  • Q|Q| 初始为0,插入后变为 A|A|,删除后又变回0
  • insert() 将数组中索引 Q|Q| 处的下一个元素吸收进堆
  • delete_max() 将最大元素移到末尾,然后通过减少 Q|Q| 来不再记录它
  • 使用数组实现的原地优先队列排序就是选择排序
  • 使用有序数组实现的原地优先队列排序就是插入排序
  • 使用二叉最大堆实现的原地优先队列排序就是堆排序(Heap Sort)

Linear Build Heap#

  • nn 个元素插入堆会对 ii00n1n-1(从根到叶)调用 max_heapify_up(i)
  • 对于阶乘的处理,应用斯特林公式
worst-case swapsi=0n1depth(i)=i=0n1logi=log(n!)=Θ(nlogn)\text{worst-case swaps} \approx \sum_{i=0}^{n-1} \text{depth}(i) = \sum_{i=0}^{n-1} \log i = \log (n!) = \Theta(n \log n)
  • Idea! 从一开始就将完整数组视为完全二叉树,然后对 iin1n-100 执行 max_heapify_down(i)(从叶到根)
worst-case swapsi=0n1height(i)=i=0n1(lognlogi)=lognnn!=Θ(lognnn(n/e)n)=Θ(n)\text{worst-case swaps} \approx \sum_{i=0}^{n-1} \text{height}(i) = \sum_{i=0}^{n-1} (\log n - \log i) = \log \frac{n^n}{n!} = \Theta \Big(\log \frac{n^n}{\sqrt n (n/e)^n} \Big) = \Theta(n)
  • 因此可以在 O(n)O(n) 时间内 build
  • 但这并不能提升堆排序 O(nlogn)O(n \log n) 的性能

Sequence AVL Tree Priority Queue#

  • 序列AVL树:具有线性构建时间的对数级数据结构
  • 任意顺序(插入顺序)将优先队列的元素存储在序列AVL树中
  • 维护最大值(and/or 最小值)增强:
    • node.max = 指向 node 子树中具有最大键值的节点的指针
    • 这是一个子树性质,维护它只需要常数因子的开销
  • find_min()find_max() 时间为 O(1)O(1)
  • delete_min()delete_max() 时间为 O(logn)O(\log n)
  • build(A) 时间为 O(n)O(n)
  • 与二叉堆具有相同的界限(而且支持更多操作)

Set vs. Multiset#

  • 虽然我们的 Set 接口假设没有重复键,但我们可以用这些 Set 来实现允许重复键的 Multiset
    • Set中的每个项目是一个序列(如链表),存储具有相同键的 Multiset 项目,该键就是这个序列的键
  • 实际上,不需要这种转换,二叉堆和AVL树可以直接处理重复键的项目(例如 delete_max 删除最大键的某个项目),只需注意使用 \leq 约束,而不是Set AVL树中的 <<(在二叉搜索树中假设键是唯一的,则比较规则通常使用严格比较

Problem Sessions#

Problem Session 1#

December 13, 2024

Problem 1-1. Stirling’s approximation#

n!2πn(ne)nn! \approx \sqrt{2\pi n} (\frac{n}{e})^nlimx+n!2πn(ne)n=1\lim_{x \to +\infty} \frac{n!}{\sqrt{2\pi n} (\frac{n}{e})^n} = 1

log(n!)=Θ(nlogn)\log (n!) = \Theta (n \log n) 的证明

logn!log(2πn)+log(ne)n=12log(2πn)+nlognnloge\log n! \approx \log (\sqrt{2 \pi n}) + \log (\frac{n}{e})^n = \frac{1}{2}\log (2 \pi n) + n \log n - n \log e

另法

log(n!)=k=1nlogkk=n/2nlogkn2log(n2)=n2(lognlog2)\log(n!) = \sum_{k=1}^n \log k \geq \sum_{k=n/2}^n \log k \geq \frac{n}{2} \log\left( \frac{n}{2} \right) = \frac{n}{2} (\log n - \log 2)log(n!)=Ω(nlogn)\log(n!) = \Omega(n \log n)log(n!)=k=1nlogknlogn\log(n!) = \sum_{k=1}^n \log k \leq n \log nlog(n!)=O(nlogn)\log(n!) = O(n \log n)

结合上下界,有

log(n!)=Θ(nlogn)\log (n!) = \Theta (n \log n)

Problem 1-3. Double-Ended Sequence Operations#

已知:

  • 动态数组可以实现 O(1)O(1) 时间的索引和尾部操作,但前端操作需要 O(n)O(n) 时间
  • 双端链表可以实现 O(1)O(1) 时间的两端操作,但索引需要 O(n)O(n) 时间

要求:设计一个数据结构同时满足:

  1. 支持 O(1)O(1) 时间的索引查找
  2. 支持两端的插入和删除,平摊时间复杂度 O(1)O(1)
  3. 存储 nn 个元素只使用 O(n)O(n) 空间

思路一:

  1. 使用两个动态数组结构来实现双端队列
    • 如 Python 的列表
  2. 需要特别处理:
    • 从空数组弹出元素的情况
    • 向满数组压入元素的情况

思路二:

  1. 核心思想
    • 将元素存储在数组的中间位置而不是开头
  2. 具体实现
    • 当需要重新分配空间时,将 nn 个元素复制到长度为 3n3n 的数组中间
    • 在两端预留线性数量的额外空间
    • 只有在执行 Ω(n)\Omega(n) 次操作后才需要重建数组
  3. 操作细节
    • 维护最左侧元素的索引位置 ii
    • 维护数组中存储的元素数量 nn
    • 通过 i+ji+j 访问第 jj 个元素
  4. 性能
    • 索引查找: 最坏情况 O(1)O(1)
    • 两端插入/删除: 平摊的 O(1)O(1)
    • 空间使用: O(n)O(n)

Problem 1-4. Linked List Reversing#

要求:

  1. 给定一个包含 2n2n 个项目的链表
  2. 设计算法在 O(n)O(n) 时间内修改链表,将后半部分反转
  3. 限制条件
    • 不能创建新的链表节点
    • 不能使用任何非常量大小的额外数据结构

实现流程:

  1. 寻找中间节点
  2. 反转后半部分
    • 从第 n+1n+1 个节点 b 开始到第 2n2n 个节点c
    • 修改每个节点的 next 指针,指向其前一个节点
  3. 处理特殊指针
    • 修改节点 abnext 指针
    • a.next 指向 c
    • b.next 指向 null

反转实现细节:

  1. 使用双指针技术
    • 维护当前节点 x 和其前驱节点 xp
    • 初始时 x = b, xp = a
  2. 反转过程
    • 记录 x 的下一个节点 xn
    • x.next 指向 xp
    • 更新当前节点和前驱节点

性能:

  • 时间复杂度: O(n)O(n)
  • 空间复杂度: O(1)O(1)
  • 不需要创建新节点
  • 不需要额外的数据结构

Problem Session 2#

January 3, 2025

Problem 2-1. Master Theorem#

主定理(Master Theorem)提供了一种解决递归关系的方法,其中递归调用通过一个常数因子减少问题的规模。给定一个形式为 T(n)=aT(n/b)+f(n)T(n)=aT(n/b)+f(n) 且 T(1)=Θ(1)T(1)=\Theta(1) 的递归关系,其中分支因子 a1a \geq 1,问题规模缩减因子 b>1b > 1,以及渐近非负函数 f(n)f(n),主定理通过比较 f(n)f(n) 与 alogbn=nlogbaa^{\log_b n} = n^{\log_b a}(递归树底部的叶子节点数量)来给出递归关系的解。

casesolutionconditions
1T(n)=Θ(nlogba)T(n)=\Theta(n^{\log_b a})f(n)=O(nlogbaϵ)f(n)=O(n^{\log_b a-\epsilon}) for some constant ϵ>0\epsilon>0
2T(n)=Θ(nlogbalogk+1n)T(n)=\Theta(n^{\log_b a} \log^{k+1} n)f(n)=Θ(nlogbalogkn)f(n)=\Theta(n^{\log_b a} \log^k n) for some constant k0k\geq0
3T(n)=Θ(f(n))T(n)=\Theta(f(n))f(n)=Ω(nlogba+ϵ)f(n)=\Omega(n^{\log_b a+\epsilon}) for some comstant ϵ>0\epsilon>0
and af(n/b)<cf(n)af(n/b)<cf(n) for some constant 0<c<10<c<1

主定理在 f(n)f(n) 是多项式时形式更为简单,此时递归关系的形式为 T(n)=aT(n/b)+Θ(nc)T(n)=aT(n/b)+\Theta(n^c),其中 c0c\geq 0 是一个常数。这种特殊情况可以通过代入法直接证明。

casesolutionconditionsintuition
1T(n)=Θ(nlogba)T(n)=\Theta(n^{\log_b a})c<logbac<\log_b a叶子节点的工作量占主导
2T(n)=Θ(nclogn)T(n)=\Theta(n^c \log n)c=logbac=\log_b a工作量均匀分布在整个树的每一层
3T(n)=Θ(nc)T(n)=\Theta(n^c)c>logbac>\log_b a根节点的工作量占主导

推广:Akra–Bazzi theorem

Problem 2-3. Combination of Sequence and Set Interface#

请设计一个数据库来跟踪文档中的图像,并支持以下操作:

  1. make_document():创建一个不包含任何图像的空白文档
  2. import_image(x):将唯一整数 ID 为 x 的图像添加到文档的顶部
  3. display():返回文档中所有图像 ID 的数组,顺序从底部到顶部
  4. move_below(x, y):将 ID 为 x 的图像移动到 ID 为 y 的图像的正下方

要求:

  • 操作 (1) 的最坏时间复杂度为 O(1)O(1)
  • 操作 (2) 和 (3) 的最坏时间复杂度为 O(n)O(n),其中 nn 是操作时文档中的图像数量
  • 操作 (4) 的最坏时间复杂度为 O(logn)O(\log n)

实现思路:

  • 需要处理 ID 和图像位置两个部分的信息
  • display
    • 要求有保存图像在文档中的位置顺序
  • move_below
    • 要支持图像位置的交换
    • 要支持 ID 的查找
  • 综合来看,可用
    • 排序数组或哈希表处理 ID
      • 哈希表:键为图像 ID,值为指向链表中对应节点的指针
      • 排序数组:存储按 xx 排序的键值对 (x,vx)(x, v_x)
    • 双向链表处理图像位置

Problem 2-4. # of Greater Items to the Right#

要求:

  1. 给定一个包含 nn 个互不相同项目的数组
  2. 设计算法在 O(nlogn)O(n\log n) 时间内计算每个位置在右侧大于该位置项目的个数

思路一:

学完树状数组再看这题,发现可以利用树状数组做。然后又想到一个之前没想到的点,直接用树状数组的时间复杂度是 O(nlogmax_num)O(n\log max\_num),如果 max_nummax\_num 过大,则对性能有影响。但是我们可以对原数组先做合并排序(稳定的,O(nlogn)O(n\log n)),然后用大小次序代替每个项目的实际数据,再利用树状数组,便可以得到总的时间复杂度O(nlogn)O(n\log n)。:) 但是这么想不是几乎所有情况都可以这样做吗,有点怪怪的..

思路二:

参考答案提供的方法是利用合并排序,对于已排序的两部分数组,在使用双指针算法合并为同一个数组的时候,更新左侧数组各个元素的结果值(原题情境下称为damage),对同一部分数组内项目的结果计算在递归中已经完成了。我认为这是比思路一更灵活的解法。

def get_damages(H):
    D = [1 for _ in H]
    H2 = [(H[i], i) for i in range(len(H))]

    def merge_sort(A, a=0, b=None):
        if b is None: 
            b = len(A)
        if 1 < b - a:
            c = (a + b + 1) // 2
            merge_sort(A, a, c)
            merge_sort(A, c, b)
            i, j, L, R = 0, 0, A[a:c], A[c:b]
            while a < b:
                if (j >= len(R)) or (i < len(L) and L[i][0] <= R[j][0]):
                    D[L[i][1]] += j
                    A[a] = L[i]
                    i += 1
                else:
                    A[a] = R[j]
                    j += 1
                a += 1
    merge_sort(H2)
    return D

Problem Session 3#

January 11, 2025

Problem 3-2. Hash Sequence#

任务:使用哈希表实现序列接口

要求:

  1. 只能使用哈希表的 Set 接口操作,即哈希表是黑箱的
  2. 性能要求(类似双端动态数组):
    • build(A): 期望 O(n)O(n) 时间
    • get_at/set_at: 期望 O(1)O(1) 时间
    • insert_at/delete_at: 期望 O(n)O(n) 时间
    • 首/尾的动态操作: 分摊期望 O(1)O(1) 时间

实现思路:

因为哈希表可以看作是索引无限大的数组(索引是自然数),可以作为动态数组的代替来使用,所以联想动态数组实现双端序列操作,就能如法炮制地用黑箱哈希表实现类似的序列接口,即用额外字段存储序列的首项对应哈希表中的键。

Problem 3-3. Applications of Radix Sort#

任务:为不同类型的键设计高效排序算法

ID#

  • 类型:整数
  • 条件:[n,n][-n,n]

先映射 f:[n,n][0,2n],f(x)=x+nf: [-n, n] \rightarrow [0, 2n], f(x)=x+n,再对映射后的 ID 应用基数排序,时间复杂度 O(n)O(n)

Name#

  • 类型:唯一的字符串
  • 条件:最多 10lgn10 \lceil \lg n \rceil 个英文字母

每个字符的数值表示有上限 kk(例如:英文字母是26,字节编码是256)。整个字符串可以被视为一个 0 到 u 之间的整数。

u=k10lgn=O(1010lgk)=nO(1)u = k^{10 \lceil \lg n \rceil} = O(10^{10 \lg k}) = n^{O(1)}

使用基数排序,时间复杂度为 O(n+nlognnO(1))=O(n)O(n + n \log_n n^{O(1)}) = O(n),这种方案适用于字符串存储在连续内存中固定数量的机器字中的情况。

若每个字符单独存储在一个机器字中,则需要先计算每个字符串的数值表示,时间复杂度为 O(n10lgn+n)=O(nlogn)O(n 10 \lceil \lg n \rceil + n) = O(n \log n)

此处关于存储形式的细节在阅读答案前本人也未注意到,提示了思考处处不可想当然。

注意一点细节:tuple sort: base 26,radix sort: base n

Win fraction#

  • 类型:比率 wi/fiw_i/f_i
  • 条件:0wifin20 \leq w_i \leq f_i \leq n^2fi>0f_i > 0
  • 注意:我们只能处理整数

注意到任意两个比率是可以比较的,即考虑 wifjfiwjw_i f_j - f_i w_j 正负性,当然我们可以使用合并排序,时间复杂度为 O(nlogn)O(n\log n)

考虑将所有比率映射到自然数集上。可行性在于,对任意不相等的两个比率,我们有

wifiwjfj=wifjfiwjfifj1fifj1n4n4wifiwjfj1|\frac{w_i}{f_i} - \frac{w_j}{f_j}| = \frac{|w_i f_j - f_i w_j|}{f_i f_j} \geq \frac{1}{f_i f_j} \geq \frac{1}{n^4} \text{, } n^4 |\frac{w_i}{f_i} - \frac{w_j}{f_j}| \geq 1

所以可以将所有比率映射为 [0,n4][0, n^4] 上的整数,并且只要不相等就两两不同。具体的映射为

pi=f(wi,fi)=n4wifip_i = f(w_i,f_i) = \lfloor \frac{n^4 w_i}{f_i} \rfloor

如果不同比率还有相同的 pi,pjp_i,p_j,那么将矛盾

n4wifiwjfj<1n^4 |\frac{w_i}{f_i} - \frac{w_j}{f_j}| < 1

因为 wi<fiw_i < f_i,所以 pi<n4p_i < n^4,不过尽管不是这样,我们还有 pi<n6p_i < n^6,总之映射后再使用基数排序,时间复杂度为 O(n+nlognn6)=O(n)O(n+n\log_n n^6) = O(n)

Problem 3-4. The Sum of Two Numbers#

输入:

  • 一组正整数边长集合 S={s0,...,sn1}S = \{s_0, ..., s_{n-1}\},适合在 Θ(n)\Theta(n) 机器字内存储
  • 一个正整数高度 hh

任务:

  1. 期望 O(n)O(n) 时间内找到是否存在两个边长恰好相加等于 hh
  2. 假设没有两个边长能恰好相加得到 hhh=600n6h = 600n^6,设计最坏情况 O(n)O(n) 时间的算法,找到不超过 hh 且最接近 hh 的两个边长之和

实现思路:

  1. 标题已经开门见山了:),如果你做这题前还不熟悉哈希表在查找操作上的杰出性能,即期望 O(1)O(1),那你可能会先想到穷举,O(n2)O(n^2),再想到先排序后双指针查找,O(nlogn+n)=O(nlogn)O(n\log n + n) = O(n\log n),其实这说的就是我

    。而用哈希表实现 O(n)O(n) 的算法是:将 SS 中的所有项目存入一个哈希表中,O(n)O(n);再迭代 SS,查找哈希表中是否存在 hsih-s_i,期望 O(n)O(n),整个算法的时间复杂度为期望 O(n)O(n)
  2. 这一问中我们知道了 hh 是多项式大小的,一个直观的点是,对于所有 si>hs_i > h 都是可以略去的,实际上就限制了 SS 的元素的范围,那么就可以对 SS 中的项目应用基数排序,O(n)O(n),再利用双指针查找题目所求的最大值,O(n)O(n),整个算法的时间复杂度为O(n)O(n)。双指针算法的细节为,分别指向排序后数组的两端,维护两个指针位置的项目之和,并记录所求的最大值,若当前和小于 hh,则移动较小一端的指针使和增大,反之移动较大一端的指针,两个指针都向数组中间移动,直至相遇,每一步都需要更新所求值。

Problem 3-5. Po-kk-er Hands#

Po-kk-er Hands,一个使用26个小写英文字母标记的特殊纸牌游戏。

游戏规则:

  1. 初始状态:牌堆 DD 面朝下,顺序已知
  2. 随机在位置 ii 处切牌,i{0,...,n1}i \in \{0, ..., n-1\}
  3. 从切牌后的牌顶发 kk 张牌
  4. 收到的 kk 张牌按字母顺序排序形成最终手牌

P(D,i,k)P(D, i, k) 为在位置 ii 处切分牌堆 DD 后得到的 Po-kk-er 手牌。

示例:

  • 对于牌堆 D= abcdbcD =\ 'abcdbc'
  • 在位置 2 切牌后变成 cdbcab'cdbcab'
  • 取前 4 张牌并排序,得到 P(D,2,4)= bccdP(D, 2, 4) =\ 'bccd'

任务:

  1. 给定DDkk,设计一个数据结构,要求:

    • 构建时间:O(n)O(n)
    • 支持 same(i, j) 操作,常数时间内判断两个切牌位置是否产生相同手牌,即是否 P(D,i,k)=P(D,j,k)P(D, i, k) = P(D, j, k)
  2. 找出最可能出现的Po-kk-er手牌,要求:

    • 时间:O(n)O(n)
    • 如果存在多个最可能手牌,选择字典序最小的

实现思路:

  1. 为每个位置 i{0,1,...,n1}i \in \{0, 1, ..., n-1\} 构建一个 P(D,i,k)P(D, i, k) 的字母频数表(可以是26维数组或哈希表等)。每个位置单独计算,时间复杂度为 nO(k)=O(n)nO(k) = O(n);每个位置依次计算,时间复杂度为 O(k)+nO(1)=O(n)O(k)+nO(1) = O(n)same(i, j) 操作只需要比对相应的频数表即可,时间复杂度为 O(1)O(1)
  2. 构建一个字典,键是可能的手牌 P(D,i,k)P(D, i, k)(字符串),也可以是26维元组(字典的键不能为可变数据结构)等等,值是对应的出现次数,初始值为0。遍历前一问中构建的数据结构,每个字母频数表作为键,对应的值加1,要求字典序最小的手牌,最坏情况再遍历一次构建的字典即可。总体时间复杂度是 O(n)+nO(1)+O(n)=O(n)O(n) + nO(1) + O(n) = O(n)。因为我们访问字典来增加可能手牌的频数,总体时间复杂度是期望的 O(n)O(n)

Problem Session 4#

January 17, 2025

Problem 4-2. Top kk Elements with Limited Memory#

问题:

给定一个包含 nn 个元素的列表,其中每个元素都有一个唯一的标识符和一个关联的整数值(可以是正数或负数)。你的目标是选择绝对值最大的前 logn\log n 个元素。对输入列表只有只读访问权限。

任务:

  1. 设计一个时间复杂度为 O(n)O(n) 的算法
  2. 设计一个时间复杂度为 O(nloglogn)O(n \log \log n) 的算法,且仅使用 O(logn)O(\log n) 的额外空间

实现思路:

  1. 想法是 build 一个包含全部元素最大优先队列,然后再重复 delete_max() logn\log n 次(事实上,我认为要直接地想出这个思路需要对 Set 接口比较熟悉)。如果这里使用Set AVL树,不幸的是 buildO(nlogn)O(n \log n) 复杂度的;很明显使用最大堆是很合适的,因为前面指出了优先队列这一接口。总体时间复杂度 O(n+log2n)=O(n)O(n + \log^2 n) = O(n)
  2. 想法是,需要一个数据结构来记录最大的 logn\log n 个元素,况且也只有 O(logn)O(\log n) 的空间,我们关心的是其存储的 logn\log n 个元素的最小值。当读到一个大于最小值的元素 xx 时,可以 delete_min() 然后 insert(x)。因此,对前 mm 个元素维护这样的一个最小优先队列,依次读取新元素并更新,直到 mmnn。观察要求可以推得,单次更新前 logn\log n 个元素的复杂度为 O(loglogn)O(\log \log n),其中 loglogn\log \log n 为一个含 logn\log n 个元素的平衡二叉树的高度,而 logn\log n 又是空间的限制,那么这里便可以使用Set AVL树。构建的时间复杂度为 O(logn×loglogn)=O(nloglogn)O(\log n \times \log \log n) = O(n \log \log n),因此总体时间复杂度就是 O(nloglogn)O(n \log \log n)。除此之外,这里可以类似前一问使用最小堆来代替Set AVL树实现,过程和结果是一致的。

Problem 4-3. Top kk Elements with Dynamic Updates#

任务:设计一个数据库,支持以下操作。假设每次操作时,数据库中有 nn 个竞标者,kk 是常数

  1. new_bid(d, b):记录一个新的竞标者 ID dd 及其出价 bb,时间复杂度为 O(logn)O(\log n)
  2. update_bid(d, b):更新现有竞标者 ID dd 的出价为 bb,时间复杂度为 O(logn)O(\log n)
  3. get_revenue():返回可从当前出价最高的 kk 个竞标者获取的总收入,即他们的总出价,时间复杂度为 O(1)O(1)

实现思路:

首先,记录出价和更新出价都是根据 ID 进行的,ID 是互异的但是出价并不这样,需要一个数据结构用于查找 ID,这里可以选用哈希表,支持时间复杂度为期望平摊 O(1)O(1)new 和期望的 update;或选用Set AVL树,支持时间复杂度为 O(logn)O(\log n)newupdate。只要不关注排序顺序,并且键是互异的,我们就可以用哈希表,为了便于叙述,下面将以哈希表为例。

然后我们还需要跟踪出价的排序,请注意 update 可能的作用,一方面可以降低前 kk 个中的出价,另一方面可以提高后 nkn - k 中的出价,事实上这表现出一种对称的关系,需要为两部分都提供数据结构来存储:前 kk 个为最小优先队列 QQ,后 nkn - k 个为最大优先队列 PP。如果只设置前者,当降低前 kk 个中某出价,此时将无法判断是否还是前 kk 个大,无法处理。由于堆不支持更新某个节点,此处选用Set AVL树来实现两个队列,关键的一点是,我们需要处理 ID 和出价两部分的信息,类似problem 2-3.的想法,我们需要在以 ID 为 key 的Set AVL树或哈希表中,同时记录指向其对应的 QQPP 节点的指针为 value。

在进行newupdate 操作后,需要对三个数据结构进行维护,可见时间复杂度为 O(logn)+O(logk)+O(log(nk))=O(logn)O(\log n) + O(\log k) + O(\log (n - k)) = O(\log n),满足要求。最后,可以再维护一个变量 total 保存 QQ 中出价之和,这样不需要通过遍历计算 get 的结果。

具体过程:

  1. 数据结构
  • 哈希表 DD:用于查询 ID 对应的节点
  • 最小优先队列 QQ (Set AVL树):存储最大的前 kk 个出价
  • 最大优先队列 PP (Set AVL树):存储其余出价
  • totalQQkk 个出价之和
  1. new_bid(d, b)
  • 新建树节点 node.item = b,为哈希表添加键值对,键为 dd,值为指向节点的指针
  • 比较 bbQQ 中的最小值
    • 如果 bb 更大,则将最小值所在节点从 QQ 中删除再插入 PP(改变节点中的指针变量而非完全删除再新建),将新节点插入 QQ,更新 total
    • 反之,将新节点插入 PP
  • 时间复杂度 O(1)+O(1)+2O(logk)+O(log(nk))=O(logn)O(1) + O(1) + 2O(\log k) + O(\log (n-k)) = O(\log n)
  1. update_bid(d, b)
  • 通过哈希表 DD 查找对应的节点
  • QQPP 中删除该节点,并执行 new_bid(d, b) 中第二步的操作
  • 时间复杂度为 O(1)+O(logn)=O(logn)O(1) + O(\log n) = O(\log n)
  1. get_revenue()
  • 返回 total
  • 时间复杂度为 O(1)O(1)

Problem 4-4. Ranked kthk^{\text{th} } Receiver#

任务:设计一个数据库,支持以下操作。假设每次操作时,数据库中有 nn 场比赛记录,nn 大于球员人数。对于每个操作,要求在最坏情况下时间复杂度为 O(logn)O(\log n)

  1. record(g, r, p):记录编号为 rr 的球员在比赛 gg 中获得了 pp
  2. clear(g, r):移除编号为 rr 的球员在比赛 gg 中的记录
  3. ranked_receiver(k):返回当前表现kk 高的球员编号(表现定义为球员在所有比赛中的平均得分)

注意,类似problem 3-2. win fraction,仍然不能处理分数,只能处理整数


实现思路:

仔细想想,整体的框架和上一题很类似,我们依然可以使用 key 为编号的哈希表(或Set AVL树)和 key 为表现的Set AVL树来实现。不同点在于,树节点变得更加复杂,需要维护球员的各场比赛 GG 和相应的得分 PP,类似地我们可以选用哈希表或Set AVL树来记录,同时我们需要维护球员参加的比赛总场数和总得分两个属性,除此之外还需要记录编号用于 ranked_receiver 操作。尽管我们只能处理整数,但是通过总场数和总得分两个属性,我们可以比较任意两个球员的表现,因此可以以此作为 key 来构建Set AVL树。最后需要能够查询第 kk 高的编号,即遍历顺序下的第 nkn - k 个节点,联想Sequence AVL树,通过增强节点即可,增强信息为子树的大小。

Problem 4-5. Efficient Range Queries#

任务:设计一个数据库,支持以下操作。假设每次操作时,数据库中有 nn 条温度记录。对于每个操作,要求在最坏情况下时间复杂度为 O(logn)O(\log n)

  1. record_temp(t, d):记录日期 dd 的温度 tt
  2. max_in_range(d1, d2):返回日期范围 [d1,d2][d_1, d_2] 内的最高温度

实现思路:

这一题很好的展示了AVL树的强大能力,你可以想想如何用Set AVL树来实现,考虑它的排序性质和节点增强的方法。

  1. 以日期 dd 为 key 构建Set AVL树
  2. 增强节点,信息为子树的最小日期和最大日期(他们构成一个区间),以及最高温度
  3. 这里需要一些数学上的思考
    • 设本节点,左子树区间和右子树区间分别是 [l,r],[l1,r1],[l2,r2][l, r], [l_1, r_1], [l_2, r_2]
    • 一个树的左右子树的区间一定是不相交的,更进一步,r1<l2r_1 < l_2(如果允许Multiset,要求排序性质为 左子节点 \leq 本节点 << 右子节点 即可实现)
    • 一个树区间的左边界是左子树区间的左边界,右边界是右子树区间的右边界,即 l=l1,r=r2l = l_1, r = r_2
    • 任一子树区间与给定区间部分重叠的节点,两个子节点树区间必有一者与给定的区间交集为空,或者有一者包含于给定的区间
      • 这里部分重叠指的是,交集非空,且任一者不为另一者的子集
    • 进一步,最多只有一个节点的左子树区间和右子树区间都部分重叠于给定的区间
      • 直观来说,有 l1d1r1<l2d2r2l_1 \leq d_1 \leq r_1 < l_2 \leq d_2 \leq r_2
      • 该顶点的子树区间包含给定的区间
  4. 实现 subtree_max_in_range(A, d1, d2) 为节点 A 的子树中日期在 [d1,d2][d_1, d_2] 范围内的测量值的最高温度(如果范围内没有测量值,则返回 None
    • 通过递归实现
      • 对于一个子树区间包含于给定区间的节点,直接返回增强信息 - 最高温度
      • 对于一个子树区间与给定区间交集为空的节点,直接返回 None
      • 对于其他情况,通过递归两个子节点来计算
        • 子树区间与给定区间部分重叠的节点
        • 子树区间包含给定区间的节点
    • 因为3.中的第4和第5点,最多有一个节点需要递归计算两个子节点,其他节点递归时,至少有一个子节点处于边界条件,所以时间复杂度为为 O(2logn)=O(logn)O(2\log n) = O(\log n)
subtree_max_in_range(A, d1, d2) = max(
	A.t,
    subtree_max_in_range(A.left, d1, d2),
    subtree_max_in_range(A.right, d1, d2)
)
  1. 最后,record_temp(t, d) 即插入一个新节点,并维护三个增强信息;max_in_range(d1, d2)subtree_max_in_range(root, d1, d2)
6.006 笔记 Part1
https://durjustice.github.io/homepage/posts/mit6-006/note-1/
作者
Durjustice
发布于
2025-01-18
许可协议
CC BY-NC-SA 4.0