排序与查找(Go实现)

排序和查找应该是平时用得最多的算法了,虽然可能很多时候是直接使用的sort包封装好的方法,但是掌握简单的算法实现,可以更好地理解排序和查找,在需要的时候更加灵活地使用排序查找,或者封装更适合具体业务场景的排序查找。

在下文的练习中所提到的整数除法,默认都是向下取整的:

go 复制代码
  fmt.Println(3 / 2)     // 整数除法,只取整数部分,结果为1
  fmt.Println(3.0 / 2.0) // 小数除法的结果才是小数,结果为1.5

使用包提供的方法

用sort.Sort或sort.Slice排序

gosort包中的sort.Sort()sort.Slice()中的排序实现是基于模式识别的快速排序算法(pattern-defeating quicksort,pdqsort)。

go 复制代码
package sortdata

import (
	"fmt"
	"sort"
)

func init() {
	data := []int{10, 2, 5, 9, 1}
	sort.Slice(data, func(i, j int) bool { return data[i] < data[j] })
	fmt.Println(data) // [1 2 5 9 10]
}

这里的修改操作直接作用在原始的切片上,如果不想改变原始切片,可以使用copy方法拷贝一个切片,对新拷贝出来的切片进行排序操作。复制的时候要注意浅拷贝和深拷贝的问题。

可以直接使用Slice方法,或者为切片类型定义好方法集,然后使用Sort方法:

go 复制代码
package sortdata

import (
	"fmt"
	"sort"
)

type Person struct {
	Name string
	Age  int
}

func (p Person) String() string {
	return fmt.Sprintf("%s: %d", p.Name, p.Age)
}

/*
ByAge实现了sort.Interface接口 基于Age字段对[]Person进行排序
*/
type ByAge []Person

func (a ByAge) Len() int           { return len(a) }
func (a ByAge) Swap(i, j int)      { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }

func init() {
	people := []Person{
		{"张三", 31},
		{"李四", 42},
		{"王五", 17},
		{"赵六", 26},
	}

	fmt.Println(people) // [张三: 31 李四: 42 王五: 17 赵六: 26]
	// 有两种方式排序切片,一种方式是为slice类型定义好方法集,比如为ByAge类型定义好方法集,然后调用sort.Sort。
	sort.Sort(ByAge(people))
	fmt.Println(people) // [王五: 17 赵六: 26 张三: 31 李四: 42]

	// 第二种方式是调用sort.Slice方法,包含一个自定义的Less方法
	sort.Slice(people, func(i, j int) bool {
		return people[i].Age > people[j].Age
	})
	fmt.Println(people) // [李四: 42 张三: 31 赵六: 26 王五: 17]
}

用sort.Search查找

sort.Search是使用的二分查找,这意味着在查找前要确保数据是已经排序好的。

go 复制代码
package searchdata

import (
  "fmt"
  "sort"
)

func init() {
  target := 23
  data := []int{1, 10, 20, 199, 122, 123}

  i := sort.Search(len(data), func(i int) bool { return data[i] >= target })
  if i < len(data) && data[i] == target {
    fmt.Println("data中找到了目标值", data[i])
  } else {
    fmt.Printf("data中没有找到目标值, 如果要将目标值放入data中, 可以放在索引%v中的位置\n", i)
  }
}

用slices.IndexFunc查找

引入golang.org/x/exp/slice...包,slices.IndexFunc 如果找到值会返回匹配的第一个索引,没找到值会返回-1。

go 复制代码
  target1 := 23
  data1 := []int{199, 122, 1, 10, 20, 123}
  idx := slices.IndexFunc(data1, func(item int) bool { return item == target1 })
  if idx == -1 {
    fmt.Println("没有找到目标值")
  } else {
    fmt.Printf("找到了目标值,在索引%v的位置\n", idx)
  }

以上就是使用已有的包进行排序查找的方法,接下来简单了解下一些排序和查找算法的具体实现。

排序

冒泡排序

将相邻的元素两两比较,需要的时候进行交换,这个过程中最大的数字会像冒泡一样"漂"到列表的顶端。

以升序排序为例,把相邻的元素两两比较,如果前一个元素大于后一个元素,就交换它们的位置,否则位置不变。

降序排序就反过来,如果前一个元素小于后一个元素,就交换它们的位置,否则位置不变。

实现方式v1

go 复制代码
func BubbleSortV1(list []int) {
  // 一共循环n-1次,每次循环后,最大的数字会到列表最右端
  // 将后n-1个数字都移动到合适的位置后,最后剩下的第一个元素自然是最小的,所以只用循环n-1次
  for i := 0; i < len(list)-1; i++ {
    // 每个循环比较n-1次,n个数字两两比较只需要比较n-1次
    for j := 0; j < len(list)-1; j++ {
      left := j
      right := j + 1
      if list[left] > list[right] {
        swap(list, left, right)
      }
    }
    fmt.Printf("第%v次外循环后的结果 %v\n", i+1, list)
  }
}

