算法图解注疏(待续)

本文介绍了算法的基本概念,包括二分查找、大O表示法和递归。特别讨论了快速排序的原理和时间复杂度,以及哈希表在解决冲突和滑动窗口问题中的应用。最后,简述了广度优先搜索在图遍历中的作用,并展示了如何用队列实现这一过程。
摘要由CSDN通过智能技术生成

算法简介

二分查找

二分查找是一种算法,其输入是一个有序的元素列表(必须有序的原因稍后解释)。如果要查找的元素包含在列表中,二分查找返回其位置;否则返回null

  1. 二分查找是一种在每次比较之后将查找空间一分为二的算法。
  2. 每次需要查找集合中的索引或元素时,都应该考虑二分查找。
  3. 如果集合是无序的,可以考虑先对其进行排序。

简单查找

下面的示例说明了二分查找的工作原理。我随便想一个1~100的数字,你的目标是以最少的次数猜到这个数字。你每次猜测后,我会说小了、大了或对了。

假设你从1开始依次往上猜,猜测过程会是这样:
在这里插入图片描述
这是简单查找,更准确的说法是傻找。每次猜测都只能排除一个数字。如果我想的数字是99,你得猜99次才能猜到!

更佳的查找方式

下面是一种更佳的猜法。从50开始,
在这里插入图片描述
小了,但排除了一半的数字!至此,你知道1~50都小了。接下来,你猜75。

大了,那余下的数字又排除了一半!使用二分查找时,你猜测的是中间的数字,从而每次都将余下的数字排除一半。接下来,你猜63(50和75中间的数字)。

这就是二分查找!一般而言,对于包含n个元素的列表,用二分查找最多需要log2n步,而简单查找最多需要n步。

你可能不记得什么是对数了,但很可能记得什么是幂。log10100相当于问“将多少个10相乘的结果为100”。答案是两个:10 × 10 = 100。因此,log10100 = 2。对数运算是幂运算的逆运算。
在这里插入图片描述

二分查找的几种模板

模板1
  1. 二分查找的最基础和最基本的形式。

  2. 查找条件可以在不与元素的两侧进行比较的情况下确定(或使用它周围的特定元素)。

  3. 不需要后处理,因为每一步中,你都在检查是否找到了元素。如果到达末尾,则知道未找到该元素。

  4. 模板特征:

    初始条件:left = 0, right = length-1
    终止:	 left > right
    向左查找:right = mid-1
    向右查找:left = mid+1
    

模板:

func search(nums []int, target int) int {
	left :=0
	right := len(nums)-1
	for left<=right{
		mid := (left+right)/2
		if nums[mid]==target{
			return mid
		}else if nums[mid]>target{
			right = mid-1
		}else{
			left = mid +1
		}
	}
	return -1
}

以上的代码还可能会存在一个问题:溢出!

var left,right,mid uint8

left = 200;
right = 250;

//则left+right =450 > 255,此时已经溢出了
//0001 1100 0010 因为只能存储8位,实际1100 0010=194
mid = (left+right)/2;  //此时实际mid=194/2

//此方法绝对不会溢出,最好写成这样
mid = left+(right-left)/2; //200+(250-200)/2 = 225
模板2
  1. 查找条件需要访问元素的直接右邻居。

  2. 使用元素的右邻居来确定是否满足条件,并决定是向左还是向右。

  3. 保证查找空间在每一步中至少有 2 个元素。

  4. 需要进行后处理。 当你剩下 1 个元素时,循环 / 递归结束。 需要评估剩余元素是否符合条件。

  5. 模板特征:

    初始条件:left = 0, right = length
    终止:	 left == right
    向左查找:right = mid
    向右查找:left = mid+1
    

模板:它用于查找需要访问数组中当前索引及其直接右邻居索引的元素或条件。

def binarySearch(nums, target):
    if len(nums) == 0:
        return -1

    left, right = 0, len(nums)
    while left < right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid + 1
        else:
            right = mid

    # Post-processing:
    # End Condition: left == right
    if left != len(nums) and nums[left] == target:
        return left
    return -1

例题:寻找峰值

峰值元素是指其值严格大于左右相邻值的元素。给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

func findPeakElement(nums []int) int {
	left:=0
	right:=len(nums)-1
	
	for left<right{
		mid := left+(right-left)/2
		if nums[mid]<nums[mid+1]{
			left=mid+1
		}else{
			right=mid
		}
	}
	return left
}
模板3
  1. 搜索条件需要访问元素的直接左右邻居。

  2. 使用元素的邻居来确定它是向右还是向左。

  3. 保证查找空间在每个步骤中至少有 3 个元素。

  4. 需要进行后处理。 当剩下 2 个元素时,循环 / 递归结束。 需要评估其余元素是否符合条件。

  5. 模板特征:

    初始条件:left = 0, right = length-1
    终止:	 left + 1 == right
    向左查找:right = mid
    向右查找:left = mid
    

模板:它用于搜索需要访问当前索引及其在数组中的直接左右邻居索引的元素或条件。

def binarySearch(nums, target):
    """
    :type nums: List[int]
    :type target: int
    :rtype: int
    """
    if len(nums) == 0:
        return -1

    left, right = 0, len(nums) - 1
    while left + 1 < right:
        mid = (left + right) // 2
        if nums[mid] == target:
            return mid
        elif nums[mid] < target:
            left = mid
        else:
            right = mid

    # Post-processing:
    # End Condition: left + 1 == right
    if nums[left] == target: return left
    if nums[right] == target: return right
    return -1

运行时间

一般而言,应选择效率最高的算法,以最大限度地减少运行时间或占用空间。

简单查找逐个地检查数字,如果列表包含100个数字,最多需要猜100次。如果列表包含40亿个数字,最多需要猜40亿次。换言之,最多需要猜测的次数与列表长度相同,这被称为线性时间(linear time)。

二分查找则不同。如果列表包含100个元素,最多要猜7次;如果列表包含40亿个数字,最多需猜32次。厉害吧?二分查找的运行时间为对数时间(或log时间)。

