geeksforgeeks —— 算法 1

geeksforgeeks 上有很多不错的基础性计算机学科知识,其风格不过多注重理论,也不是一味的像 leetcode 那种刷题,每一篇内容篇幅安排的都较短,也有一定的知识组织架构,非常适合初学者或作为工具字典书定向查阅相关内容。
该合集内容主要针对的是算法与数据结构方面相关内容,不做 100% 的翻译且基本是机翻,所有的代码只保留 Python 版本。

算法

一、查找和排序

1.1 线性查找

  • 难度级别:基础
  • 更新时间:2021.02.09
  • source:https://www.geeksforgeeks.org/linear-search/

问题: 给定一个具有 n n n 个元素的数组 arr[],写一个函数可以在数组 arr[] 中查找指定的元素 x x x

示例:

Input : arr[] = {10, 20, 80, 30, 60, 50, 110, 100, 130, 170}
        x = 110;
Output : 6
Element x is present at index 6

Input : arr[] = {10, 20, 80, 30, 60, 50, 110, 100, 130, 170}
        x = 175;
Output : -1
Element x is not present in arr[].

一个简单的方式是做线性查找,如:

  • 从数组 arr[] 最左侧的元素开始一个一个的和 x x x 进行对比
  • 如果 x x x 匹配到了其中的一个元素,返回索引
  • 如果没有匹配到任何一个元素,返回 -1
img

代码示例:

# 线性查找 arr[] 中的 x,如果 x 存在,返回它的位置,否则返回 -1

def search(arr, n, x):
    for i in range(0, n):
        if (arr[i] == x):
            return i
    return -1
 

arr = [2, 3, 4, 10, 40]
x = 10
n = len(arr)
 

result = search(arr, n, x)
if(result == -1):
    print("Element is not present in array")
else:
    print("Element is present at index", result)

Output

Element is present at index 3

时间复杂度 O ( n ) O(n) O(n)

线性搜索很少被实际使用,因为其他搜索算法,如二分查找算法和哈希表比线性搜索更快。

1.2 二分查找

  • 难度级别:简单
  • 更新时间:2020.02.03
  • source:https://www.geeksforgeeks.org/binary-search/

给定一个由 n n n 个元素组成的已排序的数组 arr[],编写一个函数来搜索 arr[] 中的给定元素 x x x

一个简单的方法是做线性查找,其时间复杂度是 O ( n ) O(n) O(n)。另一个解决该问题的方式是使用二分查找。

二分查找: 通过重复将搜索区间对半来搜索已排序的数组。从搜索整个数组的区间开始。如果搜索的值小于区间中间的项,则将区间缩小到下半部分。否则就缩小到上半部分。反复检查,直到找到值或区间为空。

示例:

img

二进制搜索的思想是利用数组被排序的信息,将时间复杂度降低到 O ( log ⁡ n ) O(\log n) O(logn)

我们基本可以在每一次比较后忽略掉一般的元素

  1. x x x 与中间位置的元素进行比较
  2. 如果 x x x 与中间的元素匹配,则返回中间位置的索引
  3. 否则,如果 x x x 比中间位置元素还大,则 x x x 只能位于右半部分,所以我们将关注右半部分的元素
  4. 否则( x x x 比较小)则关注左半部分的元素

递归实现二分查找:

# 二分查找的递归实现,如果存在返回 arr 中 x 的索引,否则返回 -1
  
def binarySearch (arr, l, r, x): 
    # 检查基本条件 right >= left
    if r >= l:      
        mid = l + (r - l) // 2
        # 如果查找的元素正好是中间这个
        if arr[mid] == x: 
            return mid 
          
        # 如果元素比中间的小,它只能出现在左半拉 
        elif arr[mid] > x: 
            return binarySearch(arr, l, mid-1, x) 
  
        # 反之,它只能出现在右半拉 
        else: 
            return binarySearch(arr, mid + 1, r, x) 
    else: 
        # 元素不存在
        return -1
  

arr = [2, 3, 4, 10, 40] 
x = 10
  

result = binarySearch(arr, 0, len(arr)-1, x) 
  
if result != -1: 
    print ("Element is present at index % d" % result) 
else: 
    print ("Element is not present in array") 

Output :

Element is present at index 3

迭代实现的二分查找

# 迭代实现二分查找

def binarySearch(arr, l, r, x): 
    while l <= r: 
        mid = l + (r - l) // 2; 
        if arr[mid] == x: 
            return mid 
        # 如果 x 比较大,忽略左半部分 
        elif arr[mid] < x: 
            l = mid + 1
        # 反之忽略右半部分 
        else: 
            r = mid - 1
    return -1
  

arr = [ 2, 3, 4, 10, 40 ] 
x = 10
  
 
result = binarySearch(arr, 0, len(arr)-1, x) 
  
if result != -1: 
    print ("Element is present at index % d" % result) 
else: 
    print ("Element is not present in array") 

Output :

Element is present at index 3

时间复杂度:

T(n) = T(n/2) + c 

上述递推问题可用递推树法或 Master 方法求解。它属于 Master 方法的第二种情况,其递推解为 Θ ( log ⁡ n ) \Theta(\log n) Θ(logn)

辅助空间: 对于迭代方式是 O ( 1 ) O(1) O(1),对于递归方式,需要 O ( log ⁡ n ) O(\log n) O(logn) 的堆栈空间。