func swap(list []int, i, j int) {
  if i == j {
    return
  }
  list[i], list[j] = list[j], list[i]
}

外层循环需要循环n - 1次,每次循环后,最大的数字会到列表的右端,将后n - 1个数字都放到合适的位置后,剩下的第一个元素自然是最小的,所以只需要循环n-1次而不是n次。

内层循环比较n-1次,因为n个数字两两比较只需要比较n-1次,比如3个数字ABC,AB比较,BC比较,只需要比较2次。

所以时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( n − 1 ) × ( n − 1 ) (n - 1) \times (n - 1) </math>(n−1)×(n−1) ,是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。

调用冒泡排序函数:

go 复制代码
func init() {
  list := []int{5, 6, 9, 8, 1, 7, 2}
  BubbleSortV1(list)
  fmt.Printf("最终结果 %v\n", list)
}

打印的结果为:

css 复制代码
第1次外循环后的结果 [5 6 8 1 7 2 9]
第2次外循环后的结果 [5 6 1 7 2 8 9]
第3次外循环后的结果 [5 1 6 2 7 8 9]
第4次外循环后的结果 [1 5 2 6 7 8 9]
第5次外循环后的结果 [1 2 5 6 7 8 9]
第6次外循环后的结果 [1 2 5 6 7 8 9]
最终结果 [1 2 5 6 7 8 9]

实现方式v2

在v1的实现方式中,内层循环无论如何都会执行n - 1次,这其实是没必要的,因为在第k次循环的时候,右边的k - 1个数字是已经排序好了的。

比如在第3次外循环开始执行的时候,前两个的外循环是已经把8、9排好序了的,内层循环只需要对索引0到索引4的元素进行两两比较就可以了,第3次循环的时候i等于2,也就是需要比较索引0len(list) - 1 - i的元素。

go 复制代码
func BubbleSortV2(list []int) {
  for i := 0; i < len(list)-1; i++ {
    // 内层循环只需循环len(list)-1-i次
    for j := 0; j < len(list)-1-i; j++ {
      left := j
      right := j + 1
      if list[left] > list[right] {
        swap(list, left, right)
      }
    }
    fmt.Printf("第%v次外循环后的结果 %v\n", i+1, list)
  }
}

所以时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∑ i = 0 i = n − 1 ( n − 1 − i ) < ( n − 1 ) 2 \sum_{i=0}^{i = n -1}(n - 1 - i) < (n - 1)^2 </math>∑i=0i=n−1(n−1−i)<(n−1)2,还是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)的时间复杂度,但是比v1要稍微高效些。

实现方式v3

观察执行v2的冒泡排序打印的结果:

css 复制代码
第1次外循环后的结果 [5 6 8 1 7 2 9]
第2次外循环后的结果 [5 6 1 7 2 8 9]
第3次外循环后的结果 [5 1 6 2 7 8 9]
第4次外循环后的结果 [1 5 2 6 7 8 9]
第5次外循环后的结果 [1 2 5 6 7 8 9]
第6次外循环后的结果 [1 2 5 6 7 8 9]

在5次比较之后,列表其实就已经排好序,可以通过一个标志来判断列表是否已经排好序了,如果在内层循环中一次都没有交换,说明列表已经排好序了,不需要进行后续的外循环。

go 复制代码
func BubbleSortV3(list []int) {
	for i := 0; i < len(list)-1; i++ {
		isSorted := true
		for j := 0; j < len(list)-1-i; j++ {
			left := j
			right := j + 1
			if list[left] > list[right] {
				swap(list, left, right)
				isSorted = false
			}
		}
		// 如果已经全部都排好序了,就结束循环
		if isSorted {
			break
		}
		fmt.Printf("第%v次外循环后的结果 %v\n", i+1, list)
	}
}

时间复杂度依然是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。

第5次执行之后列表已经排好序了,但是要在第6次外循环中判断列表是否已经排好序,所以第6次循环依然是会执行的,不会执行第6次外循环之后的代码。

选择排序

选择排序在冒泡排序的基础上进行了优化,只在每个外循环中进行一次交换,而不是在内循环中进行多次交换。

以升序排序为例,在未排序的元素中找出最小的元素,和第1个位置的元素交换,第1个位置就排好了,从剩下的元素中继续找到最小元素,放到第2个位置,以此类推,直到所有元素都排好序为止。

降序排序相反,先找到最大的元素放在第1个位置。

go 复制代码
func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	SelectionSort(list)
	fmt.Printf("最终结果 %v\n", list)
}

func SelectionSort(list []int) {
	for i := 0; i < len(list)-1; i++ {
		minIndex := i
		for j := i + 1; j < len(list); j++ {
			if list[j] < list[minIndex] {
				minIndex = j
			}
		}
		swap(list, i, minIndex)
		fmt.Printf("第%v次外循环后的结果 %v\n", i+1, list)
	}
}

