经典排序算法

个人博客传送门
参考一:全面的排序总结(动图来源之一)

参考二:画图的部分排序总结(代码参考,总结参考)

参考三:十大经典排序算法(动图演示)(动图来源之二)

排序的分类有很多种,有很多的排序方法,这里只列举了七八种常见的排序算法。

分类

交换排序

冒泡排序

冒泡排序的思路(升序):

  • 比较相邻的两个元素,如果第一个比第二个元素值要大,交换两者位置。
  • 指向第二个元素。
  • 一趟比较下来,最大的元素应该位于序列的最尾端
  • 进行下一趟比较,此时就不需要将最大的元素纳入范围,因为它已经在适合的位置了。
  • 重复直到全部完成。

如图:
冒泡排序

代码如下:

void BubbleSort(int arr[], int size){
    for(int i = size; i > 0; -- i){
        for(int j = 0; j < i-1; ++ j){
            if(arr[j] > arr[j+1]){
                int tmp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = tmp;
            }
        }
    }
}

优化:

1、记住最后一次的交换位置
因为每一趟的最后一次交换,证明这个位置之后的数据都是大于当前的。同时我们还可以通过标志来记录是否发生了交换,如果有一趟没有发生交换,说明序列已经有序。

代码如下:

void BubbleSort(int arr[], int size){
    int index = size;
    int flag = 1;
    int change = 1;
    for(int i = size; i > 0; -- i){
        for(int j = 0; j < index-1; ++ j){
            if(arr[j] > arr[j+1]){
                int tmp = arr[j];
                arr[j] = arr[j+1];
                arr[j+1] = tmp;
                change = j+1;
                flag = 0;
            }
        }
        index = change;
        if(flag){
            break;
        }
    }
}

2、改变冒泡的方向
因为之前冒泡的方向是固定的,但是如果出现了不对称的情况,有的时候反过来冒泡可以减少大量的时间。

比如:1 2 3 4 5 0;如果是从前向后冒泡,需要n-1次,但是如果是从后往前冒泡,只需要一次。反过来:5 0 1 2 3 4;如果从前向后只需要一次,从后向前需要n-1次。所以为了解决这种不对称性的问题,我们可以每走一趟,就将方向反过来冒泡。

如图(这种改进也称为鸡尾酒排序):

方向变换

代码如下:

void BubbleSortOP(int arr[], int size){
    int high = size - 1;
    int low = 0;
    while(low < high){
        bool flag = true;
        //从左向右
        for(int i = low; i < high; ++ i){
            if(arr[i] > arr[i+1]){
                int tmp = arr[i];
                arr[i] = arr[i+1];
                arr[i+1] = tmp;
                flag = false;
            }
        }
        if(flag){
            break;
        }
        -- high;
        //从右向左
        for(int i = high; i > low; -- i){
             if(arr[i] < arr[i-1]){
                int tmp = arr[i];
                arr[i] = arr[i-1];
                arr[i-1] = tmp;
                flag = false;
            }
        }
        if(flag){
            break;
        }
        ++ low;
    }
}

复杂度:

时间复杂度:平均复杂度:O(n²);最坏情况:O(n²);最好情况:O(n);

空间复杂度:O(1)

虽然我们进行了优化,但是总的来说,并没有很大的提升空间。对于完全逆序的序列,依然是最坏的情况。

快速排序

快速排序是冒泡的升级版本,我们冒泡的时候,是每次将最大值或最小值放入合适的位置。其实我们可以选准一个基准值,然后把比他小的放到左边,比他大的放到右边,这样我们就缩小的范围。即基准值左边的值一定比他小,右边的值一定比他大。然后利用这个性质,分为左右两个区间,这里使用了分治的思想。

快速排序思路(升序):

  • 从序列中选出一个基准值。
  • 将序列之后的每一个成员和基准值比较,实现比基准值小的在左边,大的在右边。这一趟结束之后,基准值位于两者之间。
  • 根据基准值的位置缩小区间,分为左边和右边区间,递归左右区间。
  • 一直到最后的区间只有一个元素或者区间左边大于右边结束。

如图:(上图是左右指针法,下图是前后指针法)

快速排序1
快速排序2

快速排序的方法:

一般来说,我们将快速排序分为三种方法,分别是挖坑法、左右指针法、前后指针法。其中左右指针法和挖坑法很相似,前后指针法有点绕。