1.3 跳跃搜索

  • 难度级别:容易
  • 更新时间:2021.03.22
  • source: https://www.geeksforgeeks.org/jump-search/

与二分搜索一样,跳跃搜索也是一种排序数组的搜索算法。其基本思想是通过按固定的步骤向前跳或跳过某些元素而不是搜索所有元素来检查较少的元素(相对线性搜索)。

例如,假设我们有一个数组 arr[],大小为 n n n,块(要跳转)的大小为 m m m。然后我们搜索索引 arr[0]、arr[m]、arr[2m]……arr[km] 等等。一旦我们找到区间(arr[km] < x < arr[(k+1)m]),我们就从索引 km 执行线性搜索操作来找到元素 x。

考虑如下数组 (0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610). 数组长度是 16,将利用跳跃搜索 55,假设跳跃的块大小为 4,则查找顺序如下:

  1. 从索引 0 跳到索引 4
  2. 从索引 4 跳到索引 8
  3. 从索引 8 跳到索引 12
  4. 因为索引 12 的值比 55 大,我们将回跳到索引 8
  5. 从索引 8 开始执行线性查找,找到元素 55

最佳的跳跃块大小是多少?

在最坏的情况下,我们必须进行 n / m n/m n/m 次跳转,如果最后检查的值大于要搜索的元素,我们将执行 m − 1 m-1 m1 比较以进行线性搜索。因此,最坏情况下的比较总数为( n / m + m − 1 n/m+m-1 n/m+m1)。当 m = n m=\sqrt{n} m=n 时取得最小值,因此,最佳步长为 m = n m = \sqrt{n} m=n .

# 跳跃查找
import math
 
def jumpSearch( arr , x , n ):
    step = math.sqrt(n)             # 计算块的大小
     
    # 查找元素所在的块(如果存在)
    prev = 0
    while arr[int(min(step, n)-1)] < x:
        prev = step
        step += math.sqrt(n)
        if prev >= n:
            return -1
     
    # 从 prev 开始在块中对 x 进行线性搜索。
    while arr[int(prev)] < x:
        prev += 1
        # 如果到达下一个块或数组的末尾,则元素不存在。
        if prev == min(step, n):
            return -1
     
    # 如果元素被找到
    if arr[int(prev)] == x:
        return prev
     
    return -1
 
arr = [ 0, 1, 1, 2, 3, 5, 8, 13, 21,
    34, 55, 89, 144, 233, 377, 610 ]
x = 55
n = len(arr)
 
index = jumpSearch(arr, x, n)
print("Number" , x, "is at index" ,"%.0f"%index)

Output:

Number 55 is at index 10

时间复杂度: O ( n ) O(\sqrt{n}) O(n ),辅助空间: O ( 1 ) O(1) O(1)

重要点:

  • 只能用于已被排序的数组中
  • 最优的块大小为 n \sqrt{n} n ,这可以使跳跃查找的复杂度为 O ( n ) O(\sqrt{n}) O(n )
  • 跳跃查找的时间复杂度介于线性查找( O ( n ) O(n) O(n)) 和二分查找(O( log ⁡ n \log n logn)) 之间
  • 二分搜索比跳跃搜索好,但是跳跃搜索有一个优点,是我们仅回溯一次(二分搜索最多可能需要 O ( log ⁡ n ) O(\log n) O(logn) 个跳转,请考虑以下情况:要搜索的元素是最小的元素或小于最小的元素)。因此,在二分搜索成本很高的系统中,我们使用跳跃搜索。

References:
https://en.wikipedia.org/wiki/Jump_search

1.4 插值搜索

  • 难度级别:容易
  • 更新时间:2021.03.22
  • source: https://www.geeksforgeeks.org/interpolation-search/

给定一个由 n n n 个均匀分布的值组成的排序数组 arr[],编写一个函数来搜索数组中的特定元素 x x x

线性搜索的复杂度是 O ( n ) O(n) O(n),跳跃搜索的复杂度是 O ( n ) O(\sqrt{n}) O(n ),二分搜索的复杂度是 O ( log ⁡ n ) O(\log n) O(logn)

对于排序数组中的值是均匀分布的,则插值搜索是对分搜索的改进。二分搜索总是转到中间元素进行检查。而插值搜索可以根据正在搜索的 key 的值去到不同的位置。例如,如果 key 的值更接近最后一个元素,则插值搜索可能会朝着结束侧开始搜索。

为了找到要搜索的位置,它使用以下公式:

// 公式的思想是当要搜索的元素更接近 arr[hi] 时,返回更高的 pos 值。
// 当接近 arr[lo] 时,返回更小的值

pos = lo + [ (x-arr[lo])*(hi-lo) / (arr[hi]-arr[Lo]) ]

x     ==> 被查找的元素
lo    ==> arr[] 的开始索引
hi    ==> arr[] 的结束索引

pos的公式可以推导如下:

假设数组的元素是线性分布的。

直线的一般方程:y=m*x+c。
y 是数组中的值,x 是它的索引。

现在把 lo,hi 和 x 的值放到方程中
arr[hi] = m*hi+c ----(1)
arr[lo] = m*lo+c ----(2)
x = m*pos + c    ----(3)

m = (arr[hi] - arr[lo] )/ (hi - lo)

