【期末知识点整理】算法设计与分析

文章目录

    • 第一部分——算法绪论
        • 算法是什么
        • 算法的目标
        • 算法的基本特征
        • 时间复杂度
        • 渐进记号
    • 第二部分——算法概述
        • 分治法
        • 蛮力法
        • 回溯法
        • 分支限界法
        • 贪心法
        • 动态规划法
    • 第三部分——算法比较
        • 动态规划VS贪心
        • 动态规划VS分治
        • 回溯VS分支限界
        • 分治VS递归
    • 第四部分——算法实例
        • 快速排序
        • 归并排序
        • 折半查找
        • 最大连续子序列和
        • 幂(子)集
        • 全排列
        • 子集树/排列树算法框架
        • 图的单源最短路径(BFS分别使用队列和优先队列)
        • 哈夫曼编码
        • 最短路径算法(Dijkstra)
        • 最短路径算法(Floyd)
        • 图的m着色问题
        • 流水作业调度问题(Johnson算法)
        • 最长公共子序列
        • 0-1背包问题
          • 蛮力法
          • 回溯法
          • 动态规划法
        • 旅行商问题
          • 蛮力法
          • 动态规划法
          • 回溯法
          • 分支限界法
          • 贪心法

第一部分——算法绪论

算法是什么

算法是求解问题的一系列计算步骤,用来将输入数据转换成输出结果。

算法的目标

正确性、可使用性、可读性、健壮性、高效率与低存储量要求。

算法的基本特征

有限性:一个算法必须总是(对任何合法的输入值)在执行有限步之后结束
确定性:算法中的每一条指令必须有确切的含义,不产生二义性
可行性:算法中的每一条运算都必须是足够基本的,原则上能够精确的执行
输入性:一个算法有零个或多个输入
输出性:一个算法有一个或多个输出

时间复杂度

算法的时间复杂度又分为最好时间复杂度,最坏时间复杂度、平均时间复杂度。其中默认为最坏时间复杂度。

渐进记号

渐近上界记号(O),渐近下界记号(Ω),渐进精确界符号(Θ)。

 

第二部分——算法概述

分治法

