Skip to content

Commit

Permalink
Update 06.Array-Quick-Sort.md
Browse files Browse the repository at this point in the history
  • Loading branch information
itcharge committed Aug 17, 2023
1 parent a03cb21 commit c74423a
Showing 1 changed file with 79 additions and 74 deletions.
153 changes: 79 additions & 74 deletions Contents/01.Array/02.Array-Sort/06.Array-Quick-Sort.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,113 +2,118 @@

> **快速排序(Quick Sort)基本思想**
>
> 通过一趟排序将无序序列分为独立的两个序列,第一个序列的值均比第二个序列的值小。然后递归地排列两个子序列,以达到整个序列有序。
> 采用经典的分治策略,选择数组中某个元素作为基准数,通过一趟排序将数组分为独立的两个子数组,一个子数组中所有元素值都比基准数小,另一个子数组中所有元素值都比基准数大。然后再按照同样的方式递归的对两个子数组分别进行快速排序,以达到整个数组有序。
>
## 2. 快速排序算法步骤

1. 从序列中找到一个基准数 `pivot`(这里以当前序列第 `1` 个元素作为基准数,即 `pivot = arr[low]`)。
2. 使用双指针,将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧:
1. 使用指针 `i`,指向当前需要处理的元素位置,需要保证位置 `i` 之前的元素都小于基准数。初始时,`i` 指向当前序列的第 `2` 个元素位置。
2. 使用指针 `j` 遍历当前序列,如果遇到 `arr[j]` 小于基准数 `pivot`,则将 `arr[j]` 与当前需要处理的元素 `arr[i]` 交换,并将 `i` 向右移动 `1` 位,保证位置 `i` 之前的元素都小于基准数。
3. 最后遍历完,此时位置 `i` 之前的元素都小于基准数,第 `i - 1` 位置上的元素是最后一个小于基准数 `pivot` 的元素,此位置为基准数最终的正确位置。将基准数与该位置上的元素进行交换。此时,基准数左侧都是小于基准数的元素,右侧都是大于等于基准数的元素。
4. 然后将序列拆分为左右两个子序列。
3. 对左右两个子序列分别重复第 `2` 步,直到各个子序列只有 `1` 个元素,则排序结束。

## 3. 快速排序动画演示

![](https://qcdn.itcharge.cn/images/20220817105805.gif)

1. 初始序列为:`[6, 2, 3, 5, 1, 4]`
2.`1` 趟排序:
1. 选择当前序列第 `1` 个元素 `6` 作为基准数。
2. 从左到右遍历序列:
1. 遇到 `2 < 6`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
2. 遇到 `3 < 6`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
3. 遇到 `5 < 6`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
4. 遇到 `1 < 6`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
5. 遇到 `4 < 6`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位,`i` 到达数组末尾。
3. 最终将基准值 `6` 与最后 `1` 位交换位置,则序列变为 `[4, 2, 3, 5, 1, 6]`
4. 将序列分为左子序列 `[4, 2, 3, 5, 1]` 和右子序列 `[]`
3.`2` 趟排序:
1. 左子序列 `[4, 2, 3, 5, 1]` 中选择当前序列第 `1` 个元素 `4` 作为基准数。
2. 从到右遍历左子序列:
1. 遇到 `2 < 4`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
2. 遇到 `3 < 4`,此时 `i``j` 相同,指针 `i` 向右移动 `1` 位。
3. 遇到 `5 > 4`,不进行操作;
4. 遇到 `1 < 4`,此时 `i` 指向 `5``j` 指向 `1`。则将 `5``1` 进行交换,指针 `i` 向右移动 `1` 位,`i` 到达数组末尾。
3. 最终将基准值 `4``1` 交换位置,则序列变为 `[1, 2, 3, 4, 5, 6]`
4. 依次类推,重复选定基准数,并将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。直到各个子序列只有 `1` 个元素,则排序结束。此时序列变为 `[1, 2, 3, 4, 5, 6]`
快速排序算法的步骤可以简单总结为两步:「递归排序」和「哨兵划分」。

## 4. 快速排序算法分析
假设数组的元素个数为 $n$ 个,则快速排序的算法步骤如下:

快速排序算法的时间复杂度主要跟基准数的选择有关。本文中是将当前序列中第 `1` 个元素作为基准值。
1. **哨兵划分**:将数组中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。
1. 从当前数组中找到一个基准数 $pivot$(这里以当前数组第 $1$ 个元素作为基准数,即 $pivot = nums[low]$)。
2. 使用指针 $i$ 指向数组开始位置,指针 $j$ 指向数组末尾位置。
3. 从右向左移动指针 $j$,找到第 $1$ 个小于基准值的元素。
4. 从左向右移动指针 $i$,找到第 $1$ 个大于基准数的元素。
5. 交换指针 $i$、指针 $j$ 指向的两个元素。
6. 重复第 $3 \sim 5$ 步,直到指针 $i$ 和指针 $j$ 相遇时停止,最后将基准数放到两个子数组交界的位置上。
2. **递归排序**:按照同样的方式递归的对两个子数组分别进行快速排序。
1. 按照基准数的位置将数组拆分为左右两个子数组。
2. 对每个子数组分别重复「哨兵划分」和「递归排序」,直到各个子数组只有 $1$ 个元素,排序结束。

在这种选择下,如果参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。也就是会得到最坏时间复杂度
接下来,我们以 $[4, 7, 5, 2, 6, 1, 3]$ 为例,演示一下快速排序的「哨兵划分」和「递归排序」

在这种情况下,第 `1` 趟排序经过 `n - 1` 次比较以后,将第 `1` 个元素仍然确定在原来的位置上,并得到 `1` 个长度为 `n - 1` 的子序列。第 `2` 趟排序进过 `n - 2` 次比较以后,将第 `2` 个元素确定在它原来的位置上,又得到 `1` 个长度为 `n - 2` 的子序列。
### 2.1 哨兵划分

最终总的比较次数为 $(n − 1) + (n − 2) + … + 1 = \frac{n(n − 1)}{2}$。因此这种情况下的时间复杂度为 $O(n^2)$,也是最坏时间复杂度。
::: tabs#quickSort

我们可以改进一下基准数的选择。如果每次我们选中的基准数恰好能将当前序列平分为两份,也就是刚好取到当前序列的中位数。
@tab <1>

在这种选择下,每一次都将序列从 $n$ 个元素变为 $\frac{n}{2}$ 个元素。此时的时间复杂度公式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$。根据主定理可以得出 $T(n) = O(n \times \log_2n)$,也是最佳时间复杂度。
@tab <2>

而在平均情况下,我们可以从当前序列中随机选择一个元素作为基准数。这样,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log_2n)$,也就是平均时间复杂度。
@tab <3>