从(3)中减去等式(2)
x - arr[lo] = m * (pos - lo)
lo + (x - arr[lo])/m = pos
pos = lo + (x - arr[lo]) *(hi - lo)/(arr[hi] - arr[lo])

算法

  1. 在循环中,使用探头位置公式计算 “pos” 的值。
  2. 如果是匹配项,则返回该项的索引,然后退出。
  3. 如果该项小于 arr[pos],则计算左侧子数组中的探头位置。否则在右边的子数组中计算相同的值。
  4. 重复此操作,直到找到匹配项或子数组减少到零。

下面是该算法的实现:

# 递归插值搜索
def interpolationSearch(arr, lo, hi, x):
    # 由于数组是排序的,所以数组中的元素必须在角点定义的范围内
    if lo <= hi and arr[lo] <= x <= arr[hi]:
        # pos = lo + ((hi - lo) // (arr[hi] - arr[lo]) * (x - arr[lo]))
        pos = lo + (x - arr[lo]) * (hi - lo) // (arr[hi] - arr[lo])
        print(pos)
        if arr[pos] == x:
            return pos

        if arr[pos] < x:
            return interpolationSearch(arr, pos + 1, hi, x)

        if arr[pos] > x:
            return interpolationSearch(arr, lo, pos - 1, x)
    return -1


arr = [10, 12, 13, 16, 18, 19, 20, 21, 22, 23, 24, 33, 35, 42, 47]
n = len(arr)

x = 18
index = interpolationSearch(arr, 0, n - 1, x)

if index != -1:
    print("Element found at index", index)
else:
    print("Element not found")

Output

Element found at index 4

1.5 指数搜索

  • 难度级别:容易
  • 更新时间:2021.03.24
  • source:https://www.geeksforgeeks.org/exponential-search/

此搜索算法的名称可能会产生误导,因为它在 O ( log ⁡ n ) O(\log n) O(logn) 时间内完成搜索。名称来自它搜索元素的方式。

给定一个已排序的数组,以及一个要查找的元素 x,找到 x 在数组中的位置

Input:  arr[] = {10, 20, 40, 45, 55}
        x = 45
Output: Element found at index 3

Input:  arr[] = {10, 15, 25, 45, 55}
        x = 15
Output: Element found at index 1

指数搜索有两个步骤:

  1. 查找元素所在的范围
  2. 在上述范围内进行二分搜索。

怎么查找元素可能的所在范围?

这个想法是从子数组大小为 1 开始,将其最后一个元素与 x x x 进行比较,然后尝试大小 2,然后 4,依此类推,直到子数组的最后一个元素不大于 x x x

一旦我们找到一个索引 i i i,我们就知道元素必须存在于 i / 2 i/2 i/2 i i i 之间(为什么 i / 2 i/2 i/2?因为我们在上一次迭代中找不到更大的值),下面给出了上述步骤的实现:

def binarySearch(arr, l, r, x):
    if r >= l:
        mid = l + (r - l) // 2
        if arr[mid] == x:
            return mid
        if arr[mid] > x:
            return binarySearch(arr, l, mid - 1, x)
        return binarySearch(arr, mid + 1, r, x)
    return -1