在这里插入图片描述

大O表示法

大O表示法指出了算法有多快。例如,假设列表包含n 个元素。简单查找需要检查每个元素,因此需要执行n 次操作。使用大O表示法,这个运行时间为O(n)。单位秒呢?没有——大O表示法指的并非以秒为单位的速度。大O表示法让你能够比较操作数,它指出了算法运行时间的增速。

为检查长度为n 的列表,二分查找需要执行log n 次操作。使用大O表示法,这个运行时间怎么表示呢?O(log n)。一般而言,大O表示法像下面这样。

在这里插入图片描述

大O表示法指出了最糟情况下的运行时间

假设你使用简单查找在电话簿中找人。你知道,简单查找的运行时间为O(n),这意味着在最糟情况下,必须查看电话簿中的每个条目。如果要查找的是Adit——电话簿中的第一个人,一次就能找到,无需查看每个条目。考虑到一次就找到了Adit,请问这种算法的运行时间是O(n)还是O(1)呢?

简单查找的运行时间总是为O(n)。查找Adit时,一次就找到了,这是最佳的情形,但大O表示法说的是最糟的情形。因此,你可以说,在最糟情况下,必须查看电话簿中的每个条目,对应的运行时间为O(n)。这是一个保证——你知道简单查找的运行时间不可能超过O(n)。

一些常见的大O运行时间

下面按从快到慢的顺序列出了经常会遇到的5种大O运行时间。

  1. O(log n),也叫对数时间,这样的算法包括二分查找。

  2. O(n),也叫线性时间,这样的算法包括简单查找。

  3. O(n * log n),这样的算法包括快速排序——一种速度较快的排序算法。

  4. O(n2),这样的算法包括选择排序——一种速度较慢的排序算法。

  5. O(n!),这样的算法包括旅行商问题的解决方案——一种非常慢的算法。

假设你要绘制一个包含16格的网格,且有5种不同的算法可供选择,这些算法的运行时间如上所示。如果你选择第一种算法,绘制该网格所需的操作数将为4(log 16 = 4)。假设你每秒可执行10次操作,那么绘制该网格需要0.4秒。如果要绘制一个包含1024格的网格呢?这需要执行10(log 1024 = 10)次操作,换言之,绘制这样的网格需要1秒。这是使用第一种算法的情况。

第二种算法更慢,其运行时间为O(n)。即要绘制16个格子,需要执行16次操作;要绘制1024个格子,需要执行1024次操作。执行这些操作需要多少秒呢?

下面按从快到慢的顺序列出了使用这些算法绘制网格所需的时间:

在这里插入图片描述
实际上,并不能如此干净利索地将大O运行时间转换为操作数,但就目前而言,这种准确度足够了当前,我们获得的主要启示如下:

  1. 算法的速度指的并非时间,而是操作数的增速。

  2. 谈论算法的速度时,说的是随着输入的增加,其运行时间将以什么样的速度增加。

  3. 算法的运行时间用大O表示法表示。

  4. O(log n)比O(n)快,当需要搜索的元素越多时,前者比后者快得越多。

O(n!)旅行商问题

这位旅行商(姑且称之为Opus吧)要前往这5个城市,同时要确保旅程最短。为此,可考虑前往这些城市的各种可能顺序。

在这里插入图片描述

在这里插入图片描述
对于每种顺序,他都计算总旅程,再挑选出旅程最短的路线。5个城市有120种不同的排列方式。因此,在涉及5个城市时,解决这个问题需要执行120次操作。涉及6个城市时,需要执行720次操作(有720种不同的排列方式)。涉及7个城市时,需要执行5040次操作!

推而广之,涉及n个城市时,需要执行n!(n的阶乘)次操作才能计算出结果。因此运行时间为O(n!),即阶乘时间。除非涉及的城市数很少,否则需要执行非常多的操作。如果涉及的城市数超过100,根本就不能在合理的时间内计算出结果——等你计算出结果,太阳都没了。

这种算法很糟糕!Opus应使用别的算法,可他别无选择。这是计算机科学领域待解的问题之一。对于这个问题,目前还没有找到更快的算法,有些很聪明的人认为这个问题根本就没有更巧妙的算法。

选择排序

内存的工作原理

假设你去看演出,需要将东西寄存。寄存处有一个柜子,柜子有很多抽屉。每个抽屉可放一样东西,你有两样东西要寄存,因此要了两个抽屉。在你可以去看演出了!这大致就是计算机内存的工作原理。计算机就像是很多抽屉的集合体,每个抽屉都有地址。

在这里插入图片描述
fe0ffeeb是一个内存单元的地址。

需要将数据存储到内存时,你请求计算机提供存储空间,计算机给你一个存储地址。需要存储多项数据时,有两种基本方式——数组和链表。但它们并非都适用于所有的情形,因此知道它们的差别很重要。

数组和链表

有时候,需要在内存中存储一系列元素。假设你要编写一个管理待办事项的应用程序,为此需要将这些待办事项存储在内存中。鉴于数组更容易掌握,我们先将待办事项存储在数组中。使用数组意味着所有待办事项在内存中都是相连的(紧靠在一起的)。

现在假设你要添加第四个待办事项,但后面的那个抽屉放着别人的东西!

在这里插入图片描述
在这种情况下,你需要请求计算机重新分配一块可容纳4个待办事项的内存,再将所有待办事项都移到那里。

因此添加新元素的速度会很慢。一种解决之道是“预留座位”:即便当前只有3个待办事项,也请计算机提供10个位置,以防需要添加待办事项。这样,只要待办事项不超过10个,就无需转移。这是一个不错的权变措施,但你应该明白,它存在如下两个缺点。

  1. 你额外请求的位置可能根本用不上,这将浪费内存。你没有使用,别人也用不了。

  2. 待办事项超过10个后,你还得转移。

因此,这种权宜措施虽然不错,但绝非完美的解决方案。对于这种问题,可使用链表来解决。

————————————————————————————————————————————

