算法之--排序

228 阅读8分钟

本篇是承数据结构与算法指北的内容篇。为了更详细的了解排序而单独开篇。

排序

如何分析一个排序算法

排序算法的执行效率
  1. 最好情况、最坏情况、平均情况时间复杂度 我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。

  2. 时间复杂度的系数、常数 、低阶

我们知道,时间复杂度反映的是数据规模n很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。

  1. 比较次数和交换(或移动)次数 基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。
排序算法的内存消耗

算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。
不过,针对排序算法的空间复杂度,有一个新的概念,原地排序(Sorted in place)。原地排序算法,就是特指空间复杂度是O(1)的排序算法。

排序算法的稳定性

仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作。

  1. 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个原地排序算法。
  2. 在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
  3. 最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行n次冒泡操作,所以最坏情况时间复杂度为O(n2)。

插入排序

一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。

插入排序(点击
// 有序度是数组中具有有序关系的元素对的个数。
// 2,4,3,1,5,6 这组数据的有序度为11.
// 冒泡排序的交换次数为逆序度。
// 插入排序移动元素的次数也为逆序度
// 但冒泡排序需要三个赋值操作(元素交换)
// 插入排序只需要一个。
// 插入排序. n^2
const insert_sort = (arr: number[]) => {
  if(arr.length <= 1) return arr

  for(let i = 1; i < arr.length; i++){
    let j = i - 1
    let val = arr[i]
    while(j >= 0){
      if(arr[j] > val){
        arr[j+1] = arr[j--]
      } else {
        break
      }
    }
    //j自减,需要+1
    arr[j+1] = val
  }
  return arr
}
  1. 过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),也就是说,这是一个原地排序算法。
  2. 在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
  3. 如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为O(n)。注意,这里是从尾到头遍历已经有序的数据。如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为O(n2)。

选择排序

选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。

  1. 序空间复杂度为O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为O(n2)。
  2. 序是一种不稳定的排序算法。选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。

归并排序

归并排序的核心思想就是将数组分成前后两部分,将前后两部分分别排序。再将两个排序好的部分合并,合成的数组就有序了。

归并排序--数组(点击
// 合并俩个有序数组。
const merge = (arr1: number[], arr2: number[]) => {
  const sortedArr = []
  let i = 0, j = 0

  while(i < arr1.length || j < arr2.length) {
    if(i === arr1.length){
      sortedArr.push(...arr2.slice(j))
      //结束循环,将剩下的全部放入数组
      break
    }
    if(j === arr2.length){
      sortedArr.push(...arr1.slice(i))
      break
    }
    arr1[i] > arr2[j] ? sortedArr.push(arr2[j++]) : sortedArr.push(arr1[i++])
  }

  return sortedArr
}

// 归并排序
const merge_sort = (arr: number[]) => {
  const _merge_sort = (_arr: number[], startIndex: number, endIndex: number) => {
    if(startIndex >= endIndex) return [_arr[endIndex]]
  
    const middleIndex = Math.floor((endIndex + startIndex) / 2)
  
    return merge(_merge_sort(_arr, startIndex, middleIndex), _merge_sort(_arr, middleIndex + 1, endIndex))
  }

  return _merge_sort(arr, 0, arr.length - 1)
}
归并排序--链表(点击
// 合并俩个有序数组。
const mergeList = (head1, head2) => {
  const header = new ListNode(null)
  let cur = header
  while(head1 && head2) {
    if(head1.val >= head2.val) {
      cur.next = head2
      head2 = head2.next
    } else {
      cur.next = head1
      head1 = head1.next
    }
    cur = cur.next
  }

  if(!head1) cur.next = head2
  if(!head2) cur.next = head1

  return header.next
}

// 归并排序
function sortList(head: ListNode | null): ListNode | null {
  if(!head?.next) return head

  const header = new ListNode(null, head)
  let slow = header
  let fast = header

  while(fast?.next) {
    slow = slow.next
    fast = fast.next.next
  }

  const right = sortList(slow.next)
  slow.next = null
  const left = sortList(head)

  return mergeList(right, left)
};
  1. 归并排序稳不稳定关键要看merge()函数,也就是两个有序子数组合并成一个有序数组的函数。在值相同的情况下保证顺序不变,归并排序可以写成稳定算法。
  2. 归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是O(nlogn)。
  3. 归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。归并排序的空间复杂度是O(n)

快速排序

快速排序算法的思想是:在数组中任意选一个数字作为锚点,遍历数字,将大于锚点的数字放左边,小于锚点的放右边,直至区间为1,整个数组就排序好了。

快速排序的算法(点击
// 快速排序   nlogn
const quick_sort = (arr: number[]) => {
  if(arr.length <= 1) return arr
  const index = Math.floor(arr.length / 2)

  const point = arr.splice(index, 1)[0]
  const less = arr.filter(v => v <= point)
  const greater = arr.filter(v => v > point)
  
  return [...quick_sort(less), point, ...quick_sort(greater)]

  // 原地快排(不占用额外内存,非稳定排序)
  // if(arr.length <= 1) return arr
  // const index = Math.floor(arr.length / 2)
  // const point = arr[index]
  // let j = 0
  // for(let i = 0; i < arr.length; i++){
  //   if(arr[i] < point){
  //     // 从j=0开始交换,j下标之前的,都是比point小的
  //     [arr[j], arr[i]] = [arr[i], arr[j]]
  //     j++
  //   }
  // }
  // return [...quick_sort(arr.slice(0, j)), point, ...quick_sort(arr.slice(j + 1, arr.length))]
}

桶排序

核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。 如果要排序的数据有n个,我们把它们均匀地划分到m个桶内,每个桶里就有k=n/m个元素。每个桶内部使用快速排序,时间复杂度为O(k * logk)。m个桶排序的时间复杂度就是O(m * k * logk),因为k=n/m,所以整个桶排序的时间复杂度就是O(n*log(n/m))。当桶的个数m接近数据个数n时,log(n/m)就是一个非常小的常量,这个时候桶排序的时间复杂度接近O(n)。

计数排序

计数排序其实是桶排序的一种特殊情况。当要排序的n个数据,所处的范围并不大的时候,比如最大值是k,我们就可以把数据划分成k个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。

计数排序(点击
// 计数排序   n
const count_sort = (arr: number[]) => {
  const max = Math.max(...arr)
  const c = new Array(max + 1).fill(0)

  // 计数
  arr.forEach(i => c[i] ? c[i] += 1 : c[i] = 1)

  // 计数求和
  for(let i = 1; i < c.length; i++) {
    c[i] = c[i] + c[i-1]
  }

  const result = []
  // 倒序遍历,保证排序的稳定性
  for(let i = arr.length -1; i >= 0; i--) {
    result[ --c[arr[i]] ] = arr[i]
  }
  return result
}