def exponentialSearch(arr, n, x):
    if arr[0] == x:
        return 0

    i = 1
    while i < n and arr[i] <= x:
        i = i * 2

    return binarySearch(arr, i // 2, min(i, n - 1), x)


arr = [2, 3, 4, 10, 40]
n = len(arr)
x = 10
result = exponentialSearch(arr, n, x)
if result == -1:
    print("Element not found in thye array")

else:
    print("Element is present at index %d" % result)

# This code is contributed by Harshit Agrawal

Output

Element is present at index 3

时间复杂度 O ( log ⁡ n ) O(\log n) O(logn)

辅助空间: 上述二分搜索的实现是递归的,需要 O ( log ⁡ n ) O(\log n) O(logn) 空间。使用迭代二进制搜索,我们只需要 O ( 1 ) O(1) O(1) 空间。

指数搜索的应用

  1. 指数二分搜索对于数组大小无限的无界搜索特别有用。有关示例,请参阅无界二分搜索
  2. 对于有界数组,以及当要搜索的元素更接近第一个元素时,它比二分搜索效果更好。

Reference:
https://en.wikipedia.org/wiki/Exponential_search

1.6 为什么二元搜索优于三元搜索?

  • 难度级别:中等
  • 更新时间:2019.10.01
  • source:https://www.geeksforgeeks.org/binary-search-preferred-ternary-search/

下面是一个简单的递归实现的三元搜索函数:

def ternarySearch(arr, l, r, x):
	if (r >= l):
		mid1 = l + (r - l) // 3
		mid2 = mid1 + (r - l)//3

		if arr[mid1] == x:
			return mid1

		if arr[mid2] == x:
			return mid2

		if arr[mid1] > x:
			return ternarySearch(arr, l, mid1-1, x)

		if arr[mid2] < x:
			return ternarySearch(arr, mid2+1, r, x)

		return ternarySearch(arr, mid1+1, mid2-1, x)
	
	return -1	
# This code is contributed by ankush_953

在最坏的情况下,二分和三分方法中哪一种比较少?

三元搜索在进行 log ⁡ 3 n \log_3^n log3n递归调用时,比较的次数似乎较少,但是二元搜索进行 log ⁡ 2 n \log_2^n log2n 递归调用。让我们仔细看看。

以下是在二分搜索的最坏情况下计算比较的递归公式。

T(n) = T(n/2) + 2,  T(1) = 1

以下是三元搜索最坏情况下计数比较的递归公式。

T(n) = T(n/3) + 4, T(1) = 1

在二分搜索中,最坏情况下有 2 log ⁡ 2 n + 1 2 \log_2^n+1 2log2n+1 比较。在三元搜索中,最坏情况下有 4 log ⁡ 3 n + 1 4 \log_3^n+1 4log3n+1 次比较。

二分搜索的时间复杂度 = 2clog2n + O(1)
三分搜索的时间复杂度 = 4clog3n + O(1)

因此,三元和二元搜索的比较归结为表达式 2 log ⁡ 3 n 2\log_3^n 2log3n log ⁡ 2 n \log_2^n log2n 的比较。 2 log ⁡ 3 n 2\log_3^n 2log3n 的值可以写成 ( 2 / log ⁡ 2 3 ) × log ⁡ 2 n (2/\log_2^3) \times \log_2^n (2/log23)×log2n。由于 ( 2 / log ⁡ 2 3 ) (2/\log_2^3) (2/log23) 的值大于 1,在最坏的情况下,三元搜索比二元搜索做更多次的比较。

练习

为什么归并排序将输入数组分成两半,为什么不分成三部分或更多部分?


1.7 选择排序

  • 难度级别:容易
  • 更新时间:2019.05.02
  • source:https://www.geeksforgeeks.org/selection-sort/

选择排序算法通过从未排序的部分重复寻找最小元素(考虑升序)并将其放在开始处来对数组进行排序。该算法在给定的数组中保持两个子数组。

  • 已排序的子数组
  • 未排序的剩余子数组

在选择排序的每次迭代中,从未排序的子数组中选取最小元素(考虑升序),并将其移动到已排序的子数组中。

以下示例说明了上述步骤:

arr[] = 64 25 12 22 11

// 在 arr[0…4] 中找到最小元素并将其放在开头
11 25 12 22 64

// 在 arr[1…4] 中找到最小元素并将其放置在 arr[1…4] 的开头
11 12 25 22 64

// 在 arr[2…4] 中找到最小元素并将其放置在 arr[2…4] 的开头
11 12 22 25 64

// 在 arr[3…4] 中找到最小元素并将其放置在 arr[3…4] 的开头
11 12 22 25 64 
A = [64, 25, 12, 22, 11]

for i in range(len(A)):

    # Find the minimum element in remaining unsorted array
    min_idx = i
    for j in range(i + 1, len(A)):
        if A[min_idx] > A[j]:
            min_idx = j
    # Swap the found minimum element with the first element
    A[i], A[min_idx] = A[min_idx], A[i]

# Driver code to test above
print("Sorted array")
for i in range(len(A)):
    print("%d" % A[i])

Output:

Sorted array: 
11 12 22 25 64

时间复杂度: O ( n 2 ) O(n^2) O(n2),因为有两个嵌套循环。

辅助空间: , O ( 1 ) ,O(1) O(1)选择排序的好处在于,它不会进行超过 O ( n ) O(n) O(n) 次的交换,而且在内存写入是一项代价高昂的操作时非常有用。


1.8 冒泡排序

  • 难度级别:容易
  • 更新时间:2021.05.07
  • source:https://www.geeksforgeeks.org/bubble-sort/

冒泡排序是最简单的排序算法,如果相邻元素顺序错误,它会重复交换相邻元素。

示例:

第一轮:

( 5 1 4 2 8 ) –> ( 1 5 4 2 8 ),在这里,算法比较前两个元素,因为 5 > 1,交换
( 1 5 4 2 8 ) –> ( 1 4 5 2 8 ),因为 5 > 4,交换
( 1 4 5 2 8 ) –> ( 1 4 2 5 8 ),因为 5 > 2,交换
( 1 4 2 5 8 ) –> ( 1 4 2 5 8 ),因为 5 < 8,不做交换

第二轮:

( 1 4 2 5 8 ) –> ( 1 4 2 5 8 )
( 1 4 2 5 8 ) –> ( 1 2 4 5 8 ),因为 4 > 2,交换
( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )
( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )

现在,数组已经排序,但是我们的算法不知道它是否完成。该算法需要一个完整的没有任何交换的过程,它才能确定排序结束。

第三轮:

( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )
( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )
( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )
( 1 2 4 5 8 ) –> ( 1 2 4 5 8 )

下面是冒泡排序的实现:

def bubbleSort(arr):
    n = len(arr)
    for i in range(n):
        # Last i elements are already in place
        for j in range(0, n-i-1):
            if arr[j] > arr[j+1] :
                arr[j], arr[j+1] = arr[j+1], arr[j]
 
arr = [64, 34, 25, 12, 22, 11, 90]
 
bubbleSort(arr)
 
print ("Sorted array is:")
for i in range(len(arr)):
    print ("%d" %arr[i]),

Output:

Sorted array:
11 12 22 25 34 64 90

优化实现:

即使对已排序的数组进行排序,上述函数也始终运行 O ( n 2 ) O(n^2) O(n2) 次。如果内环没有任何交换,可以停止。

def bubbleSort(arr):
    n = len(arr)

    for i in range(n):
        swapped = False
        for j in range(0, n - i - 1):
            if arr[j] > arr[j + 1]:
                arr[j], arr[j + 1] = arr[j + 1], arr[j]
                swapped = True
        if not swapped:
            break


arr = [64, 34, 25, 12, 22, 11, 90]

bubbleSort(arr)

print("Sorted array :")
for i in range(len(arr)):
    print("%d" % arr[i], end=" ")

Output:

Sorted array:
11 12 22 25 34 64 90

最差和平均时间复杂度: O ( n 2 ) O(n^2) O(n2),当数据逆序排序时是最坏情况

最好情况下时间复杂度: O ( n ) O(n) O(n),即数组已经是排序好的情况下

辅助空间: O ( 1 ) O(1) O(1)

属于就地排序、稳定排序

由于其简单性,冒泡排序经常被用来引入排序算法的概念。在计算机图形学中,它很受欢迎,因为它能够在几乎已排序的数组中检测到非常小的错误(如两个元素的交换),并以线性复杂度( 2 n 2n 2n)修复它。例如,它用于多边形填充算法,其中边界线在特定扫描线(平行于 x x x 轴的线)处按其 x x x 坐标排序,随着 y y y 的增加,它们的顺序变化(两个元素交换)仅在两条线的交点处(来源:Wikipedia


1.9 插入排序

  • 难度级别:容易
  • 更新时间:2021.04.13
  • source:https://www.geeksforgeeks.org/insertion-sort/

插入排序是一种简单的排序算法,其工作原理类似于对手中的扑克牌进行排序。数组实际上分为排序部分和未排序部分。未排序数组中的值将被拾取并放置在已排序数组中的正确位置。

算法

要按升序对大小为 n n n 的数组排序:

  1. 在数组上从 arr[1] 迭代到 arr[n]
  2. 将当前元素(key)与其前一个元素进行比较
  3. 如果 key 元素比前一个元素小,与前一个元素进行比较。将较大的元素向上移动一个位置,为交换的元素腾出空间。

示例:

def insertionSort(arr):
    for i in range(1, len(arr)):
        key = arr[i]
        j = i - 1
        while j >= 0 and key < arr[j]:
            arr[j + 1] = arr[j]
            j -= 1
        arr[j + 1] = key


arr = [12, 11, 13, 5, 6]
insertionSort(arr)
for i in range(len(arr)):
    print("% d" % arr[i])

Output:

5 6 11 12 13

时间复杂度: O ( n 2 ) O(n^2) O(n2)

辅助空间: O ( 1 ) O(1) O(1)

稳定排序、就地排序、在线排序

使用场景: 当元素数较少时使用插入排序。当输入数组几乎被排序时,它也很有用。

什么是二分插入排序?

我们可以使用二分搜索来减少正常插入排序中的比较次数。二分插入排序使用二分搜索来查找在每次迭代中插入选定项的正确位置。在正常插入中,排序在最坏情况下取 O ( i ) O(i) O(i)(在第 i i i 次迭代中)。我们可以使用二分搜索将其缩减为 O ( log ⁡ i ) O(\log i) O(logi)。由于每次插入都需要一系列的交换,因此该算法总体上仍具有 O ( n 2 ) O(n^2) O(n2) 的最坏运行时间。具体实现参阅这里

如何实现链表的插入排序?

1) 创建空的已排序(或结果)列表
2) 遍历给定的列表,对每个节点执行以下操作
......a) 在排序或结果列表中按排序方式插入当前节点
3) 将给定链表的头更改为已排序(或结果)列表的头。

