【专业课学习】「算法分析与设计」期末复习

143 阅读25分钟

69065999_p0 (1).png

算法

什么是算法

算法是用于求解实际问题的计算过程。实际问题的陈述说明了该问题的输入和所预期的输出结果,算法则描述通过该问题的输入求解出预期输出的过程。

算法的重要性

可以通过算法在实际生活实际问题中的应用来举例说明。

算法的四个性质

  • 有穷性:任意算法都应在执行有限次基本操作之后终止并给出输出
  • 正确性:算法所给的输出还应该能够符合由问题本身在事先确定的条件
  • 确定性:算法应可描述为由若干语义明确的基本操作组成的指令序列
  • 可行性:算法中每一基本操作在对应的计算模型中均可兑现

怎么样做算法分析

通过伪代码,分析算法的性能

例子1

image.png

x = 2, 4, 8, ..., n / 2

即x = 2^1, 2^2, 2^3, ..., 2log2n22^{\log_{2}{\frac{n}{2}}}

可见算法会执行log2n2\log_{2}{\frac{n}{2}}次,时间复杂度O(logn)O(\log{n})

例子2

image.png

对于该递归算法,T(n)=T(n1)+O(1)=T(n1)+cT(n) = T(n-1) + O(1) = T(n-1) + c

通过错位相减法可以很容易求出T(n)=O(n)T(n) = O(n).

例子3

image.png

两层循环,且内层循环条件与外层循环没有依赖关系

外层循环k = 1, 2, 4, ..., n = 20,21,22,,2log2n2^0, 2^1, 2^2, \dots, 2^{\log_{2}{n}},因此外层循环会迭代log2n\log_{2}{n}

内层循环j从1到n,迭代n次

该算法时间开销O(nlogn)O(n\log{n})

例子4

image.png

第1次迭代,i=1, sum = sum + i = 0 + 1 = 1

第2次迭代,i=2, sum = sum + i = 1 + 2 = 3

第3次迭代,i=3,sum = sum + i = 3 + 3 = 6

...

第i次迭代,sum[i] = sum[i-1] + i,

解出sum[i]=1+2++i=i(i+1)2sum[i] = 1 + 2 + \dots + i = \frac{i(i+1)}{2}

根据算法边界条件,令sum[i]=nsum[i] = n,得到总迭代次数O(n)O(\sqrt{n})

例子5

image.png

第1次迭代,算法边界条件n12n ≥ 1^2

第2次迭代,算法边界条件n22n ≥ 2^2

...

第i次迭代,算法边界条件ni2n ≥ i^2

n=i2n = i^2,解得i=O(n)i = O(\sqrt{n})

例子6

image.png

sum变量与两层循环的循环条件无关

第1次外层循环迭代,i = 202^0,j = 0

第2次外层循环迭代,i = 212^1,j = 0, 1, ..., 212^1,内层循环迭代212^1

第3次外层循环迭代,i = 222^2,j = 0, 1, ..., 222^2,内层循环迭代222^2

...

第k次外层循环迭代,i = 2k2^k,j = 0, 1, ..., 2k2^k,内层循环迭代2k2^k

i=2k=ni = 2^k = n,解出k=log2nk = \log_2 {n}

于是sum++语句被执行的次数为21+22++2log2n=O(n)2^1 + 2^2 + \dots + 2^{\log_2 {n}} = O(n)

例子-插入排序

算法思想

向一个有序的数组中按序插入一个新元素,这个数组仍然保持有序

伪代码

function InsertionSort(ar) {
    let len = ar.length;
    for (let i = 1; i < len; i++) {
        let elem = ar[i];
        let j = i - 1;
        // 注意这里运用了短路保护,因此两个判断条件的顺序不可颠倒!
        while (j >= 0 && elem < ar[j]) {
            ar[j + 1] = ar[j];
            j--;
        }
        ar[j + 1] = elem;
    }
    return ar;
}

性能

不需要额外的辅助空间,空间开销O(1)O(1)

时间开销方面,在外层循环n次迭代过程中,假设循环遍历i指向的元素都要被调往数组开头,则每次迭代过程中内循环的次数为1,2,,n1, 2, \dots, n。累加得该算法的时间开销上界为O(n2)O(n^2)

怎样做算法设计

算法设计的常用策略有哪些?

  • 枚举法(穷举法)
  • 迭代法:斐波那契数列
  • 递归
  • 分治:快速排序
  • 动态规划:最大子序列
  • 贪心:活动排序
  • 回溯法:0-1背包、N皇后
  • 分支限界法