外层循环需要循环n - 1次,每次循环后,最小的数字会到列表的左端,将前n - 1个数字都放到合适的位置后,剩下的最后一个元素自然是最大的,所以只需要循环n-1次而不是n次。

内层循环要寻找应该放在第i个位置的最小值,先假设索引i所在的元素是最小的,如果i + 1之后没有更小的元素,说明i位置的元素就是最小的,保持不变;如果i+1之后存在更小的元素,更新minIndex为更小的元素的索引,通过与minIndex索引位置的元素反复比较,找到最小的元素,与位置i的元素进行交换。

打印的结果:

css 复制代码
第1次外循环后的结果 [1 6 9 8 5 7 2]
第2次外循环后的结果 [1 2 9 8 5 7 6]
第3次外循环后的结果 [1 2 5 8 9 7 6]
第4次外循环后的结果 [1 2 5 6 9 7 8]
第5次外循环后的结果 [1 2 5 6 7 9 8]
第6次外循环后的结果 [1 2 5 6 7 8 9]
最终结果 [1 2 5 6 7 8 9]

选择排序的时间复杂度也是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)。

插入排序

插入排序就像是打牌的时候,手里的牌是排好序的,每次从桌上摸一张牌,从右到左一张牌一张牌地比较,插入合适的位置。

go 复制代码
func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	InsertionSort(list)
	fmt.Printf("最终结果 %v\n", list)
}

func InsertionSort(list []int) {
	for i, cur := range list {
		// 将preIdx 设置为已排序序列的最右边元素的索引
		preIdx := i - 1

		/*
			第1次外循环时,preIdx为-1, 不满足preIdx >= 0 的条件,所以以下for循环中的代码块不会执行
			直接执行 list[preIdx+1] = cur 将索引0的元素设置为当前元素,相当于将索引0的元素当作已排序序列的第1个元素
		*/

		/*
			从右到左依次比较元素,如果cur比已排序序列中的元素小,就将已排序的元素往后挪动一位
			重复比较,直到找到一个合适的位置放置cur
		*/
		for preIdx >= 0 && list[preIdx] > cur {
			list[preIdx+1] = list[preIdx]
			preIdx -= 1
		}

		list[preIdx+1] = cur
		fmt.Printf("第%v次外循环后的结果 %v\n", i+1, list)
	}
}

p1指针在每次插入元素前都会指向已经排序的序列的最后一个元素。

最后一次外循环的时候,p1指向索引为5的元素,将要往有序序列中插入元素2,最后一次循环结束,整个列表中的元素就都被排好序了。

第一次外循环的时候,p1指向索引-1,为一个不存在的索引值。从索引-1到索引5,需要遍历len(list)次。

插入排序的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 1 + 1 + 2 + 3 + . . . + ( n − 2 ) 1 + 1 + 2 + 3 + ... + (n - 2) </math>1+1+2+3+...+(n−2) ,也是O(n^2)。(第1个外循环的时候,内层循环的代码块不执行,第2个外循环的时候,内层循环代码块最多执行1次,第3个外循环的时候,内存代码块最多执行2次,依此类推。)

希尔排序

希尔排序是对插入排序的优化,插入排序每次只能移动一个元素到合适的位置,希尔排序将列表分为多个子列表,对每一个子列表进行插入排序,等整个序列中的记录"基本有序"的时候,再对所有元素进行直接插入排序,这样能减少总的移动次,提高效率。

比如上述插入排序例子中,第5次外循环开始要放元素1的时候,列表是这样的[5, 6, 8, 9, 1, 7, 2],需要把前面的4个元素都往后移动一位,希尔排序就是先将列表处理成大部分都是从小到大排序,减少这种移动,从而提高效率。

希尔排序的时间复杂度在 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g n ) O(nlogn) </math>O(nlogn)和 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2)之间,根据概率统计得到的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 3 ) O(n^{\frac{2}{3}}) </math>O(n32)。

希尔排序按照一定的gap值,将列表分成子列表,经典的希尔算法的gap值是 <math xmlns="http://www.w3.org/1998/Math/MathML"> n 2 1 、 n 2 2 、 n 2 3 . . . \frac{n}{2^1}、\frac{n}{2^2}、\frac{n}{2^3}... </math>21n、22n、23n...直到gap的值为1,gap值为1时会对整个列表进行插入排序。gap值不一定是这样变化,也可以是从n/3开始。

go 复制代码
func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2, -5}
	ShellSort(list)
	fmt.Printf("最终结果 %v\n", list)
}

func ShellSort(list []int) {
	for gap := len(list) / 2; gap > 0; gap /= 2 {
		for i := gap; i < len(list); i++ {
			cur := list[i]
			preIdx := i - gap
			for preIdx >= 0 && list[preIdx] > cur {
				list[preIdx+gap] = list[preIdx]
				preIdx -= gap
			}
			list[preIdx+gap] = cur
		}
	}
}

