作为一个大学几乎零基础的算法小白来说,算法是笔试的一道难关,这里分享一些我准备过程的一些小经验。
排序算法
简单对事件和空间复杂度做一个简单的总结,有时在笔试题选择题会考到某一排序算法的时间复杂度,或者在编程题里指定复杂度。
简单介绍一下时间复杂度的计算方法
常数时间复杂度:O(1):固定时间操作,例如直接访问数组元素。
int getElement(int arr[], int index) {
return arr[index];
}
线性时间复杂度:O(n):与输入规模成正比的操作,例如遍历数组,计算数组和。
int sumElements(int arr[], int n) {
int total = 0;
for (int i = 0; i < n; ++i) {
total += arr[i];
}
return total;
}
平方时间复杂度:O(n²):嵌套循环操作,例如冒泡排序。
void bubbleSort(int arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
for (int j = 0; j < n - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
swap(arr[j], arr[j + 1]);
}
}
}
}
线性对数时间复杂度:O(n log n):分治法操作,例如快速排序。
int partition(int arr[], int low, int high) {
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; ++j) {
if (arr[j] < pivot) {
++i;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
return i + 1;
}
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high);
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
二分查找:O(log n):每次将问题规模减半的操作,例如二分查找。
int binarySearch(int arr[], int n, int target) {
int left = 0, right = n - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1; // target not found
}
排序算法 | 最优时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
快速排序 | O(n log n) | O(n log n) | O(n²) | O(log n)~O(n) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
希尔排序 | O(n log² n) | O(n^(3/2)) | O(n²) | O(1) | 不稳定 |
计数排序 | O(n + k) | O(n + k) | O(n + k) | O(k) | 稳定 |
桶排序 | O(n + k) | O(n + k) | O(n²) | O(n + k) | 稳定 |
基数排序 | O(n * k) | O(n * k) | O(n * k) | O(n + k) | 稳定 |
冒泡排序
通过重复交换相邻的未排序元素,将最大或最小的元素逐步“冒泡”到数组的边界。
#include <iostream>
using namespace std;
// 冒泡排序函数
void bubbleSort(int arr[], int n) {
bool swapped; // 用于检测在某一轮排序中是否发生了交换
for (int i = 0; i < n - 1; ++i) {
swapped = false; // 每轮排序开始前,设置 swapped 为 false
for (int j = 0; j < n - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
// 交换 arr[j] 和 arr[j + 1]
swap(arr[j], arr[j + 1]);
swapped = true; // 发生了交换
}
}
// 如果没有发生交换,说明数组已排序好,可以提前退出
if (!swapped) break;
}
}
// 打印数组函数
void printArray(int arr[], int n) {
for (int i = 0; i < n; ++i) {
cout << arr[i] << " ";
}
cout << endl;
}
int main() {
int arr[] = {64, 34, 25, 12, 22};
int n = sizeof(arr) / sizeof(arr[0]);
cout << "Original array: ";
printArray(arr, n);
bubbleSort(arr, n);
cout << "Sorted array: ";
printArray(arr, n);
return 0;
}
- 最坏情况:O(n²),发生在数组完全逆序时。
- 平均情况:O(n²)。
- 最佳情况:O(n),当数组已经排序好时(通过
swapped
优化)。
选择排序
基本思想是每次从未排序部分中选择最小(或最大)的元素,并将其放到已排序部分的末尾。
#include <iostream>
using namespace std;
// 选择排序函数
void selectionSort(int arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
// 假设当前索引 i 的元素是最小值
int minIndex = i;
// 寻找从 i + 1 到 n - 1 的最小值
for (int j = i + 1; j < n; ++j) {
if (arr[j] < arr[minIndex]) {
minIndex = j; // 记录最小值的索引
}
}
// 交换当前位置 i 的元素和找到的最小值元素
if (minIndex != i) {
swap(arr[i], arr[minIndex]);
}
}
}
插入排序
将每个元素插入到已排序的部分中,类似于整理扑克牌时的操作
#include <iostream>
using namespace std;
// 插入排序函数
void insertionSort(int arr[], int n) {
for (int i = 1; i < n; ++i) {
int key = arr[i]; // 当前要插入的元素
int j = i - 1;
// 将大于 key 的元素移到下一个位置
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
--j;
}
// 插入 key 到正确的位置
arr[j + 1] = key;
}
}
快速排序
选择一个“基准”(pivot)元素,将数组分成两部分,使得一部分的所有元素都小于基准元素,另一部分的所有元素都大于基准元素,然后递归地对这两部分进行排序。
int partition(int arr[], int low, int high) {
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = low - 1; // 记录比基准值小的元素的最后位置
for (int j = low; j < high; ++j) {
if (arr[j] < pivot) {
++i;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]);
return i + 1; // 返回基准元素的位置
}
// 快速排序函数
void quickSort(int arr[], int low, int high) {
if (low < high) {
int pi = partition(arr, low, high); // 分割数组
quickSort(arr, low, pi - 1); // 排序基准值左边的子数组
quickSort(arr, pi + 1, high); // 排序基准值右边的子数组
}
}
归并排序
将数组分割成两个子数组,分别对这两个子数组进行排序,然后将这两个已排序的子数组合并成一个完整的排序数组。
// 合并两个已排序的子数组
void merge(int arr[], int l, int m, int r) {
int n1 = m - l + 1; // 左子数组的大小
int n2 = r - m; // 右子数组的大小
// 创建临时数组
int* L = new int[n1];
int* R = new int[n2];
// 复制数据到临时数组 L[] 和 R[]
for (int i = 0; i < n1; ++i)
L[i] = arr[l + i];
for (int j = 0; j < n2; ++j)
R[j] = arr[m + 1 + j];
// 合并临时数组到原数组
int i = 0; // 初始左子数组索引
int j = 0; // 初始右子数组索引
int k = l; // 初始合并子数组索引
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
arr[k++] = L[i++];
} else {
arr[k++] = R[j++];
}
}
// 复制剩余的元素
while (i < n1) {
arr[k++] = L[i++];
}
while (j < n2) {
arr[k++] = R[j++];
}
// 释放临时数组的内存
delete[] L;
delete[] R;
}
// 归并排序函数
void mergeSort(int arr[], int l, int r) {
if (l < r) {
int m = l + (r - l) / 2; // 计算中间点
mergeSort(arr, l, m); // 排序左半部分
mergeSort(arr, m + 1, r); // 排序右半部分
merge(arr, l, m, r); // 合并两个已排序的部分
}
}
堆排序
通过构建一个最大堆(或最小堆),然后逐步将堆顶元素(最大或最小)移到数组的末尾,从而实现排序。
// 调整堆,使其满足堆的性质
void heapify(int arr[], int n, int i) {
int largest = i; // 假设当前节点是最大的
int left = 2 * i + 1; // 左子节点索引
int right = 2 * i + 2; // 右子节点索引
// 检查左子节点是否比当前节点大
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 检查右子节点是否比当前节点大
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是当前节点,则交换并继续调整堆
if (largest != i) {
swap(arr[i], arr[largest]);
heapify(arr, n, largest);
}
}
// 堆排序函数
void heapSort(int arr[], int n) {
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; --i) {
heapify(arr, n, i);
}
// 从堆中取出元素,并重新调整堆
for (int i = n - 1; i > 0; --i) {
swap(arr[0], arr[i]); // 将当前最大值放到数组的末尾
heapify(arr, i, 0); // 重新调整堆
}
}
贪心算法
贪心算法是一种局部最优选择策略,即每一步都选择在当前状态下最优的决策,期望通过这些局部最优的选择达到全局最优。贪心算法的步骤通常如下:
1.选择贪心策略:确定每一步要如何做出选择。
2.实现局部最优决策:实现每一步的局部最优决策。
3.根据局部决策找到全局解:通过不断应用局部最优策略,最终达到全局最优解。
买卖股票的最佳时机
问题描述:给定一个数组,其中第 i
个元素代表某只股票第 i
天的价格。你最多可以完成一笔交易(买入和卖出各一次),求最大利润。
分析:每一天,我们都假设当前价格是我们今天卖出的价格,查看以之前最低价格买入的情况下,能获得的最大利润。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int maxProfit(vector<int>& prices) {
if (prices.empty()) return 0;
int minPrice = INT_MAX; // 记录最小买入价格
int maxProfit = 0; // 记录最大利润
for (int price : prices) {
minPrice = min(minPrice, price); // 当前最小买入价格
maxProfit = max(maxProfit, price - minPrice); // 当前最大利润
}
return maxProfit;
}
int main() {
vector<int> prices = {7, 1, 5, 3, 6, 4}; // 股票价格
cout << "Maximum profit: " << maxProfit(prices) << endl;
return 0;
}
跳跃游戏
问题描述:给定一个非负整数数组 nums
,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个位置。
分析:从左到右遍历数组,更新当前能到达的最远距离。如果当前索引超过了最远可达位置,则无法到达终点;否则继续更新最远可达距离。
#include <iostream>
#include <vector>
using namespace std;
bool canJump(vector<int>& nums) {
int reachable = 0; // 最远可以到达的距离
for (int i = 0; i < nums.size(); ++i) {
if (i > reachable) return false; // 如果当前位置不可达,返回 false
reachable = max(reachable, i + nums[i]); // 更新最远可达位置
}
return true; // 如果能遍历整个数组,返回 true
}
int main() {
vector<int> nums = {2, 3, 1, 1, 4}; // 跳跃数组
cout << "Can reach the end: " << (canJump(nums) ? "Yes" : "No") << endl;
return 0;
}
划分字母区间
问题描述:给定一个字符串 S
,将字符串划分为尽可能多的片段,同一个字母只会出现在一个片段中,返回每个片段的长度。
分析:对于每个字母,尽量让其所在的区间涵盖其最后一次出现的位置。每当当前字符到达其最后一次出现的位置时,就可以确定当前片段结束,从而划分出一个片段。
#include <iostream>
#include <vector>
#include <string>
using namespace std;
vector<int> partitionLabels(string S) {
vector<int> lastIndex(26, 0); // 记录每个字母最后出现的位置
vector<int> result;
// 记录每个字母最后出现的位置
for (int i = 0; i < S.size(); ++i) {
lastIndex[S[i] - 'a'] = i;
}
int start = 0, end = 0;
for (int i = 0; i < S.size(); ++i) {
end = max(end, lastIndex[S[i] - 'a']); // 更新当前片段的最远边界
if (i == end) {
result.push_back(end - start + 1); // 添加当前片段的长度
start = i + 1; // 更新下一片段的起始点
}
}
return result;
}
int main() {
string S = "ababcbacadefegdehijhklij"; // 输入字符串
vector<int> partitions = partitionLabels(S); // 计算划分结果
cout << "Partition lengths: ";
for (int len : partitions) {
cout << len << " ";
}
cout << endl;
return 0;
}
动态规划
动态规划(Dynamic Programming, DP)是一种用于解决具有重叠子问题和最优子结构性质的问题的算法方法。它的核心思想是将原问题拆解为更小的子问题,并通过存储子问题的解来避免重复计算。
动态规划的一般步骤
定义状态:确定问题的解可以通过哪些子问题来得到。
定义状态(即子问题)通常用一个数组或一个表来表示,比如 dp[i] 表示问题在规模为 i 时的解。
确定状态转移方程:通过子问题的解构造出原问题的解,即定义状态之间的递推关系。
寻找问题的递归关系,并将其转换为状态转移方程。
初始化:根据问题的具体情况,设置初始条件。例如,处理规模为 0 或 1 的基本情况。
计算状态:从小到大,逐步计算出每一个状态的值,直到得到问题的解。
返回结果:最后返回所求的目标状态的值,通常为 dp[n] 或类似的形式。
爬楼梯
问题描述:你正在爬楼梯,楼梯有 n
级。每次你可以爬 1 级或 2 级。问你有多少种不同的方法可以爬到顶层?
状态定义:dp[i] 表示到达第 i 级台阶的方法数。
状态转移方程:dp[i] = dp[i - 1] + dp[i - 2],因为到达第 i 级台阶可以从第 i - 1 级台阶跨一步上来,或者从第 i - 2 级台阶跨两步上来。
初始化:dp[1] = 1, dp[2] = 2。
#include <iostream>
#include <vector>
using namespace std;
int climbStairs(int n) {
if (n <= 2) return n;
vector<int> dp(n + 1);
dp[1] = 1; // 到第1级台阶有1种方法
dp[2] = 2; // 到第2级台阶有2种方法
for (int i = 3; i <= n; ++i) {
dp[i] = dp[i - 1] + dp[i - 2]; // 状态转移方程
}
return dp[n];
}
int main() {
int n = 5; // 爬5级楼梯
cout << "Number of ways to climb " << n << " stairs: " << climbStairs(n) << endl;
return 0;
}
完全平方数问题
问题描述:给定正整数 n
,找到若干个完全平方数(例如 1, 4, 9, 16...
)的和,使得这些数的和等于 n
。你需要让组成和的完全平方数的个数最少。
#include <iostream>
#include <vector>
#include <cmath>
using namespace std;
int numSquares(int n) {
vector<int> dp(n + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i <= n; ++i) {
for (int j = 1; j * j <= i; ++j) {
dp[i] = min(dp[i], dp[i - j * j] + 1); // 状态转移方程
}
}
return dp[n];
}
int main() {
int n = 12; // 示例输入
cout << "Least number of perfect squares summing to " << n << ": " << numSquares(n) << endl;
return 0;
}
状态定义:dp[i] 表示和为 i 的最少完全平方数的个数。
状态转移方程:dp[i] = min(dp[i], dp[i - j * j] + 1),其中 j * j 是小于等于 i 的一个完全平方数。
初始化:dp[0] = 0,其他初始化为无穷大(INT_MAX)。
零钱兑换问题
问题描述:给定不同面额的硬币 coins
和一个总金额 amount
,请计算凑成总金额所需的最少硬币个数。如果无法凑成总金额,返回 -1
。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, amount + 1); // 初始化为一个不可能的大数
dp[0] = 0;
for (int i = 1; i <= amount; ++i) {
for (int coin : coins) {
if (i - coin >= 0) {
dp[i] = min(dp[i], dp[i - coin] + 1); // 状态转移方程
}
}
}
return dp[amount] > amount ? -1 : dp[amount];
}
int main() {
vector<int> coins = {1, 2, 5}; // 硬币面额
int amount = 11; // 总金额
cout << "Minimum coins required: " << coinChange(coins, amount) << endl;
return 0;
}
状态定义:dp[i] 表示凑成总金额 i 所需的最少硬币数。
状态转移方程:dp[i] = min(dp[i], dp[i - coin] + 1),其中 coin 是硬币面额。
初始化:dp[0] = 0,其余值初始化为一个不可能的大数(如 amount + 1)。
回溯算法
1.定义问题的解空间:确定问题的所有可能解的集合
2.backtrace:
参数:开始位置,现在位置,是否被使用,结果,操作的对象(情况不同,用到的参数不同)
设定终止条件:
确定何时停止递归,通常是当达到某种有效解时(即满足目标条件)或者当无法继续(如超出约束条件)时。
处理当前状态:
在递归函数中处理当前状态,包括:
检查当前状态是否满足问题的终止条件。
如果满足终止条件,记录解或输出结果。
递归探索:
在当前状态下,探索所有可能的选择(即扩展当前状态),并递归调用处理子问题。
每次递归时,都在当前状态的基础上进行“尝试”,然后“撤回”(即回溯)以恢复到之前的状态。
撤销选择:
在返回到上一个状态时,撤销当前的选择,恢复到之前的状态,以便继续探索其他可能的解。
全排列
当处理一个排列时,我们依次尝试将每个元素放在当前的位置,然后递归处理剩余的元素。在递归过程中,我们还要注意恢复状态(即回溯),以便继续尝试其他排列。
#include <iostream>
#include <vector>
#include <algorithm> // 为了使用 swap 函数
using namespace std;
// 回溯函数生成所有排列
void backtrack(vector<int>& nums, int start, vector<vector<int>>& results) {
if (start == nums.size()) {
results.push_back(nums); // 找到一个完整的排列,添加到结果集中
return;
}
for (int i = start; i < nums.size(); ++i) {
swap(nums[start], nums[i]); // 将当前元素与 start 位置交换
backtrack(nums, start + 1, results); // 递归处理剩余的部分
swap(nums[start], nums[i]); // 回溯,恢复到之前的状态
}
}
// 主函数,生成全排列
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> results;
backtrack(nums, 0, results); // 从索引 0 开始递归
return results;
}
// 打印二维数组中的每个排列
void printPermutations(const vector<vector<int>>& permutations) {
for (const auto& perm : permutations) {
for (int num : perm) {
cout << num << " ";
}
cout << endl;
}
}
int main() {
vector<int> nums = {1, 2, 3}; // 输入的数组
vector<vector<int>> permutations = permute(nums); // 生成全排列
cout << "All permutations:" << endl;
printPermutations(permutations); // 打印所有排列
return 0;
}
子集
利用回溯算法从空集开始,通过逐步选择或不选择每个元素来生成所有可能的子集
// 回溯函数:生成所有子集
void backtrack(vector<int>& nums, int start, vector<int>& current, vector<vector<int>>& result) {
result.push_back(current); // 当前子集是有效解,加入结果集
for (int i = start; i < nums.size(); ++i) {
current.push_back(nums[i]); // 选择当前元素
backtrack(nums, i + 1, current, result); // 递归处理剩余元素
current.pop_back(); // 回溯,撤销选择
}
}
// 主函数:调用回溯函数生成子集
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> result;
vector<int> current;
backtrack(nums, 0, current, result);
return result;
}
N皇后
回溯算法逐行放置皇后,并使用递归检查每一行、列和对角线的合法性,在遇到不合法的情况下回溯,直到找到所有可能的摆放方案
// 判断当前放置的皇后是否合法
bool isSafe(const vector<string>& board, int row, int col, int n) {
// 检查列是否有冲突
for (int i = 0; i < row; ++i) {
if (board[i][col] == 'Q') return false;
}
// 检查左上对角线是否有冲突
for (int i = row - 1, j = col - 1; i >= 0 && j >= 0; --i, --j) {
if (board[i][j] == 'Q') return false;
}
// 检查右上对角线是否有冲突
for (int i = row - 1, j = col + 1; i >= 0 && j < n; --i, ++j) {
if (board[i][j] == 'Q') return false;
}
return true;
}
// 回溯函数:解决 N 皇后问题
void solveNQueensHelper(vector<string>& board, int row, int n, vector<vector<string>>& result) {
if (row == n) {
result.push_back(board); // 找到一个有效的解决方案
return;
}
for (int col = 0; col < n; ++col) {
if (isSafe(board, row, col, n)) {
board[row][col] = 'Q'; // 在当前位置放置皇后
solveNQueensHelper(board, row + 1, n, result); // 递归处理下一行
board[row][col] = '.'; // 回溯,撤销当前的皇后
}
}
}
// 主函数:调用回溯函数解决 N 皇后问题
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> result;
vector<string> board(n, string(n, '.')); // 初始化一个 n x n 的空棋盘
solveNQueensHelper(board, 0, n, result);
return result;
}
分割回文数
我们可以使用回溯算法来尝试在每一个位置将字符串分割,并在递归中检查子串是否是回文。如果是,则继续在剩余的部分继续分割,直到字符串被完全分割
// 辅助函数:判断字符串从 left 到 right 是否是回文
bool isPalindrome(const string& s, int left, int right) {
while (left < right) {
if (s[left] != s[right]) {
return false;
}
++left;
--right;
}
return true;
}
// 回溯函数:寻找所有回文分割
void backtrack(const string& s, int start, vector<string>& current, vector<vector<string>>& result) {
if (start == s.length()) {
result.push_back(current); // 如果遍历到字符串末尾,记录当前方案
return;
}
for (int end = start; end < s.length(); ++end) {
if (isPalindrome(s, start, end)) {
current.push_back(s.substr(start, end - start + 1)); // 选择当前回文子串
backtrack(s, end + 1, current, result); // 递归处理剩余字符串
current.pop_back(); // 回溯,撤销选择
}
}
}
// 主函数:调用回溯函数并返回所有回文分割方案
vector<vector<string>> partition(string s) {
vector<vector<string>> result;
vector<string> current;
backtrack(s, 0, current, result);
return result;
}