具体实现参照这里


1.10 归并排序

  • 难度级别:中等
  • 更新时间:2021.05.18
  • source:https://www.geeksforgeeks.org/merge-sort/

与快速排序一样,归并排序也是一种分而治之的算法。它将输入数组分成两半分别排序,然后合并已排序的两半。merge() 函数用于合并。merge(arr, l, m, r) 是一个关键过程,它假定 arr[l..m]arr[m+1..r] 被排序,并将两个排序的子数组合并为一个。

MergeSort(arr[], l,  r)
If r > l
     1. Find the middle point to divide the array into two halves:  
             middle m = l+ (r-l)/2
     2. Call mergeSort for first half:   
             Call mergeSort(arr, l, m)
     3. Call mergeSort for second half:
             Call mergeSort(arr, m+1, r)
     4. Merge the two halves sorted in step 2 and 3:
             Call merge(arr, l, m, r)

下图显示了示例数组 {38,27,43,3,9,82,10} 的完整合并排序过程,示例来自于 wikipedia。如果我们仔细看这个图,我们可以看到数组被递归地分成两半,直到大小变为 1。一旦大小变为 1,合并过程就开始了,直到整个数组被合并。

Merge-Sort-Tutorial

def mergeSort(arr):
    if len(arr) > 1:
        mid = len(arr) // 2
        L = arr[:mid]
        R = arr[mid:]
        mergeSort(L)
        mergeSort(R)

        i = j = k = 0

        # Copy data to temp arrays L[] and R[]
        while i < len(L) and j < len(R):
            if L[i] < R[j]:
                arr[k] = L[i]
                i += 1
            else:
                arr[k] = R[j]
                j += 1
            k += 1

        # Checking if any element was left
        while i < len(L):
            arr[k] = L[i]
            i += 1
            k += 1

        while j < len(R):
            arr[k] = R[j]
            j += 1
            k += 1