挖坑法(此处选择基准值从最右端选取):

步骤如下:

  • 选取基准值,用变量保存,同时此处作为第一个坑。
  • 此时从最左端开始寻找第一个比基准值大或等于的元素,找到就将值填入第一个坑。将该位置作为第二个坑
  • 此时从右向左寻找第一个比基准值小或者等于的元素,找到就将值填入第二个坑。将该位置作为第三个坑。
  • 一直到左右指向同一个元素,将该值和基准值交换,同时划分左右区间,递归左右区间的元素。

挖坑法代码如下:

void QuickSort(int arr[], int low, int high){
    int key = arr[high];  //第一个坑处于high处

    if(low >= high){
        return;
    }
    int left = low;
    int right = high;

    while(left < right){
        while(left < right && arr[left] <= key){
            ++ left;
        }
        arr[right] = arr[left];  //将左边的元素填入右边的坑中
        while(left < right && arr[right] >= key){
            -- right;
        }
        arr[left] = arr[right];  //将右边的元素填入左边的坑中
    }
    arr[left] = key;  //将基准值填入最后一个坑中
    QuickSort(arr, low, left-1);
    QuickSort(arr, left+1, high);
}

左右指针法(选择最右元素为基准值):

步骤如下:

  • 从最左边开始找到第一个比基准值大的元素,然后再从最右边找到第一个比基准值小的元素。交换两者,此时完成第一次交换。
  • 只要左边的指针没有等于右边的指针,循环继续。
  • 一直到两者相等,此时指针指向的元素肯定比基准值大,因为是左边先走的,相等的时候肯定是大的。交换此值和基准值。
  • 划分区间,递归左右区间。

左右指针代码如下:

void QuickSort2(int arr[], int low, int high){
    int key = arr[high];

    if(low >= high){
        return;
    }
    int left = low;
    int right = high;

    while(left < right){
        while(left < right && arr[left] <= key){
            ++ left;
        }
        while(left < right && arr[right] >= key){
            -- right;
        }
        if(left != right){
            swap(arr[left], arr[right]);
        }
    }
    //此处交换的时候,不要和key交换,应该是序列中与基准值相同的值。也就是这里的最右元素。
    swap(arr[left], arr[high]);
    QuickSort2(arr, low, left-1);
    QuickSort2(arr, left+1, high);
}

前后指针法:

前后指针法的思想见下图:(前后指针法比较绕,如果不理解,可以画图验证,就懂了。)
前后指针法

前后指针法代码如下:

void QuickSort3(int arr[], int low, int high){
    if(low >= high){
        return;
    }
    int key = arr[high];
    int cur = low;
    int prev = cur-1;
    while(cur <= high){
        //cur找小于key的值,找到了停下。
        while(cur <= high && arr[cur] > key){
            ++ cur;
        }
        //cur跟++ prev的值进行比较,如果相同,证明没有拉开距离,就不需要交换
        //也就是cur的下一个就遇到了小于key的值,所以跟prev的距离为一
        if(++ prev != cur){
            swap(arr[prev], arr[cur]);
        }
        ++ cur;
    }
    QuickSort3(arr, low, prev-1);
    QuickSort3(arr, prev, high);
}

优化1:

快速排序是一个很优的算法,但是如果遇到某些特殊情况,基准值每次选到的都是最小或者最大的值,就会出现退化的情况。比如:使用左右指针法,基准值从最右选取,序列为升序。此时每次选到的基准值都是最大的,产生的两个区间,有一个必然为空,而且每次都需要遍历整个区间。这时候的时间复杂度就是O(n²)。

解决这个问题的一种方法是三数取中法。所谓三数取中法指的是,在序列中选出最左,中间,最右的三个位置的元素进行比较,然后取出值在中间的那个元素,跟基准值的选取值进行交换。比如三数取中法取出的中间元素是arr[mid],基准值的取值是arr[end],交换这两者。这样就不会出现每次选到的都是最大的元素值,或者最小的元素值了。

三数取中法代码如下:

int Mid(int *arr, int low, int high){
    int middle = low + (high-low)/2;
    if(arr[low] < arr[high]){
        if(arr[high] < arr[middle]){
            return high;
        }
        else if(arr[middle] < arr[low]){
            return low;
        }
        else{
            return middle;
        }
    }
    else{
        if(arr[low] < arr[middle]){
            return low;
        }
        else if(arr[middle] < arr[high]){
            return high;
        }
        else{
            return middle;
        }
    }
}