渐进记号

分析算法效率的三种情况

  • 最坏情况
  • 平均情况
  • 最好情况

什么是"渐进"(Asymptotic)?

算法的渐进效率描述了一个算法在输入数据规模趋近于无穷大时的运行效率。分析算法的渐进效率,可以避免精确地计算一个算法所需的运行时间。

渐进记号

数学定义

  • 渐进紧确界:若f(n)Θ(g(n))f(n) ∈ Θ(g(n)),表示存在c1>0,c2>0,n0>0c_1 > 0, c_2 > 0, n_0 > 0,使得对于所有的nn0n ≥ n_0,都有0c1g(n)f(n)c2g(n)0 ≤ c_1 g(n) ≤ f(n) ≤ c_2 g(n).
  • 渐进上界:若f(n)O(g(n))f(n) ∈ O(g(n)),表示存在c>0,n0>0c > 0, n_0 > 0,使得对于所有的nn0n ≥ n_0,都有0f(n)cg(n)0 ≤ f(n) ≤ c g(n).
  • 渐进下界:若f(n)Ω(g(n))f(n) ∈ Ω(g(n)),表示存在c>0,n0>0c > 0, n_0 > 0,使得对于所有的nn0n ≥ n_0,都有0cg(n)f(n)0 ≤ c g(n) ≤ f(n).
  • 非渐进紧确的上界:若f(n)o(g(n))f(n) ∈ o(g(n)),表示存在c>0,n0>0c > 0, n_0 > 0,使得对于所有的nn0n ≥ n_0,都有0f(n)<cg(n)0 ≤ f(n) < c g(n).
  • 非渐进紧确的下界:若f(n)ω(g(n))f(n) ∈ ω(g(n)),表示存在c>0,n0>0c > 0, n_0 > 0,使得对于所有的nn0n ≥ n_0,都有0cg(n)<f(n)0 ≤ c g(n) < f(n).

一般在实际工程中:

  • 当渐进记号独立存在于式子的右侧,如n=O(n2)n = O(n^2),即表示对于函数f(n)=nf(n)=n,我们有f(n)O(n2)f(n) ∈ O(n^2).
  • 当渐进记号出现在某个公式中时,例如T(n)=T(n/2)+Θ(n)T(n) = T(n/2) + Θ(n),即表示T(n)=T(n/2)+f(n)T(n) = T(n/2) + f(n),其中f(n)Θ(n)f(n) ∈ Θ(n).

性质

  • 传递性(对所有渐进符号适用):如果f(n)=O(g(n))f(n)=O(g(n))并且g(n)=O(h(n))g(n)=O(h(n)),那么f(n)=O(h(n))f(n)=O(h(n)).
  • 自反性(对O、Ω、Θ适用):f(n)=O(f(n))f(n) = O(f(n)).
  • 对称性(对Θ适用):f(n)=Θ(g(n))f(n)=Θ(g(n))当且仅当g(n)=Θ(f(n))g(n)=Θ(f(n)).

证明题

参考walkccc.me/CLRS/Chap03…

例题1

求证:对于任意两个函数f(n)f(n)g(n)g(n),我们有f(n)=Θ(g(n))f(n)=Θ(g(n)),当且仅当f(n)=O(g(n))f(n)=O(g(n))f(n)=Ω(g(n))f(n)=Ω(g(n)).

证明:

先证明f(n)=Θ(g(n))f(n)=O(g(n))f(n)=Θ(g(n)) ⇒ f(n)=O(g(n))

由紧确界的定义,存在c1>0,c2>0,n1>0c_1 > 0, c_2 > 0, n_1 > 0,使得当n>n1n > n_1时,0c1g(n)f(n)c2g(n)0 ≤ c_1 g(n) ≤ f(n) ≤ c_2 g(n).

可见,对于c2>0,n1>0c_2 > 0, n_1 > 0,我们有当n>n1n > n_1时,0f(n)c2g(n)0 ≤ f(n) ≤ c_2 g(n),即f(n)=O(g(n))f(n)=O(g(n)).

同理,可证明f(n)=Θ(g(n))f(n)=Ω(g(n))f(n)=Θ(g(n)) ⇒ f(n)=Ω(g(n))

再证明f(n)=O(g(n)),f(n)=Ω(g(n))f(n)=Θ(g(n))f(n)=O(g(n)),f(n)=Ω(g(n)) ⇒ f(n)=Θ(g(n))

