【数据结构初阶】八大排序算法+时空复杂度

简介: 【数据结构初阶】八大排序算法+时空复杂度

学会控制自己是人生的必修课

c21d39e0b9ae42539709445f29eb2516.jpeg



一、插入排序

1.直接插入排序


764ece2fec5b4e6a84f2cdef355a8aa5.png



1.直接插入排序思想: 假设现在已经有一个有序序列,如果有一个数字插入到这段序列的末尾,我们会选择拿这个数和它前面的每个数字都比较一遍,如果前面的数字比他大,那我们就让前面的数字赋值到这个被插入的数字位置,依次与前面的数字进行比较,最后我们把插入有序序列的数字放到他应该在的位置

void InsertSort(int* arr, int n)
{
  for (int i = 0; i < n-1; i++)
  {
    int end = i;//end是指有序序列最后一个数字的下标
    int tmp = arr[end+1];//需要暂存一下尾部位置的元素
    while (end >= 0)
    {
      if (arr[end] > tmp)
      {
        arr[end + 1] = arr[end];
        end--;
      }
      else
      {
        break;
      }
    }
    arr[end+1] = tmp;
  }
}



2.代码细节说明:

一般来说,我们还是习惯将end定义为有序序列的最后一个数字的下标,如果我们将它定义为被插入数字的下标的话,比较的时候,我们就得往前比,end-1和end比,如果你喜欢这样定义的话,那也可以,后面的希尔排序,你也这样定义,比较的时候拿end-gap和end比较,也可以,没什么问题。

但是我个人觉得还是有些别扭的,我还是推荐大家将end定义为有序序列最后一个数字的下标,这样在逻辑思考上面还是对我们很有帮助的。


2.希尔排序

66eac765fe68404395fcc904aeecfb64.png

1.希尔排序思想: 将一整组数据分成gap组,我们先将这gap组进行组排序,将每组排好之后,可以让较大的数据尽快到后面,较小的数尽快到前面,然后我们逐渐减小gap直到1,进行最后的一次直接插入排序,最后的这次排序进行完毕,我们就可以得到一个有序序列了。

void ShellSort(int* arr, int n)//我的希尔排序一点问题都没有
{
  int gap = n;
  while (gap > 1)//当while循环里面gap为1时,排序一次之后,循环结束,排序结束
  {
    gap = gap / 3 + 1;//保证我们的最后一次排序为直接插入排序,而不是预排序
    for (int i = 0; i < n - gap; i++)//实现gap组并排,并且最后一次排序为直接插入排序
    {
      int end = i;//end为最后一个有序数字的下标
      int tmp = arr[end + gap];
      while (end >= 0)
      {
        if (arr[end] > tmp)
        {
          arr[end + gap] = arr[end];
          end -= gap;
        }
        else
        {
          break;
        }
      }
      arr[end + gap] = tmp;
    }
  }
}


2.代码细节说明:

a. gap其实就是间距,希尔排序其实就是直接插入排序基础上的一种升华,gap/3+1是为了保证最后一次gap为1,而不是0.

如果你对于直接插入排序理解较好的话,那其实希尔排序也是很好理解的,我们只不过将数据进行了一个分组,然后进行组排序,这样会很节省时间,遍历数据的个数也会减少下来,等到最后一次排序的时候,我们也无需遍历大量的数据了,只需遍历个别不满足升序的数据即可,我们可以看到组成希尔排序的每个部分所消耗的时间其实都是短的,这也就是希尔排序效率高的一个原因,因为它可以很快的将数据归位,并且每次归位的时间消耗相比于直接插入排序,要优化很多,这也是希尔排序效率高的原因所在。

希尔排序=预排序+直接插入排序


3.希尔排序的时间复杂度:

分成gap组,每组N/gap个数据。

每组最坏的情况下的挪动次数:1 + 2 + 3 + …… + N / gap - 1 等差数列

总次数:(1 + 2 + 3 + …… + N / gap - 1) * gap次。这个次数没算外层的while循环


最开始gap很大:(N / 3 + 1),代入到总次数可得(1 + 2 + …… 3)* (N / 3 + 1),很接近于O(N)