链表中的元素可存储在内存的任何地方。

链表的每个元素都存储了下一个元素的地址,从而使一系列随机的内存地址串在一起。

在这里插入图片描述
在需要读取链表的最后一个元素时,你不能直接读取,因为你不知道它所处的地址,必须先访问元素#1,从中获取元素#2的地址,再访问元素#2并从中获取元素#3的地址,以此类推,直到访问最后一个元素。需要同时读取所有元素时,链表的效率很高:你读取第一个元素,根据其中的地址再读取第二个元素,以此类推。但如果你需要跳跃,链表的效率真的很低。

数组与此不同:你知道其中每个元素的地址。例如,假设有一个数组,它包含五个元素,起始地址为00,那么元素#5的地址是多少呢?

在这里插入图片描述
只需执行简单的数学运算就知道:04。需要随机地读取元素时,数组的效率很高,因为可迅速找到数组的任何元素。在链表中,元素并非靠在一起的,你无法迅速计算出第五个元素的内存地址,而必须先访问第一个元素以获取第二个元素的地址,再访问第二个元素以获取第三个元素的地址,以此类推,直到访问第五个元素。

下面列出了常见的数组和链表操作的运行时间。

在这里插入图片描述

数组和链表哪个用得更多呢?显然要看情况。但数组用得很多,因为它支持随机访问。有两种访问方式:随机访问和顺序访问。顺序访问意味着从第一个元素开始逐个地读取元素。链表只能顺序访问:要读取链表的第十个元素,得先读取前九个元素,并沿链接找到第十个元素。随机访问意味着可直接跳到第十个元素。

选择排序

假设你的计算机存储了很多乐曲。对于每个乐队,你都记录了其作品被播放的次数。

在这里插入图片描述
你要将这个列表按播放次数从多到少的顺序排列,从而将你喜欢的乐队排序。该如何做呢?

一种办法是遍历这个列表,找出作品播放次数最多的乐队,并将该乐队添加到一个新列表中。

要找出播放次数最多的乐队,必须检查列表中的每个元素。这需要的时间为O(n)。因此对于这种时间为O(n)的操作,你需要执行n次。
在这里插入图片描述
需要的总时间为 O(n × n),即O(n2)。

选择排序是一种灵巧的算法,但其速度不是很快。快速排序是一种更快的排序算法,其运行时间为O(n log n)。

func selectorSort(nums []int) {
	length := len(nums)

	for index := 0; index < length-1; index++ {
		for ptr := index + 1; ptr < length; ptr++ {
			if nums[ptr] < nums[index] {
				nums[ptr], nums[index] = nums[index], nums[ptr]
			}
		}
	}
}

递归

假设你在祖母的阁楼中翻箱倒柜,发现了一个上锁的神秘手提箱。祖母告诉你,钥匙很可能在下面这个盒子里。
在这里插入图片描述

这个盒子里有盒子,而盒子里的盒子又有盒子。钥匙就在某个盒子中。为找到钥匙,你将使用什么算法?

下面是一种方法。

在这里插入图片描述
(1) 创建一个要查找的盒子堆。

(2) 从盒子堆取出一个盒子,在里面找。

(3) 如果找到的是盒子,就将其加入盒子堆中,以便以后再查找。

(4) 如果找到钥匙,则大功告成!

(5) 回到第二步。

下面是另一种方法。
在这里插入图片描述
(1) 检查盒子中的每样东西。

(2) 如果是盒子,就回到第一步。

(3) 如果是钥匙,就大功告成!

第一种方法使用的是while循环:只要盒子堆不空,就从中取一个盒子,并在其中仔细查找。伪代码如下:

在这里插入图片描述

第二种方法使用递归——函数调用自己,这种方法的伪代码如下。

def look_for_key(box): 
	for item in box:
		if item.is_a_box():
 			look_for_key(item)   ←------递归!
 		elif item.is_a_key():
			print "found the key!"

这两种方法的作用相同,第二种方法更清晰。递归只是让解决方案更清晰,并没有性能上的优势。实际上,在有些情况下,使用循环的性能更好。

基线条件和递归条件

由于递归函数调用自己,因此编写这样的函数时很容易出错,进而导致无限循环。

编写递归函数时,必须告诉它何时停止递归。正因为如此,每个递归函数都有两部分:基线条件(base case)和递归条件(recursive case)。递归条件指的是函数调用自己,而基线条件则指的是函数不再调用自己,从而避免形成无限循环。

在这里插入图片描述

调用栈不仅对编程来说很重要,使用递归时也必须理解这个概念。

假设你去野外烧烤,并为此创建了一个待办事项清单——一叠便条。

一叠便条要简单得多:插入的待办事项放在清单的最前面;读取待办事项时,你只读取最上面的那个,并将其删除。因此这个待办事项清单只有两种操作:压入(插入)和弹出(删除并读取)。

在这里插入图片描述

计算机在内部使用被称为调用栈的栈。我们来看看计算机是如何使用调用栈的。下面是一个简单的函数。这个函数问候用户,再调用另外两个函数。这两个函数的代码如下。

def greet(name):
	print "hello, " + name + "!" 
	greet2(name)
	print "getting ready to say bye..."
	bye()
def greet2(name):
	print "how are you, " + name + "?" 
def bye():
	print "ok bye!"

假设你调用greet(“maggie”),计算机将首先为该函数调用分配一块内存。变量name被设置为maggie,这需要存储到内存中。
在这里插入图片描述
每当你调用函数时,计算机都像这样将函数调用涉及的所有变量的值存储到内存中。接下来,你打印hello, maggie!,再调用greet2(“maggie”)。同样,计算机也为这个函数调用分配一块内存。
在这里插入图片描述
计算机使用一个栈来表示这些内存块,其中第二个内存块位于第一个内存块上面。你打印how are you, maggie?,然后从函数调用返回。此时,栈顶的内存块被弹出。