由上界定义,存在c3>0,n2>0c_3 > 0, n_2 > 0,我们有当n>n2n > n_2时,0f(n)c3g(n)0 ≤ f(n) ≤ c_3 g(n).

由下界定义,存在c4>0,n3>0c_4 > 0, n_3 > 0,我们有当n>n3n > n_3时,0c4g(n)f(n)0 ≤ c_4 g(n) ≤ f(n).

N=max{n2,n3}N = \max\{n_2,n_3\}

我们有当n>Nn > N时,0c4g(n)f(n)c3g(n)0 ≤ c_4 g(n) ≤ f(n) ≤ c_3 g(n)

f(n)=Θ(g(n))f(n)=Θ(g(n)).

例题2

求证:image.png

证明:

设函数F(n),G(n)F(n), G(n),使得F(n)=O(f(n))F(n) = O(f(n))G(n)=O(g(n))G(n) = O(g(n))

由大O记号定义:

  • 存在c1>0,n1>0c_1 > 0, n_1 > 0,使得当n>n1n > n_1时,0F(n)c1f(n)0 ≤ F(n) ≤ c_1 f(n).
  • 存在c2>0,n2>0c_2 > 0, n_2 > 0,使得当n>n2n > n_2时,0G(n)c2g(n)0 ≤ G(n) ≤ c_2 g(n).

N=max{n2,n3}N = \max\{n_2,n_3\}

我们有当n>Nn > N时,0F(n)+G(n)c1f(n)+c2g(n)0 ≤ F(n) + G(n) ≤ c_1 f(n) + c_2 g(n)

进一步地,令C=max{c1,c2}C = \max\{c_1,c_2\}

我们有当n>Nn > N时,0F(n)+G(n)Cf(n)+Cg(n)0 ≤ F(n) + G(n) ≤ C f(n) + C g(n)

进一步地,令H(n)=max{f(n),g(n)}H(n) = \max\{f(n),g(n)\}C=2CC' = 2C

我们有当n>Nn > N时,0F(n)+G(n)CH(n)0 ≤ F(n) + G(n) ≤ C' H(n)

F(n)+G(n)=O(max{f(n),g(n)})F(n) + G(n) = O(\max\{f(n),g(n)\})

函数的增长情况

image.png

递归

递归表达式的求解

求解方法

  • 画递归树
  • 代入法
  • 主方法

主方法内容表述如下:

令常数a1,b>1a ≥ 1, b > 1,对于递归表达式T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)

  • 若存在常数ε>0ε > 0,使得f(n)=O(nlogbaε)f(n) = O(n^{\log_b{a} - ε}),那么T(n)=Θ(nlogba)T(n) = Θ(n^{\log_b{a}}).
  • f(n)=Θ(nlogba)f(n) = Θ(n^{\log_b{a}}),则T(n)=Θ(nlogbalgn)T(n) = Θ(n^{\log_b{a}}\lg{n}).
  • 若存在常数ε>0ε > 0,使得f(n)=Ω(nlogba+ε)f(n) = Ω(n^{\log_b{a} + ε}),且满足正则化条件「存在常数c<1c<1,对于所有足够大的nn,都有af(n/b)<cf(n)af(n/b) < cf(n)」,那么T(n)=Θ(f(n))T(n) = Θ(f(n)).
  • 推论:若f(n)=Θ(nlogbalgkn)f(n) = Θ(n^{\log_b{a}} \lg^k{n}),则T(n)=Θ(nlogbalgk+1n)T(n) = Θ(n^{\log_b{a}}\lg^{k+1}{n}).

注:教材中的lg\lg均指以2为底的对数

例题

参考juejin.cn/post/734510…

分治策略

算法思想

小问题比大问题更容易求解,把大问题分解为若干个性质相同的子问题,再有子问题的解组装成原问题的解

如何使用分治策略设计算法(用分治策略解题的步骤是什么)?

  1. 分解(Divide):将原问题分解为若干个规模更小、更容易求解的子问题
  2. 求解子问题(Conquer):递归地求解子问题;若子问题足够小,则可直接返回答案
  3. 合并(Merge):由子问题的解,组装得到原问题的解

分治策略的优缺点

优点:

  1. 易于理解和实现:将一个大问题分解为多个小问题,使得问题的结构更加清晰,易于理解和实现。
  2. 提高效率:在某些情况下(例如快速排序中pivot恰好等分原数组),基于分治策略设计的算法性能优异,可以达到最佳的时间复杂度。