……

快结束时,gap很小,带入到总次数可得总次数接近于等差数列,应该是O(N²),但其实并不是,因为这是最后的一次预排序,接近有序,所以我们可以近似为O(N)


我们可以将每一次的预排序的运行次数看作O(N)


N/3/3/3/3……/3/3/3=1

N/2/2/2/2……/2/2/2=1

所以预排序的次数是logN,2和3作为log的底数我们不管。所以次数就是logN

那我们其实就可以毛估出来希尔排序的时间复杂度大概是O(N*logN)这个级别的。


但希尔排序的时间复杂度并不是我们毛估出来那样的,其实它的计算难度非常大,因为随着gap的减小,总次数左右两边都是较为趋近于N的,这个时候,他的时间复杂度其实是不可以看作O(N)这类的了,我们这里给出希尔排序时间复杂度的结论,O(N^1.3),有关的严格数学证明,大家可以自己网上搜索或查阅资料43a55f3f928b4fd490a3d6c289ceb729.png

排序这个地方的时间复杂度有两个量级:一个是O(N^2),一个是O(N*logN),希尔排序,堆排序,快排,归并排序,等都是重量级的排序,自然学习的难度也是较大。
43a55f3f928b4fd490a3d6c289ceb729.png


二、选择排序

1.直接选择排序

1.直接选择排序思想:我们每次将数组遍历一遍,每遍历一遍就选出最大和最小的数字将其放在序列的首尾,直到将序列遍历完毕或者遍历的只剩一个数字时,我们的序列也就变得有序了。

void SelectSort(int* arr, int n)
{
  //选出来最大和最小的数分别放在左右两端
  int begin = 0, end = n - 1;
  while (begin < end)
  {
    int mini = begin, maxi = begin;
    for (int i = begin; i <= end; i++)
    {
      if (arr[i] < arr[mini])
      {
        mini = i;
      }
      if (arr[i] > arr[maxi])
      {
        maxi = i;
      }
    }
    if (maxi == begin)
//我们这里需要做一下调整,因为有可能最大元素的位置在begin的位置,这样我们以交换元素之后,maxi的位置就是最小元素了。
//所以如果maxi在begin的位置上,我们就需要事先把maxi的下标改为mini的下标,这样在交换元素过后,mini正好就是最大元素了。
      maxi = mini;
    Swap(&arr[begin], &arr[mini]);
    Swap(&arr[end], &arr[maxi]);
//下面那次不用调整的原因是,上面调整过后,maxi位置肯定不存在最小元素的情况,所以不存在交换最大元素和end位置时,出现最小元素在end
//位置,因为我们是先交换begin和mini,所以下面再次交换时,肯定是不会出问题的,因为我们上面已经交换过了。
    begin++;
    end--;
  }
}



2.代码细节说明:

如果出现最大元素下标正好在left位置的时候,我们第二次交换max和right一定会出问题,所以我们需要对这样的情况做出调整,将max的下标改为min的下标。

当然也有人或许会有疑问,那最小元素如果出现在right位置时,你不需要做出调整么?答案是:不需要做出调整,只需要调整一次就够了。

因为我们交换元素的顺序是先交换小在交换大,所以只要交换小不出问题,后面的交换大肯定也不会出问题。

d53d67a5189348758b38fd05d316367e.png


2.堆排序(已经建好堆的基础之上)

1.堆排序思想

升序建大堆,降序建小堆,不断的利用向下调整算法将堆顶元素依次放到堆尾。记得父节点和子节点之间的关系:parent=(child-1)/2不要给忘了。我们每重新调整堆时,传给向下调整算法的数组个数要减1,因为我们交换一次,数组的最后一个元素已经变得有序了,不需要我们调整了。

c39298bec8fd4f71a0531263a114c30a.png


94b1e195beda4959ad67eadd3f178b62.png