在这里插入图片描述
现在,栈顶的内存块是函数greet的,这意味着你返回到了函数greet。当你调用函数greet2时,函数greet只执行了一部分。这是一个重要概念:调用另一个函数时,当前函数暂停并处于未完成状态。该函数的所有变量的值都还在内存中。执行完函数greet2后,你回到函数greet,并从离开的地方开始接着往下执行:首先打印getting ready to say bye.,再调用函数bye。
在这里插入图片描述
在栈顶添加了函数bye的内存块。然后,你打印ok bye!,并从这个函数返回。
在这里插入图片描述
现在你又回到了函数greet。由于没有别的事情要做,你就从函数greet返回。这个栈用于存储多个函数的变量,被称为调用栈。

递归调用栈

递归函数也使用调用栈!来看看递归函数factorial的调用栈。factorial(5)写作5!,其定义如下:5! = 5 * 4 * 3 * 2 * 1。同理,factorial(3)为3 * 2 * 1。下面是计算阶乘的递归函数。

def fact(x): 
	if x == 1:
		return 1 else:
	return x * fact(x-1)

下面来详细分析调用fact(3)时调用栈是如何变化的。
在这里插入图片描述
注意,每个fact调用都有自己的x变量。在一个函数调用中不能访问另一个的x变量。

  1. 每个递归函数都有两个条件:基线条件和递归条件。

  2. 栈有两种操作:压入和弹出。

  3. 所有函数调用都进入调用栈。

  4. 调用栈可能很长,这将占用大量的内存。

快速排序

分而治之(divide and conquer,D&C)

D&C并不那么容易掌握,将通过示例来介绍。首先,介绍一个直观的示例;

假设你是农场主,有一小块土地。你要将这块地均匀地分成方块,且分出的方块要尽可能大。显然,下面的分法都不符合要求。
在这里插入图片描述
如何将一块地均匀地分成方块,并确保分出的方块是最大的呢?使用D&C策略!D&C算法是递归的。使用D&C解决问题的过程包括两个步骤。

(1) 找出基线条件,这种条件必须尽可能简单。

(2) 不断将问题分解(或者说缩小规模),直到符合基线条件。

下面就来使用D&C找出前述问题的解决方案。可你能使用的最大方块有多大呢?

首先,找出基线条件。最容易处理的情况是,一条边的长度是另一条边的整数倍。
在这里插入图片描述
如果一边长25 m,另一边长50 m,那么可使用的最大方块为 25 m×25 m。换言之,可以将这块地分成两个这样的方块。

现在需要找出递归条件,这正是D&C的用武之地。根据D&C的定义,每次递归调用都必须缩小问题的规模。如何缩小前述问题的规模呢?我们首先找出这块地可容纳的最大方块。
在这里插入图片描述
你可以从这块地中划出两个640 m×640 m的方块,同时余下一小块地。现在是顿悟时刻:何不对余下的那一小块地使用相同的算法呢?

最初要划分的土地尺寸为1680 m×640 m,而现在要划分的土地更小,为640 m×400 m。适用于这小块地的最大方块,也是适用于整块地的最大方块。换言之,你将均匀划分1680 m×640 m土地的问题,简化成了均匀划分640 m×400 m土地的问题!对于640 m × 400 m的土地,可从中划出的最大方块为400 m × 400 m。
在这里插入图片描述
这将余下一块更小的土地,其尺寸为400 m × 240 m。你可从这块土地中划出最大的方块,余下一块更小的土地,其尺寸为240 m × 160 m。接下来,从这块土地中划出最大的方块,余下一块更小的土地。余下的这块土地满足基线条件,因为160是80的整数倍。将这块土地分成两个方块后,将不会余下任何土地!
在这里插入图片描述
因此,对于最初的那片土地,适用的最大方块为80 m× 80 m。
在这里插入图片描述
这里重申一下D&C的工作原理:

(1) 找出简单的基线条件;

(2) 确定如何缩小问题的规模,使其符合基线条件。

D&C并非可用于解决问题的算法,而是一种解决问题的思路。

再来看一个例子。给定一个数字数组。你需要将这些数字相加,并返回结果。使用循环很容易完成这种任务。但如何使用递归函数来完成这种任务呢?
在这里插入图片描述

第一步:找出基线条件。最简单的数组什么样呢?请想想这个问题,再接着往下读。如果数组不包含任何元素或只包含一个元素,计算总和将非常容易。

在这里插入图片描述
因此这就是基线条件。

第二步:每次递归调用都必须离空数组更近一步。如何缩小问题的规模呢?下面是一种办法。
在这里插入图片描述
函数sum的工作原理类似于下面这样。
在这里插入图片描述
这个函数的运行过程如下。
在这里插入图片描述

快速排序

快速排序是一种常用的排序算法,比选择排序快得多。例如,C语言标准库中的函数qsort实现的就是快速排序。快速排序也使用了D&C。

下面来使用快速排序对数组进行排序。对排序算法来说,最简单的数组什么样呢?还记得前一节的“提示”吗?就是根本不需要排序的数组。

在这里插入图片描述
因此,基线条件为数组为空或只包含一个元素。在这种情况下,只需原样返回数组——根本就不用排序。

def quicksort(array): 
	if len(array) < 2:
		return array

我们来看看更长的数组。对包含两个元素的数组进行排序也很容易,检查第二个元素是否比第一个元素小,小就交换位置。
在这里插入图片描述

包含三个元素的数组呢?要使用D&C,因此需要将数组分解,直到满足基线条件。下面介绍快速排序的工作原理。首先,从数组中选择一个元素,这个元素被称为基准值(pivot)。

在这里插入图片描述

稍后再介绍如何选择合适的基准值。我们暂时将数组的第一个元素用作基准值。接下来,找出比基准值小的元素以及比基准值大的元素。

在这里插入图片描述
这被称为分区(partitioning)。现在你有:

  1. 一个由所有小于基准值的数字组成的子数组;基准值;
  2. 一个由所有大于基准值的数组组成的子数组。

这里只是进行了分区,得到的两个子数组是无序的。但如果这两个数组是有序的,对整个数组进行排序将非常容易。