数组的长度为8,第一次最外层循环的时候gap8/2 = 4,第二次最外层循环的时候gap8/4 = 2,第三次最外层循环的时候gap8/8=1

下图为gap4时分的子列表,其中黄色框框中的是一个个拆分出来的子列表,对子列表中的元素进行快速排序。

下图是gap2时拆分的子列表,同样对子列表中的元素进行快速排序。

gap1时,直接对整个列表,也就是[1 -5 2 6 5 7 9 8]进行快速排序,排序后的结果为:

[-5 1 2 5 6 7 8 9]

归并排序

归并排序基于分治法。

分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可以得到原问题的解。

归并排序的实现有两种方法:

  • 自上而下的递归
  • 自下而上的迭代

用递归实现从代码上看比较直观一些,但是需要使用调用栈,需要使用一定的调用栈空间。

递归方式实现

先用递归的方式实现:

go 复制代码
func init() {
  list := []int{5, 6, 9, 8, 1, 7, 2}
  sortedList := MergeSort(list)
  fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [1 2 5 6 7 8 9]
}

func MergeSort(list []int) []int {
  dst := make([]int, len(list))
  copy(dst, list)
  merge(list, dst, 0, len(list))
  return dst
}

func merge(src, dst []int, start, end int) {
  // 如果只有一个元素,直接返回
  if start+1 >= end {
    return
  }

  // 将列表从中间分为两部分,分别进行归并排序
  mid := (start + end) / 2

  merge(dst, src, start, mid) // [start, mid)
  merge(dst, src, mid, end)   // [mid, end)

  var (
    i = start
    j = mid
    k = start
  )

  // 当包含的元素个数 > 1 时,从左右两个子列表中取具体的值放到目标数组中
  for i < mid || j < end {
    /*
      如果右侧的子列表已经遍历完毕,或者
      左侧的子列表没有遍历完毕,并且左侧的子列表的元素小于右侧的子列表的元素
      将目标列表的值设置为左侧子列表的元素。
      否则,设置为右侧子列表的元素。
    */
    if (j == end) || (i < mid && src[i] < src[j]) {
      dst[k] = src[i]
      k += 1
      i += 1
    } else {
      dst[k] = src[j]
      k += 1
      j += 1
    }
  }
}

先分成子问题:

再依次处理子问题(注意箭头的方向是从下到上的):

因为函数调用栈先进后出的特点,代码的执行的过程可以看作是上图二叉树的后序遍历,先遍历左子树,再遍历右子树,再遍历根节点。

叶子节点只有一个元素,所以在执行merge函数的时候直接返回了,非叶子节点的处理是怎样的呢,以合并[1, 8][2, 7] 为例:

具体的排序过程如下:

归并排序的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g n ) O(nlogn) </math>O(nlogn),空间复杂度为函数调用栈需要的 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( l o g n ) O(logn) </math>O(logn)的空间以及辅助列表需要占用的 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)空间,所以空间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。

迭代方式实现

递归的方式是自上而下的,将整个列表分成两个较小的子列表,子列表再分子列表。

而迭代的方式是自下而上的,先处理小的子列表,再合并成较大的子列表,最后合并成总的列表。

go 复制代码
func MergeSortV1(list []int) []int {
	length := len(list)
	src := list
	dst := make([]int, length)
	copy(dst, list)
	for seg := 1; seg < length; seg += seg {
		for start := 0; start < length; start += seg * 2 {
			mid := min(start+seg, length)
			end := min(start+seg*2, length)
			var (
				i = start
				j = mid
				k = start
			)
			for i < mid || j < end {
				if j == end || (i < mid && src[i] < src[j]) {
					dst[k] = src[i]
					k++
					i++
				} else {
					dst[k] = src[j]
					k++
					j++
				}
			}
		}
		src, dst = dst, src
	}
	return src
}

func min(num1, num2 int) int {
	if num1 < num2 {
		return num1
	}
	return num2
}

迭代的代码没有递归的直观。

快速排序

快速排序也是基于分治法的。快速排序选择一个元素作为基准值,然后把比基准值小的元素放在基准值的前面,把比基准值大的元素放在基准值后面,然后对基准值左右两侧的子列表也进行同样的操作,直到子列表中只有一个数字为止。

go 复制代码
package sortdata

import (
	"fmt"
	"math/rand"
)

func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	sortedList := QuickSort(list)
	fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [1 2 5 6 7 8 9]
}

func QuickSort(list []int) []int {
	return quickSort(list, 0, len(list)-1)
}

func quickSort(list []int, left, right int) []int {
	if left < right {
		pivot := partition(list, left, right)
		quickSort(list, left, pivot-1)
		quickSort(list, pivot+1, right)
	}
	return list
}