void AdjustDownTeach(HPDataType* array, int n, int parent)
{
  int child = parent * 2 + 1;//上来我们就先假设最大的孩子是左孩子
  while (child<n)//我们想的是循环结束的条件,写的是循环继续的条件
  {
    //保证有右孩子的同时,看看我们的假设是否正确,错误就调整
    if (child + 1 < n && array[child + 1] > array[child])
    //如果假设错误,我们将孩子改为右孩子,并且你也有可能没有右孩子,没有右孩子,默认左孩子就是最大的
    //这里其实不用担心没有孩子的问题,因为如果parent没有孩子,child被赋值过后肯定大于n了直接跳出循环了就。
    {
      ++child;//将下标自增,左孩子就变为右孩子
    }
    if (array[parent] < array[child])//我们这里建的是大堆
    {
      Swap(&array[parent], &array[child]);
      parent = child;
      child = parent * 2 + 1;//这里再重新假设左孩子是大的,下一次循环就是先看看我们的假设是否正确,若不正确就进行调整。
    }
    else
    {
      break;
    }
  }
}
void HeapSort(int* arr, int n)
{
  for (int i = 1; i < n; i++)
  {
    Swap(&arr[0], &arr[n - i]);
    AdjustDownTeach(arr, n - i, 0);//我们传过去的数组大小是需要逐渐减小的,所以传的是n-i
  }
}


2.代码细节说明:

a. 无论是建堆还是堆排序,其核心主要还是在于向下调整算法,既能帮助我们建堆,又能帮助我们对堆进行排序。我们主要说明一下向下调整算法的代码细节。


b. 由于每次判断左孩子和右孩子哪个大很费时间,并且代码写起来不够精炼,所以这里利用了一个代码技巧,就是上来就假设左孩子是最大的,如果我们假设错误,那就重新进行调整,在调整的那部分代码,有一个很坑的地方就是,有可能父节点没有右孩子,此时在if语句的条件判断部分就出现了越界访问的问题,所以我们还要加上一个判断条件,就是保证有右孩子的同时,去看一下我们的假设是否正确。


c. 另外有关于向下调整算法什么时候向下调整结束,我们这里也要说一下,一定是child>=n时,循环就要结束了,说一下为什么child==n循环就结束就可以了,因为child>n循环肯定是要结束的,这个应该是很好理解的,我也就不多说了

1276f0eb89b2492dbc4843d81b1f1eb0.png


三、交换排序(Swap)

1.冒泡排序(大学牲最熟悉的排序)

1.冒泡排序思想:

每遍历一次序列就将最大的元素交换到序列的最后面,直到遍历的序列中元素仅剩1个时,冒泡排序结束。

void BubbleSort(int* arr, int n)
{
  for (int i = 0; i < n; i++)
  {
    int exchange = 0;
    for (int j = 0; j < n - 1 - i; j++)
    {
      if (arr[j] > arr[j + 1])
      {
        Swap(&arr[j], &arr[j + 1]);
        exchange = 1;
      }
    }
    if (exchange == 0)
    {
      break;//数组元素已经有序,不用继续排序了,跳出循环即可。
    }
  }
}


2.代码细节说明: 就简单的做了一个优化,如果我们排序的某一趟每个元素都不用交换,则说明这一趟要排序的元素已经有序,那后面的每趟排序其实就不用进行了,我们选择直接跳出循环。


2.快速排序(The fastest sort of all sorts有点儿装B,但确实挺快)

2.1 hoare版本


1.hoare排序思想: 我们默认序列左起第一个数为key,我们定义两个下标left和right分别从序列的左边和右边去找值,left找比key大的值,right找比key小的值,找到之后,交换left和right的值,等到left和right相遇的时候,此时的值一定是比key小的值,我们再把key和这个相遇位置的值进行交换,这样key就回到它应有的正确位置上,我们再递归处理key的左边区间和右边区间,递归结束之后,快排也就结束了。


ba5b4a43675243d380bb1beac2052326.png