【概述】对于一个规模为n的问题,若该问题可以容易的解决(比如n的规模较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解决这些子问题,然后将各子问题的解合并得到原问题的解。

【步骤】将大问题分解为若干个子问题、求解子问题、合并子问题的结果得到最终解。

【理解】分而治之,逐个击破。

蛮力法

【概述】蛮力法是一种简单、暴力、直接解决问题的方法,通常直接基于问题的描述和所涉及的概念定义,依赖计算机的计算速度直接求解。

【步骤】搜索所有的解空间、搜索所有的路径、直接计算、模拟和仿真。

【理解】有的暴力枚举即可,有的直接模拟即可。

回溯法

【概述】回溯法实际上是一个类似穷举的搜索尝试过程,主要就是在搜索尝试过程中寻找问题的解,当发现已不满足解条件时,就回退,尝试别的路径。

【步骤】确定解空间树,确定结点的扩展搜索规则,以深度优先方式搜索解空间树,并在搜索过程中采用剪枝来避免无效搜索。

【理解】试探所有路径,不符合时则回退到刚才的状态。

分支限界法

【概述】分支限界法类似于回溯法,也是一种在解空间树上搜索解的算法。但在一般情况下,二者的求解目标不同。回溯法求解目标是找出解空间树中满足约束条件的所有解,分支限界法的求解目标是找出满足约束条件的一个解。

【步骤】将根结点加入或活结点队列;取出头结点,作为当前扩展结点;对于当前扩展结点,从左到右生成它的所有孩子结点,用约束条件检查,满足后加入活结点队列;重复上述步骤直至找到一个解或者活结点队列为空。

【理解】就是个带减枝的BFS。

贪心法

【概述】贪心法的基本思路是在对问题求解时总是做出在当前看来最好的选择,也就是说,通过局部最优达到整体最优。通常,这需要证明。

【步骤】步骤很简单,就是每一步都做出当前状态下最好的选择。

【理解】通过局部最优达到整体最优。

动态规划法

【概述】动态规划是一种解决多阶段决策问题的优化方法,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。能用动态规划法的问题一般具有3个性质:最优子结构,无后效性,有重叠子问题。

【步骤】分析最优解的性质,递归的定义最优解;以自底向上或自顶向下的记忆化方式计算出最优值;根据计算最优值时得到的信息,构造问题的最优解。

【理解】自底向上的构建过程。

 

第三部分——算法比较

动态规划VS贪心

贪心是一种特殊的动态规划,二者的最大差别在于是否记录子问题的结果。

我们不妨从两种算法的相同点说起。动态规划和贪心都是在做一系列的决策,并在结束时得到最优决策,换句话说,它们都是一个不断求解子问题的过程。

为什么说贪心是一种特殊的动态规划呢?这是因为,贪心时当前子问题的最优解一定包含上一个子问题的最优解,因此对前的子问题的最优解不做保留;动态规划时每一步的最优解一定包含之前子问题的最优解,但不一定是上一个,因此需要对之前所有子问题的最优解进行记录。

上面对于“是否对之前子问题最优解进行记录”的叙述有些晦涩,因此我举一个通俗的例子。我买了一杯18元奶茶,假如我手里有不限数量的面值为10元、5元、1元的零钱,那么我该如何付钱呢?使用贪心算法,10+5+1+1+1=18;但是,假如我手里有不限数量的面值为8元、5元、3元的零钱,是不是就无法直接贪心了呢?贪心时并没有进行子问题最优解的记录,因此当我们付了8+8后,并不知道刚刚付了8+8,问题就进入死胡同了,如果是动态规划,因为记录了之前子问题的最优解,所以可以回退并得到8+5,并继续进行。

动态规划因为需要记录子问题的结果,因此具有更高的复杂度。上面的问题其实是一个完全背包问题,贪心O(n),动态规划O(n²),正好印证了这个论述。

动态规划VS分治

动态规划和分治的共同点在于,它们都是将一个大问题分解为了若干个子问题,然后求解子问题。而它们的区别,在于如何由子问题结果构造出最终结果。

从表面上看,分治使用的是递归,是一个自顶向下的过程;动态规划使用的是迭代。是一个自底向上的过程。

如果想更深入的思考二者的区别,比较二者各自的优劣,可以接着看下面的论述——自顶向下的分治如何转化为自底向上的动态规划。它们之间有一个中间过程,即记忆化搜索;记忆化搜索是一个使用了记忆化数组、防止重复计算子问题的分治;动态规划是通过巧妙的填表顺序、避免了栈的使用的记忆化搜索。(看图戳这里)

分治法适合于子问题相互独立的情况,动态规划适用于子问题相互重叠的情况;在出现重复计算子问题的情况下,动态规划性能显著优于分治。

回溯VS分支限界

从解空间的搜索方式看,回溯法使用DFS,分支限界法使用BFS。

从存储结点的数据结构上看,回溯法使用栈,分支限界法使用队列或优先队列。

从结点存储特性上看,对于回溯法,活结点的所有子结点被遍历后才能出栈,对于分支限界法,每个结点只有一次成为活结点的机会。

从应用场景上看,回溯法通常用于找出满足条件的所有解,分支限界法通常用于找出满足条件的一个解或者特定意义的最优解。

分治VS递归

分治是一种算法,即将大问题分解为若干个子问题、求解子问题、合并子问题,通俗的说就是分而治之,逐个击破。

递归是一种代码的编写技巧,通俗的说递归就是自己调用自己,而且递归代码的执行与计算机的栈结构相契合。

二者是两个层面的概念,如果非要有联系,则可以说分治往往通过递归实现。

 

第四部分——算法实例

快速排序

从序列中选出一个基准(最左侧元素),走一躺之后,基准被换到了中间,基准左侧的元素都比基准要小,基准右侧的元素都比基准要大,然后递归排序左侧和右侧的序列。

时间复杂度O(nlog2n),空间复杂度O(log2n),且是不稳定的排序算法。

private void quickSort(int[] arr, int L, int R) {
	if (L >= R) {
		return;
	}
	int left = L;
	int right = R;
	// 基准
	int temp = arr[left];	
	while (left < right) {
		// 找到右侧第一个比基准小的元素
		while (left < right && arr[right] >= temp) {
			right--;
		}
		arr[left] = arr[right];
		// 找到左侧第一个比基准大的元素
		while (left < right && arr[left] <= temp) {
			left++;
		}
		arr[right] = arr[left];
	}
	arr[left] = temp;
	// 最后left和right会收缩到基准点的正确位置处
	quickSort(arr, L, left - 1);
	quickSort(arr, left + 1, R);
}

// tip:arr[a]=arr[b],则arr[b]处冗余,等待被覆盖
归并排序

使用分治的思想,先排左子序列,再排右子序列,然后二路归并;对于左子序列/右子序列,分别递归。

时间复杂度O(nlog2n),空间复杂度O(n),且是稳定的排序算法。

private void mergeSort(int[] arr, int left, int right) {
	if (left >= right) {
		return;
	}
	int mid = left + (right - left) / 2;	// 二分为左右子序列
	mergeSort(arr, left, mid);				// 左子序列排序
	mergeSort(arr, mid + 1, right);			// 右子序列排序
	merge(arr, left, mid, right);			// 二路归并
}

private void merge(int[] arr, int left, int mid, int right) {
	int[] temp = new int[arr.length];		// 辅助数组,可提升作用域防止重复开辟
	int i = left;
	int j = mid + 1;
	int t = left;
	while (i <= mid && j <= right) {
		if (arr[i] < arr[j]) {
			temp[t++] = arr[i++];
		} else {
			temp[t++] = arr[j++];
		}
	}
	while (i <= mid) {
		temp[t++] = arr[i++];
	}
	while (j <= right) {
		temp[t++] = arr[j++];
	}
	while (left <= right) {
		arr[left] = temp[left];
		left++;
	}
}

// tip:二路归并merge方法需要三个指针参数;注意mid指向的位置时偏左的
折半查找

不用多说,时间复杂度O(log2n)。

// 递归写法,虽说一般不用这种写法...
public int binarySearch(int[] nums, int target, int left, int right) {
	int mid = left + (right - left) / 2;
	if (nums[mid] == target) {
		return mid;
	} else if (nums[mid] < target) {
		return binarySearch(nums, target, mid + 1, right);
	} else {
		return binarySearch(nums, target, left, mid - 1);
	}
	return -1;
}
最大连续子序列和

动态规划的滚动数组降维。

遍历到n时,如果以前一个数结尾的序列和序列和已经小于0,那么它不会对序列和做出正向的贡献,因此我们重置为此时的n作为新的尝试的开始。

public int maxSubArray(int[] nums) { 	
	int maxSum = nums[0];
	int sum = 0;
	for(int n : nums) {		
		if(sum > 0) {
    		sum += n;		
    	} else {
    		sum = n;		
    	}
    	maxSum = Math.max(maxSum, sum);
    }
    return maxSum;
}

// 上面的解法时间复杂度为O(n),课件中还给出了O(n2)和O(n3)的解法
幂(子)集

蛮力增量法。对于给定的n,每一个集合元素都要处理,因此时间复杂度为O(2^n)。

最初:{}
添加1: {1}
添加2: {2}, {1,2}
添加3: {3}, {1,3}, {2,3}, {1,2,3}
全排列

蛮力增量法。对于给定的n,每一种全排列都要处理,因此时间复杂度为O(n*n!)。

  		  1
	   /	  \
	12			21
  /	 |	\	 /	 |	\
123	132	123	213	231	321
子集树/排列树算法框架

子集树:从n个元素的集合中找出满足某种性质的子集,相应的解空间称为子集树

排列树:从n个元素的排列中找出满足某种性质的排列,相应的解空间称为排列树

int x[n];										// x存放解向量,这是一个全局变量
void traceBack(int i) {
	if (i > n) {								// 每搜索到一个叶子结点,输出一个可行解
		输出结果;
	} else {
		for (j = 1; j <= k; k++) {				// 枚举所有可能的路径
			x[i] = k;							// 产生一个可能的解分量
			其他操作;				
			if (constraint() && bound()) {		// 如果满足约束条件和限界条件
				traceBack(i + 1);				// 则继续下一层
			}
		}
	}
}
int x[n];
void traceBack(int i) {
	if (i > n) {								// 每搜索到一个叶子结点,输出一个可行解
		输出结果;
	} else {
		for (int j = i; j <= n; j++) {
			swap(x[i], x[j]);
			if (constraint() && bound()) {		// 如果满足约束条件和限界条件
				traceBack(i + 1);				// 则继续下一层
			}
			swap(x[i], x[j]);					// 回溯
		}
	}
}
图的单源最短路径(BFS分别使用队列和优先队列)

使用队列和优先队列的区别在于,前者按先入先出的顺序选取下一个结点点作为扩展结点,后者按长度从小到大的顺序选取下一个结点点作为扩展结点。

下面给出一个例子,自己画一画BFS树:

(0, 2, 10), (0, 4, 30), (0, 5, 100), (1, 2, 4), (2, 3, 50), (4, 3, 20), (4, 5, 60), (3, 5, 10)。

哈夫曼编码

选出权值最小的两个结点,构造成一个新的结点,且新结点的权值看作两个结点的权值之和;不断重复这个过程。

哈夫曼树有一个优秀的性质,即权值越小的叶子越靠下,权值越大的叶子越靠上。那么,如果我们把叶子结点权值设置为字符出现的频度,比给边标记上0/1,将从根结点到叶子经过的边的所有标记序列作为编码的话——频度越高的字符编码越短,频度越低的字符编码越长,并且不会出现一个编码是另一个编码的前缀的情况!

最短路径算法(Dijkstra)

用一个dist[]数组维护所有点到源点的最短距离,每次选出距源点最近的顶点k,然后修改与k相关的dist[]的值;这个过程进行 n-1 次,从而让所有的顶点都用于维护dist[]数组一次。

每一次都固定一个到k的距离dist[k],之后就不再修改它,但是负权边的出现会破坏贪心的正确性。所以,Dijkstra算法不适合于出现负权边的场景。

private void Dijkstra(int[][] graph, int source) {
	int len = graph.length;
	// dist[i]的含义是源点到i的最短距离,对其初始化
	int[] dist = new int[len];
	for (int j = 0; j < len; j++) {
		dist[j] = graph[source][j];
	}

    // 记录是否被访问过的数组
    boolean[] visited = new boolean[len];
    visited[source] = true;

    // 执行n-1次,就贪心过了所有的顶点
    for (int i = 0; i < len - 1; i++) {
        // 1.寻找距源点最近的顶点k
        int minDist = Integer.MAX_VALUE;
        int k = -1;
        for (int j = 0; j < len; j++) {
            if (!visited[j] && graph[source][j] < minDist) {
                minDist = graph[source][j];
                k = j;
            }
        }
        // 2.固定k
        dist[k] = minDist;
        visited[k] = true;
        // 3.修改与k相关的距离
        for (int j = 0; j < len; j++) {
            if (!visited[j] && dist[k] + graph[k][j] < dist[j]) {
                dist[j] =  dist[k] + graph[k][j];
            }
        }
    }
}
最短路径算法(Floyd)

核心很简单,就是尝试用k作为中转更新从i到j的最短路径。

与其他最短路径算法相比,Floyd算法最大的特点是它维护的多源的最短路径,也因此Floyd算法的时间复杂度为O(n³),Dijkstra算法的时间复杂度是O(n²)——它相当于在每个顶点用一次Dijkstra。

Floyd算法允许图中出现负权边,但不允许负权边成环。

private void Floyd(int[][] graph) {
	int len = graph.length;
	// 一定注意,k一定写在第一层,目的是防止graph[i][j]过早固定!!!
	for (int k = 0; k < len; k++) {
		for (int i = 0; i < len; i++) {
			for (int j = 0; j < len; j++) {
				if (graph[i][k] + graph[k][j] < graph[i][j]) {
					graph[i][j] = graph[i][k] + graph[k][j];
				}
			}
		}
	}
}
图的m着色问题

对于每一个顶点,尝试1~m种着色,对应的解空间的形状是一棵m叉树,时间复杂度为O(m的n次方)。

// 判断顶点i是否存在同色的邻居
boolean hasSame(int i) {
	for (int j = 1; j <= n; j++) {
		if (graph[i][j] == 1 && draw[i] == draw[j]) {
			return true;
		}
	}
	return false;
}

// 回溯DFS
void dfs(int i) {
	if (i > n) {
		count++;
	} else {
		for (int j = 1; j <= m; j++) {
			draw[i] = j;
			if (!hasSame(i)) {
				dfs(i + 1);
			}
			draw[i] = 0;
		}
	}
}
流水作业调度问题(Johnson算法)

第一步:把所有作业分为两组,a[i]<=b[i]的放到第一组,a[i]>b[i]的放到第二组。

第二步:将第一组的作业按a[i]递增排序,将第二组的作业按b[i]递减排序。

第三步:按顺序先执行第一组的作业,接着执行第二组的作业,得到的就是耗时最少的调度方案。

评价:如果使用蛮力法/回溯法,那么这就是一个全排列问题,时间复杂度为O(n!);而如果使用Johnson算法,开销主要在于排序,时间复杂度为O(nlog2n)。

可以用下面的题目练习一下:

作业1 作业2 作业3 作业4
机器M1 5 12 4 8
机器M2 6 2 14 7

答案是顺序执行3142,时间为33。

最长公共子序列

显然时间复杂度为O(mn)。

下面给出一个例子,自己画一下dp数组:s1=“acbbabdbb”,s2=“abcbdb”。

class Solution {
    public int longestCommonSubsequence(String text1, String text2) {
        int m = text1.length();
        int n = text2.length();
        char[] arr1 = text1.toCharArray();
        char[] arr2 = text2.toCharArray();

        int[][] dp = new int[m + 1][n + 1];
        for (int i = 1; i <= m; i++) {
            for (int j = 1; j <= n; j++) {
                if (arr1[i - 1] == arr2[j - 1]) {
                    dp[i][j] = dp[i - 1][j - 1] + 1;
                } else {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
                }
            }
        }
        return dp[m][n];
    }
}
0-1背包问题
蛮力法

思路:使用求幂集的方法求出所有物品组合方式,对于每一种组合,计算总重量sumw和总价值sumv,在sumw小于等于限重W的前提下,用sumv尝试更新所维护的最大价值maxv。

评价:需要求出整个幂集,所以时间复杂度为O(2^n)。

回溯法

思路:对于当前的物品i,有选与不选两种情况,分别用0/1表示,便得到了一棵解空间树,然后DFS搜索该解空间树。设dfs(i, sumw, sumv, choose[]),其中i表示当前为第i个物品,sumw表示当前总重量,sumv表示当前总价值,choose[i]=true表示选,choose[i]=false表示不选。如果选,则下一层为dfs(i+1, sumw+w[i], sumv+v[i], choose[]),如果不选,则下一层为dfs(i+1, sumw, sumv, choose[])。

评价:虽然我们可以通过减枝优化来缩小解空间(如果选择了当前物品且重量已经超过了W,则没有必要继续扩展该路径;如果没选当前物品且即使选了后面的所有物品重量依旧无法达到W,则没有必要继续扩展该路径),但本质还是求出整个幂集,时间复杂度依旧是O(2^n)。

动态规划法

思路:dp[i][j]表示将前i件物品装进限重为j的背包可以获得的最大价值,明确了含义,其实很容易写出状态转移方程dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]]+v[i])。

