[算法] Golang 时间复杂度 O(n²) 级排序算法

179 阅读3分钟

写在前面

开山篇的快速排序写完,我觉得这样一个一个写过于零碎且不好比较,我们选择某个排序,最主要的还是看其排序效率,因此接下来的排序我就按照时间复杂度来归类,便于理解

冒泡排序

冒泡排序是入门级的算法,但也有一些有趣的玩法。通常来说,冒泡排序有三种写法:

  • 一边比较一边向后两两交换,将最大值 / 最小值冒泡到最后一位;

  • 经过优化的写法:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生交换表示已经有序,不再继续排序;

  • 进一步优化的写法:除了使用变量记录当前轮次是否发生交换外,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较。

第一种写法

func BubbleSort(arr []int, a, b int) []int {
   for i := a; i < b; i++ {
      for j := a; j < b-1-i; j++ { //-i是因为每次排序都能确定一位最大的值
         if arr[j] > arr[j+1] {
            arr[j], arr[j+1] = arr[j+1], arr[j]    //交换
         }
      }
   }
   return arr
}

第二种写法

第二种写法是在第一种写法的基础上改良来的

func BubbleSort(arr []int) {
   changed := true    //初始时changed为true,否则排序无法进行
   for i := 0; i < len(arr)-1; i++ {
      //如果没有发生交换,说明剩余部分已经有序,排序完成
      if !changed {break}
      //设置changed值为false,如果发生变化,再将其置为true
      changed = false
      for j := 0; j < len(arr)-1; j++ {
         if arr[j] > arr[j+1] {
            //交换
            arr[j], arr[j+1] = arr[j+1], arr[j]
            changed = true  //表示进行了交换
         }
      }
   }
}

最外层的 for 循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位。这种写法相对于第一种写法的优点是:如果一轮比较中没有发生过交换,则立即停止排序,因为此时剩余数字一定已经有序了。

第三种写法

func BubbleSort(arr []int) {
   changed := true
   LastUnSortIndex := len(arr) - 1 //最后一个没有经过排序的元素下标
   changedIndex := -1              //上次发生交换的位置
   for changed {
      changed = false
      for i := 0; i < LastUnSortIndex; i++ {
         if arr[i] > arr[i+1] {
            arr[i], arr[i+1] = arr[i+1], arr[i]
            changed = true
            changedIndex = i //更新交换的位置
         }
      }
      //最后一个没有经过排序的元素下标就是最后一次发生交换的位置
      LastUnSortIndex = changedIndex
   }
}

最外层的for循环每经过一轮,剩余数字中的最大值仍然是被移动到当前轮次的最后一位,在下一轮进行比较时,只需要比较到上一轮比较中,最后一次发生交换的位置即可,因为后面的元素都没有发生交换,必然是有序的了,当一轮比较中从头到尾都没有发生交换,则整个列表已经有序

选择排序

选择排序的思想是:双重循环遍历数组,每经过一轮比较,找到最小元素的下标,将其交换至首位。

func SelectSort(arr []int) {
   var minIndex int //最小值的位置
   for i := 0; i < len(arr); i++ {
      //从剩余元素中找到最小元素的下标
      //每次循环都将最小值的位置置为i
      minIndex = i
      for j := i + 1; j < len(arr); j++ {
         if arr[j] < arr[minIndex] {
            minIndex = j
         }
      }
      //交换
      arr[i], arr[minIndex] = arr[minIndex], arr[i]
   }
}

选择排序是我认为最好理解的排序方法。选择排序就好比第一个数字站在擂台上,大吼一声:“还有谁比我小?”。剩余数字来挨个打擂,如果出现比第一个数字小的数,则新的擂主产生。每轮打擂结束都会找出一个最小的数,将其交换至首位。经过 n-1 轮打擂,所有的数字就按照从小到大排序完成了。

二元选择排序

选择排序算法也是可以进行优化的,既然每轮找出了最小值,呢呢更不能同时将最大值也找出来?这就是二元选择排序的思想

func SelectSort(arr []int) {
   minIndex, maxIndex := 0, 0
   //i只需要遍历一半
   for i := 0; i < len(arr)/2; i++ {
      minIndex, maxIndex = i, i
      for j := i + 1; j < len(arr); j++ {
         if arr[minIndex] > arr[j] {
            minIndex = j //记录最小值的下标
         }
         if arr[maxIndex] < arr[j] {
            maxIndex = j //记录最大值的下标
         }
      }
      //如果minIndex 和 maxIndex相等,那么他们必定都等于i且后面的所有数字都与 arr[i] 相等,此时已经排序完成
      if minIndex == maxIndex {
         break
      }
      //将最小元素交换至首位
      arr[minIndex], arr[maxIndex] = arr[maxIndex], arr[minIndex]
      //如果最大值的下标刚好是i,由于arr[i]和arr[minIndex]已经交换,所以这里要更新maxIndex的值
      if maxIndex == i {
         maxIndex = minIndex
      }
      lastIndex := len(arr)-1-i
      arr[maxIndex], arr[lastIndex] = arr[lastIndex], arr[maxIndex]
   }
}

我们使用 minIndex 记录最小值的下标,maxIndex 记录最大值的下标。每次遍历后,将最小值交换到首位,最大值交换到末尾,就完成了排序。

由于每一轮遍历可以排好两个数字,所以最外层的遍历只需遍历一半即可。

插入排序

插入排序的思想非常简单,生活中有一个很常见的场景:在打扑克牌时,我们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手上已有的牌中合适的位置,逐渐完成整个排序。 插入排序有两种写法:

  • 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置。

  • 移动法:在新数字插入过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当新数字找到自己的位置后,插入一次即可。

交换法插入排序

func insertSort(arr []int) {
   //从第二个数字开始,往前插入数字
   for i:=1; i<len(arr); i++ {
      //j记录当前数字的下标
      j := i
      //当前数字比前一个数字小,就交换
      for j>=1 && arr[j] < arr[j-1] {
         arr[j], arr[j-1] = arr[j-1], arr[j]
         j--
      }
   }
}

当数字少于两个时,不存在排序问题,当然也需要插入,我们直接从第二个数字往前插入

移动法插入排序

所谓移动法,就是让新插入的数字先进行比较,前面比他大的数字不断向后移动,直到找到适合这个新数字的位置后,新数字只做一次插入操作即可,这种方案需要我们将新插入的数字保存起来

//插入排序(有序序列的排序)
func InsertSort(arr []int) {
   //任何数组,下标为0的元素都是有序的
   var preIndex int //前驱位置
   for i := 0; i < len(arr); i++ {
      preIndex = i - 1
      tmp := arr[i]                              //临时存储当前位置的数值
      for preIndex >= 0 && arr[preIndex] > tmp { //需要交换的情况,tmp不能用arr[i]代替,因为arr[i]在接下来的循环中会被覆盖掉
         arr[preIndex+1] = arr[preIndex] //前面的值将后一位的值覆盖掉
         preIndex--
      }
      arr[preIndex+1] = tmp //找到插入位置
   }
}

动图演示

blob.gif

总结

相同点

  • 时间复杂度都是 O(n^2),空间复杂度都是 O(1)。

  • 都需要采用两重循环。

不同点

  • 选择排序是不稳定的,冒泡排序、插入排序是稳定的;

  • 在这三个排序算法中,选择排序交换的次数是最少的;

  • 在数组几乎有序的情况下,插入排序的时间复杂度接近线性级别。