如何对子数组进行排序呢?对于包含两个元素的数组(左边的子数组)以及空数组(右边的子数组),快速排序知道如何将它们排序,因此只要对这两个子数组进行快速排序,再合并结果,就能得到一个有序数

quicksort([15, 10]) + [33] + quicksort([])

刚才你大致见识了归纳证明!归纳证明是一种证明算法行之有效的方式,它分两步:基线条件和归纳条件。是不是有点似曾相识的感觉?例如,假设我要证明我能爬到梯子的最上面。归纳条件是这样的:如果我站在一个横档上,就能将脚放到上面一个横档上。换言之,如果我站在第二个横档上,就能爬到第三个横档。这就是归纳条件。而基线条件是这样的,即我已经站在第一个横档上。因此,通过每次爬一个横档,我就能爬到梯子最顶端。

对于快速排序,可使用类似的推理。在基线条件中,我证明这种算法对空数组或包含一个元素的数组管用。在归纳条件中,我证明如果快速排序对包含一个元素的数组管用,对包含两个元素的数组也将管用;如果它对包含两个元素的数组管用,对包含三个元素的数组也将管用,以此类推。因此,我可以说,快速排序对任何长度的数组都管用。这里不再深入讨论归纳证明,但它很有趣,并与D&C协同发挥作用。

下面是快速排序的代码。

def quicksort(array): 
	if len(array) < 2:
		return array   ←------基线条件:为空或只包含一个元素的数组是“有序”的else:
	pivot = array[0]------递归条件
	less = [i for i in array[1:] if i <= pivot]------由所有小于等于基准值的
	greater = [i for i in array[1:] if i > pivot]------由所有大于基准值的
	return quicksort(less) + [pivot] + quicksort(greater)
	
print quicksort([10, 5, 2, 3])

这段代码是一个递归实现的快速排序算法。它使用了 Python 语言来实现快速排序,并且采用了一种相对简洁的递归写法。虽然在代码形式上与传统的快速排序实现有所不同,但本质上仍然是一个快速排序算法,具有相同的时间复杂度。

让我们来详细解释这段代码的逻辑:

  1. 首先,定义了一个 quicksort 函数,该函数接受一个列表 array 作为输入。

  2. quicksort 函数中,首先检查列表 array 的长度,如果长度小于 2,即为空或只包含一个元素,那么该列表已经是有序的,直接返回该列表。

  3. 如果列表长度大于等于 2,则进行递归排序:

    a. 首先,选择列表中的第一个元素 pivot 作为基准值(也可以是其他选择,但这里选择的是第一个元素)。

    b. 创建两个新列表 lessgreater,用来存放比基准值小于等于和大于的元素。

    c. 使用列表解析生成 lessgreater 列表:less 中包含所有小于等于基准值的元素,而 greater 中包含所有大于基准值的元素。

    d. 对 lessgreater 两个列表分别递归调用 quicksort 函数,对它们进行排序。

    e. 最后,将 less 列表、基准值 pivotgreater 列表拼接在一起,形成最终的有序列表,并返回。

  4. print 语句中,调用 quicksort 函数并传入 [10, 5, 2, 3] 作为输入,然后打印排序后的结果。

这段代码使用了递归来不断划分子列表并排序,直到达到基线条件(列表长度小于 2),从而完成整个快速排序过程。虽然在传统的快速排序实现中,可能会使用原地排序和分区技巧,但这个实现依然是一个有效的快速排序算法。

再给出第二种快排方式:

def partition(li, left, right):
    """
    归位操作
    :param li: 列表
    :param left: 左边下标
    :param right: 右边下标
    :return:
    """
    tmp = li[left]
    while left < right:
        while left < right and li[right] >= tmp:  # 在右边找比tmp小的
            right -= 1
        li[left] = li[right]  # 把右边的值写到左边
        print(li, 'right')

        while left < right and li[left] <= tmp:  # 在左边找比tmp大的
            left += 1
        li[right] = li[left]  # 把左边的值写到右边
        print(li, 'left')
    li[left] = tmp  # 把tmp归位
    return left


def quick_sort(li, left, right):
	"""
    快排
    :param li: 列表
    :param left: 左边下标
    :param right: 右边下标
    :return:
    """
    if left < right:
        mid = partition(li, left, right)
        quick_sort(li, left, mid - 1)
        quick_sort(li, mid + 1, right)

这段代码是另一种快速排序实现方式,与之前的递归实现方式相比,它采用了一种基于指针的原地排序方法。这种实现方式通常称为“Hoare Partition”的快速排序。

主要区别在于如何进行分区(Partition)操作:

  1. Hoare Partition:

    • 分区函数 partition(li, left, right) 用于将列表 li 的子区间 [left, right] 进行分区操作,将小于等于基准值 tmp 的元素移到左边,大于基准值的元素移到右边,并返回基准值的索引位置。

    • partition 函数中,使用两个指针 leftright 分别从区间的左右两端开始遍历,不断找到需要交换的元素,然后进行交换,直到两个指针相遇。

    • 该方法在进行分区操作时,可能会产生两侧的子区间不平衡,即某一侧可能比较短,可能导致快速排序的效率下降。为了解决这个问题,partition 函数返回的基准值索引位置不是固定的中间位置,而是根据实际情况得出的。

  2. 递归快速排序:

    • 快速排序函数 quick_sort(li, left, right) 采用递归方式进行快速排序。

    • quick_sort 函数中,先调用 partition 函数将列表分为两个子区间,然后对两个子区间分别递归调用 quick_sort 函数进行排序。

不同之处在于实现细节和分区方法,而核心思想都是快速排序。partition 函数负责将列表进行分区,quick_sort 函数负责递归调用实现排序过程。这种 Hoare Partition 的快速排序在实际中也是常用且高效的排序算法。

再谈大O表示法

快速排序的独特之处在于,其速度取决于选择的基准值。在讨论快速排

