学数据结构与算法不是仅仅学算法本身(经验),而是学习思维(解决问题的能力
)。
1、算法的性能分析
衡量指标:时间复杂度、空间复杂度、稳定性
1.1 时间复杂度
用O()表示,本质就是:算法的计算次数。
O(1) < O(logn) < O(n) < O(nlogn) < O(n^2) < …
#include<iostream>
using namespace std;
void test01()
{
// 时间复杂度:O(n)
int n, ans = 0;
cin >> n;
for (int i = 0; i < n; i++)
{
ans++;
}
// 有效计算次数 / 实际时间复杂度:O(n+3)
// 但当运行次数趋于无穷时候,3可以忽略不计
// 时间复杂度:O(n)
cin >> n;
for (int i = 0; i < n; i++)
{
ans++;
}
ans++;
ans++;
ans++;
}
void test02()
{
// 有效计算次数 / 实际时间复杂度:O(n/2)
// 但当运行次数趋于无穷时候,1/2可以忽略不计
// 时间复杂度:O(n)
int n, ans = 0;
cin >> n;
for (int i = 0; i < n; i+=2)
{
ans++;
}
}
void test02()
{
// 有效计算次数 / 实际时间复杂度:O(n/2)
// 时间复杂度:O(n)
int n, ans = 0;
cin >> n;
for (int i = 0; i < n; i += 2)
{
ans++;
}
}
void test03()
{
int n, ans = 0;
cin >> n;
// 时间复杂度:O(n^2)
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
ans++;
}
}
// 有效计算次数 / 实际时间复杂度:O(n^2+n)
// 但当运行次数趋于无穷时候,n相较于n^2,可以忽略不计
// 时间复杂度:O(n^2)
for (int i = 0; i < n; i++)
{
for (int j = 0; j < n; j++)
{
ans++;
}
}
for (int i = 0; i < n; i++)
{
ans++;
}
}
int main()
{
test01();
system("pause");
return 0;
}
1.2 空间复杂度
本质就是:除去输入数据本身所占用的空间之外,额外
产生的空间(不是数据本身);这些数据通常是由用户提供的或从外部系统读取的,是算法的“输入”。
额外
:也就是算法为了处理数据,额外使用的空间。
空间复杂度是和内存
有关。
logn的空间比较少,n^2这种空间的嵌套也比较少。
O(1) < O(n) < O(nlogn)
int x = 5; 定义了一个整型变量 x,它占用的空间是固定的,不随输入规模 n 变化,因此其空间复杂度为 O(1)。
int arr[n]; 定义了一个大小为 n 的数组 arr,需要 n 个整型空间。因此,arr 占用的空间随 n 增长,空间复杂度为 O(n)。
void example(int n)
{
int x = 5; // 占用常数空间 O(1)
int arr[n]; // 占用线性空间 O(n)
}
假设你有一个算法,它需要对一个整数数组进行排序。这个数组就是输入数据。输入数据:arr[] 是输入数据,它是算法需要处理的内容。arr 的大小是 n,所以输入数据本身占用的空间是 O(n)。那么这个部分我们通常不考虑在空间复杂度分析中,因为这是算法必须使用
的空间。
void sortArray(int arr[], int n)
{
// 对数组进行排序的算法
}
额外空间
:这是除了存储输入数据之外,算法需要额外分配的空间。例如,创建新的数组、栈空间(在递归中)或者额外的变量。
void processData(int arr[], int n)
{
int temp; // 一个额外的变量,占用常数空间 O(1)
int result[n]; // 一个新的数组,占用 O(n) 的空间
for (int i = 0; i < n; i++) {
result[i] = arr[i] * 2; // 操作输入数据,并存储到新数组中
}
}
上述示例中:
输入数据:arr[] 是输入数据,它的大小为 n,输入数据本身的空间复杂度是 O(n)。
额外空间:变量 temp 占用了 O(1) 的空间,因为它是一个单独的整数变量。
数组 result[] 占用了 O(n) 的空间,因为它的大小与输入数组 arr[] 相同。
#include<iostream>
using namespace std;
void test01()
{
// 冒泡排序
// 真实时间复杂度:O((n^2-n) / 2 + 2n)
// 时间复杂度:O(n^2)
// 空间复杂度:O(1)
int a[101];
int n;
cin >> n;
// 这里的a占用n个空间,属于输入,不算额外产生的空间
for (int i = 0; i < n; i++)
{
cin >> a[i];
}
for (int i = n - 1; i > 0; i--)
{
for (int j = 0; j < n - 1; j++)
{
if (a[j] > a[j + 1])
{
// swap底层其实是有第三变量temp交换,但是是有穷的,算O(1)
swap(a[j], a[j + 1]);
}
}
}
for (int i = 0; i < n; i++)
{
cout << a[i] << " ";
}
cout << endl;
}
void test02()
{
int n;
cin >> n;
// 使用了额外的空间,空间复杂度:0(n)
int* arr = new int[n];
for (int i = n - 1; i > 0; i--)
{
for (int j = 0; j < n - 1; j++)
{
if (arr[j] > arr[j + 1])
{
swap(arr[j], arr[j + 1]);
}
}
}
delete[] arr;
}
int main()
{
test01();
system("pause");
return 0;
}
1.3 小结
时间复杂度越高,程序运行的时间就越长。
时间和空间往往是相对的,一般,可能会用时间换空间,用空间换时间。
2、高精度
为什么要接触高精度?
主要就是:解决数据溢出
的问题。
下方的代码,不同的整型是有取值范围的。
int main()
{
// int型就是:-2^31 到 2^32-1
// long long 是:-2^63 到 2^63-1
int a, b;
cin >> a >> b;
cout << a + b << endl;
system("pause");
return 0;
}
还没等输入第二个变量的赋值,程序就结束了。低精度
,就搞不定我们这种需求了。
高精度
的本质就是使用字符
进行计算。
但是,当发现使用字符串
(字符数组)时,能够很好地解决这种数据溢出的问题,但是字符串之间不能够像数值计算那样,+号运算符重载也只是字符串拼接。
int main()
{
string a, b;
cin >> a >> b;
cout << a + b << endl;
system("pause");
return 0;
}
但是,单个字符使用ASCII码,进行存储的,字符’1’是49,使用字符'1' - '0'
就可以巧妙的得到数值1本身。
2.1 高精度加法
注意事项:
1、字符数组局部变量要初始化
,可以写到全局
变量(默认初始化为0)
2、高精度
的本质就是使用字符
进行计算,通过字符 - ‘0’ 转换为数值本身
3、使用两个字符数组相加,存放结果的数组的长度为这两个字符数组中的最长长度+1
。
#include<iostream>
using namespace std;
#include<string>
void string_to_int(string str, int dst[])
{
for (int i = 0; i < str.size(); i++)
{
// "1234"
// 要放置成
// "4321"
// 也就是第0个放在第3个,第1个放在第2个,
// 如果有n个,第i个就是放在第n-i-1个
dst[str.size() - i -1] = str[i] - '0';
}
}
int main()
{
// "1234"
// +"567"
// 个位不能够有效对齐,所以对字符数组取反,再计算
// "4321"
// +"7650"
// "1081"
// 取反不影响计算,最后算完,再反转,得:1801就可以了。
string s1, s2;
// 字符数组局部变量要初始化,可以写到全局变量(默认初始化为0)
int a[101]= {
0 };
int b[101]= {
0 };
int c[101]= {
0 };
cin >> s1 >> s2;
// 1.个位对齐(先反转),字符转整型
string_to_int(s1, a);
string_to_int(s2, b);
for (int i = 0; i < s1.size(); i++)
{
cout << a[i];
}
cout << endl;
for (int i = 0; i < s2.size(); i++)
{
cout << b[i];
}
cout << endl;
// 2.对位相加,放到c数组中,但首先要计算c数组的长度
// "9999" + "1" 是5位。所以两数相加,结果的最高的长度就是最长的数位数+1
int c_len = max(s1.size(), s2.size()) + 1;
// 3.对位相加,放到c数组中
for (int i = 0; i < c_len; i++)
{
c[i] += a[i] + b[i];
// 下一位进位
c[i + 1] = c[i] / 10;
// 两数相加可能会有进位,保留个位
c[i] = c[i] % 10;
}
// 反转输出,比如相加为1801没有进位, 但是这样可能会造成c_len多1个0
// 就会是18010
// 从最后1位逐个判断首位是否是0,逐个去0
while (c[c_len] == 0 && c_len>1)
{
c_len--;
}
// 倒叙打印
for (int i = c_len; i >= 0; i--)
{
cout << c[i];
}
cout << endl;
system("pause");
return 0;
}
2.2 高精度减法
注意事项:
1、最好保证是长
的数 - 短
的数,最后加个负号
就行;长度相同,就比字符串。
2、使用两个字符数组相减,存放结果的数组的长度为这两个字符数组中的最长长度
。
3、在字典序中,比较两个字符串时是逐字符
进行的,每个字符的排序取决于其编码值。即使 s1 和 s2 的长度不同,只要 s1 的第一个字符在字典序中大于 s2 的第一个字符,结果就是 true。
#include<iostream>
using namespace std;
#include<string>
void string_to_int(string str, int dst[])
{
for (int i = 0; i < str.size(); i++)
{
// "1234"
// 要放置成
// "4321"
// 也就是第0个放在第3个,第1个放在第2个,
// 如果有n个,第i个就是放在第n-i-1个
dst[str.size() - i -1] = str[i] - '0';
}
}
bool comStr(string str1, string str2)
{
if (str1.size() != str2.size())
{
return str1.size() >= str2.size();
}
else
{
// 两个字符串长度相同,比大小
return str1 >= str2;
}
}
int main()
{
int a[101] = {
0 };
int b[101] = {
0 };
int c[101] = {
0 };
string s1, s2;
cin >> s1 >> s2;
if (comStr(s1, s2) == false)
{
// 如果s1比s2长度短,或s1 < s2,进行替换,这样就能保证大数减小数
/*string temp = s1; s1 = s2; s2 = temp;*/
swap(s1, s2);
}
// 1.反转
string_to_int(s1, a);
string_to_int(s2, b);
// 2.对位相减,放到c数组中,但首先要计算c数组的长度
// "9999" - "1" 是4位。所以两数相减,结果的最高的长度就是最长的数位数
int c_len = max(s1.size(), s2.size());
// 3.对位相减,放到c数组中
// "1234"
//- "567"
// 反转
// "4321"
// - "765"
for (int i = 0; i < c_len; i++)
{
// 要判断个位够不够减
if (a[i] < b[i])
{
a[i] += 10; //借位
a[i + 1]--; // 高位减1
}
c[i] = a[i] - b[i];
}
while (c[c_len] == 0 && c_len > 0)
{
c_len--;
}
// 倒叙打印
for (int i = c_len; i >= 0; i--)
{
cout << c[i];
}
cout << endl;
system("pause");
return 0;
}
2.3 高精度乘法
注意事项:
1、乘法里还是有加法的影子,找通项公式
。
2、使用两个字符数组相乘,存放结果的数组的长度最大为这两个字符数组中的长度之和
。
3、在字典序中,比较两个字符串时是逐字符
进行的,每个字符的排序取决于其编码值。即使 s1 和 s2 的长度不同,只要 s1 的第一个字符在字典序中大于 s2 的第一个字符,结果就是 true。
#include<iostream>
using namespace std;
#include<string>
void string_to_int(string str, int* a)
{
for (int i = 0; i < str.size(); i++)
{
// "1234"
// 要放置成
// "4321"
// 也就是第0个放在第3个,第1个放在第2个,
// 如果有n个,第i个就是放在第n-i-1个
a[str.size() - i - 1] = str[i] - '0';
}
}
int main()
{
int a[201] = {
0 };
int b[201] = {
0 };
int c[201] = {
0 };
string s1, s2;
cin >> s1 >> s2;
// 1.反转
string_to_int(s1, a);
string_to_int(s2, b);
// 2.对位相乘,放到c数组中,但首先要计算c数组的长度
// "1000" * "10" 是5位。所以两数相乘,结果的最高的长度就是最长的数位数
int c_len = s1.size() + s2.size();
// 3.对位相乘,放到c数组中
// a3 a2 a1 a0
//* b1 b0
// a3b0 a2b0 a1b0 a0b0
//+ a3b1 a2b1 a1b1 a0b1
//
//对应 c[4] c[3] c[2] c[1] c[0]
//结合a和b的下标,规律就是:c[i+j] = a[i] + b[j]
// c[3+1] c[3+0] c[2+0] c[1+0] c[0+0]
for (int i = 0; i < s1.size(); i++)
{
for (int j = 0; j < s2.size(); j++)
{
// 这里应该是累加
// 比如:这里c[2] = a3b1 + a2b2 两项
c[i + j] += a[i] * b[j];
// 进位,也是累加,可能是多次进位
c[i + j + 1] += c[i + j] / 10;
// 留下个位
c[i + j] = c[i + j] % 10;
}
}
while (c[c_len] == 0 && c_len > 1)
{
c_len--;
}
// 倒叙打印
for (int i = c_len; i >= 0; i--)
{
cout << c[i];
}
cout << endl;
system("pause");
return 0;
}
2.4 高精度除法
注意事项:
1、不用字符串取反
。
2、只用考虑够除
和余数
就行。
#include<iostream>
using namespace std;
void string_to_int(string str, int* a)
{
for (int i = 0; i < str.size(); i++)
{
// 将每个字符转换成数值
a[i] = str[i] - '0';
}
}
// 高精度除法(高精度 / 低精度)
int main()
{
string s;
int a[201] = {
0 }; // 高精度
long long b; // 低精度
int c[201] = {
0 }; // 存放结果(高精度)
int temp = 0;
cin >> s;
cin >> b;
string_to_int(s, a);
for (int i = 0; i < s.size(); i++)
{
c[i] = (temp * 10 + a[i]) / b;
// 余数
temp = (temp * 10 + a[i]) % b;
}
int lc = 0;
while (c[lc] == 0 && lc < s.size())
{
lc++;
}
for (int i = 0; i < s.size(); i++)
{
cout << c[i];
}
cout << endl;
system("pause");
return 0;
}
3、排序
学排序的根本不是将一个数组排序成功,那其实1个sort
就可以完成了。而是掌握其中的思维
。
由于涉及多个排序算法,采用分文件编写。
.h
头文件:
#pragma once
#include<iostream>
using namespace std;
#include<vector>
#include<algorithm>
class mySort
{
public:
// 打印数组
void printArr(vector<int>& v);
// 冒泡排序
void BubbleSort(vector<int>& v);
// 选择排序
void SelectSort(vector<int>& v);
// 插入排序
void InsertSort(vector<int>& v);
// 希尔插入排序
void ShellInsertSort(vector<int>& v, int start, int gap);
// 希尔排序
void ShellSort(vector<int>& v);
// 计数排序
void CountSort(vector<int>& v);
// 桶排序
void BuckerSort(vector<int>& v);
// 构建大根堆
void Build_max_headp(vector<int>& v, int start, int end);
// 堆排序
void HeadpSort(vector<int>& v);
// 快速排序
void QuickSort(vector<int>& v, int l, int r);
// 递归分解
void Merge_up2down(vector<int>& v, int l, int r);
// 归并排序
void MergeSort(vector<int>& v, int l, int mid, int r);
};
3.1 冒泡排序
想法:
1、就是先写完第一趟排序,再套个外层循环全部遍历完。
2、第一躺比n-1次,第二趟比n-2次。。。。
注意事项:
写完任何一个算法,必须对该算法进行评价
:时间复杂度
、空间复杂度
、稳定性
。
#include"MySort.h"
// 打印数组
void mySort::printArr(vector<int>& v)
{
// auto 是 C++11 引入的关键字,用于自动推导变量的类型。
for (auto& i : v)
{
cout << i << " ";
}
cout << endl;
}
// 冒泡排序
void mySort::BubbleSort(vector<int>& v)
{
for (int j = v.size() - 1; j >=1; j--)
{
// 先写完第一趟排序,第一躺比n-1次,第二趟比n-2次。。。。
// n-1,n-2 一直递减 所以将这个设置成参数j;最后只剩两个数相比,也就是v[0] v[0+1] 所以i最后<1结束
//for (int i = 0; i < v.size()-1; i++)
// 其实循环就是找初始值和结束条件的值
for (int i = 0; i < j; i++)
{
if (v[i] > v[i + 1])
{
swap(v[i], v[i + 1]);
}
}
}
}
但是,当在某一趟已经排序完成,外层循环仍然会继续遍历,增加了时间复杂度。因此,可以进行优化
,当某一趟排序完成,则结束程序。
// 冒泡排序
// 时间复杂度:O(n^2)
// 空间复杂度(额外):O(1)
// 稳定性:稳定(判断一个算法是否稳定的方法就是:看一个数组中两个相同元素的相对位置是否会发生改变)
void mySort::BubbleSort(vector<int>& v)
{
for (int j = v.size() - 1; j >=1; j--)
{
// 定义1个flag
bool flag = false;
for (int i = 0; i < j; i++)
{
if (v[i] > v[i + 1])
{
swap(v[i], v[i + 1]);
// 发生swap,说明还没排序结束
flag = true;
}
}
// 这一趟结束还是false,说明排序完成,结束程序。
if (flag == false)
{
break;
}
}
}
3.2 选择排序
选择排序是冒泡排序的优化,冒泡排序是将所有的比较完,选择排序是首先选择一个最值。
第一趟:假设20是i,假设是最值,那么40就是i+1,是j,j++就是40及以后的数都要和i比
当10 < 20时,则需要更新最值。
// 选择排序
// 时间复杂度:O(n^2)
// 空间复杂度(额外):O(1)
// 稳定性:不稳定
void mySort::SelectSort(vector<int>& v)
{
for (int i = 0; i < v.size(); i++)
{
int min_index = i;
// j从i的下一个数出发
for (int j = i + 1; j < v.size(); j++)
{
if (v[j] < v[min_index])
{
min_index = j;
}
}
// 整个遍历完,找到1个min_index,和i进行交换
swap(v[i], v[min_index]);
}
}
3.3 插入排序
假设20是i,是有序区,那么30往后就是无序区,是j。
当30 > 20时,不用插入,直接j++就行。一旦,10 < 20时,10存放到临时temp,20、30、40位移++,10插入到i位置。
j在外层,因为每个无序区的元素,都得遍历一次有序区,i在内层,
//插入排序
// 时间复杂度:O(n^2)
// 空间复杂度(额外):O(1)
// 稳定性:稳定
void mySort::InsertSort(vector<int>& v)
{
// 无序区下表首先从1开始
for (int j = 1; j < v.size(); j++) //构造无序区
{
// 有序区下表首先从0开始,一直到无序区第一个元素的前一个
for (int i = 0; i < j; i++) //构造有序区
{
if (v[j] < v[i])
{
int temp = v[j];
// 这一层循环不是每次都执行,是有条件才执行的,时间是有穷的,是O(1)
for (int k = j-1; k >= i; k--)
{
v[k + 1] = v[k];
}
v[i] = temp;
break; // 这一趟已经结束,不用再遍历
}
}
}
}
3.4 希尔排序
希尔排序(Shell Sort)是插入排序的一种,它是针对直接插入排序算法的改进。该方法又称缩小增量排序
,因DL. Shell于1959年提出而得名。
希尔排序实质上是一种分组插入
方法。它的基本思想是:对于n个待排序的数列,取一个小于n的整数gap
(gap被称为步长)将待排序元素分成gap
个组子序列,所有距离为gap的倍数的记录放在同一个组中;然后,对各组内的元素进行直接插入排序。这一趟排序完成之后,每一个组的元素都是有序的。然后减小gap的值,并重复执行上述的分组和排序。重复这样的操作,当gap=1时,整个数列就是有序的。
分组排序只是针对局部的某些元素进行排序,分组插入排序后就是会使得这些元素变得相对有序
。最后,将整个1组元素变得相对有序之后,再进行一个插入排序,此时的效率是直接对1个无序序列进行插入排序的0.几倍之上
。
// 希尔插入排序
void mySort::ShellInsertSort(vector<int>& v, int start, int gap)
{
// 无序区下表首先从1开始
for (int j = start + gap; j < v.size(); j+=gap) //构造无序区
{
// 有序区下表首先从0开始,一直到无序区第一个元素的前一个
for (int i = start; i < j; i+=gap) //构造有序区
{
if (v[j] < v[i])
{
int temp = v[j];
// 这一层循环不是每次都执行,是有条件才执行的,时间是有穷的,是O(1)
for (int k = j - gap; k >= i; k-=gap)
{
v[k + gap] = v[k];
}
v[i] = temp;
break; // 这一趟已经结束,不用再遍历
}
}
}
}
// 希尔排序
// 时间复杂度:O(n^2)
// 空间复杂度(额外):O(1)
// 稳定性:不稳定(局部的组和组之间的位置发生了相对变化)
void mySort::ShellSort(vector<int>& v)
{
// 首先是4组,然后是2组,每次 ÷ 2,最后1次是1组
for (int gap = v.size() / 2; gap >= 1; gap/=2)
{
for (int i = 0; i < gap; i++)
{
// 首先下表是从0开始的组,然受是1开始的组,下标也得套个循环
ShellInsertSort(v, i, gap);
}
}
}
3.5 计数排序
// 计数排序
void mySort::CountSort(vector<int>& v)
{
// 1.先找最大值
int max_value = *max_element(v.begin(), v.end());
// 2.根据最大值开辟数组
int* countArr = new int[max_value + 1];
// 用于将指定的内存块设置为某个值,一共(max_value + 1)个int型指针
memset(countArr, 0, (max_value + 1) * sizeof(countArr));
// 3.将数组元素放入到新数组中并计数
// c++ 11风格
/*for (auto& i : v) { countArr[i]++; }*/
for (int i = 0; i < v.size(); i++)
{
countArr[v[i]]++;
}
// 4.按照下标依次取出新数组中的元素
v.clear(); // 原数组进行清空再更新
for (int i = 0; i < max_value + 1; i++)
{
while (countArr[i])
{
v.push_back(i);
countArr[i]--;
}
}
}
3.6 桶排序
桶排序是计数排序的扩展版本
,计数排序可以看成每个桶只存储相同
元素,而桶排序每个桶存储一定范围
的元素,通过映射函数,将待排序数组中的元素映射到各个对应的桶中,对每个桶中的元素进行排序,最后将非空桶中的元素逐个放入原序列中。
桶排序需要尽量保证元素分散均匀
,否则当所有数据集中在同一个桶中时,桶排序失效
。
// 桶排序
// 时间复杂度:n+m(桶的数量)*n/m*logn/m = n+n*logn/m = n+n*(logn-logm) = O(N+C)(如果是冒泡,C就是别的复杂度了)
// 空间复杂度(额外):O(M+N) N:桶空间,M:排序空间
// 稳定性:稳定性取决于所选择的排序
void mySort::BuckerSort(vector<int>& v)
{
// 1.先找最大值、最小值
int max_value = *max_element(v.begin(), v.end()); // 时间复杂度:n
int min_value = *min_element(v.begin(), v.end()); // 时间复杂度:n
// 2.确定桶的数量
// 通过最大值和最小值来确定,最多一共(max_value - min_value + 1)个数
// 极端情况下,可能所有数放在一个桶里,也就是1个桶要能容纳n个数
int bucketNum = (max_value - min_value + 1) / v.size() + 1; // 要加1,下标从0开始
// 每个桶里还得存放1维数组
vector<vector<int>> Buckets(bucketNum);
// 遍历原始数据
for (int i = 0; i < v.size(); i++) // 时间复杂度:n
{
// 确定当前元素在第几个桶的索引,也就是当前元素距离最小元素的距离 ÷ 每个桶存放的元素个数
int index = (v[i] - min_value + 1) / v.size();
Buckets[index].push_back(v[i]);
}
for (int i = 0; i < Buckets.size(); i++) // 时间复杂度:m
{
// 桶排序原理就是:分好桶后,每个桶的排序可以采用多种排序算法
sort(Buckets[i].begin(), Buckets[i].end()); // 快排 // 时间复杂度:n/m*logn/m
}
v.clear();
for (int i = 0; i < Buckets.size(); i++)
{
for (int j = 0; j < Buckets[i].size(); j++)
{
// 取出第i个桶里第j个元素
v.push_back(Buckets[i][j]);
}
}
}
3.7 堆排序
堆排序(Heap Sort)是指利用堆
这种数据结构所设计的一种排序算法。
因此,学习堆排序之前,有必要了解堆!若读者不熟悉堆,建议先了解堆(健议可以通过二叉堆,左倾堆,斜堆,二项堆或斐波那契堆等文章进行了解)。
我们知道,堆分为"最大堆"
和"最小堆"
。最大堆通常被用来进行"升序"
排序而最小堆通常被用来进行"降序"排序。
最大堆进行升序排序的基本思想
:
①初始化堆:将数列a[1…n]构造成最大堆
。
②交换数据:将a[1]和a[0]交换,使a[n]是al[1…n]中的最大值;然后将al[1…n-1]重新调整为最大堆。接着,将a[1]和a[n-1]交换,使a[n-1]是a[1…n-1]中的最大值;然后将a[1…n-2]重新调整为最大值。依次类推,直到整个数列都是有序
的。
下面,通过图文来解析堆排序的实现过程。注意实现中用到了"数组
实现的二叉堆的性质"。在第一个元素的索引为0的情形中:
性质一
:索引为i的左孩子的索引是(2i+1);
性质二
:索引为i的左孩子的索引是(2i+2);
性质三
:索引为i的父结点的索引是floor((i-1)/2);
堆,其实可以看成1个树结构
,例如,对于最大堆{110,100,90,40,80,20,60,10,30,50,70}而言:索引为0的左孩子的所有是1;索引为0的右孩子是2;索引为8的父节点3。
整个堆建立大根堆,肯定是从局部的子堆
都建立为大根堆
开始,最后整个堆肯定为大根堆。
// 构建大根堆
void mySort::Build_max_headp(vector<int>& v, int start, int end)
{
// 从当前节点构建局部大根堆
int cur = start;
// 左孩子
int l = 2 * cur + 1;
// 局部并不是比1次,万一左孩子或者右孩子还作为下一个局部子堆的根节点,因此要循环
// 循环的下一次l就要更新为cur,左孩子更新2 * cur + 1
// l可以=end,也就是没有右孩子
for (; l <= end; cur=l, l = 2 * cur + 1)
{
// 如果左孩子小于右孩子,就拿右孩子和父节点比;就拿左孩子和父节点比
// 但是这里的代码是比较左右孩子,如果没有右孩子,就不用比了,所以是小于
if (l < end && v[l] < v[l + 1]) l++;
if (v[l] > v[cur]) swap(v[l], v[cur]);
else break;
}
}
// 堆排序
// 时间复杂度:O(n*logn)
// 空间复杂度(额外):O(1)
// 稳定性:不稳定
void mySort::HeadpSort(vector<int>& v)
{
// 最开始的最后一个结点是:n / 2 - 1,然后--,直到最后0的根节点
for (int i = v.size() / 2 - 1; i >= 0; i--)
{
Build_max_headp(v, i, v.size() - 1);
}
for (int i = v.size() - 1; i > 0; i--) // 时间复杂度:n
{
swap(v[0], v[i]);
// 建立完1次大根堆,拿到最大数后,就不要再从下面就往上建立了,因为有些局部的已经建立好。
// 我们这根节点0开始,每次减去最大数
Build_max_headp(v, 0, i - 1); // 时间复杂度:logn
}
}
总结
:
1.凡是涉及到:二分
、倍增
,分解也好,合并也好,一定少不了logn,
假设1个n个结点的满二叉树,树高为h,总结点数为:
2^0+2^1+2^2+...2^h-1 = n
–>2^h - 1 = n
–>h = log2(n+1)
--> h = log2n
(1可以忽略不计) ;建堆的过程就是从堆顶遍历到堆底的过程,所以时间复杂度就是logn。
3.8 递归
递归:在自定义函数种调用自己
。
递归的正确使用,必须加1个结束条件
。
递归特点
:
1.先进后出
。
2.代码简洁,理解 / 实现困难
递归求n项和的示例:
3.8.1 求n项和
int sum(int n)
{
if (n == 1) return 1; // 递归必须有出口--终止条件
return n + sum(n - 1); // 递归调用
}
// 第1层 sum(5) return 5 + sum(4)
// 第2层 sum(4) return 4 + sum(3)
// 第3层 sum(3) return 3 + sum(2)
// 第4层 sum(2) return 2 + sum(1)
// 递归必须有出口--终止条件
// 第5层 sum(1) return 1
// 函数结束,没有下一层了
// 第5层 sum(1) return 1 + sum(0)
// 第6层 sum(0) return 0 + sum(-1)
3.8.2 递归打印1–n
// 递归打印1--n
void recursion_print(int n)
{
if (n == 1) // 递归必须有出口--终止条件
{
cout << n << " ";
return;
}
recursion_print(n - 1);
cout << n << " ";
}
// 递归是先进后出的特点,逐层结束时,就会执行cout << n << " ";
// 从下往上看,第5层一旦return结束,立刻回到第4层的recursion_print(1)函数调用,随后就会走cout<<2
// 第4层一旦结束,立刻回到第3层的recursion_print(2)函数调用,随后就会走cout<<3
// 第3层一旦结束,立刻回到第2层的recursion_print(3)函数调用,随后就会走cout<<2
// 第1层 recursion_print(5) return recursion_print(4) cout<<5
// 第2层 recursion_print(4) return recursion_print(3) cout<<4
// 第3层 recursion_print(3) return recursion_print(2) cout<<3
// 第4层 recursion_print(2) return recursion_print(1) cout<<2
// 第5层 recursion_print(1) return; cout<<1
3.8.3 递归正序打印数组
// 递归正序打印数组
int a[] = {
20,40,30,10,60,50 };
void printArr(int index)
{
if (index == 0)
{
cout << a[index] << " ";
return;
}
printArr(index - 1);
cout<< a[index] << " ";
}
// 第5层 printArr(5) printArr(4) cout<<a[5]
// 第4层 printArr(4) printArr(3) cout<<a[4]
// 第3层 printArr(3) printArr(2) cout<<a[3]
// 第2层 printArr(2) printArr(1) cout<<a[2]
// 第1层 printArr(1) printArr(0) cout<<a[1]
// 第0层 printArr(0) cout<<a[0]
3.8.4 递归逆序打印数组
// 递归逆序打印数组
int a[] = {
20,40,30,10,60,50 };
void printArr2(int index)
{
if (index == 6) return;
printArr2(index + 1);
cout << a[index] << " ";
}
// 第6层 printArr(0) printArr(1) cout<<a[0]
// 第5层 printArr(1) printArr(2) cout<<a[1]
// 第4层 printArr(2) printArr(3) cout<<a[2]
// 第3层 printArr(3) printArr(4) cout<<a[3]
// 第2层 printArr(4) printArr(5) cout<<a[4]
// 第1层 printArr(5) printArr(6) cout<<a[5]
// 第0层 printArr(6) return;
3.8.5 二分搜索的递归版本
// 二分搜索(数据默认有序),以升序为例
// 时间复杂度:O(logn)数学归纳法,求数高
// 空间复杂度(额外):O(1)
// 稳定性:
int binarySearch(vector<int>& v, int l, int r, int target)
{
// 有序序列:{1, 2, 4, 6, 8, 10, 12},二分查找11
// 最左边数值1的索引为`l`,最右边数值12的索引为`r`,中间数值6的索引为`m`,
// 11 > a[m]=6,所以更新:`l`更新到m+1的位置(数值8),`r`不变,中间数值10的索引为`m`,
// 11 > a[m]=10,所以更新:`l`更新到m+1的位置(数值12),最后l、m+1、r(不变)都指向同一个数值12(其实当l和r重合时,就找不到了。)
// 11 < a[m]=12,所以更新:l不变(指向数值12),`r`更新到m-1的位置(数值10),中间数值10的索引为`m`,
// 终止条件,l = r重合时,还能再查一次
if (l > r) return -1;
int mid = (l + r) / 2;
if (v[mid] == target)
{
return mid;
}
else if (v[mid] < target)
{
// 往右边查,l更新到m+1
return binarySearch(v, mid + 1, r, target);
}
else if (v[mid] > target)
{
// 往左边查,r更新到m-1
return binarySearch(v, l, mid - 1, target);
}
return -1; //都没查到
}
总结:
1.如果直接遍历数组查找,时间复杂度是:O(n);
2.但是,二分查找,每次都会舍弃一半,查找速度更快。
3.8.4 总结
1.先写终止
条件。
2.肯定在函数体中调用函数本身,并且要逐个++ / --,直到走终止条件。
3.可以画逐层草稿进行推算。
4.树
本身就是一种递归
。递归,其实也可以理解成一种单一
的树结点,只是没有二分。
5.用到递归,很有可能涉及到左区l
、右区r
、中间mid
。
3.9 快速排序
先进后出
,除了可以用递归
,还可以用栈
,但是栈相对复杂。
// 快速排序
void mySort::QuickSort(vector<int>& v, int l, int r)
{
// 左右重合就不再查了
if (l >= r) return;
// 默认排序序列中最左侧元素是基准
int temp = v[l];
int i = l, j = r;
while (i < j)
{
// 先看j,当v[j]一直大于temp的时候,j一直--
while (i < j && v[j] > temp)
{
j--;
}
// 等到v[j] 小于 temp时,上述while结束,v[j]要往前放,这里将v[i] = v[j],然后i++
if (i < j)
{
v[i++] = v[j];
}
// 再看i,当v[i]一直小于temp的时候,i一直++
while (i < j && v[i] < temp)
{
i++;
}
// 等到v[i] 大于 temp时,上述while结束,v[i]要往后放,这里将v[j] = v[i],然后j--
if (i < j)
{
v[j--] = v[i];
}
}
// 当i和j相遇时,i/j的位置就是基准位,也就是临时存放的temp
v[i] = temp;
// 以基准分左右,开始分别进行快排
QuickSort(v, l, i - 1); // 左边
QuickSort(v, i + 1, r); // 右边
}
总结
:
1.快速排序,和二分查找类似,也是有左l
、中间值mid
、右r
,并结合递归
。
3.10 归并排序
归并分解和二分搜索、快速排序类似,都是找l
、r
、mid
。但是二分搜索是左右递归只走一个
;快排是找基准,分左右时,两边都
快排。归并两边
都要分解,直到分解到l=r重合
时结束。递归,是先从上往下
分解,再从下往上
逐层返回,在返回的时候合并并排序
,保证每个子区间是有序
的。
// 归并排序
void mySort::MergeSort(vector<int>& v, int l, int mid, int r)
{
int* tmp = new int[r - l + 1];
// i从最左边开始,到mid结束
int i = l;
// j从mid+1开始,到r结束
int j = mid + 1;
int index = 0;
while (i <= mid && j <= r)
{
// 取i和j中最小的
if (v[i] < v[j])
{
tmp[index++] = v[i++];
}
else
{
tmp[index++] = v[j++];
}
}
while (i <= mid) tmp[index++] = v[i++];
while (j <= r) tmp[index++] = v[j++];
for (int i = 0; i < index; i++)
{
v[l + i] = tmp[i];
}
delete[]tmp;
}
// 递归分解+合并+排序
// 递归的时间复杂度就是求树高,不是简单的算循环次数
// 时间复杂度:O(n*logn) 树高logn,每一层都要计算n次
// 空间复杂度(额外):O(n*logn)
// 稳定性:稳定
void mySort::Merge_up2down(vector<int>& v, int l, int r)
{
if (l >= r) return;
int mid = (l + r) / 2;
// 快排的mid不参与下次递归,而这里归并要参与
Merge_up2down(v, l, mid);
Merge_up2down(v, mid + 1, r);
// 左递归、右递归都结束了,要针对这两个子数进行排序了。
MergeSort(v, l, mid, r);
}
注意事项
:
1.递归的时间复杂度就是求树高,不是简单的算循环次数
4、贪心算法
4.1 引入
降序,让钱的面值最大的优先级最高,优先
消耗
int total, n, ans;
int money[101];
bool cmp(int a, int b)
{
return a > b;
}
int main()
{
// total钱的总数,n多少种纸币面值
cin >> total >> n;
for (int i = 0; i < n; i++)
{
cin >> money[i];
}
sort(money, money + n, cmp); // 降序,让钱的面值最大的优先级最高,优先消耗
for (int i = 0; i < n; i++)
{
if (money[i] <= total)
{
int num = total / money[i];
total -= num * money[i];
ans += num;
}
}
cout << ans << endl;
system("pause");
return 0;
}
4.2 奥利凡德
要想全部放下,必须最小的魔杖对应最小的盒子;最大的魔杖对应最大的盒子。
思路依旧是排序,选优先级
。
int n;
int a[101];
int b[101];
int main()
{
cin >> n;
for (int i = 0; i < n; i++)
{
cin >> a[i];
}
for (int i = 0; i < n; i++)
{
cin >> b[i];
}
sort(a, a + n);
sort(b, b + n);
for (int i = 0; i < n; i++)
{
if (a[i] > b[i])
{
cout << "No" << endl;
return 0;
}
}
cout << "Yes" << endl;
system("pause");
return 0;
}
4.3 采购商品
贪心算法都是冲个最
字去的,只有最才能产生优先思维
。
struct goods
{
int price;
int num;
};
int m, n;
bool cmp(goods a, goods b)
{
return a.price < b.price;
}
int main()
{
// m总价钱,n商品数量
cin >> m >> n;
struct goods a[101];
for (int i = 0; i < n; i++)
{
cin >> a[i].price >> a[i].num;
}
sort(a, a + n , cmp);
int ans = 0;
// 商品从最便宜的开始遍历
for (int i = 0; i < n; i++)
{
// 对于当前商品尽可能多买,如果能买,全买
if (m >= a[i].num * a[i].price)
{
ans += a[i].num;
m -= a[i].num * a[i].price;
}
else
{
// 不能全买,能买多少是多少
int num = m / a[i].price;
ans += num;
m -= num * a[i].price;
}
}
cout << ans << endl;
system("pause");
return 0;
}
4、递推算法
递推也是经常被使用的一种简单算法。递推是一种用若干步可重复的简单运算来描述复杂问题的方法。
递推的特点在于,每一项都和他前面的若干项有一定关联
,这种关联一般可以通过递推关系式
来表示,可以通过其前面若干项得出某项的数据。
对于递推问题的求解一般从初始
的一个
或若干个数据项
出发,通过递推关系式逐步推进,从而得出想要的结果,这种求解问题的方法叫递推法
。其中,初始的若干数据项称为边界
。
4.1 一级递推
4.2 递推求兔子问题
递归与递推的区别
:
1、递归
是从前往后,找完以后再推回来。
2、递推
是一波直接推完,把每个阶段的值记录
下来,后面的值只跟前面的值有关系。用空间
换时间。
int f[101];
// 递推算法
// 1.边界 f[1]=f[2]=1
// 2.递推关系式 f[i]=f[i-1] + f[i-2]
// 时间复杂度:O(n)
int main()
{
int n;
cin >> n;
f[1] = f[2] = 1;
for (int i = 3; i <= n; i++)
{
f[i] = f[i - 1] + f[i - 2];
}
cout << f[n];
cout << endl;
system("pause");
return 0;
}
x.1 二分搜索
要从1-1000的数里面查找某个值,最快应该是折半查找
,如果这个数比500大,那1-500的范围就不用再找了。
二分搜索的前提是有序序列
:{1, 2, 4, 6, 8, 10, 12},设最左边数值1的索引为l
,最右边数值12的索引为r
,中间数值6的索引为m
,比如要查找10这个数,10 > a[m],所以直接从m + 1
往后查找就行,继续取m + 1
到r
区间的中间索引mm
,继续比较a[mm] 和 要找的10,重复这个过程。
二分搜索
有两种情况:1、用循环写;2、用递归写。
文章评论