func partition(list []int, start, end int) int {
	random := rand.Intn(end-start+1) + start
	swap(list, random, end)

	small := start - 1
	for i := start; i < end; i++ {
		if list[i] < list[end] {
			small++
			swap(list, i, small)
		}
	}
	small++
	swap(list, small, end)
	return small
}

swap函数最开始写冒泡排序的时候已经定义过:

css 复制代码
func swap(list []int, i, j int) {
  if i == j {
    return
  }
  list[i], list[j] = list[j], list[i]
}

如果每次选取的基准值在排序数组中靠近中间的位置,那么时间复杂度就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g n ) O(nlogn) </math>O(nlogn),如果每次选择的基准值都是最小值或者最大值,时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n 2 ) O(n^2) </math>O(n2),在随机选取基准值的情况下,快速排序的平均时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g n ) O(nlogn) </math>O(nlogn)。

关于较好的时间复杂度和较差的时间复杂度,可以脑补以下两棵二叉搜索树进行类比:

左边的树选择的基准值依次是4、2、6,右边的树选择的基准值依次是1、2、3、4、5、6。

[5, 6, 9, 8, 1, 7, 2] 进行快速排序的过程如下:

以对列表[5, 6, 9, 8, 1, 7, 2]进行排序为例,每个子列表的排序过程都是这样的,随机选择一个元素作为基准值,把比基准值小的元素都放到基准值的前面,把比基准值大的元素都放到基准值的后面。

堆排序

堆排序是利用堆这种数据结构的特点来设计的一种排序算法,

堆是一棵完全二叉树,当每个节点的值都大于或等于其子节点的值时,为最大堆(大根堆),当每个节点的值都小于或等于其子节点的值时,为最小堆(小根堆)。

升序排序使用最大堆,降序排序使用最小堆。

完全二叉树可以用数组实现,如果数组中的一个元素的下标为 <math xmlns="http://www.w3.org/1998/Math/MathML"> i i </math>i,那么它在堆中对应节点的父节点在数组中的下标是 <math xmlns="http://www.w3.org/1998/Math/MathML"> ( i − 1 ) / 2 (i - 1)/2 </math>(i−1)/2,它的左右子节点在数组中的下标为 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 i + 1 2i+1 </math>2i+1和 <math xmlns="http://www.w3.org/1998/Math/MathML"> 2 i + 2 2i+2 </math>2i+2。

堆排序的平均时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n l o g n ) O(nlogn) </math>O(nlogn)。

下面以升序排序为例说明问题,使用的是最大堆。

简单了解下是堆这种数据结构是如何新增节点和删除节点的:

  • 新增节点

往堆中新增了一个节点之后,如果这个节点的值小于或等于其父节点的值,那么就是满足最大堆条件的,算法完成;如果这个节点的值大于其父节点的值,就将这个节点与父节点进行交换,再次判断交换后这个节点的值是否大于其父节点的值,如果大于,又与父节点进行交换,直到这个节点的值小于或等于父节点的值,或者这个节点已经成为了根节点。

  • 删除节点

删除根节点,为了避免直接删除节点后一棵树变成两棵无连接的树,所以不能最直接删除根节点。

先将最后一个节点A复制到根节点,此时最后一个节点成为了新的根节点,如果此时树只包含一个节点,算法完成;否则判断节点A的值是否大于或等于其左右子节点,如果是,算法完成;如果节点A只包含左子节点,左子节点的值比节点A大,那么将节点A与它的左子节点交换,如果节点A包含左右子节点,就将节点A和左右子节点中较大的那个值交换,直到节点A的值大于或等于其左右子节点,或者节点A成为叶子节点,算法终止。

堆排序的代码实现:

go 复制代码
package sortdata

import "fmt"

func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	sortedList := HeapSort(list)
	fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [1 2 5 6 7 8 9]
}

func HeapSort(list []int) []int {
	length := len(list)
	buildMaxHeap(list, length)
	for i := length - 1; i >= 1; i-- {
		swap(list, 0, i)
		length -= 1
		heapify(list, 0, length)
	}
	return list
}

func buildMaxHeap(list []int, length int) {
	for i := length/2 - 1; i >= 0; i-- {
		heapify(list, i, length)
	}
}

func heapify(list []int, i, length int) {
	left := 2*i + 1
	right := 2*i + 2
	largest := i
	if left < length && list[left] > list[largest] {
		largest = left
	}
	if right < length && list[right] > list[largest] {
		largest = right
	}
	if largest != i {
		swap(list, i, largest)
		heapify(list, largest, length)
	}
}

整体的排序过程:

以将[5, 6, 9, 8, 1, 7, 2] 构造为最大堆[9 8 7 6 1 5 2]为例,heapify方法构造最大堆的过程为:

图中p1一开始指向的是索引2(即7/2 - 1)的位置,从length/2 - 1的位置开始,逐个往前遍历元素,判断是否需要交换元素,为什么不从最后一个元素开始,而是从中间位置的元素开始呢,因为叶子节点没有子节点,所以每个叶子节点都是满足最大堆条件的,不需要构建堆。最后一个元素的索引为length - 1,它的父节点就是最后一个非叶子节点(索引为(length - 1) - 1 / 2 = length/2 - 1 ),从最后一个非叶子节点开始逐个往前遍历元素即可。

