面试官问:排序算法都有哪些?你写几个出来?

开发 前端
插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。

图片图片

稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面;

不稳定:如果a原本在b的前面,而a=b,排序之后a可能会出现在b的后面;

内排序:所有排序操作都在内存中完成;

外排序:由于数据太大,因此把数据放在磁盘中,而排序通过磁盘和内存的数据传输才能进行;

时间复杂度: 一个算法执行所耗费的时间。

空间复杂度: 运行完一个程序所需内存的大小。

冒泡排序

冒泡排序是最简单直观的排序方式,通过比较前后两个元素的大小,然后交换位置来实现排序。

每次比较相邻两个数的大小,如果前面的数大于后面的数,则交换两个数的位置(否则不变),向后移动。

// 冒泡排序
func BubbleSort(arr []int){
    n := len(arr)
    for i:=0; i<n-1; i++{
        for j:=i+1; j<n; j++{
            if arr[i] > arr[j]{
                arr[i], arr[j] = arr[j], arr[i]
            }
        }
    }
    fmt.Println(arr)
}

改进冒泡排序:

冒泡排序第1次遍历后会将最大值放到最右边,这个最大值也是全局最大值。

同理,当前轮的最大值也都会放在最后,每轮结束后,最大值、次大值。。。都会固定,但是普通版冒泡排序每次都会比较全部元素。可以记录每轮比较后最后一个位置,也可以逆序遍历。

// 改进的冒泡排序
func BubbleSort2(arr []int){
    n := len(arr)
    for i:=n-1; i>0; i--{    // 逆序遍历
        for j:=0; j<i; j++{
            if arr[j] > arr[j+1]{
                arr[j], arr[j+1] = arr[j+1], arr[j]
            }
        }
    }
    fmt.Println(arr)
}

选择排序

选择排序是一种简单直观的排序算法,无论什么数据进去都是 O(n²) 的时间复杂度。所以用到它的时候,数据规模越小越好。唯一的好处可能就是不占用额外的内存空间了吧。

算法步骤:

  • 初始序列arr,无序。分成有序区和无序区,有序区初始为0,不断变大;无序区初始为len(arr),不断变小。
  • 遍历无序找到最小值,与无序区最左边交换。有序区长度+1。
  • 重复第二步。
// 选择排序
func SelectionSort(arr []int){
    n := len(arr)
    for i:=0; i<n-1; i++{
        minNumIndex := i    // 无序区第一个
        for j:=i+1; j<n; j++{
            if arr[j] < arr[minNumIndex]{
                minNumIndex = j
            }
        }
        arr[i], arr[minNumIndex] = arr[minNumIndex], arr[i]
    }
    fmt.Println(arr)
}

插入排序

插入排序的代码实现虽然没有冒泡排序和选择排序那么简单粗暴,但它的原理应该是最容易理解的了,因为只要打过扑克牌的人都应该能够秒懂。

插入排序是一种最简单直观的排序算法,它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。

插入排序和冒泡排序一样,也有一种优化算法,叫做拆半插入。

将第一待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当成是未排序序列。

从头到尾依次扫描未排序序列,将扫描到的每个元素插入有序序列的适当位置。(如果待插入的元素与有序序列中的某个元素相等,则将待插入元素插入到相等元素的后面。)

// 插入排序
func InsertionSort(arr []int){
    for i := range arr{
        preIndex := i-1
        current := arr[i]

        for preIndex >= 0 && arr[preIndex] > current{  // 移动
            arr[preIndex+1] = arr[preIndex]
            preIndex--
        }

        arr[preIndex+1] = current
    }
    fmt.Println(arr)
}

改进插入排序: 查找插入位置时使用二分查找的方式

// 改进版插入排序
func InsertionSort2(arr []int){
    n := len(arr)
    for i:=1; i<n; i++{   // 无序区
        tmp := arr[i]
        left, right := 0, i-1
        for left<=right{
            mid := (left+right)/2
            if arr[mid] > tmp{
                right = mid-1
            }else{
                left = mid+1
            }
        }
        j:=i-1
        for ; j>=left; j--{   // 有序区
            arr[j+1] = arr[j]
        }
        arr[left] = tmp
    }
    fmt.Println(arr)
}