缺点:

  1. 递归开销:递归调用带来的函数调用开销(包括栈空间的使用)可能会导致性能问题,尤其是在递归深度较大的情况下。
  2. 复杂度分析困难:对于某些问题,确定递归方程的时间复杂度可能较为复杂,需要深入分析和理解问题的结构。
  3. 子问题重叠:在某些情况下(例如,动态规划问题),不同子问题之间可能存在重叠,需要额外的存储来记录已经计算过的子问题解,这增加了空间复杂度。

实际案例

  • 归并排序:要对原数组进行排序,则可以先将原数组等分为两个子数组进行排序,再将排序好的子数组线性归并,即可得到原数组的排序结果。
  • 快速排序
  • 最大子数组:一个数组的最大子数组可能在数组中点的左侧或右侧,也有可能跨越了数组的中点;前两种情况可递归求解,最后一种情况可在线性时间内求解(从中点开始向两侧扩张区间),最后三者比较取最大者即可。
  • 一个数的幂次:xn=xn/2xn/2x^n = x^{n/2} x^{n/2}(n为偶数);xn=xn/2xn/2xx^n = x^{\lfloor n/2 \rfloor} x^{\lfloor n/2 \rfloor} x(n为奇数)
  • 二分查找:根据ar[mid]和key的关系分成三种情况

什么是完全二叉树?什么是二叉堆?

完全二叉树是一种特殊的二叉树,其特点是除最后一层外,每一层的节点都是满的,即每一层都有2𝑛个节点(第 𝑛 层)。在最后一层中,所有节点都尽可能地集中在左边,没有"缺口"。具体地说:

  1. 每一层节点满:从根节点开始,每一层的节点数都是满的,直到倒数第二层。
  2. 最后一层集中:在最后一层中,节点从左到右依次填充,中间没有缺失的空位。

image.png

二叉堆是一种特殊的完全二叉树。

  • 最大堆:对于每个节点 𝑖 及其子节点 𝑙 和 𝑟,满足 𝐴[𝑖]≥𝐴[𝑙] 且 𝐴[𝑖]≥𝐴[𝑟]。
  • 最小堆:对于每个节点 𝑖 及其子节点 𝑙 和 𝑟,满足 𝐴[𝑖]≤𝐴[𝑙] 且 𝐴[𝑖]≤𝐴[𝑟]。

如何用堆实现排序?

image.png

代码参考:juejin.cn/post/734955…

如何用堆构建优先级队列

  1. 初始化堆:创建一个空的二叉堆,可以选择最大堆(Max Heap)或最小堆(Min Heap)。
  2. 插入元素:将新元素插入到堆的末尾,然后逐步上浮(heapify-up),与其父节点比较并交换,直到堆的性质恢复。
  3. 取出最大(或最小)元素:从堆中取出具有最高优先级的元素(对于最大堆来说是最大元素,最小堆则是最小元素)。此时堆顶在逻辑上为空。
  4. 调整堆:将堆的最后一个元素移动到堆顶,然后逐步下沉(heapify-down),与其子节点比较并交换,直到堆的性质恢复。

顺序统计

同时找区间中的最大值和最小值

维护两个变量minmax用作储存最终答案

  • 若区间长度为偶数,这两个变量分别取区间左侧两元素a[0]、a[1]的较小者和较大者
  • 若区间长度为奇数,这两个变量均取a[0]

接下来,不断地同时从数组中取出两个元素a[i]、a[i + 1],设t_mint_max分别表示这两者的较小值和较大值(1次比较),在分别与minmax比较以确定是否更新答案(2次比较)。

  • 对于偶数长度区间,共执行1 + (n-2)/2 * 3次比较
  • 对于奇数长度区间,共执行(n-1)/2 * 3次比较

找第k小元素

对待查找区间A[p, r]用随机化partition划分,设划分后pivot的下标为i,那么pivot为区间中第i - p + 1小的元素。设rank=i - p + 1

  • 如果rank==k,则A[i]即为所求
  • 如果rank < k,说明pivot选小了,在区间A[i + 1, r]中查找第i - rank小的元素
  • 如果rank > k,说明pivot选大了,在区间A[p, i - 1]中查找第i小的元素

快速排序

Partition

快排中对待排序区间进行划分时要选取pivot。几种可能方案如下:

  • 选取待排序区间中第一个元素作为pivot
  • 选取待排序区间中最后一个元素作为pivot
  • 选取待排序区间中位居中间的元素作为pivot
  • 随机选择一个元素作为pivot

为了实现代码的方便,在选取完pivot后,可以将其与待排序区间最左侧的元素互换,再在"逻辑上"从区间最左侧取出pivot,这样最左侧的元素就为空了。