计数排序

如果整数的范围比较小,比如年龄这种数值,就可以用计数排序。利用数组的索引是有顺序的,通过数组中索引的大小对应数字的大小,数组中存储元素出现的次数,然后按照顺序将对应的元素取出来。

go 复制代码
package sortdata

import (
	"fmt"
	"math"
)

func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	sortedList := CountingSort(list)
	fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [1 2 5 6 7 8 9]
}

func CountingSort(list []int) []int {
	minNum := math.MaxInt
	maxNum := math.MinInt
	for _, val := range list {
		minNum = min(minNum, val)
		maxNum = max(maxNum, val)
	}
	counts := make([]int, maxNum-minNum+1)
	for _, val := range list {
		counts[val-minNum] += 1
	}

	i := 0
	for num := minNum; num <= maxNum; num++ {
		for counts[num-minNum] > 0 {
			list[i] = num
			i++
			counts[num-minNum] -= 1
		}
	}
	return list
}

func max(num1, num2 int) int {
	if num1 > num2 {
		return num1
	}
	return num2
}

如果数组的长度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n,数组的范围为 <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k,那么计数排序的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n + k ) O(n+k) </math>O(n+k)。由于需要创建一个长度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( k ) O(k) </math>O(k)的辅助数组,因此空间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( k ) O(k) </math>O(k)。

找出最大值和最小值的循环需要花费n的时间,组织计数数组counts需要花费n的时间,将元素从计数数组中取出来,外层循环需要花费k的时间,内层循环总共加起来需要花费n的时间,所以计数排序的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n + k ) O(n+k) </math>O(n+k)。

桶排序

桶排序是计数排序的升级版,它利用了函数的映射关系,将数组分到有限数量的桶里,每个桶再分别排序(可以继续使用桶排序,也可以使用别的排序算法)。

go 复制代码
package sortdata

import (
	"fmt"
	"math"
)

func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2, 26, 12}
	sortedList := BucketSort(list)
	fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [1 2 5 6 9 8 7 12 26]
}

func BucketSort(list []int) []int {
	length := len(list)
	maxNum := getMaxInList(list)
	buckets := make([][]int, length)

	var index int
	for _, val := range list {
		index = (length - 1) * val / maxNum
		buckets[index] = append(buckets[index], val)
	}

	pos := 0
	for i := 0; i < length; i++ {
		bl := len(buckets[i])
		if bl > 0 {
			buckets[i] = sortInBucket(buckets[i])
			copy(list[pos:], buckets[i])
			pos += bl
		}
	}
	return list
}

// 桶内的排序选择使用插入排序
func sortInBucket(bucket []int) []int {
	for i, cur := range bucket {
		preIdx := i - 1
		for preIdx >= 0 && bucket[i] > cur {
			bucket[preIdx+1] = bucket[preIdx]
			preIdx -= 1
		}
		bucket[preIdx+1] = cur
	}
	return bucket
}

func getMaxInList(list []int) int {
	maxNum := math.MinInt
	for _, val := range list {
		if val > maxNum {
			maxNum = val
		}
	}
	return maxNum
}

index = (length - 1) * val / maxNum这个方法得到的值是0 ~ (length - 1)范围内的数字,对应着数组的索引。

桶排序的时间复杂度接近 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。把n个数字分在m个桶里,每个桶里的数据就是 <math xmlns="http://www.w3.org/1998/Math/MathML"> k = n m k = \frac{n}{m} </math>k=mn,每个桶内排序的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( k l o g k ) O(klogk) </math>O(klogk),总的时间复杂度为 <math xmlns="http://www.w3.org/1998/Math/MathML"> m × O ( k l o g k ) = m × O ( n m × l o g ( n m ) ) = O ( n l o g ( n m ) ) m \times O(klogk) = m \times O(\frac{n}{m} \times log(\frac{n}{m})) = O(nlog(\frac{n}{m})) </math>m×O(klogk)=m×O(mn×log(mn))=O(nlog(mn)),桶的个数接近数字的个数时, <math xmlns="http://www.w3.org/1998/Math/MathML"> l o g ( n m ) log(\frac{n}{m}) </math>log(mn)是一个很小的常数,所以桶排序的时间复杂度接近 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。

基数排序

基数排序将整数按位数切割成不同的数字,然后按照每个位数分别进行比较。

计数排序、桶排序和基数排序都使用了桶的概念,基数排序根据值的每位的数字来分配桶,计数排序每个桶只存储单一键值,桶排序每个桶存储一定范围的数值。

go 复制代码
package sortdata

import (
	"fmt"
	"strconv"
)