希尔排序

希尔排序又称递减增量排序、缩小增量排序,是简单插入排序的改进版,但是是非稳定算法。

希尔排序是基于插入排序的以下两点性质而提出改进方法的:

  • 插入算法在对几乎已经排好序的数据操作时,效率高,即可达线性排序效率
  • 但插入排序一般来说是低效的,因为每次只能将数据移动一位

希尔排序的基本思想是:先将整个待排序列分割成若干个子序列,对若个子序列分别进行插入排序,待整个待排序列基本有序时,对整体进行插入排序。

算法步骤:

  • 选择一个增量序列t1,t2,…,tk,其中ti>ti+1,tk=1;
  • 按增量序列个数k,对序列进行k 趟排序;
  • 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

其实是两个两个换位置,将整个序列变成基本排好序的

图片图片

// 希尔排序
func ShellSort(arr []int){
    n := len(arr)
    for gap:=n/2; gap>=1; gap=gap/2{   // 缩小增量序列,希尔建议每次缩小一半
        for i:=gap; i<n; i++{       // 子序列
            tmp := arr[i]
            j:=i-gap
            for ; j>=0 && tmp<arr[j]; j=j-gap{
                arr[j+gap] = arr[j]
            }
            arr[j+gap] = tmp
        }
    }
    fmt.Println(arr)
}

归并排序

归并排序(Merge sort)是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。

作为一种典型的分而治之思想的算法应用,归并排序的实现由两种方法:

  • 自上而下的递归(所有递归的方法都可以用迭代重写,所以就有了第 2 种方法);
  • 自下而上的迭代;

递归版:

// 归并排序--递归版
func MergeSort(arr []int) []int{
    n := len(arr)
    if n < 2{
        return arr
    }
    mid := n/2
    left := arr[:mid]
    right := arr[mid:]
    return merge(MergeSort(left), MergeSort(right))

}
func merge(left, right []int) []int{
    res := []int{}
    for len(left)!=0 && len(right)!=0{
        if left[0] <= right[0]{
            res = append(res, left[0])
            left = left[1:]     // 将头一个直接切出去
        }else {
            res = append(res, right[0])
            right = right[1:]
        }
    }
    if len(left) == 0{      // left结束,right剩下的直接拖下来
        res = append(res, right...)
    }
    if len(right) == 0{     // right结束,left剩下的直接拖下来
        res = append(res, left...)
    }
    return res
}

迭代版:

// 归并排序--迭代版
func MergeSort2(arr []int) []int{
    n := len(arr)
    min := func(a, b int) int{
        if a < b{
            return a
        }
        return b
    }
    for step:=1; step<=n; step<<=1{     // 外层控制步长
        offset := 2*step
        for i:=0; i<n; i+=offset{       // 内层控制分组
            h2 := min(i+step, n-1)      // 第二段头部,防止超过数组长度
            tail2 := min(i+offset-1, n-1)   // 第二段尾部
            merge2(arr, i, h2, tail2)
        }
    }
    return arr
}

func merge2(arr []int, h1 int, h2 int, tail2 int){
    start := h1
    tail1 := h2-1   // 第一段尾部
    length := tail2-h1+1    // 两段长度和
    tmp := []int{}
    for h1 <= tail1 || h2 <= tail2{   // 其中一段未结束
        if h1 > tail1 {     // 第一段结束,处理第二段
            tmp = append(tmp, arr[h2])
            h2++
        }else if h2 > tail2{    // 第二段结束,处理第一段
            tmp = append(tmp, arr[h1])
            h1++
        }else {     // 两段都未结束
            if arr[h1] <= arr[h2]{
                tmp = append(tmp, arr[h1])
                h1++
            }else {
                tmp = append(tmp, arr[h2])
                h2++
            }
        }
    }

    for i:=0; i<length; i++{    // 将排序好两段合并写入arr
        arr[start+i] = tmp[i]
    }
}

快速排序