随机化的例子如下:

function QuickSort(ar, left = 0, right = ar.length) {
    if (right - left <= 1) return;
    let i = left;
    let j = right - 1;
    // Math.floor(Math.random() * (j - i + 1)) ∈ [0, j - i]
    let rand = i + Math.floor(Math.random() * (j - i + 1));
    // swap ar[i] and ar[rand]
    let t = ar[i];
    ar[i] = ar[rand];
    ar[rand] = t;
    let pivot = ar[i];
    while (i < j) {
        while (i < j && pivot <= ar[j]) --j; 
        ar[i] = ar[j];
        while (i < j && ar[i] <= pivot) ++i;
        ar[j] = ar[i];
    }
    ar[i] = pivot;
    QuickSort(ar, left, i);
    QuickSort(ar, i + 1, right);
}

时间复杂度

最坏情况O(n2)O(n^2),最好情况O(nlogn)O(n\log{n}),随机化快速排序平均情况O(nlogn)O(n\log{n})

为什么实践中经常使用快排?

  • 虽然平均时间复杂度均为为O(nlogn)O(n\log n),但快速排序的递归式常数因子较小,性能优于归并排序和堆排序。
  • 在实践中,通过合适的枢轴选择方法(如随机选择枢轴),可以大大降低快排O(n2)O(n^2)最坏情况发生的概率。

线性时间排序

比较排序算法下界定理

二叉决策树描述了在理想情况下,为了确定一个待排序序列的顺序,一个基于比较的排序算法至少要进行的比较次数。由于一个长度为n的序列的排序结果一定为其n!种排列组合方案中的某一种,该二叉树将具备n!个叶子节点。该二叉树的树高为h即为排序算法比较次数的下界,2hn!2^h ≥ n!,由斯特林公式可得hΩ(nlogn)h ≥ Ω(n\log{n}).

image.png

常用的线性排序算法

  • 计数排序:统计各元素出现次数、求前缀和、从右往左遍历原数组,根据前缀和的结果将原数组中的元素放置到新数组中
  • 基数排序:先按元素最低位(最右边那一位)进行一轮排序;再按元素次低位进行排序;循环往复,直到完成按元素最高位的排序
  • 桶排序:适用于浮点数。先将浮点数按一定规则(例如nA[i]\lfloor nA[i] \rfloor)转移到对应的桶(共有n个桶)中,在桶的内部执行插入排序等算法,最后将不同桶中的元素依次拼接,得到排序后的结果。

image.png

image.png

image.png

散列表

为什么需要散列?

计算机有限的内存空间不足以直接使用地址映射的方式来构造词典,必须对查询键使用散列函数映射到一个有限的内存空间当中去。

解决冲突的策略

  • 拉链法
  • 开放定址法

image.png

image.png

设计散列函数的策略

设计散列函数时需要尽量降低冲突的可能性,为此一个散列函数需要近似满足以下理想情况:

简单一致散列(simple uniform hashing)

适用于基于拉链法的散列表。

在理想情况下,任意一个key会被哈希函数h(key)等概率地映射到任意一个槽中,与其他任何key的映射情况无关。

一致散列(uniform hashing)

适用于基于开放定址法的散列表。

我们假定在使用h(key, i)向哈希表插入key时,对于任意的key,程序在探测用于插入哈希表的空槽时,依次访问哈希表中槽的位置的顺序是完全随机的,并不会偏袒某些槽,从而保证所有槽都有充分的机会被探测,降低冲突的概率。

具体的散列函数

除法散列法

h(k) = k mod m,其中m为散列表大小,一般选取不太接近2的整数幂的素数,例如701。

乘法散列法

  • 用关键字k乘上某个常数A,其中0 < A < 1
  • 取出k的小数部分,再与散列表长度m(一般取2的整数幂)相乘
  • 将相乘结果向下取整,即为散列结果

全域散列

任何一个特定的散列函数都有可能出现最坏的情况,所有节点都散列到了同一个桶中。

全域函数组:一组有限的散列函数,他们中的每一个都把关键字全域U映射到集合{0,1,2,3......m-1},如果对于任意2个关键字k和l都有,从函数组中随机选取一个函数h,发生散列冲突(h(k)=h(l))的概率不超过1/m,那么我们称这样的函数组为全域函数组。

全域散列法:在全域函数组中随机选取一个函数作为散列函数。

PS:初始化的时候选择一次即可,选定之后不再更改。

全域散列法对于任意数据的平均性能都是最好的,但是仍然可能出现最坏情况,因此需要引入完美散列。