下面来总结一下:
:::

- **最佳时间复杂度**:$O(n \times \log_2n)$。每一次选择的基准数都是当前序列的中位数,此时算法时间复杂度满足的递推式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$,由主定理可得 $T(n) = O(n \times \log_2n)$。
- **最坏时间复杂度**:$O(n^2)$。每一次选择的基准数都是序列的最终位置上的值,此时算法时间复杂度满足的递推式为 $T(n) = T(n - 1) + \Theta(n)$,累加可得 $T(n) = O(n^2)$。
- **平均时间复杂度**:$O(n \times \log_2n)$。在平均情况下,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log_2n)$。
- **空间复杂度**:$O(n)$。无论快速排序算法递归与否,排序过程中都需要用到堆栈或其他结构的辅助空间来存放当前待排序序列的首、尾位置。最坏的情况下,空间复杂度为 $O(n)$。如果对算法进行一些改写,在一趟排序之后比较被划分所得到的两个子序列的长度,并且首先对长度较短的子序列进行快速排序,这时候需要的空间复杂度可以达到 $O(log_2 n)$。
- **排序稳定性**:快速排序是一种 **不稳定排序算法**
### 2.2 递归排序

## 5. 快速排序代码实现


## 3. 快速排序代码实现

```python
import random

class Solution:
# 从 arr[low: high + 1] 中随机挑选一个基准数,并进行移动排序
def randomPartition(self, arr: [int], low: int, high: int):
# 随机哨兵划分:从 nums[low: high + 1] 中随机挑选一个基准数,并进行移位排序
def randomPartition(self, nums: [int], low: int, high: int) -> int:
# 随机挑选一个基准数
i = random.randint(low, high)
# 将基准数与最低位互换
arr[i], arr[low] = arr[low], arr[i]
# 以最低位为基准数,然后将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
return self.partition(arr, low, high)
nums[i], nums[low] = nums[low], nums[i]
# 以最低位为基准数,然后将数组中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
return self.partition(nums, low, high)

# 以最低位为基准数,然后将序列中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。最后将基准数放到正确位置上
def partition(self, arr: [int], low: int, high: int):
pivot = arr[low] # 以第 1 为为基准数
i = low + 1 # 从基准数后 1 位开始遍历,保证位置 i 之前的元素都小于基准数
# 哨兵划分:以第 1 位元素 nums[low] 为基准数,然后将比基准数小的元素移动到基准数左侧,将比基准数大的元素移动到基准数右侧,最后将基准数放到正确位置上
def partition(self, nums: [int], low: int, high: int) -> int:
# 以第 1 位元素为基准数
pivot = nums[low]

for j in range(i, high + 1):
# 发现一个小于基准数的元素
if arr[j] < pivot:
# 将小于基准数的元素 arr[j] 与当前 arr[i] 进行换位,保证位置 i 之前的元素都小于基准数
arr[i], arr[j] = arr[j], arr[i]
# i 之前的元素都小于基准数,所以 i 向右移动一位
i, j = low, high
while i < j:
# 从右向左找到第 1 个小于基准数的元素
while i < j and nums[j] >= pivot:
j -= 1
# 从左向右找到第 1 个大于基准数的元素
while i < j and nums[i] <= pivot:
i += 1
# 交换元素
nums[i], nums[j] = nums[j], nums[i]

# 将基准节点放到正确位置上
arr[i - 1], arr[low] = arr[low], arr[i - 1]
# 返回基准数位置
return i - 1
nums[i], nums[low] = nums[low], nums[i]
# 返回基准数的索引
return i

def quickSort(self, arr, low, high):
def quickSort(self, nums: [int], low: int, high: int) -> [int]:
if low < high:
# 按照基准数的位置,将序列划分为左右两个子序列
pi = self.randomPartition(arr, low, high)
# 对左右两个子序列分别进行递归快速排序
self.quickSort(arr, low, pi - 1)
self.quickSort(arr, pi + 1, high)
# 按照基准数的位置,将数组划分为左右两个子数组
pivot_i = self.randomPartition(nums, low, high)
# 对左右两个子数组分别进行递归快速排序
self.quickSort(nums, low, pivot_i - 1)
self.quickSort(nums, pivot_i + 1, high)

return arr
return nums

def sortArray(self, nums: List[int]) -> List[int]:
def sortArray(self, nums: [int]) -> [int]:
return self.quickSort(nums, 0, len(nums) - 1)
```