def printList(arr):
    for i in range(len(arr)):
        print(arr[i], end=" ")
    print()


# Driver Code
if __name__ == '__main__':
    arr = [12, 11, 13, 5, 6, 7]
    print("Given array is", end="\n")
    printList(arr)
    mergeSort(arr)
    print("Sorted array is: ", end="\n")
    printList(arr)

Output

Given array is 
12 11 13 5 6 7 
Sorted array is 
5 6 7 11 12 13

时间复杂度: 归并排序是一种递归算法,时间复杂度可以表示为以下递推关系: T ( n ) = 2 T ( n / 2 ) + θ ( n ) T(n) = 2T(n/2) + \theta(n) T(n)=2T(n/2)+θ(n)

上述递推可采用递推树法或 Master 方法求解。它属于 Master 方法的第二种情况,其递推解是 θ ( b log ⁡ n ) \theta(b \log n) θ(blogn)。归并排序的时间复杂度在所有 3 种情况下(最差、平均和最佳)均为 θ ( n log ⁡ n ) \theta(n \log n) θ(nlogn),归并排序总是将数组分为两半,并且需要线性时间来合并两半。

辅助空间: O ( n ) O(n) O(n)

典型的实现并非就地排序,属于稳定排序

归并排序的应用:

  1. 归并排序对于在 O ( n log ⁡ n ) O(n \log n) O(nlogn) 时间内对链表进行排序非常有用。在链表下,情况不同主要是因为数组和链接列表的内存分配不同。与数组不同,链接的列表节点可能在内存中不相邻。与数组不同,在链接列表中,我们可以在 O ( 1 ) O(1) O(1) 额外空间和 O ( 1 ) O(1) O(1) 时间内完成插入。因此,归并排序的合并操作可以不需要为链表留出额外空间。在数组中,我们可以进行随机访问,因为元素在内存中是连续的。假设我们有一个整数(4 字节)数组 A,让 A[0] 的地址为 x x x,那么要访问A[i],我们可以直接访问( x + i ∗ 4 x+i*4 x+i4)处的内存。与数组不同,我们不能在链表中进行随机访问。快速排序需要大量此类访问。在链表中,为了访问第 i i i 个索引,我们必须将每个节点从头部移动到第 i i i 个节点,因为我们没有连续的内存块。因此,快速排序的开销会增加。归并排序按顺序存取数据,对随机存取的要求较低。
  2. 倒数问题
  3. 用于外部排序

归并排序的缺点:

  • 对于较小的任务,与其他排序算法相比速度较慢。
  • 归并排序算法需要为临时数组增加 O ( n ) O(n) O(n) 的内存空间。
  • 即使对已排序数组进行排序,它也会贯穿整个过程。

1.11 堆排序

  • 难度级别:中等
  • 更新时间:2021.05.18
  • source:https://www.geeksforgeeks.org/heap-sort/

堆排序是一种基于二叉堆数据结构的比较排序技术。它类似于选择排序,我们首先找到最小元素并将最小元素放在开头。我们对其余的元素重复同样的过程。

什么是二叉堆?

