注:文本中的所有代码块均为伪代码,不考虑具体的实现形式。
1.排序
插入排序
for j = 2 to A.length
key = A[j]
i = j - 1
while(i > 0 && A[i] > key){
A[i + 1] = A[i];
i--;
}
A[i + 1] = key
归并排序
- 归并排序:
Merge(A, p, q, r):
if p == r:
return
for i = 1 to q - p + 1:
L[i] = A[i + p - 1]
for j = 1 to r - q://因为A[q]已经放入L中
R[j] = A[q + j]
L[q - p + 2] = ∞, R[r - q + 1] = ∞ //哨兵
i = 1, j = 1
for k = p to r:
if L[i] > R[j]:
A[k] = R[j]
j = j + 1
else A[k] = L[i]
i = i + 1
return A
Merge_sort(A, p, r):
while(p < r):
q = (p + r) / 2 //注意这里取下整
Merge_sort(A, p, q)
Merge_sort(A, q + 1, r)
Merge(A, p, q, r)
用递归树法得出时间复杂度。(ch2)
2. 宽度优先
- BFS
procedure BFS(v)
VISITED(v) = 1; u = v
将Queue初始化为空
loop
for each w ∈ Adj[v] do
if VISITED(w) = 0 then
ENQUEUE(Q, w)
VISITED(w) = 1
endif
repeat
if Q == Empty
then return
endif
ENQUEUE(Q, v)
repeat
end BFS
- 宽度优先周游
procedure BFT(G, n)
int VISITED(n)
for i = 1 to n
do VISITED(i) = 0
repeat
for i = 1 to n
if VISITED(i) = 0
then call BFS(i)
endif
repeat
end BFT
若G是无向连通图或强连通图,则1次完成G的周游。否则完成多次。
-
宽度优先生成树
在BFS的基础上,增加了边的存储。
procedure BFS(v)
VISITED(v) = 1; u = v
将Queue初始化为空
*T⬅空集
loop
for each w ∈ Adj[v] do
if VISITED(w) = 0 then
T⬅T∪{(v, w)}
ENQUEUE(Q, w)
VISITED(w) = 1
endif
repeat
if Q == Empty
then return
endif
ENQUEUE(Q, v)
repeat
end BFS
结束时,T中的边组成G的一棵生成树。
D_search
改造BFS,用 栈来保存未被检测的结点,则得到新的检索算法。
在上述图中,结点访问序列为:
1 2 3 6 7 8 4 5
3.深度优先
- DFS
procedure DFS(v)
VISITED(v) = 1
for w ∈ Adj[v] do
if VISITED(w) == 0
then call DFS(w)
endif
repeat
END DFS
注意: BFS和DFS的时间和空间开销相同。
-
深度优先周游DFT
深度优先周游的应用:(与宽度优先周游的应用相同)
- 判断图的连通性
- 计算图的连通分量
- 无向图的自反传递闭包矩阵
-
深度优先生成树:与宽度优先周游相似,增加一个存储边的集合T。
T⬅Φ
procedure DFS*(v, T)
VISITED(v) = 1
for w ∈ Adj[v] do
if VISITED(w) == 0 then
T = T ∪ {(v, w)}
call DFS*(w, T)
endif
repeat
end DFS*
BFS和DFS生成树的区别
4 背包问题
01背包
描述:n个物体,每个物体数量有1个,重量w, 价值v。
i为坐标编号,j为背包容量。
状态转移方程:x[i][j] = max(x[i - 1][j], x[i - 1][j - w[i]] + v[i])
//遍历时,需要:
for i = 1 to n:
for j = C to 0:
if j > w[i]:
x[i][j] = max(x[i - 1][j], x[i - 1][j - w[i]] + v[i])
else x[i][j] = x[i - 1][j]
return x[n][C]
由于i情况下,只与上一种情况i - 1有关,因此状态转移方程可以降维到:
x[j] = max(x[j], x[j - w[i]] + v[i]);
//这里的j为背包此时容量,取值为0~C
//注意j遍历时,应当从大到小遍历,直到j < w[i]为止,可以减少遍历次数。
变式:如果是多重背包问题
描述:n个物体,每个物体数量有d个,重量w, 价值v。
状态转移方程只要稍作修改:x[j] = max(x[j], x[j - k * w[i]] + k * v[i])
此时的遍历改为:
for(int i = 1; i <= n; i++)
for(int j = C; j >= w[i]; j--)
for(int k = 1; k <= d[i] && j >= k * w[i]; k++)
x[j] = max(x[j], x[j - k * w[i]] + k * v[i]);
return f[C];
分数背包
思路为找单位重量收益最大的先放入,直到放满为止。
//已知:w[1...n], p[1...n]
let x[1...n] be a new array
for k = 1 to n:
x[k] = 0
Package(frac, pos)
for i = 1 to n:
Package.frac = p[i] / w[i]
Package.pos = i
sort(Package) //按照frac降序排序
for i = 1 to n:
if M >= Package.frac[i]
x[Package.pos[i]] = 1
M = M - w[Package.pos[i]]
else
x[Package.pos[i]] = M / w[Package.pos[i]]
break
return x
5 动态规划问题
注:这部分是本课程的最核心和最重点内容!但是考试只考了一道类似于LCS的简单动态规划问题…
钢条切割问题
- 自顶向下:思路是,利用递归,分为i和n - i两个方向求解,且第一个方向不再分割。n-i采用递归。
这样的好处是,思路比较简单,但是时间和空间开销很大,生成了一棵递归调用树,多个部位重复计算。
- 带备忘的自顶向下:
思路:采用一个数组r记忆计算过的钢条切割收益,然后在递归调用时,若计算过则直接返回。
- 自底向上
//数组p[i]存储长度为i的钢条的收益,r[i]存储长度为i的钢条最大收益
r[0] = 0;
for j = 1 to n:
q = -∞
for i = 1 to j:
q = max(q, p[i] + r[j - i])//关键递推式
r[j] = q
repeat
return r[n]
时间复杂度:O(n2)
- 带存储切割方案的自底向上方法
//数组p[i]存储长度为i的钢条的收益,r[i]存储长度为i的钢条最大收益,s[i]存储i段钢条切割的第一段
r[0] = 0;
for i = 1 to n:
s[i] = 0;//new
for j = 1 to n:
q = -∞
for i = 1 to j:
if q < p[i] + r[j - i]
q = p[i] + r[j - i]
s[i] = i;//new
r[j] = q
repeat
return r and s
- 输出最优切割方案
PRINT():
while n > 0
print s[n]
n = n - s[n]
矩阵链乘法
一种直观的想法就是,写成矩阵内积的样子:
//p[i]存储Ai+1个数组的行(row),m[i, j]二维数组,存放Ai到Aj乘法链运算的最优解
//部分正确,但是部分错误的想法:
for i = 0 to n:
m[i, i] = 0
m[i + 1, i] = 0
for i = 1 to n:
for j = i to n:
m[i, j] = -∞
for k = i to j - 1:
m[i, j] = min(m[i, j], m[i, k] + m[k + 1, j] + p[i - 1]p[i]p[j])
return m[1, n]
但是仔细推敲一下,这样做有问题,因为
当 i = 1, j = 3,k = 1时,m[1, 3] = m[1, 1] + m[2, 3] + p0p1p3,但是此时m[2, 3]还没有计算出来。
因此,需要限定第一次乘法时,矩阵链的长度为1,然后长度慢慢往上涨,一直涨到n,此时得出结果。
for i = 1 to n:
m[i,i] = 0 //一定不会出现m[i + 1, i]的情况,因为i >=j > k
for l = 2 to n://l = 1时,一个操作数,无法进行乘操作。
for i = 1 to n - l + 1://i遍历到末尾时,要留出l的长度
j = i + l - 1
m[i, j] = +∞
for k = i to j - 1:
m[i, j] = min(m[i, j], m[i, k] + m[k + 1, j] + p[i - 1]p[i]p[j])
return m[1, n]
类似地,对括号方案开一个数组进行记录,得:
//s[i,j]存储进行到从i到j矩阵的运算时,先在哪两个之间打括号
for i = 1 to n:
m[i,i] = 0 //一定不会出现m[i + 1, i]的情况,因为i >=j > k
for l = 2 to n://l = 1时,一个操作数,无法进行乘操作。
for i = 1 to n - l + 1://i遍历到末尾时,要留出l的长度
j = i + l - 1
m[i, j] = +∞
for k = i to j - 1:
if m[i, k] + m[k + 1, j] + p[i - 1]p[i]p[j] < m[i, j]:
m[i, j] = m[i, k] + m[k + 1, j] + p[i - 1]p[i]p[j]
s[i, j] = k
return m[1, n]
时间复杂度:O(n2)
打印矩阵链乘法方案:
PRINT(s, i, j):
if i == j:
print "Ai"
else
print "("
print PRINT(s, i, s[i, j])
print PRINT(s, s[i ,j] + 1, j)
print ")"
最长公共子序列
递推式如下:
采用自底向上的动态规划如下:
//原始思路(存在问题):
for i = 1 to n:
for j = 1 to m:
c[i, j] = 0;
for i = 1 to n:
for j = 1 to m:
if x[i] == y[j]:
c[i, j] = c[i - 1, j - 1] + 1
else
c[i, j] = max(c[i - 1, j], c[i, j - 1])
return c[n, m]
这样分析,发现有问题,因为i-1和j-1的存在,因此i,j都可以取0
因此修改为:
//在初始化中将c[i, 0], c[0, j]均初始化为0
for i = 0 to n:
for j = 0 to m:
c[i, j] = 0;
for i = 1 to n:
for j = 1 to m:
if x[i] == y[j]:
c[i, j] = c[i - 1, j - 1] + 1
else
c[i, j] = max(c[i - 1, j], c[i, j - 1])
return c[n, m]
增加一个存储如何前行的数组b,用来记录前行的方向。
//在初始化中将c[i, 0], c[0, j]均初始化为0
for i = 0 to n:
for j = 0 to m:
c[i, j] = 0;
for i = 1 to n:
for j = 1 to m:
if x[i] == y[j]:
c[i, j] = c[i - 1, j - 1] + 1
b[i, j] = '↖'
else
aa = c[i - 1, j]
bb = c[i, j - 1]
c[i, j] = max(aa, bb)
if aa >= bb
b[i, j] = '↑'
else b[i, j] = '←'
return c[n, m] and b
时间复杂度:O(mn)
算法改进:
- 直接依据c[i,j]来求方向
- 压缩数组维度(只能得到LCS的长度,得不到具体的值)
for i = 0 to m
c[i] = 0
t = 0
for i = 1 to n:
for j = 1 to m:
if x[i] == y[j]:
c[j] = c[j - 1] + 1
else c[j] = c[j - 1]
return c[m]
- 打印LCS
PRINT(b, X, i, j):
if i == 0 or j == 0
return
if b[i, j] = '↖'
PRINT(b, X, i - 1, j - 1)
print Xi
elseif b[i, j] = '↑'
PRINT(b, X, i - 1, j)
else PRINT(b, X, i, j - 1)
最优二叉搜索树
有n个单词,其概率分别为p1, p2…pn
落在两个单词之间的概率,用q0, q1, q2, … qn表示,每一个区间以伪关键字di表示,共n + 1个。
一个二叉搜索树的搜索成本用(depth(i) + 1) * ti表示,ti表示一个结点。
目的就是使这个搜索成本最小,因此需要pi大的靠近根,pi小的靠近叶子。所有的叶子结点都是伪关键字结点。
状态转移方程:
在ki~kj的关键字中,选一个kr当作根节点,此时ki ~ kr-1和kr+1到kj变为kr的子树,每个结点的深度加1。
再加上变为根节点的pr代价,共计新增搜索代价为w(i, j)
对于w[i, j]和w[i, j - 1],其关系为:
特别地,对于i = 1, j = 1, w[i, j] = w[1, 0] + p1 + q1 = q0 + p1 + q1
for i = 1 to n + 1:
e[i, i - 1] = q[i - 1]
w[i, i - 1] = q[i - 1]
for l = 1 to n:
for i = 1 to n - l + 1
j = i + l - 1
e[i, j] = +∞
w[i, j] = w[i, j - 1] + pj + qj
for k = i to j
t = e[i, k-1] + e[k+1, j] + w[i,j]
if t < e[i, j]
e[i, j] = t
root[i, j] = k
return e and root
6 函数的增长
- 大O:渐进上界(但可能不是渐临紧确界)
- 小o:非渐进上界
- 大omiga: 渐进下界
- 小omiga(w):非渐进下界
注意theta符号给出的是:渐进紧确界
定理:
7 分治
归并排序
- 归并排序:
Merge(A, p, q, r):
if p == r:
return
for i = 1 to q - p + 1:
L[i] = A[i + p - 1]
for j = 1 to r - q://因为A[q]已经放入L中
R[j] = A[q + j]
L[q - p + 2] = ∞, R[r - q + 1] = ∞ //哨兵
i = 1, j = 1
for k = p to r:
if L[i] > R[j]:
A[k] = R[j]
j = j + 1
else A[k] = L[i]
i = i + 1
return A
Merge_sort(A, p, r):
while(p < r):
q = (p + r) / 2 //注意这里取下整
Merge_sort(A, p, q)
Merge_sort(A, q + 1, r)
Merge(A, p, q, r)
用递归树法得出时间复杂度。(ch2)
最大连续子数组
FIND_MID_CROSS_SUBARRAY(A, low, mid, high):
left_sum = -∞
right_sum = -∞
sum = 0
for i = mid to low:
if left_sum < A[i] + sum
sum += A[i]
left_sum = sum
L = i
sum = 0
for i = mid + 1 to high: //从mid + 1开始,避免mid算两次
if right_sum < A[i] + sum:
sum += A[i]
right_sum = sum
R = i
return L, R, left_sum + right_sum
FIND_MAX_SUBARRAY(A, low, high):
if low == high:
return (low, high, A[low])
if low < high:
mid = (low + high) / 2
(left_l, left_r, left_sum) = FIND_MAX_SUBARRAY(A, low, mid)
(right_l, right_r, right_sum) = FIND_MAX_SUBARRAY(A, mid + 1, high)
(mid_l, mid_r, mid_sum) = FIMD_MID_CROSS_SUBARRAY(A, low, mid, high)
return 三者中最大的sum的三元组(l, r, sum)
8 贪心算法
证明贪心算法的最优子结构性
大意:假设一个最优解使用k个硬币,其中使用了1个面值为c的硬币。对于面值为n的纸币,最优的硬币兑换方案必须让n-c的纸币兑换方案最优,且使用k - 1个硬币。
- 否则利用“剪切-粘贴法”证明:
若n-c的纸币兑换需要小于k-1个硬币,那么最优解的硬币个数要小于k,与最优解为k个硬币的假设矛盾。故货币兑换问题具有最优子结构。
路径选择问题
- 迭代求解:时间复杂度O(n)
假设路径已经按照结束时间排好序
CHOOSE(s, f):
A = {a1}
k = 1
for m = 2 to n:
if s[m] >= f[k]:
k = m
A = A ∪ {am}
return A
路径选择问题的进阶版
图的区域着色问题:
-
一种直观的想法就是,将上述路径选择重复进行多次,然后每次将一个最大兼容子数组从原集合中剔除,继续调用CHOOSE函数直到原集合为空。执行次数即为图的着色颜色数/最少所需教室数。
-
但是,最坏情况下,这样的时间复杂度为O(n2)
-
采用另一种更快的思路:
每一个开始和结束的时间想象为一条线段,如果两条线段不重叠,就把这条线段加入上一条的后面,更新线段的末端值。否则重新开一条线段。最后的线段数为所需的最少教室数。
struct activity[1...n] //contains s, f, pos, room in each struct
priority queue q //按照存入的f升序排序
ans = 0
sort(activity) //按照开始时间和结束时间排序,升序。优先开始时间。
for i = 1 to n:
if q == Empty:
q.push(activity[i])
ans = ans + 1
activity[i].room = ans
else
if q.top().f <= activity[i].s
w = q.pop()
q.push(activity[i])
activity[i].room = w.room
else
q.push(activity[i])
ans = ans + 1
activity[i].room = ans
return ans, activity
时间复杂度:O(nlogn)
分数背包问题
思路为找单位重量收益最大的先放入,直到放满为止。
//已知:w[1...n], p[1...n]
let x[1...n] be a new array
for k = 1 to n:
x[k] = 0
Package(frac, pos)
for i = 1 to n:
Package.frac = p[i] / w[i]
Package.pos = i
sort(Package) //按照frac降序排序
for i = 1 to n:
if M >= Package.frac[i]
x[Package.pos[i]] = 1
M = M - w[Package.pos[i]]
else
x[Package.pos[i]] = M / w[Package.pos[i]]
break
return x
由于本题需要排序,所以时间复杂度为O(nlogn)
如何在线性时间内求分数背包问题?
已知获得一个数中位数的时间复杂度为O(n)
货币兑换问题
证明最优子结构性
大意:假设一个最优解使用k个硬币,其中使用了1个面值为c的硬币。对于面值为n的纸币,最优的硬币兑换方案必须让n-c的纸币兑换方案最优,且使用k - 1个硬币。
- 否则利用“剪切-粘贴法”证明:
若n-c的纸币兑换需要小于k-1个硬币,那么最优解的硬币个数要小于k,与最优解为k个硬币的假设矛盾。故货币兑换问题具有最优子结构。
C的幂次面值硬币的贪心算法证明:
#### 在O(nk)下利用动态规划进行货币兑换
denomination: 货币面值
denom[1…n]:存储当前 j分纸币减少时使用的硬币面值。
//已知货币序列(降序)为d[1...k]
Compute_coins(d, k, n):
let A[1...n], denom[1...n] be new arrays//A记录硬币个数,denom记录兑换方案
j = C
for j = 1 to n:
A[j] = +∞
for i = 1 to k:
if j >= d[i] and A[j] < A[j - d[i]] + 1:
A[j] = A[j - d[i]] + 1
denom[j] = d[i]
return A, denom
PRINT(j):
if j > 0:
print ("1 coin of " + denom[j])
PRINT(j - denom[j])
9 回溯与分枝限界的对比
9.1 回溯法(DFS+剪枝)
子集和数
回溯法+限界函数
限界采用两个隐式条件,可以尽早杀死一些对答案无用的结点,使状态空间树的规模缩小。
第一个条件理解:当前选择方案的和数与后面所有未选择数的和相加,必须不小于M,否则整个序列和都小于M,不会存在一个答案。
第二个条件理解:当前选择方案的和数与下一个选择的数字之和必须小于M,否则由于W[1…n]按照降序排序,则M落在当前和数与下一个和数之间,不存在答案。
机器的可靠性问题
//已知:c[1...n][1...m], q[1...n][1...m], C
global current = +∞
global record[1...m], best[1...m]
FIND_METHOD(k, C, cost, Q):
if cost > current:
return
if k > n:
current = cost
for j = 1 to m:
best[j] = record[j]
return
for j = 1 to m:
if cost + c[k][j] < C:
record[j] = k //存储解决方案
FIND_METHOD(k + 1, C - q[k][j], cost + c[k][j], Q + q[k][j])
Machine_design(n, C, 0, 0)
//得到current, best
9.2 分枝限界(BFS+剪枝->引入成本估计函数)
多处理机调度问题(与作业调度问题不同)
//Queue:q, r[1...n]为n个作业的运行时间
FIND(n, k, r):
Queue q 为空 //维护一个队列,元素依照A.r排序。A.r:成本估计函数,值等于等待时间+运行时间
struct A{r, pos, tag} //定义一个结构,r为成本估计函数,pos记录原始数据位置,tag记录由哪一台处理机处理
A[1...n]
for i = 1 to n: //初始化
A[i].r = r[i]
A[i].pos = i
sort(A[1...n]) //按照A[i].r降序排序
if n <= k: //n的数量小于处理机数量,直接返回r的最大值
return A[1].r
time = 0
for j = 1 to k: //前k个作业直接入队
A[j].tag = j
q.push(A[j])
time += A[j].r
for j = k + 1 to n:
w = EXTRACT_MIN(q) //将队列元素按照成本估计函数排序,返回成本估计最小的结点,并在队列中删除
A[j].r += w.r
A[j].tag = w.tag
if A[j].r > time:
time = A[j].r
q.push(A[j])
return time, A
十五谜问题
f(x): 由根节点到X的路径长度
g(x):把X转换为目标状态的最小移动数
作业惩罚调度问题
上界:未选入S的惩罚求和
下界:目前S经过的点中,未选入S的惩罚求和
后记:考试原题
2022年5月8日22:07
虽然已经考完试了,但是还是想把其中发挥的一道题写下来,记录思路。
求A{a1, a2, … an}中,ai + aj = M的两个数的下标。
global record[2]
FIND_PAIR(A, n, X):
let B1 and B2 be new sets
B1 = 空集
B2 = 空集
A1 = A ∪ X
sort(A1) //按照升序,对新集合排序
if X == A1[1] or X == A1[n + 1]: //排除不存在的情况
print "not exist"
return
i = 1
while A1[i] != X: //初始化两个集合
B1 = B1 ∪ {X - A1[i]}
i = i + 1
for j = i + 1 to n + 1:
B2 = B2 ∪ {A2[j]}
sort(B1) //升序排序
sort(B2) //升序排序
//下面利用归并排序中归并的思想,将两个集合B1, B2做升序排序。
//若出现L[i] == R[j]的情况,则返回对应元素的下标
//n1 = i - 1
n2 = n + 1 - (i + 1) + 1 = n - i + 1
i = 1
k = 1
for t = 1 to n:
if B1[i] < B2[k]:
i++
elseif B1[i] > B2[k]:
k++
else record[1] = B1[i].pos
record[2] = B2[k].pos
return record
时间复杂度:O(nlogn)