使用三数取中法的时候,只需要在选取key值得时候替换就可以,如下:

int index = Mid(arr, low, high);
swap(arr[index], arr[high]);
int key = arr[high];

这样我们的基准值就不会是一直是大的或者一直是小的这种情况,从而导致效率的大大降低。

优化2:

其实,当我们的数据处于比较小的情况,我们使用快速排序不如使用插入排序。因为我们划分子区间的时候,使用的是递归(当然可以使用非递归的形式,利用栈模拟)。十几个元素划分的递归有点多,这样是一种浪费。我们可以直接使用插入排序实现,可以减少栈桢的开销。
修改代码如下:

//这个小区间一般是10~20之间。
if(high - low > 15){
    //QuickSort();
}
else{
    //InsertSort();
}

复杂度:

快速排序的时间复杂度:平均情况和最好情况都是O(nlog₂n);最坏的情况是O(n²);

空间复杂度是:O(nlog₂n);

时间复杂度为什么是O(nlog₂n);因为递归的时间复杂度的计算是递归的深度*每次递归的复杂度。我们递归的深度是log₂n,因为我们每次都是取一半去进行下次递归,好比有n个元素,每次只看一半的元素,这样一直到只剩下一个元素,我们需要看多少次。也就是2的m次方等于n,求m是多少。这跟求二叉树的深度,二分查找等问题是一样的思路。然后每次递归我们都需要对当前区间的每一个元素进行比较,复杂度是O(n)。所以最后的时间复杂度是O(nlog₂n);至于最坏的情况,是因为快速排序退化成了冒泡排序。

时间复杂度的简易分析

选择排序

选择排序

选择排序的思路(升序)

  • 顾名思义,通过选择获得最小的元素,将其放入到序列起始位置。
  • 将剩下的元素以此类推。
  • 直到结束。

如图:

选择排序

代码如下:

void SelectSort(int arr[], int size){
    int min = 0;
    for(int i = 0; i < size; ++ i){
        min = i;
        for(int j = i + 1; j < size; ++ j){
            if(arr[j] < arr[min]){
                min = j;
            }
        }
        if(min != i){
            swap(arr[min], arr[i]);
        }
    }
}

优化

之前的选择排序只是从前向后排序,每次只排序一个最大值。我们可以同时排序最小值和最大值。这样当元素有n个的时候,我们可以优化最坏情况为循环n/2次。但是时间复杂度并没有减少,数量级还在同一个级别。

void SelectSortOP(int arr[], int size){
    int begin = 0;
    int end = size - 1;
    for(; begin < end; ++ begin, -- end){
        int min = begin;
        int max = begin;
        for(int i = begin; i <= end; ++ i){
            if(arr[max] < arr[i]){
                max = i;
            }
            if(arr[min] > arr[i]){
                min = i;
            }
        }
        if(max != end){
            swap(arr[max], arr[end]);
        }
        //防止min在end,max在begin。
        //然后出现交换两次的情况,这样就等于没有交换
        if(end == min){
            min = max;
        }
        if(min != begin){
            swap(arr[min], arr[begin]);
        }
    }
}

复杂度:

时间复杂度:最好、最坏、平均的时间复杂度都是O(n²);

空间复杂度:O(1);

堆排序

堆排序的思路(升序):

  • 创建最大堆
  • 开始堆排序:从堆顶获取最大值,取出来,跟序列的最后一个元素交换,这样就让最大的值处于序列的最后面。
  • 进行堆调整,让他重新为最大堆。
  • 一直到所有的值都被处理。

如图:

堆排序

关于堆的知识点如下:
堆的详细解释

代码如下:

void Down(int arr[], int index, int size){
    int parent = index;
    int child = parent*2 + 1;
    while(child < size){
        if(child+1 < size && arr[child] < arr[child+1]){
            ++ child;
        }
        if(arr[child] > arr[parent]){
            swap(arr[child], arr[parent]);
            parent = child;
            child = parent*2 + 1;
        }
        else{
            break;
        }
    }
}

