期末复习——算法设计与分析
时间:2022-09-27 15:00:04
目录
- 引言
- 算法基础知识
-
- 算法的五个特点:(必考)
- 常用的描述算法的方法:
- 时间复杂:
- 基本算法设计技术
-
- 分治法和减治法
-
- 基本思路:
- 同与异:
- 适用范围:
- 应用:
-
- 归并排序
- 快速排序:
- 堆排序:
- 折半查找:
- 二叉搜索树(二叉搜索树):
- 插入排序:
- 动态规划法和贪婪法
-
- 基本思路:
- 同与异:
- 适用范围:
- 应用:
-
-
- 多段图的最短路径问题:
- 01背包问题:
- 最长公共子序列问题:
- 最小生成树问题:
-
- 算法设计技术基于搜索
-
- 回溯法与分支限界法
-
- 基本思路:
- 同与异:
- 适用范围:
- 应用:
-
-
- 图着色问题:
- 核心思想
- 哈密顿回路问题
- TSP问题:
- 01背包问题
-
- 计算的限制
-
- 问题的复杂性
- 近似算法
- 概率算法
-
- 蒙特卡罗概率算法
- 拉斯维加斯型概率算法
引言
期末考试快到了,总结算法,以备后续学习。
看之前可以先关注目录,这样可以明确本文的结构,快速找到所需的知识
算法基础知识
算法的五个特点:(必考)
- 输入
- 输出
- 有穷
- 可行
- 确定
描述算法的常用方法:
- 自然语言
- 流程图
- 伪代码
- 语言的编程设计
时间复杂:
概念:
- O(n):用来描述增长率的上限
- Ω(n):用来描述增长率的下限
最优算法:大Ω符号通常与大O符号合作,以证明问题的具体算法是问题的最佳算法,或者是问题中算法类的最佳算法。一般来说,如果能证明问题的时间下限是Ω(g(n))任何解决问题的算法都被认为是解决问题的最佳算法
主定理:
习题:
基本算法设计技术
分治法和减治法
基本思路:
分治法:将难以直接解决的大问题分为一些小子问题,分别解决每个子问题,然后合并每个子问题。分为三个步骤:划分、解决问题和合并。
减治法:将难以直接解决的大问题分为几个子问题,但这些子问题不需要单独解决,只需要解决其中一个子问题,因此不需要合并子问题的解决方案。
同与异:
同样:都需要划分子问题,都需要解子问题;两者的编程理念都是递归
不同:减治法不需要合并子问题,只需要找出子问题与原问题之间的对应关系,或者直接解决原问题。
适用范围:
子问题的规模最好大致相同(子问题的平衡)
应用:
分治法:
- 归并排序
- 快速排序
减治法:
- 折半查找
- 二叉查找树
- 堆排序
没有最好的排序和搜索算法,只有最合适的。
归并排序
核心思想:
不断将序列一分为二(如果是奇数,则向上取整,如7/2=3.5向上取整则为4,所以左四个数,右三个数)直到只有一个数,然后开始合并排序 。
详细解读:
一是分,二是治,先不断分为二,再不断合二为一。
我们需要将两个有序的子序列合并成一个有序的序列,如上图中的最后一个合并,将[4、5、7、8]和[1、2、3、6]两个有序的子序列合并成最后一个序列[1、2、3、4、5、6、7、8]。
以下是两个应用,一个是偶序列,一个是奇序列
代码:
void merge_sort(int *data, int start, int end, int *result) {
if(1 == end - start)//如果间隔中只有两个元素,则对这两个元素进行排序 {
if(data[start] > data[end])
{
int temp = data[start];
data[start] = data[end];
data[end] = temp;
}
return;
}
else if(0 == end - start)//如果只有一个元素,则不用排序
return;
else
{
//继续划分子区间,分别对左右子区间进行排序
merge_sort(data,start,(end-start+1)/2+start,result);
merge_sort(data,(end-start+1)/2+start+1,end,result);
//开始归并已经排好序的start到end之间的数据
merge(data,start,end,result);
//把排序后的区间数据复制到原始数据中去
for(int i = start;i <= end;++i)
data[i] = result[i];
}
}
merge的过程为:
void merge(int *data,int start,int end,int *result)
{
int left_length = (end - start + 1) / 2 + 1;//左部分区间的数据元素的个数
int left_index = start;
int right_index = start + left_length;
int result_index = start;
while(left_index < start + left_length && right_index < end+1)
{
//对分别已经排好序的左区间和右区间进行合并
if(data[left_index] <= data[right_index])
result[result_index++] = data[left_index++];
else
result[result_index++] = data[right_index++];
}
while(left_index < start + left_length)
result[result_index++] = data[left_index++];
while(right_index < end+1)
result[result_index++] = data[right_index++];
}
现在对程序进行测试:
int main()
{
int data[] = {9,6,7,22,20,33,16,20};
const int length = 8;
int result[length];
cout << "Before sorted:" << endl;
for(int i = 0;i < length;++i)
cout << data[i] << " ";
cout << endl;
cout << "After sorted:" << endl;
merge_sort(data,0,length-1,result);
for(int i = 0;i < length;++i)
cout << data[i] << " ";
cout << endl;
return 0;
}
运行结果:
总结:
归并排序的性能不受输入数据的影响,始终都是 O(nlogn) 的时间复杂度。代价是需要额外的内存空间。
快速排序:
核心思想
快速排序算法的思想非常简单,在待排序的数列中,我们首先要找一个数字作为基准数(这只是个专用名词)。为了方便,我们一般选择第 1 个数字作为基准数(其实选择第几个并没有关系)。接下来我们需要把这个待排序的数列中小于基准数的元素移动到待排序的数列的左边,把大于基准数的元素移动到待排序的数列的右边。这时,左右两个分区的元素就相对有序了;接着把两个分区的元素分别按照上面两种方法继续对每个分区找出基准数,然后移动,直到各个分区只有一个数时为止。
因此可以总结为三点:
- 1.先从数列中取出一个数作为基准数。
- 2.分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
- 3.再对左右区间重复第二步,直到各区间只有一个数。
那么移动元素,该怎么移动呢?
快速排序的操作是这样的:首先从数列的最右边(也就是最后一个数字)开始往左边找,我们设这个下标为 j,也就是进行减减操作(j–),找到第 1 个比基准数小的值,让它与基准值交换;接着从左边开始往右边找,设这个下标为 i,然后执行加加操作(i++),找到第 1 个比基准数大的值,让它与基准值交换;然后继续寻找,直到 i 与 j 相遇时结束,最后基准值所在的位置即 k 的位置,也就是说 k 左边的值均比 k 上的值小,而 k 右边的值都比 k 上的值大。
详细解读:
代码:(Java版)
public class QuickSort {
private int[] array;
public QuickSort(int[] array) {
this.array = array;
}
public void sort() {
quickSort(array, 0, array.length - 1);
}
public void print() {
for (int i = 0; i < array.l ength; i++) {
System.out.println(array[i]);
}
}
/** * 递归排序 * @param src * @param begin * @param end */
private void quickSort(int[] src, int begin, int end) {
if (begin < end) {
int key = src[begin];
int i = begin;
int j = end;
while (i < j) {
while (i < j && src[j] > key) {
j--;
}
if (i < j) {
src[i] = src[j];
i++;
}
while (i < j && src[i] < key) {
i++;
}
if (i < j) {
src[j] = src[i];
j--;
}
}
src[i] = key;
quickSort(src, begin, i - 1);
quickSort(src, i + 1, end);
}
}
}
堆排序:
核心思想:
堆排序中的“堆”的含义是完全二叉树。算法的具体操作步骤分为4步:
- 先将无序序列画成一个完全二叉树的形式
- 再自底向上,自右向左对完全二叉树中的元素进行比较得到大根堆或小根堆
- 然后将完全二叉树的根节点与堆中最右下角的元素互换,互换之后根节点脱落,从此此根节点不再参与比较,而是等待之后的根节点相继脱落一起形成有序区。
- 之后一种重复2,3步便可得到有序区(有序序列)。
这里说一下什么是完全二叉树,同时也区分一下完全二叉树与满二叉树的区别
先看图:
完全二叉树:设二叉树的深度为h,除第 h 层外,其它各层 (1~h-1) 的结点数都达到最大个数,
第 h 层所有的结点都连续集中在最左边(例如如果在上图的完全二叉树的g结点接一个k结点,则这个数便不再是完全二叉树)
满二叉树:深度为k且有2^k-1个结点的二叉树称为满二叉树
详细解读:
代码:(C++)
#include
#include
using namespace std;
void max_heapify(int arr[], int start, int end) {
//建立父节点指标和子节点指标
int dad = start;
int son = dad * 2 + 1;
while (son <= end) { //若子节点指标在范围内才做比较
if (son + 1 <= end && arr[son] < arr[son + 1]) //先比较两个子节点大小,选择最大的
son++;
if (arr[dad] > arr[son]) //如果父节点大于子节点代表调整完毕,直接跳出函数
return;
else { //否则交换父子内容再继续子节点和孙节点比较
swap(arr[dad], arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
void heap_sort(int arr[], int len) {
//初始化,i从最后一个父节点开始调整
for (int i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
//先将第一个元素和已经排好的元素前一位做交换,再从新调整(刚调整的元素之前的元素),直到排序完毕
for (int i = len - 1; i > 0; i--) {
swap(arr[0], arr[i]);
max_heapify(arr, 0, i - 1);
}
}
int main() {
int arr[] = { 3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6 };
int len = (int) sizeof(arr) / sizeof(*arr);
heap_sort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
return 0;
}
总结:
堆是一种很好做调整的结构,在算法题里面使用频度很高。常用于想知道最大值或最小值的情况,比如优先级队列,作业调度等场景。堆排序的时间性能是O(logn)
折半查找:
核心思想:
在一个有序表中,每次取目标值与序列中间的值进行比较。若目标值等于值,则查找成功,返回此值的位置;若目标值大于此值,则说明目标值在该查找表的右半区,此时我们往右半区进行查找;若目标值小于此值,则说明目标值在该查找表的右左半区此时我们往左半区进行查找。如此往复,直至查找成功;若无和目标值相等的记录数,则查无此值。
详细解读:
代码:
#include
#include
//折半查找,又称为二分查找 ,条件保证要好排序的,不适合应用在频繁的插入操作,因为会打乱顺序
int Binary_Search(int *a,int n,int key)
{
int low,high,mid;
low = 0; //定义最低下标为记录首位
high = n; //记录最高下标为记录末位
while ( low <= high )
{
mid = (low + high) / 2;
if (key < a[mid]) {
high = mid - 1;//最高位下标调小 一位
}
else if(key > a[mid]){
low = mid + 1; //最低下标调整到中位下标大一位
}
else{
return mid; //代表就是次位置
}
}
return -1; //没有找到返回-1
}
void main()
{
int a[] = {
1,2,3,4,5,6,7,8,9,10};
//需求要查找8, 如果用传统的方式 要查找8次才能得出
int index;//下标
index = Binary_Search(a, sizeof(a) / sizeof(int),8);
if (index == -1)
printf("没有找到");
else
printf("找到了,index为:%d",index);
}
总结:
二分查找有个很重要的特点,就是不会查找数列的全部元素,而查找的数据量其实正好符合元素的对数,正常情况下每次查找的元素都在一半一半地减少。所以二分查找的[时间复杂度](http://data.biancheng.net/view/2.html)为 `O(log2n)` 是毫无疑问的。当然,最好的情况是只查找一次就能找到,但是在最坏和一般情况下的确要比顺序查找好了很多。
二叉查找树(二叉搜索树):
核心思想:
左右子树与根节点的关系(左子树比根节点都小,右子树比根节点都大)
详细解读:
在BST中搜索一个值是非常简单和高效的。
看上面的树,假设要搜索7这个节点。首先从Root节点出发,我们知道7大于3,所以会走到右子树6,然后因为7也大于6,所以会继续往右子树走,到了9,因为7小于9,所以会向左子树走,走到7,发现7等于7,所以找到要搜索的节点。
代码:
// 这里先定义出节点的结构
class Node
{
public int data;
public Node parent;
public Node left;
public Node right;
public Node(int _data)
{
this.data = _data;
}
}
// 定义二叉搜索树结构
class BST
{
private Node root;
// 这个函数是 private 的,递归调用,插入节点
private Node RecursionInsert(Node node, int data)
{
if (node == null)
{
return new Node(data);
}
if (data < node.data)
{
node.left = RecursionInsert(node.left, data);
node.left.parent = node;
}
else if (data > node.data)
{
node.right = RecursionInsert(node.right, data);
node.right.parent = node;
}
return node;
}
// 对外开放的 插入 接口
public void Insert(int data)
{
if (root == null)
{
root = RecursionInsert(root, data);
}
else
{
RecursionInsert(root, data);
}
}
// 按层序打印二叉树
public void LevelOrderTraversal()
{
Queue<Node> q = new Queue<Node>();
q.Enqueue(root);
while (q.Count > 0)
{
Node currNode = q.Dequeue();
if (currNode.left != null)
{
q.Enqueue(currNode.left);
}
if (currNode.right != null)
{
q.Enqueue(currNode.right);
}
// 括号里面是父节点的值
string msg = string.Format("{0}({1})", currNode.data, currNode.parent != null ? currNode.parent.data.ToString() : "null");
Debug.Log(msg);
}
}
}
// 创建一个二叉搜索树
class Program
{
/* Let us create following BST 50 / \ 30 70 / \ / \ 20 40 60 80 */
static void Main(string[] args)
{
BST bst = new BST();
bst.Insert(50);
bst.Insert(30);
bst.Insert(20);
bst.Insert(40);
bst.Insert(70);
bst.Insert(60);
bst.Insert(80);
bst.LevelOrderTraversal();
}
}
总结
二叉查找树查找成功的平均查找长度为: ASL = [ (n+1)/n] * log2 (n+1) - 1 [公式1] 其时间复杂度是: O (log2 (n))
插入排序:
核心思想:
打扑克+遍历,打过扑克的朋友对于理解这个排序应该是手到擒来的,我们在摸牌阶段要不断调整牌的顺序,每次摸一张牌查到原来序列当中,其操作流程就是与每一张牌比大小然后放在比它大的牌前面。
详细解读:
代码:
void insertion_sort(int arr[],int len){
for(int i=1;i=0) && (key
总结:
插入排序的时间复杂度为O(n^2)
动态规划法与贪心法
基本思路:
动态规划法:
-
划分子问题
将原问题分解为若干个子问题,并且子问题之间具有重叠关系(交集)
-
确定动态规划函数
根据子问题之间的重叠关系,找出满足子问题的递推关系式(即动态规划函数),此乃动态规划法之关键,找递归关系式往往需要自顶向下
-
填写表格
以自底向上的方式计算各个子问题的解并填表,实现动态规划过程
**贪心法:**其核心便是四个字“目光短浅”,即在解决问题时,只根据当前已有的信息做出最优选择,而不管将来有什么选择,因此其算法希望通过局部最优的选择达到全局最优的选择 。
同与异:
**同:**要求原问题必须有最优子结构。
**异:**贪心法的计算方式“自顶向下”,但并不等待子问题求解完毕后再选择使用哪一 个,而是通过一种策略直接选择一个子问题去求解,没被选择的子问题直接抛 弃。这种所谓“最优选择”的正确性需要用归纳法证明。而动态规划不管是采用 自底向上还是自顶向下的计算方式,都是从边界开始向上得到目标问题的解 (即考虑所有子问题)。
**贪心:**壮士断腕的决策,只要选择,绝不后悔。
**动态规划:**要看哪个选择笑到最后,暂时领先说明不了问题。
适用范围:
**动态规划:**多用来解决多阶段决策最优化(最值与优化)问题。
**贪心法:**求解最优化问题,而且对于许多问题都能都到整体最优解,即使不能得到整体最优解,通常也是最优解的很好近似。
应用:
多段图的最短路径问题:
首先它是个最短路径问题,其次其中的多段的意思,是将图在先不考虑边的情况下,将顶点们分成几个部分。
如上图所示将图分为了五个部分,也就是分成了5段,分成多个段的目的其实与动态规划解决问题的特性——多阶段是契合的。
核心思想:
我们可以把情况从特殊推广到一般情况,设 Cuv 为多段图有向边 的权值,源点 s 到终点 v 的最短路径长为 d(s,v),终点为 t,则可以得到该问题的状态转移方程为:
详细解读:
以上为理论解释,但是不够直观,接下来我用通俗易懂的方式解释一下:
先解释几个定义,以便我们叙述
d(1,6):从0到6的最短路径,换个角度看我们可以叫他:非直接路径
c(1,2):从1到2的路径,例如c(2,5)=9,同上我们可以叫他:直接路径
先说一个结论:直接路径是确定的,非直接路径是需要求最小值的。
我们用图中一个应用来解释,比如说我们现在要从1走到8,求最短路径d(1,8),那么应该怎么求呢?首先要考虑的一个问题是既然我们要走到8,那哪些点可以直接到8呢?站在8的位置一看,哦!5、6、7这三个点可以到8,因此确定的是c(5,8)=5、c(6,8)=8、c(7,8)=6,此时从1走到8有三种情况
- 1——5——8
- 1——6——8
- 1——7——8
因此只要我们知道d(1,5)、d(1,6)、d(1,7)便可以用**d(1,5)+c(5,8)**求出1——5——8,剩下两个以此类推,那么接下来的问题就是求d(1,5)了,这显然这跟我们正在求的d(1,8)本质是一样的。那接下来就是分别站在5,6,7的位置重复这一流程。
最后在分别求出d(1,5)+c(5,8)以及其他两个之后,比较三者取最值即可。
可以画一个树来表示整个过程:
代码:
#include
#include #include #define Max 0xffff using namespace std; //动态规划求最短路径 void dp_path(int c[][100], int *cost, int *path) { int m, n; cout << "输入顶点个数和边个数" << endl; cin >> n >> m; //初始化代价 矩阵 for (int i = 0; i < n; i++) for (int j = 0; j < n; j++) c[i][j] = Max; //输入代价矩阵 int u, v, s; for (int i = 0; i < m; i++) { cin >> u >> v >> s; //cout<=0; i--) { if (cost[j] > cost[i] + c[i][j]) { path[j] = i; cost[j] = cost[i] + c[i][j]; } } } cout<
=0){ cout<
总结:
此算法的时间复杂度主要由两部分组成:第一部分是依次计算从源点到各个顶点的最短路径长度,由两层嵌套的循环组成,外层循环执行 n-1 次,内层循环对所有入边进行计算,并且在所有循环中,每条入边只计算一次。若假定图的边数为 m,则时间性能是 O(m)。第二部分是输出最短路径经过的顶点,设多段图划分为 k 段,其时间性能是 O(k)。综上所述,时间复杂度为 O(m+k)。
许多导航和地图软件,只需要输入起始点和目的点,系统便会给出到达目的地的最短路线,这是多段图最短路径问题的典型应用。
01背包问题:
最长公共子序列问题:
公共就是你有我也有,子序列就是字符串中不一定连续但先后顺序一致的n个字符, 例如字符串abcbca,aca、abba就属于它的子序列;(这里要区别一下子串, 子串:指的是字符串中连续的n个字符 ,例如字符串qwerabc中,qwe、rab、wera都属于它的子串)那么连起来就是:你我都有的最长的不一定连续但先后顺序一致字符串。
例如: 序列1,3,5,4,2,6,8,7和序列1,4,8,6,7,5,它们的最长公共子序列有1,4,8,7和1,4,6,7 , 其长度是4, 并且通过这个应用我们可以发现,最长公共子序列不一定唯一。
核心思想:
首先我们想解决这个问题,下意识想到的就是无脑遍历啦,但是遍历的时间复杂度太高了,所以不推荐,采用动态规划做有几个点比较重要,那就是动态规划函数的理解
详细解读:
代码:
#include
#include
#include
#include
using namespace std;
int table[100][100] = { 0 };
int judge[100][100] = { 0 };
int print_LCS(int judge[][100], string x,int,int);
int main()
{
string a, b;
cin >> a >> b;
int m = a.length();
int n = b.length();
for (int i = 1; i <= m; i++)
{
for (int j = 1; j <= n; j++)
{
if (a[i] == b[j])
{
table[i][j] = table[i - 1][j - 1] + 1;
judge[i][j] = 1; //最后一个字符相等,则
}
else if(table[i - 1][j] >= table[i][j - 1]) //最后一个字符不相等,且上方大于左方
{
table[i][j] = table[i - 1][j];
judge[i][j] = 2;
}
else
//最后一个字符不相等,且上方小于左方
{
table[i][j] = table[i][j - 1];
judge[i][j] = 3;
}
}
}
cout << table[m][n] << endl;
print_LCS(judge, a, m, n);
cout << endl;
system("pause");
}
int print_LCS(int judge[][100],string a,int x,int y)//x,y,分别为两段长度
{
if (x == 0 || y == 0)
{
return 0;
}
if (judge[x][y] == 1)
{
print_LCS(judge, a, x-1, y-1);
cout << a[x-1];
}
if (judge[x][y] == 2)
{
print_LCS(judge, a, x - 1, y);
}
if (judge[x][y] == 3)
{
print_LCS(judge, a, x, y - 1);
}
}
总结:
此算法时间复杂度为O(n×m),其中n为第一个字符串的个数,m为第二个字符串的个数。
最长公共子序列是一个十分实用的问题,它可以描述两段文字之间的“相似度”,即它们的雷同程度,从而能够用来辨别抄袭。对一段文字进行修改之后,计算改动前后文字的最长公共子序列,将除此子序列外的部分提取出来,这种方法判断修改的部分,往往十分准确。简而言之,百度知道、百度百科都用得上。如判断S1和S2相似的办法是找出他们的公共子序列S3,S3以相同的顺序在S1和S2中出现,但是不必要连续。S3越长,S1和S3就越相似。
两个字符串A与B,如果他们最后一个字符是相同的,则此时的最长公共子序列是去掉它们相同的那个,用之前的最长公共子序列长度+1则是,此时的最长公共子序列的长度;如果他们最后一个字符不相同,则此时用A去掉最后一个字符和B求最长公共子序列,再用B去掉最后一个字符和A求最长公共子序列,将两个最长公共子序列作比较,哪个长取哪个。
最小生成树问题:
对于含有 n 个顶点的连通图来说可能包含有多种生成树,例如图所示 :
在给定一张无向图,如果在它的子图中,任意两个顶点都是互相连通,并且是一个树结构,那么这棵树叫做生成树。当连接顶点之间的图有权重时,权重之和最小的树结构为最小生成树!
在实际中,这种算法的应用非常广泛,比如我们需要在n个城市铺设电缆,则需要n-1条通信线路,那么我们如何铺设可以使得电缆最短呢?最小生成树就是为了解决这个问题而诞生的!
核心思想:
-
Prim算法(从点出发)
任选一个顶点,并以此建立生成树的根节点,每一步的贪心选择是把不再生成树中的最近顶点添加到生成树中。
-
Kruskal算法(从边出发)
将无向连通图去边,然后在边的权值中选择最小的,然后将这条边的两个顶点相连,再寻找权值最小的,然后将对应顶点相连,直到形成一个生成树(没有闭环)。
详细解读:
代码:****
Prim算法
#include
#define V 6 // 记录图中顶点的个数 typedef enum { false, true } bool; //查找权值最小的、尚未被选择的顶点,key 数组记录了各顶点之间的权值数据,visited数组记录着各个顶点是否已经被选择的信息 int min_Key(int key[], bool visited[]) { int min = 2147483647, min_index; //遍历 key 数组使用,min 记录最小的权值,min_index 记录最小权值关联的顶点 //遍历 key 数组 for (int v = 0元器件数据手册 、IC替代型号,打造电子元器件IC百科大全!