Quick Sort
This page provides a brief introduction to quicksort.
Definition
Quicksort, also known as partition-exchange sort, often referred to as "quicksort," is a widely used sorting algorithm.
Basic Principles and Implementation
Process
Quicksort works by sorting an array through a divide-and-conquer approach.
Quicksort consists of three steps:
- Partition the array into two parts (ensuring relative order is maintained);
- Recursively apply quicksort to the two subsequences;
- No merging is needed because the array is already fully sorted at this point.
Unlike merge sort, the first step does not directly split the array into two sequences. Instead, during partitioning, the relative order must be maintained. Specifically, the first step is to divide the array into two parts, ensuring that all elements in the first subsequence are smaller than those in the second subsequence. To guarantee average time complexity, a random element \(m\) is typically chosen as the pivot to separate the two subsequences.
After that, maintain two pointers, \(p\) and \(q\), one at the front and one at the back, and check whether the current element is in the correct position (front or back). If the current element is misplaced—for example, if the back pointer \(q\) encounters an element smaller than \(m\)—then swap the elements at positions \(p\) and \(q\), and move \(p\) one step forward. Once all current elements are correctly placed, move the pointers and continue processing until the two pointers meet.
In fact, quicksort does not specify how to implement the first step concretely. Both the process of selecting \(m\) and the partitioning process can be implemented in more than one way.
In the third step, the sequences are already sorted individually, and all elements in the first sequence are smaller than those in the second, so they can simply be concatenated.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | |
Properties
Stability
Quicksort is an unstable sorting algorithm.
Time Complexity
The optimal and average time complexity of quicksort is \(O(n\log n)\), while the worst-case time complexity is \(O(n^2)\).
In the optimal case, each chosen pivot is the median of the sequence. The recurrence relation for the time complexity is \(T(n) = 2T(\dfrac{n}{2}) + \Theta(n)\). By the master theorem, \(T(n) = \Theta(n\log n)\).
In the worst case, each chosen pivot is an extreme value (minimum or maximum) of the sequence. The recurrence relation is \(T(n) = T(n - 1) + \Theta(n)\), which sums to \(T(n) = \Theta(n^2)\).
In the average case, the pivot can be considered as chosen uniformly at random.
Proof
Below we prove that the time complexity in this case is \(O(n\log n)\).
Lemma 1: When performing quicksort on an array of \(n\) elements, let the total number of comparisons during partitioning be \(X\). Then the time complexity of quicksort is \(O(n + X)\).
Since an element is selected as the pivot during each partitioning step, partitioning occurs at most \(n\) times. Moreover, the number of comparisons during partitioning is on the same order as other basic operations, so the total time complexity is \(O(n + X)\).
Let \(a_i\) be the \(i\)-th smallest element in the original array. Define \(A_{i,j}\) as \(\{ a_i, a_{i+1}, \dots, a_j \}\), and \(X_{i,j}\) as a discrete random variable taking values \(0\) or \(1\), indicating whether \(a_i\) and \(a_j\) are compared during the sorting process.
Clearly, each chosen pivot is distinct, and elements are only compared with the pivot. Thus, the total number of comparisons is
By linearity of expectation,
Lemma 2: \(a_i\) and \(a_j\) are compared if and only if \(a_i\) or \(a_j\) is the first pivot selected from the set \(A_{i,j}\).
First, necessity: if neither \(a_i\) nor \(a_j\) is the first pivot selected from \(A_{i,j}\), then \(a_i\) and \(a_j\) are not compared.
If neither \(a_i\) nor \(a_j\) is the first pivot selected from \(A_{i,j}\), then there exists an \(x\) with \(i < x < j\) such that \(a_x\) is the first pivot selected from \(A_{i,j}\). In the partition with \(a_x\) as the pivot, \(a_i\) and \(a_j\) are placed into two different subsequences of the array, so they will definitely not be compared afterward. Since elements are only compared with the pivot, \(a_i\) and \(a_j\) are not compared before or during this partition. Thus, \(a_i\) and \(a_j\) are not compared.
Next, sufficiency: if \(a_i\) or \(a_j\) is the first pivot selected from \(A_{i,j}\), then \(a_i\) and \(a_j\) are compared.
Without loss of generality, assume \(a_i\) is the first pivot selected from \(A_{i,j}\). Since no other element in \(A_{i,j}\) is selected as a pivot, all elements in \(A_{i,j}\) remain in the same subsequence of the array. In the partition with \(a_i\) as the pivot, \(a_i\) is compared with all elements in the current subsequence, so \(a_i\) and \(a_j\) are compared.
Consider computing \(P(a_i\ \text{and}\ a_j\ \text{are compared})\). Before any element in \(A_{i,j}\) is selected as a pivot, all elements in \(A_{i,j}\) are in the same subsequence of the array. Thus, each element in \(A_{i,j}\) is equally likely to be the first selected as a pivot. Since \(A_{i,j}\) has \(j - i + 1\) elements, by Lemma 2,
Therefore,
Thus, the expected time complexity of quicksort is \(O(n \log n)\).
In practice, the worst case is almost never reached. Moreover, quicksort's memory access follows the principle of locality, so in most cases, quicksort performs significantly better than other \(O(n \log n)\) sorting algorithms like heapsort.1
Optimization
Naive Optimization Ideas
If quicksort is implemented solely based on the basic idea described above (or by directly copying a template), it will likely fail to pass the template problem P1177【Template】Quicksort. This is because malicious data can degrade naive quicksort to \(O(n^2)\).
Therefore, we need to optimize the naive quicksort approach. Common optimization ideas include the following2:
- Use the median-of-three method (i.e., select the median of the first, last, and middle elements) to choose the pivot for partitioning the two subsequences. This avoids degradation caused by extreme data (such as ascending or descending sequences);
- When the sequence is short, use insertion sort for better efficiency;
- After each partitioning step, gather elements equal to the pivot around the pivot. This avoids degradation caused by extreme data (such as sequences where most elements are equal).
Below are several more mature optimization methods for quicksort.
Three-way Quicksort
Definition
Three-way quicksort (also known as 3-way radix quicksort) is a hybrid of quicksort and radix sort. Its algorithm is based on the solution to the Dutch national flag problem.
Process
Unlike the original quicksort, after randomly selecting a pivot \(m\), three-way quicksort partitions the array into three parts: elements less than \(m\), equal to \(m\), and greater than \(m\). This achieves the effect of gathering elements equal to the pivot around it.
Properties
Three-way quicksort is far more efficient than the original quicksort when dealing with arrays containing many duplicate values. Its best-case time complexity is \(O(n)\).
Implementation
Three-way quicksort is straightforward to implement. Below is a C++ implementation of three-way quicksort.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | |
Introsort
Definition
Introsort (short for introspective sort)3 is a hybrid of quicksort and heapsort, invented by David Musser in 1997. Introsort is essentially an optimization of quicksort, guaranteeing a worst-case time complexity of \(O(n\log n)\).
Properties
Introsort limits the maximum recursion depth of quicksort to \(\lfloor \log_2n \rfloor\). When this depth is exceeded, it switches to heapsort. This preserves the locality of memory access in quicksort while preventing performance degradation to \(O(n^2)\) in certain scenarios.
Implementation
Since June 2000, the sort() function in SGI C++ STL's stl_algo.h has been implemented using the introsort algorithm.
Finding the k-th Largest Element in Linear Time
In the code examples below, the \(k\)-th largest element is defined as the element at the \(k\)-th position (0-indexed) when the sequence is sorted in ascending order.
The simplest method to find the \(k\)-th largest element (K-th order statistic) is to sort the sequence first and then directly access the element at the \(k\)-th position. This approach has a time complexity of \(O(n\log n)\), which is inefficient for this problem.
We can solve this problem using the idea of quicksort. Consider the partitioning step of quicksort. After partitioning, the array \(A_{p} \cdots A_{r}\) is divided into \(A_{p} \cdots A_{q}\) and \(A_{q+1} \cdots A_{r}\). At this point, we can decide whether to recursively search in the left or right part based on the number of elements on the left (\(q - p + 1\)) and the value of \(k\).
Like quicksort, the time complexity of this method depends on the pivot chosen during each partitioning step. If the pivot is selected randomly, it can be proven that the expected time complexity is \(O(n)\).
Implementation (C++)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
Improvement: Median of Medians
The median of medians method provides a deterministic way to select the pivot during partitioning, enabling the algorithm to find the \(k\)-th largest element with linear time complexity even in the worst case.
The algorithm proceeds as follows:
- Divide the entire sequence into \(\left \lfloor \dfrac{n}{5} \right \rfloor\) groups, each containing at most 5 elements;
- Find the median of each group (since the groups are small, algorithms like insertion sort can be used directly).
- Find the median of these \(\left \lfloor \dfrac{n}{5} \right \rfloor\) medians. Use this element as the pivot in the partitioning step of the algorithm described above.
Time Complexity Proof
We now prove that the worst-case time complexity of this algorithm is \(O(n)\). Let \(T(n)\) be the computational cost required to solve a problem of size \(n\).
First, analyze the first two steps—partitioning and finding medians. Since each group is very small, finding the median of a group can be considered \(O(1)\). Thus, finding all \(\left \lfloor \dfrac{n}{5} \right \rfloor\) medians takes \(O(n)\) time.
Next, analyze the third step—the recursive process. This step involves two recursive calls: the first is to find the median of medians, which clearly costs \(T(\dfrac{n}{5})\). The second is to enter either the left or right side of the pivot. Based on the chosen pivot, at least \(\dfrac{1}{2} \times \left \lfloor \dfrac{n}{5} \right \rfloor = \left \lfloor \dfrac{n}{10} \right \rfloor\) groups have medians smaller than the pivot. In these groups, elements smaller than the median are also smaller than the pivot. Thus, at least \(3 \times \left \lfloor \dfrac{n}{10} \right \rfloor = \left \lfloor \dfrac{3n}{10} \right \rfloor\) elements in the entire sequence are smaller than the pivot. Similarly, at least \(\left \lfloor \dfrac{3n}{10} \right \rfloor\) elements are greater than the pivot. Therefore, each side of the pivot has at most \(\dfrac{7n}{10}\) elements, so the upper bound for the time cost of this recursive call is \(T(\dfrac{7n}{10})\).
In summary, we can write the inequality:
Assume \(T(n) = O(n)\) holds for sufficiently small problem sizes. By definition, \(T(n) \leq cn\) for some constant \(c\). Substitute all \(T(n)\) on the right side:
Thus, we have proven that the algorithm has a worst-case time complexity of \(O(n)\).
References and Notes
本页面最近更新:,更新历史
发现错误?想一起完善? 在 GitHub 上编辑此页!
本页面贡献者:OI-wiki
本页面的全部内容在 CC BY-SA 4.0 和 SATA 协议之条款下提供,附加条款亦可能应用