算法
- 俩数之和
算法设计与分析
什么是算法?
算法(algorithm)可以看作是任何良定义的计算过程,该过程取某个值或值的集合作为输入并产生某 个值或值的集合作为输出。这样算法就是把输人转换成输出的计算步骤的一个序列。 我们也可以把算法看成是用于求解某类计算问题的工具,问题陈述说明了期望的输人和输出对应的 关系。算法则描述一个特定的计算过程来实现该输入/输出关系。
如何学习算法?
学习算法的三个步骤
- 实现算法
- 验证算法的正确性
- 分析算法的运行时间
前置知识
1.数据结构的分类与选择
数据结构是计算机存储、组织处理数据的方式,常见数据结构如下:
- 线性结构:数据,链表,栈,队列,哈希表
- 树形结构:二叉树,AVL树,2-3-4树,红黑树,B树,堆。。
- 图形结构:领接矩阵,邻接表
按照速度的快慢来分类:数字和链表是最慢的,树相对较快,哈希表是最快的;
但最快的不代表是最好的方案,要根据实际情况分析选择,一般的可以参考下图进行选择;
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TEg8H2Jx-1628239470356)(C:\Users\孤风\AppData\Local\Temp\WeChat Files\fae79651f7be598f44e525980f9d225.png)]
算法的评估
算法(Algorithm)是指用来操作数据、解决程序问题的一系列方法;对于同一个问题,使用不同的 算法,也许最终得到的结果是一样的,但在过程中消耗的资源和时间却会有很大的区别,比如下面这个 案例:
求第n个斐波那契数
public class Test {
/**
*
* 斐波那契数列:这个数列从第3项开始,每一项都等于前两项之和。
* 下标 0 1 2 3 4 5 6 7
* 数列 0 1 1 2 3 5 8 13
*/
//递归的方式:
public static long fun1(long n){
if(n<=1) return n;
return fun1(n-1)+fun1(n-2);
}
//普通循环的方式
public static long fun2(int n){
if(n<=1) return n;
long first=0;
long second=1;
for (long i = 0; i <n-1 ; i++) {
long sum=first+second;
first=second;
second=sum;
}
return second;
}
public static void main(String[] args) {
System.out.println(fun1(60));
System.out.println(fun2(60));
}
}
可以看到,实现方式有很多种,那么我们应该如何去衡量不同算法之间的优劣呢?这里就涉及到对 算法的评估。
事后统计法
通过统计、监控,利用计算机计时器对不同算法的运行时间进行比较,从而确定算法效率的高低, 但有非常大的局限性:
- 测试结构非常依赖测试环境
- 测试结构受数据规模的影响很大
事前分析估算
算法的执行效率,粗略地讲,就是算法代码执行的时间;但是,如何在不运行代码的情况下,用“肉 眼”得到一段代码的执行时间呢? 依据统计方法对算法进行估算:
时间维度:是指执行当前算法所消耗的时间,我们通常用[时间复杂度]来描述
空间维度:是指执行当前算法需要占用多少内存空间,我们通常用[空间复杂度]来计算
时间复杂度分析
公式:T(n)= O(f(n))
这种用大写O()来体现算法时间复杂度的记法,我们称之为大O阶记法;它表示的并不是代码真正的 执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以也叫作渐进时间复杂度 (asymptotic time complexity),简称时间复杂度。这种表示方法描述的是运行时间的渐进上界, 也就是最坏情况下运行方法需要的时间,一般随着n的增大,T(n)增长最慢的算法为最优算法。
公式说明如下:
- T(n)表示代码执行的时间;
- n表示数据规模的大小;
- f(n)表示每行代码执行的次数总和,因为这是一个公司,所以用f(n)来表示;
- 公式中的O,表示代码执行时间T(n)与f(n)表达式成正比;
- 公式中的低阶表达式,常量,系数等对增长趋势的影响不是很大,所以都可以忽略。
常见的时间复杂度量级
要确定某个算法的阶次,需要确定语句运行的次数,因此分析算法的复杂度关键就是要分析预计的运行情况。
1.常数阶O(1)
无论代码执行了多少行,只要是没有循环,方法调用等复杂结构,那这个代码的时间复杂度就是O(1),如:
void fun1(){//5
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
}
void fun01(int age) {
if(){
}else if (){
}else{
}
}
上述代码在执行的时候,它消耗的时候并不随着某个变量的增长而增长,那么无论这类代码有多 长,即使有几万几十万行,都可以用O(1)来表示它的时间复杂度,不管这个常数是多少,都记作O(1),而 不能记作O(3),O(12)等其他任何数字; 对于分支结构无论判断条件是真还是假,执行的次数都是恒定的,不会随着n的变大而发生变化,所 以单纯的分支结构(不包含在循环结构中),其时间复杂度都是O(1)。
线性阶O(n)
计算循环的阶次时,要确定循环体内语句运行的次数,单独的一个循环,没有加其它特殊的语句, 那这个代码的时间复杂度就是O(n),如:
void fun2(){
for(int i=1;i<=n;i++){
System.out.println(i);
}
}
如果是for循环并列关系那么会执行2n次,由于常数可以忽略,所以也是O(n),如:
void fun2(){
for(int i=1;i<=n;i++){
System.out.println(i);//n
}
for(int i=1;i<=n;i++){
System.out.println(i);//n 一共是2n
}
}
3.对数阶O(logn)
循环体里面的条件决定循环的次数,如果该条件是指数级增长的话,我们称这个代码的时间复杂度 是O(logn),如:
int i = 1;
while(i<n){
i = i * 2;//2^3
}
从上面代码可以看到,在while循环里面,每次都将判断条件 i 乘以 2,相当于是x个2相乘,也就是 2的x次方,在进行x次运算之后,i>=n终止循环,也就是2^x>=n,这里面我们重点关注这行代码执行的 次数,也就是要求x的值,在数学运算中
当
a
x
=
n
时
,
则
有
x
=
l
o
g
a
n
所
以
2
x
=
n
则
x
=
l
o
g
2
n
a^x = n 时,则有 x=log_an 所以 2^x = n 则 x=log_2n
ax=n时,则有x=logan所以2x=n则x=log2n
在估算时间复杂度的时候,一般忽略底数,因此这个代码的时间复杂度为:O(logn)。
4.平方阶O(n^2)
双重循环意味着外出循环走一次,内层循环走一圈,所以代码执行的次数为外层循环的次数*内层循环的次数,我们称这个代码的时间复杂度是O(n^2)
for(i=1; i<=n; i++){ //n
for(j=1; j<=n; j++){//n
j = i;
j++;
}
}
这段代码其实就是嵌套了2层n循环,它的时间复杂度就是 O(n*n),即 O(n²),如果将其中一层循环 的n改成m,即:
for(i=1; i<=m; i++){
for(j=1; j<=n; j++){
j = i;
j++;
}
}
那它的时间复杂度就变成了 O(m*n),所以总结循环的时间复杂度等于循环体的复杂度乘以该循环 运行的次数。下面这个循环嵌套它的时间复杂度又是多少呢?
for(i=0; i<n; i++){ //n
for(j=i; j<n; i++){//n,n-1,n-2,,,,,1
Sytem.out.println("111");
}
}
由于i=0时,内循环执行了n次,当i=1时,执行了n-1次…当i=n-1时,执行了1次,所以总共执行 了:
n
+
(
n
−
1
)
+
(
n
−
2
)
+
.
.
.
.
.
.
+
1
=
n
(
n
+
2
)
/
2
=
1
/
2
∗
n
2
+
1
/
2
∗
n
n+(n-1)+(n-2)+......+1=n(n+2)/2 = 1/2*n^2+1/2*n
n+(n−1)+(n−2)+......+1=n(n+2)/2=1/2∗n2+1/2∗n
使用推导大O阶的方法:去掉低阶表达式,然后忽略掉系数,所以最终为 O(n²)
线性对数阶O(nlogn)
线性对数阶O(nlogN) 其实非常容易理解,将时间复杂度为O(logn)的代码循环N遍的话,那么它的时 间复杂度就是 n * O(logN),也就是了O(nlogN)。如:
for(m=1; m<n; m++){
i = 1;
while(i<n){
i = i * 2;
}
}
立方阶O(n³)、K次方阶O(n^k)参考上面的O(n²) 去理解就好了,O(n³)相当于三层n循环,其它的类 似。
时间复杂度的比较
当n越来越大的时候,各个时间复杂度的大小排序如下
O
(
1
)
<
O
(
l
o
g
n
)
<
O
(
n
)
<
O
(
n
l
o
g
n
)
<
O
(
n
2
)
<
O
(
2
n
)
O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(2^n)
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(2n)
如何提升n位数相乘的效率?—分治策略
许多有用的算法在结构上是递归的,即为了解决一个给定的问题,算法一次或多次递归地调用其自 身以解决紧密相关的若干子问题。这些算法典型地遵循分治法的思想,也就是将原问题分解为几个规模 较小但类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解,最终得到原问题 的解。
分治策略是常用的算法设计范例,分三个步骤来实现:
- 分解:将原问题分解为若干子问题,这些子问题是原问题的规模较小的实例;
- 解决:递归地求解各子问题
- 合并:合并这些子问题的解组合成原问题的解。
第一个分治算法-KARATSUBA
MULTIPLY( x, y ):
if (n = 1):
return x·y
write x as a·10n/2 + b
write y as c·10n/2 + d
ac = MULTIPLY(a,c)
ad = MULTIPLY(a,d)
bc = MULTIPLY(b,c)
bd = MULTIPLY(b,d)
return ac·10n + (ad + bc)·10n/2 + bd
插入排序的实现
插入排序,适用于少量元素的排序,它的工作方式就像许多人排序扑克牌一样。开始时,我们的左 手为空并且桌子上的牌面向下。然后,我们每次从桌子上拿走一张牌并将它插人左手中正确的位置。为 了找到牌的正确位置,我们从右到左将它与已在手中的每张牌进行比较,拿在左手上的牌总是排序好 的
伪代码实现:
InsertionSort(A):
for i in range(1,len(A));
cur_value=A[i]
j = i - 1
while j>=0 and A[j] > cur_value:
A[j+1] = A[j]
j -= 1
A[j+1] = cur_value
思想:
- 外层循环i遍历数组中的元素,内存循环j实现排序功能,每次都把拿到的元素放到合适的位置上去;
- 每次与当前拿到元素的前一个元素进行比较,如果当前元素小于前一个元素,则把前一个元素向后移动,留出空位;然后继续比较,直到前一个元素不大于当前元素退出内层循环;
- 把当前元素的值插入到j+1的位置上去;
java代码实现快速插入:
public class QuickInsert {
/*快速插入排序,升序排列*/
static void quickInsert(int []nums){
for (int i = 0; i < nums.length; i++) {
int curVal = nums[i];
int j = i - 1;
while (j>=0 && nums[j]>curVal){
nums[j+1]=nums[j];
j--;
}
nums[j+1]=curVal;
}
}
public static void main(String[] args) {
int nums[]={3,2,6,8,1,5,4,7};
quickInsert(nums);
for (int i = 0; i < nums.length; i++) {
System.out.println(nums[i]);
}
}
}
插入排序的可行性
算法实现之后,需要通过某种方式证明该算法的可行性,也就是验证该算法在非特定输入的情况下都是正确的。
关注点:
可以看到,插入排序是一个迭代算法,那么每一次迭代有什么性质呢?
每一次迭代都将在已排序数组部分插入一个新元素,也就是说,第i次迭代开始前,前i项元素是排好序的,第i此迭代完成后,前i+1项元素是排好序的;也就是说一个循环开始之前,循环执行的过程中以及循环执行结束后,始终保持该性质,我们把这种情况称为循环不变式,通过循环不变式来帮助我们验证算法的正确性。关于循环不变式,我们必须证明三条性质:
- 初始化:循环的第一次迭代之前,它为真;
- 保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真;
- 终止:在循环终止时,不变式为我们提供一个有用的性质,该性质有助于证明算法是正确的。
例子:
初始化
首先证明在第一次循环迭代之前(当 i = 1 时),循环不变式成立。 此时子数组 A[0…i-1]仅由单个元素 A[0]组成,而且该子数组是排好序的。这表明第一次循环迭代之 前循环不等式成立。
保持
处理第二条性质:证明每次迭代都保持循环不变式。 每当遍历到A[i]元素时,都会通过比较将A[i]元素放到合适的位置上保证A[0]~A[i]之间的顺序,那么 对于下一次迭代时,前 i 项已排好序,所以循环不变式得以保持。
终止
最后研究在终止循环时,是否仍保持循环不变式。 循环终止时 i=n,此时 A[0…n-1] 由原来在 A[0…n-1] 中的元素组成,且已排好序,因此算法正确。
后续也将采用这种循环不变式的方法来验证算法的正确性。
插入排序的效率
通常使用大O记号来体现算法的时间复杂度,也就是通过统计语句执行的次数来估算方法的运行时 间,这种表示方法描述的是运行时间的渐进上界,也就是最坏情况下运行方法需要的时间。
插入排序最坏情况下的运行时间:
InsertionSort(A):
for i in range(1, len(A)):
cur_value = A[i]
j = i - 1
while j >= 0 and A[j] > cur_value:
A[j+1] = A[j]
j -= 1
A[j+1] = cur_value
通过分析可知,插入排序的执行总次数为4n+2n2次,根据大O的特性忽略掉系数、常熟、低阶,最终为 O(n2)。 很显然O(n^2)不是一个特别好的运行时间,那么问题来了,有没有更优的办法呢?
归并排序
归并排序算法完全遵循分治模式,操作如下:
- 分解:分解待排序的n个元素的序列成各具n/2个元素的两个子序列
- 解决:使用归并排序递归地排序两个子序列;
- 解决:使用归并排序递归地排序两个子序列;
MERGESORT(A):
n = len(A) //数组长度
if n <= 1: //数组长度=1的时候,开始返回合并
return A
L = MERGESORT(A[0:n/2])
R = MERGESORT(A[n/2:n])
return MERGE(L,R)
MERGE(L,R):
result = length n array
i = 0, j = 0
for k in [0,...,n-1]:
if L[i] < R[j]:
result[k] = L[i]
i += 1
else:
result[k] = R[j]
j += 1
return result
Java代码实现归并排序:
归并排序递归地排序两个子序列;
- 解决:使用归并排序递归地排序两个子序列;
MERGESORT(A):
n = len(A) //数组长度
if n <= 1: //数组长度=1的时候,开始返回合并
return A
L = MERGESORT(A[0:n/2])
R = MERGESORT(A[n/2:n])
return MERGE(L,R)
MERGE(L,R):
result = length n array
i = 0, j = 0
for k in [0,...,n-1]:
if L[i] < R[j]:
result[k] = L[i]
i += 1
else:
result[k] = R[j]
j += 1
return result
Java代码实现归并排序:
import java.util.Arrays;
public class MergeSort {
public static int [] mergeSort(int []A){
int []L;
int []R;
if (A.length <= 1){
return A;
}
L=mergeSort(Arrays.copyOfRange(A, 0, A.length/2));
R=mergeSort(Arrays.copyOfRange(A, A.length/2,A.length ));
return merge(L,R);
}
public static int [] merge(int []L,int []R){
int n = L.length+R.length;
int []result = new int[n];
int i = 0,j = 0,k = 0;
while (i<L.length&&j<R.length){
if (L[i]<R[j]){
result[k++]=L[i++];
}else {
result[k++]=R[j++];
}
}
while (i<L.length){
result[k++] = L[i++];
}
while (j<R.length){
result[k++]=R[j++];
}
return result;
}
public static void main(String[] args) {
int nums[]={3,2,6,8,1,5,4,7,9,10,11,12,13};
System.out.println(Arrays.toString(new MergeSort().mergeSort(nums)));
}
}