首先说明一下关注点:
- 对于各个算法而言,它的实现思想以及实现的代码是非常需要关注的,且要记忆的,可以说非常重要。
- 各个算法的时间复杂度、空间复杂度、稳定性、最好最好的复杂度以及它们的原因必须要明白。
- 各个算法逐渐变化变迁的过程,以及后面的算法不断在前面的算法的基础上进行更新的思路(目的是为了降低时间复杂度)。
- 各个算法使用场景上的区分。
首先记录一下第一点,各个算法的实现思路以及实现代码
冒泡排序
思路:整个序列是0到n,第0项和第1项如果不满足既定顺序(既定顺序包含升序和降序)则交换,满足既定顺序不交换;紧接着第1项和第2项如果不满足既定顺序则交换,否则不交换,以此类推,相邻的两个依次交换,可以发现这样一次处理下来,最后一个位置就是最值,不是最大就是最小,那么最后一个位置就是排序结束了,这个位置在以后的排序中可以不考虑了。除了这个位置之后一个新的序列是0-(n-1),在这个序列基础上继续按照上述相邻的依次交换,直到拿到倒数第二个元素,倒数第三个等等直接最后n-1=0结束,也就是0号元素的位置最后才开始处理。 代码:按照上述流程实现代码如下:可以发现上面可以用循环,依次得出最后一个位置、倒数第二个位置、倒数第三个位置等等,其次可以发现这个过程可以使用递归来实现,每次传入长度即可,依次传入n, n-1,n-2,2 ,1, 0等等。
pai = [14, 6, 7, 9, 2, 11, 4, 15, 25]def maoPaoSortedDiGui(pai, lenth):# 递归版本,使用下面几行代码来代替for/while循环if lenth <= 0:returnlenth -= 1for j in range(lenth):if pai[j] > pai[j+1]:pai[j], pai[j+1] = pai[j+1], pai[j]maoPaoSortedDiGui(pai, lenth)def maoPaoSortedRn(pai):lenth = len(pai) - 1while lenth > 0:for j in range(lenth):if pai[j] > pai[j + 1]:pai[j], pai[j + 1] = pai[j + 1], pai[j]lenth -= 1return paiprint(maoPaoSortedDiGui(pai, lenth=len(pai)))print(maoPaoSortedRn(pai))
分析:可以发现整个由于使用的是内外2次循环,处理的次数总和是1+2+3+4+...n,所以平均时间复杂度是O(n^2)。那么最好的时间复杂度是已经有序了,此时时间复杂度就是O(n),只需要外部扫描一次,内部不需要交换,因此当有序的时候,时间复杂度最小。同时最差的情况和平均时间一致,都是O(n^2)。
那么空间复杂度由于没有引入太多的外部变量,只需要一个变量用来交换相邻的2个值,因此空间复杂度是O(1)。
同时我们可以发现这是一个稳定的排序算法,相同的数据不需要交换位置,当然可以交换,但是我们发现没有必要,因此这是一个稳定排序算法。
选择排序(由小到大排序) ——以前一直用的排序的那种写法其实本质上就是选择排序,只不过真的选择排序不需要每次都交换
思想:选择排序的思路就是从第一个元素开始,固定这个元素,取一个变量存储min保存这个元素的下标,如果当前的元素比后面的元素大,那么这个min就变化为这个小的元素的下边,那么这样依次扫描过来,min就存储的是整个数组中最小元素的下标。然后if min!= i : swap(pai[i], pai[min])就可以了。此时第一个位置就是最小值。下一次从第二个位置开始,继续执行上述过程,选择出第二个小数,依次执行。可以发现一次执行之后,就能确定一个元素的位置,只不过确定了第一个位置,而冒泡排序一次执行也能确定一个位置,只不过确定了最后一个位置。
代码:可以发现也可以循环和递归。此处就不写递归了。
def selectSorted(pai):for i in range(len(pai)):min = ifor j in range(i, len(pai)):if pai[min] > pai[j]:min = jif min != i:pai[min], pai[i] = pai[i], pai[min]return paiprint(selectSorted(pai))
分析:和冒泡排序一样。整个由于使用的是内外2次循环,处理的次数总和是1+2+3+4+...n,所以平均时间复杂度是O(n^2)。那么最好的时间复杂度是已经有序了,此时时间复杂度就是O(n),只需要外部扫描一次,内部不需要交换,因此当有序的时候,时间复杂度最小。同时最差的情况和平均时间一致,都是O(n^2)。
那么空间复杂度由于没有引入太多的外部变量,只需要一个变量用来交换相邻的2个值,因此空间复杂度是O(1)。
可以发现选择排序是一种不稳定的排序。如序列5, 6, 7, 5, 2,排序的时候,第一次就是5和2交换,此时2个5的顺序就变了,那么这就是不稳定算法的原因。
插入排序(用由小到大举例子)
思想:将数组分为两个部分,前面是有序的,后面是无需的,每次从无序的部分最开始选择一个数据a,和前面有序的部分从后面开始依次比较,如果a比倒数第一个大,继续往后遍历,如果a比倒数第一个小,交换a和倒数第一个,如果a比倒数第二个还小,在交换a和倒数第二个,直到a不变,此时,无序部分就少了一个元素,继续往后遍历,从无序的部分在选择第一个,和前面有序的部分执行上述过程,直到全部排序,这就是思路。可以发现,插入排序每次插入一个元素不能确定一个位置,插入排序是最后一次性产生一个有序数组,这个和冒泡排序和选择排序是最大的不同。
代码:
def insertSorted(pai):for i in range(len(pai)): # 数组没有元素进不来这里j = iwhile j > 0: # 数组只有一个元素进不来这里if pai[j] < pai[j-1]:pai[j-1], pai[j] = pai[j], pai[j-1]j -= 1else:breakreturn paiprint(insertSorted(pai))
分析:和冒泡排序一样。整个由于使用的是内外2次循环,处理的次数总和是1+2+3+4+...n,所以平均时间复杂度是O(n^2)。那么最好的时间复杂度是已经有序了,此时时间复杂度就是O(n),只需要外部扫描一次,内部不需要交换,因此当有序的时候,时间复杂度最小。同时最差的情况和平均时间一致,都是O(n^2)。
那么空间复杂度由于没有引入太多的外部变量,只需要一个变量用来交换相邻的2个值,因此空间复杂度是O(1)。
可以发现插入排序是一种稳定的排序。
希尔排序
思想:希尔排序就是看到了插入排序每次插入一个值的时候,需要移动好多次,当然可以通过二分查找插入排序减小查询次数,但是移动的次数不变。所以如果我们尽可能让这个序列尽可能有序的话,那么插入排序是相对容易的。希尔排序就是这种思想。 希尔排序先设定一个步长h,按照步长h进行直接插入排序,完成之后这个序列就基本有序了,在使用直接插入排序,就比原来的直接插入排序快多了。但是我们发现在一次希尔之后,将h = h // 2,缩小,继续进行希尔,之后再缩小h,直到h == 1,此时退化为直接插入排序,所以我们不是一次希尔之后+直接插入,而是不断地希尔直到h == 1。这就是希尔排序的整体思想。
可以发现希尔排序就是直接插入排序在间隔为h上的实现,所以希尔排序的代码就是在直接插入排序代码的基础上将1全部更换为步长h,再不断缩小步长 之后逐步递归的过程。
代码:
# 希尔排序def xiErSorted(pai, h):if h == 0:return# 可以发现希尔排序就是插入排序的1全部更换为h实现的。for i in range(h, len(pai)):j = iwhile j > 0:if pai[j] < pai[j-h]:pai[j], pai[j-h] = pai[j-h], pai[j]j -= hxiErSorted(pai, h // 2)xiErSorted(pai, h=4)print(pai)
步长我们可以提前设定,不过,从人们的使用上来看,间隔是有公式的。while h < len(array): h = 3 * h + 1,来确定初始的h,为什么这样呢?这个类似于黄金分割点,为什么这样,没什么原因就是人们发现好用。那么后面也不是 h = h // 2,使用的是 h= h // 3,采用的是三分这样的分法。
分析:这个的时间复杂度就是O(nlong),最好的是O(n),最差的是O(n^2)。由于希尔排序的本质就是插入排序,插入排序的最好最坏就是O(n)和O(n^2),所以如果间隔h == 1,那么希尔排序就退化成插入排序了,就达到了插入排序的最好和最坏。
空间复杂度是O(1),因为这个本质上就是插入排序,所以消耗的空间还是交换,所以空间复杂度和插入排序相同也是O(1)。
插入排序是稳定的排序,但是这里是分区按照间隔h进行的插入,所以完全有可能相同的数据分在不同的区间内进行插入,所以是不稳定的算法。
归并排序(还是由小到大)
思想:将数组二分,分了之后分别排序,然后在将两个有序的数组合起来。然后每一个数组继续二分,不断地二分,直到只剩下一个元素,此时这一个元素构成的数组就有序了,然后反过来依次进行合并,那么此时合并成了一个具有2个元素的新数组,之后再合并就变成了具有4个元素的排序数组,不断合并就得到了最后的排序结果。
这就是分治的思想,开始这个问题不能解决,你就是一个数组二分了之后,不是有序的,不能采用有序数组的方式合并。但是不断地二分不断地二分(在大数据中叫Map)直到变成一个数组就有序了,此时就可以使用有序数组的方式合并了。所以分治的本质就是不断地二分不断地二分直到某个时候可以使用某一种方法求解,解出来后反向再使用同样的方法求解,这就是合并的过程(在大数据内叫做Reduce),所以分治在大数据中就是MapReduce,反过来说MapReduce就是做这么个事情。 代码:
def twoSortedList(left, right):i = 0j = 0result = []while i < len(left) and j < len(right):if left[i] > right[j]:result.append(right[j])j += 1else:result.append(left[i])i += 1if i < len(left):d(left[i:])if j < len(right):d(right[j:])return result# 归并排序的思想就是二分法def guiBingSorted(pai):i = len(pai) // 2if i == 0:return paileft = guiBingSorted(pai[:i])right = guiBingSorted(pai[i:])# 对两个有序数组排序,类似于对两个有序列表进行排序return twoSortedList(left, right)
分析:这个是见复杂度是O(nlogn),直观上来看是不断地二分因此是logn,每次分了之后就开始排序,所以在有n的复杂度,所以直观上来看的话时间复杂度是nlogn。除了直观上理解之外,我们还要会从公司上进行推倒,从公式上推断我们队使用递归的程序时间复杂度进行推倒。可以发现T(n) = T(n/2) + T(n/2) + n = 2T(n/2)+n = 2(2T(n/4)+n/2)+n=4T(n/2)+2n=4(2T(n/8)+n/4)+2n=8T(n/8)+3n。。。不断递归下去,最后我们发现T(n)=2^kT(n/(2^k))+kn,那么递归到最后,n/2^k=1,此时k=logn,T(1)=c,带回左面的表达式得到T(n)=cn+nlogn=nlogn,这就是它时间复杂度的推倒,同时也给出了我们对于时间复杂度的获取的方法:第一直观的理解法,第二是递归的可以采用公式推倒法。那么最好的和最坏的时间复杂度都是O(nlogn)。
空间复杂度:可以发现每次都采用额外的空间进行二个有序数组的排序,所以空间复杂度是O(n)。
稳定性:其实就是二个有序的数组进行排序,可以发现没必要改变相同数据,所以是一种稳定的排序。
快速排序
思路:快速排序也是一种二分法,它就是发现了归并排序的空间复杂度是O(n),为了缩小这个空间,快速排序核心思想还是二分,不过它二分的过程不是按照索引二分,而是把待排序的序列的最后一个元素认为是锚定点,前面的数组依据比锚定点大还是小分为分为2个部分,这是它二分的思想,这样分了之后,比锚定点大的第一个位置和锚定点交换,此时锚定点左侧的元素都小于锚定点,右侧的元素都大于锚定点,那么此时锚定点这个元素的位置就确定了,所以快速排序以一趟下来,就能确定一个位置。
void quickSort(int left, int right, vector<int> & nums){if(right <= left)return ;int x = nums[right];int i=left;for(int j=left; j<=right; j++){if(nums[j] < x){nums[i++]=nums[j];}}swap(nums[i], nums[right]);quickSort(left, i-1, nums);quickSort(i+1, right, nums);}以上是快速排序的c++版本。注意二个点,一个是不需要右指针,只需要i左指针作为比
苗定点小的边界的下一位,正常循环就是右指针。第二:和归并排序的区别是归并要先
递归然后才能处理,因为归并的中点就是mid,是传入的left和right决定的;而快速排
序的排序点是一次排序完成之后才可以确定的,所以快速排序是先处理后递归,这就是
快速的快的意义,是先处理,后递归,切记先处理,后递归。三路快速排序的方法,巧妙。int i = 0, j = 0, k = nums.size() - 1;
while(j < k){if(nums[j] > mid){swap(nums[j], nums[k]);--k;}else if(nums[j] < mid){swap(nums[j], nums[i]);++i;++j;}else{++j;}
}
代码:
也是分治的思想,但是相比于上边的归并排序空间复杂度更低,因为没有利用到额外的空间def quickSorted(pai, left, right):# 不传入切片,因为切片不是在原来修改,这里传入下标目的是在原来的pai上修改left_ = leftright_ = right# 递归的退出条件if right - left <= 0:return# 锚定点的位置maoIndex = right# 右顶点的位置right -= 1while right > left:if pai[right] < pai[maoIndex] and pai[left] > pai[maoIndex]:pai[right], pai[left] = pai[left], pai[right]# 交换了只有不必移动,因为此时满足下面的条件,下面的会执行if pai[left] < pai[maoIndex]:left += 1if pai[right] > pai[maoIndex]:right -= 1# left == right 考虑只有2个元素的情况if pai[right] > pai[maoIndex]:pai[right], pai[maoIndex] = pai[maoIndex], pai[right]quickSorted(pai, left_, right-1)quickSorted(pai, right+1, right_)将一个数组按照某一个数分为2个部分,前面的都小于这个数,后面的都大于这个数。方法有2种,都是使用2个指针。一种方法是上面的前后2个指针,left是左边的最后,right是右边的最左,left的值小于锚定点,left就一直后移,right值如果一直大于锚定点,right一直左移,直到left值大于锚定点且right值小于锚定点,此时二者交换,之后继续只想上述,这种好理解。另外一种认为大于锚定点的值不动,right指针继续右移,一旦right遇到小于锚定点值,就和第一个大于锚定点的值交换,那么第一个值得index就是left,left指向左边的最后,right是右边的最后,所以left后面到right前面都比锚定点大,所以当right的值比锚定点小,就和left的下一个值替换,因为left的下一个值是大于猫定点的,这种相对装逼,但是不是好理解。def quickSorted(pai, left, right):# 递归的退出条件if right - left <= 0:returni = left - 1 # 左边(小于锚定点)的最后j = left # 右边(大于锚定点)的最后while j < right - 1:if pai[j] < pai[right]:i += 1pai[i], pai[j] = pai[j], pai[i]j += 1# 获取第一个大于锚定点的数,目的是为了和锚定点交换i += 1# left == right 考虑只有2个元素的情况if pai[i] > pai[right]:pai[i], pai[right] = pai[right], pai[i]quickSorted(pai, left, i-1)quickSorted(pai, i+1, right)quickSorted(pai, 0, len(pai)-1)print(pai)
分析:归并排序可以发现由于是二分的思想,它的时间复杂度也可以计算,和上面的二路归并排序的时间复杂度是一样的。下面我们计算一下:可以发现归并T(n) = T(n/2) + T(n/2) + n,那是因为归并是完整的二分,所以两个递归都是T(n/2),但是这里的快速排序不是标准的二分,但是如果每次锚定点都是最大值或者最小值,那么此时两个递归变退化为一个是n-1的数组,一个是0的数组,所以快熟排序此时的可以发现T(n) = T(n-1) + T(0) + n = T(n-1)+n =(T(n-2)+n-1)+n=T(n-2)+2n-1.。。=T(n-k)+kn-c,当递归到最后的位置的时候,n-k=0.此时k=n,带回去得到T(n)=T(1)+n*n-c=n^2,此时最差的时间复杂度就是O(n^2),当然一般排序的话这种是很少见的,所以不必太担心这种情况的出现。最好的每次就是中位数,和归并排序就一样了,所以最好的和平均时间复杂度都是O(nlogn)。
空间复杂度就是使用了锚定点和大于锚定点的第一个值做交换,所以空间复杂度是O(1),数组的那个部分使用的是交换,也是O(1),所以空间复杂度就是O(1),数组尽量避免移动,尽量使用交换,这个快速排序给我们提供了很好的一种思路,将有序数按照某一个数值分为2部分。
稳定性的话是不稳定的,因为那个位置直接和锚定点替换了,完全有可能造成不稳定。
堆排序
关于堆排序的部分在完全二叉树的部分里面已经说过了,堆的创建、插入、删除都是从完全二叉树替换过来的,先操作完全二叉树,之后把他们每次的操作只要按照大根堆或者小根堆的要求替换更新即可。代码和思路这里就不放了。
分析:时间复杂度的分析:外面依次循环是n,内部调整是logn,二分么,左调又调的,所以整体就是nlogn,当然也可以使用递归法去计算,这里就不算了。那么最好最差的也是O(nlogn)。
空间复杂度是O(1),因为替换节点就是在原数组上替换了,不需要开辟新的数据结构,只要交换就行,所以空间复杂度是1.
稳定性:相同的数据可以在替换的过程中往后放,所以是不稳定的排序算法。
基数排序
思路:拜托,面试别再问我基数排序了!!!_架构师之路_的博客-CSDN博客 这个链接讲的好一点。首先要弄明白基和桶的概念。基的大小就是最大的位数,桶表示位数的取值范围,数字的话是0-9。思路就是从低到高遍历基,从个位、十位、百位..遍历,首先考虑个位,该位相同的放一个桶里。然后按顺序将这个在填回去,然后在十位,十位相同的放一个桶里,在填回去,依次处理。那么它为什么能排序呢?其实仔细想想,发现高位相同的数据按照低位为基做上述处理得到的一定是有序的。你比如17, 12,,25, 13,按照2,3,,5,7进行排序一定是12,13,25,17,你可以发现高位相同的数据12,13,17一定是有序的,这样就保证之后按照高位为基进行排序的时候,桶内就是排好的数据,这就保证了么一个桶的有序性。这就是它能排序的原理
代码:
def getDight(val, d):while d >= 0:yu = val % 10val = val // 10d -= 1return yudef jishuSorted(pai):# 在C++里面下面的获取位数当然可以变一变d = len(str(max(pai)))for i in range(d):# 它的长度是10,表示余数是几的数据有几个,初始化为0geshu = [0 for i in range(10)]# 利用上面的geshu获取每一个数在新数组中的位置tong = [0 for i in range(len(pai))]for val in pai:geshu[getDight(val, i)] += 1# 获取每一个数据在新数组里面的位置,方法就是,确定每一位(0, 1, 2...9)在数组中开始位置# 但是发现开始位置不好获取,所以我们通过求和获取每一位的结束位置,然后倒着把数据放回去。for k in range(1, 10):geshu[k] = geshu[k-1] + geshu[k]# 倒叙过来为了匹配上面的,倒着往里面填充# 这就是LSD,由个位往高位排序,还有一种是从高位往地位排序,此时geshu记录的是每一位的开始位置,刚好和上面反着呢。verse()for val in pai:yushu = getDight(val, i)geshu[yushu] -= 1tong[geshu[yushu]] = valpai = tongreturn paiprint(jishuSorted(pai))# [14, 6, 7, 9, 2, 11, 4, 15, 25]# geshu = [0, 1, 1, 0, 2, 2, 1, 1, 0, 1]# geshuSum = [0, 1, 2, 2, 4, 6, 7, 8 ,8, 9]
分析:它的时间复杂度就是O(d*(n+r)),d表示最大多少位,n表示那个临时的桶的数组tong,r就表述基的大小geshu,便于基和的求取。最好的时间复杂度就是这个,最差的时间复杂度想想也是这个。忽略d, r,所以时间复杂度就是O(n)。
空间复杂度就是那两个数组的和O(n+r),忽略r,空间复杂度就是O(n)。
稳定性的话发现是稳定的,因为按照相同数必然再一个桶内,由前向后排着,扫描返回原来的数组也是这样,所以是稳定的排序。
计数排序
思路:计数排序的思路很简单。首先计数排序是给定某一个范围如[MIN, MAX]的数据,那么取桶的个数就是MAX-MIN,保证每一个桶内至少一个数据,具体的原理看下面的链接。代码实现很简单,利用tong[pai[i]]++,就能实现了。
=MjM5ODYxMDA5OQ==&mid=2651961665&idx=1&sn=b7a6d0ca45a0b91801778baec0f759c6&chksm=bd2d0c9d8a5a858b0fc54dbc08d75ecdb4f11383a97222aede422c9f72a7c0240d82e5833aec&scene=21#wechat_redirect
分析:时间复杂度就是n,一次扫描,非常简单。最好最差的也是n,不存在什么变化,一眼就看出来了,空间复杂度也是n,这是一个各个方面复杂度都是n的算法,唯一的要求就是排序的数据要求有范围[MIN, MAX]。
桶排序
思路:其实桶排序的思考类似于上述的计数排序,我们看计数排序的桶数就等于n,而为了缩小空间复杂度,我们取一定数量的桶就行了,那么将数组内的元素按照某种映射函数放入桶内,通内部的排序采用插入排序或者其他的排序算法进行排序,最后从头到尾扫描桶就行了。可以发现计数排序就是桶排序的一个特例,桶数等于n,计数排序的映射函数保证每一个桶内最多一个元素,所以不存在桶内排序了,然后就是扫描全桶得到结果。显然桶排序是计数排序的一般方法,这个和基数排序是一个完全不同的思路。
那么现在的问题就是如果确定桶数?如何映射到桶内?时间复杂度为什么是O(n)?
1:桶数的确定是通过公式(MAX-MIN)/width = much ,width是桶的间隔,那么每一个元素对应的桶号
index = (pai[i] - MIN) / width = (pai[i] - MIN) * much /(MAX-MIN)。
那么此时如果n取值为pai.lenth的时候,此时退化为计数排序,时间复杂度是O(n)。
index = (pai[i] - MIN) * len(pai)/(MAX-MIN) # 注意数组中MAX-MIN 和 len(pai)没关系。
如果much桶数取(MAX-MIN)/len(pai)时候,
index = (pai[i] - MIN) / len(pai)。
上面就回答了桶的数量的一般的确定方法以及映射的方法。
2:那么时间复杂度为什么是O(n)?
对于N个待排数据,M个桶,平均每个桶[N/M]个数据的桶排序平均时间复杂度为: O(N)+O(M*(N/M)*log(N/M))=O(N+N*(logN-logM))=O(N+N*logN-N*logM),当N=M时,即极限情况下每个桶只有一个数据时。桶排序的最好效率能够达到O(N),此时退化为计数排序。所以桶排序最好就是O(n)。那么最差的时间复杂度是只有一个桶,那么外部扫描一次n,桶内排序依次nlog,所以最差的时间复杂度是O(n^2)。
空间复杂度是O(n+m),n是数组长,因为桶内开辟的和就是数组长,m是桶数,所以空间复杂度是m+n。
代码实现的话可以采用二维数组,也可以采用数组中每一个元素是链表,都可以,网上的实现版本很多,这里就不介绍了。
各个算法的时间复杂度、空间复杂度、稳定性、最好最好的复杂度以及它们的原因必须要明白。
原因上面每一个部分都分析过了,记住就行了,下面进行一个全网最细致的汇总。
各个算法逐渐变化变迁的过程,以及后面的算法不断在前面的算法的基础上进行更新的思路(目的是为了降低时间复杂度)。
使用场景
一般而言最开始的O(n^2)的三种一般不用。常常使用的是归并排序以及快排,归并排序由于使用到了额外的空间,所以对于数据量不是太大的时候可以使用,反之就是快排数据量大时候使用,但是快排有时候会出现O(n^2),所以数据量不大的时候不使用快排,才牺牲空间使用归并。 可以发现堆排时间各个都是O(nlogn),空间是O(1),所以在比较算法中堆排也是非常优越的。
那么对于映射类方法,基数排序不知道使用场景,对于后面的映射类的2种方法,使用的场景是数据在一定的范围[MIN,MAX]内部,此时使用这两种方法。
本文发布于:2024-01-31 07:14:44,感谢您对本站的认可!
本文链接:https://www.4u4v.net/it/170665648526566.html
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。
留言与评论(共有 0 条评论) |