## 4. 快速排序算法分析

快速排序算法的时间复杂度主要跟基准数的选择有关。本文中是将当前数组中第 $1$ 个元素作为基准值。

在这种选择下,如果参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。也就是会得到最坏时间复杂度。

在这种情况下,第 $1$ 趟排序经过 $n - 1$ 次比较以后,将第 $1$ 个元素仍然确定在原来的位置上,并得到 $1$ 个长度为 $n - 1$ 的子数组。第 $2$ 趟排序进过 $n - 2$ 次比较以后,将第 $2$ 个元素确定在它原来的位置上,又得到 $1$ 个长度为 $n - 2$ 的子数组。

最终总的比较次数为 $(n − 1) + (n − 2) + … + 1 = \frac{n(n − 1)}{2}$。因此这种情况下的时间复杂度为 $O(n^2)$,也是最坏时间复杂度。

我们可以改进一下基准数的选择。如果每次我们选中的基准数恰好能将当前数组平分为两份,也就是刚好取到当前数组的中位数。

在这种选择下,每一次都将数组从 $n$ 个元素变为 $\frac{n}{2}$ 个元素。此时的时间复杂度公式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$。根据主定理可以得出 $T(n) = O(n \times \log n)$,也是最佳时间复杂度。

而在平均情况下,我们可以从当前数组中随机选择一个元素作为基准数。这样,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log n)$,也就是平均时间复杂度。

下面来总结一下:

- **最佳时间复杂度**:$O(n \times \log n)$。每一次选择的基准数都是当前数组的中位数,此时算法时间复杂度满足的递推式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$,由主定理可得 $T(n) = O(n \times \log n)$。
- **最坏时间复杂度**:$O(n^2)$。每一次选择的基准数都是数组的最终位置上的值,此时算法时间复杂度满足的递推式为 $T(n) = T(n - 1) + \Theta(n)$,累加可得 $T(n) = O(n^2)$。
- **平均时间复杂度**:$O(n \times \log n)$。在平均情况下,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log n)$。
- **空间复杂度**:$O(n)$。无论快速排序算法递归与否,排序过程中都需要用到堆栈或其他结构的辅助空间来存放当前待排序数组的首、尾位置。最坏的情况下,空间复杂度为 $O(n)$。如果对算法进行一些改写,在一趟排序之后比较被划分所得到的两个子数组的长度,并且首先对长度较短的子数组进行快速排序,这时候需要的空间复杂度可以达到 $O(log_2 n)$。
- **排序稳定性**:快速排序是一种 **不稳定排序算法**

## 参考资料

- 【文章】[快速排序 - OI Wiki](https://oi-wiki.org/basic/quick-sort/)

0 comments on commit c74423a

Please sign in to comment.