func init() {
	list := []int{5, 16, 9, 18, 15, 7, 2, 26, 12}
	sortedList := RadixSort(list)
	fmt.Printf("最终结果 %v\n", sortedList) // 最终结果 [2 5 7 9 12 15 16 18 26]
}

func RadixSort(list []int) []int {
	maxDigit := maxBit(list)
	mod, div := 10, 1
	for i := 0; i < maxDigit; i++ {
		buckets := [10][]int{}
		for _, num := range list {
			bucket := (num % mod) / div
			buckets[bucket] = append(buckets[bucket], num)
		}
		index := 0
		for _, bucket := range buckets {
			for _, num := range bucket {
				list[index] = num
				index++
			}
		}

		mod *= 10
		div *= 10
	}
	return list
}

func maxBit(list []int) int {
	maxVal := 0
	for _, val := range list {
		if val > maxVal {
			maxVal = val
		}
	}
	return len(strconv.Itoa(maxVal))
}

基数排序的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( d ( n + k ) ) O(d(n+k)) </math>O(d(n+k)), <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n是元素的个数, <math xmlns="http://www.w3.org/1998/Math/MathML"> d d </math>d是数字的位数(比如代码中使用的数字列表中的数字最多有2位), <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k是每个数字的取值范围(比如10进制数的 <math xmlns="http://www.w3.org/1998/Math/MathML"> k k </math>k值就是10,取值范围是0~9)。

查找

顺序查找

最好情况目标位于列表的第一个位置,只比较一次;最坏情况是目标位于列表的最后位置,要比较 <math xmlns="http://www.w3.org/1998/Math/MathML"> n n </math>n次;平均情况下在列表的中间找到,需要比较 <math xmlns="http://www.w3.org/1998/Math/MathML"> n / 2 n/2 </math>n/2次,所以顺序搜索算法的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( n ) O(n) </math>O(n)。

go 复制代码
package searchdata

import "fmt"

func init() {
	list := []int{5, 6, 9, 8, 1, 7, 2}
	sr := SequentialSearch(list, 20)
	fmt.Printf("最终结果 %v\n", sr)
	list1 := []int{10, 20, 30, 40, 50, 60, 70}
	sr1 := OrderedSequentialSearch(list1, 211)
	fmt.Printf("最终结果 %v\n", sr1)
}

func SequentialSearch(list []int, target int) bool {
	pos := 0
	found := false
	for pos < len(list) && !found {
		if list[pos] == target {
			return true
		}
		pos += 1
	}
	return found
}

// 如果列表是有序的,只要遇到大于目标的元素,就知道后续的元素都不会再等于目标了,直接返回
func OrderedSequentialSearch(list []int, target int) bool {
	pos := 0
	found := false
	for pos < len(list) && !found {
		if list[pos] == target {
			return true
		}
		if list[pos] > target {
			return false
		}
		pos += 1
	}
	return found
}

二分查找

如果要进行查找的列表是排序的,可以使用二分查找。

二分查找先比较列表中间的位置的元素是否等于目标元素,如果等于就返回true,如果中间的元素大于目标元素,就在后半部分找,中间的元素小于目标元素就在前半部分找。二分查找的时间复杂度是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( l o g n ) O(logn) </math>O(logn)。

go 复制代码
package searchdata

import "fmt"

func init() {
	list := []int{10, 20, 30, 40, 50, 60, 70}
	sr := BinarySearch(list, 31)
	fmt.Printf("最终结果 %v\n", sr)
}

func BinarySearch(list []int, target int) bool {
	left := 0
	right := len(list) - 1
	found := false
	for left <= right && !found {
		mid := (left + right) / 2
		if list[mid] == target {
			return true
		} else {
			if target < list[mid] {
				right = mid - 1
			} else {
				left = mid + 1
			}
		}
	}
	return found
}

哈希(散列)查找

哈希查找就是从哈希表中查找数据时用的查找方法。

使用求余的哈希函数,选择线性探测的方式简单实现一个哈希表:

go 复制代码
package searchdata

import "fmt"

func init() {
	ht := NewHashTable[string, int]()
	ab := "ab"
	ba := "ba"
	c := "c"
	d := "d"
	e := "e"
	ht.Put(&ab, 5)
	ht.Put(&ba, 6)
	ht.Put(&c, 9)
	ht.Put(&d, 8)
	fmt.Println(ht.GetKeys(), ht.Vals)
	found, val := ht.Get(&c)
	fmt.Printf("键是否存在 %v 对应的值是 %v\n", found, val)
	found1, val1 := ht.Get(&e)
	fmt.Printf("键是否存在 %v 对应的值是 %v\n", found1, val1)
}

type HashTable[K string, V any] struct {
	Size int  // 初始容量
	Keys []*K // 存储键
	Vals []V  // 存储值
	Len  int  // 键值对的个数
}