评价:动态规划法相比于回溯法,使用了dp数组记录了子问题的解,从而避免了重复子问题的计算,算法效率大大提高。时间复杂度为O(nW),其中n为物品的数量,W为背包的限重。其实,我们还可以通过滚动数组进行降维优化,但这只是在不影响其他状态转移的前提下进行状态值的覆盖,从而复用数组空间,时间复杂度依旧是O(nW)。

//【蛮力法】
// 获取幂集
Set<List<Integer>> set = new HashSet<>();
set.add(new ArrayList<>());
for (int i = 1; i <= n; i++) {
	for (List<Integer> list : set) {
		list.add(i);
		set.add(list);
	}
}
// 处理每一种组合
int maxv = 0;
for (List<Integer> list : set) {
	int sumw = 0;
	int sumv = 0;
	for (int i : list) {
		sumw += w[i];
		sumv += v[i];
	}	
	if (sumw <= W && sumv > maxv) {
		maxv = sumv;
	}
}

//【回溯法】
void traceBack(int i, int sumw, int sumv, boolean[] choose) {
	if (i > n) {
		if (sumw <= W && sumv > maxv) {
			maxv = sumv;
		} 
	} else {
		choose[i] = true;
		traceBack(i + 1, sumw + w[i], sumv + v[i], choose);
		choose[i] = false;
		traceBack(i + 1, sumw, sumv, choose);
	}
}