快速排序是东尼-霍尔发展来的一种排序算法。 平均状态下,排序n个项目要做O(nlogn)次比较,最坏情况下,要做O(n2)次比较,但是比较少见。

事实上,快速排序通常明显比其他O(nlogn)算法速度要快,因为它的内部循环能够再大部分框架上很有效地被实现出来。

快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。

为什么快速排序比其他O(nlogn)排序算法快呢?

快速排序的最坏运行情况是 O(n²),比如说顺序数列的快排。但它的平摊期望时间是 O(nlogn),且 O(nlogn) 记号中隐含的常数因子很小,比复杂度稳定等于 O(nlogn) 的归并排序要小很多。所以,对绝大多数顺序性较弱的随机数列而言,快速排序总是优于归并排序。

算法步骤:

  • 选择一个基准。
  • 将比基准小的数放到基准左边,比基准大的数放到基准右边(相同的数可以放在任意一边)在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
  • 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
func QuickSort(arr []int) []int{
    return _QuickSort(arr, 0, len(arr)-1)
}

func _QuickSort(arr []int, left int, right int) []int{
    if left < right{
        partitionIndex := Partition1Way(arr, left, right)
        // partitionIndex := Partition2Way(arr, left, right)
        _QuickSort(arr, left, partitionIndex-1)
        _QuickSort(arr, partitionIndex+1, right)
    }
    return arr
}

单路快排:从左向右遍历

// 快速排序--单路
func Partition1Way(arr []int, left int, right int) int{
    // 先分区,最后把基准换到边界上
    privot := left
    index := privot + 1
    for i := index; i<=right; i++{
        if arr[privot] > arr[i]{  // 当前值小于基准就交换,大于的不用管
            arr[index], arr[i] = arr[i], arr[index]
            index++   // 交换后的下一个
        }
    }
    // arr[index]是大于基准的
    arr[privot], arr[index-1] = arr[index-1], arr[privot]
    return index-1
}

双路快排:双指针从首尾向中间移动

// 快速排序--双路版
func Partition2Way(arr []int, low int, high int) int{
    tmp := arr[low]     // 基准
    for low < high{
        // 当队尾的元素大于等于基准数据时,向前挪动high指针
        for low < high && arr[high] >= tmp{
            high--
        }
        // 如果队尾元素小于tmp了,需要将其赋值给low
        arr[low] = arr[high]
        // 当队首元素小于等于tmp时,向前挪动low指针
        for low < high && arr[low] <= tmp{
            low++
        }
        // 当队首元素大于tmp时,需要将其赋值给high
        arr[high] = arr[low]

    }
    // 跳出循环时low和high相等,此时的low或high就是tmp的正确索引位置
    // 由原理部分可以很清楚的知道low位置的值并不是tmp,所以需要将tmp赋值给arr[low]
    arr[low] = tmp
    return low
}

三路排序:分成小于区、等于区、大于区,不对等于区进行递归操作

// 快速排序--三路
func QuickSort3Way(arr []int) []int{
    // 确定分区位置
    return _QuickSort3Way(arr, 0, len(arr)-1)
}
func _QuickSort3Way(arr []int, left int, right int) []int{
    if left < right{
        lo, gt := Partition3Way(arr, left, right)
        _QuickSort3Way(arr, left, lo-1)
        _QuickSort3Way(arr, gt, right)
    }
    return arr
}
func Partition3Way(arr []int, left, right int) (int, int){
    key := arr[left]
    lo, gt, cur := left, right+1, left+1  // lo和gt是相等区左右边界
    for cur < gt{
        if arr[cur] < key{  // 小于key,移到前面
            arr[cur], arr[lo+1] = arr[lo+1], arr[cur]   // lo+1,保证最后arr[lo]小于key
            lo++    // 左边界右移
            cur++   // 能够确定换完之后该位置值小于key,
        }else if arr[cur] > key{
            arr[cur], arr[gt-1] = arr[gt-1], arr[cur]
            gt--    // 从后面换到前面,不知道是否比key的大,还要再比一下,所以cur不移动
        }else {
            cur++
        }
    }
    arr[left], arr[lo] = arr[lo], arr[left]   // 最后移动基准,arr[lo]一定比key小
    return lo, gt
}

