← 題庫 / Archive
2026-06-17 TI150 Medium StringStack

71. Simplify Path

題目 / Problem

給你一個 Unix 風格檔案系統的絕對路徑(一定以斜線 '/' 開頭),請把它轉換成簡化後的標準路徑(canonical path)。

規則: - 單個句點 '.' 代表「目前目錄」,要忽略。 - 雙句點 '..' 代表「上一層(父)目錄」,要回到上一層。 - 多個連續斜線('//''///')視為一個斜線。 - 任何不符合上述規則的句點序列,都當作合法的目錄/檔名。例如 '...''....' 都是合法名字。

輸出的標準路徑必須:以單一 '/' 開頭、目錄之間用恰好一個 '/' 分隔、結尾不能有 '/'(除非是根目錄)、不能再含有 '.''..'

You are given an absolute Unix-style path that always begins with '/'. Transform it into its simplified canonical path. A single '.' means the current directory (skip it); a double '..' means the parent directory (go up one level); multiple consecutive slashes collapse into one; any other sequence of periods like '...' is a valid name. The result must start with one '/', separate directories by exactly one '/', and not end with '/' unless it is the root.

Constraints / 限制: - 1 <= path.length <= 3000 - path 只含英文字母、數字、'.''/''_' - path 一定是合法的絕對路徑

Worked example / 範例: - Input: path = "/home/user/Documents/../Pictures" - Output: "/home/user/Pictures" - 解釋:.. 把我們從 Documents 退回到 user,再進入 Pictures

名詞解釋 / Glossary

  • stack / 堆疊:一種「後進先出」(LIFO) 的資料結構,只能在尾端加入(push)或取出(pop)。這題我們用一個陣列當堆疊,存放目前有效的目錄名;遇到 .. 就 pop 掉最後一個目錄,正好對應「回到上一層」。
  • token / 詞元(路徑片段):把路徑用 '/' 切開後得到的每一小段,例如 "home""..""."""(空字串,來自連續斜線)。我們逐一檢視每個片段並決定怎麼處理。
  • string tokenization / 字串切割:按某個分隔符(這裡是 '/')把長字串拆成多個小段的過程。
  • in-place / 原地操作:直接在輸入或固定緩衝區上修改,不額外配置大量記憶體。C 解法用一個字元緩衝區直接拼出答案。
  • pointer / 指標 (C):一個變數,裡面存的是「記憶體位址」。char *s 表示 s 指向某個字元的位置;*s 取出該位置的字元;s[i] 等同於「從 s 往後第 i 個字元」。
  • malloc / free:C 裡面手動向系統「借」一塊記憶體 (malloc) 與「還」回去 (free)。LeetCode 要你回傳的字串通常需要 malloc 出來,因為函式結束後區域變數會消失。
  • std::vector / 動態陣列 (C++):會自動增長的陣列,push_back 加到尾端、pop_back 移除尾端、back() 看尾端元素——正好拿來當堆疊。
  • std::stringstream / getline (C++):把字串當成輸入流,getline(ss, token, '/') 可以一次讀出兩個 '/' 之間的內容,是最方便的切割工具。

思路

最直覺的想法可能是用字串「找到 .. 就把前面的目錄刪掉」,但直接在字串上來回刪除很容易出錯,而且每次刪除都要搬動後面的字元,效率差。關鍵觀察是:路徑其實是由 '/' 分隔的一串「片段」,我們只要從左到右處理每個片段,並維護一個「目前有效目錄的列表」即可。這個列表的行為恰好是堆疊——.. 要移除最近一個目錄(pop 尾端),而新目錄要加到尾端(push)。於是演算法變成:用 '/' 切割路徑,對每個片段分四種情況處理:空字串或 '.' 直接跳過(對應連續斜線與「目前目錄」);'..' 時若堆疊非空就 pop 一個(回到上層,根目錄時無事可做);其他任何字串(包含 '...''....' 這種合法名字)就 push 進堆疊。最後把堆疊裡的目錄用單一 '/' 連起來,前面加一個 '/';若堆疊為空就直接回傳 "/"。為什麼這樣對?因為堆疊在任何時刻都精確表示「從根目錄一路走下來目前所在的目錄序列」,這就是一個不變量(invariant),每處理一個片段都維持它成立,所以最後直接輸出就是答案。

