tips:该文章主要包含课本《计算机算法设计与分析》中的重点内容,主要是自己比较通俗的理解。很多语言比较口语化。
一.算法概论
1.算法复杂性在渐近意义下的阶
渐近意义下的记号:O(欧米克戎,omikrong)、Ω(欧米伽,omiga)、θ(西塔)、o、ω
O可以理解为fn和gn作为两个函数,Fn中的n无论多大都无法超过gn,gn是它的上界之一,用fn=O(gn)表示
Ω的理解与O相反
θ的理解为f(N)的增长率不超过g(N)的增长率,也不会超过。(考虑到一个常数因子)。
o理解为f(N)的增长速度比g(N)的增长速度慢到可以忽略不计
二.递归与分治策略
2.链表反转
给定一个单链表的头结点pHead(该头节点是有值的,比如在下图,它的val是1),长度为n,反转该链表后,返回新链表的表头。{1,2,3}->{3,2,1}
/* 链表节点结构体 */
struct ListNode {
int val; // 节点值
ListNode *next; // 指向下一节点的指针
ListNode(int x) : val(x), next(nullptr) {} // 构造函数 };
class Solution {
public:
ListNode* ReverseList(ListNode* pHead) {
if(pHead==NULL || pHead->next==NULL){
//特判:不要漏掉pHead->next==NULL
return pHead;
}
//递归调用
ListNode* ans = ReverseList(pHead->next);
//让当前结点的下一个结点的 next 指针指向当前节点
pHead->next->next=pHead;
//同时让当前结点的 next 指针指向NULL ,从而实现从链表尾部开始的局部反转
pHead->next=NULL;
return ans;
}};
理解:首先通过递归,到达结点3,由于if判断,不能继续递归,直接返回,接着到达2,
2->next是3,->next=phead就是等于他自己,也就是3->2,接着执行phead->=null,让2->3这个地方断开,就实现了第一步反转,以此类推。
3.小青蛙跳台阶:斐波那契数列
原理:以到达4为例,即j(3)+j(2),也就是到达3的所有情况加上到达2的所有情况,但问题是这里没有包括到达4的情况,也就是在前面的式子中包含了,将这些方法分解:
1-1-1-1
1-1-2
1-2-1
2-1-1
2-2
一共5种,而j(3)+j(2)=2+3=5,再仔细分解,到达3,(红),到达2,(黑)也就是说,到达3时,最后一下也就是到达4的过程并没有新方法,也就是只能1步,所以包含了。
4.汉诺塔画图
5.分治法使用条件
复杂性
6、二分搜索
7.棋盘覆盖(代码)
public void chessBoard(int tr, int tc, int dr, int dc, int size)
{
if (size == 1) return;
int t = tile++, // L型骨牌号
s = size/2; // 分割棋盘
// 覆盖左上角子棋盘
if (dr < tr + s && dc < tc + s)
// 特殊方格在此棋盘中
chessBoard(tr, tc, dr, dc, s);
else {// 此棋盘中无特殊方格
// 用 t 号L型骨牌覆盖右下角
board[tr + s - 1][tc + s - 1] = t;
// 覆盖其余方格
chessBoard(tr, tc, tr+s-1, tc+s-1, s);}
// 覆盖右上角子棋盘
if (dr < tr + s && dc >= tc + s)
// 特殊方格在此棋盘中
chessBoard(tr, tc+s, dr, dc, s);
else {// 此棋盘中无特殊方格
// 用 t 号L型骨牌覆盖左下角
board[tr + s - 1][tc + s] = t;
// 覆盖其余方格
chessBoard(tr, tc+s, tr+s-1, tc+s, s);}
// 覆盖左下角子棋盘
if (dr >= tr + s && dc < tc + s)
// 特殊方格在此棋盘中
chessBoard(tr+s, tc, dr, dc, s);
else {// 用 t 号L型骨牌覆盖右上角
board[tr + s][tc + s - 1] = t;
// 覆盖其余方格
chessBoard(tr+s, tc, tr+s, tc+s-1, s);}
// 覆盖右下角子棋盘
if (dr >= tr + s && dc >= tc + s)
// 特殊方格在此棋盘中
chessBoard(tr+s, tc+s, dr, dc, s);
else {// 用 t 号L型骨牌覆盖左上角
board[tr + s][tc + s] = t;
// 覆盖其余方格
chessBoard(tr+s, tc+s, tr+s, tc+s, s);}
}
原理如下,重点是代码。首先size/2进行二维划分,即将棋盘分为四个部分接着开始对四个部分进行判断,判断特殊棋子在不在,顺序:左上右上左下右下。如果在,那就对该区域再次划分,如果不在,就给其对角上覆盖。直到size=1时,return到递归的上一步,进行对其右下角的特殊覆盖,其实也就是将这个1x1的给覆盖。然后再将左上角覆盖,然后右上角,左下角,接着发现右下角有特殊方格,且划分不了,则返回前一步递归,到右上角的2x2棋盘,然后发现没特殊方块,对其划分,给左下角特殊覆盖,然后给另外三个角上L。接着是左下角,有特殊方块,则继续划分,此时区别刚刚,不用对对角,也就是右上进行特殊覆盖。划分到1x1后上L右下类似右边上,完成后则算递归完成,再对右上的4x4进行处理。
接下来解释一下代码:dr是特殊方块,tr代表左上角。关键代码以左上为例,此时是2
X2棋盘,board[tr + s - 1][tc + s - 1] = t;就代表了覆盖右下。board[tr + s - 1][tc + s - 1] = t
8.归并排序
void merge(int arr[],int temp[],int low,int mid,int high){
int i = low;//左分治数组开始的下标
int j =mid+1;//右分治数组开始的下标
int k = low;//temp数组开始下标
while(i<mid+1 && j<high+1){//到达最右边,即后方无元素时
if(arr[i]< arr[j])
temp[k++]=arr[i++];//存放数组的同时移动角标
if(arr[i]>arr[j])
temp[k++]=arr[j++];
}
while(i<mid+1)
temp[k++]=arr[i++];
while(j<high+1)
temp[k++]=arr[j++];//把剩下的一个挪到temp中
for (int i=low;i<=high;i++)
arr[i]=temp[i];//将排序好的数组放回上层
}
void MergeSort(int arr[],int temp[],int low,int high){
int mid = (low+high)/2;
if(low<high){
MergeSort(arr,temp,low,mid);//递归将左半边数组分成一个一个
MergeSort(arr,temp,mid+1,high);//递归将右半边数组分成一个一个
merge(arr,temp,low,mid,high);//排序
}
}
时间复杂度nlogn
当对84排序完成(48)后,就会回溯到57,继续排序,以此类推,57完成后,就会回溯到4857进行排序。
9.快速排序
首先,最左边为low和key,最右边为high,high--。找到比key小的,就将这个high换到low,key换到刚刚的位置上,上图空出来了比较直观。接着将low++直到找到比key大的,再将low换到key的位置,重复刚刚的步骤,直到low=high,就将key写上去,代表该处已经排好(左边比key小,右边比key大),再对其左边和右边分别重复。
int partition(int arr[], int low, int high) {
int pivot = arr[low];
while (low < high)
{
while(high > low && arr[high] > pivot)
high--;//如果high比基准大,往前移动
arr[low] = arr[high];//如果high比基准小,则换到low的位置
while (high > low && arr[low] < pivot)
low++;
arr[high] = arr[low];//如果low比基准大,则换到high的位置
}
arr[low] = pivot;
return low;
}
void QuickSort(int arr[], int low, int high) {
if (low < high) {
int pivotpos = partition(arr, low, high);
QuickSort(arr, low, pivotpos - 1);
QuickSort(arr, pivotpos + 1, high);
}
}
三.动态规划
10.动态规划思想
动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题
但是经分解得到的子问题往往不是互相独立的。不同子问题的数目常常只有多项式量级。在用分治法求解时,有些子问题被重复计算了许多次。
如果能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,就可以避免大量重复计算,从而得到多项式时间算法。
11.矩阵连乘问题
A = 50 x 10
B = 10 x 40
C = 40 x 30
D = 30 x 5
((A(BC))D) 的计算次数:
BC:10 x 40 x 30 = 12,000 次
A(BC):50 x 10 x 30 = 15,000 次
(A(BC))D:50 x 30 x 5 = 7,500 次
总共:12,000 + 15,000 + 7,500 = 34,500 次
(A(B(CD))) 的计算次数:
CD:40 x 30 x 5 = 6000 次
B(CD):10 x 40 x 5 = 2000 次
A(B(CD)):50 x 10 x 5 = 2,500 次
总共:6,000 + 2000 + 2,500 = 10500 次
(((AB)C)D) 的计算次数:
AB:50 x 10 x 40 = 20,000 次
(AB)C:50 x 40 x 30 = 60,000 次
((AB)C)D:50 x 30 x 5 = 7,500 次
总共:20,000 + 60,000 + 7,500 = 87,500 次
以此类推
12.最优子结构:
最优子结构是问题能用动态规划算法求解的前提。
13.最优凸多边形(搞不定)
14.01背包动态规划
/* 0-1 背包:动态规划 */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
int n = wgt.size();
// 初始化 dp 表
vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
// 状态转移
for (int i = 1; i <= n; i++) {
for (int c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
dp[i][c] = dp[i - 1][c];// 若超过背包容量,则不选物品 i
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[n][cap];
}
关键步骤理解(核心代码):
dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1])
核心代码原理:首先是dp[i - 1][c],意思是如果不装第i个物品,那么总价值就是之前第i-1的价值。
然后就是要明确,wgt[i-1]代表的是第i个物品的重量。
dp[i - 1][c - wgt[i - 1]] + val[i - 1]代表的是,如果装第i个物品,首先假设只有它自己在里面,要算他自己的价值val[i - 1],其次再看看剩余了多少重量:c - wgt[i - 1],然后对照着物品的表格,查看当前剩余的重量,在前一个物品的记录下,所对应的最优解,以此可以看出全局的最优解。
四.贪心算法
15.背包问题贪心算法
public static float knapsack(float c,float [] w, float [] v,float [] x)
{
int n=v.length;
Element [] d = new Element [n];
for (int i = 0; i < n; i++)
d[i] = new Element(w[i],v[i],i);
MergeSort.mergeSort(d);
int i;
float opt=0;
for (i=0;i<n;i++) x[i]=0;
for (i=0;i<n;i++) {
if (d[i].w>c) break;
x[d[i].i]=1;
opt+=d[i].v;
c-=d[i].w;
}
if (i<n){
x[d[i].i]=c/d[i].w;
opt+=x[d[i].i]*d[i].v;
}
return opt;
}
16.最优装载
- 背包的最大容量(
c
)为 50 单位。 - 有三个物品,它们的重量分别为 10 单位,20 单位和 30 单位。
现在,我们将使用这段代码来确定在不超过背包容量的情况下,可以装入背包的物品的总重量。
初始化数据:
float c = 50; // 背包容量
float[] w = {10, 20, 30}; // 物品重量数组
int[] x = new int[3]; // 用于存储结果的数组
public Element(float ww, int ii)
{
w=ww;
i=ii;
}
//这是 Element 类的构造函数。它创建了一个包含两个字段的对象:w(物品的重量)和 i(物品的原始索引)。
public static float loading(float c, float [] w, int [] x)
{//这是 loading 方法的定义,它接受背包容量 c,物品重量数组 w 和一个将用于存储结果的整型数组 x(表示每个物品是否被选中)。
int n=w.length;
Element [] d = new Element [n];//确定物品的数量 n 并初始化一个 Element 类型的数组 d。
for (int i = 0; i < n; i++)
d[i] = new Element(w[i],i);//为每个物品创建一个 Element 对象,并存储其重量和索引。
MergeSort.mergeSort(d);//使用归并排序(或其他排序算法)对 d 数组进行排序。假设排序是按照物品的重量进行的。
float opt=0;
for (int i = 0; i < n; i++) x[i] = 0;//初始化最优装载的重量 opt 为 0,并将 x 数组中所有元素设为 0(表示初始时没有物品被选中)。
for (int i = 0; i < n && d[i].w <= c; i++) {//遍历排序后的物品数组 d,选择每个物品直到背包容量 c 无法再装下更多物品。
x[d[i].i] = 1;//对于每个选中的物品,标记 x[d[i].i] 为 1(表示该物品被选中)
opt+=d[i].w;
c -= d[i].w;
}
//并更新已选中物品的总重量 opt 和剩余的背包容量 c。
return opt;
}
调用
loading
方法当我们调用
loading(c, w, x)
方法时,以下是代码的执行过程:
初始化:
确定物品的数量
n = 3
。创建
Element
对象数组d
。填充
Element
数组:
为每个物品创建
Element
对象:d[0] = new Element(10, 0)
,d[1] = new Element(20, 1)
,d[2] = new Element(30, 2)
。对
d
数组排序:
假设排序是按物品重量升序排列的,排序后
d
的顺序不变(因为物品已经是按重量升序排列的)。贪心选择物品:
opt = 0;
x = {0, 0, 0};
//初始化最优装载的重量 opt 为 0,并将 x 数组中所有元素设为 0(表示初始时没有物品被选中)。遍历
d
数组,依次尝试加入每个物品:(解释一下 x[d[i].i],由于对数组d进行了排序,所以例如d[0].i中,排序前d[0].i代表的就是第一个重量为30的物品,现在就变成了第三个重量为10的物品。)
加入
d[0]
(重量 10),x[0] = 1
,opt = 10
,剩余容量c = 40
。加入
d[1]
(重量 20),x[1] = 1
,opt = 30
,剩余容量c = 20
。加入
d[2]
(重量 30),但是d[2].w > c
,所以不加入。返回最优装载重量:
return opt;
// 返回 30,表示能装入背包的物品的总重量为 30 单位。
算法loading的主要计算量在于将集装箱依其重量从小到大排序,故算法所需的计算时间为 O(nlogn)。
17.单源最短路径
1、算法基本思想
Dijkstra(迪杰斯特拉)算法是解单源最短路径问题的贪心算法。
以下图为例:是整个算法的更新过程
step1
1是当前最优,将1作为永久标号。
step2
1到2对比刚刚的0到2的无穷,较优,更新数值。更新完后,此时3是最小数值,作为永久标号
step3
032对比012更优,更新2的数值,034对比04更优,更新4数值,当前2为最小数值,作为永久标号
step4
0324对比04更优,更新4数值,并且也作为永久标号,结束,该图展现了路径。
结合该算法的另外一种表达方式
迭代 | S | u | dist[2] | dist[3] | dist[4] | dist[5] |
初始 | {1} | 0 | 10 | / | 30 | 100 |
1 | {1,2} | 2 | 10 | 60 | 30 | 100 |
2 | {1,2,4} | 4 | 10 | 50 | 30 | 90 |
3 | {1,2,4,3} | 3 | 10 | 50 | 30 | 60 |
4 | {1,2,4,3,5} | 5 | 10 | 50 | 30 | 60 |
时间复杂度On^2
18.prim kruskal 最小生成树
Prim算法:核心就是依次选择最小的路径。从1开始,当前最短情况是13,接下来从3开始,要考虑的情况包括(14,12,34,32,36,35)而次数最短的就是36,接下来从6开始,也要考虑(12,14,32,34,35,64,65)此时最短是4,接下来从4开始,按照刚刚的思路对前几个点也进行考虑,逐步得出结论。
时间复杂度:
Kruskal算法:
该算法的核心是,首先将G的n个顶点看成n个孤立的连通分支,逐步找出两个数字间的最短距离,但前提是不能让图连通,将所有的边按权从小到大排序。看d步骤,36的距离比14,12,32,34,35都要小,所以选择36连接。
五.回溯法
尝试、剪枝、回退
剪枝可以避免无意义的搜索空间,提高搜索效率。
19.问题的解空间
问题的解向量:回溯法希望一个问题的解能够表示成一个n元式(x1,x2,…,xn)的形式。
显约束:对分量xi的取值限定。
隐约束:为满足问题的解而对不同分量之间施加的约束。
解空间:对于问题的一个实例,解向量满足显式约束条件的所有多元组,构成了该实例的一个解空间。
注意:同一个问题可以有多种表示,有些表示方法更简单,所需表示的状态空间更小(存储量少,搜索方法简单)。
n=3时的0-1背包问题用完全二叉树表示的解空间
20.回溯法的基本思想
3个步骤:
(1)针对所给问题,定义问题的解空间;
(2)确定易于搜索的解空间结构;
(3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
21.子集树与排列树
子集树:当所给的问题是从n个元素的集合S中找出满足某种性质的子集时,相应树称为子集树。
例如,n个物品的0-1背包问题所相应的解空间树就是一棵子集树。(01背包,装载问题、最大团问题、图的m着色)
排列树:当所给的问题是确定n个元素满足某种性质的排列时,相应的解空间树称为排列树。排列树通常有n!个叶结点。因此遍历排列树需要Ω(n!)的计算时间。旅行售货员问题的解空间树就是一棵排列树。(批处理作业调度、旅行售货员)
22.装载问题
cw:已选物品重量 bestw:最优解 r:集装箱剩余容量 w[i]:当前物品重量
private static void backtrack (int i)
{// 搜索第i层结点
if (i > n) {// 到达叶结点
if(cw>bestw)//如果当前物品总重量大于最优重量
bestw=cw;//则这个所有重量换成该物品
return;}
r -= w[i];//开始对物品进行评估,
if (cw + w[i] <= c) {//如果当前总重量加上当前物品重量小于总容量
x[i] = 1;//加入该物品
cw += w[i];//当前容量加上该物品
backtrack(i + 1);//继续搜索左子树
cw -= w[i]; //此时递归结束,将当前物品的重量减掉
}
if (cw + r > bestw) {//剪枝
x[i] = 0; // 搜索右子树
backtrack(i + 1);
}
r += w[i];//
}
参考该图(num:物品索引,tw:当前总重量,)
算法执行过程
最大容量为130,物品的重量分别是
[90, 80, 40, 30, 20]
开始探索(
backtrack(1)
):
开始时,
i = 1
,即考虑第一个物品(重量90)。考虑第一个物品(90):
r -= w[1]
:从剩余重量中减去当前物品的重量,即r = 130 - 90 = 40
。检查
cw + w[1] <= c
,即0 + 90 <= 130
。条件成立,可以选择此物品。设置
x[1] = 1
,表示选择物品90。更新cw = 90
。递归调用
backtrack(2)
,考虑第二个物品。考虑第二个物品(80):
r -= w[2]
,现在r = 40 - 80 = -40
(负值表示即使选择所有剩余物品也无法达到更优解)。检查
cw + w[2] > c
,即90 + 80 > 130
,条件不成立,不能选择此物品。检查
cw + r > bestw
,即90 - 40 > 0
。条件成立,可以考虑不选择物品80的情况。设置
x[2] = 0
,表示不选择物品80。递归调用
backtrack(3)
,考虑第三个物品。考虑第三个物品(40):
r -= w[3]
,现在r = -40 - 40 = -80
。检查
cw + w[3] <= c
,即90 + 40 <= 130
。条件成立,能选择此物品。设置
x[3] = 1
,表示选择物品40。更新
cw = 90+40=130
递归调用
backtrack(4)
,考虑第四个物品。探索第四个物品(30)
r -= w[4]
,现在r =-80-30=-120
。检查
cw + w[4] <= c
,即130 + 30 <= 130
。条件不成立,不能选择此物品。
检查
cw + r > bestw
,即130 - 120 > 0
。条件成立,可以考虑不选择物品30的情况。设置
x[4] = 0
,表示不选择物品30。探索第五个物品(20)
r -= w[5]
,现在r = -120 - 20 = -140
。检查
cw + w[5] <= c
,即130 + 20 <= 150
。条件不成立,不能选择此物品。
检查
cw + r > bestw
,即130 - 140 > 0
。条件不成立执行r=-140+20=-120
接着回溯到上一步 5.
7.回溯到探索第四个物品
r=-120+30=-90
8.回溯到第三个物品
cw=130-40=90
判断if (cw + r > bestw) 90-90=0 不成立
r=-90+40=-50
9.回溯到第二个物品
r=-50+80=30
10.回溯到第一个物品
cw=90-80=10
判断 if (cw + r > bestw) 10+30>0
x[2]=0
backtrack(3)
下面思路混乱,不会了
六.分支界限法
分支限界法的基本思想
与回溯法对比
(1)求解目标:回溯法的求解目标是找出解空间树中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出在某种意义下的最优解。
(2)搜索方式的不同:回溯法以深度优先的方式搜索解空间树,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树。
八.NP完全性理论与近似算法
NP类问题定义
P类问题:P类问题是指那些可以在多项式时间内被确定性图灵机解决的问题。这意味着存在一个算法,可以在关于输入大小的多项式时间内找到问题的解.例如:排序,最短路径
NP类问题: 一个问题属于NP类(非确定性多项式时间类),如果给定一个潜在的解决方案,该方案可以在多项式时间内被验证是否正确。
例如:最长公共子序列问题:why?给定两个序列和一个潜在的公共子序列作为“解决方案”,我们可以通过遍历这两个序列来验证这个潜在子序列是否实际上是它们的子序列。验证过程不需要重新计算最长公共子序列,只需要检查该子序列的每个元素在两个原始序列中按顺序出现,这可以在多项式时间内完成。
NP难问题:是指至少和NP中最难的问题一样难的问题。所有NP问题都可以在多项式时间内归约到任何一个NP难问题。重要的是,NP难问题并不要求本身在NP中,即它们的解不一定能在多项式时间内被验证。
NP完全问题:NP问题类包括所有那些解可以在多项式时间内被验证的问题。而NP完全问题是这样一类特殊的NP问题:任何其他NP问题都可以在多项式时间内转化(归约)为这个NP完全问题。
例如:合取范式的可满足性问题(CNF-SAT),元合取范式的可满足性问题 (3-SAT),顶点覆盖问题,旅行售货员问题,子集和问题,团问题,哈密顿回路问题