完美散列

image.png

红黑树

红黑树的五大性质

  1. 所有的节点都被标记为红色或黑色
  2. 树的根节点是黑色的
  3. 树的叶节点(NIL)是黑色的,且除了叶节点外所有节点的度都为2
  4. 红节点的父亲和孩子都是黑色的
  5. 从任一节点出发到达任意的一个叶节点的简单路径(不含出发节点本身)上,黑节点的数目(即黑高度)是固定的

红黑树的高度

二叉搜索树的基本操作性能为O(h)

红黑树作为一种平衡二叉搜索树,其黑高度满足hblackh2h_{black} ≥ \frac{h}{2},其树高h2log2(n+1)h ≤ 2\log_2{(n + 1)},进一步可证明基本操作性能均为O(logn)O(\log{n})

红黑树的插入操作

image.png

image.png

image.png

例题

例题1

调整红黑树

无标题.png

例题2

Show the red-black trees that result after successively inserting the keys 41,38,31,12,19,8 into an initially empty red-black tree.

微信图片_20240614212501.jpg

动态规划

动态规划的优势

分治算法独立地求解子问题,这可能会导致多次求解同一个子问题。对此,除了自顶向下的备忘录方法,动态规划(自底向上)可以避免这个问题

问题能用动态规划求解的特征

  • 最优子结构:目标问题中原问题的最优解包含了子问题的最优解
  • 重叠子问题:如使用递归算法求解原问题,在求解过程中会反复地出现相同的子问题

算法策略

算法思想:

自底向上,先求解较小的子问题,再由子问题得到原问题解的形式进行求解

算法步骤:

  1. 分析最优解的结构特征(是否具备最优子结构)
  2. 递归地定义最优解的值(写出状态转移方程)
  3. 采用自底向上的迭代方式计算最优解的值(如LCS问题中求出最长子序列的长度值)
  4. 利用计算过程中的中间信息构造最优解(如LCS问题中求出一种可能的最长子序列)

实际案例

LCS

  1. 当i=0或j=0,dp[i][j]=0
  2. 当str1[i]≠str2[j]时,dp[i][j] = max{dp[i-1][j], dp[i][j-1]}
  3. 当str1[i]=str2[j]时,dp[i][j] = dp[i-1][j-1]+1

矩阵链乘

  1. 当i=j时,dp[i][j] = 0
  2. 当i < j时,dp[i][j] = max{dp[i][k] + dp[k+1][j] + p[i-1]p[k]p[j]} for i ≤ k ≤ j - 1

背包问题

w背包总容量,i表示正在放第几个物品

  1. 当w=0或i=0时,dp[w][i]=0
  2. 当weight[i]≤w,dp[i][w] = max{dp[i - 1][w], dp[i - 1][w - weight[i]] + value[i]}
  3. 当weight[i]>w,dp[i][w] = dp[i-1][w]

贪心算法

动态规划和贪心的区别和联系?

相同点:

一般贪心策略和动态规划策略都要求目标问题具备最优子结构

区别:

  1. 贪心策略要求目标问题具备贪心选择性质,即在该问题中可以通过做出局部最优选择的方式来构造全局最优解。
  2. 在求解时,动态规划采用自底向上,先求解较小的子问题,再由子问题得到原问题解的形式进行求解;而贪心策略通常采用自顶向下的方式,通过不断选取局部最优解的形式,将问题逐渐变小。

联系:

如果某个问题可以利用动态规划进行求解,并且能够证明它具备贪心选择性质,则可在动规算法的基础上设计出针对该问题的贪心算法。

算法策略(如何利用贪心策略设计算法?)

  1. 确定问题的最优子结构
  2. 给出一个递归表达式,设计一个递归算法
  3. 证明如果我们做出一个贪心选择,则待求解的子问题只剩下一个
  4. 证明贪心选择是安全的
  5. 设计一个递归/递推算法实现贪心策略

实际案例

  • 活动选择问题:先按活动结束时间对所有活动排序,然后始终选择结束时间最早,且开始时间与先前已选择活动不冲突的活动。
  • 分数背包问题:根据不同物品的性价比进行排序,优先选择性价比最高的物品;最后背包容量还有盈余,还可以塞进最后一个物品的一部分。

不相交集合的数据结构

即并查集(disjoint-set),在kruskal算法的时候用于维护哪些节点在同一个连通分量当中。

function InitSet(n) {
    let set = [];
    for (let i = 0; i < n; ++i) set.push(i);
    return set;
}