The naive idea—scan the string and delete the previous directory whenever you hit ..—is fragile and slow, because deleting in place forces you to shift characters around. The key insight is that the path is just a list of segments separated by '/', so we process them left-to-right while maintaining a list of the currently-valid directories. That list behaves exactly like a stack: .. pops the most recent directory, and a real directory name gets pushed. So the algorithm is: split on '/', and for each segment handle four cases—an empty string or '.' is skipped (this absorbs consecutive slashes and the "current directory"); '..' pops one directory if the stack is non-empty (going up from root does nothing); anything else, including valid names like '...', is pushed. Finally, join the stack with single slashes and prepend a '/'; if the stack is empty, return "/". This is correct because the stack always represents the exact directory chain from the root to where we currently are—an invariant preserved at every step—so emitting it at the end gives the canonical path.

逐步走查 / Walkthrough

path = "/home/user/Documents/../Pictures" 為例,用 '/' 切割後得到片段序列:["", "home", "user", "Documents", "..", "Pictures"]。堆疊一開始是空的。

步驟 Step 片段 token 判斷 Decision 堆疊 Stack (處理後)
1 "" 空字串,跳過 / empty, skip []
2 "home" 一般目錄,push / normal name, push ["home"]
3 "user" 一般目錄,push ["home", "user"]
4 "Documents" 一般目錄,push ["home", "user", "Documents"]
5 ".." 上一層,pop 尾端 / parent, pop ["home", "user"]
6 "Pictures" 一般目錄,push ["home", "user", "Pictures"]

結束後把堆疊用 '/' 連接並在最前面加 '/'"/" + "home/user/Pictures" = "/home/user/Pictures"。這就是答案。

Solution — C

// 演算法 / Algorithm:
// 用 '/' 切割路徑,逐段處理;空字串與 "." 跳過,".." 退回上一層,其餘 push。
// Split on '/'; skip "" and ".", pop on "..", push everything else.
// 我們用一個指標陣列 stack[] 記錄每段目錄在緩衝區中的位置與長度。
// We keep a stack of (start,length) for each kept directory, then join them.

#include <stdlib.h>
#include <string.h>

char* simplifyPath(char* path) {
    int n = (int)strlen(path);              // 路徑長度 / length of the input path

    // 緩衝區:複製一份 path,方便我們就地切出每個片段
    // Buffer: a copy of path so we can carve tokens out of it.
    char* buf = (char*)malloc((size_t)n + 1); // +1 給結尾的 '' / room for the '' terminator
    strcpy(buf, path);                        // 把 path 的內容複製進 buf / copy contents in

    // stack 存「每個保留目錄的起始指標」,最多不會超過 n 段
    // stack holds a start-pointer for each kept directory; at most n of them.
    char** stack = (char**)malloc(sizeof(char*) * (size_t)n);
    int top = 0;                              // 堆疊大小 / number of items on the stack

    int i = 0;                                // 目前掃描位置 / current scan index
    while (i < n) {
        // 跳過分隔用的斜線;連續多個斜線會在這裡一起被略過
        // Skip slash separators; consecutive slashes are all skipped here.
        if (buf[i] == '/') { i++; continue; }

        // 找到一個片段的開頭,記住起點 / found a token start, remember where it begins
        int start = i;
        while (i < n && buf[i] != '/') i++;   // 一直走到下一個 '/' 或字串結尾 / advance to next '/'
        buf[i] = '';                        // 把該 '/' 改成 '',讓這段成為獨立 C 字串
                                              // turn the '/' into '' so the token is its own string
        char* tok = &buf[start];              // tok 指向這個片段 / tok points at the token
        i++;                                  // 跳過剛剛寫入的 ''(原本是 '/') / step past it

        if (strcmp(tok, ".") == 0) {
            // "." 代表目前目錄,忽略 / "." means current dir, ignore
            continue;
        } else if (strcmp(tok, "..") == 0) {
            // ".." 代表上一層;堆疊非空才 pop(根目錄無法再上去)
            // ".." means parent; pop only if non-empty (cannot go above root)
            if (top > 0) top--;
        } else {
            // 其他都是合法目錄名(含 "..."),push 進堆疊
            // anything else is a valid directory name (incl. "..."), push it
            stack[top++] = tok;
        }
    }

    // 組合答案:最長情況約等於原長度,配置 n+2 一定夠
    // Build the answer; n+2 bytes is always enough (leading '/' + '').
    char* res = (char*)malloc((size_t)n + 2);
    int len = 0;                              // 已寫入的字元數 / chars written so far

    if (top == 0) {
        // 堆疊為空 => 根目錄 / empty stack => root directory
        res[len++] = '/';
    } else {
        // 每個目錄前面加一個 '/' 再接目錄名 / prefix each dir with one '/'
        for (int k = 0; k < top; k++) {
            res[len++] = '/';                 // 分隔斜線 / the single separating slash
            int L = (int)strlen(stack[k]);    // 這段目錄名長度 / length of this dir name
            memcpy(&res[len], stack[k], (size_t)L); // 複製目錄名到結果 / copy the name in
            len += L;                         // 移動寫入位置 / advance write position
        }
    }
    res[len] = '';                          // 收尾,C 字串必須以 '' 結束 / null-terminate

    free(stack);                              // 釋放暫存堆疊 / free temporary stack
    free(buf);                                // 釋放路徑副本 / free the path copy
    return res;                               // 回傳結果(呼叫端負責 free)/ caller owns res
}