//【动态规划】
// 0-1背包
dp[i][j] = max(dp[i−1][j], dp[i−1][j−w[i]]+v[i])
// 完全背包
dp[i][j] = max(dp[i−1][j], dp[i][j−w[i]]+v[i])
// 还可以通过滚动数组进行降维优化,这里不再赘述
旅行商问题
蛮力法

思路:除了旅行商的起点/终点,求其余所有顶点的全排列,并计算每一种路径的长度和。

评价:求全排列的时间复杂度是O(nn!),因此蛮力法的时间复杂度就是O(nn!)。

动态规划法

思路:f(V,i)的含义是,从起点出发经过V集合中的所有顶点后,到达顶点i的最短路径长度。那么不难得到状态转移方程:当V为空集,即可以直接到达,所以f(V,i)=edges[0][i];当V不为空集时,f(V,i)=min{f(V-{j},j)+edges[j][i]}。

评价:对于n个顶点,都要进行子集操作,因此时间复杂度是O(2^n)。

回溯法

思路:以起点为根结点,开始进行DFS,并在DFS过程中记录当前路径长度并维护最短路径长度;如果当前路径长度已经大于等于维护的最短路径长度,进行剪枝优化。

评价:对于n个顶点,都要进行子集操作,因此时间复杂度是O(2^n)。剪枝可以提升性能,但不能改变渐进时间。

分支限界法

思路:以起点为根结点,开始进行BFS,并在BFS过程中记录当前路径长度并维护最短路径长度;如果当前路径长度已经大于等于维护的最短路径长度,进行剪枝优化。

评价:对于n个顶点,都要进行子集操作,因此时间复杂度是O(2^n)。剪枝可以提升性能,但不能改变渐进时间。

贪心法

思路:每次选出剩余顶点中距离最近的顶点,访问了所有顶点后,最后返回起点。

评价:整个过程一目了然,时间复杂度为O(n²)。但是,局部最优不等于全局最优,旅行商问题中使用贪心法并不能保证最终结果的正确性。

你可能感兴趣的:(UtilityRoom,算法,期末,复习)