int PartSort1(int* arr, int left, int right)
{
  //1.默认左边的数为key值
  //2.先让右边的数走,找小。再让左边的数走,找大,交换两个元素
  //3.当两边相遇的时候,相遇位置一定是比key要小的值,交换key和相遇位置
  int key = left;
  while (left<right)
  {
    while (left < right && arr[right] >= arr[key])
    {
      right--;
    }
    while (left < right && arr[left] <= arr[key])
    {
      left++;
    }
    Swap(&arr[left], &arr[right]);
  }
  Swap(&arr[key], &arr[left]);
  key = left;
  return key;
void QuickSort(int* arr, int begin, int end)
{
  if (begin>=end)//递归结束条件
  {
    return;
  }
  int key = PartSort1(arr, begin, end);
  QuickSort(arr, begin, key - 1);
  QuickSort(arr, key + 1, end);
}
}


2.代码细节说明:


代码思路看起来比较简单,其实实现的时候,有很多坑人的地方。

a. 如果key后面的每个数都比key小或大的话,那left向后面找或right向前面找,就会产生越界访问的问题,为了防止这样情况的产生,我们选择在if语句的判断部分加个逻辑与&&保证left小于right,以免产生越界访问的问题。

b. 我们在if语句的判断部分,找的数一定得比key小或大,连相等都是不可以的,为什么呢?因为会产生死循环。

一旦序列中的左右出现相等的数字的时候,我们if语句如果写成>或<而不是>=或<=,程序就废了。

e7ab372855024f5f8dc0d7b02fb24f78.png

2.2 三数取中+小区间优化

三数取中就是为了让我们选取的key在序列中的位置尽量靠中间,让我们递归处理的效率更高。

23bd1d21fb624d1f82334e8d1c423049.png

int GetMidIndex(int *arr, int begin, int end)
{
  int mid = (begin + end) / 2;
  if (arr[begin] > arr[end])
  {
    if (arr[end] > arr[mid])
    {
      return end;
    }
    else if (arr[begin] < arr[mid])
    {
      return begin;
    }
    else
    {
      return mid;
    }
  }
  else// arr[begin] < arr[end]
  {
    if (arr[mid] < arr[begin])
    {
      return begin;
    }
    else if (arr[end] > arr[mid])
    {
      return mid;
    }
    else
    {
      return end;
    }
  }
}


小区间优化是为了让递归深度不要太深,因为数据量过大以后,我们递归的深度就会增加,递归建立的栈帧数量会随着递归深度的增加而增加,又因为栈空间本身就小,很容易造成栈溢出的问题,这时我们就想到了一个办法,例如当递归区间的数据量较小的时候,我们不采用递归解决他们,而是换成直接插入的方法来解决较小数据量的排序。

void QuickSort(int* arr, int begin, int end)
{
  if ((end - begin) < 1)//递归结束条件
  {
    return;
  }
  if ((end - begin + 1) < 15)
  {
    InsertSort(arr + begin, end - begin + 1);
  }
  else
  {
    int key = PartSort1(arr, begin, end);
    QuickSort(arr, begin, key - 1);
    QuickSort(arr, key + 1, end);
  }
}

2.3 挖坑法版本

1.挖坑法思想


挖坑法的思想还是要比hoare大佬的思想更加容易理解的,整体是一个什么思想呢?

我们先在序列的最左边挖一个坑,然后这个坑位的值就是key值,然后从右往左找比key小的值填到坑位上面去,这个时候坑位就变成right位置了,我们再从左向右找比key大的值,把这个值填到坑位上面去,再将坑位更新为left,循环进行这个工作,直到left和right相遇,他们相遇的位置也就是最后一次hole的位置,我们将key填到hole的位置,这样key就回到它本身的位置了。

剩下的工作我们还是交给递归,递归处理左区间和右区间。

int PartSort2(int* arr, int left, int right)
{
  int mid = GetMidIndex(arr, left, right);
  Swap(&arr[left], &arr[mid]);
  int hole = left;
  int key = arr[left];
  while (left<right)
  {
    while (left < right && arr[right] >= key)
    {
      --right;
    }
    arr[hole] = arr[right];
    hole = right;
    while (left < right && arr[left] <= key)
    {
      ++left;
    }
    arr[hole] = arr[left];
    hole = left;
  }
  arr[hole] = key;
  return hole;
}


2.代码细节说明:

填好坑位的值之后,我们要记得将坑位进行更新,最后坑位的下标其实就是key应该在的位置的索引。

