1.快排
快排的思想就是"分而治之".找到一个分区点,然后将小的放在前面,大的放在后面,分区点放在中间.最后发现待排序的区间变为1时停止.
/*在这个函数中会改变原数组,见下图:*/
/*从头到尾迭代,和支点比较,大的不管,小的换。换了才往后看。最后支点戳中间,算是分界线*/
int partition(vector<int> &a, int l, int r)
{
int k = l, pivot = a[r];
int temp = 0;
for (int i = l; i < r; i++)
{
if (a[i] <= pivot) /*相等的元素要么放右边,要么放左边,这里也可以放在右边*/
{
std::swap(a[i], a[k++]);
}
}
std::swap(a[k], a[r]);
return k;
}
/*快速排序递归函数*/
void quick_sort(vector<int> &a, int l, int r)
{
if (l >= r)
return;
int q = partition(a, l, r); /*获取分区点*/
quick_sort(a, l, q - 1);
quick_sort(a, q + 1, r);
}
void QuickSortWithRecursive(vector<int> &a)
{
quick_sort(a, 0, a.size() - 1);
}
其中经过partition
函数分区之后,数组就会变成这样.partition
在一般情况下会选择以数组的尾巴为分界线
- 时间复杂度:大多数情况下时间复杂度O
(nlog(n))
.极少数情况下,效率是O(n^2)
.(所谓的优化其实就是将这个概率不断降低) - 空间复杂度O(1).原地排序
- 是否稳定:不稳定
2. 堆排序(完全二叉树形数据结构)
只要满足这两点,它就是一个堆。
- 堆是一个完全二叉树; (最后一层靠左排列)
- 堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。
对于同一组数据,我们可以构建多种不同形态的堆。
如何实现一个堆?
完全二叉树适合用数组来存储数据,这样会比较节省内存,因为节点会一个接一个出现在数组中,而不会产生空白.
从图中我们可以看到,数组下标从1开始,对于数组中下标为 i 的节点:
- 左子节点,就是下标为
i∗2
的节点, - 右子节点,就是下标为
i∗2+1
的节点 - 父节点,就是下标为
i/2
的节点。
插入元素
堆化有两种方式:从上往下和从下往上.
从下往上
删除堆顶元素
假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。
这里我也画了一个分解图。不过这种方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性。
从上往下(其实就是把节点从上面往下堆化)
实际上,我们稍微改变一下思路,就可以解决这个问题。你看我画的下面这幅图。我们把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法
因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。
一个包含 n
个节点的完全二叉树,树的高度不会超过 log2n
。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)
。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)
堆排序时间复杂度:O(nlogn)
,原地算法
如何基于堆实现排序?(两步:建堆与排序)
1. 建堆
- 第一种方法就是:从第二个元素开始,依次插入
- 第二种方法是:从后往前处理数组,并且每个数据都是从上往下堆化
由于第二种方法比较有效率,所以直接上第二种方法即可
因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从第一个非叶子节点开始,依次堆化就行了。
对于完全二叉树而言:
- 叶子节点是:
n/2+1
到n - 非叶子节点是:1到
n/2
void heapify(vector<int> &a, int n, int i)
{
while (true)
{
int maxPos = i;
if (i * 2 <= n && a[i] < a[i * 2])
maxPos = i * 2;
if (i * 2 + 1 <= n && a[maxPos] < a[i * 2 + 1])
maxPos = i * 2 + 1;
if (maxPos == i)
break;
swap(a[i], a[maxPos]);
i = maxPos;
}
}
void buildHeap(vector<int> &a, int n)
{
for (int i = n / 2; i >= 1; --i)
{
heapify(a, n, i);
for (auto i : a)
cout << i << " ";
cout << endl;
sleep(1);
}
for (auto i : a)
cout << i << " ";
cout << endl;
sleep(1);
}
建堆的时间复杂度:O(n)
2.排序(O(n))
建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。然后堆该n-1
个元素再次堆化就行了
// n 表示数据的个数,数组 a 中的数据从下标 1 到 n 的位置。
void sort(vector<int> &a, int n)
{
buildHeap(a, n);
int k = n;
while (k > 1)
{
swap(a[1], a[k]);
--k;
heapify(a, k, 1);
}
}
堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。
提问:为什么它与快排的时间复杂度相同,但是快排就是比它要性能好呐?
- 第一点,堆排序数据访问的方式没有快速排序友好。(没有顺序遍历,对CPU缓存不友好)
- 第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。(因为需要建堆,就会打乱有些有序序列)
3.冒泡与选择
冒泡:冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个数据进行比较,如果前者比后者大,就互相交换,最后就会找到一个最大的落在数组最后.重复以上工作n次即可完成排序.
void BubbleSort(vector<int> a)
{
int len = a.size();
if (len <= 1)
return;
for (int i = 0; i < len; i++)
{
for (int j = 0; j < len - i - 1; j++)
{
if (a[j + 1] < a[j])
std::swap(a[j], a[j + 1]);
}
}
}
- 时间复杂度:平均时间复杂度O(n^2).
- 空间复杂度O(1).原地排序
- 是否稳定:稳定
选择:每次找到最小的一个元素放到已经排好序的数组的尾部即可.
void SelectSort(vector<int> a)
{
int len = a.size();
if (len <= 1)
return;
for (int i = 0; i < len; i++)
{
for (int j = i + 1; j < len; j++)
{
if (a[j] < a[i])
std::swap(a[j], a[i]);
}
}
}
2.插入
就是将后面的数据与前面的数据进行相比,如果小的话就将前面的数据向后移动,然后将其插入即可.
void InsertSort(vector<int> &a)
{
int len = a.size();
if (len <= 1)
return;
for (int i = 1; i < len; i++)
{
int key = a[i];
int j = i - 1;
/*查找插入的位置*/
for (; j >= 0; j--)
{
if (a[j] > key)
a[j + 1] = a[j]; /*数据移动*/
else
break;
}
a[j + 1] = key; //数据插入
}
}
- 时间复杂度:平均时间复杂度O(n^2).因为我们在一个数组中插入数据是O(n),最坏也是n的平方
- 空间复杂度O(1).原地排序
- 是否稳定:稳定
4. 拓扑排序(与图有关,图用(逆)邻接表存储)
我们先来看一个生活中的拓扑排序的例子。
我们在穿衣服的时候都有一定的顺序,我们可以把这种顺序想成,衣服与衣服之间有一定的依赖关系。比如说,你必须先穿袜子才能穿鞋,先穿内裤才能穿秋裤。假设我们现在有八件衣服要穿,它们之间的两两依赖关系我们已经很清楚了,那如何安排一个穿衣序列,能够满足所有的两两之间的依赖关系?
这就是个拓扑排序问题。从这个例子中,你应该能想到,在很多时候,拓扑排序的序列并不是唯一的。你可以看我画的这幅图,我找到了好几种满足这些局部先后关系的穿衣序列。
拓扑排序的一个经典问题就是"如何确定代码源文件的编译依赖关系?"什么是依赖关系,这个就不属于本文讨论范畴了.那么该如何解决呐?
我们可以把源文件与源文件之间的依赖关系,抽象成一个有向图。每个源文件对应图中的一个顶点,源文件之间的依赖关系就是顶点之间的边。 如果 a 先于 b 执行,也就是说 b 依赖于 a,那么就在顶点 a 和顶点 b 之间,构建一条从 a 指向 b 的边。而且,这个图不仅要是有向图,还要是一个有向无环图,也就是不能存在像 a->b->c->a 这样的循环依赖关系。因为图中一旦出现环,拓扑排序就无法工作了。实际上,拓扑排序本身就是基于有向无环图的一个算法。
OK ,现在我们有思路了,但是我们如何实现拓扑排序呐?这里有两种方法:
(1)Kahn 算法
Kahn 算法实际上用的是贪心算法思想
,思路非常简单、好懂。
定义数据结构的时候,如果 s 需要先于 t 执行,那就添加一条 s 指向 t 的边。所以,如果某个顶点入度为 0, 也就表示,没有任何顶点必须先于这个顶点执行,那么这个顶点就可以执行了。
我们先从图中,找出一个入度为 0 的顶点,将其输出到拓扑排序的结果序列中(对应代码中就是把它打印出来),并且把这个顶点从图中删除(也就是把这个顶点<指向的顶点的入度>都减 1)
。我们循环执行上面的过程,直到所有的顶点都被输出。最后输出的序列,就是满足局部依赖关系的拓扑排序。
(2)DFS 算法
首先,我们去思考DFS
算法的思想,递归,从开始到结束,再从结束到上一步.那么我们邻接表是否适合?答案不适合,应该用逆邻接表
.具体看代码解释:
#include <iostream>
#include <vector>
#include <list>
#include <algorithm>
#include <queue>
// 如果点的表示还需要插入等操作的话,就将vector 改为 std::list
using namespace std;
class Graph
{
public:
Graph(int v)
{
this->v = v;
for (int i = 0; i < v; i++)
adj.push_back(std::list<int>());
}
void AddEdge(int s, int t)//s->t
{
adj[s].push_back(t);
}
void TopSortByKahn()
{
vector<int> inDegree(v, 0); //统计每个顶点的入度
for (int i = 0; i < v; i++)
{
for (auto t : adj[i])
{
inDegree[t]++;
}
}
queue<int> que;
for (int i = 0; i < v; i++)
{
if (inDegree[i] == 0)
que.push(i);
}
while (!que.empty())
{
int head = que.front();
que.pop();
cout << "->" << head;
for (auto t : adj[head])
{
inDegree[t]--;
if (inDegree[t] == 0)
que.push(t);
}
}
}
void TopSortByDFS()
{
// 先构建逆邻接表
std::vector<std::list<int>> inverseadj(v, std::list<int>());
for (int i = 0; i < v; i++) // 通过邻接表生成逆邻接表
{
for (auto t : adj[i])
{
inverseadj[t].push_back(i);
}
}
bool visted[v] = {false};
for (int i = 0; i < v; i++) //dfs
{
if (visted[i] == false)
{
visted[i] = true;
dfs(i, inverseadj, visted);
}
}
}
private:
void dfs(int vertex, const std::vector<std::list<int>> &inverseadj,
bool visted[])
{
// cout << vertex << endl;
for (auto t : inverseadj[vertex])
{
if (visted[t] == true)
continue;
else
{
visted[t] = true;
dfs(t, inverseadj, visted);
}
}// 先把 vertex "这个顶点依赖的所有顶点"都打印出来之后,再打印它自己(核心)
cout << "->" << vertex;
}
private:
int v; //顶点个数
std::vector<std::list<int>> adj; //邻接表
};
int main(void)
{
Graph g(6);
g.AddEdge(5, 2);
g.AddEdge(5, 0);
g.AddEdge(4, 0);
g.AddEdge(4, 1);
g.AddEdge(2, 3);
g.AddEdge(3, 1);
g.TopSortByKahn();
cout << endl;
g.TopSortByDFS();
cout << endl;
return 0;
}
所使用的图是:
5.
6.
7.几道排序题目:
(1)148. 排序链表
在 O(n log n) 时间复杂度和常数级空间复杂度下,对链表进行排序。
示例 1:
输入: 4->2->1->3
输出: 1->2->3->4
示例 2:
输入: -1->5->3->4->0
输出: -1->0->3->4->5
考虑到O(nlogn)的排序算法有归并,快排,堆排.这里直接使用归并排序.
具体思路就是:把链表不断的断成两条,直到剩一个节点,然后再合并起来就行了.具体见代码:
class Solution
{
public:
ListNode *sortList(ListNode *head)
{
if (head == NULL || head->next == NULL)
return head;
ListNode *mid = findMid(head);
ListNode *left = sortList(head);
ListNode *right = sortList(mid);
return mergeTwoLists(left, right);
}
ListNode *findMid(ListNode *head)
{
if (!head || !head->next)
return head;
ListNode *slow = head;
ListNode *fast = head->next->next;
while (fast)
{
slow = slow->next;
fast = fast->next;
if (fast)
fast = fast->next;
}
ListNode *mid = slow->next;
slow->next = NULL;
return mid;
}
ListNode *mergeTwoLists(ListNode *l1, ListNode *l2)
{
ListNode result = ListNode(-100);
ListNode *test = &result;
while (l1 && l2)
{
//注意这里判断了相等的情况
//l1->val == l2->val
if (l1->val <= l2->val)
{
test->next = l1;
l1 = l1->next;
}
else
{
test->next = l2;
l2 = l2->next;
}
test = test->next;
}
if (l1)
test->next = l1;
if (l2)
test->next = l2;
return result.next;
}
};