function FindSet(set, i) {
    if (set[i] === i) {
        return i;
    } else {
        return (set[i] = FindSet(set, set[i]));
    }
}

function UnionSet(set, i, j) {
    set[i] = set[j];
}

image.png

最小生成树

Prim

  • 算法思想:贪心算法。维护顶点集合S,开始时选取一个初始顶点放入S中;算法执行时在顶点集合S和V-S之间做割,将割上轻边连接的顶点收入集合S中;重复上述步骤,直到S=V。
  • 时间复杂度,同Dj,为O(ElogV)O(E\log{V})
  • 伪代码:
for vertex in V:
    dist[vertex] = ∞
dist[source] = 0

Q是图中顶点v∈V以dist[v]为关键字的优先级队列
while len(Q) > 0:
    u = 从Q中弹出dist值最小的顶点
    for v in adj[u]:
        if (v in Q) and (edge[u][v] < dist[v]):
            dist[v] = edge[u][v]
            parent[v] = u

Kruskal

  • 算法思想:贪心算法。每次挑权重最小且不会形成环的边加入森林F,因为这条边一定连接着最小生成树的两棵子树。
  • 时间复杂度,同Prim,为O(ElogV)O(E\log{V})
  • 伪代码
MST = {}

for v in V:
    创建一个以v为根节点的并查集

将图中的所有边e∈E,按weight[e]的大小从小到大排序,排序结果存入序列SortedE

for edge in SortedE:
    u = edge.u, v = edge.v
    if u和v不属于同一个集合:
        MST = MST ∪ {edge}
        合并u、v所在的集合

最短路径

单源最短路径

Bellman-Ford

  • 算法思想:使用松弛操作,通过多次迭代(最多 𝑉−1 次)逐步减少路径长度。
  • 时间复杂度:O(VE)O(VE)
  • 伪代码:
for vertex in V:
    dist[vertex] = ∞
dist[source] = 0

for i in range(1, len(V)):
    for edge in E:
        u = edge.u, v = edge.v
        if dist[u] + edge.weight < dist[v]:
            dist[v] = dist[u] + edge.weight
            
for edge in E:
    u = edge.u, v = edge.v
    if dist[u] + edge.weight < dist[v]:
        raise Error("出现负权环!")
  • 算法思想:

Dijkstra

  • 算法思想:最短路径问题具备最优子结构,使用贪心算法求解,每次通过优先队列选择当前已知距离最短的未处理节点
  • 时间复杂度:一般O((V+E)logV)O((V+E)\log{V}),对稠密图O(ElogV)O(E\log{V})
  • 伪代码:
for vertex in V:
    dist[vertex] = ∞
dist[source] = 0

Q为关于dist的优先级队列
while len(Q) > 0:
    u = 从Q中取出dist值最小的顶点
    for v in adj[u]:
        if dist[u] + edge[u][v] < dist[v]:
            dist[v] = dist[u] + edge[u][v]
            更新Q

Bellman-Ford和Dijkstra的异同点

相同:

  • 都是单源最短路径算法
  • 都依赖对边进行松弛操作

差异:

  • BF算法可以处理带负权边的图,并能检测负权环;Dj算法不能处理带负权边的图
  • Dj的时间复杂度优于BF,适用于稠密图

点对最短路径

Floyd-Warshall

  • 算法思想:动态规划。令点对(i, j)最短路径上的所有中间节点都从集合{v1, v2, ..., vk}中取,该算法通过不断扩充集合的大小,来逐渐求得最短路径
  • 时间复杂度:O(V3)O(V^3)
  • 伪代码:
for k in range(0, len(V)):
    for i in range(0, len(V)):
        for j in range(0, len(V)):
            if dist[i][k] + dist[k][j] < dist[i][j]:
                dist[i][j] = dist[i][k] + dist[k][j]

最大流问题

基本概念

流网络(Flow networks)

流网络(Flow networks)是用于描述和分析网络流动的一种数学模型。它由一组顶点(节点)和连接这些顶点的有向边(边)组成,每条边都有一个容量限制,表示通过该边的最大流量。流网络常用于解决实际中的运输、通信和供应链问题。

在流网络中,流量从源点s开始流出,因此源点没有流入的边;流量在汇点结束,因此汇点t没有流出的边。

image.png

f(u,v)f(u, v)表示节点u和v之间实际流过网络的流量。