让我们先定义一个完整的二叉树,在这个二叉树中,除了可能的最后一层外,每一层都被完全填满,所有的节点都尽可能的左移(来源维基百科

二叉堆是一个完整的二叉树,其中项目按特殊顺序存储,使得父节点中的值大于(或小于)其两个子节点中的值。前者称为最大堆,后者称为最小堆。堆可以用二叉树或数组表示。

为什么要对二叉堆使用基于数组的表示?

由于二叉堆是一个完整的二叉树,因此它可以很容易地表示为一个数组,并且基于数组的表示是空间有效的。如果父节点存储在索引 i i i 处,则左子节点可以按 2 ∗ i + 1 2*i+1 2i+1计算,右子节点可以按 2 ∗ i + 2 2*i+2 2i+2 计算(假设索引从 0 开始)。

按递增顺序排序的堆排序算法:

  1. 从输入数据构建最大堆。
  2. 此时,最大的项存储在堆的根目录下。用堆的最后一项替换它,然后将堆的大小减少 1。最后,堆化树的根。
  3. 当堆的大小大于 1 时,重复步骤 2。

如何构建堆?

Heapify 过程仅当其子节点被 Heapify 时才能应用于节点。因此,堆化必须按自下而上的顺序进行。

通过一个例子让我们了解:

Input data: 4, 10, 3, 5, 1
         4(0)
        /   \
     10(1)   3(2)
    /   \
 5(3)    1(4)

括号中的数字表示数据数组表示中的索引。

将 heapify 过程应用于索引 1:
         4(0)
        /   \
    10(1)    3(2)
    /   \
5(3)    1(4)

将 heapify 过程应用于索引 0:
        10(0)
        /  \
     5(1)  3(2)
    /   \
 4(3)    1(4)
 
heapify 过程递归地调用自身以自顶向下的方式构建堆。
def heapify(arr, n, i):
    largest = i                      # Initialize largest as root
    l = 2 * i + 1                    # left = 2*i + 1
    r = 2 * i + 2                    # right = 2*i + 2

    # See if left child of root exists and is greater than root
    if l < n and arr[largest] < arr[l]:
        largest = l

    # See if right child of root exists and is greater than root
    if r < n and arr[largest] < arr[r]:
        largest = r

    # Change root, if needed
    if largest != i:
        arr[i], arr[largest] = arr[largest], arr[i]  # swap

        # Heapify the root.
        heapify(arr, n, largest)


def heapSort(arr):
    n = len(arr)

    # Build a maxheap.
    for i in range(n // 2 - 1, -1, -1):
        heapify(arr, n, i)

    # One by one extract elements
    for i in range(n - 1, 0, -1):
        arr[i], arr[0] = arr[0], arr[i]  # swap
        heapify(arr, i, 0)


arr = [12, 11, 13, 5, 6, 7]
heapSort(arr)
n = len(arr)
print("Sorted array is")
for i in range(n):
    print("%d" % arr[i]),

Output

Sorted array is 
5 6 7 11 12 13 

就地排序,典型实现是非稳定的,稳定版可以参照这里

时间复杂度: heapify 的时间复杂度为 O ( log ⁡ n ) O(\log n) O(logn)。createAndBuildHeap() 的时间复杂度为 O ( n ) O(n) O(n),堆排序的总体时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn)

HeapSor t的应用

  1. 对近似排序(或K排序)数组进行排序
  2. 求 top k

堆排序算法的用途有限,因为快速排序和归并排序在实践中效果更好。然而,堆数据结构本身被大量使用。


1.12 快速排序

  • 难度级别:中等
  • 更新时间:2021.04.28
  • source:https://www.geeksforgeeks.org/quick-sort/

与归并排序一样,快速排序也是一种分而治之的算法。它选取一个元素作为轴,并围绕所选取的轴对给定数组进行分区。有许多不同版本的快速排序以不同的方式选择轴。

  1. 始终选择第一个元素作为轴。

  2. 始终选择最后一个元素作为枢轴(在下面实现)

  3. 选择一个随机元素作为轴心。

  4. 选择中间带作为轴。

快速排序的关键过程是 partition()。分区的目标是,给定一个数组和数组的一个元素 x x x 作为轴心,将 x x x 放在排序数组中正确的位置,将所有较小的元素(小于 x x x)放在 x x x 之前,将所有较大的元素(大于 x x x)放在 x x x 之后。所有这些都应该在线性时间内完成。

快速排序的递归版本伪代码

/* low  --> Starting index,  high  --> Ending index */
quickSort(arr[], low, high)
{
    if (low < high)
    {
        /* pi is partitioning index, arr[pi] is now at right place */
        pi = partition(arr, low, high);

        quickSort(arr, low, pi - 1);  // Before pi
        quickSort(arr, pi + 1, high); // After pi
    }
}

quicksort

Partition 算法

划分的方法有很多种,下面的伪代码采用 CLRS 手册中给出的方法。逻辑很简单,我们从最左边的元素开始,跟踪较小(或等于)元素的索引作为 i i i。在遍历时,如果我们找到一个较小的元素,我们就用 arr[i] 交换当前元素。否则我们忽略当前元素。

/* low  --> Starting index,  high  --> Ending index */
quickSort(arr[], low, high)
{
    if (low < high)
    {
        /* pi is partitioning index, arr[pi] is now at right place */
        pi = partition(arr, low, high);

        quickSort(arr, low, pi - 1);  // Before pi
        quickSort(arr, pi + 1, high); // After pi
    }
}

Pseudo code for partition()

/* 此函数将最后一个元素作为轴,将轴元素放置在排序数组中的正确位置,
并将所有较小(小于轴)的元素放置在轴的左侧,将所有较大的元素放置在轴的右侧 */
partition (arr[], low, high)
{
    // pivot (Element to be placed at right position)
    pivot = arr[high];  
 
    i = (low - 1)  // Index of smaller element and indicates the 
                   // right position of pivot found so far

    for (j = low; j <= high- 1; j++)
    {
        // If current element is smaller than the pivot
        if (arr[j] < pivot)
        {
            i++;    // increment index of smaller element
            swap arr[i] and arr[j]
        }
    }
    swap arr[i + 1] and arr[high])
    return (i + 1)
}

partition() 图解 :

arr[] = {10, 80, 30, 90, 40, 50, 70}
Indexes:  0   1   2   3   4   5   6 

low = 0, high =  6, pivot = arr[h] = 70
初始化较小元素的索引, i = -1

遍历元素,从 j = low 到 high-1
j = 0 : 因为 arr[j] <= pivot, 执行 i++ 以及 swap(arr[i], arr[j])
i = 0 
arr[] = {10, 80, 30, 90, 40, 50, 70} // 由于 i 和 j 相等,不做修改

j = 1 : 因为 arr[j] > pivot, 不做任何事

j = 2 : 因为 arr[j] <= pivot, 执行 i++ 以及 swap(arr[i], arr[j])
i = 1
arr[] = {10, 30, 80, 90, 40, 50, 70} // 我们交换了 80 和 30 

j = 3 : 因为 arr[j] > pivot, 不做任何事

j = 4 : 因为 arr[j] <= pivot, 执行 i++ 以及 swap(arr[i], arr[j])
i = 2
arr[] = {10, 30, 40, 90, 80, 50, 70} // 交换 80 和 40