void HeadSort(int arr[], int size){
    //建堆
    //为什么是size-1-1,因为size-1指的是最后一个的下标。
    //再然后完全二叉树的最后一个父节点的计算式为最后一个孩子节点-1除于2。
    for(int i = (size-1-1)/2; i >= 0; -- i){
        Down(arr, i, size);
    }
    int end = size - 1;
    while(end > 0){
        swap(arr[0], arr[end]);
        //交换完之后,需要重新调整为最大堆,但是最需要调整一次,
        //因为此时只有堆顶元素不符合大堆的原则。
        Down(arr, 0, end);
        -- end;
    }
}

复杂度:

时间复杂度:平均、最好、最坏的情况都是O(nlog₂n);

空间复杂度:O(1);

时间复杂度分析:堆排序的消耗主要在于初始化创建堆和每次调整堆。创建堆的时间复杂度是:O(n);调整堆的时间复杂度是:O(nlog₂n);两者相加得到最终的时间复杂度为O(nlog₂n);

堆排序的时间复杂度分析

插入排序

直接插入排序

插入排序的思路(升序):

  • 从序列的最左边选取第一个元素,以它为基准,从第二个开始跟第一个比较。
  • 如果大,向前一个元素比较,一直到比较比前面一个小,插入到他的后面。
  • 依次循环。

如图:
插入排序

插入排序代码如下:

void InsertSort(int arr[], int size){
    for(int i = 0; i < size-1; ++ i){
        int j = i+1;
        //记录下来当前值
        int tmp = arr[j];
        while(j > 0){
            if(tmp < arr[j-1]){
                arr[j] = arr[j-1];
                -- j;
            }
            else{
                break;
            }
        }
        //将当前值赋给插入的位置
        arr[j] = tmp;
    }
}

优化

这个优化的方式叫做折半插入排序。也就是我们在进行插入排序的时候,进行到当前的值,说明前面区间肯定已经有序。所以就没有必要依次向前寻找,我可以通过二分查找的方式快速定位。但是实际上的时间复杂度并没有降低,而是稍微的提升了一些效率。

代码如下:

//二分查找函数,如果arr中有跟key相同的元素,返回该元素的最后一个下标
//如果没有,返回最后一个小于key的下一个下标。
int BinarySearch(int arr[], int size, int key){
    int low = 0;
    int high = size - 1;
    //等于不能少
    while(low <= high){
        int mid = low + (high-low)/2;
        if(arr[mid] < key){
            low = mid+1;
        }
        else if(arr[mid] > key){
            high = mid-1;
        }
        else{
            int next = mid+1;
            while(next < size && arr[next] == arr[mid]){
                ++ next;
                ++ mid;
            }
            return next;
        }
    }
    return low;
}

void InsertSortBinary(int arr[], int size){
    for(int i = 0; i < size-1; ++ i){
        int j = i+1;
        int tmp = arr[j];
        if(tmp < arr[j-1]){
            int index = BinarySearch(arr, j, tmp);
            while(j > index){
                arr[j] = arr[j-1];
                -- j;
            }
            arr[index] = tmp;
        }
    }
}

复杂度:

时间复杂度:平均、最坏情况是:O(n²);最好的情况是:O(n);

空间复杂度:O(1);

最好情况的时间复杂度是有序的,因为不需要交换,只需要遍历一遍。最坏和平均情况的时间复杂度是逆序的情况,遍历一遍序列的同时,每一个都需要一直到序列的第一个进行插入,相当于二次遍历。

希尔排序

希尔排序是针对于直接插入排序的一种优化方案。因为直接插入排序的最坏情况时间复杂度为O(n²),我们通过取间隔的方法,让大数据很快的从最前面移动到后面,不像直插一样,移动n个。从而减小交换的次数。

希尔排序的思路(升序):

  • 选取步长,根据步长划分区间。同时保证步长最后可以为一。
  • 交换间隔步长的元素,利用插入排序。
  • 每次进行完一趟排序,将步长减小,直到步长为一。此时进行的就是直接插入排序。

希尔排序

代码如下:

void ShellSort(int arr[], int size){
    int space = size;
    int low = 0;

    //space最后一直都是1
    //当space为1的时候,进行的就是插入排序。只是此时的序列已经基本有序,大的成员都在后面。
    while(space > 1){
        space = space / 3 + 1;
        //让每一个元素都进行了排序。
        for(int front = 0; front < size-space; ++ front){
            low = front;
            int tmp = arr[low+space];
            //跳跃式的插入排序,进行排序的元素之间隔了一个space的大小。
            while(low >= 0){
                if(arr[low] > tmp){
                    arr[low+space] = arr[low];
                    low -= space;
                }
                else{
                    break;
                }
            }
            arr[low+space] = tmp;
        }
    }
}