堆排序

堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。

堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。堆排序可以说是一种利用堆的概念来排序的选择排序。

分为两种方法:

大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列;

小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列;

堆排序的平均时间复杂度为 Ο(nlogn)。

  • 创建一个堆 H[0……n-1]
  • 把堆首(最大值)和堆尾互换;
  • 把堆的尺寸缩小 1,并调用 shift_down(0),目的是把新的数组顶端数据调整到相应位置;
  • 重复步骤 2,直到堆的尺寸为 1。
// 堆排序
func HeapSort(arr []int) []int{
    arrLen := len(arr)
    BuildMaxHeap(arr, arrLen)   // 初始化大顶堆
    for i:=arrLen-1; i>=0; i--{
        swap(arr, 0, i)    // 交换根节点和最后一个节点
        arrLen--
        heapify(arr, 0, arrLen)
    }
    return arr
}

func BuildMaxHeap(arr []int, arrLen int){
    for i:=arrLen/2; i>=0; i--{
        heapify(arr, i, arrLen)
    }
}

func heapify(arr []int, i, arrLen int){
    left := 2*i+1       // 左子
    right := 2*i+2      // 右子
    largest := i        // 当前最大值位置
    if left<arrLen && arr[left]>arr[largest]{
        largest = left
    }
    if right<arrLen && arr[right]>arr[largest]{
        largest = right
    }
    if largest != i{
        swap(arr, i, largest)   // 交换
        heapify(arr, largest, arrLen)  // 调整二叉树
    }
}

func swap(arr []int, i, j int) {
    arr[i], arr[j] = arr[j], arr[i]
}

计数排序

计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。

作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

计数排序的特征

当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是 Θ(n + k)。计数排序不是比较排序,排序的速度快于任何比较排序算法。

由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。

例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。

通俗地理解,例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位,用这个方法可以得到其他每个人的位置,也就排好了序。

当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去 1 的原因。

算法的步骤如下:

1)找出待排序的数组中最大和最小的元素

2)统计数组中每个值为i的元素出现的次数,存入数组C的第i项

3)对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)

4)反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1

// 计数排序
func CountingSort(arr []int) []int{
    length := len(arr)
    maxValue := getMaxValue(arr)
    bucketLen := maxValue+1
    bucket := make([]int, bucketLen)
    sortedIndex := 0
    // 统计每个元素出现的个数
    for i:=0; i<length; i++{
        bucket[arr[i]] += 1
    }
    // 按照统计结果写入arr
    for j:=0; j<length; j++{
        for bucket[j] > 0{
            arr[sortedIndex] = j   // bucket[j]的值是统计结果,后面会变化,j是真正值
            sortedIndex++
            bucket[j]--
        }
    }
    return arr
}
// 获得数组的最值差
func getMaxValue(arr []int) int{
    largest := math.MinInt32
    smallest := math.MaxInt32
    for i:=0; i<len(arr);i++{
        if arr[i] > largest{
            largest = arr[i]
        }
        if arr[i] < smallest{
            smallest = arr[i]
        }
    }
    maxValue := largest-smallest
    return maxValue
}

桶排序

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。

为了使桶排序更加高效,我们需要做到这两点:

1 在额外空间充足的情况下,尽量增大桶的数量

2 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

  • 什么时候最快

当输入的数据可以均匀的分配到每一个桶中。

  • 什么时候最慢

当输入的数据被分配到了同一个桶中。

示意图:

图片图片