j = 5 : 因为 arr[j] <= pivot, 执行 i++ 以及 swap(arr[i], arr[j])
i = 3 
arr[] = {10, 30, 40, 50, 80, 90, 70} // 交换 90 和 50 

我们退出循环,因为 j 现在等于 high-1。
最后我们通过交换将枢轴放置在正确的位置

arr[i+1] and arr[high] (or pivot) 
arr[] = {10, 30, 40, 50, 70, 90, 80} // 交换 80 和 70

现在 70 在正确的位置。小于 70 的元素都在它前面,大于 70 的元素都在它后面。
def partition(start, end, array):
    pivot_index = start
    pivot = array[pivot_index]

    while start < end:

        while start < len(array) and array[start] <= pivot:
            start += 1

        while array[end] > pivot:
            end -= 1

        if start < end:
            array[start], array[end] = array[end], array[start]

    array[end], array[pivot_index] = array[pivot_index], array[end]
    return end


def quick_sort(start, end, array):
    if start < end:
        p = partition(start, end, array)
        quick_sort(start, p - 1, array)
        quick_sort(p + 1, end, array)


array = [10, 7, 8, 9, 1, 5]
quick_sort(0, len(array) - 1, array)

print(f'Sorted array: {array}')

Output

Sorted array: 
1 5 7 8 9 10 

时间复杂度分析: 最好情况下, O ( n 2 ) O(n^2) O(n2),最好和平均情况下都是 O ( n log ⁡ n ) O(n \log n) O(nlogn),具体分析过程略。

尽管快速排序的最坏情况时间复杂度是 O ( n 2 ) O(n^2) O(n2),这比许多其他排序算法(如归并排序和堆排序)都要高,但是快速排序在实践中速度更快,因为它的内部循环可以在大多数体系结构和大多数实际数据中有效地实现。快速排序可以通过改变 pivot 的选择以不同的方式实现,这样对于给定的数据类型,最坏的情况很少发生。然而,当数据很大且存储在外部存储器中时,归并排序通常被认为更好。

默认实现不稳定。然而,任何排序算法都可以通过将索引作为比较参数来实现稳定。根据就地算法的广泛定义,它是就地排序算法,因为它只使用额外的空间来存储递归函数调用,而不用于操作输入。

什么是三向快速排序?

在简单的快速排序算法中,我们选择一个元素作为 pivot,围绕 pivot 对数组进行划分,并在 pivot 的左右两侧对子数组进行递归。

考虑一个有许多冗余元素的数组。例如,{1,4,2,4,2,4,1,2,4,1,2,2,2,2,4,1,4,4,4}。如果在简单快速排序中选择 4 作为轴心,我们只修复一个 4 并递归处理剩余的事件。在三向快速排序中,数组 arr[l…r] 被分为三部分:

  • arr[l…i] 元素小于枢轴。
  • arr[i+1…j-1] 元素等于枢轴。
  • arr[j…r] 元素大于枢轴。

关于快速排序在链表上的实现以及迭代方式实现可以参考:

为什么快速排序比归并排序更适合排序数组

快速排序的一般形式是就地排序(即它不需要任何额外的存储),而归并排序需要 O ( N ) O(N) O(N) 额外的存储, N N N 表示数组大小,这可能非常昂贵。分配和取消分配用于归并排序的额外空间会增加算法的运行时间。比较平均复杂度,我们发现这两种类型的排序都有 O ( N log ⁡ N ) O(N \log N) O(NlogN) 平均复杂度,但常数不同。对于数组,由于使用了额外的 O ( N ) O(N) O(N) 存储空间,归并排序较差。

快速排序的大多数实际实现都使用随机版本。随机版本的时间复杂度预期为 O ( n log ⁡ n ) O(n \log n) O(nlogn)。最坏的情况在随机化版本中也是可能的,但最坏的情况不会出现在特定的模式(如排序数组)中,随机化快速排序在实践中效果很好。

快速排序也是一种缓存友好的排序算法,因为它在用于数组时具有良好的引用局部性。

快速排序也是尾部递归的,因此尾部调用优化已经完成。

为什么对于链表来说 MergeSort 比 QuickSort 更受欢迎?

在链表的情况下,情况不同主要是由于数组和链表的内存分配不同。与数组不同,链表节点在内存中可能不相邻。与数组不同的是,在链表中,我们可以用 O ( 1 ) O(1) O(1) 额外的空间和 O ( 1 ) O(1) O(1) 时间内完成插入。因此,归并排序的合并操作可以在不为链表增加额外空间的情况下实现。

在数组中,我们可以进行随机存取,因为元素在内存中是连续的。假设我们有一个整数(4 字节)数组 A,让 A[0] 的地址为 x x x,那么要访问 A[i],我们可以直接访问( x + i ∗ 4 x+i*4 x+i4)处的内存。与数组不同,我们不能在链表中进行随机访问。快速排序需要大量此类访问。在链表中,为了访问第 i i i 个索引,我们必须将每个节点从头部移动到第 i i i 个节点,因为我们没有连续的内存块。因此,快速排序的开销会增加。归并排序按顺序存取数据,对随机存取的要求较低。

如何优化快速排序,以便在最坏的情况下占用 O ( log ⁡ n ) O(\log n) O(logn) 额外的空间?

请参阅快速排序尾部调用优化(将最坏情况空间减少到 O ( log ⁡ n ) O(\log n) O(logn)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值