流的性质:

  • 容量限制:0f(u,v)c(u,v)0 ≤ f(u, v) ≤ c(u, v),c(u, v)是边的最大容量
  • 流量守恒:对于除s、t外的节点u,vVf(v,u)=vVf(u,v)\sum_{v ∈ V} {f(v, u)} = \sum_{v ∈ V} {f(u, v)}

残存网络

给定流网络G(V, E)和流量f,残存网络GfG_f由G中仍有余地对流量进行调整的边构成。这些边在GfG_f中均带有一个残存容量cfc_f

对残存网络中的某一条边(u, v),残存容量定义:

  • 正向边:若(u,v)E(u, v) ∈ Ecf=c(u,v)f(u,v)c_f = c(u, v) - f(u, v),这些边的存在代表可以在流网络中对应边继续增加流量
  • 反向边:若(v,u)E(v, u) ∈ Ecf=f(u,v)c_f = f(u, v),这些边的存在代表可以减小流网络中对应边的流量

增广路径

增广路径为从残存网络中从s到t的一条可达路径。

增广路径p瓶颈边的残存容量,定义为该增广路径的残存容量cf(p)c_f(p),是调整流网络的依据。

Ford-Fulkerson算法

从残存网络中不断找出一条增广路径p,利用其残存容量cf(p)c_f(p)对原流网络中的相应各边作更新,更新完毕后再更新残存网络。当残差网络中找不到增广路径时,原流网络中的流达到最大。

更新策略:

  • 若边(u, v)为残存网络的正向边,则在原图中f(u,v)=f(u,v)+cf(p)f(u, v) = f(u, v) + c_f(p)
  • 若边(u, v)为残存网络的反向边,则在原图中f(v,u)=f(v,u)cf(p)f(v, u) = f(v, u) - c_f(p)

最大流最小割定理

将图中节点划分成包含s的集合A和包含t的集合B,这即为流网络的一个割。

割的两个参数:净流量,容量

image.png

image.png

最大流最小割定理

引理:流网络中任意流的大小,不能超过任意割的大小,即fc(S,T)|f|≤c(S, T)

以下三件事等价:

  • 流网络中的流取到最大值
  • 残存网络中不含有增广路径
  • 流值大小等于流网络中某一个割的容量c(S, T),且这个割的容量大小为所有可能的割的方案中最小的(可由引理推出)。

数据结构的扩展

如何进行数据结构的扩展?

  1. 确定一个基础的数据结构
  2. 为这种数据结构添加一个额外的性质,要求该性质有利于原问题的求解,有利于在基础数据结构上进行维护
  3. 在对该数据结构进行修改时,维护这个性质
  4. 利用这个性质,求解目标问题

回溯法

什么是回溯法

基于回溯法设计的算法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。算法搜索至解空间树的任意结点时,先判断该结点是否包含问题的解(即剪枝)。若肯定不包含,则跳过对以该结点为根的子树的搜索,逐层向其祖先结点回溯;否则,进入该子树,继续按深度优先搜索策略搜索。

几个重要的概念:

  • 解向量:将问题的某一种可能解表示为满足某个约束条件的等长向量X={x1,x2,x3,}X = \{x_1, x_2, x_3, \dots\}
  • 解空间:所有可能的解向量构成了问题的解空间
  • 解空间树:问题的解空间用解空间树的形式来组织。树的根节点位于第1层,表示问题求解前的初始状态;第2层的节点表示对解向量中第1个分量作出选择后,问题所达到的状态,并以此类推。
  • 搜索解空间树:算法从解空间树根节点出发,采用深度优先搜索访问所有可能的从根节点到解空间树上叶子节点的路径。当搜索抵达树上的某一叶子节点,即算法找到了一种符合题目约束条件的可能解。需要指出,当算法在搜索过程中,若发现某一节点向下的路径违反了原问题的约束条件,即继续,则放弃从该节点继续往下搜索,转而向上向着祖先节点回溯。

实际案例

0-1背包

解向量xix_i可取0或1,表示不选取或选取第i件物品。

可根据当前放入物品的重量 ≤ 当前背包剩余容量这一约束条件来实现剪枝。

旅行商问题(TSP)

解向量xix_i可取1,2,..,n,表示旅行商去的第i座城市是什么。

可根据每个城市恰好访问一次的约束条件来剪枝。

image.png

N皇后

解向量x[i][j]x[i][j]可取0或1,表示在棋盘坐标(i, j)处是否放置棋子。

可根据在(i,j)处放置棋子,需要保证它不会被已有的棋子攻击这一约束条件来实现剪枝。

分支限界法

image.png

image.png

image.png

image.png

www.bilibili.com/video/BV1gb…