Solution — C++

// 演算法 / Algorithm:
// 用 stringstream + getline 以 '/' 切片,逐段處理:
// "" 與 "." 跳過,".." 對 vector pop_back,其餘 push_back,最後接成 "/a/b/c"。
// Tokenize on '/', skip ""/".", pop on "..", push otherwise, then join.

#include <string>
#include <vector>
#include <sstream>

class Solution {
public:
    std::string simplifyPath(std::string path) {
        std::vector<std::string> stack;       // 當作堆疊用的動態陣列 / vector used as a stack
        std::stringstream ss(path);           // 把 path 包成可讀的串流 / wrap path as a stream
        std::string token;                    // 每次取出的片段 / one segment per read

        // getline 以 '/' 為分隔符,一次讀出兩個 '/' 之間的內容
        // getline with '/' delimiter yields the text between two slashes.
        // 連續斜線會產生空字串 token,下面會被跳過。
        // Consecutive slashes yield empty tokens, skipped below.
        while (std::getline(ss, token, '/')) {
            if (token.empty() || token == ".") {
                // 空片段(連續斜線)或 "."(目前目錄)都忽略
                // empty token (multiple slashes) or "." (current dir): ignore
                continue;
            } else if (token == "..") {
                // 回到上一層:非空才 pop / go up: pop only if non-empty
                if (!stack.empty()) stack.pop_back();
            } else {
                // 合法目錄名,push 到尾端 / valid name, push to the back
                stack.push_back(token);
            }
        }

        // 組合結果:根目錄時為 "/",否則 "/" + 每段用 '/' 連接
        // Build result: "/" for root, else each dir prefixed by '/'.
        std::string result;
        if (stack.empty()) return "/";        // 堆疊空 => 根目錄 / empty => root

        for (const std::string& dir : stack) { // range-for 逐一走訪 / range-for over each dir
            result += '/';                    // 先放一個分隔斜線 / one separating slash
            result += dir;                    // 再接目錄名 / then the directory name
        }
        return result;                        // 例如 "/home/user/Pictures"
    }
};

複雜度 / Complexity

  • Time: O(n)n 是路徑長度。我們把每個字元在切割時讀一次、在組合答案時最多再寫一次,沒有巢狀重複掃描。pop/push 都是 O(1)。/ Each character is read once during tokenization and written at most once when building the output; push and pop are O(1), so the total is linear in the path length.
  • Space: O(n) — 堆疊與緩衝區(C 的 buf、C++ 的 vector 與 token)所佔空間最多正比於路徑長度,因為所有目錄名加起來不會超過原字串。/ The stack plus the working buffer hold at most the characters of the original path, so extra space is linear.

Pitfalls & Edge Cases

  • '...''....' 是合法名字 / '...' and '....' are valid names:只有「剛好一個點」. 和「剛好兩個點」.. 有特殊意義;用 strcmp/== 精確比對整個 token,不要只看開頭是不是 .,否則會誤判 ...
  • 連續斜線與結尾斜線 / consecutive and trailing slashes"/home//foo/" 會切出空字串 token。程式碼把空 token 直接跳過,自然吸收掉 // 與結尾的 /,所以結果不會出現多餘斜線。
  • 從根目錄再 .. / .. above root:例如 "/../",此時堆疊是空的,pop 前一定要檢查 top > 0(C)或 !stack.empty()(C++),否則會存取非法記憶體或當機。最終正確回傳 "/"
  • 空結果要回傳 "/" 而非空字串 / empty result must be "/":所有目錄都被 .. 抵銷時堆疊為空,必須特別處理回傳根目錄,不能輸出 ""
  • C 的記憶體管理 / memory in C:回傳的字串必須用 malloc 配置(不能回傳區域陣列,函式結束就失效);同時記得 free 掉暫存的 bufstack,避免記憶體洩漏。
  • 緩衝區大小 / buffer size:結果最長不超過「原長度 + 1 個前導斜線 + 結尾 '\0'」,配置 n + 2 一定夠,避免越界寫入。