序的运行时间前,我们再来看看最常见的大O运行时间。
在这里插入图片描述
上述图表中的时间是基于每秒执行10次操作计算得到的。这些数据并不准确,这里提供它们只是想让你对这些运行时间的差别有大致认识。实际上,计算机每秒执行的操作远不止10次。

平均情况和最糟情况

快速排序的性能高度依赖于你选择的基准值。假设你总是将第一个元素用作基准值,且要处理的数组是有序的。由于快速排序算法不检查输入数组是否有序,因此它依然尝试对其进行排序。
在这里插入图片描述

注意,数组并没有被分成两半,相反,其中一个子数组始终为空,这导致调用栈非常长。现在假设你总是将中间的元素用作基准值,在这种情况下,调用栈如下。
在这里插入图片描述
调用栈短得多!因为你每次都将数组分成两半,所以不需要那么多递归调用。你很快就到达了基线条件,因此调用栈短得多。

第一个示例展示的是最糟情况,而第二个示例展示的是最佳情况。在最糟情况下,栈长为O(n),而在最佳情况下,栈长为O(log n)。

现在来看看栈的第一层。你将一个元素用作基准值,并将其他的元素划分到两个子数组中。这涉及数组中的全部8个元素,因此该操作的时间为O(n)。在调用栈的第一层,涉及全部8个元素,但实际上,在调用栈的每层都涉及O(n)个元素。

即便以不同的方式划分数组,每次也将涉及O(n)个元素。因此,完成每层所需的时间都为O(n)。

在这里插入图片描述
在这个示例中,层数为O(log n)(用技术术语说,调用栈的高度为O(log n)),而每层需要的时间为O(n)。因此整个算法需要的时间为O(n) * O(log n) = O(n log n)。这就是最佳情况。

在最糟情况下,有O(n)层,因此该算法的运行时间为O(n) * O(n) = O(n2)。

最佳情况也是平均情况。只要你每次都随机地选择一个数组元素作为基准值,快速排序的平均运行时间就将
为O(n log n)。快速排序是最快的排序算法之一,也是D&C典范。

散列表

哈希表 是一种使用哈希函数组织数据的数据结构,它支持快速插入和搜索。

原理

借助 哈希函数,将键映射到存储桶地址。

  1. 首先开辟一定长度的,具有连续物理地址的桶数组;

  2. 插入一个新的键时,哈希函数将决定该键应该分配到哪个桶中,并将该键存储在相应的桶中;

  3. 搜索一个键时,哈希表将使用哈希函数来找到对应的桶,并在该桶中进行搜索

负载因子

  1. 负载因子 又叫装填因子,是哈希表的一个重要参数,它反映了哈希表的装满程度。

  2. 实际利用桶的个数 与 桶的总数 的比值,称为负载因子。

  3. 比较合理的负载因子是 0.7,如果数据量是 7,则会创建 10 个桶,以此类推。随着插入的数据量的增加,计算机会逐渐增加桶的个数,并选择合适的哈希函数,使得数据经过映射之后能均匀地分布在桶中。

冲突解决

线性试探法

线性试探法属于开放定址法的一种,除此之外,开放定址法还包括二次探测法、双重哈希法等。

线性试探法,就是当插入键 key 时,如果发现桶单元 bucket[hash(key)] 已经被占用,则向下线性寻找,直到找到可以使用的空桶。

链地址法

解决冲突的另一种办法是将桶内产生冲突的键串联成一个链表。

再哈希法

发生冲突时,通过使用另一个哈希函数来避免冲突。

公共溢出区法

建立另一个哈希表 dict_overflow 作为公共溢出区,当发成冲突时则将该键保存在该哈希表中。

若查找的键发生冲突,则在公共溢出区进行线性查找。

哈希表与滑动窗口

滑动窗口

滑动窗口就是将数组或字符串中的一个分段,形象地看作一个“窗口”,通过更改“窗口”的左右边界,实现窗口的移动、缩放等操作。
在这里插入图片描述

使用哈希表

对于基本情况,滑动窗口使用双指针即可实现,但是有时会出现一些问题,假如如窗口中的元素为 “(aaaaaabc)”,左右边界指针分别为 i 和 j,如果我们想要将窗口变为 “(abc)”,可以选择将左边界指针 i 向右移动 5 步。

以上情况会造成时间的浪费,假如建立元素 a 为键,下标为值的哈希表 {“a”:5},那么元素 a 只需 1 步即可“跳跃”到下标 5 的位置。

优化运行时间,是滑动窗口问题中使用哈希表的一个目的。除此之外,哈希表还被用来统计窗口中的元素个数,以判断当前窗口的状态是否满足条件。

例题

只出现一次的数字:
给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。

解决方法:哈希表记录出现次数或直接异或操作

func singleNumber(nums []int) int {
	res:=0
	for _,val:=range nums{
		res = res ^ val
	}
	return res
}

存在重复元素 II:
给你一个整数数组 nums 和一个整数 k ,判断数组中是否存在两个 不同的索引 i 和 j ,满足 nums[i] == nums[j] 且 abs(i - j) <= k 。如果存在,返回 true ;否则,返回 false 。

解决方法:用map存储index和val

func containsNearbyDuplicate(nums []int, k int) bool {
	tmp:=make(map[int]int,len(nums))
	for index,val :=range nums{
		v,ok:=tmp[val]
		if !ok{
			tmp[val]=index
		}else{
			if int(math.Abs(float64(v-index)))<=k{
				return true
			}else{
				tmp[val]=index
			}
		}
	}
	return false
}

性能

在平均情况下,散列表执行各种操作的时间都为O(1)。O(1)被称为常量时间。
在这里插入图片描述
在平均情况下,散列表的查找(获取给定索引处的值)速度与数组一样快,而插入和删除速度与链表一样快,因此它兼具两者的优点!但在最糟情况下,散列表的各种操作的速度都很慢。因此,在使用散列表时,避开最糟情况至关重要。为此,需要避免冲突。而要避免冲突,需要有:

  • 较低的填装因子;
  • 良好的散列函数。

填装因子

