冒泡、快速、选择、插入排序以及时间复杂度、空间复杂度的解析
时间复杂度
评估执行程序所需的时间。可以估算出程序对处理器的使用程度。
(1)时间频率
一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。并且一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)。
(2)时间复杂度
在刚才提到的时间频度中,n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。但有时我们想知道它变化时呈现什么规律。为此,我们引入时间复杂度概念。 一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
时间复杂度的表示方法
其实就是算法(代码)的执行效率,算法代码的执行时间。我们来看下面一个简单的代码:
int sumFunc(int n) {
int num = 0; // 执行一次
for (int i = 1; i <= n; ++i) //执行n次
{
num = num + i; // 执行n次
}
return num;//执行一次
}
假设,每行代码的执行时间为t,那么这块代码的时间就是(2n+2)*t
由此得出:代码执行时间T(n)与代码的执行次数是成正比的
接下来让我们看下一个例子:
int sumFunc(int n) {
int num = 0; // 执行一次
for (int i = 1; i <= n; ++i)// 执行n次
{
for (int j = 1; j <= n; ++j)//执行n*n次
{
num = num + i * j; // 执行n*n次
}
}
}
同理,该代码执行时间为(2n*n+n+1)*t
注意:在数据结构/算法中,通常使用T(n)表示代码执行时间,n表示数据规模大小,f(n)表示代码执行次数综合,所以上面这个例子可以表示为f(n)=(2n*n+n+1)*t,其实就是一个求总和的式子,O表示代码执行时间与f(n) 成正比例。
根据上面两个例子得出结论:代码的执行时间 T(n)与每行代码的执行次数 n 成正比,人们把这个规律总结成这么一个公式:T(n) = O(f(n))
所以呢,第一个例子中的 T(n)=O(2n+1),第二个例子中的 T(n)=O(2n*n+n+1),这就是时间复杂度表示法,也叫大O时间复杂度表示法。
但是,O时间复杂度并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度,简称时间复杂度。
当n变得越来越大时,公式中的低阶,常量,系数三部分影响不了其增长趋势,所以可以直接忽略他们,只记录一个最大的量级就可以了,所以上述两个例子实际他们的时间复杂度应该记为:T(n)=O(n) ,T(n)=O(n*n)
时间复杂度的分析和计算方法
(1)循环次数最多原则
我们上面说过了,当n变得越来越大时,公式中的低阶,常量,系数三部分影响不了其增长趋势,可以直接忽略他们,只记录一个最大的量级就可以了。因此我们在计算时间复杂度时,只需关注循环次数最多的那段代码即可。
int sumFunc(int n) {
int sum = 0; //执行1次,忽略不计
for (int i = 0; i < n; i++)
{
sum += i; // 执行n次
}
return sum; //执行1次,忽略不计
}
循环内执行次数最多,执行次数为n次,因此时间复杂度记为O(n)
(2)加法原则
int sumFunc(int n)
{
int sum = 0; //常量级,忽略
for (int i = 0; i < 99; i++)
{
sum += i; //执行100次,还是常量级,忽略
}
for (int i = 0; i < n; i++)
{
sum += i; //执行n次
}
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
sum += i; //执行n*n次
}
}
return sum;
}
上述例子中,最大的两块代码时间复杂度分别为 O(n)和O(nn),结果本应该是:T(n)=O(n)+O(nn),我们取其中最大的量级,忽略低阶的时间复杂度,因此整段代码的复杂度为:O(n*n)
所以得出结论:量级最大的那段代码时间复杂度=总的时间复杂度
(3)乘法原则
嵌套代码的复杂度=嵌套外代码复杂度*嵌套内代码复杂度
void Func1(int n) {
for (int i = 0; i < n; i++)
{
Func2(n); //执行n次,每次都会调用Func2函数执行n次
}
}
void Func2(int n) {
int sum = 0;
for (int i = 0; i < n; i++)
{
sum += 1; //执行n次
}
}
因此这段代码时间复杂度为O(n) * O(n) = O(nn) = O(nn)
同理,如果将其中一个n换成m,那么它的时间复杂度就是O(n*m)
常见的几种时间复杂度
(1)O(1)常量级时间复杂度
void Func(void)
{
for (int i = 0; i < 100; i++)
{
printf("hello"); //执行一百次,也是常量级,记为O(1)
}
}
void Func(void)
{
printf("hello");
printf("hello");
printf("hello");
//各执行一次,还是记为O(1)
}
相信你也看明白了,O(1)不是说代码只有一行,这个1它代表的是一个常量,即使它有以前一万行这样的也是O(1),因为它是固定的不会变化(也就是常量),所以凡是常量级复杂度代码,均记为O(1)
(2)常见的O(n)复杂度
void Func(int n) {
for (int i = 0; i < n; i++)
{
printf("hello");//执行n次
}
}
循环体内执行n次,时间复杂度为O(n)
(3)常见的O(n*n)复杂度
int sumFunc(int n) {
int num = 0;
for (int i = 1; i <= n; ++i)
{
for (int j = 1; j <= n; ++j)
{
num = num + i * j; // 执行n*n次
}
}
}
循环体内执行nn次,时间复杂度为O(nn)
(4)O(logn),O(nlogn)
首先我们来回忆一下换底公式:
记住公式,来看例子:
void Func(int n) {
for (int i = 1; i < n; i++)
{
i = i * 2;
}
}
可以看出,i = i * 2这行代码执行次数是最多的,那么到底执行了多少次呢?
第一次 i=2,执行第二次 i=4,执行第三次 i=8…
假设它执行了x次,那么x的取值为:
当上述代码的2改成3的时候,x的取值也就是:
当然不管log的底数是几,是e也好,是10也罢,统统记为:
为什么这样,由换底公式可以看出出:
换底之后,可以看出log3(2)其实就是一个常数,忽略掉,而log默认就是以2为底的,所以统统记为O(logn)。
void Func(int n)
{
for (int i = 0; i < n; i++)
{
Func2(n); //执行n次
}
}
void Func2(int n) {
for (int i = 0; i < n; i++)
{
i = i * 2; //执行logn次
}
}
在Func中嵌套调用,每次调用执行logn次,所以时间复杂度为O(logn)
常见的时间复杂度排序
O(1)<O(logn)<O(n)<O(nlogn)<O(n²)<O(n³)<O(2ⁿ)<O(n!)
空间复杂度
评估执行程序所需的存储空间。可以估算出程序对计算机内存的使用程度。
一个程序的空间复杂度是指运行完一个程序所需内存的大小。利用程序的空间复杂度,可以对程序的运行所需要的内存多少有个预先估计。一个程序执行时除了需要存储空间和存储本身所使用的指令、常数、变量和输入数据外,还需要一些对数据进行操作的工作单元和存储一些为现实计算所需信息的辅助空间。程序执行时所需存储空间包括以下两部分。
(1)固定部分。
这部分空间的大小与输入/输出的数据的个数多少、数值无关。主要包括指令空间(即代码空间)、数据空间(常量、简单变量)等所占的空间。这部分属于静态空间。
(2)可变空间
这部分空间的主要包括动态分配的空间,以及递归栈所需的空间等。这部分的空间大小与算法有关。
一个算法所需的存储空间用f(n)表示。S(n)=O(f(n)),其中n为问题的规模,S(n)表示空间复杂度。
时间复杂度的分析和计算方法
(1)O(1)
int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
代码中的 i、j、m 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)
如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为O(1)
(2) O(n)
int[] m = new int[n]
for(i=1; i<=n; ++i)
{
j = i;
j++;
}
这段代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2-6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)
总结:当一个算法的空间复杂度为一个常量,即不随被处理数据量n的大小而改变时,可表示为O(1);当一个算法的空间复杂度与以2为底的n的对数成正比时,可表示为O(logn);当一个算法的空I司复杂度与n成线性比例关系时,可表示为O(n).若形参为数组,则只需要为它分配一个存储由实参传送来的一个地址指针的空间,即一个机器字长空间;若形参为引用方式,则也只需要为其分配存储一个地址的空间,用它来存储对应实参变量的地址,以便由系统自动引用实参变量
冒泡排序
(1)基本思想: 冒泡排序,类似于水中冒泡,较大的数沉下去,较小的数慢慢冒起来,假设从小到大,即为较大的数慢慢往后排,较小的数慢慢往前排。
(2)直观表达:每一趟遍历,将一个最大的数移到序列末尾。
算法描述
依次比较相邻的两个数,将比较小的数放在前面,比较大的数放在后面。
(1)第一次比较:首先比较第一和第二个数,将小数放在前面,将大数放在后面。
(2)比较第2和第3个数,将小数 放在前面,大数放在后面。
…
(3)如此继续,知道比较到最后的两个数,将小数放在前面,大数放在后面,重复步骤,直至全部排序完成
(4)在上面一趟比较完成后,最后一个数一定是数组中最大的一个数,所以在比较第二趟的时候,最后一个数是不参加比较的。
(5)在第二趟比较完成后,倒数第二个数也一定是数组中倒数第二大数,所以在第三趟的比较中,最后两个数是不参与比较的。
(6)依次类推,每一趟比较次数减少依次
动图实现
算法分析
(1)由此可见:N个数字要排序完成,总共进行N-1趟排序,每i趟的排序次数为(N-i)次,所以可以用双重循环语句,外层控制循环多少趟,内层控制每一趟的循环次数
(2)冒泡排序的优点:每进行一趟排序,就会少比较一次,因为每进行一趟排序都会找出一个较大值。如上例:第一趟比较之后,排在最后的一个数一定是最大的一个数,第二趟排序的时候,只需要比较除了最后一个数以外的其他的数,同样也能找出一个最大的数排在参与第二趟比较的数后面,第三趟比较的时候,只需要比较除了最后两个数以外的其他的数,以此类推……也就是说,没进行一趟比较,每一趟少比较一次,一定程度上减少了算法的量。
(3)时间复杂度
1.如果我们的数据正序,只需要走一趟即可完成排序。所需的比较次数C和记录移动次数M均达到最小值,即:Cmin=n-1;Mmin=0;所以,冒泡排序最好的时间复杂度为O(n)。
2.如果很不幸我们的数据是反序的,则需要进行n-1趟排序。每趟排序要进行n-i次比较(1≤i≤n-1),且每次比较都必须移动记录三次来达到交换记录位置。在这种情况下,比较和移动次数均达到最大值:
综上所述:冒泡排序总的平均时间复杂度为:O(n2) ,时间复杂度和数据状况无关。
(4)空间复杂度
空间复杂度就是在交换元素时那个临时变量所占的内存空间;
1.最优的空间复杂度就是开始元素顺序已经排好了,则空间复杂度为:0;
2.最差的空间复杂度就是开始元素逆序排序了,每次都要借用一次内存,按照实际的循环次数,则空间复杂度为:O(n);
综上所述:冒泡排序总的平均空间复杂度为:O(1);
代码实现
void bubble_sort(int *a, int size)
{
int i, j, t;
for (i = 1; i < size; ++i)
{
for (j = 0; j < size - i; ++j)
{
if (a[j] > a[j + 1])
{
t = a[j];
a[j] = a[j + 1];
a[j + 1] = t;
}
}
}
}
快速排序
(1)基本思想: 在每一轮挑选一个基准元素,并让其他比它大的元素移动到数列一边,比它小的元素移动到数列的另一边,从而把数列拆解成了两个部分。
(2)直观表达:采用分治法,分而治之
算法描述
(1)假设最开始的基准数据为数组第一个元素23,则首先用一个临时变量去存储基准数据,即tmp=23;然后分别从数组的两端扫描数组,设两个指示标志:low指向起始位置,high指向末尾.
(2)首先从后半部分开始,如果扫描到的值大于基准数据就让high减1,如果发现有元素比该基准数据的值小(如上图中18<=tmp),就将high位置的值赋值给low位置 ,结果如下:
(3)然后开始从前往后扫描,如果扫描到的值小于基准数据就让low加1,如果发现有元素大于基准数据的值(如上图46=>tmp),就再将low位置的值赋值给high位置的值,指针移动并且数据交换后的结果如下:
(4)然后再开始从后向前扫描,原理同上,发现上图11<=tmp,则将high位置的值赋值给low位置的值,结果如下:
(5)然后再开始从前往后遍历,直到low=high结束循环,此时low或high的下标就是基准数据23在该数组中的正确索引位置.如下图所示.
(6)采用递归的方式分别对前半部分和后半部分排序
算法分析
(1)由此可见:快速排序的本质就是把基准数大的都放在基准数的右边,把比基准数小的放在基准数的左边,这样就找到了该数据在数组中的正确位置.
(2)时间复杂度
1.快速排序的最优复杂度:
最优的情况就是每一次取到的元素都刚好平分整个数组(很显然我上面的不是);
此时的时间复杂度公式则为:T[n] = 2T[n/2] + f(n);T[n/2]为平分后的子数组的时间复杂度,f[n] 为平分这个数组时所花的时间;
下面来推算下,在最优的情况下快速排序时间复杂度的计算(用迭代法):
T[n] = 2T[n/2] + n //第一次递归
令:n = n/2 = 2 { 2 T[n/4] + (n/2) } + n //第二次递归
= 2^2 T[ n/ (2^2) ] + 2n
令:n = n/(2^2) = 2^2 { 2 T[n/ (2^3) ] + n/(2^2)} + 2n //第三次递归
= 2^3 T[ n/ (2^3) ] + 3n
......................................................................................
令:n = n/( 2^(m-1) ) = 2^m T[1] + mn //第m次递归(m次后结束)
当最后平分的不能再平分时,也就是说把公式一直往下跌倒,到最后得到T[1]时,说明这个公式已经迭 代完了(T[1]是常量了)。
得到:T[n/ (2^m) ] = T[1] ===>> n = 2^m ====>> m = logn;
T[n] = 2^m T[1] + mn ;其中m = logn;
T[n] = 2^(logn) T[1] + nlogn = n T[1] + nlogn = n + nlogn ;其中n为元素个数
又因为当n >= 2时:nlogn >= n (也就是logn > 1),所以取后面的 nlogn;
综上所述:快速排序最优的情况下时间复杂度为:O( nlogn )
2.最差情况下时间复杂度:
最差的情况就是每一次取到的元素就是数组中最小/最大的,这种情况其实就是冒泡排序了(每一次都排好一个元素的顺序)
这种情况时间复杂度就好计算了,就是冒泡排序的时间复杂度:T[n] = n * (n-1) = n^2 + n;
综上所述:快速排序最差的情况下时间复杂度为:O( n^2 )
3.平均时间复杂度
快速排序的平均时间复杂度也是:O(nlogn)
(3)空间复杂度
首先就地快速排序使用的空间是O(1)的,也就是个常数级;而真正消耗空间的就是递归调用了,因为每次递归就要保持一些数据;
1.最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况
2.最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况
代码实现
void FastSord(int *n,int left,int right){
if (left < right)
{
int i = left, j = right;
int temp = n[left];
while (i < j){
while (i<j && n[j]>temp)
j--;
if (i < j){
n[i] = n[j];
i++;
}
while (i < j&&n[i] < temp)
i++;
if (i < j){
n[j] = n[i];
j--;
}
}
n[i] = temp;
FastSord(n, left, i-1);
FastSord(n, i+1, right);
}
}
选择排序
基本思想:
每一次遍历待排序的序列,记录最小(大)值的下标,和待排序第一个元素进行比较,如果小(大)与待排序第一个元素交换
算法描述
(1)将数据分为有序部分和无序部分。
(2)在无序部分找出最大的元素,将最大的元素和无序部分最后一个元素交换,使得无序部分最后一个元素并入有序部分。
(3)重复第二步,直到无序部分都插入到有序部分结束。
动图实现
算法分析
(1)由此可见:排序的思想就是维护一个有序的部分,将无序部分中最大的元素和最后一个元素相交换,交换后无序部分的最后一个元素也将有序。
(2)时间复杂度
第一次内循环比较N - 1次,然后是N-2次,N-3次,……,最后一次内循环比较1次。
共比较的次数是 (N - 1) + (N - 2) + … + 1,求等差数列和,得 (N - 1 + 1)* N / 2 = N^2 / 2。
舍去最高项系数,其时间复杂度为 O(N^2)。
虽然选择排序和冒泡排序的时间复杂度一样,但实际上,选择排序进行的交换操作很少,最多会发生 N - 1次交换。
而冒泡排序最坏的情况下要发生N^2 /2交换操作。从这个意义上讲,交换排序的性能略优于冒泡排序。
而且,交换排序比冒泡排序的思想更加直观。
(3)空间复杂度
空间复杂度就是在交换元素时那个临时变量所占的内存空间,空间复杂度为O(1)
代码实现
void sel_sort(int *a, size_t size)
{
int i, j;
int min_index;
int t;
for(i = 0; i < size - 1; i ++){
min_index = i;
for( j = i + 1; j < size; j++){
if(a[j] < a[min_index])
min_index = j;
}
if (min_index != i){
t = a[i];
a[i] = a[min_index];
a[min_index] = t;
}
}
}
插入排序
基本思想:
插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。
算法描述
(1) 第一个元素默认有序。
(2) 取待排序的元素B,在有序序列上从后往前寻找。
(3) 如果已排序元素A大于待排序的元素B,则将A往后移动一位。
(4) 重复(3),直到找到元素A<=B(待排序)时或者有序序列全部被扫描。将待排序元素A插入。
重复(2)-(4)
动图实现
算法分析
(1)由此可见:
(2)时间复杂度
1.插入排序的最好情况是数组已经有序,此时只需要进行n-1次比较,时间复杂度为O(n)
2.最坏情况是数组逆序排序,此时需要进行n(n-1)/2次比较以及n-1次赋值操作(插入),因此,最坏情况下的比较次数是 1 + 2 + 3 + … + (N - 1),等差数列求和,结果为 N^2 / 2,所以最坏情况下的复杂度为 O(N^2)。
3.平均来说插入排序算法的复杂度为O(n2)
(3)空间复杂度
空间复杂度上,直接插入法是就地排序,空间复杂度为(O(1))
代码实现
void insertion_sort(int *a, size_t size)
{
int i, j, t;
for(i = 1; i < size; i++){
t = a[i];
j = i - 1;
while(j >= 0){
if(a[j] > t){
a[j + 1] = a[j];
}
else
break;
j--;
}
j += 1;
a[j] = t;
}
}