数据结构与算法06:递归和简单的排序

目录

【递归】

【排序】

冒泡排序

插入排序

选择排序

【每日一练:K 个一组翻转链表】


【递归】

递归是将一些有规律的重复问题分解为同类的子问题的方法,也就是在函数中自己调用自己。比较经典的递归代码就是 斐波那契数列,实现方式如下:

// 1、1、2、3、5、8、13、21、34
// F(0)=1, F(1)=1, F(n)=F(n-1)+F(n-2)
func getFibonacci(n int) int {
	if n == 1 || n == 2 {
		return 1
	}
	return getFibonacci(n-1) + getFibonacci(n-2)
}

写递归代码最关键的是写出递推公式,找到终止条件。只要同时满足以下三个条件,就可以用递归来解决:

  •  一个问题的解可以分解为几个子问题的解
  • 这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样
  • 存在递归终止条件

递归代码要警惕堆栈溢出和重复计算的问题,而且过多的函数调用会耗时较多。由于递归调用一次就会在内存栈中保存一次现场数据,所以递归代码的空间复杂度一般是O(n)。一般能用递归解决的问题也基本都能用循环解决,因此要根据实际情况来选择是否需要用递归的方式来实现。

【排序】

常见的排序算法有下面这些:冒泡排序、插入排序、选择排序、归并排序、快速排序、堆排序、计数排序、基数排序、桶排序。

排序算法时间复杂度是否基于比较
冒泡、插入、选择O(n^2)
快排、归并、堆排序O(nlogn)
桶、计数、基数O(n)
  • 是否基于比较:基于比较的排序算法的执行过程中会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。
  • 原地排序算法:是指空间复杂度是 O(1) 的排序算法。
  • 稳定的排序算法:针对排序算法,不仅要关注执行效率和内存消耗,还要关注稳定性,如果待排序的序列中存在值相等的元素,经过排序之后这些相等元素之间原有的先后顺序应该保持不变。

稳定排序算法可以保持某个key相同的两个对象,在排序之后的前后顺序不变。比如下面这组数据:[{id:1,name:"AA",age:50},{id:2,name:"BB",age:30},{id:3,name:"CC",age:40},{id:4,name:"DD",age:30}],假设需要按照age由小到大排序,由于有两个age=30的数据,如果排序后的结果为 [{id:4,name:"DD",age:30},{id:2,name:"BB",age:30},{id:3,name:"CC",age:40},{id:1,name:"AA",age:50}],就不是一个稳定的排序算法。如果要实现稳定排序,可以对唯一字段(比如id)先排序一次,然后再对age排序。

冒泡排序

冒泡排序只会操作相邻的两个数据,每次冒泡操作都会对相邻的两个元素比较大小,如果不满足条件就让这两个元素互换。如下图所示:

上面第4次和第5次实际并没有交换数据,因此在实现过程中可以判断这种情况直接跳过。下面是一个冒泡排序的代码:

// 冒泡排序,a表示数组,n表示数组大小
func BubbleSort(a []int, n int) {
	if n <= 1 {
		return
	}
	for i := 0; i < n; i++ {
		// 提前退出标志
		flag := false
		for j := 0; j < n-i-1; j++ {
			if a[j] > a[j+1] {
				a[j], a[j+1] = a[j+1], a[j]
				//此次冒泡有数据交换
				flag = true
			}
		}
		// fmt.Println(a)
		// 如果没有交换数据,提前跳过
		if !flag {
			break
		}
	}
}

func main() {
	arr := []int{8, 5, 6, 3, 1, 7}
	BubbleSort(arr, len(arr))
	fmt.Println("冒泡排序后:", arr) // [1 3 5 6 7 8]
}

关于冒泡排序的细节分析:

  • 冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法
  • 在冒泡排序中,当有相邻的两个元素大小相等的时候不做交换,因此相同大小的数据在排序前后顺序保持不变,所以冒泡排序是稳定的排序算法
  • 当数据已经是有序的,那么时间复杂度是 O(n);当数据刚好是倒序的,那么时间复杂度为 O(n^2);平均情况下的时间复杂度基本上是 O(n^2)

插入排序

插入排序:将数组中的元素分为已排序区间和未排序区间,排序的时候把未排序区间中的元素在已排序区间中找到合适的位置插入,并保证已排序区间数据一直有序,然后重复这个过程,直到未排序区间中元素为空。如下图所示(左侧为已排序区间,右侧为未排序区间):

插入排序包含两步操作,先是比较元素大小,再然后移动元素。将一个元素 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,然后找到合适的插入位置,再然后将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。参考代码如下:

// 插入排序,a表示数组,n表示数组大小
func InsertionSort(a []int, n int) {
	if n <= 1 {
		return
	}
	for i := 1; i < n; i++ {
		value := a[i]
		j := i - 1
		//查找要插入的位置并移动数据
		for ; j >= 0; j-- {
			if a[j] > value {
				a[j+1] = a[j]
			} else {
				break
			}
		}
		a[j+1] = value
		//fmt.Println(a)
	}
}

func main() {
	arr := []int{8, 5, 6, 3, 1, 7}
	InsertionSort(arr, len(arr))
	fmt.Println("插入排序后:", arr) // [1 3 5 6 7 8]
}