实现起来还是较为简单的,有了前面hoare版本的铺垫。


2.4 前后指针版本

1.前后指针思想:


我们定义两个指针一个cur一个prev,利用cur去找比key小的值,然后交换cur和prev++的值,让cur不断的向后找比key小的值,等到cur找完整个数组之后,prev位置的值肯定是比key要小的,所以prev的位置其实就是key最终应该待在的位置,这样我们就处理好key了。

剩下的还是交给递归处理。

int PartSort3(int* arr, int left, int right)
{
  int mid = GetMidIndex(arr, left, right);
  Swap(&arr[left], &arr[mid]);
  int key = left;
  int prev = left, cur = left + 1;
  while (cur <= right)
  {
    if (arr[cur] < arr[key] && ++prev != cur)//逻辑与后面的条件就是为了防止交换同一个位置的元素,这时交换没有必要
    {
      Swap(&arr[cur], &arr[++prev]);
    }
    cur++;
  }
  Swap(&arr[key], &arr[prev]);
  return prev;
}



2.代码细节说明:

有的人可能看到++prev!=cur时,有些蒙,但其实这个就是为了避免同一位置的值交换多次所做的优化。

cur是需要一直向后走的,我们只需要判断cur的每一个位置的值和key的关系并将其处理即可。


2.5 三指针版本(快排的终极优化,可适用任何刁钻的数据分布)

1.三指针思想:


假设某个序列中几乎所有数据都是相同的,并且数据量极大的情况下,原来的快排就无法适用了,因为要建立很多栈帧,递归深度太深,运行时间过长,那些几乎所有相同的数据,我们完全可以将其集中起来,把他们放到他们应该在的位置,不对其作任何处理。

这时候就从原来的前后指针版本衍生出我们的三指针版本。


大体思路:

定义left,right,cur等三个下标,利用cur对序列的遍历,找出大于key,等于key,小于key的各个数字,将他们交换到left和right等下标位置,然后递归剩余的左右区间,完成我们的排序。

void QuickSortPlus(int* arr, int begin, int end)
{
  if (begin >= end)
  {
    return;
  }
  if ((end - begin + 1) < 15)
  {
    InsertSort(arr + begin, end - begin + 1);
  }
  else
  {
    int mid = GetMidIndex(arr, begin, end);
    Swap(&arr[begin], &arr[mid]);
    int key = arr[begin];
    int left = begin, right = end, cur = begin + 1;
    while (cur <= right)
    {
      if (arr[cur] < key)
      {
        Swap(&arr[left], &arr[cur]);
        left++;
        cur++;
      }
      else if (arr[cur] > key)
      {
        Swap(&arr[cur], &arr[right]);
        right--;
      }
      else//arr[cur]==key时
      {
        cur++;
      }
    }
    QuickSortPlus(arr, begin, left - 1);
    QuickSortPlus(arr, right + 1, end);
  }
}


2.代码细节说明:

因为我们是将left位置看作key,所以只有交换比key小的元素时,cur才可以++,或者遇到和key相等的值时,cur可以++,我们必须保证left左侧都是比key小的值,left和right中间都是和key相等的值,right右侧都是比key大的值。

然后我们递归begin到left-1,right+1到end两个区间,即可完成排序


e77bef0ba4fb4063a2e0361844781dc7.png


3.快速排序(非递归)

1.快排非递归思想


我们可以利用栈结构来存储递归区间,然后再利用快排的随便一个算法模块儿对这个区间进行排序,之后再继续入栈剩余的区间,并进行排序。

void QuickSortNonR(int* arr, int begin, int end)
{
  ST st;
  StackInit(&st);
  StackPush(&st, begin);
  StackPush(&st, end);
  while (!StackEmpty(&st))
  {
    int right = StackTop(&st);
    StackPop(&st);
    int left = StackTop(&st);
    StackPop(&st);
    int key = PartSort3(arr, left, right);
    // [begin,key-1]  [key+1,end]
    if (key + 1 < right)//key+1==right说明区间只剩一个值,无需入栈了,一个元素可以认为有序
    {
      StackPush(&st, key+1);
      StackPush(&st, right);
    }
    if (left < key - 1)
    {
      StackPush(&st, left);
      StackPush(&st, key-1);
    }
  }
  StackDestroy(&st);
}