散列表的填装因子很容易计算。
在这里插入图片描述
散列表使用数组来存储数据,因此你需要计算数组中被占用的位置数。例如,下述散列表的填装因子为2/5,即0.4。
在这里插入图片描述
一旦填装因子开始增大,你就需要在散列表中添加位置,这被称为调整长度(resizing)。

良好的散列函数

良好的散列函数让数组中的值呈均匀分布。
在这里插入图片描述

哈希表的hash函数对于不同的key可能运算得出同样的的index,但对于sha256,md5这种哈希函数,永远不可能得出一样的值,所以为什么哈希表的hash函数不使用md5这种hash函数呢?

哈希表的目标是将不同的键(key)映射到不同的索引(index),以便能够高效地查找和访问存储在哈希表中的数据。虽然 SHA-256 和 MD5 这些哈希函数能够产生不同的输出,但是它们通常不用作哈希表的哈希函数的原因有以下几点:

1. 碰撞概率:虽然 SHA-256 和 MD5 等哈希函数具有较低的碰撞概率,即不同的输入在哈希后产生相同的输出的可能性较小,但仍然存在碰撞的可能性。在哈希表中,碰撞会导致键值对的冲突,使得数据存储和检索变得复杂,影响哈希表的性能。

2. 效率:SHA-256 和 MD5 等哈希函数相对于简单的哈希函数,计算过程更加复杂。哈希表需要频繁地进行哈希计算,而使用复杂的哈希函数可能会导致不必要的计算开销,影响哈希表的性能和速度。

3. 资源消耗:SHA-256 和 MD5 等哈希函数通常产生较长的哈希值(256位和128位),而哈希表需要适当大小的索引范围,以避免哈希冲突。因此,使用较长的哈希值会增加哈希表所需的存储空间,这可能对内存和存储资源造成额外的负担。

因此,哈希表通常选择简单、快速且具有较低碰撞概率的哈希函数。这些哈希函数可能会通过取模运算将哈希值映射到合适的索引范围内,以确保较小的索引冲突,并在保持较好性能的同时尽量减少碰撞的可能性。这样能够保证哈希表的高效性和稳定性。

糟糕的散列函数让值扎堆,导致大量的冲突。
在这里插入图片描述

广度优先搜索

图简介

图模拟一组连接。例如,假设你与朋友玩牌,并要模拟谁欠谁钱,可像下面这样指出Alex欠Rama钱。
在这里插入图片描述
完整的欠钱图可能类似于下面这样。
在这里插入图片描述
Alex欠Rama钱,Tom欠Adit钱,等等。图由节点(node)和边(edge)组成。
在这里插入图片描述
图由节点和边组成。一个节点可能与众多节点直接相连,这些节点被称为邻居。在前面的欠钱图中,Rama是Alex的邻居。Adit不是Alex的邻居,因为他们不直接相连。但Adit既是Rama的邻居,又是Tom的邻居。

图用于模拟不同的东西是如何相连的。

广度优先搜索

广度优先搜索是一种用于图的查找算法,可帮助回答两类问题。

第一类问题:从节点A出发,有前往节点B的路径吗?

第二类问题:从节点A出发,前往节点B的哪条路径最短?

第一类问题

假设你经营着一个芒果农场,需要寻找芒果销售商,以便将芒果卖给他。在Facebook,你与芒果销售商有联系吗?为此,你可在朋友中查找。

这种查找很简单。首先,创建一个朋友名单。然后,依次检查名单中的每个人,看看他是否是芒果销售商。

在这里插入图片描述
假设你没有朋友是芒果销售商,那么你就必须在朋友的朋友中查找。检查名单中的每个人时,你都将其朋友加入名单。
在这里插入图片描述
这样一来,你不仅在朋友中查找,还在朋友的朋友中查找。别忘了,你的目标是在你的人际关系网中找到一位芒果销售商。因此,如果Alice不是芒果销售商,就将其朋友也加入到名单中。这意味着你将在她的朋
友、朋友的朋友等中查找。使用这种算法将搜遍你的整个人际关系网,直到找到芒果销售商。这就是广度优先搜索算法。

第二类问题(查找最短路径)

例如,朋友是一度关系,朋友的朋友是二度关系。
在这里插入图片描述
在你看来,一度关系胜过二度关系,二度关系胜过三度关系,以此类
推。因此,你应先在一度关系中搜索,确定其中没有芒果销售商后,才在二度关系中搜索。广度优先搜索就是这样做的!在广度优先搜索的执行过程中,搜索范围从起点开始逐渐向外延伸,即先检查一度关系,再检查二度关系。
将先检查Claire还是Anuj呢?Claire是一度关系,而Anuj是二度关系,因此将先检查Claire,后检查Anuj。

你也可以这样看,一度关系在二度关系之前加入查找名单。

你按顺序依次检查名单中的每个人,看看他是否是芒果销售商。这将先在一度关系中查找,再在二度关系中查找,因此找到的是关系最近的芒果销售商。广度优先搜索不仅查找从A到B的路径,而且找到的是最短的路径。

在这里插入图片描述
注意,只有按添加顺序查找时,才能实现这样的目的。换句话说,如果Claire先于Anuj加入名单,就需要先检查Claire,再检查Anuj。如果Claire和Anuj都是芒果销售商,而你先检查Anuj再检查Claire,结果将如何呢?找到的芒果销售商并非是与你关系最近的,因为Anuj是你朋友的朋友,而Claire是你的朋友。因此,你需要按添加顺序进行检查。有一个可实现这种目的的数据结构,那就是队列(queue)。

队列

队列是一种先进先出(First In First Out,FIFO)的数据结构,而栈是一种后进先出(Last In First Out,LIFO)的数据结构。

知道队列的工作原理后,我们来实现广度优先搜索!

实现图

首先,需要使用代码来实现图。图由多个节点组成。

每个节点都与邻近节点相连,如果表示类似于“你→Bob”这样的关系呢?好在你知道的一种结构让你能够表示这种关系,它就是散列表!
在这里插入图片描述
表示这种映射关系的Python代码如下。