关于插入排序的细节分析: 

  • 插入排序不需要额外的存储空间,所以空间复杂度是 O(1),是一个原地排序算法
  • 插入排序中对于值相同的元素,可以选择将后面出现的元素插入到前面出现元素的后面,就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法
  • 当数据已经是有序的,那么时间复杂度是 O(n);当数据刚好是倒序的,那么时间复杂度为 O(n^2);平均情况下的时间复杂度基本上是 O(n^2)

选择排序

选择排序和插入排序有点类似,也分为 已排序区间 和 未排序区间,只不过 选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。如下图所示:

参考代码:

// 选择排序,a表示数组,n表示数组大小
func SelectionSort(a []int, n int) {
	if n <= 1 {
		return
	}
	for i := 0; i < n; i++ {
		// 查找最小值
		minIndex := i
		for j := i + 1; j < n; j++ {
			if a[j] < a[minIndex] {
				minIndex = j
			}
		}

		// 交换
		a[i], a[minIndex] = a[minIndex], a[i]
		//fmt.Println(a)
	}
}

func main() {
	arr := []int{8, 5, 6, 3, 1, 7}
	SelectionSort(arr, len(arr))
	fmt.Println("选择排序后:", arr) // [1 3 5 6 7 8]
}

关于选择排序的细节分析: 

  • 选择排序空间复杂度是 O(1),是一个原地排序算法
  • 选择排序每次都要找剩余未排序元素中的最小值和前面的元素交换位置,这样破坏了稳定性,因此选择排序是不稳定的排序算法
  • 选择排序的时间复杂度是 O(n^2)

三种排序的效率比较:

是否原地排序是否稳定排序最好最坏平均
冒泡排序O(n)O(n^2)O(n^2)
插入排序O(n)O(n^2)O(n^2)
选择排序O(n^2)O(n^2)O(n^2)

【问】插入排序和冒泡排序的时间复杂度都是 O(n^2),为什么在开发中更倾向于使用插入排序算法 而不是 冒泡排序算法?

【答】冒泡排序交换数据时需要 3 个赋值操作,而插入排序移动数据只需要 1 个赋值操作,在运算量很大的时候,插入排序相对来说有一定优势。

源代码:sort1/SortDemo1.go · 浮尘/go-algo-demo - Gitee.com 

【每日一练:K 个一组翻转链表】

力扣25. K 个一组翻转链表

给你链表的头节点 head ,每 k 个节点一组进行翻转,请你返回修改后的链表。k 是一个正整数,它的值小于或等于链表的长度。如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。你不能只是单纯的改变节点内部的值,而是需要实际进行节点交换。

示例1:输入:head = [1,2,3,4,5], k = 2,输出:[2,1,4,3,5]。

示例2:输入:head = [1,2,3,4,5], k = 3,输出:[3,2,1,4,5]。

思路1:使用递归,时间复杂度: O(N),空间复杂度: O(N/K)。

type ListNode struct {
	Next  *ListNode
	value interface{}
}

func reverseKGroup1(head *ListNode, k int) *ListNode {
	count := 0
	cur := head
	var tmp *ListNode
	for cur != nil && count < k {
		count++
		cur = cur.Next
	}
	// 当前 k-group 压根没有k个node,那么直接保持这个k-group不动返回head
	if count < k {
		return head
	}
	// last是k+1个节点后的链表的翻转结果
	last := reverseKGroup1(cur, k)
	// 从第一个节点开始反转,第一个节点挂在last前面,把last换成第一个节点
	// 第二个节点挂在last前面,继续把last换成第一个节点,直到把k个节点都反转完
	for count = 0; count < k; count++ {
		tmp = head
		head = head.Next
		tmp.Next = last
		last = tmp
	}
	return last
}

func main() {
	n5 := &ListNode{value: 5}
	n4 := &ListNode{value: 4, Next: n5}
	n3 := &ListNode{value: 3, Next: n4}
	n2 := &ListNode{value: 2, Next: n3}
	n1 := &ListNode{value: 1, Next: n2}

	n1.Print() //原链表: 1->2->3->4->5
	//reverseKGroup1(n1, 2).Print() //每2个节点一组翻转: 2->1->4->3->5
	reverseKGroup1(n1, 3).Print() //每3个节点一组翻转: 3->2->1->4->5
}

思路2:使用循环,时间复杂度: O(N),空间复杂度: O(1)。

func reverseKGroup2(head *ListNode, k int) *ListNode {
	dummy := &ListNode{Next: nil, value: -1}
	dummy.Next = head
	dummy2 := dummy
	for {
		p := dummy2.Next
		start := p
		count := 0
		for count < k && p != nil {
			p = p.Next
			count++
		}
		//如果少于k个节点,则不需要翻转
		if count < k {
			break
		}
		//k个节点后的那个节点
		last := p
		//一个一个连过去
		p = dummy2.Next
		for i := 0; i < k; i++ {
			next := p.Next
			p.Next = last
			last = p
			p = next
		}
		//翻转后的结果
		dummy2.Next = last
		//当前的第k个数据就是先前的第一个数据
		dummy2 = start
	}
	return dummy.Next
}

func main() {
	//reverseKGroup2(n1, 2).Print() //每2个节点一组翻转: 2->1->4->3->5
	reverseKGroup2(n1, 3).Print() //每3个节点一组翻转: 3->2->1->4->5
}

源代码:leetcode/ReverseKGroup.go · 浮尘/go-algo-demo - Gitee.com

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

浮尘笔记

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值