2.代码细节说明:

只要区间长度大于等于2,我们就需要继续入栈剩余的区间,其实非递归还是延续了递归的思想,我们用入栈出栈模拟了递归的过程。















































































相关文章
|
5天前
|
算法 搜索推荐 开发者
别再让复杂度拖你后腿!Python 算法设计与分析实战,教你如何精准评估与优化!
在 Python 编程中,算法的性能至关重要。本文将带您深入了解算法复杂度的概念,包括时间复杂度和空间复杂度。通过具体的例子,如冒泡排序算法 (`O(n^2)` 时间复杂度,`O(1)` 空间复杂度),我们将展示如何评估算法的性能。同时,我们还会介绍如何优化算法,例如使用 Python 的内置函数 `max` 来提高查找最大值的效率,或利用哈希表将查找时间从 `O(n)` 降至 `O(1)`。此外,还将介绍使用 `timeit` 模块等工具来评估算法性能的方法。通过不断实践,您将能更高效地优化 Python 程序。
17 4
|
3天前
|
算法 程序员 Python
程序员必看!Python复杂度分析全攻略,让你的算法设计既快又省内存!
在编程领域,Python以简洁的语法和强大的库支持成为众多程序员的首选语言。然而,性能优化仍是挑战。本文将带你深入了解Python算法的复杂度分析,从时间与空间复杂度入手,分享四大最佳实践:选择合适算法、优化实现、利用Python特性减少空间消耗及定期评估调整,助你写出高效且节省内存的代码,轻松应对各种编程挑战。
13 1
|
1月前
|
机器学习/深度学习 存储 算法
【初阶数据结构篇】时间(空间)复杂度
复杂度是计算机科学中的一个基础概念,它帮助我们理解和评估算法的效率,对于算法设计和优化至关重要。
【初阶数据结构篇】时间(空间)复杂度
|
10天前
|
机器学习/深度学习 算法
基于心电信号时空特征的QRS波检测算法matlab仿真
本课题旨在通过提取ECG信号的时空特征并应用QRS波检测算法识别心电信号中的峰值。使用MATLAB 2022a版本实现系统仿真,涵盖信号预处理、特征提取、特征选择、阈值设定及QRS波检测等关键步骤,以提高心脏疾病诊断准确性。预处理阶段采用滤波技术去除噪声,检测算法则结合了一阶导数和二阶导数计算确定QRS波峰值。
|
1月前
|
算法
【初阶数据结构】复杂度算法题篇
该方法基于如下的事实:当我们将数组的元素向右移动 k 次后,尾部 kmodn 个元素会移动至数组头部,其余元素向后移动 kmodn 个位置。
|
1月前
|
机器学习/深度学习 人工智能 算法
【人工智能】线性回归模型:数据结构、算法详解与人工智能应用,附代码实现
线性回归是一种预测性建模技术,它研究的是因变量(目标)和自变量(特征)之间的关系。这种关系可以表示为一个线性方程,其中因变量是自变量的线性组合。
43 2
|
2月前
|
算法 搜索推荐 开发者
别再让复杂度拖你后腿!Python 算法设计与分析实战,教你如何精准评估与优化!
【7月更文挑战第23天】在Python编程中,掌握算法复杂度—时间与空间消耗,是提升程序效能的关键。算法如冒泡排序($O(n^2)$时间/$O(1)$空间),或使用Python内置函数找最大值($O(n)$时间),需精确诊断与优化。数据结构如哈希表可将查找从$O(n)$降至$O(1)$。运用`timeit`模块评估性能,深入理解数据结构和算法,使Python代码更高效。持续实践与学习,精通复杂度管理。
53 9
|
1月前
|
算法
【初阶数据结构篇】二叉树算法题
二叉树是否对称,即左右子树是否对称.
|
1月前
|
算法 索引
【初阶数据结构篇】单链表算法题进阶
深拷贝应该正好由 n 个全新节点组成,其中每个新节点的值都设为其对应的原节点的值。