复杂度:

时间复杂度:平均情况O(n^(1.3));最好情况O(n);最坏情况O(n²);

空间复杂度:O(1);

在这里来说,希尔排序的时间复杂度不好算,需要步长决定。希尔排序的最坏情况就是当序列有序的情况下。

归并排序

归并排序

归并排序的思路:

  • 首先先执行划分区间,一直划分到区间只有一个元素为止。
  • 在划分的区间内进行比较排序。
  • 一直到结束为止。

归并排序

归并排序代码如下:

归并函数有两种实现方式,递归是一种,非递归是一种。递归版本需要两个函数,一个用于递归划分区间,另一个用于两个区间的合并排序。这里需要一个辅助空间,防止将原来的数据被覆盖。非递归版本利用迭代

//递归版本
void Merge(int arr[], int* tmp, int left1, int right1, int left2, int right2){
    int i = left1;
    int front = left1;
    int end = right2;
    while(left1 <= right1 && left2 <= right2){
        if(arr[left1] <= arr[left2]){
            tmp[i++] = arr[left1++];
        }
        else if(arr[left1] > arr[left2]){
            tmp[i++] = arr[left2++];
        }
    }
    while(left1 <= right1){
        tmp[i++] = arr[left1++];
    }
    while(left2 <= right2){
        tmp[i++] = arr[left2++];
    }

    while(front <= end){
        arr[front] = tmp[front];
        front ++;
    }
}

void MergeSort(int arr[], int* tmp, int left, int right){
    if(left >= right){
        return;
    }
    int mid = left + (right - left) / 2;

    MergeSort(arr, tmp, left, mid);
    MergeSort(arr, tmp, mid+1, right);
    Merge(arr, tmp, left, mid, mid+1, right);
}

int main(){
    int arr[] = {1, 3, 5, 3, 2, 8, 6, 0};
    int *tmp = new int[sizeof(arr)/sizeof(arr[0])];
    Print(arr, sizeof(arr)/ sizeof(arr[0]));
    MergeSort(arr, tmp, 0, sizeof(arr)/sizeof(arr[0])-1);
    Print(arr, sizeof(arr)/ sizeof(arr[0]));
    delete[] tmp;
    return 0;
}

//非递归版本
//来源于网路
void MergeSortNoR(int *list, int length){
    int i, left_min, left_max, right_min, right_max, next;
    int *tmp = (int*)malloc(sizeof(int) * length);

    for (i = 1; i < length; i *= 2)
            for (left_min = 0; left_min < length - i; left_min = right_max){
                    right_min = left_max = left_min + i;
                    right_max = left_max + i;
                    if (right_max > length)
                            right_max = length;
                    next = 0;
                    while (left_min < left_max && right_min < right_max)
                            tmp[next++] = list[left_min] > list[right_min] ? list[right_min++] : list[left_min++];
                    while (left_min < left_max)
                            list[--right_min] = list[--left_max];
                    while (next > 0)
                            list[--right_min] = tmp[--next];
            }
    free(tmp);
}

优化

归并排序的优化,第一,使用递归版本的时候,我们可以在当成员个数比较少的时候,转换为使用插入排序,这样可以减小栈桢的开销。第二,不使用递归版本,使用迭代版本。

复杂度:

时间复杂度:平均、最好、最坏情况都是:O(nlog₂n)

空间复杂度:O(n)或者是O(1);

时间复杂度一直都是O(nlog₂n);因为不管是什么情况都是要采取一样的步骤,不因为成员是否有序而改变。空间复杂度是因为使用了辅助空间。如果我们不使用辅助空间,直接在源数据空间内交换的话,就是O(1)。

分布排序

基数排序

基数排序其实是桶排序的扩展,对于整形来说,基数排序首先先从个位开始,根据个位的值大小放入指定的数组中,数组的大小是十,下表表示值。然后过渡到十位、百位。这样依次放入取出排序,就可以了。

基数排序

计数排序

计数排序适合于大于零并且数据之间都比较相近的情况,当确定数据的大小,开辟一个以最大值为下标的数组。将数组初始化为全0;然后依次遍历序列让数组对应的下标的值加一。最后将数组从头到尾扫描,数据是多少代表对应下标有多少个。

计数排序

复杂度、稳定性集合

总结

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值