1 了解数据结构和算法
(1)数据结构
数据结构是计算机存储,组织数据(例如增删查找)的方式。
(2)算法
简单来说算法就是一系列的计算步骤,用来将输出数据转化成数据结果。(例如冒泡排序就是将乱序的数组变成有序的数组)。
2 复杂度的概念
算法设计的好与坏,需要从复杂度上去分析。算法设计的越好,算法的效率就越高.
算法在编写成可执⾏程序后,运⾏时需要耗费时间资源和空间(内存)资源。因此衡量⼀个算法的好 坏,⼀般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。 时间复杂度主要衡量⼀个算法的运⾏快慢,⽽空间复杂度主要衡量⼀个算法运⾏所需要的额外空间。
3 时间复杂度
定义:在计算机科学中,算法的时间复杂度是⼀个函数式T(N),它定量描述了该算法的运⾏时间。
T(N)函数式计算了程序的执⾏次数。
程序的执行时间=二进制指令运行时间 *执行次数(假设时间是一定的)
那我们如何计算T(N)呢
例如
// 请计算⼀下Func1中++count语句总共执⾏了多少
次?
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N ; ++ i)
{
for (int j = 0; j < N ; ++ j)
{
++count;
}
}
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
}
第一个for循环内运行N次,第二个for循环内运行N次,第三个for循环内运行N^2次,while循环中运行10次,所以
T(N)=n^2+2N+10
我们发现,当N的值逐渐增大的时候,T(N)的值主要取决与N^2。当N不断变⼤时常数和低阶项对结果的影响很⼩,所以我们只需要计算程序能代表增⻓量级的⼤概执⾏次数,复杂度的表⽰通常使⽤⼤O的渐进表⽰法。此时我们引入推导大O阶规则。
推导⼤O阶规则
1. 时间复杂度函数式T(N)中,只保留最⾼阶项,去掉那些低阶项,因为当N不断变⼤时,
低阶项对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
2. 如果最⾼阶项存在且不是1,则去除这个项⽬的常数系数,因为当N不断变⼤,这个系数
对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。
3. T(N)中如果没有N相关的项⽬,只有常数项,⽤常数1取代所有加法常数。
所以,得到 Func1 的时间复杂度为:O(N^2)
以下再举几个计算时间复杂度的例子
<1>
// 计算Func2的时间复杂度?
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 2 * N ; ++ k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
Func2执⾏的基本操作次数:T (N) = 2N + 10
根据推导规则第2条得出: Func2的时间复杂度为:O(N)
<2>
// 计算Func3的时间复杂度?
void Func3(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++ k)
{
++count;
}
for (int k = 0; k < N ; ++
k)
{
++count;
}
printf("%d\n", count);
}
Func3执⾏的基本操作次数:T (N) = M + N
因此:Func2的时间复杂度为:O(M+N)
注:T(N)中的N 不代表后面的任何变量 等号后面的M和N是两个变量
<3>
// 计算Func4的时间复杂度?
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 100; ++ k)
{
++count;
}
printf("%d\n", count);
}
T(N)中如果没有N相关的项目,只有常数项,用常数1代替所有的加法常数
T(N)=100,因此时间复杂度O(1)
注:时间变化率计算的是变化,不是一个具体的数。例如常数的变化了为1,所以都用一代替,而含有变量的式子中变化率是改变的。
<4>
// 计算strchr的时间复杂度?
const char * strchr ( const char
* str, int character)
{
const char* p_begin = s;
while (*p_begin != character)
{
if (*p_begin == '\0')
return NULL;
p_begin++;
}
return p_begin;
}
strchr执⾏的基本操作次数:
1)若要查找的字符在字符串第⼀个位置,则:T (N) = 1
2)若要查找的字符在字符串最后的⼀个位置, 则:T (N) = N
3)若要查找的字符在字符串中间位置,则:T (N) = N/2
因此:strchr的时间复杂度分为:
最好情况:O(1) 最坏情况:O(N) 平均情况:O(N)
总结 :通过上⾯我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
最坏情况:任意输⼊规模的最⼤运⾏次数(上界)
平均情况:任意输⼊规模的期望运⾏次数
最好情况:任意输⼊规模的最⼩运⾏次数(下界)
⼤O的渐进表⽰法在实际中⼀般情况关注的是算法的上界,也就是最坏运⾏情况。
<5>
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
外层的循环控制的是数组要遍历多少次,内层的循环控制的是两两交换的次数
外层循环一共循环n次
内层循环:第一次循环n-1次,第二次循环n-2次,第三次循环n-3次,一直循环到1。所以内层循环一共循环了n-1+n-2+……+1=1/2n^2-1/2n(等差数列求和,一共循环了n-1次)
所以T(N)=N^2
时间复杂度:O(N^2)
根据这个例子,我们发现时间复杂度不一定是看上去的数字
<6>
void func5(int n)
{
int cnt = 1;
while (cnt < n)
{
cnt *= 2;
}
}
当n=2时,执⾏次数为1
当n=4时,执⾏次数为2
当n=16时,执⾏次数为4
假设执⾏次数为x ,则2^x = n
因此执⾏次数:x = log n
因此:func5的时间复杂度取最差情况为:
O(log2 n)
<7>
// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
if(0 == N)
return Fac(N-1)*N;
}
调⽤⼀次Fac函数的时间复杂度为O(1) ⽽在Fac函数中,存在n次递归调⽤Fac函数
递归算法的时间复杂度=递归次数*单次递归的·时间·复杂度
因此,时间复杂度为O(N)
4 空间复杂度
空间复杂度也是⼀个数学表达式,是对⼀个算法在运⾏过程中因为算法的需要额外临时开辟的空间。
空间复杂度不是程序占⽤了多少bytes的空间,因为常规情况每个对象⼤⼩差异不会很⼤,所以空间复
杂度算的是变量的个数。
空间复杂度计算规则基本跟实践复杂度类似,也使⽤⼤O渐进表⽰
注意:函数运⾏时所需要的栈空间(存储参数、局部变量、⼀些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运⾏时候显式申请的额外空间来确定
所以空间复杂度计算函数体内因为实现算法而要额外开辟的空间
下面举几个例子
<1>
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i-1] > a[i])
{
Swap(&a[i-1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
其中 size_t end exchange size_t 一共是三个常量空间
因此,空间复杂度为O(1)
<2>
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
if(N == 0)
return 1;
return Fac(N-1)*N;
}
递归空间复杂度=递归次数*单次递归的空间复杂度
Fac递归调⽤了N次,额外开辟了N个函数栈帧,每个栈帧使⽤了常数个空间
因此空间复杂度为:O(N)
5 常见复杂度对比
通过这两张表,我们可以发现当复杂度的曲线变化越平缓的时候,复杂度越小,越不容易被改变。相反,复杂度的曲线变化越陡峭的时候,复杂度越大,越容易被改变。
复杂度越小,程序的性能就越好。
6 复杂度算法题
方法一:
思路:向右轮转一次,就是将数组的最后一个元素取出存放在临时变量中,将数组其他元素向后挪动一位,然后把存放在临时变量里的元素放在数组下标为0的位置。向右轮转K次,就是将这个步骤重复K次。
void rotate(int* nums, int numsSize, int k) {
while(k--)
{
int end = nums[numsSize-1];
for(int i = numsSize - 1;i > 0 ;i--)
{
nums[i] = nums[i-1];
}
nums[0] = end;
}
}
此时的时间复杂度是O(N^2)空间复杂度是O(1)
时间复杂度太大了,那么有没有时间复杂度更小的方法呢,我们引入方法2
方法二:
思路:创建临时数组tmp,和原数组大小一致,先将tmp后K个数据依次放到tmp数组中,再将剩下的数据依次放到tmp中
但是如果此时的K大于数组的元素个数,那么就会出错,所以我们要对K取余。
void rotate(int* nums, int numsSize, int k)
{
//创建新数组
int newArr[numsSize];
//遍历原数组,将数据轮转后放到tmp数组中
for (int i = 0; i < numsSize; ++i)
{
newArr[(i + k) % numsSize] = nums[i];
}
//将tmp的数据导入到nums数组
for (int i = 0; i < numsSize; ++i)
{
nums[i] = newArr[i];
}
}
此时的时间复杂度是O(N) 空间复杂度是O(N)
也就是用“空间换时间”,那有没有不需要另外开辟空间的方法,此时我们引入方法3
方法三:三次逆置
思路:
三个步骤中都用到了逆置,所以我们先写一个逆置的函数,此时也需要对K取余。
//逆置函数
void reverse(int* nums,int left,int right)
{
while(left<right){
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
left++;
right--;
}
}
void rotate(int* nums, int numsSize, int k)
{
k = k%numsSize;
//前n-k个数据逆置
reverse(nums,0,numsSize-k-1);
//后k个数据逆置
reverse(nums,numsSize-k,numsSize-1);
//整体逆置
reverse(nums,0,numsSize-1);
}
此时 时间复杂度是O(N),空间复杂度是O(1)