算法的概念
算法(Algorithm)是指解题方案的准确而完整的描述,是一系列解决问题的清晰指令,算法代表着用系统的方法描述解决问题的策略机制。
- 描述:算法是一种解决特定问题的思路
- 常见算法:
递归
概念
递归,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。也就是说,递归算法是一种直接或者间接调用自身函数或者方法的算法。
递归三要素
- 递归结束条件:既然是循环就必须要有结束,不结束就会OOM了
- 函数的功能:这个函数要干什么,打印,计算…
- 函数的等价关系式:递归公式,一般是每次执行之间,或者与个数之间的逻辑关系
Demo案例
/**
* @Description: 打印5次”Hello World“
* @title: PrintHelloWorld
* @Author szh
* @Date: 2022/1/7 22:35
* @Version 1.0
*/
public class PrintHelloWorld {
//递归实现
public static void print(String ss, int n) {
//递归条件
if(n>0){
//函数的功能
System.out.println(ss);
//函数的等价关系式
print(ss,n-1);
}
}
public static void main(String[] args) {
//调用递归
print("Hello World", 5);
}
}
/**
* @Description: 斐波那契数列:0、1、1、2、3、5、8、13、21、34、55.....
* @title: FibonacciSequenceTest
* @Author szh
* @Date: 2022/1/7 22:38
* @Version 1.0
*/
public class FibonacciSequenceTest {
//递归实现 -> 函数的等价关系式:fun(n)=fun(n-1)+fun(n-2)
public static int fun2(int n) {
if (n <= 1) return n;
return fun2(n - 1) + fun2(n - 2);
}
public static void main(String[] args) {
System.out.println(fun2(9));
}
}
规律:从第3个数开始,每个数等于前面两个数的和递归分析:函数的功能:返回n的前两个数的和递归结束条件:从第三个数开始,n<=2函数的等价关系式:fun(n)=fun(n-1)+fun(n-2)
分治算法
概念
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
关于分治和递归的区别
分治算法是一种处理问题的思想,递归是一种编程技巧
分治算法的递归实现中,每一层递归都会涉及这样三个操作:
- 分解:将原问题分解成一系列子问题
- 解决:递归地求解各个子问题,若子问题足够小,则直接求解
- 合并:将子问题的结果合并成原问题
Demo案例:
将字符串中的小写字母转化为大写字母“abcde”转化为"ABCDE"
利用分治的思想将整个字符串转化成一个一个的字符处理
/**
* @Description: 字符串小写字母转大写字母---分治算法
* @title: CharUpCase
* @Author szh
* @Date: 2022/1/7 22:53
* @Version 1.0
*/
public class CharUpCase {
public static char[] toUpCase(char[] arr,int i){
//递归结束条件
if(i>=arr.length){
return arr;
}
arr[i]=toUpCaseUnit(arr[i]);
return toUpCase(arr,i+1);
}
/**
* 单元方法 小写转大写
* @param a
* @return
*/
private static char toUpCaseUnit(char a){
//不是字母
if((int)a<97||(int)a>122){
return ' ';
}
return (char)Integer.parseInt(String.valueOf((int)a-32));
}
public static void main(String[] args) {
char[] arr=toUpCase("abcdde".toCharArray(),0);
System.out.println(arr);
}
}
/**
* @Description: 求x的n次方问题
* @title: Pow
* @Author szh
* @Date: 2022/1/7 23:03
* @Version 1.0
*/
public class Pow {
public static int dividpow(int x,int n){
//递归结束 任何数的1次方都是它本身
if(n==1){
return x;
}
//每次分拆成幂的一半
int half=dividpow(x,n/2);
//偶数
if(n%2==0){
return half*half;
} else {
return half*half*x;
}
}
public static void main(String[] args) {
System.out.println(dividpow(2,10));
}
}
时间复杂度
根据拆分情况可以是O(n)或O(logn)
优缺点
优势:将复杂的问题拆分成简单的子问题,解决更容易,另外根据拆分规则,性能有可能提高。
劣势:子问题必须要一样,用相同的方式解决
适用场景
分治算法能解决的问题,一般需要满足下面这几个条件:
- 原问题与分解成的小问题具有相同的模式;
- 原问题分解成的子问题可以独立求解,子问题之间没有相关性,这一点是分治算法跟动态规划的明显区别具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
- 可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
贪婪算法
概念
贪婪算法(Greedy)的定义:是一种在每一步选中都采取在当前状态下最好或最优的选择,从而希望导致结果是全局最好或最优的算法。
贪婪算法:当下做局部最优判断,不能回退(能回退的是回溯,最优+回退是动态规划)
由于贪心算法的高效性以及所求得答案比较接近最优结果,贪心算法可以作为辅助算法或解决一些要求结果不特别精确的问题。
注意:当下是最优的,并不一定全局是最优的。
举例
有硬币分值为10、9、4若干枚,问如果组成分值18,最少需要多少枚硬币?
采用贪心算法,选择当下硬币分值最大的:10 ->18-10=8 -> 8/4=2 即:1个10、2个4,共需要3枚硬币.
实际上,选择分值为9的硬币,2枚就够了18/9=2
如果改成:
有硬币分值为10、5、1若干枚,问如果组成分值16,最少需要多少枚硬币?
采用贪心算法,选择当下硬币分值最大的:10 -> 16-10=6 -> 6-5=1即:1个10,1个5,1个1 ,共需要3枚硬币即为最优解
由此可以看出贪心算法适合于一些特殊的情况,如果能用一定是最优解
Demo案例:部分背包
背包问题是算法的经典问题,分为部分背包和0-1背包,主要区别如下:
部分背包:某件物品是一堆,可以带走其一部分
0-1背包:对于某件物品,要么被带走(选择了它),要么不被带走(没有选择它),不存在只带走一部分的情况。
部分背包问题可以用贪心算法求解,且能够得到最优解。
假设一共有N件物品,第 i 件物品的价值为 Vi ,重量为Wi,一个小偷有一个最多只能装下重量为W的背包,他希望带走的物品越有价值越好,可以带走某件物品的一部分,请问:他应该选择哪些物品?
假设背包可容纳50Kg的重量,物品信息如下表:
贪心算法的关键是贪心策略的选择
将物品按单位重量 所具有的价值排序。总是优先选择单位重量下价值最大的物品按照我们的贪心策略,单位重量的价值排序: 物品A > 物品B > 物品C因此,我们尽可能地多拿物品A,直到将物品1拿完之后,才去拿物品B,然后是物品C 可以只拿一部分…
/**
* @Description: 物品实体类信息
* @title: Goods
* @Author szh
* @Date: 2022/1/8 19:41
* @Version 1.0
*/
public class Goods {
String name; //名称
double weight; //重量
double price; //价格
double value; //价值
public Goods(String name, double weight, double price) {
this.name = name;
this.weight = weight;
this.price = price;
this.value=price/weight;
}
}
/**
* @Description: 部分背包——贪心算法
* @title: Bag1
* @Author szh
* @Date: 2022/1/8 19:41
* @Version 1.0
*/
public class Bag1 {
//最大承重
double max=0;
public void getMaxValue(Goods[] glist){
Goods[] glist2=sort(glist); //价值排序
//当前总重
double sum_w=0;
for(int i=0;i<glist2.length;i++){
sum_w+=glist2[i].weight;
if(sum_w<=max){
System.out.println(glist2[i].name+"取"+glist2[i].weight+"kg");
}
else{
System.out.println(glist2[i].name+"取"+(max-(sum_w-glist2[i].weight))+"kg");
return ;
}
}
}
//按价值排序
private Goods[] sort(Goods[] goods){
return goods;
}
public static void main(String[] args) {
Bag1 bag1=new Bag1();
Goods g1=new Goods("A",10,60); //6
Goods g2=new Goods("B",20,100); //5
Goods g3=new Goods("C",30,120); //4
Goods[] goods={g1,g2,g3}; // 价值倒序
bag1.max=50;
bag1.getMaxValue(goods);
}
}
运行结果:
A取10.0kg
B取20.0kg
C取20.0kg
时间复杂度
在不考虑排序的前提下,贪心算法只需要一次循环,所以时间复杂度是O(n)
优缺点
优点:性能高,能用贪心算法解决的往往是最优解
缺点:在实际情况下能用的不多,用贪心算法解的往往不是最好的
回溯算法
回溯算法实际上一个类似枚举的深度优先搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回(也就是递归返回),尝试别的路径。
回溯的处理思想,有点类似枚举(列出所有的情况)搜索。我们枚举所有的解,找到满足期望的解。为了有规律地枚举所有可能的解,避免遗漏和重复,我们把问题求解的过程分为多个阶段。每个阶段,我们都会面对一个岔路口,我们先随意选一条路走,当发现这条路走不通的时候(不符合期望的解),就回退到上一个岔路口,另选一种走法继续走。
Demo案例(N皇后问题分析):
n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
这个问题划分成 8 个阶段,依次将 8 个棋子放到第一行、第二行、第三行……第八行。在放置的过程中,我们不停地检查当前放法,是否满足要求。如果满足,则跳到下一行继续放置棋子;如果不满足,那就再换一种放法,继续尝试。
/**
* @Description: N皇后问题---回溯算法
* @title: NQueens
* @Author szh
* @Date: 2022/1/8 16:55
* @Version 1.0
*/
public class NQueens {
static final int QUEENS = 8;//皇后数
// 下标是行 值是列
int[] result = new int[QUEENS]; // 存储放好的皇后
static int sum=0;
/**
* 在指定行放皇后
* @param row
*/
public void setQueens(int row) {
//放置完成
if (row == QUEENS) {
print();
return;
}
//循环列
for (int col = 0; col < QUEENS; col++) {
if (isOK(row, col)) {
//存入数组
result[row] = col;
//开始下一行
setQueens(row + 1);
}
}
}
/**
* 判断是否可以放置
* @param row
* @param col
* @return
*/
private boolean isOK(int row, int col) {
int leftup = col - 1;//左对角线
int rightup = col + 1;//右对角线
for (int i = row - 1; i >= 0; i--) {
//等于列 原列存在
if (result[i] == col) {
return false;
}
//左对角线
if (leftup >= 0) {
if (result[i] == leftup) return false;
}
if (rightup < QUEENS) {
if (result[i] == rightup) return false;
}
leftup--;
rightup++;
sum++;
}
return true;
}
/**
* 打印结果
*/
private void print() {
for (int i = 0; i < result.length; i++) {
for (int j = 0; j < result.length; j++) {
if (result[i] == j) {
System.out.print("Q|");
} else {
System.out.print("*| ");
}
}
System.out.println();
}
System.out.println("-------------------");
}
public static void main(String[] args) {
NQueens queens=new NQueens();
queens.setQueens(0);
System.out.println(sum);
System.out.println(8*7*6*5*4*3*2*1);
}
}
时间复杂度
N皇后问题的时间复杂度为:n! 实际为:n!/2
优缺点
优点:回溯算法的思想非常简单,大部分情况下,都是用来解决广义的搜索问题,也就是,从一组可能的解中,选择出一个满足要求的解。回溯算法非常适合用递归来实现,在实现的过程中,剪枝操作是提高回溯效率的一种技巧。利用剪枝,我们并不需要穷举搜索所有的情况,从而提高搜索效率。
劣势:
效率相对于低(动态规划)
动态规划
概念
动态规划(Dynamic Programming),是一种分阶段求解的方法。
动态规划算法是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。
首先是拆分问题,我的理解就是根据问题的可能性把问题划分成一步一步这样就可以通过递推或者递归来实现. 关键就是这个步骤,动态规划有一类问题就是从后往前推到,有时候我们很容易知道:如果只有一种情况时,最佳的选择应该怎么做.然后根据这个最佳选择往前一步推导,得到前一步的最佳选择 然后就是定义问题状态和状态之间的关系,我的理解是前面拆分的步骤之间的关系,用一种量化的形式表现出来,类似于高中学的推导公式,因为这种式子很容易用程序写出来,也可以说对程序比较亲和(也就是最后所说的状态转移方程式) 我们再来看定义的下面的两段,我的理解是比如我们找到最优解,我们应该讲最优解保存下来,为了往前推导时能够使用前一步的最优解,在这个过程中难免有一些相比于最优解差的解,此时我们应该放弃,只保存最优解,这样我们每一次都把最优解保存了下来,大大降低了时间复杂度。
动态规划中有三个重要概念:
- 最优子结构
- 边界
- 状态转移公式(递推方程)dp方程
Demo案例:
再谈斐波那契数列(优化递归):
通过上边的递归树可以看出在树的每层和上层都有大量的重复计算,可以把计算结果存起来,下次再用的时候就不用再计算了,这种方式叫记忆搜索,也叫做备忘录模式:
/**
* @Description: 斐波那契数列:递归分治+备忘录(动态规划)
* @title: Fib2
* @Author szh
* @Date: 2022/1/8 19:12
* @Version 1.0
*/
public class Fib2 {
//用于存储每次计算的结果 -> 空间换时间
static long[] sub=new long[1000000];
public static long fib(int n){
if(n<=1) return n;
//该数字已被计算
if(sub[n]!=0){
return sub[n];
}
else{
sub[n]=fib(n-1)+fib(n-2);
}
return sub[n];
}
public static void main(String[] args) {
// fib(64)使用普通递归64计算不了,
System.out.println(fib(64));
}
}
dp方程:
最优子结构: fib[9]=finb[8]+fib[7]
边界:a[0]=0; a[1]=1;
dp方程:fib[n]=fib[n-1]+fib[n-2]
/**
* @Description: 斐波那契数列——DP方程(动态规划)
* @title: Fib3
* @Author szh
* @Date: 2022/1/8 19:15
* @Version 1.0
*/
public class Fib3 {
public static long fib(int n){
long a[]=new long[n+1];
//初始值
a[0]=0;
a[1]=1;
int i=0;
for(i=2;i<=n;i++){
//dp方程 dp(n)=dp(n-1)+dp(n-2)
a[i]=a[i-1]+a[i-2];
}
return a[i-1];
}
public static void main(String[] args) {
System.out.println(fib(64));
}
}
使用动态规划四个步骤
- 把当前的复杂问题转化成一个个简单的子问题(分治)
- 寻找子问题的最优解法(最优子结构)
- 把子问题的解合并,存储中间状态
- 递归+记忆搜索或自底而上的形成递推方程(dp方程)
时间复杂度
新的斐波那契数列实现时间复杂度为O(n)
优缺点
优点:时间复杂度和空间复杂度都相当较低
缺点:难,有些场景不适用
适用场景
尽管动态规划比回溯算法高效,但是,并不是所有问题,都可以用动态规划来解决。能用动态规划解决的问题,需要满足三个特征,最优子结构、无后效性和重复子问题。在重复子问题这一点上,动态规划和分治算法的区分非常明显。分治算法要求分割成的子问题,不能有重复子问题,而动态规划正好相反,动态规划之所以高效,就是因为回溯算法实现中存在大量的重复子问题。
(经典动态规划问题)0-1背包问题
有n件物品和一个最大承重为W的背包,每件物品的重量是w[i],价值是v[i]在保证总重量不超过W的前提下,选择某些物品装入背包,背包的最大总价值是多少?
(注意:每个物品只有一件,也就是每个物品只能选择0件或者1件)
分析:
假设:W=10,有5件物品,重量和价值如下:
w[1]=2,v[1]=6
w[2]=2,v[2]=3
w[3]=6,v[3]=5
w[4]=5,v[4]=4
w[5]=4,v[5]=6
dp数组的计算结果如下表: (i:选择i件物品 j:最大承重)
解法:
/**
* @Description: 0-1背包,计算最大价值 DP方程
* @title: Bag2
* @Author szh
* @Date: 2022/1/8 20:50
* @Version 1.0
*/
public class Bag2 {
/**
* 计算最大价值
* @param values 物品的价值数组
* @param weights 物品的重量数组
* @param max 背包最大承重
* @return
*/
public static int maxValue(int[] values,int[] weights,int max){
if(values == null|| values.length == 0) return 0;
if(weights == null|| weights.length == 0) return 0;
if(values.length != weights.length|| max <= 0) return 0;
//dp数组 dp[i-1] i从1开始
int[][] dp = new int[values.length+1][max+1];
for(int i = 1;i <= values.length;i++){
for(int j=1;j<=max;j++){
if(weights[i-1] > j){ //选择的物品超过最大承重
dp[i][j] = dp[i-1][j]; //不能选该物品 等于上轮的最大价值
} else{ //选择的物品不超过最大承重
int proValue = dp[i-1][j]; //上轮的最大价值
int curValue = values[i-1] + dp[i-1][j-weights[i-1]];//选择该商品后的最大价值
dp[i][j] = Math.max(proValue,curValue); //两者取最大值
}
}
}
return dp[values.length][max];
}
public static void main(String[] args) {
int[] values = {6,3,5,4,600};
int[] weights = {2,2,6,5,10};
int max = 10;
System.out.println(maxValue(values,weights,max));
}
}
时间复杂度为:o(i*j)
可以看出动态规划是计算的值是上次的某个值+这次的值,是一种用空间换时间的算法。
链表红黑树
问题一:
给定一个链表,判断链表中是否有环。存在环返回 true ,否则返回 false?
分析:
该题可以理解为检测链表的某节点能否二次到达(重复访问)的问题。
需要一个容器记录已经访问过的节点 每次访问到新的节点,都与容器中的记录进行匹配,若相同则存在环 若匹配之后没有相同节点,则存入容器,继续访问新的节点 直到访问节点的next指针返回null,或者当前节点与容器的某个记录相同,操作结束。
解法:
1.定义快慢两个指针:slow=head; fast=head.next;
2.遍历链表:
- 快指针步长为2:fast=fast.next.next;
- 慢指针步长为1:slow=slow.next;
3.当且仅当快慢指针重合(slow==fast),有环,返回true
4.快指针为null,或其next指向null,没有环,返回false,操作结束
package com.szh.demo.list;
/**
* @Description: 判断链表是否有环
* @title: RingList
* @Author szh
* @Date: 2022/1/8 20:56
* @Version 1.0
*/
public class RingList {
/**
* 判断链表是否有环
* @param head
* @return
*/
public static boolean isRing(Node head){
if(head==null) return false;
//定义快慢指针
Node slow=head; //慢
Node fast=head.next; //快
while(fast!=null&&fast.next!=null){
//追击相遇 有环
if(slow==fast){
return true;
}
fast=fast.next.next; //步长为2
slow=slow.next;//步长为1
}
//无环
return false;
}
public static void main(String[] args) {
Node n1=new Node(1,"张飞");
Node n2=new Node(2,"赵云");
Node n3=new Node(3,"关羽");
Node n4=new Node(4,"黄忠");
Node n5=new Node(5,"狄仁杰");
n1.next=n2;
n2.next=n3;
n3.next=n4;
n4.next=n5;
n5.next=null;
System.out.println(isRing(n1));
}
}
排序算法
根据时间复杂度的不同,主流的排序算法可以分为3大类
- 时间复杂度为O(n*n)的排序算法
冒泡排序、选择排序、插入排序、希尔排序 - 时间复杂度为O(nlogn)的排序算法
快速排序 、归并排序、堆排序 - 时间复杂度为线性的排序算法
计数排序、桶排序、基数排序
根据其稳定性,可以分为稳定排序和不稳定排序
- 稳定排序:值相同的元素在排序后仍然保持着排序前的顺序
- 不稳定排序:值相同的元素在排序后打乱了排序前的顺序
冒泡排序
按照冒泡排序的思想,我们要把相邻的元素两两比较,当一个元素大于右侧相邻元素时,交换它们的位置;当一个元素小于或等于右侧相邻元素时,位置不变。
/**
* @Description: 冒泡排序
* @title: BubbleSort
* @Author szh
* @Date: 2022/1/9 11:27
* @Version 1.0
*/
public class BubbleSort {
public static void main(String[] args) {
int[] nums = {5, 8, 6, 3, 9, 2, 1, 7};
//循环次数
for (int i = 0; i < nums.length - 1; i++) {
boolean isSort = true; //默认是排好的
//两两比较 已经移到右侧的就不用再比较了
for (int j = 0; j < nums.length - 1 - i; j++) {
int tmp = 0;
if (nums[j] > nums[j + 1]) {
isSort=false;
//大的右移 交换
tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
//排好则跳出循环
if(isSort) break;
}
for (int n : nums) {
System.out.println(n);
}
}
}