// 为了简化流程,只支持字符串类型的键值,假如需要扩展其他类型,可以修改类型参数
func NewHashTable[K string, V any]() HashTable[K, V] {
	size := 3 // 容器的初始尺寸设置为3
	ht := HashTable[K, V]{
		Size: size,
		Keys: make([]*K, size), // 这里为了区分空值和值不存在的情况,使用的指针类型的数据,这样的设计有些奇怪,但是又找不到什么更好的办法
		Vals: make([]V, size),
		Len:  0, // 初始的元素个数为0
	}
	return ht
}

// 扩容
func (h *HashTable[K, V]) Resize(newSize int) {
	newKeys := make([]*K, newSize)
	newVals := make([]V, newSize)
	h.Size = newSize

	copy(newKeys, h.Keys)
	copy(newVals, h.Vals)

	h.Keys = make([]*K, newSize)
	h.Vals = make([]V, newSize)

	h.Len = 0
	for i, key := range newKeys {
		if key != nil {
			h.Put(key, newVals[i])
		}
	}
}

func (h *HashTable[K, V]) Put(key *K, val V) {
	if h.Size == h.Len { // 元素个数达到容量时,需要先进行扩容
		h.Resize(h.Size * 2)
	}

	hashVal := h.Hash(key, h.Size)
	// hashVal的位置没有值的时候直接把值放在这个位置
	if h.Keys[hashVal] == nil {
		h.Keys[hashVal] = key
		h.Vals[hashVal] = val
		h.Len += 1
	} else {
		// 如果 hashVal位置已经有值了,就继续往后找,直到找到一个空位,或者已经存储的同样名字的key
		next := h.ReHash(hashVal, h.Size)
		for h.Keys[next] != nil && h.Keys[next] != key {
			next = h.ReHash(next, h.Size)
		}
		// 有空位就将键值放在空位
		if h.Keys[next] == nil {
			h.Keys[next] = key
			h.Vals[next] = val
			h.Len += 1
		} else { // 已经有这个键就覆盖这个键对应的值
			h.Vals[next] = val
		}
	}
}

func (h *HashTable[K, V]) Hash(key *K, size int) int {
	sum := 0
	k := *key
	for _, a := range k {
		sum += int(a)
	}
	return sum % size
}

// 通过线性探测的方式来处理冲突
func (h *HashTable[K, V]) ReHash(oldHash int, size int) int {
	return (oldHash + 1) % size
}

func (h *HashTable[K, V]) Get(key *K) (found bool, val V) {
	start := h.Hash(key, len(h.Keys))

	pos := start
	// 一直往后查找,直到找到,或者找了一圈都没找到
	for h.Keys[pos] != nil {
		if h.Keys[pos] == key {
			found = true
			val = h.Vals[pos]
			return found, val
		} else {
			pos = h.ReHash(pos, len(h.Keys))
			if pos == start {
				return found, val
			}
		}
	}
	return found, val
}

func (h *HashTable[K, V]) GetKeys() []K {
	keys := []K{}
	for _, key := range h.Keys {
		if key != nil {
			keys = append(keys, *key)
		} else {
			keys = append(keys, "-")
		}
	}
	return keys
}

哈希查找的时间复杂度一般是 <math xmlns="http://www.w3.org/1998/Math/MathML"> O ( 1 ) O(1) </math>O(1)。

哈希表的存储过程。为了看起来直观,图中keys列表中写的是字符串(其实存储的是字符串的地址):

哈希查找的过程,以查找键c为例子吧:

参考文章

  1. 十大经典排序算法:www.runoob.com/w3cnote/bub...
  2. 希尔排序:www.runoob.com/data-struct...
  3. 桶排序:www.bookstack.cn/read/For-le...
  4. 基数排序:www.lifezb.com/golang/17.h...
  5. 《剑指offer专项突破版》
相关推荐
TaoYuan__30 分钟前
机器学习的常用算法
人工智能·算法·机器学习
用户40547878374821 小时前
深度学习笔记 - 使用YOLOv5中的c3模块进行天气识别
算法
shinelord明1 小时前
【再谈设计模式】建造者模式~对象构建的指挥家
开发语言·数据结构·设计模式
十七算法实验室2 小时前
Matlab实现麻雀优化算法优化随机森林算法模型 (SSA-RF)(附源码)
算法·决策树·随机森林·机器学习·支持向量机·matlab·启发式算法
黑不拉几的小白兔2 小时前
PTA部分题目C++重练
开发语言·c++·算法
迷迭所归处2 小时前
动态规划 —— dp 问题-买卖股票的最佳时机IV
算法·动态规划
chordful2 小时前
Leetcode热题100-32 最长有效括号
c++·算法·leetcode·动态规划
_OLi_2 小时前
力扣 LeetCode 459. 重复的子字符串(Day4:字符串)
算法·leetcode·职场和发展·kmp
Romanticroom2 小时前
计算机23级数据结构上机实验(第3-4周)
数据结构·算法
白藏y2 小时前
数据结构——归并排序
数据结构·算法·排序算法