C++ 學習筆記(2):String、遞迴、排序

AurLemon發表於2024-11-30

背景

記個筆記,這幾天跟著這個教程到第五章了,順帶把遞迴和排序也看了(沙比學校天天整些屁事都沒什麼空折騰)。

String

字串就直接用 GPT 生成了,這裡就當文件記。(感覺沒啥好說的)

  1. 字串的輸入和輸出

輸入字串:使用 cin 輸入字串,注意會自動去除末尾的換行符。

std::string str;
std::cin >> str;  // 只讀取到第一個空白字元

輸出字串:使用 cout 輸出字串。

std::cout << str << std::endl;
  1. 字串的長度

size()length() 返回字串的長度(字元個數),這兩個方法是等效的。

std::string str = "Hello";
std::cout << str.size() << std::endl;  // 輸出 5
  1. 字串的遍歷

使用 for 迴圈或範圍 for 遍歷字串中的每個字元:

for (char ch : str) {
    std::cout << ch << " ";  // 輸出每個字元
}

或者使用 for 迴圈結合索引:

for (int i = 0; i < str.size(); ++i) {
    std::cout << str[i] << " ";  // 輸出每個字元
}
  1. 字串拼接(連線)

使用 + 運算子或 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"
  1. 字串查詢

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;
}
  1. 字串替換

replace() 替換子字串:

std::string str = "Hello, World!";
str.replace(7, 5, "C++");
std::cout << str << std::endl;  // 輸出 "Hello, C++!"
  1. 字串比較

使用 ==、!=、<、>、<=、>= 運算子進行比較:

std::string str1 = "Hello";
std::string str2 = "Hello";
std::cout << (str1 == str2) << std::endl;  // 輸出 1,表示相等
  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),文裡提到的三個排序演算法都是原地排序。

快速排序打算放到下一個筆記裡面講(和分治/二分一起嘻嘻)。

相關文章