graph = {}
graph["you"] = ["alice", "bob", "claire"]

图不过是一系列的节点和边,因此在Python中,只需使用上述代码就可表示一个图。那像下面这样更大的图呢?

在这里插入图片描述

graph = {}
graph["you"] = ["alice", "bob", "claire"]
graph["bob"] = ["anuj", "peggy"]
 
graph["alice"] = ["peggy"]
graph["claire"] = ["thom", "jonny"]
graph["anuj"] = []
graph["peggy"] = []
graph["thom"] = []
graph["jonny"] = []

Anuj、Peggy、Thom和Jonny都没有邻居,这是因为虽然有指向他们的箭头,但没有从他们出发指向其他人的箭头。这被称为有向图(directed graph),其中的关系是单向的。因此,Anuj是Bob的邻居,但Bob不是Anuj的邻居。无向图(undirected graph)没有箭头,直接相连的节点互为邻居。例如,下面两个图是等价的。
在这里插入图片描述

实现算法

先概述一下这种算法的工作原理。
在这里插入图片描述
首先,创建一个队列。在Python中,可使用函数deque来创建一个双端队列。

from collections import deque
search_queue = deque()------------创建一个队列
search_queue += graph["you"]------将你的邻居都加入到这个搜索队列中

graph[“you”]是一个数组,其中包含你的所有邻居,如[“alice”, “bob”, “claire”]。这些邻居都将加入到搜索队列中。

下面来看看其他的代码。

while search_queue:------只要队列不为空
	person = search_queue.popleft()------就取出其中的第一个人
	if person_is_seller(person):------检查这个人是否是芒果销售商
		print person + " is a mango seller!"------是芒果销售商	
		return True
	else:
		search_queue += graph[person]------不是芒果销售商。将这个人的朋友都加入队列
		return False------如果到达了这里,就说明队列中没人是芒果销售商

最后,你还需编写函数person_is_seller,判断一个人是不是芒果销售商,如下所示。

def person_is_seller(name): 
	return name[-1] == 'm'

这个函数检查人的姓名是否以m结尾:如果是,他就是芒果销售商。这种判断方法有点搞笑,但就这个示例而言是可行的。下面来看看广度优先搜索的执行过程。

在这里插入图片描述

这个算法将不断执行,直到满足以下条件之一:

  1. 找到一位芒果销售商;
  2. 队列变成空的,这意味着你的人际关系网中没有芒果销售商。

Peggy既是Alice的朋友又是Bob的朋友,因此她将被加入队列两次:一次是在添加Alice的朋友时,另一次是在添加Bob的朋友时。因此,搜索队列将包含两个Peggy。

但你只需检查Peggy一次,看她是不是芒果销售商。如果你检查两次,就做了无用功。因此,检查完一个人后,应将其标记为已检查,且不再检查他。

如果不这样做,就可能会导致无限循环。假设你的人际关系网类似于下面这样。

在这里插入图片描述
一开始,搜索队列包含你的所有邻居。现在你检查Peggy。她不是芒果销售商,因此你将其所有邻居都加入搜索队列。接下来,你检查自己。你不是芒果销售商,因此你将你的所有邻居都加入搜索队列。以此类推。这将形成无限循环,因为搜索队列将在包含你和包含Peggy之间反复切换。

检查一个人之前,要确认之前没检查过他,这很重要。为此,你可使用一个列表来记录检查过的人。

考虑到这一点后,广度优先搜索的最终代码如下。

def search(name): 
	search_queue = deque()
	search_queue += graph[name]
 
	searched = []------------------------------这个数组用于记录检查过的人
	while search_queue:
		person = search_queue.popleft()
		if person not in searched:----------仅当这个人没检查过时才检查
			if person_is_seller(person):
				print person + " is a mango seller!" 
				return True
			else:
				search_queue += graph[person]
				searched.append(person)------将这个人标记为检查过
	return False

search("you")

请尝试运行这些代码,看看其输出是否符合预期。你也许应该将函数person_is_seller改为更有意义的名称。

如果你在你的整个人际关系网中搜索芒果销售商,就意味着你将沿每条边前行(记住,边是从一个人到另一个人的箭头或连接),因此运行时间至少为O(边数)。

你还使用了一个队列,其中包含要检查的每个人。将一个人添加到队列需要的时间是固定的,即为O(1),因此对每个人都这样做需要的总时间为O(人数)。所以,广度优先搜索的运行时间为O(人数 + 边数),这通常写作O(V + E),其中V 为顶点(vertice)数,E 为边数。

广度优先遍历

借助队列实现

广度优先遍历呈现出一层一层向外扩张的特点,先看到的结点先遍历,后看到的结点后遍历,因此广度优先遍历可以借助队列实现。
在这里插入图片描述
遍历到一个结点时,如果这个结点有左(右)孩子结点,依次将它们加入队列。

树的广度优先遍历的写法模式相对固定:

  • 使用队列;
  • 在队列非空的时候,动态取出队首元素;
  • 取出队首元素的时候,把队首元素相邻的结点(非空)加入队列。

二叉树层序遍历

type TreeNode struct {
	Val   int
	Left  *TreeNode
	Right *TreeNode
}

func levelOrder(root *TreeNode) [][]int {
	// 声明[][]int类型的空指针
	var ans [][]int
	if root == nil {
		return ans
	}
	// 将头加入空队列
	queue := []*TreeNode{root}

	for len(queue) > 0 {
		// 声明[]*TreeNode类型的空指针用于存结点
		var current []*TreeNode
		// 声明[]int类型的空指针用于存值
		var values []int

		for _, j := range queue {
			values = append(values, j.Val)
			// 将当前结点相邻的结点加入
			if j.Left != nil {
				current = append(current, j.Left)
			}
			if j.Right != nil {
				current = append(current, j.Right)
			}
		}
		ans = append(ans, values)
		queue = current
	}
	return ans
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Generalzy

文章对您有帮助,倍感荣幸

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

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

打赏作者

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

抵扣说明:

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

余额充值