// 桶排序
func BucketSort(arr []int, bucketSize int) []int{
    // 获得arr的最值
    length := len(arr)
    maxNum, minNum := arr[0], arr[0]
    for i:=0; i<length; i++{
        if arr[i]>maxNum{
            maxNum = arr[i]
        }else if arr[i]<minNum{
            minNum = arr[i]
        }
    }
    maxValue := maxNum - minNum
    // 初始化桶
    bucketNum := maxValue/bucketSize + 1  // 桶个数
    buckets := make([][]int, bucketNum)
    for i:=0; i<bucketNum; i++{
        buckets[i] = make([]int, 0)
    }
    // 利用映射将元素分配到每个桶中
    for i:=0; i<len(arr); i++{
        id := (arr[i]-minNum)/bucketSize   // 桶ID
        buckets[id] = append(buckets[id], arr[i])
    }
    // 对每个桶进行排序,然后按顺序将桶中数据放入arr
    arrIndex := 0
    for i:=0; i<bucketNum; i++{
        if len(buckets[i]) == 0{   // 空桶
            continue
        }
        InsertionSort2(buckets[i])    // 桶内排序
        for j:=0; j<len(buckets[i]); j++{   // 将每个桶的排序结果写入arr
            arr[arrIndex] = buckets[i][j]
            arrIndex++
        }
    }
    return arr
}

基数排序

基数排序是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。

由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

基数排序 vs 计数排序 vs 桶排序

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

基数排序:根据键值的每位数字来分配桶;

计数排序:每个桶只存储单一键值;

桶排序:每个桶存储一定范围的数值;

思路

基数排序是按照低位先排序,然后收集;

再按照高位排序,然后再收集;

依次类推,直到最高位。

有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。

最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。

图片图片

// 基数排序
func RadixSort(arr []int, ) []int{
    maxn := maxBitNum(arr)      // arr最大位数
    dev := 1    // 除数,保证商最后一位是我们想要的
    mod := 10   // 模,取商的最后一位
    for i:=0; i<maxn; i++{      // 进行maxn次排序
        bucket := make([][]int, 10) // 定义10个空桶
        result := make([]int, 0)    // 存储中间结果
        for _, v := range arr{
            n := v / dev % mod  // 取出对应位的值,放入对应桶中
            bucket[n] = append(bucket[n], v)
        }
        dev *= 10
        // 按顺序存入中间数组
        for j:=0; j<10; j++{
            result = append(result, bucket[j]...)
        }
        // 转存到原数组(结果)
        for k := range arr{
            arr[k] = result[k]
        }
    }
    return arr
}
// 获取数组的最大位数
func maxBitNum(arr []int) int{
    ret := 1
    count := 10
    for i:=0; i<len(arr); i++{
        for arr[i]>count{   // 对arr变化会修改内存里的值
            count *= 10     // 所以这里对count进行放大
            ret++
        }
    }
    return ret
}

总结

按时间复杂度分类:

  • O(n2):冒泡排序、选择排序、插入排序
  • O(nlogn):希尔排序、归并排序、快速排序、堆排序
  • O(n):计数排序、桶排序、基数排序

按稳定性分类

  • 稳定:冒泡排序、插入排序、归并排序、计数排序、桶排序、基数排序
  • 不稳定:选择排序、希尔排序、快速排序、堆排序

按排序方式

  • In-Place:冒泡排序、选择排序、插入排序、希尔排序、快速排序、堆排序
  • Out-Place:归并排序、计数排序、桶排序、基数排序
责任编辑:武晓燕 来源: Go语言圈
相关推荐

2021-09-30 07:57:13

排序算法面试

2023-02-20 08:08:48

限流算法计数器算法令牌桶算法

2024-06-04 07:38:10

2024-08-19 09:13:02

2021-12-02 18:20:25

算法垃圾回收

2024-04-19 00:00:00

计数器算法限流算法

2023-08-02 08:48:11

C#碟片算法

2022-11-04 08:47:52

底层算法数据

2020-10-08 14:15:15

Zookeeper

2015-08-13 10:29:12

面试面试官

2021-12-25 22:31:10

MarkWord面试synchronize

2021-11-08 09:18:01

CAS面试场景

2019-12-25 11:22:19

负载均衡集群算法

2023-09-21 15:20:49

算法开发

2021-05-11 21:56:11

算法清除JVM

2024-07-26 08:10:10

2021-12-16 18:38:13

面试Synchronize

2010-08-23 15:06:52

发问

2021-01-06 05:36:25

拉链表数仓数据

2022-01-05 09:55:26

asynawait前端
点赞
收藏

51CTO技术栈公众号