背景
記個筆記,這幾天跟著這個教程到第五章了,順帶把遞迴和排序也看了(沙比學校天天整些屁事都沒什麼空折騰)。
String
字串就直接用 GPT 生成了,這裡就當文件記。(感覺沒啥好說的)
- 字串的輸入和輸出
輸入字串:使用 cin 輸入字串,注意會自動去除末尾的換行符。
std::string str;
std::cin >> str; // 只讀取到第一個空白字元
輸出字串:使用 cout 輸出字串。
std::cout << str << std::endl;
- 字串的長度
size()
或 length()
返回字串的長度(字元個數),這兩個方法是等效的。
std::string str = "Hello";
std::cout << str.size() << std::endl; // 輸出 5
- 字串的遍歷
使用 for 迴圈或範圍 for 遍歷字串中的每個字元:
for (char ch : str) {
std::cout << ch << " "; // 輸出每個字元
}
或者使用 for 迴圈結合索引:
for (int i = 0; i < str.size(); ++i) {
std::cout << str[i] << " "; // 輸出每個字元
}
- 字串拼接(連線)
使用 +
運算子或 append()
方法:
std::string str1 = "Hello";
std::string str2 = "World";
std::string combined = str1 + " " + str2; // 拼接字串
std::cout << combined << std::endl; // 輸出 "Hello World"
append()
方法:
str1.append(" World"); // 向 str1 拼接 " World"
- 字串查詢
find()
查詢子字串在字串中的位置,返回索引,找不到返回 std::string::npos
。
std::string str = "Hello, World!";
size_t pos = str.find("World");
if (pos != std::string::npos) {
std::cout << "'World' found at position: " << pos << std::endl;
} else {
std::cout << "'World' not found!" << std::endl;
}
- 字串替換
replace()
替換子字串:
std::string str = "Hello, World!";
str.replace(7, 5, "C++");
std::cout << str << std::endl; // 輸出 "Hello, C++!"
- 字串比較
使用 ==、!=、<、>、<=、>= 運算子進行比較:
std::string str1 = "Hello";
std::string str2 = "Hello";
std::cout << (str1 == str2) << std::endl; // 輸出 1,表示相等
- 字串擷取
substr()
擷取子字串:
std::string str = "Hello, World!";
std::string sub = str.substr(7, 5); // 從索引 7 開始,擷取 5 個字元
std::cout << sub << std::endl; // 輸出 "World"
最後是文件:
方法/操作 | 描述 | 示例程式碼 |
---|---|---|
size() / length() |
獲取字串的長度 | std::string str = "Hello"; std::cout << str.size(); |
empty() |
判斷字串是否為空 | std::string str = ""; std::cout << str.empty(); |
append() |
向字串後追加內容 | str.append(" World"); |
operator+ |
拼接兩個字串 | std::string str1 = "Hello", str2 = "World"; std::string result = str1 + " " + str2; |
find() |
查詢子字串,返回首次出現的位置 | std::string str = "Hello World"; size_t pos = str.find("World"); |
substr() |
獲取子字串 | std::string str = "Hello World"; std::string sub = str.substr(0, 5); |
replace() |
替換指定位置的字元或子字串 | str.replace(0, 5, "Hi"); |
erase() |
刪除字串中的部分字元或子字串 | str.erase(0, 5); |
at() |
透過位置訪問字元(越界時會丟擲異常) | std::string str = "Hello"; char c = str.at(1); |
operator[] |
透過索引訪問字元(不安全,越界時不會丟擲異常) | char c = str[1]; |
c_str() |
返回 C 風格的字串指標(常用於與 C 函式互動) | const char* c_str = str.c_str(); |
compare() |
比較兩個字串 | std::string str1 = "abc", str2 = "abc"; str1.compare(str2); |
resize() |
改變字串的大小(增加或減少) | str.resize(10, 'x'); |
insert() |
在指定位置插入子字串 | str.insert(5, "Beautiful "); |
find_first_of() |
查詢第一個出現的字元(從左到右) | str.find_first_of("aeiou"); |
find_last_of() |
查詢最後一個出現的字元(從右到左) | str.find_last_of("aeiou"); |
to_string() |
將數字轉換為字串(C++11 起) | int x = 10; std::string str = std::to_string(x); |
stoi() |
將字串轉換為整數 | std::string str = "123"; int x = std::stoi(str); |
stoll() |
將字串轉換為長整型 | std::string str = "123456789"; long long x = std::stoll(str); |
stof() |
將字串轉換為浮點數 | std::string str = "3.14"; float x = std::stof(str); |
getline() |
從輸入流中讀取一行(通常與 cin 一起使用) | std::string line; std::getline(std::cin, line); |
to_upper() / to_lower() |
轉換為大寫或小寫字母(需自行實現或使用庫) | std::transform(str.begin(), str.end(), str.begin(), ::toupper); |
遞迴
流程
遞迴還是比較常見的,JS 裡面也寫過,但是用的不多(主要是 JS 寫遞迴弄不好就爆棧了,能用但是得謹慎點),先簡單過一下寫法:
#include <iostream>
using namespace std;
int factorial(int number) {
if (number == 0) {
return 1;
} else {
return number * factorial(number - 1);
}
}
int main() {
int number;
cout << "Please enter a number: ";
cin >> number;
int result = factorial(number);
cout << "The factorial is " << result << endl;
}
這是一個計算階乘的程式碼,我們都知道階乘是 n! = n × (n-1) × (n-2) × ... × 3 × 2 × 1 計算的,在 n > 0 的情況下是可以應用 f(n) = n * f(n-1) 的,這就可以使用遞迴實現。那麼這段程式碼中的遞迴是如何計算的?以計算 3! 為例,呼叫 factorial(3)。
第一次呼叫 factorial(3)
:
-
n = 3,不滿足 n == 0,進入 else 部分。
遞迴呼叫factorial(2)
,並保留當前的 n 值。
函式等待factorial(2)
返回結果。
第二次呼叫factorial(2)
: -
n = 2,不滿足 n == 0,進入 else 部分。
遞迴呼叫factorial(1)
,並保留當前的 n 值。
函式等待factorial(1)
返回結果。
第三次呼叫factorial(1)
: -
n = 1,不滿足 n == 0,進入 else 部分。
遞迴呼叫factorial(0)
,並保留當前的 n 值。
函式等待factorial(0)
返回結果。
第四次呼叫factorial(0)
: -
n = 0,滿足基準條件,返回 1。
遞迴的最深層結束,返回值為 1。
開始回溯並計算每層的結果:
-
回到
factorial(1)
:
factorial(1)
得到factorial(0)
的返回值1
。
執行n * result = 1 * 1 = 1
,返回1
。
factorial(1)
的計算結果是1
,並將該結果返回給factorial(2)
。 -
回到
factorial(2)
:
factorial(2)
得到factorial(1)
的返回值1
。
執行n * result = 2 * 1 = 2
,返回2
。
factorial(2)
的計算結果是2
,並將該結果返回給factorial(3)
。 -
回到
factorial(3)
:
factorial(3)
得到factorial(2)
的返回值2
。
執行n * result = 3 * 2 = 6
,返回6
。
factorial(3)
的計算結果是6
。
再打個草稿,用腦子推一遍過程就是:
// 通式:f(n) = n * f(n - 1)
f(3) = 3 * f(3 - 1) => 3 * f(2 * f(2 - 1)) => 3 * f(2 * f(1))
f(3) = 3 * f(2 * f(1))
f(3) = 3 * f(2 * 1)
f(3) = 3 * 2 * 1 = 6
那麼在執行的過程編譯器幹了什麼呢?其實每一次遞迴呼叫都會建立一個新的棧幀,在棧幀中儲存當前函式的區域性變數和狀態。每次遞迴呼叫的結果都會返回到上一層,直到最終返回到最初的函式呼叫,即遞迴呼叫的棧幀。遞迴棧的展開過程如下:
factorial(3)
呼叫factorial(2)
。factorial(2)
呼叫factorial(1)
。factorial(1)
呼叫factorial(0)
。factorial(0)
返回1
。factorial(1)
返回1
。factorial(2)
返回2
。factorial(3)
返回6
。
尾遞迴
但是這麼做會不會很麻煩?呼叫完函式還要等返回,能不能直接把運算寫在函式引數裡面,免去最後的乘法那一步?有。請看這段寫法:
int factorial_tail(int number, int acc = 1) {
if (number == 0) return acc; // 基本情況,直接返回累積結果
else return factorial_tail(number - 1, number * acc); // 尾遞迴呼叫
}
這段程式碼直接把運算後的結果丟在了 acc 裡面,實際的執行流程是(還是以 3! 為例):
factorial_tail(3)
呼叫factorial_tail(2, 3)
。factorial_tail(2, 3)
呼叫factorial_tail(1, 6)
。factorial_tail(1, 6)
呼叫factorial_tail(0, 6)
。factorial_tail(0, 6)
返回6
。
欸?!這樣做就不用再一直往上返回結果了。原來我們的寫法 return number * factorial(number - 1)
在呼叫完函式還需要另外和 number
做運算,需要等待遞迴後的函式逐步返回結果。但如果我們直接把運算寫在引數內,讓他們在函式執行過程中完成這一步,就能省去這種操作。這種方式也被叫做尾遞迴。也就是遞迴呼叫在函式的最後一步,並且遞迴呼叫的結果直接返回,不需要再做任何額外的計算。
也就是說,只要遞迴呼叫是函式的最後一步、遞迴呼叫的結果直接返回給呼叫者、沒有額外的操作(如加法、乘法等),遞迴呼叫的返回值就是最終的結果,那就是尾遞迴。相比常規的遞迴,遞迴呼叫後不再有其他操作,返回值就是遞迴呼叫的結果,而常規遞迴在遞迴呼叫後還需要執行其他操作(如加法、乘法等)。同時尾遞迴可以被編譯器最佳化為迭代形式,從而避免了每次遞迴呼叫都在棧上分配新的棧幀,減少了記憶體的消耗和棧溢位的風險,也就是節省棧空間。另一點是避免棧溢位,如果遞迴呼叫的深度非常大,使用尾遞迴可以防止棧溢位(因為尾遞迴在某些編譯器中可以被最佳化為迭代)。
(提一嘴,因為說到棧幀突然想到 i++
和 ++i
的效能差異了,其實 ++i
會更好一點。使用 i++
時,需要先儲存 i 的原始值,這會生成一個 臨時副本,然後再執行自增操作。在某些情況下,這會導致不必要的記憶體分配,增加一些額外的操作(如複製構造)而,++i
則直接修改 i 的值,返回的是 i 的引用,沒有產生任何臨時副本。所以,++i
通常比 i++
稍微快一點)
排序
時間複雜度與空間複雜度
時間複雜度和空間複雜度在演算法領域是經常見到的一個詞,特別是 Leetcode、洛谷、ACM 這種平臺就更多了。其實這兩者就是一個描述演算法執行時間和佔用空間大小的概念,拿來衡量演算法的一個標準。在進入排序(或者說學習所有演算法)之前要知道一下這個,嘻嘻。
兩者通常使用大 O 符號表示,也就是 O(n)
。先說時間複雜度。大 O 裡面的 n 就是指,一個演算法執行所需的時間隨著輸入規模(通常表示為 n)變化的增長率,隨著輸入資料量增加,演算法的執行時間如何變化。
如果是 O(1),也就是常數時間複雜度。無論輸入大小如何,演算法執行的時間始終是固定的。比如訪問陣列中的某個元素;如果是 O(log n),對數時間複雜度,那麼演算法的執行時間隨著輸入規模的增長而按對數比例增長。典型的例子是二分查詢;如果是O(n),線性時間複雜度,那麼演算法的執行時間與輸入規模成正比。比如線性搜尋。
如果用座標系表示出來,表示輸入規模 n(也可以看作是問題規模的增長),隨著 n 增加,影像上的點會沿著 x 軸從左到右移動;y 軸是演算法的複雜度,這裡的複雜度是以大 O 符號的階數來描述的,比如 O(1)、O(n)、O(n²) 等。y 軸的數值反映了隨著 n 增加,演算法所需的時間或空間量。
當然,除了常數、對數和線性這三種複雜度以外,還有很多,看這個表格。
時間複雜度 | 描述 | 示例演算法 | 效率 |
---|---|---|---|
O(1) | 常數時間複雜度 | 訪問陣列元素 | 高效 |
O(log n) | 對數時間複雜度 | 二分查詢 | 高效 |
O(n) | 線性時間複雜度 | 線性搜尋 | 中等 |
O(n log n) | 線性對數時間複雜度 | 快速排序、歸併排序 | 高效 |
O(n²) | 平方時間複雜度 | 氣泡排序、選擇排序 | 低效 |
O(2^n) | 指數時間複雜度 | 遞迴演算法(如斐波那契) | 低效 |
O(n!) | 階乘時間複雜度 | 排列、組合問題 | 極低 |
另外就是空間複雜度,一個演算法在執行過程中所需的記憶體空間,隨著輸入規模的增加而增加的程度。也就是演算法執行時,除了輸入資料外,額外需要的儲存空間。
空間複雜度 | 描述 | 示例演算法 | 效率 |
---|---|---|---|
O(1) | 常數空間複雜度 | 原地排序 | 高效 |
O(n) | 線性空間複雜度 | 需要儲存陣列或連結串列 | 中等 |
O(n²) | 平方空間複雜度 | 儲存二維矩陣、圖 | 低效 |
一般情況下,時間複雜度是大多數情況下最被關注的,因為這直接表示了程式的執行效率,尤其是在資料規模很大時,最佳化時間複雜度是首要任務。而空間複雜度在記憶體有限或需要處理超大規模資料集時尤為重要,尤其是在嵌入式系統、大資料處理、影像處理和深度學習中,空間複雜度的最佳化常常與時間複雜度的最佳化同等重要。
氣泡排序
說完了複雜度這個基本的概念,就是排序演算法了。氣泡排序是最經典的一種排序演算法,甚至一些中學考試都會考這個。其基本原理就是一直比較相鄰兩個值的大小,如果左邊的大於右邊的,那就把左邊的移到右邊去,如此重複,最大的數像冒泡一樣“冒”到最後,就是氣泡排序。
#include <iostream>
#include <vector>
using namespace std;
void bubbleSort(vector<int>& array) {
int size = array.size();
for (int i = 0; i < size; ++i) {
for (int j = 0; j < size - i; ++j) {
if (array[j] > array[j + 1]) {
swap(array[j], array[j + 1]);
}
}
}
}
int main() {
vector<int> array = {114, 514, 19, 1, 9, 81, 0};
bubbleSort(array);
for (int num: array)
cout << num << endl;
return 0;
}
最內層的迴圈負責交換資料,最外層的迴圈確保每個迴圈都能被遍歷到。為啥要兩層迴圈?舉個例子,原資料是 [114, 514, 19, 1, 9, 81, 0]
,第一次內層迴圈是 [114, 19, 1, 9, 81, 0, 514]
,內層的迴圈只能確保相鄰的兩個資料交換,如果最開始連續多個數都是大值,就會出現例子那樣的情況,數字 514 確實被冒到最後了,但是 114 還在最前面。這時候需要外層迴圈繼續處理資料。
而外層迴圈的迴圈數根據陣列長度 n = arr.size() - 1
,內層迴圈的迴圈數是 n = arr.size() -1 - i
。內層迴圈可以省掉外層迴圈的層數(也就是 ... - i
)是因為氣泡排序每次內層迴圈處理完,最大數已經在最右邊了,此時也就沒有必要額外再進行比較。
回到這段程式碼中,bubbleSort 函式的引數處用的是 vector<int>& array
而非 vector<int> array
。在 C++ 中,&
和 *
分別意味著取地址(在變數前使用這個符號表示獲取該變數的記憶體地址)和解引用,但這是在變數中。在函式中,這兩個符號的意味有一些不一樣。&
表引用,在函式引數中,&
用於建立引用型別。引用本質上是給一個變數或物件起了一個別名,讓你可以透過別名訪問原物件。就如同程式碼中的 bubbleSort 函式是 void 型別的,而 main 函式呼叫 bubbleSort() 是沒有定義變數接收返回值的。
氣泡排序的時間複雜度是 O(n²),最佳化後的氣泡排序的時間複雜度最好情況下是 O(n),最壞依舊是 O(n²)。最佳化後的氣泡排序也就是自動跳過已排序的層級(程式碼放下面)。空間複雜度是 O(1),因為氣泡排序屬於那種原地排序的,沒有額外使用資料結構。
#include <iostream>
#include <vector>
using namespace std;
void bubbleSortOptimized(vector<int>& array) {
int size = array.size();
for (int i = 0; i < size; ++i) {
bool isSwapped = false;
for (int j = 0; j < size - i; ++j) {
if (array[j] > array[j + 1]) {
swap(array[j], array[j + 1]);
isSwapped = true;
}
}
if (!isSwapped) {
break;
}
}
}
int main() {
vector<int> array = {114, 514, 19, 1, 9, 81, 0};
bubbleSortOptimized(array);
for (int num: array)
cout << num << endl;
return 0;
}
如果陣列已經是有序的,那麼內層迴圈不會進行交換,isSwapped 會保持為 false,break 會提前終止排序,時間複雜度變成 O(n)。但如果陣列是逆序的,最壞情況和普通氣泡排序一樣,複雜度依舊是 O(n²),因為需要進行 O(n²) 次比較和交換。
選擇排序
接下來是選擇排序。選擇排序就是從每次從為排列的資料中的最小值放到前面,以此類推迴圈下去。舉個例子,假設陣列 [5, 3, 7, 2, 6]
,按照選擇排序的順序來就是:
- 第一次外層迴圈,i = 0:
內層迴圈遍歷[3, 7, 2, 6]
,比較這些值,最終找到最小值 2,並交換array[0]
和array[3]
,得到[2, 3, 7, 5, 6]
。 - 第二次外層迴圈,i = 1:
內層迴圈只比較[7, 5, 6]
,找到最小值 5,並交換array[1]
和array[3]
,得到[2, 3, 5, 7, 6]
。 - 第三次外層迴圈,i = 2:
內層迴圈只比較[7, 6]
,找到最小值 6,並交換array[2]
和array[4]
,得到[2, 3, 5, 6, 7]
。 - 第四次外層迴圈,i = 3:
內層迴圈只剩下一個元素 7,無需再交換,排序結束。
將以上邏輯轉換為程式碼實現,大致結構就是兩層迴圈,外層迴圈確定當前位置,內層迴圈比較待排序空間的最小值,然後透過一個變數儲存最小數的索引,以此類推。
#include <iostream>
#include <vector>
using namespace std;
void selectionSort(vector<int>& array) {
int size = array.size();
for (int i = 0; i < size; ++i) {
int minIndex = i;
for (int j = i + 1; j < size; ++j) {
if (array[j] < array[minIndex]) {
minIndex = j;
}
}
swap(array[i], array[minIndex]);
}
}
int main() {
vector<int> array = {114, 514, 19, 1, 9, 81, 0};
selectionSort(array);
for (int num: array)
cout << num << endl;
return 0;
}
以上是大致實現(還可以進一步最佳化),最外層迴圈遍歷陣列的所有數,內部迴圈額外定義了索引 j
,在這個區域內檢索最小值,最後把最小值索引放到 minIndex
中,最後交換資料。選擇排序的複雜度是 O(n²),空間複雜度是 O(1)。
插入排序
最後是插入排序。就像打撲克一樣,從陣列的第二個值開始一直與前面的元素進行比較,比前面的小就找合適的位置插進去。
#include <iostream>
#include <vector>
using namespace std;
void insertSort(vector<int>& array) {
int size = array.size();
for (int i = 1; i < size; ++i) {
int key = array[i];
int j = i - 1;
for (; j >= 0 && array[j] > key; --j) {
array[j + 1] = array[j];
}
array[j + 1] = key;
}
}
int main() {
vector<int> array = {114, 514, 19, 1, 9, 81, 0};
insertSort(array);
for (int num: array)
cout << num << endl;
return 0;
}
首先,外層迴圈遍歷所有陣列元素,索引從 1 開始(因為插入排序是和前面的對比,當然如果要從 0 開始也行,反正後面都會排回來嘻嘻)。int key = array[i]
獲取當前元素,隨後定義一個內層迴圈 for (int j = 0; j >= 0 && array[j] > key; --j)
用於檢測是否符合條件,如果符合條件,那就讓開位置,把元素都放到後面去。 j >= 0
確保從陣列的索引 0 開始查詢;array[j] > key
確保當前元素比前面某個元素要小(否則沒必要往前移)。最後透過 array[j + 1] = key
存回數值,但 j
是內層迴圈變數,此時提升作用域到內層迴圈外部以解決問題。
舉個例子,假設待排序陣列為 [5, 2, 9, 1, 5, 6]
。
- 第一輪:從第二個元素 2 開始,將其與第一個元素 5 比較,發現 2 小於 5,於是將 5 向右移動,2 插入到最前面。陣列變為
[2, 5, 9, 1, 5, 6]
。 - 第二輪:將第三個元素 9 與前面已經排序的 5 比較,發現 9 大於 5,無需移動,陣列保持不變 `[2, 5, 9, 1, 5, 6]。
- 第三輪:將第四個元素 1 與前面的元素依次比較,發現 1 比 9、5 和 2 都小,將它們依次向後移動,最後將 1 插入到最前面,陣列變為
[1, 2, 5, 9, 5, 6]
。 - 第四輪:將第五個元素 5 與前面的元素比較,發現它比 9 小,因此將 9 向後移,再與 5 比較,發現它們相等,不需要交換。陣列變為
[1, 2, 5, 5, 9, 6]
。 - 第五輪:將第六個元素 6 與前面的元素比較,發現它比 9 小,因此將 9 向後移,再與 5 比較,發現 6 比 5 大,於是插入到 5 後面,陣列變為
[1, 2, 5, 5, 6, 9]
。
插入排序的時間複雜度在最優情況下(已經有序的陣列)的時間複雜度是 O(n),因為每次插入操作都不需要移動任何元素,最壞情況(反向排序的陣列)下時間複雜度為 O(n²),因為每個數都需要移動。空間複雜度依舊是 O(1),文裡提到的三個排序演算法都是原地排序。
快速排序打算放到下一個筆記裡面講(和分治/二分一起嘻嘻)。